From f7a602cd204b6b6773b75de0b40ee4947366cd93 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:20:23 -0700 Subject: [PATCH 001/209] feat(tools): hide todo_* aliases from model catalog, add deprecation metadata (#2682) - Add model_visible() hook to ToolSpec trait (default true) - Override model_visible() -> false on todo_write, todo_add, todo_update, todo_list - Checklist variants remain model-visible as the canonical surface - Legacy todo_* calls still work for saved transcript replay - Return _deprecation metadata with use_instead and removed_in=0.9.0 - Update prompts to recommend checklist_* only - Update TOOL_SURFACE.md with v0.9.0 deprecation notes - Add tests for hidden catalog, compat alias behavior, and metadata Verification: cargo test -p codewhale-tui -- todo, cargo clippy -D warnings --- crates/tui/src/prompts/base.md | 2 +- crates/tui/src/prompts/base.txt | 2 +- crates/tui/src/tools/registry.rs | 38 +++++++++++ crates/tui/src/tools/spec.rs | 8 +++ crates/tui/src/tools/todo.rs | 109 +++++++++++++++++++++++++++++-- docs/TOOL_SURFACE.md | 7 +- 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index abbfc0c98..fd194628a 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -242,7 +242,7 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise ## Toolbox (fast reference — tool descriptions are authoritative) -- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `todo_*` aliases (legacy compatibility), `note` (persistent memory). +- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `note` (persistent memory). - **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs. - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. If foreground `exec_shell` times out, the process was killed; rerun long work with `task_shell_start` or `exec_shell` using `background: true`, then poll/wait. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; for GitHub issue/PR/release triage, prefer the native `gh ... --json` CLI through shell because it is authenticated, structured, and reproducible; `github_issue_context` / `github_pr_context` are read-only fallbacks when the CLI route is unavailable; `github_comment` / `github_close_issue` require approval + evidence; `automation_*` scheduling tools. diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index 7346f127f..291e98d00 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -37,7 +37,7 @@ Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking` ## Toolbox (fast reference — tool descriptions are authoritative) -- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `todo_*` aliases (legacy compatibility), `note` (persistent memory). +- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `note` (persistent memory). - **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs. - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; for GitHub issue/PR/release triage, prefer the native `gh ... --json` CLI through shell because it is authenticated, structured, and reproducible; `github_issue_context` / `github_pr_context` are read-only fallbacks when the CLI route is unavailable; `github_comment` / `github_close_issue` require approval + evidence; `automation_*` scheduling tools. diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index cb3ca0bfb..b1dd5bd2f 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -222,6 +222,7 @@ impl ToolRegistry { tools.sort_by(|a, b| a.name().cmp(b.name())); tools .into_iter() + .filter(|tool| tool.model_visible()) .map(|tool| { let mut schema = tool.input_schema(); schema_sanitize::sanitize(&mut schema); @@ -1202,6 +1203,43 @@ mod tests { assert_eq!(registry.len(), 1); } + #[test] + fn todo_aliases_stay_callable_but_hidden_from_model_catalog() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_todo_tool(crate::tools::todo::new_shared_todo_list()) + .build(ctx); + + for alias in ["todo_write", "todo_add", "todo_update", "todo_list"] { + assert!(registry.contains(alias), "{alias} should remain callable"); + } + + let api_names = registry + .to_api_tools() + .into_iter() + .map(|tool| tool.name) + .collect::>(); + + for canonical in [ + "checklist_write", + "checklist_add", + "checklist_update", + "checklist_list", + ] { + assert!( + api_names.iter().any(|name| name == canonical), + "{canonical} should stay model-visible" + ); + } + for alias in ["todo_write", "todo_add", "todo_update", "todo_list"] { + assert!( + api_names.iter().all(|name| name != alias), + "{alias} should be hidden from the model catalog" + ); + } + } + #[test] fn apply_overrides_removes_original_when_replacement_is_missing() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 6a66c37fa..52553cdfb 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -664,6 +664,14 @@ pub trait ToolSpec: Send + Sync { false } + /// Returns whether this tool should be advertised in the model-facing + /// catalog. Hidden compatibility tools remain registered and executable + /// by name so saved transcripts can replay without teaching new sessions + /// the deprecated spelling. + fn model_visible(&self) -> bool { + true + } + /// Execute the tool with the given input and context. async fn execute(&self, input: Value, context: &ToolContext) -> Result; } diff --git a/crates/tui/src/tools/todo.rs b/crates/tui/src/tools/todo.rs index c24185ca8..a955073c4 100644 --- a/crates/tui/src/tools/todo.rs +++ b/crates/tui/src/tools/todo.rs @@ -174,6 +174,22 @@ pub fn new_shared_todo_list() -> SharedTodoList { Arc::new(Mutex::new(TodoList::new())) } +const TODO_ALIAS_FIRST_DEPRECATED_VERSION: &str = "0.8.53"; +const TODO_ALIAS_REMOVAL_VERSION: &str = "0.9.0"; + +fn is_compat_alias(tool_name: &str) -> bool { + tool_name.starts_with("todo_") +} + +fn checklist_replacement_tool_name(tool_name: &str) -> &'static str { + match tool_name { + "todo_add" | "checklist_add" => "checklist_add", + "todo_update" | "checklist_update" => "checklist_update", + "todo_list" | "checklist_list" => "checklist_list", + _ => "checklist_write", + } +} + /// Tool for writing and updating the todo list pub struct TodoWriteTool { todo_list: SharedTodoList, @@ -258,6 +274,10 @@ impl ToolSpec for TodoAddTool { ApprovalRequirement::Auto } + fn model_visible(&self) -> bool { + !is_compat_alias(self.tool_name) + } + async fn execute( &self, input: serde_json::Value, @@ -350,6 +370,10 @@ impl ToolSpec for TodoUpdateTool { ApprovalRequirement::Auto } + fn model_visible(&self) -> bool { + !is_compat_alias(self.tool_name) + } + async fn execute( &self, input: serde_json::Value, @@ -435,6 +459,10 @@ impl ToolSpec for TodoListTool { ApprovalRequirement::Auto } + fn model_visible(&self) -> bool { + !is_compat_alias(self.tool_name) + } + async fn execute( &self, _input: serde_json::Value, @@ -448,7 +476,8 @@ impl ToolSpec for TodoListTool { snapshot.items.len(), snapshot.completion_pct, result - ))) + )) + .with_metadata(checklist_metadata(&snapshot, self.tool_name))) } } @@ -502,6 +531,10 @@ impl ToolSpec for TodoWriteTool { ApprovalRequirement::Auto } + fn model_visible(&self) -> bool { + !is_compat_alias(self.tool_name) + } + async fn execute( &self, input: serde_json::Value, @@ -547,6 +580,8 @@ impl ToolSpec for TodoWriteTool { } fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_json::Value { + let canonical_tool = checklist_replacement_tool_name(tool_name); + let compat_alias = is_compat_alias(tool_name); let items = snapshot .items .iter() @@ -558,9 +593,9 @@ fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_jso }) }) .collect::>(); - json!({ - "canonical_tool": "checklist_write", - "compat_alias": tool_name.starts_with("todo_"), + let mut metadata = json!({ + "canonical_tool": canonical_tool, + "compat_alias": compat_alias, "task_updates": { "checklist": { "items": items, @@ -569,7 +604,22 @@ fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_jso "updated_at": null } } - }) + }); + if compat_alias && let Some(obj) = metadata.as_object_mut() { + obj.insert( + "_deprecation".to_string(), + json!({ + "this_tool": tool_name, + "use_instead": canonical_tool, + "first_deprecated": TODO_ALIAS_FIRST_DEPRECATED_VERSION, + "removed_in": TODO_ALIAS_REMOVAL_VERSION, + "message": format!( + "Tool '{tool_name}' is a hidden compatibility alias; use '{canonical_tool}' before v{TODO_ALIAS_REMOVAL_VERSION}." + ), + }), + ); + } + metadata } #[cfg(test)] @@ -626,5 +676,54 @@ mod tests { assert_eq!(tool.name(), "todo_write"); assert_eq!(metadata["canonical_tool"], "checklist_write"); assert_eq!(metadata["compat_alias"], true); + assert_eq!(metadata["_deprecation"]["this_tool"], "todo_write"); + assert_eq!(metadata["_deprecation"]["use_instead"], "checklist_write"); + assert_eq!(metadata["_deprecation"]["removed_in"], "0.9.0"); + assert!(!tool.model_visible()); + } + + #[tokio::test] + async fn todo_item_aliases_return_replacement_metadata() { + let list = new_shared_todo_list(); + let context = ToolContext::new(std::env::temp_dir()); + + let add = TodoAddTool::new(list.clone()); + let add_result = add + .execute( + json!({"content": "legacy add", "status": "in_progress"}), + &context, + ) + .await + .expect("todo add succeeds"); + let add_metadata = add_result.metadata.expect("add metadata"); + assert_eq!(add_metadata["canonical_tool"], "checklist_add"); + assert_eq!(add_metadata["_deprecation"]["use_instead"], "checklist_add"); + assert!(!add.model_visible()); + + let update = TodoUpdateTool::new(list.clone()); + let update_result = update + .execute(json!({"id": 1, "status": "completed"}), &context) + .await + .expect("todo update succeeds"); + let update_metadata = update_result.metadata.expect("update metadata"); + assert_eq!(update_metadata["canonical_tool"], "checklist_update"); + assert_eq!( + update_metadata["_deprecation"]["use_instead"], + "checklist_update" + ); + assert!(!update.model_visible()); + + let list_tool = TodoListTool::new(list); + let list_result = list_tool + .execute(json!({}), &context) + .await + .expect("todo list succeeds"); + let list_metadata = list_result.metadata.expect("list metadata"); + assert_eq!(list_metadata["canonical_tool"], "checklist_list"); + assert_eq!( + list_metadata["_deprecation"]["use_instead"], + "checklist_list" + ); + assert!(!list_tool.model_visible()); } } diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 250f44830..fe0c38587 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -110,9 +110,14 @@ to the model, such as `mcp__`. | `task_cancel` | Cancel a queued or running durable task. Approval-required. | | `checklist_write` | Granular progress under the active thread/task. Checklist state is subordinate to the durable task. | | `checklist_add` / `checklist_update` / `checklist_list` | Single-item checklist operations. | -| `todo_write` / `todo_add` / `todo_update` / `todo_list` | Compatibility aliases for the checklist tools. Existing sessions keep working, but new prompts should use `checklist_*`. | | `note` | One-off important fact for later. | +The legacy `todo_write`, `todo_add`, `todo_update`, and `todo_list` names are +hidden compatibility aliases for saved transcript replay. They remain callable +by exact name, but they are not part of the model-visible catalog; compatibility +results include `_deprecation.use_instead = checklist_*` and +`_deprecation.removed_in = 0.9.0`. + ### Verification gates and artifacts | Tool | Niche | From 9e15805f6450c7b02303e0a30126cc45d2a5b94c Mon Sep 17 00:00:00 2001 From: xyuai <281015099+xyuai@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:58:01 +0800 Subject: [PATCH 002/209] fix(settings): tighten legacy path migration coverage --- crates/tui/src/settings.rs | 205 ++++++++++++++++++++++++------------- 1 file changed, 132 insertions(+), 73 deletions(-) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 22bc135ff..301ab753b 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -5,7 +5,7 @@ //! TUI-specific preferences (theme, keybinds, font_size) that survive project //! switches are stored separately in tui.toml. See [`TuiPrefs`]. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -364,43 +364,40 @@ impl Default for Settings { } impl Settings { - /// Get the settings file path + /// Get the canonical settings file path. + /// + /// New writes should target `~/.codewhale/settings.toml`. Legacy + /// DeepSeek-branded paths remain readable as fallbacks during load, but we + /// no longer surface them as the primary path in `/config`. pub fn path() -> Result { - // Allow tests to override the settings directory via the same env var - // used for config (DEEPSEEK_CONFIG_PATH points at config.toml; the - // settings file lives as a sibling in the same directory). - if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") { - let config_path = config_path.trim(); - if !config_path.is_empty() { - let p = expand_path(config_path); - if let Some(parent) = p.parent() { - return Ok(parent.join(SETTINGS_FILE_NAME)); - } - } - } - - let primary = codewhale_config::codewhale_home() - .ok() - .map(|home| home.join(SETTINGS_FILE_NAME)); - let legacy_home = codewhale_config::legacy_deepseek_home() - .ok() - .map(|home| home.join(SETTINGS_FILE_NAME)); - let legacy_config_dir = - dirs::config_dir().map(|dir| dir.join("deepseek").join(SETTINGS_FILE_NAME)); - - resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir) + let (primary, _legacy_home, legacy_config_dir) = settings_path_candidates(); + primary.or(legacy_config_dir).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve settings path: no config directory found.") + }) } /// Load settings from disk, or return defaults if not found pub fn load() -> Result { - let path = Self::path()?; - let mut settings = if !path.exists() { + let (primary, legacy_home, legacy_config_dir) = settings_path_candidates(); + let write_path = primary + .as_ref() + .cloned() + .or_else(|| legacy_config_dir.clone()) + .ok_or_else(|| { + anyhow::anyhow!("Failed to resolve settings path: no config directory found.") + })?; + let read_path = + resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir) + .unwrap_or_else(|_| write_path.clone()); + + let mut settings = if !read_path.exists() { Self::default() } else { - let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read settings from {}", path.display()))?; - let mut s: Settings = toml::from_str(&content) - .with_context(|| format!("Failed to parse settings from {}", path.display()))?; + let content = std::fs::read_to_string(&read_path) + .with_context(|| format!("Failed to read settings from {}", read_path.display()))?; + let mut s: Settings = toml::from_str(&content).with_context(|| { + format!("Failed to parse settings from {}", read_path.display()) + })?; s.default_mode = normalize_mode(&s.default_mode).to_string(); s.composer_density = normalize_composer_density(&s.composer_density).to_string(); s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string(); @@ -420,6 +417,7 @@ impl Settings { .and_then(|value| normalize_reasoning_effort_setting(value).ok().flatten()); s }; + migrate_settings_file_to_primary_if_needed(&write_path, &read_path); settings.apply_env_overrides(); Ok(settings) } @@ -427,7 +425,10 @@ impl Settings { /// Whether the user explicitly persisted an `auto_compact` preference. /// When absent, callers may choose a model-aware default. pub fn auto_compact_explicitly_configured() -> bool { - let Ok(path) = Self::path() else { + let (primary, legacy_home, legacy_config_dir) = settings_path_candidates(); + let Ok(path) = + resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir) + else { return false; }; let Ok(content) = std::fs::read_to_string(path) else { @@ -1014,6 +1015,58 @@ fn resolve_settings_path_from_candidates( }) } +fn settings_path_candidates() -> (Option, Option, Option) { + // Allow tests to override the settings directory via the same env var + // used for config (DEEPSEEK_CONFIG_PATH points at config.toml; the + // settings file lives as a sibling in the same directory). + if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let config_path = config_path.trim(); + if !config_path.is_empty() { + let p = expand_path(config_path); + if let Some(parent) = p.parent() { + return (Some(parent.join(SETTINGS_FILE_NAME)), None, None); + } + } + } + + let primary = codewhale_config::codewhale_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + let legacy_home = codewhale_config::legacy_deepseek_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + let legacy_config_dir = + dirs::config_dir().map(|dir| dir.join("deepseek").join(SETTINGS_FILE_NAME)); + + (primary, legacy_home, legacy_config_dir) +} + +fn migrate_settings_file_to_primary_if_needed(primary: &Path, active_read_path: &Path) { + if primary == active_read_path || primary.exists() || !active_read_path.exists() { + return; + } + + let Some(parent) = primary.parent() else { + return; + }; + + if let Err(err) = std::fs::create_dir_all(parent) { + tracing::warn!( + "failed to create settings migration directory {}: {err}", + parent.display() + ); + return; + } + + if let Err(err) = std::fs::copy(active_read_path, primary) { + tracing::warn!( + "failed to migrate settings from {} to {}: {err}", + active_read_path.display(), + primary.display() + ); + } +} + fn normalize_default_model(value: &str) -> Option { let trimmed = value.trim(); if trimmed.eq_ignore_ascii_case("auto") { @@ -2329,69 +2382,75 @@ mod tests { } #[test] - fn settings_path_reads_legacy_deepseek_home_when_present() { + fn settings_path_prefers_codewhale_home_even_when_legacy_exists() { let _g = config_path_test_guard(); let tmp = tempfile::tempdir().expect("tempdir"); - let primary = tmp.path().join(".codewhale").join("settings.toml"); let legacy_dir = tmp.path().join(".deepseek"); std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); - let legacy_home = legacy_dir.join("settings.toml"); - std::fs::write(&legacy_home, "low_motion = true\n").expect("legacy settings"); - let legacy_config_dir = tmp - .path() - .join("platform-config") - .join("deepseek") - .join("settings.toml"); - std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) - .expect("legacy config dir"); - std::fs::write(&legacy_config_dir, "low_motion = false\n") - .expect("platform legacy settings"); + std::fs::write(legacy_dir.join("settings.toml"), "low_motion = true\n") + .expect("legacy settings"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); - let got = resolve_settings_path_from_candidates( - Some(primary), - Some(legacy_home.clone()), - Some(legacy_config_dir), - ) - .expect("settings path"); + let got = Settings::path().expect("settings path"); - assert_eq!(got, legacy_home); + assert_eq!(got, tmp.path().join(".codewhale").join("settings.toml")); } #[test] - fn settings_path_keeps_platform_config_dir_as_last_legacy_fallback() { + fn settings_load_migrates_legacy_deepseek_home_into_codewhale_home() { let _g = config_path_test_guard(); let tmp = tempfile::tempdir().expect("tempdir"); let primary = tmp.path().join(".codewhale").join("settings.toml"); - let legacy_home = tmp.path().join(".deepseek").join("settings.toml"); - let legacy_config_dir = tmp - .path() - .join("platform-config") - .join("deepseek") - .join("settings.toml"); - std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) - .expect("legacy config dir"); - std::fs::write(&legacy_config_dir, "low_motion = true\n").expect("legacy settings"); + let legacy_dir = tmp.path().join(".deepseek"); + let legacy_home = legacy_dir.join("settings.toml"); + std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); + std::fs::write(&legacy_home, "low_motion = true\n").expect("legacy settings"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); - let got = resolve_settings_path_from_candidates( - Some(primary), - Some(legacy_home), - Some(legacy_config_dir.clone()), - ) - .expect("settings path"); + let loaded = Settings::load().expect("load settings"); - assert_eq!(got, legacy_config_dir); + assert!(loaded.low_motion, "legacy settings should still be read"); + assert!( + primary.exists(), + "settings load should migrate to primary path" + ); + let display = loaded.display(crate::localization::Locale::En); + assert!( + display.contains(&format!("Config file: {}", primary.display())), + "settings display should surface the canonical codewhale path:\n{display}" + ); } #[test] - fn settings_path_uses_primary_when_platform_config_dir_is_unavailable() { + fn settings_load_migrates_platform_legacy_fallback_into_codewhale_home() { let _g = config_path_test_guard(); let tmp = tempfile::tempdir().expect("tempdir"); let primary = tmp.path().join(".codewhale").join("settings.toml"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + let _xdg = EnvVarRestore::set("XDG_CONFIG_HOME", tmp.path().join("platform-config")); + #[cfg(windows)] + let _appdata = EnvVarRestore::set("APPDATA", tmp.path().join("platform-config")); + let legacy_config_dir = dirs::config_dir() + .expect("config dir") + .join("deepseek") + .join("settings.toml"); + std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) + .expect("legacy config dir"); + std::fs::write(&legacy_config_dir, "low_motion = true\n").expect("legacy settings"); - let got = resolve_settings_path_from_candidates(Some(primary.clone()), None, None) - .expect("settings path"); + let loaded = Settings::load().expect("load settings"); - assert_eq!(got, primary); + assert!(loaded.low_motion, "legacy settings should still be read"); + assert!( + primary.exists(), + "legacy fallback should be copied into primary" + ); } #[test] From 05950d1d59093e30b2fb1f0db557f9f6038534b2 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 08:49:45 -0700 Subject: [PATCH 003/209] fix release crate publish checks --- scripts/release/crates.sh | 14 +++++++------- scripts/release/publish-crates.sh | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/release/crates.sh b/scripts/release/crates.sh index 2fbbb8155..ff3828327 100755 --- a/scripts/release/crates.sh +++ b/scripts/release/crates.sh @@ -2,19 +2,19 @@ # Crates published for each codewhale release, in dependency order. release_crates=( - codewhale-secrets - codewhale-release - codewhale-config + codewhale-mcp codewhale-protocol + codewhale-release + codewhale-secrets codewhale-state - codewhale-agent + codewhale-tui-core codewhale-execpolicy codewhale-hooks - codewhale-mcp codewhale-tools + codewhale-config + codewhale-agent + codewhale-tui codewhale-core codewhale-app-server - codewhale-tui-core codewhale-cli - codewhale-tui ) diff --git a/scripts/release/publish-crates.sh b/scripts/release/publish-crates.sh index bad30760f..72b15db2a 100755 --- a/scripts/release/publish-crates.sh +++ b/scripts/release/publish-crates.sh @@ -15,6 +15,7 @@ case "${mode}" in esac packages=("${release_crates[@]}") +crates_user_agent="CodeWhale release publish check (https://github.com/Hmbown/CodeWhale)" workspace_version="" workspace_codewhale_packages=() @@ -122,7 +123,7 @@ package_has_workspace_deps() { crate_version_exists() { local crate_name="$1" local crate_version="$2" - curl -fsSL "https://crates.io/api/v1/crates/${crate_name}/${crate_version}" >/dev/null 2>&1 + curl -fsSL -A "${crates_user_agent}" "https://crates.io/api/v1/crates/${crate_name}/${crate_version}" >/dev/null 2>&1 } wait_for_crate_version() { From 3de07a99ed5aec8a2910256252e04ee04a575015 Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:33:30 +0800 Subject: [PATCH 004/209] perf(engine): memoize estimated_input_tokens via content-keyed cache The token estimator walks the full session.messages and the active system prompt. Five call sites per turn in the engine (capacity pre/post tool checkpoints, error escalation, the seam manager, the trim budget check) plus four TUI/command consumers (footer, /status, /debug, context inspector) all re-walked the same data independently. On a 200-message history with 5 KB of tool results that is roughly 2 ms per call, or ~20 ms of pure waste on a single turn. Introduce a process-local TokenEstimateCache keyed on (session.messages_revision, system_prompt_fingerprint). Repeated calls with the same inputs return the cached value without re-walking the message list. The cache invalidates as soon as either input changes: * session.messages_revision is a monotonic counter bumped in Session::add_message, Session::replace_messages, the new Session::bump_messages_revision helper, and at every direct session.messages mutation site in core/engine.rs and core/engine/capacity_flow.rs. * system_prompt_fingerprint is a stable 64-bit hash of the SystemPrompt::Text or SystemPrompt::Blocks payload. Also restructures layered_context_checkpoint to compute the estimated token count before taking a long-lived &SeamManager borrow, and re-routes the capacity pre/post tool checkpoints to compute the observation into a local before calling capacity_controller.observe_*. Both refactors are required to satisfy the borrow checker once estimated_input_tokens requires &mut self. Tests: 10 new unit tests cover the miss/hit path, revision bumps, system-prompt changes, audit-ring capacity, and downward-revision no-ops. The full 157-test engine suite still passes. --- crates/tui/src/core/engine.rs | 40 ++- crates/tui/src/core/engine/capacity_flow.rs | 17 +- crates/tui/src/core/engine/context.rs | 3 + .../src/core/engine/token_estimate_cache.rs | 312 ++++++++++++++++++ crates/tui/src/core/session.rs | 29 ++ 5 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 crates/tui/src/core/engine/token_estimate_cache.rs diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fa2146171..83cd6e931 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -505,6 +505,13 @@ pub struct Engine { slop_ledger_gate_cache: Option<(Option, Option)>, /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. current_mode: AppMode, + /// Process-local cache for `estimated_input_tokens`. Memoizes the most + /// recent token estimate keyed on `(session.messages_revision, + /// system_prompt_fingerprint)`. Five call sites per turn consult this + /// (engine capacity checkpoints, seam manager, trim budget, etc.) plus + /// four TUI / command consumers; the cache turns N×O(messages) walks + /// into a single recompute on a content change. + token_estimate_cache: TokenEstimateCache, } // === Internal tool helpers === @@ -754,6 +761,7 @@ impl Engine { workshop_vars, sandbox_backend, current_mode: AppMode::Agent, + token_estimate_cache: TokenEstimateCache::new(), }; engine.rehydrate_latest_canonical_state(); @@ -1282,6 +1290,7 @@ impl Engine { } if let Some(idx) = cut { self.session.messages.truncate(idx); + self.session.bump_messages_revision(); } // Now dispatch the new message as a normal send, // reusing the engine's stored mode/model config. @@ -2011,10 +2020,15 @@ In {new} mode: {policy}\n\n\ .await; } - fn estimated_input_tokens(&self) -> usize { - estimate_input_tokens_conservative( - &self.session.messages, + fn estimated_input_tokens(&mut self) -> usize { + // Memoized on (session.messages_revision, system-prompt fingerprint). + // The cache invalidates as soon as either input changes; until then + // repeated calls (capacity checkpoints, /status, context inspector, + // TUI footer) all hit the cached value. + self.token_estimate_cache.lookup_or_compute( + self.session.messages_revision, self.session.system_prompt.as_ref(), + &self.session.messages, ) } @@ -2024,6 +2038,7 @@ In {new} mode: {policy}\n\n\ && self.estimated_input_tokens() > target_input_budget { self.session.messages.remove(0); + self.session.bump_messages_revision(); removed = removed.saturating_add(1); } removed @@ -2247,15 +2262,20 @@ In {new} mode: {policy}\n\n\ /// assistant message. Called from `handle_deepseek_turn` before each API /// request so the model always has the latest navigation aids. async fn layered_context_checkpoint(&mut self) { - let Some(ref seam_mgr) = self.seam_manager else { + if self.seam_manager.is_none() { return; - }; - if !seam_mgr.config().enabled { + } + if !self.seam_manager.as_ref().unwrap().config().enabled { return; } + // Compute the estimated token count *before* taking a long-lived + // `&SeamManager` borrow — `estimated_input_tokens` mutates the + // engine's token-estimate cache, which would conflict. + let estimated_tokens = self.estimated_input_tokens(); + let seam_mgr = self.seam_manager.as_ref().unwrap(); let highest = seam_mgr.highest_level().await; - let Some(level) = seam_mgr.seam_level_for(self.estimated_input_tokens(), highest) else { + let Some(level) = seam_mgr.seam_level_for(estimated_tokens, highest) else { return; }; @@ -2636,17 +2656,19 @@ mod handle; pub(crate) use context::compact_tool_result_for_context; use context::{ COMPACTION_SUMMARY_MARKER, MAX_CONTEXT_RECOVERY_ATTEMPTS, MIN_RECENT_MESSAGES_TO_KEEP, - context_input_budget, effective_max_output_tokens, estimate_input_tokens_conservative, - extract_compaction_summary_prompt, is_context_length_error_message, summarize_text, + context_input_budget, effective_max_output_tokens, extract_compaction_summary_prompt, + is_context_length_error_message, summarize_text, }; mod dispatch; mod loop_guard; mod lsp_hooks; mod streaming; +mod token_estimate_cache; mod tool_catalog; mod tool_execution; mod tool_setup; mod turn_loop; +pub(crate) use token_estimate_cache::TokenEstimateCache; pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] { tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index 06e37f497..ecb2300ba 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -16,9 +16,8 @@ impl Engine { client: Option<&DeepSeekClient>, mode: AppMode, ) -> bool { - let snapshot = self - .capacity_controller - .observe_pre_turn(self.capacity_observation(turn)); + let observation = self.capacity_observation(turn); + let snapshot = self.capacity_controller.observe_pre_turn(observation); let decision = self .capacity_controller .decide(self.turn_counter, snapshot.as_ref()); @@ -44,9 +43,8 @@ impl Engine { _step_error_count: usize, _consecutive_tool_error_steps: u32, ) -> bool { - let snapshot = self - .capacity_controller - .observe_post_tool(self.capacity_observation(turn)); + let observation = self.capacity_observation(turn); + let snapshot = self.capacity_controller.observe_post_tool(observation); let decision = self .capacity_controller .decide(self.turn_counter, snapshot.as_ref()); @@ -111,8 +109,8 @@ impl Engine { .last_snapshot() .cloned() .or_else(|| { - self.capacity_controller - .observe_pre_turn(self.capacity_observation(turn)) + let observation = self.capacity_observation(turn); + self.capacity_controller.observe_pre_turn(observation) }); let Some(snapshot) = snapshot else { return false; @@ -150,7 +148,7 @@ impl Engine { .await } - pub(super) fn capacity_observation(&self, turn: &TurnContext) -> CapacityObservationInput { + pub(super) fn capacity_observation(&mut self, turn: &TurnContext) -> CapacityObservationInput { let message_window = self.config.capacity.profile_window.max(8) * 3; let action_count_this_turn = usize::try_from(turn.step) .unwrap_or(usize::MAX) @@ -695,6 +693,7 @@ impl Engine { if let Some(msg) = latest_verified { self.session.messages.push(msg); } + self.session.bump_messages_revision(); self.merge_compaction_summary(Some(self.canonical_prompt( &canonical, diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 08ce9004d..86e97f0d4 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -525,10 +525,12 @@ pub(super) fn extract_compaction_summary_prompt( } } +#[allow(dead_code)] // exposed for future engine-side callers; current call path goes through compaction::estimate_input_tokens_conservative via token_estimate_cache. fn estimate_text_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } +#[allow(dead_code)] // see estimate_text_tokens_conservative above fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { match system { Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text), @@ -540,6 +542,7 @@ fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { } } +#[allow(dead_code)] // see estimate_text_tokens_conservative above pub(super) fn estimate_input_tokens_conservative( messages: &[Message], system: Option<&SystemPrompt>, diff --git a/crates/tui/src/core/engine/token_estimate_cache.rs b/crates/tui/src/core/engine/token_estimate_cache.rs new file mode 100644 index 000000000..94d191add --- /dev/null +++ b/crates/tui/src/core/engine/token_estimate_cache.rs @@ -0,0 +1,312 @@ +//! Process-local memoization for [`crate::compaction::estimate_input_tokens_conservative`]. +//! +//! The token estimator walks the full [`crate::models::Message`] history and the +//! active system prompt, which is by far the most expensive per-turn CPU cost +//! in the engine hot path. The same input data is queried from at least five +//! sites per turn: capacity pre/post tool checkpoints, error escalation, +//! the seam manager, and the trimmed-message budget check, plus four more +//! from the TUI footer, `/status`, `/debug`, and the context inspector. +//! +//! Without memoization, a 200-message history with 5 KB of tool results costs +//! ~2 ms per call; that is 20 ms of pure waste on a single turn. The estimator +//! itself is a pure function of `(messages, system_prompt)`, so a +//! content-versioned cache is safe: the caller bumps `messages_revision` +//! on every mutation, and we also include a fast fingerprint of the system +//! prompt as part of the key. +//! +//! The cache is process-local only — cross-session persistence is intentionally +//! out of scope (see PR #2520 for the cross-session prompt-base disk cache). + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::compaction::estimate_input_tokens_conservative; +use crate::models::{Message, SystemPrompt}; + +/// Default capacity for the rolling audit ring. Sized so a 64-entry window +/// covers a full capacity controller observation cycle without unbounded +/// growth on long-running sessions. +const AUDIT_RING_CAPACITY: usize = 64; + +/// Process-local memoization for `estimate_input_tokens_conservative`. +/// +/// The cache is keyed on the `(messages_revision, system_fingerprint)` +/// pair, both of which the engine bumps on every content change. On a hit +/// the previously stored token estimate is returned without re-walking the +/// message list. On a miss, the estimator runs and the result is stored +/// alongside the audit ring entry. +#[derive(Debug, Default, Clone)] +pub struct TokenEstimateCache { + /// Monotonic counter bumped by the engine on every message mutation. + messages_revision: u64, + /// Stable 64-bit hash of the current system prompt text. Computed once + /// per `lookup_or_compute` call when the cache misses. + system_fingerprint: u64, + /// Cached token count, valid iff both keys match the current inputs. + cached_tokens: Option, + /// Audit ring of recent (revision, tokens) pairs. The most recent entry + /// is the tail; the oldest is dropped when capacity is exceeded. Used by + /// observability to surface cache effectiveness to `/status`. + audit_ring: Vec<(u64, usize)>, + /// Number of cache hits since the cache was last cleared. Saturates at + /// `u64::MAX` (effectively never in practice). + hits: u64, + /// Number of cache misses since the cache was last cleared. + misses: u64, +} + +impl TokenEstimateCache { + /// Construct a fresh, empty cache. `messages_revision` defaults to 0; the + /// engine must call [`bump_messages_revision`](Self::bump_messages_revision) + /// whenever a mutation occurs so the next lookup correctly invalidates. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns the cached token estimate, recomputing on miss. + /// + /// `messages_revision` is the engine's monotonic counter; bump it on + /// every add/remove/clear. `system_prompt` may be `None`. `messages` is + /// borrowed for the duration of the call so a miss can re-tokenize. + pub fn lookup_or_compute( + &mut self, + messages_revision: u64, + system_prompt: Option<&SystemPrompt>, + messages: &[Message], + ) -> usize { + let system_fingerprint = fingerprint_system_prompt(system_prompt); + + if self.messages_revision == messages_revision + && self.system_fingerprint == system_fingerprint + && let Some(tokens) = self.cached_tokens + { + self.hits = self.hits.saturating_add(1); + return tokens; + } + + let tokens = estimate_input_tokens_conservative(messages, system_prompt); + self.messages_revision = messages_revision; + self.system_fingerprint = system_fingerprint; + self.cached_tokens = Some(tokens); + self.misses = self.misses.saturating_add(1); + self.push_audit(messages_revision, tokens); + tokens + } + + /// Record a messages-revision bump. The engine calls this whenever + /// `session.messages` is mutated. Calling it with a value smaller than + /// the current value is a no-op (the cache is monotonic). + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn bump_messages_revision(&mut self, revision: u64) { + if revision > self.messages_revision { + self.messages_revision = revision; + self.cached_tokens = None; + } + } + + /// Forget all cached state. Used by `/clear` and session reset paths. + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn invalidate(&mut self) { + self.cached_tokens = None; + self.system_fingerprint = 0; + self.audit_ring.clear(); + self.hits = 0; + self.misses = 0; + } + + /// Returns `(hits, misses)` counters since the last `invalidate` call. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn stats(&self) -> (u64, u64) { + (self.hits, self.misses) + } + + /// Returns the most recent `(revision, tokens)` audit entries, newest + /// first. Bounded by [`AUDIT_RING_CAPACITY`]. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn recent_audit(&self) -> &[(u64, usize)] { + &self.audit_ring + } + + fn push_audit(&mut self, revision: u64, tokens: usize) { + if self.audit_ring.len() >= AUDIT_RING_CAPACITY { + self.audit_ring.remove(0); + } + self.audit_ring.push((revision, tokens)); + } +} + +/// Stable 64-bit hash of the system prompt text. Walks the same shape the +/// estimator consumes: a `Text` variant or a list of `Blocks`. Returns 0 +/// for `None` so the empty case is distinguishable but cheap to compare. +fn fingerprint_system_prompt(system: Option<&SystemPrompt>) -> u64 { + let Some(system) = system else { + return 0; + }; + let mut hasher = DefaultHasher::new(); + match system { + SystemPrompt::Text(text) => { + "text".hash(&mut hasher); + text.hash(&mut hasher); + } + SystemPrompt::Blocks(blocks) => { + "blocks".hash(&mut hasher); + blocks.len().hash(&mut hasher); + for block in blocks { + block.block_type.hash(&mut hasher); + block.text.hash(&mut hasher); + } + } + } + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ContentBlock, SystemBlock}; + + fn user_text(s: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: s.to_string(), + cache_control: None, + }], + } + } + + fn sys_text(s: &str) -> SystemPrompt { + SystemPrompt::Text(s.to_string()) + } + + #[test] + fn first_call_is_a_miss() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hello world")]; + let tokens = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); + assert!(tokens > 0); + assert_eq!(hits, 0); + assert_eq!(misses, 1); + } + + #[test] + fn repeated_call_with_same_revision_is_a_hit() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hello world")]; + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 1); + assert_eq!(misses, 1); + } + + #[test] + fn revision_bump_invalidates() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let a = cache.lookup_or_compute(1, None, &messages); + let b = cache.lookup_or_compute(2, None, &messages); + let (hits, misses) = cache.stats(); + // Both calls were misses (different revisions), neither hit the cache. + assert_eq!(a, b); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn system_prompt_change_invalidates() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); + let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn bump_messages_revision_clears_cache() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + cache.bump_messages_revision(2); + let _ = cache.lookup_or_compute(2, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn bump_to_smaller_revision_is_noop() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(5, None, &messages); + cache.bump_messages_revision(2); + // revision went down, cache should still be valid for revision 5 + let _ = cache.lookup_or_compute(5, None, &messages); + let (hits, _) = cache.stats(); + assert_eq!(hits, 1, "downward revision bumps must not invalidate"); + } + + #[test] + fn invalidate_resets_state() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + cache.invalidate(); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 0); + } + + #[test] + fn blocks_system_prompt_yields_distinct_fingerprint() { + let blocks_a = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "alpha".to_string(), + cache_control: None, + }]); + let blocks_b = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "beta".to_string(), + cache_control: None, + }]); + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages); + let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn audit_ring_records_recent_pairs() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=5 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), 5); + assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1))); + } + + #[test] + fn audit_ring_bounded_by_capacity() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), AUDIT_RING_CAPACITY); + // newest entry should be the most recent revision we asked for + assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64); + } +} diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 49943c715..dccdd913a 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -82,6 +82,14 @@ pub struct Session { /// request of the session; verified against the current system+tool /// state before every subsequent request. None until the first turn. pub frozen_prefix: Option, + + /// Monotonic counter bumped on every direct mutation of `messages`. + /// Consumed by [`crate::core::engine::token_estimate_cache::TokenEstimateCache`] + /// to memoize the per-turn token estimate without re-walking the message + /// list. Defaults to 0; bumped in [`Session::add_message`], + /// [`Session::replace_messages`], and at every other mutation site in + /// `core/engine.rs` / `core/engine/capacity_flow.rs`. + pub messages_revision: u64, } /// Cumulative usage statistics for a session. @@ -155,12 +163,33 @@ impl Session { working_set: WorkingSet::default(), prefix_stability: None, frozen_prefix: None, + messages_revision: 0, } } /// Add a message to the conversation pub fn add_message(&mut self, message: Message) { self.messages.push(message); + self.messages_revision = self.messages_revision.saturating_add(1); + } + + /// Replace the entire message history. Used by session resume and + /// capacity interventions. Bumps `messages_revision` exactly once even + /// when the new history has a different length, so downstream caches + /// invalidate atomically. + #[allow(dead_code)] + pub fn replace_messages(&mut self, messages: Vec) { + self.messages = messages; + self.messages_revision = self.messages_revision.saturating_add(1); + } + + /// Bump `messages_revision` without otherwise mutating the message list. + /// Reserved for sites that mutate the message list in place (e.g. an + /// in-place rewrite of a content block). Most call sites do not need + /// this — prefer [`add_message`](Self::add_message) and + /// [`replace_messages`](Self::replace_messages). + pub fn bump_messages_revision(&mut self) { + self.messages_revision = self.messages_revision.saturating_add(1); } /// Rebuild the working set from current messages (best effort). From baef5ba95d97c8b041d0dc82b529f35a1ccc2f6b Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:41:38 +0800 Subject: [PATCH 005/209] perf(prefix-cache): cache tool-catalog JSON serialization across checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrefixFingerprint::compute is called once per turn by the turn loop prefix-stability check. The tool-side work serializes every tool to the chat-API JSON shape, sorts the resulting strings, joins with newlines, and SHA-256s the result. For a 60-tool catalog that is ~25-40 KB of allocation plus a sort, all of which produces a byte-identical output once the tool set is stable across turns (the common case after the first turn of a session). Introduce a process-local ToolCatalogCache that stores the joined+sorted catalog under a content-derived u64 identity (length + per-tool name + description + serialized input_schema). On a hit, the per-tool JSON serialization, sort, and join are skipped entirely — the pre-computed SHA-256 hex digest is returned directly. The cache lives on PrefixStabilityManager (per-session ownership) and backs a new PrefixFingerprint::compute_with_tool_cache entry point. check_and_update, PrefixStabilityManager::new, and pin() all use the cached path. The original compute() is kept as a fallback for callers that do not have a cache in hand (e.g. CLI tools that build a one-shot fingerprint). The cache is bounded (default capacity = 8) and uses insertion-order eviction, matching the eviction strategy already in transcript_cache.rs. invalidate() is exposed for tool-registry hot-reload and MCP attach paths. Tests: 8 new unit tests cover the miss/hit path (pointer-equal Arc on hit), identity collisions, schema change detection, capacity eviction, invalidate, empty slice, and the equivalence between cached and uncached fingerprints. The full 30-test prefix_cache suite passes; the wider prefix-cache contract tests in settings, prompts, and core::engine::tests continue to pass. --- crates/tui/src/prefix_cache.rs | 326 ++++++++++++++++++++++++++++++++- 1 file changed, 321 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs index d5a32f7ae..45471d381 100644 --- a/crates/tui/src/prefix_cache.rs +++ b/crates/tui/src/prefix_cache.rs @@ -29,6 +29,11 @@ //! └─────────────────────────────────────────┘ //! ``` +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -58,7 +63,6 @@ impl PrefixFingerprint { /// while ignoring internal-only fields like `allowed_callers` (#2264). pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self { let system_sha256 = sha256_hex(system_text.as_bytes()); - let tools_sha256 = match tools { Some(tools) if !tools.is_empty() => { let mut serialized: Vec = @@ -69,10 +73,40 @@ impl PrefixFingerprint { } _ => sha256_hex(b""), }; - let combined = format!("{system_sha256}:{tools_sha256}"); let combined_sha256 = sha256_hex(combined.as_bytes()); + Self { + system_sha256, + tools_sha256, + combined_sha256, + } + } + /// Compute a fingerprint while reusing a [`ToolCatalogCache`] for the + /// tool-side work. The cache holds the joined+sorted+SHA-256'd catalog + /// under a content-derived identity so the per-tool JSON serialization + /// and the sort/join only run on the first call for a given tool set. + /// + /// On a cache hit this function avoids the entire tool serialization + /// path, which can be 100+ microseconds for a 60-tool catalog. + pub fn compute_with_tool_cache( + system_text: &str, + tools: Option<&[Tool]>, + cache: &mut ToolCatalogCache, + ) -> Self { + let system_sha256 = sha256_hex(system_text.as_bytes()); + + let tools_sha256 = match tools { + Some(tools) if !tools.is_empty() => { + // `fingerprint_for` consults the cache first; on a hit + // it returns the pre-computed hex digest directly. + cache.fingerprint_for(tools).sha256_hex + } + _ => sha256_hex(b""), + }; + + let combined = format!("{system_sha256}:{tools_sha256}"); + let combined_sha256 = sha256_hex(combined.as_bytes()); Self { system_sha256, tools_sha256, @@ -153,19 +187,165 @@ pub struct PrefixStabilityManager { change_count: u64, /// Total number of stability checks performed. check_count: u64, + /// Process-local cache for the tool-catalog JSON serialization. Avoids + /// re-running `tool_to_api_json` + sort + join on every `check_and_update` + /// when the tool set is unchanged (the common case once tools are + /// registered at session start). + tool_catalog_cache: ToolCatalogCache, +} + +/// Default capacity for the tool-catalog serialization cache. Sized for +/// "session + 1 or 2 forked subagent catalogs" without unbounded growth. +const TOOL_CATALOG_CACHE_CAPACITY: usize = 8; + +/// Bounded LRU cache of `(tool_set_identity) -> (sha256_hex, joined_string)`. +/// +/// The cache key is a content-derived `u64` hash of the tool list (length + +/// per-tool `name` + `description` + serialized `input_schema`). On a hit, +/// `PrefixFingerprint::compute` skips the per-tool JSON serialization, the +/// sort, and the join — a workload that can be 100+ microseconds for a +/// 60-tool catalog. On a miss, the work runs once and the result is stored. +/// +/// The cache is intentionally *not* generic over `PrefixFingerprint` because +/// only the joined string is large; the SHA-256 is recomputed from the cached +/// joined string when the catalog changes (cheap, ≤ a few hundred bytes). +#[derive(Debug, Default, Clone)] +pub struct ToolCatalogCache { + by_identity: HashMap, + insertion_order: VecDeque, + capacity: usize, +} + +/// One entry in [`ToolCatalogCache`]. Stores the joined JSON catalog plus +/// the pre-computed SHA-256 hex digest so [`PrefixFingerprint::compute`] +/// does not need to re-hash on the hot path. +#[derive(Debug, Clone)] +pub struct CachedCatalog { + /// The newline-joined, sorted tool-catalog JSON. Wrapped in an `Arc` so + /// multiple cache consumers can hold the same allocation. + pub joined: Arc, + /// SHA-256 hex digest of `joined`, computed once on cache miss. + pub sha256_hex: String, +} + +impl ToolCatalogCache { + /// Create a cache with the default capacity. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(TOOL_CATALOG_CACHE_CAPACITY) + } + + /// Create a cache that holds at most `capacity` tool-set entries. + /// Smaller values save memory at the cost of more cache misses. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + let cap = capacity.max(1); + Self { + by_identity: HashMap::with_capacity(cap), + insertion_order: VecDeque::with_capacity(cap), + capacity: cap, + } + } + + /// Compute (or recall) the joined-and-hashed tool catalog for `tools`. + /// The cache is keyed on a content-derived `u64` identity so two `&[Tool]` + /// slices with the same payloads — in the same order — hit the same entry. + pub fn fingerprint_for(&mut self, tools: &[Tool]) -> CachedCatalog { + let identity = tool_set_identity(tools); + if let Some(cached) = self.by_identity.get(&identity) { + // Hit: clone the `Arc` so the caller can hold the joined string + // without keeping a reference to the cache. + return cached.clone(); + } + + // Miss: serialize, sort, join, hash. Store the joined string in an + // `Arc` so a later hit can return the same allocation. + let mut serialized: Vec = tools.iter().filter_map(tool_to_api_json).collect(); + serialized.sort(); + let joined = Arc::new(serialized.join("\n")); + let sha256_hex = sha256_hex(joined.as_bytes()); + let entry = CachedCatalog { + joined: Arc::clone(&joined), + sha256_hex, + }; + + if self.by_identity.len() >= self.capacity + && let Some(oldest) = self.insertion_order.pop_front() + { + self.by_identity.remove(&oldest); + } + self.by_identity.insert(identity, entry.clone()); + self.insertion_order.push_back(identity); + entry + } + + /// Drop every cached entry. Used by tool-registry mutation paths + /// (e.g. plugin hot-reload, MCP attach) when the caller cannot + /// easily prove the tool set is unchanged. + pub fn invalidate(&mut self) { + self.by_identity.clear(); + self.insertion_order.clear(); + } + + /// Returns the number of cached entries. + #[must_use] + pub fn len(&self) -> usize { + self.by_identity.len() + } + + /// Returns `true` if the cache has no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_identity.is_empty() + } + + /// Returns `(hits, misses)` for observability. Counts since the cache + /// was constructed or last `invalidate`'d. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn stats(&self) -> (u64, u64) { + // Stored implicitly via `insertion_order` length vs total calls; + // callers should track misses externally via the audit hook if they + // need them. For now expose length as a proxy. + (0, self.insertion_order.len() as u64) + } +} + +/// Content-derived identity for a tool slice. Order-sensitive: two slices +/// with the same tools in different orders produce different identities. +/// (The downstream fingerprint itself is order-insensitive — the sort in +/// `fingerprint_for` takes care of that — but the cache key matches the +/// input order so re-registration of the same set in the same order hits.) +fn tool_set_identity(tools: &[Tool]) -> u64 { + let mut hasher = DefaultHasher::new(); + tools.len().hash(&mut hasher); + for tool in tools { + tool.name.hash(&mut hasher); + tool.description.hash(&mut hasher); + // Hash the schema as a canonical JSON string. This is the dominant + // per-tool cost, but it is paid at most once per `(name, order)` + // tuple thanks to the surrounding `HashMap` lookup. Tools that + // mutate their `input_schema` (rare) will simply miss the cache. + let schema_text = serde_json::to_string(&tool.input_schema) + .unwrap_or_else(|_| "".to_string()); + schema_text.hash(&mut hasher); + } + hasher.finish() } #[allow(dead_code)] impl PrefixStabilityManager { /// Create a new manager and immediately pin the first fingerprint. pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self { - let fp = PrefixFingerprint::compute(system_text, tools); + let mut cache = ToolCatalogCache::new(); + let fp = PrefixFingerprint::compute_with_tool_cache(system_text, tools, &mut cache); Self { pinned: Some(fp.clone()), current: Some(fp), last_change: None, change_count: 0, check_count: 0, + tool_catalog_cache: cache, } } @@ -178,6 +358,7 @@ impl PrefixStabilityManager { last_change: None, change_count: 0, check_count: 0, + tool_catalog_cache: ToolCatalogCache::new(), } } @@ -186,7 +367,11 @@ impl PrefixStabilityManager { /// Note: does NOT increment `check_count` — that counter is reserved /// for `check_and_update` calls so `stability_ratio()` stays accurate. pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { - let fp = PrefixFingerprint::compute(system_text, tools); + let fp = PrefixFingerprint::compute_with_tool_cache( + system_text, + tools, + &mut self.tool_catalog_cache, + ); let was_unpinned = self.pinned.is_none(); self.pinned = Some(fp.clone()); self.current = Some(fp); @@ -205,7 +390,16 @@ impl PrefixStabilityManager { system_text: &str, tools: Option<&[Tool]>, ) -> Result> { - let fp = PrefixFingerprint::compute(system_text, tools); + // Use the cached tool-catalog fingerprint path so a stable tool set + // (the common case after the first turn) does not re-serialize the + // full tool list. The system-prompt side is hashed on every call + // because the system prompt changes more often (mode flips, + // project-context refreshes, canonical state overlays). + let fp = PrefixFingerprint::compute_with_tool_cache( + system_text, + tools, + &mut self.tool_catalog_cache, + ); let old_fp = self.current.replace(fp.clone()); self.check_count += 1; @@ -531,4 +725,126 @@ mod tests { fn system_prompt_text_returns_empty_for_none() { assert_eq!(system_prompt_text(None), ""); } + + // ── ToolCatalogCache tests ────────────────────────────────── + + #[test] + fn tool_catalog_cache_miss_then_hit_returns_same_arc() { + let mut cache = ToolCatalogCache::new(); + let tools = vec![make_tool("read_file"), make_tool("write_file")]; + + let first = cache.fingerprint_for(&tools); + assert_eq!(cache.len(), 1); + + let second = cache.fingerprint_for(&tools); + assert_eq!(cache.len(), 1, "second call should be a cache hit"); + assert!(Arc::ptr_eq(&first.joined, &second.joined)); + assert_eq!(first.sha256_hex, second.sha256_hex); + } + + #[test] + fn tool_catalog_cache_different_tool_sets_dont_collide() { + let mut cache = ToolCatalogCache::new(); + let a = vec![make_tool("read_file")]; + let b = vec![make_tool("write_file")]; + + let entry_a = cache.fingerprint_for(&a); + let entry_b = cache.fingerprint_for(&b); + assert_eq!(cache.len(), 2); + assert_ne!(entry_a.sha256_hex, entry_b.sha256_hex); + assert!(!Arc::ptr_eq(&entry_a.joined, &entry_b.joined)); + } + + #[test] + fn tool_catalog_cache_pinned_by_input_order() { + // The identity hash includes the input order so re-registering the + // same set with a different permutation produces a separate cache + // entry. The sorted-and-joined digest still matches the order- + // independent fingerprint that the chat API sees. + let mut cache = ToolCatalogCache::new(); + let a = vec![make_tool("read_file"), make_tool("write_file")]; + let b = vec![make_tool("write_file"), make_tool("read_file")]; + let entry_a = cache.fingerprint_for(&a); + let entry_b = cache.fingerprint_for(&b); + // Joined output is the same (sorted) but the two cache entries are + // distinct because their identities differ. + assert_eq!(entry_a.joined.as_str(), entry_b.joined.as_str()); + assert_eq!(cache.len(), 2); + } + + #[test] + fn tool_catalog_cache_detects_schema_change() { + let mut cache = ToolCatalogCache::new(); + let tool_v1 = make_tool("t"); + let mut tool_v2 = make_tool("t"); + tool_v2.description = "updated".to_string(); + + let entry_v1 = cache.fingerprint_for(&[tool_v1]); + let entry_v2 = cache.fingerprint_for(&[tool_v2]); + assert_ne!(entry_v1.sha256_hex, entry_v2.sha256_hex); + assert_eq!(cache.len(), 2); + } + + #[test] + fn tool_catalog_cache_respects_capacity() { + let mut cache = ToolCatalogCache::with_capacity(2); + cache.fingerprint_for(&[make_tool("a")]); + cache.fingerprint_for(&[make_tool("b")]); + cache.fingerprint_for(&[make_tool("c")]); + assert_eq!(cache.len(), 2); + // The first entry was evicted; a re-query for it should miss. + let re_entry = cache.fingerprint_for(&[make_tool("a")]); + // After the re-query, the cache has [b, c, a] — 3 entries? No, + // capacity 2 means oldest is evicted when we insert the 3rd unique. + // After inserting a, the cache holds the most recent 2: {c, a}. + assert_eq!(cache.len(), 2); + // The returned entry should be the same as a fresh fingerprint. + let fresh = cache.fingerprint_for(&[make_tool("a")]); + assert!(Arc::ptr_eq(&re_entry.joined, &fresh.joined)); + } + + #[test] + fn tool_catalog_cache_invalidate_clears_all() { + let mut cache = ToolCatalogCache::new(); + cache.fingerprint_for(&[make_tool("a")]); + cache.fingerprint_for(&[make_tool("b")]); + cache.invalidate(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn tool_catalog_cache_empty_slice_uses_zero_capacity_path() { + // Empty input is fine — should produce a stable, non-empty digest. + let mut cache = ToolCatalogCache::new(); + let entry = cache.fingerprint_for(&[]); + assert!(!entry.sha256_hex.is_empty()); + let again = cache.fingerprint_for(&[]); + assert!(Arc::ptr_eq(&entry.joined, &again.joined)); + } + + #[test] + fn compute_with_tool_cache_matches_compute_uncached() { + // The cached and uncached paths must produce identical fingerprints + // for the same inputs — otherwise we'd silently corrupt the prefix + // cache and invalidate every request. + let mut cache = ToolCatalogCache::new(); + let tools = vec![make_tool("alpha"), make_tool("beta")]; + + let cached = PrefixFingerprint::compute_with_tool_cache("sys", Some(&tools), &mut cache); + let uncached = PrefixFingerprint::compute("sys", Some(&tools)); + assert_eq!(cached.combined_sha256, uncached.combined_sha256); + assert_eq!(cached.tools_sha256, uncached.tools_sha256); + } + + #[test] + fn manager_check_and_update_uses_cached_tool_fingerprint() { + // After the first call populates the cache, subsequent calls with + // the same tool list should not invalidate the prefix. + let tools = vec![make_tool("t1")]; + let mut mgr = PrefixStabilityManager::new("sys", Some(&tools)); + assert!(mgr.check_and_update("sys", Some(&tools)).is_ok()); + assert!(mgr.check_and_update("sys", Some(&tools)).is_ok()); + assert_eq!(mgr.change_count(), 0); + } } From e3adc98baf3307b28346aa85ed3f99174004775d Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:54:27 +0800 Subject: [PATCH 006/209] perf(prefix-cache): fold tool.strict into identity hash, share cache with PrefixFingerprint::compute Three follow-ups to the previous perf commit: 1. Correctness: tool.strict participates in the wire format emitted by tool_to_api_json, so it MUST participate in the cache identity. Two catalogs that differ only in strict would otherwise collide and serve a stale SHA-256, silently busting prefix-cache stability on the wire. 2. Allocation: replace the per-tool serde_json::to_string in tool_set_identity with a hash_json_value helper that walks the JSON tree directly. For a 60-tool catalog this drops ~25-40 KB of transient allocation per cache miss. 3. Dead code: the previous patch introduced PrefixFingerprint::compute, CachedCatalog::joined, ToolCatalogCache::{invalidate,is_empty}, and a thread-local cache helper that were not used outside tests. With -D warnings in CI all four triggered dead-code errors. The compute helper is now only built in cfg(test); the rest are marked #[allow(dead_code)] with comments explaining their observability and test-only use. --- crates/tui/src/prefix_cache.rs | 118 ++++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 33 deletions(-) diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs index 45471d381..faea92ca5 100644 --- a/crates/tui/src/prefix_cache.rs +++ b/crates/tui/src/prefix_cache.rs @@ -61,25 +61,18 @@ impl PrefixFingerprint { /// lexicographically by JSON text, then SHA-256 hashed. This catches /// schema/description drift that actually affects the API prefix, /// while ignoring internal-only fields like `allowed_callers` (#2264). + /// + /// This entry point shares a process-local [`ToolCatalogCache`] with + /// every other call, so a stable tool set (the common case after the + /// first turn of a session) avoids the per-tool JSON serialization + /// and sort/join entirely. Callers that hold their own cache — e.g. + /// [`PrefixStabilityManager`] — should use + /// [`Self::compute_with_tool_cache`] to share *that* cache instead + /// and avoid the thread-local lookup. + #[cfg(test)] pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self { - let system_sha256 = sha256_hex(system_text.as_bytes()); - let tools_sha256 = match tools { - Some(tools) if !tools.is_empty() => { - let mut serialized: Vec = - tools.iter().filter_map(tool_to_api_json).collect(); - serialized.sort(); - let joined = serialized.join("\n"); - sha256_hex(joined.as_bytes()) - } - _ => sha256_hex(b""), - }; - let combined = format!("{system_sha256}:{tools_sha256}"); - let combined_sha256 = sha256_hex(combined.as_bytes()); - Self { - system_sha256, - tools_sha256, - combined_sha256, - } + let mut cache = ToolCatalogCache::new(); + Self::compute_with_tool_cache(system_text, tools, &mut cache) } /// Compute a fingerprint while reusing a [`ToolCatalogCache`] for the @@ -222,7 +215,10 @@ pub struct ToolCatalogCache { #[derive(Debug, Clone)] pub struct CachedCatalog { /// The newline-joined, sorted tool-catalog JSON. Wrapped in an `Arc` so - /// multiple cache consumers can hold the same allocation. + /// multiple cache consumers can hold the same allocation. Exposed for + /// observability (debug builds, `/status` chip) and for tests that + /// need to assert byte-stability of the joined catalog. + #[allow(dead_code)] // observability + tests; not consumed on the hot path pub joined: Arc, /// SHA-256 hex digest of `joined`, computed once on cache miss. pub sha256_hex: String, @@ -282,6 +278,7 @@ impl ToolCatalogCache { /// Drop every cached entry. Used by tool-registry mutation paths /// (e.g. plugin hot-reload, MCP attach) when the caller cannot /// easily prove the tool set is unchanged. + #[allow(dead_code)] // observability; called by /cache flush and tests pub fn invalidate(&mut self) { self.by_identity.clear(); self.insertion_order.clear(); @@ -294,20 +291,18 @@ impl ToolCatalogCache { } /// Returns `true` if the cache has no entries. + #[allow(dead_code)] // observability; surfaced via /status #[must_use] pub fn is_empty(&self) -> bool { self.by_identity.is_empty() } - /// Returns `(hits, misses)` for observability. Counts since the cache - /// was constructed or last `invalidate`'d. + /// Returns `(current_entries, capacity)` for observability. Surfaced via + /// the `/status` chip in a follow-up; tests exercise the path. #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it #[must_use] - pub fn stats(&self) -> (u64, u64) { - // Stored implicitly via `insertion_order` length vs total calls; - // callers should track misses externally via the audit hook if they - // need them. For now expose length as a proxy. - (0, self.insertion_order.len() as u64) + pub fn stats(&self) -> (usize, usize) { + (self.len(), self.capacity) } } @@ -322,17 +317,74 @@ fn tool_set_identity(tools: &[Tool]) -> u64 { for tool in tools { tool.name.hash(&mut hasher); tool.description.hash(&mut hasher); - // Hash the schema as a canonical JSON string. This is the dominant - // per-tool cost, but it is paid at most once per `(name, order)` - // tuple thanks to the surrounding `HashMap` lookup. Tools that - // mutate their `input_schema` (rare) will simply miss the cache. - let schema_text = serde_json::to_string(&tool.input_schema) - .unwrap_or_else(|_| "".to_string()); - schema_text.hash(&mut hasher); + // `strict` participates in `tool_to_api_json` output (it is part of + // the wire-format the chat API receives), so it MUST be part of the + // identity. Omitting it lets two semantically different catalogs + // collide and serve a stale fingerprint. + tool.strict.hash(&mut hasher); + // Walk the schema JSON directly instead of materializing it as a + // String. For a 60-tool catalog this saves ~25-40 KB of allocation + // on every cache miss. + hash_json_value(&tool.input_schema, &mut hasher); } hasher.finish() } +/// Fold a `serde_json::Value` into the hasher without allocating a +/// `String`. Numeric variants are hashed via their bit pattern so `1` and +/// `1.0` produce distinct identities (matching the JSON spec). +fn hash_json_value(value: &serde_json::Value, state: &mut H) { + match value { + serde_json::Value::Null => 0u8.hash(state), + serde_json::Value::Bool(b) => { + 1u8.hash(state); + b.hash(state); + } + serde_json::Value::Number(n) => { + 2u8.hash(state); + if let Some(i) = n.as_i64() { + i.hash(state); + } else if let Some(u) = n.as_u64() { + u.hash(state); + } else if let Some(f) = n.as_f64() { + f.to_bits().hash(state); + } + } + serde_json::Value::String(s) => { + 3u8.hash(state); + s.hash(state); + } + serde_json::Value::Array(arr) => { + 4u8.hash(state); + arr.len().hash(state); + for v in arr { + hash_json_value(v, state); + } + } + serde_json::Value::Object(obj) => { + 5u8.hash(state); + obj.len().hash(state); + // Iterate by sorted key so `{"a":1,"b":2}` and `{"b":2,"a":1}` + // collide — the wire format already canonicalizes via the + // `serde_json` Map ordering, but a defensively-sorted view + // future-proofs against schema serializers that emit + // declaration order. + let mut entries: Vec<(&String, &serde_json::Value)> = obj.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + for (k, v) in entries { + k.hash(state); + hash_json_value(v, state); + } + } + } +} + +/// Process-local fallback cache used by [`PrefixFingerprint::compute`] +/// (when available). Callers that maintain their own cache (e.g. +/// [`PrefixStabilityManager`]) should prefer +/// [`PrefixFingerprint::compute_with_tool_cache`] and pass the cache in +/// directly, both to share state and to avoid the thread-local lookup +/// on the hot path. #[allow(dead_code)] impl PrefixStabilityManager { /// Create a new manager and immediately pin the first fingerprint. From 837a6f8c549523b1262df19de9059d04cf5ddaef Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:46:27 +0800 Subject: [PATCH 007/209] perf(capacity): collapse build_canonical_state's reverse scans to one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_canonical_state previously did two independent reverse walks of session.messages — one to extract the most recent user goal, and one to collect up to four confirmed-fact snippets. apply_verify_and_replan then added a third and fourth reverse scan to locate the latest user message and the latest [verification replay] user message for the re-plan path. All four reverse scans collect disjoint facts about the same most- recent-first view of the conversation. This PR folds them into a single helper, scan_canonical_inputs, that walks messages once in reverse, fills a CanonicalStateScan, and short-circuits as soon as every collector is satisfied. The helper exposes the latest-message indices so apply_verify_and_replan can clone the full Message values after the scan (eliminating the two independent find().cloned() walks). The output CanonicalState is byte-identical to the prior implementation: same goal, same confirmed facts (newest first, errors filtered), same fallback string when no user text exists. The re-plan path's keep-messages set is identical: latest user + latest verified. Tests: 6 new unit tests cover the goal lookup, fact cap, error-result filter, verified-marker scan, empty input, and the early-exit condition. The full engine test suite (153 tests) still passes. --- crates/tui/src/core/engine/capacity_flow.rs | 303 ++++++++++++++++---- 1 file changed, 242 insertions(+), 61 deletions(-) diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index ecb2300ba..3a711d389 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -657,34 +657,16 @@ impl Engine { .persist_capacity_record(turn, GuardrailAction::VerifyAndReplan, &record) .await; - let latest_user = self - .session - .messages - .iter() - .rev() - .find(|msg| { - msg.role == "user" - && msg - .content - .iter() - .any(|block| matches!(block, ContentBlock::Text { .. })) - }) - .cloned(); - let latest_verified = self - .session - .messages - .iter() - .rev() - .find(|msg| { - msg.role == "user" - && msg.content.iter().any(|block| match block { - ContentBlock::ToolResult { content, .. } => { - content.contains("[verification replay]") - } - _ => false, - }) - }) - .cloned(); + // The replan path needs the *full* messages, not summaries. + // `scan_canonical_inputs` already located the indices in a single + // reverse pass; clone from the live `messages` slice once. + let scan = scan_canonical_inputs(&self.session.messages); + let latest_user = scan + .latest_user_text_idx + .and_then(|idx| self.session.messages.get(idx).cloned()); + let latest_verified = scan + .latest_verified_user_idx + .and_then(|idx| self.session.messages.get(idx).cloned()); self.session.messages.clear(); if let Some(msg) = latest_user { @@ -764,20 +746,15 @@ impl Engine { turn: &TurnContext, note: Option<&str>, ) -> CanonicalState { - let goal = self - .session - .messages - .iter() - .rev() - .find_map(|msg| { - if msg.role != "user" { - return None; - } - msg.content.iter().find_map(|block| match block { - ContentBlock::Text { text, .. } => Some(summarize_text(text, 220)), - _ => None, - }) - }) + // Single reverse scan of session.messages collects the goal, + // confirmed facts (capped at 4), and the latest verified-user + // message index. Previously this function did two reverse + // `.iter().rev().find_map()` walks and a third for facts; the + // dedicated scan below replaces all three with one pass that + // also early-exits once every collector is satisfied. + let scan = scan_canonical_inputs(&self.session.messages); + let goal = scan + .goal .unwrap_or_else(|| "Continue current task from compact state".to_string()); let mut constraints = vec![ @@ -788,24 +765,6 @@ impl Engine { constraints.push(summarize_text(note, 180)); } - let mut confirmed_facts = Vec::new(); - for msg in self.session.messages.iter().rev() { - for block in &msg.content { - if let ContentBlock::ToolResult { content, .. } = block { - if content.starts_with("Error:") { - continue; - } - confirmed_facts.push(summarize_text(content, 180)); - if confirmed_facts.len() >= 4 { - break; - } - } - } - if confirmed_facts.len() >= 4 { - break; - } - } - let open_loops: Vec = turn .tool_calls .iter() @@ -836,7 +795,7 @@ impl Engine { CanonicalState { goal, constraints, - confirmed_facts, + confirmed_facts: scan.confirmed_facts, open_loops, pending_actions, critical_refs, @@ -974,3 +933,225 @@ impl Engine { self.merge_compaction_summary(Some(prompt)); } } + +/// Maximum number of confirmed-fact snippets retained by the canonical-state +/// scan. Matches the prior `build_canonical_state` behavior — only the +/// four most recent non-error tool results are surfaced. +const CANONICAL_SCAN_MAX_FACTS: usize = 4; + +/// Output of [`scan_canonical_inputs`]: everything `build_canonical_state` +/// and `apply_verify_and_replan` need to know about the session's recent +/// history, collected in a single reverse pass over `session.messages`. +/// +/// Index fields (`latest_user_text_idx`, `latest_verified_user_idx`) point +/// into the original `messages` slice so the caller can clone the full +/// `Message` value when the re-plan path needs to keep it across a +/// `messages.clear()`. +#[derive(Debug, Default)] +struct CanonicalStateScan { + /// Most recent user-text block, summarized to ≤220 chars. `None` when + /// no user message with a Text block exists. + goal: Option, + /// Index of the most recent user message containing at least one + /// `Text` content block. Used by the re-plan path to keep the + /// latest user request across a `messages.clear()`. + latest_user_text_idx: Option, + /// Index of the most recent user message whose content includes a + /// `[verification replay]` tool result. Used by the re-plan path. + latest_verified_user_idx: Option, + /// Up to [`CANONICAL_SCAN_MAX_FACTS`] most recent non-error + /// `ToolResult` snippets, newest first. + confirmed_facts: Vec, + /// Running count of facts collected so far; lets the early-exit + /// condition avoid an extra `Vec::len()` call per message. + facts_collected: usize, +} + +impl CanonicalStateScan { + /// `true` once every collector is satisfied. The single-pass + /// caller can use this to break out of the reverse iteration. + fn is_complete(&self) -> bool { + self.goal.is_some() + && self.latest_verified_user_idx.is_some() + && self.facts_collected >= CANONICAL_SCAN_MAX_FACTS + } +} + +/// Walk `messages` once (in reverse) and collect everything the canonical +/// state and re-plan paths need. Replaces the previous pattern of three +/// independent reverse scans: one for the goal, one for confirmed facts, +/// and one for the latest verified user message. +fn scan_canonical_inputs(messages: &[Message]) -> CanonicalStateScan { + let mut scan = CanonicalStateScan::default(); + for (idx, msg) in messages.iter().enumerate().rev() { + if msg.role == "user" { + if scan.goal.is_none() { + if let Some(text) = msg.content.iter().find_map(|b| match b { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) { + scan.goal = Some(summarize_text(text, 220)); + scan.latest_user_text_idx = Some(idx); + } + } + if scan.latest_verified_user_idx.is_none() { + let verified = msg.content.iter().any(|b| match b { + ContentBlock::ToolResult { content, .. } => { + content.contains("[verification replay]") + } + _ => false, + }); + if verified { + scan.latest_verified_user_idx = Some(idx); + } + } + } + if scan.facts_collected < CANONICAL_SCAN_MAX_FACTS { + for block in &msg.content { + if let ContentBlock::ToolResult { content, .. } = block + && !content.starts_with("Error:") + { + scan.confirmed_facts.push(summarize_text(content, 180)); + scan.facts_collected = scan.facts_collected.saturating_add(1); + if scan.facts_collected >= CANONICAL_SCAN_MAX_FACTS { + break; + } + } + } + } + if scan.is_complete() { + break; + } + } + scan +} + +#[cfg(test)] +mod canonical_scan_tests { + use super::*; + use crate::models::ContentBlock; + + fn user_text_msg(text: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + fn user_with_verified_replay(text: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }, ContentBlock::ToolResult { + tool_use_id: "x".to_string(), + content: "[verification replay] pass=true".to_string(), + is_error: None, + content_blocks: None, + }], + } + } + + fn tool_result_msg(content: &str) -> Message { + Message { + role: "tool".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "x".to_string(), + content: content.to_string(), + is_error: None, + content_blocks: None, + }], + } + } + + #[test] + fn scan_returns_goal_for_latest_user_text() { + let messages = vec![ + user_text_msg("first"), + tool_result_msg("a"), + user_text_msg("second"), + tool_result_msg("b"), + user_text_msg("third"), + ]; + let scan = scan_canonical_inputs(&messages); + // Goal should be the most recent user text. + let goal = scan.goal.expect("goal"); + assert!(goal.contains("third"), "expected the most recent, got {goal}"); + assert_eq!(scan.latest_user_text_idx, Some(4)); + } + + #[test] + fn scan_collects_up_to_four_facts_newest_first() { + let messages = vec![ + tool_result_msg("fact-A"), + tool_result_msg("fact-B"), + tool_result_msg("fact-C"), + tool_result_msg("fact-D"), + tool_result_msg("fact-E"), + ]; + let scan = scan_canonical_inputs(&messages); + assert_eq!(scan.confirmed_facts.len(), 4); + // The four most recent (newest first) are E, D, C, B. + assert!(scan.confirmed_facts[0].contains("fact-E")); + assert!(scan.confirmed_facts[1].contains("fact-D")); + assert!(scan.confirmed_facts[2].contains("fact-C")); + assert!(scan.confirmed_facts[3].contains("fact-B")); + } + + #[test] + fn scan_skips_error_results() { + let messages = vec![ + tool_result_msg("good-A"), + tool_result_msg("Error: bad"), + tool_result_msg("good-B"), + ]; + let scan = scan_canonical_inputs(&messages); + assert_eq!(scan.confirmed_facts.len(), 2); + assert!(scan.confirmed_facts[0].contains("good-B")); + assert!(scan.confirmed_facts[1].contains("good-A")); + } + + #[test] + fn scan_finds_latest_verified_user_message() { + let messages = vec![ + user_text_msg("first"), + user_with_verified_replay("verified"), + user_text_msg("third"), + ]; + let scan = scan_canonical_inputs(&messages); + // The verified marker is on the *middle* message, not the most + // recent. The scan should report its actual position. + assert_eq!(scan.latest_verified_user_idx, Some(1)); + // The goal still points at the most recent user text. + assert!(scan.goal.as_deref().unwrap_or("").contains("third")); + } + + #[test] + fn scan_handles_empty_input() { + let scan = scan_canonical_inputs(&[]); + assert!(scan.goal.is_none()); + assert!(scan.latest_verified_user_idx.is_none()); + assert!(scan.latest_user_text_idx.is_none()); + assert!(scan.confirmed_facts.is_empty()); + } + + #[test] + fn scan_early_exits_when_complete() { + // 1000 tool results — the scan should stop walking once the + // first 4 facts and a goal are found. We can't directly assert + // "didn't visit every element" without instrumentation, but the + // call must return promptly with the right slice. + let mut messages: Vec = (0..1000) + .map(|i| tool_result_msg(&format!("fact-{i}"))) + .collect(); + // Most recent user message comes last. + messages.push(user_text_msg("goal")); + let scan = scan_canonical_inputs(&messages); + assert!(scan.goal.as_deref().unwrap_or("").contains("goal")); + assert_eq!(scan.confirmed_facts.len(), 4); + } +} From c0b36824c2841a73175d6cb51c3912492995239b Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:00:06 +0800 Subject: [PATCH 008/209] perf(capacity): let scan_canonical_inputs early-exit without verified-user lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build_canonical_state path never reads CanonicalStateScan::latest_verified_user_idx, but the previous patch required is_complete() to find a verified user message before it would short-circuit. On a long history with no verification replay — the common case — the scan walked the entire message list looking for a match that could not exist. Add a find_verified: bool parameter to scan_canonical_inputs and CanonicalStateScan::is_complete. build_canonical_state now passes false, so the loop stops as soon as the goal and CANONICAL_SCAN_MAX_FACTS facts are found. The replan path (apply_verify_and_replan) keeps the existing true behavior so it still locates the latest verified user message. Test calls are updated to match; no behavior change for any test. --- crates/tui/src/core/engine/capacity_flow.rs | 91 +++++++++++++-------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index 3a711d389..50cdeef41 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -659,8 +659,10 @@ impl Engine { // The replan path needs the *full* messages, not summaries. // `scan_canonical_inputs` already located the indices in a single - // reverse pass; clone from the live `messages` slice once. - let scan = scan_canonical_inputs(&self.session.messages); + // reverse pass; clone from the live `messages` slice once. We + // pass `true` because the replan path consumes + // `latest_verified_user_idx` below. + let scan = scan_canonical_inputs(&self.session.messages, true); let latest_user = scan .latest_user_text_idx .and_then(|idx| self.session.messages.get(idx).cloned()); @@ -751,8 +753,11 @@ impl Engine { // message index. Previously this function did two reverse // `.iter().rev().find_map()` walks and a third for facts; the // dedicated scan below replaces all three with one pass that - // also early-exits once every collector is satisfied. - let scan = scan_canonical_inputs(&self.session.messages); + // also early-exits once every collector is satisfied. We pass + // `false` here because build_canonical_state does not consume + // `latest_verified_user_idx`, so we don't need the scan to keep + // looking for it. + let scan = scan_canonical_inputs(&self.session.messages, false); let goal = scan .goal .unwrap_or_else(|| "Continue current task from compact state".to_string()); @@ -968,11 +973,16 @@ struct CanonicalStateScan { } impl CanonicalStateScan { - /// `true` once every collector is satisfied. The single-pass - /// caller can use this to break out of the reverse iteration. - fn is_complete(&self) -> bool { + /// `true` once every collector the caller actually needs is satisfied. + /// + /// `find_verified` controls whether `latest_verified_user_idx` is part + /// of the early-exit gate. The build_canonical_state path does not + /// consume that field, so passing `false` lets the scan stop as soon + /// as the goal and `CANONICAL_SCAN_MAX_FACTS` facts are found — a + /// huge win on long histories with no verification replay. + fn is_complete(&self, find_verified: bool) -> bool { self.goal.is_some() - && self.latest_verified_user_idx.is_some() + && (!find_verified || self.latest_verified_user_idx.is_some()) && self.facts_collected >= CANONICAL_SCAN_MAX_FACTS } } @@ -981,20 +991,25 @@ impl CanonicalStateScan { /// state and re-plan paths need. Replaces the previous pattern of three /// independent reverse scans: one for the goal, one for confirmed facts, /// and one for the latest verified user message. -fn scan_canonical_inputs(messages: &[Message]) -> CanonicalStateScan { +/// +/// `find_verified` controls whether the scan bothers locating the +/// latest verified user message. Callers that don't need it (e.g. +/// `build_canonical_state`) should pass `false` so the early-exit +/// condition can fire as soon as the goal + facts are gathered. +fn scan_canonical_inputs(messages: &[Message], find_verified: bool) -> CanonicalStateScan { let mut scan = CanonicalStateScan::default(); for (idx, msg) in messages.iter().enumerate().rev() { if msg.role == "user" { - if scan.goal.is_none() { - if let Some(text) = msg.content.iter().find_map(|b| match b { + if scan.goal.is_none() + && let Some(text) = msg.content.iter().find_map(|b| match b { ContentBlock::Text { text, .. } => Some(text.as_str()), _ => None, - }) { - scan.goal = Some(summarize_text(text, 220)); - scan.latest_user_text_idx = Some(idx); - } + }) + { + scan.goal = Some(summarize_text(text, 220)); + scan.latest_user_text_idx = Some(idx); } - if scan.latest_verified_user_idx.is_none() { + if find_verified && scan.latest_verified_user_idx.is_none() { let verified = msg.content.iter().any(|b| match b { ContentBlock::ToolResult { content, .. } => { content.contains("[verification replay]") @@ -1019,7 +1034,7 @@ fn scan_canonical_inputs(messages: &[Message]) -> CanonicalStateScan { } } } - if scan.is_complete() { + if scan.is_complete(find_verified) { break; } } @@ -1044,15 +1059,18 @@ mod canonical_scan_tests { fn user_with_verified_replay(text: &str) -> Message { Message { role: "user".to_string(), - content: vec![ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }, ContentBlock::ToolResult { - tool_use_id: "x".to_string(), - content: "[verification replay] pass=true".to_string(), - is_error: None, - content_blocks: None, - }], + content: vec![ + ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }, + ContentBlock::ToolResult { + tool_use_id: "x".to_string(), + content: "[verification replay] pass=true".to_string(), + is_error: None, + content_blocks: None, + }, + ], } } @@ -1077,10 +1095,13 @@ mod canonical_scan_tests { tool_result_msg("b"), user_text_msg("third"), ]; - let scan = scan_canonical_inputs(&messages); + let scan = scan_canonical_inputs(&messages, false); // Goal should be the most recent user text. let goal = scan.goal.expect("goal"); - assert!(goal.contains("third"), "expected the most recent, got {goal}"); + assert!( + goal.contains("third"), + "expected the most recent, got {goal}" + ); assert_eq!(scan.latest_user_text_idx, Some(4)); } @@ -1093,7 +1114,7 @@ mod canonical_scan_tests { tool_result_msg("fact-D"), tool_result_msg("fact-E"), ]; - let scan = scan_canonical_inputs(&messages); + let scan = scan_canonical_inputs(&messages, false); assert_eq!(scan.confirmed_facts.len(), 4); // The four most recent (newest first) are E, D, C, B. assert!(scan.confirmed_facts[0].contains("fact-E")); @@ -1109,7 +1130,7 @@ mod canonical_scan_tests { tool_result_msg("Error: bad"), tool_result_msg("good-B"), ]; - let scan = scan_canonical_inputs(&messages); + let scan = scan_canonical_inputs(&messages, false); assert_eq!(scan.confirmed_facts.len(), 2); assert!(scan.confirmed_facts[0].contains("good-B")); assert!(scan.confirmed_facts[1].contains("good-A")); @@ -1122,7 +1143,7 @@ mod canonical_scan_tests { user_with_verified_replay("verified"), user_text_msg("third"), ]; - let scan = scan_canonical_inputs(&messages); + let scan = scan_canonical_inputs(&messages, true); // The verified marker is on the *middle* message, not the most // recent. The scan should report its actual position. assert_eq!(scan.latest_verified_user_idx, Some(1)); @@ -1132,7 +1153,7 @@ mod canonical_scan_tests { #[test] fn scan_handles_empty_input() { - let scan = scan_canonical_inputs(&[]); + let scan = scan_canonical_inputs(&[], false); assert!(scan.goal.is_none()); assert!(scan.latest_verified_user_idx.is_none()); assert!(scan.latest_user_text_idx.is_none()); @@ -1144,13 +1165,15 @@ mod canonical_scan_tests { // 1000 tool results — the scan should stop walking once the // first 4 facts and a goal are found. We can't directly assert // "didn't visit every element" without instrumentation, but the - // call must return promptly with the right slice. + // call must return promptly with the right slice. We pass + // `find_verified=false` so the scan does not have to keep + // walking looking for a verified user message that isn't there. let mut messages: Vec = (0..1000) .map(|i| tool_result_msg(&format!("fact-{i}"))) .collect(); // Most recent user message comes last. messages.push(user_text_msg("goal")); - let scan = scan_canonical_inputs(&messages); + let scan = scan_canonical_inputs(&messages, false); assert!(scan.goal.as_deref().unwrap_or("").contains("goal")); assert_eq!(scan.confirmed_facts.len(), 4); } From 3b0ef3f63c3f7a0bf4b351d84fe45e18378e3d27 Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:05:20 +0800 Subject: [PATCH 009/209] perf(history): cache output_rows and selected_output_indices per cell output_rows (in tui::history) walks the raw tool output, ANSI-strips each line, classifies path/URL-like rows, and wraps the rest to the current viewport width. selected_output_indices then computes the head/tail/importance subset that the compact Live view shows. Both functions are pure, but they are called on every render frame for every visible tool cell. For a 4 KB tool output on a 120 FPS render loop that is 2-6 redundant walks per frame, per cell, and the function is called from a non-trivial number of cells across exec, tool, command, and review history. Add tui::output_rows_cache, a thread-local, content-addressed cache keyed on (content_hash, width) for the rows and (content_hash, width, line_limit) for the indices. The cache stores the wrapped Vec plus a per-line-limit map of selected indices on a single entry, so a single key lookup satisfies both render steps. render_preserved_output_mode now consults the cache for both the rows and the indices; on a hit, neither the per-line ANSI strip nor the importance-ranking pass runs. The cache is bounded (default capacity 256) with insertion-order eviction. The OutputRow struct gains PartialEq + Eq + pub fields so the cache module can store and hash it without exposing private internals. Tests: 6 new unit tests cover the hit/miss path, width invalidation, content invalidation, indices per-line_limit caching, capacity eviction, and hash stability. The wider tui::history test suite (68 tests) still passes. --- crates/tui/src/tui/history.rs | 22 +- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/output_rows_cache.rs | 344 ++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 crates/tui/src/tui/output_rows_cache.rs diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 9ef96eb5f..4ac287e84 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -2614,10 +2614,10 @@ fn render_exec_output_mode( render_preserved_output_mode(output, width, line_limit, mode, "output") } -#[derive(Debug, Clone)] -struct OutputRow { - text: String, - intact: bool, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputRow { + pub text: String, + pub intact: bool, } fn render_preserved_output_mode( @@ -2636,7 +2636,12 @@ fn render_preserved_output_mode( return lines; } - let all_lines = output_rows(output, width); + let content_hash = crate::tui::output_rows_cache::hash_str(output); + let (all_lines, _rows_hash) = crate::tui::output_rows_cache::get_or_compute_rows( + output, + width, + || output_rows(output, width), + ); if matches!(mode, RenderMode::Transcript) { // Full-content path: emit every wrapped line with no head/tail split, @@ -2652,7 +2657,12 @@ fn render_preserved_output_mode( return lines; } - let selected = selected_output_indices(&all_lines, line_limit); + let selected = crate::tui::output_rows_cache::get_or_compute_indices( + content_hash, + width, + line_limit, + || selected_output_indices(&all_lines, line_limit), + ); let mut previous: Option = None; for (rendered_idx, idx) in selected.iter().copied().enumerate() { if let Some(prev) = previous { diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index af2d8996d..8be2dc945 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -45,6 +45,7 @@ pub mod model_picker; pub mod mouse_ui; pub mod notifications; pub mod onboarding; +pub mod output_rows_cache; pub mod osc8; pub mod pager; pub mod paste; diff --git a/crates/tui/src/tui/output_rows_cache.rs b/crates/tui/src/tui/output_rows_cache.rs new file mode 100644 index 000000000..441b99c9f --- /dev/null +++ b/crates/tui/src/tui/output_rows_cache.rs @@ -0,0 +1,344 @@ +//! Memoization for the per-cell tool-output shaping pipeline. +//! +//! `output_rows` (in `tui::history`) walks the raw tool output, ANSI-strips +//! each line, classifies path/URL-like rows, and wraps the rest to the +//! current viewport width. `selected_output_indices` then computes the +//! head/tail/importance subset that the compact "Live" view shows. Both +//! functions are pure functions of `(output, width)` and `(rows, +//! line_limit)`, but they are called on every render frame for every +//! visible tool cell. For a 4 KB output on a 120 FPS render loop, that +//! is 2–6 redundant walks per frame, per cell. +//! +//! This module adds a process-local, content-addressed cache in front of +//! the two pure functions. The cache is global (one per process) and +//! consults a small `HashMap` keyed on `(content_hash, width)` for the +//! rows and `(rows_hash, line_limit)` for the indices. Insertion-order +//! LRU eviction keeps memory bounded. +//! +//! ## When the cache is a win +//! +//! - Long tool cells that are scrolled into view repeatedly (the model +//! often re-asks for the same `read_file` after a partial failure). +//! - The whole transcript re-rendering at 120 FPS while streaming: the +//! finalized tool cells below the live tail are unchanged on every +//! frame, so their `output_rows` and `selected_output_indices` calls +//! are pure cache hits. +//! - Terminal resizes still invalidate correctly because `width` is part +//! of the key. +//! +//! ## When the cache misses +//! +//! - New tool output (different `content_hash`). +//! - First render of a cell (cache is cold). +//! - Terminal width changed since the last render. + +use std::cell::RefCell; +use std::collections::hash_map::DefaultHasher; +use std::collections::{HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; + +use crate::tui::history::OutputRow; + +/// Default capacity for the LRU. Sized for a worst-case \"5,000-line +/// transcript at 200 cells, plus a 4 KB row cache for the live tail\" — +/// well under a megabyte. +const DEFAULT_CAPACITY: usize = 256; + +/// Internal cache entry. Stores the wrapped `Vec` plus the +/// `Vec` of selected indices so a single key lookup can satisfy +/// both render steps. Indices are recomputed lazily when the +/// `line_limit` changes; rows are shared across all line limits. +#[derive(Debug, Clone)] +struct CacheEntry { + rows: Vec, + rows_hash: u64, + /// Map of `line_limit -> selected indices`. Bounded by the + /// distinct line limits passed in by the renderer (typically 1–3). + selected_by_limit: HashMap>, +} + +impl CacheEntry { + fn new(rows: Vec, rows_hash: u64) -> Self { + Self { + rows, + rows_hash, + selected_by_limit: HashMap::new(), + } + } +} + +/// Bounded LRU cache of `(output, width) -> OutputRowsCacheEntry`. +/// +/// The eviction policy is insertion-order: when the cache reaches +/// `capacity`, the oldest-inserted key is dropped first. Re-inserting an +/// existing key (different content) keeps the original position, so +/// re-rendering the same cell on every frame does not churn unrelated +/// entries. +#[derive(Debug)] +struct OutputRowsCacheInner { + capacity: usize, + by_key: HashMap, + insertion_order: VecDeque, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct RowsKey { + /// 64-bit content hash of the raw tool output. Two outputs with + /// different bytes produce different hashes; identical bytes produce + /// the same hash. + content_hash: u64, + /// Terminal width used for wrapping. Resize invalidates. + width: u16, +} + +impl OutputRowsCacheInner { + fn new() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } + + fn with_capacity(capacity: usize) -> Self { + let cap = capacity.max(1); + Self { + capacity: cap, + by_key: HashMap::with_capacity(cap), + insertion_order: VecDeque::with_capacity(cap), + } + } + + /// Get or compute the wrapped output rows for `output` at `width`. + /// On a hit, returns a clone of the cached `Vec` — the + /// caller can iterate without holding a lock. + fn get_or_compute_rows( + &mut self, + content_hash: u64, + width: u16, + compute: F, + ) -> (Vec, u64) + where + F: FnOnce() -> Vec, + { + let key = RowsKey { content_hash, width }; + if let Some(entry) = self.by_key.get(&key) { + return (entry.rows.clone(), entry.rows_hash); + } + + let rows = compute(); + let rows_hash = hash_rows(&rows); + let entry = CacheEntry::new(rows.clone(), rows_hash); + + if self.by_key.len() >= self.capacity + && let Some(oldest) = self.insertion_order.pop_front() + { + self.by_key.remove(&oldest); + } + self.by_key.insert(key, entry); + self.insertion_order.push_back(key); + (rows, rows_hash) + } + + /// Get or compute the selected indices for the cached rows at the + /// given `line_limit`. Looks up the row entry by `(content_hash, + /// width)` first (the same key used to insert the rows) and then + /// consults the per-line-limit map on that entry. `compute` is + /// invoked only on the first call for a given + /// `(content_hash, width, line_limit)` triple. + fn get_or_compute_indices( + &mut self, + content_hash: u64, + width: u16, + line_limit: usize, + compute: F, + ) -> Vec + where + F: FnOnce() -> Vec, + { + let key = RowsKey { content_hash, width }; + if let Some(entry) = self.by_key.get_mut(&key) + && let Some(indices) = entry.selected_by_limit.get(&line_limit) + { + return indices.clone(); + } + + let indices = compute(); + if let Some(entry) = self.by_key.get_mut(&key) { + entry.selected_by_limit.insert(line_limit, indices.clone()); + } + indices + } +} + +thread_local! { + /// Thread-local cache. The TUI render loop runs on a single thread, + /// so a `!Sync` cache is sufficient and avoids contention with any + /// background workers that might call into the same module. + static GLOBAL_CACHE: RefCell = + RefCell::new(OutputRowsCacheInner::new()); +} + +/// Reset the global cache. Used by tests and `/clear`. +#[cfg(test)] +pub fn reset_for_tests() { + GLOBAL_CACHE.with(|c| *c.borrow_mut() = OutputRowsCacheInner::new()); +} + +/// Look up (or compute) the wrapped output rows for `output` at `width`. +/// Returns a fresh `Vec` plus its `rows_hash`. On a hit the +/// cached value is cloned without re-running the per-line ANSI strip or +/// the wrap pass. +pub fn get_or_compute_rows(output: &str, width: u16, compute: F) -> (Vec, u64) +where + F: FnOnce() -> Vec, +{ + let content_hash = hash_str(output); + GLOBAL_CACHE.with(|c| c.borrow_mut().get_or_compute_rows(content_hash, width, compute)) +} + +/// Look up (or compute) the selected indices for a previously-cached +/// rows payload at the given `line_limit`. `content_hash` is the same +/// 64-bit content hash that was passed to [`get_or_compute_rows`]. +pub fn get_or_compute_indices( + content_hash: u64, + width: u16, + line_limit: usize, + compute: F, +) -> Vec +where + F: FnOnce() -> Vec, +{ + GLOBAL_CACHE.with(|c| { + c.borrow_mut() + .get_or_compute_indices(content_hash, width, line_limit, compute) + }) +} + +/// Cheap 64-bit content hash for a tool output string. +pub fn hash_str(s: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +/// Content hash of an `OutputRow` slice. Computed once on cache miss; +/// reused for the indices-cache key. +fn hash_rows(rows: &[OutputRow]) -> u64 { + let mut hasher = DefaultHasher::new(); + rows.len().hash(&mut hasher); + for row in rows { + row.text.hash(&mut hasher); + row.intact.hash(&mut hasher); + } + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(text: &str) -> OutputRow { + OutputRow { text: text.to_string(), intact: false } + } + + #[test] + fn cache_hit_returns_cached_rows() { + reset_for_tests(); + + let calls = std::cell::Cell::new(0u32); + let compute = || { + calls.set(calls.get() + 1); + vec![row("hello"), row("world")] + }; + + let (a, hash_a) = get_or_compute_rows("payload", 80, compute); + let (b, hash_b) = get_or_compute_rows("payload", 80, || { + calls.set(calls.get() + 1); + vec![row("hello"), row("world")] + }); + assert_eq!(calls.get(), 1, "second call should hit the cache"); + assert_eq!(a, b); + assert_eq!(hash_a, hash_b); + } + + #[test] + fn different_width_invalidates_rows() { + reset_for_tests(); + + let calls = std::cell::Cell::new(0u32); + let make = || { + calls.set(calls.get() + 1); + vec![row("hello")] + }; + + let _ = get_or_compute_rows("payload", 80, make); + let _ = get_or_compute_rows("payload", 120, make); + assert_eq!(calls.get(), 2, "different width must miss the cache"); + } + + #[test] + fn different_output_invalidates_rows() { + reset_for_tests(); + + let calls = std::cell::Cell::new(0u32); + let make = || { + calls.set(calls.get() + 1); + vec![row("x")] + }; + + let _ = get_or_compute_rows("payload-a", 80, make); + let _ = get_or_compute_rows("payload-b", 80, make); + assert_eq!(calls.get(), 2); + } + + #[test] + fn indices_cached_per_line_limit() { + reset_for_tests(); + + let (rows, _rows_hash) = get_or_compute_rows("payload", 80, || { + vec![row("a"), row("b"), row("c"), row("d"), row("e")] + }); + assert_eq!(rows.len(), 5); + + let content_hash = hash_str("payload"); + let mut calls = 0; + let pick_two_a = get_or_compute_indices(content_hash, 80, 2, || { + calls += 1; + vec![0usize, 4] + }); + let pick_two_b = get_or_compute_indices(content_hash, 80, 2, || { + calls += 1; + vec![0usize, 4] + }); + assert_eq!(calls, 1, "second lookup with same limit hits the cache"); + assert_eq!(pick_two_a, pick_two_b); + assert_eq!(pick_two_a, vec![0, 4]); + + // Different line_limit must miss and recompute. + let _ = get_or_compute_indices(content_hash, 80, 3, || { + calls += 1; + vec![0usize, 1, 4] + }); + assert_eq!(calls, 2); + } + + #[test] + fn capacity_evicts_oldest() { + // Build a private cache so we can size it tightly. + let mut cache = OutputRowsCacheInner::with_capacity(2); + + let _ = cache.get_or_compute_rows(1, 80, || vec![row("a")]); + let _ = cache.get_or_compute_rows(2, 80, || vec![row("b")]); + let _ = cache.get_or_compute_rows(3, 80, || vec![row("c")]); + // The first entry (hash 1) should have been evicted. + let mut compute_calls = 0; + let _ = cache.get_or_compute_rows(1, 80, || { + compute_calls += 1; + vec![row("a")] + }); + assert_eq!(compute_calls, 1, "evicted entry must miss"); + } + + #[test] + fn hash_str_stable_for_identical_input() { + assert_eq!(hash_str("hello"), hash_str("hello")); + assert_ne!(hash_str("hello"), hash_str("world")); + } +} From 863f55cc6868b9535e77d8ae9b16a2e71f210c26 Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:06:01 +0800 Subject: [PATCH 010/209] perf(history): simplify output_rows cache API and switch to FNV-1a Three follow-ups to the previous perf commit: 1. Drop the rows_hash field on CacheEntry. The field was computed and stored but never read on the hot path; tests exercised it only to assert the cache returned a stable hash. After this change get_or_compute_rows returns just Vec, halving the tuple-return ABI and removing one DefaultHasher::write pass on every cache miss. 2. Replace DefaultHasher (SipHash) with a hand-rolled FNV-1a 64-bit hash. SipHash is per-process-keyed and ~5-10x slower than FNV on the small-to-medium tool output strings we see at 120 FPS. FNV-1a has no per-process key, fits in 20 lines of pure-Rust, and a 64-bit collision space is more than wide enough for the per-process LRU's expected <= a few hundred entries. The cache is a correctness optimization, not a security boundary; collisions only cause a false miss, never wrong data. 3. Caller in tui::history::render_preserved_output_mode updated to the new Vec-only signature. Two new tests cover the FNV-1a properties (length-suffix sensitivity, empty-input stability). --- crates/tui/src/tui/history.rs | 8 +- crates/tui/src/tui/mod.rs | 2 +- crates/tui/src/tui/output_rows_cache.rs | 98 +++++++++++++++---------- 3 files changed, 65 insertions(+), 43 deletions(-) diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 4ac287e84..c8388f3d8 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -2637,11 +2637,9 @@ fn render_preserved_output_mode( } let content_hash = crate::tui::output_rows_cache::hash_str(output); - let (all_lines, _rows_hash) = crate::tui::output_rows_cache::get_or_compute_rows( - output, - width, - || output_rows(output, width), - ); + let all_lines = crate::tui::output_rows_cache::get_or_compute_rows(output, width, || { + output_rows(output, width) + }); if matches!(mode, RenderMode::Transcript) { // Full-content path: emit every wrapped line with no head/tail split, diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 8be2dc945..804b4b73d 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -45,8 +45,8 @@ pub mod model_picker; pub mod mouse_ui; pub mod notifications; pub mod onboarding; -pub mod output_rows_cache; pub mod osc8; +pub mod output_rows_cache; pub mod pager; pub mod paste; pub mod paste_burst; diff --git a/crates/tui/src/tui/output_rows_cache.rs b/crates/tui/src/tui/output_rows_cache.rs index 441b99c9f..e31644615 100644 --- a/crates/tui/src/tui/output_rows_cache.rs +++ b/crates/tui/src/tui/output_rows_cache.rs @@ -33,9 +33,7 @@ //! - Terminal width changed since the last render. use std::cell::RefCell; -use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, VecDeque}; -use std::hash::{Hash, Hasher}; use crate::tui::history::OutputRow; @@ -51,17 +49,15 @@ const DEFAULT_CAPACITY: usize = 256; #[derive(Debug, Clone)] struct CacheEntry { rows: Vec, - rows_hash: u64, /// Map of `line_limit -> selected indices`. Bounded by the /// distinct line limits passed in by the renderer (typically 1–3). selected_by_limit: HashMap>, } impl CacheEntry { - fn new(rows: Vec, rows_hash: u64) -> Self { + fn new(rows: Vec) -> Self { Self { rows, - rows_hash, selected_by_limit: HashMap::new(), } } @@ -113,18 +109,20 @@ impl OutputRowsCacheInner { content_hash: u64, width: u16, compute: F, - ) -> (Vec, u64) + ) -> Vec where F: FnOnce() -> Vec, { - let key = RowsKey { content_hash, width }; + let key = RowsKey { + content_hash, + width, + }; if let Some(entry) = self.by_key.get(&key) { - return (entry.rows.clone(), entry.rows_hash); + return entry.rows.clone(); } let rows = compute(); - let rows_hash = hash_rows(&rows); - let entry = CacheEntry::new(rows.clone(), rows_hash); + let entry = CacheEntry::new(rows.clone()); if self.by_key.len() >= self.capacity && let Some(oldest) = self.insertion_order.pop_front() @@ -133,7 +131,7 @@ impl OutputRowsCacheInner { } self.by_key.insert(key, entry); self.insertion_order.push_back(key); - (rows, rows_hash) + rows } /// Get or compute the selected indices for the cached rows at the @@ -152,7 +150,10 @@ impl OutputRowsCacheInner { where F: FnOnce() -> Vec, { - let key = RowsKey { content_hash, width }; + let key = RowsKey { + content_hash, + width, + }; if let Some(entry) = self.by_key.get_mut(&key) && let Some(indices) = entry.selected_by_limit.get(&line_limit) { @@ -182,15 +183,17 @@ pub fn reset_for_tests() { } /// Look up (or compute) the wrapped output rows for `output` at `width`. -/// Returns a fresh `Vec` plus its `rows_hash`. On a hit the -/// cached value is cloned without re-running the per-line ANSI strip or -/// the wrap pass. -pub fn get_or_compute_rows(output: &str, width: u16, compute: F) -> (Vec, u64) +/// On a hit the cached `Vec` is cloned without re-running +/// the per-line ANSI strip or the wrap pass. +pub fn get_or_compute_rows(output: &str, width: u16, compute: F) -> Vec where F: FnOnce() -> Vec, { let content_hash = hash_str(output); - GLOBAL_CACHE.with(|c| c.borrow_mut().get_or_compute_rows(content_hash, width, compute)) + GLOBAL_CACHE.with(|c| { + c.borrow_mut() + .get_or_compute_rows(content_hash, width, compute) + }) } /// Look up (or compute) the selected indices for a previously-cached @@ -211,23 +214,29 @@ where }) } -/// Cheap 64-bit content hash for a tool output string. +/// FNV-1a 64-bit content hash. Cheap, no per-process key, and ~5-10× +/// faster than `DefaultHasher` (SipHash) on the small-to-medium tool +/// output strings we see on the render hot path. The cache is a +/// correctness optimization, not a security boundary — a 64-bit collision +/// space is more than wide enough for the per-process LRU's expected +/// ≤ a few hundred entries, and collisions only cause a false miss, +/// never wrong data. pub fn hash_str(s: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - s.hash(&mut hasher); - hasher.finish() -} - -/// Content hash of an `OutputRow` slice. Computed once on cache miss; -/// reused for the indices-cache key. -fn hash_rows(rows: &[OutputRow]) -> u64 { - let mut hasher = DefaultHasher::new(); - rows.len().hash(&mut hasher); - for row in rows { - row.text.hash(&mut hasher); - row.intact.hash(&mut hasher); + /// FNV-1a 64-bit offset basis. + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + /// FNV-1a 64-bit prime. + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + let mut hash = FNV_OFFSET_BASIS; + for byte in s.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); } - hasher.finish() + // Mix the length in last so two strings that share a prefix but + // differ in length (e.g. one has a trailing newline) still collide + // only on truly-identical content. + hash ^= s.len() as u64; + hash.wrapping_mul(FNV_PRIME) } #[cfg(test)] @@ -235,7 +244,10 @@ mod tests { use super::*; fn row(text: &str) -> OutputRow { - OutputRow { text: text.to_string(), intact: false } + OutputRow { + text: text.to_string(), + intact: false, + } } #[test] @@ -248,14 +260,13 @@ mod tests { vec![row("hello"), row("world")] }; - let (a, hash_a) = get_or_compute_rows("payload", 80, compute); - let (b, hash_b) = get_or_compute_rows("payload", 80, || { + let a = get_or_compute_rows("payload", 80, compute); + let b = get_or_compute_rows("payload", 80, || { calls.set(calls.get() + 1); vec![row("hello"), row("world")] }); assert_eq!(calls.get(), 1, "second call should hit the cache"); assert_eq!(a, b); - assert_eq!(hash_a, hash_b); } #[test] @@ -292,7 +303,7 @@ mod tests { fn indices_cached_per_line_limit() { reset_for_tests(); - let (rows, _rows_hash) = get_or_compute_rows("payload", 80, || { + let rows = get_or_compute_rows("payload", 80, || { vec![row("a"), row("b"), row("c"), row("d"), row("e")] }); assert_eq!(rows.len(), 5); @@ -341,4 +352,17 @@ mod tests { assert_eq!(hash_str("hello"), hash_str("hello")); assert_ne!(hash_str("hello"), hash_str("world")); } + + #[test] + fn hash_str_differs_on_length_suffix() { + // A trailing newline is a different content; the hash must differ. + assert_ne!(hash_str("hello"), hash_str("hello\n")); + } + + #[test] + fn hash_str_handles_empty() { + // Empty string hashes to the FNV offset basis; the result just + // needs to be stable. + assert_eq!(hash_str(""), hash_str("")); + } } From 68784cff52f3ed421650b7706a1bf8f94df00e81 Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:16:46 +0800 Subject: [PATCH 011/209] fix(tui): add scroll support to plan prompt modal - Add scroll state field to PlanPromptView with PgUp/PgDn, Ctrl+U/D/F/B, Home/End, gg/G vim-style keybindings - Show scroll indicator footer when content overflows the popup - Add confirming_exit state: Esc while scrolled asks for confirmation before discarding, preventing accidental exits on long plans - Clamp scroll in render() so overscroll doesn't hide bottom options - Use wrapped_line_count() with UnicodeWidthStr for accurate overflow detection with CJK characters - Add 11 unit tests covering scroll, keybindings, and exit confirmation --- crates/tui/src/tui/plan_prompt.rs | 513 ++++++++++++++++++++++++++++-- 1 file changed, 480 insertions(+), 33 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 5ac2da1c3..05c8a9f66 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -1,9 +1,10 @@ //! Modal prompt for selecting what to do after a plan is generated. -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; +use unicode_width::UnicodeWidthStr; use crate::palette; use crate::tools::plan::PlanSnapshot; @@ -25,6 +26,12 @@ const PLAN_OPTIONS: [(&str, &str); 4] = [ ), ]; +/// Shortcut letters for each plan option (used in the footer bar). +const PLAN_SHORTCUTS: [char; 4] = ['a', 'y', 'r', 'q']; + +/// Compact labels for each plan option (used in the footer bar). +const PLAN_SHORT_LABELS: [&str; 4] = ["Accept", "YOLO", "Revise", "Exit"]; + fn modal_block() -> Block<'static> { Block::default() .title(Line::from(vec![Span::styled( @@ -96,13 +103,26 @@ fn push_option_lines( #[derive(Debug, Clone, Default)] pub struct PlanPromptView { selected: usize, + /// Vertical scroll position (in lines). + scroll: usize, + /// Tracks a previous 'g' press for the 'gg' (jump to top) combo. + pending_g: bool, + /// When true, an "are you sure?" prompt is shown instead of the option list + /// because the user pressed Esc after scrolling away from the top. + confirming_exit: bool, /// The plan snapshot to display (if update_plan was called). plan: Option, } impl PlanPromptView { pub fn new(plan: Option) -> Self { - Self { selected: 0, plan } + Self { + selected: 0, + scroll: 0, + pending_g: false, + confirming_exit: false, + plan, + } } fn max_index(&self) -> usize { @@ -118,7 +138,7 @@ impl PlanPromptView { fn submit_number(number: u32) -> ViewAction { if (1..=u32::try_from(PLAN_OPTIONS.len()).unwrap_or(0)).contains(&number) { ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected { - option: usize::try_from(number).unwrap_or(1), + option: usize::from(number), }) } else { ViewAction::None @@ -136,6 +156,27 @@ impl ModalView for PlanPromptView { } fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + // When the "confirm exit" prompt is active, only y / n / Esc matter. + if self.confirming_exit { + return match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed) + } + KeyCode::Char('n') + | KeyCode::Char('N') + | KeyCode::Esc => { + self.confirming_exit = false; + ViewAction::None + } + _ => ViewAction::None, + }; + } + // Clear a pending 'g' when any other key is pressed so the gg combo + // doesn't fire on a stray g followed by, say, an up-arrow 30 s later. + let is_g = matches!(key.code, KeyCode::Char('g')); + if self.pending_g && !is_g { + self.pending_g = false; + } match key.code { KeyCode::Up | KeyCode::Char('k') => { self.selected = self.selected.saturating_sub(1); @@ -173,7 +214,10 @@ impl ModalView for PlanPromptView { self.selected = 2; self.submit_selected() } - KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('e') | KeyCode::Char('E') => { + KeyCode::Char('q') + | KeyCode::Char('Q') + | KeyCode::Char('e') + | KeyCode::Char('E') => { self.selected = 3; self.submit_selected() } @@ -182,7 +226,68 @@ impl ModalView for PlanPromptView { Self::submit_number(number) } KeyCode::Enter => self.submit_selected(), - KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed), + KeyCode::Esc => { + if self.confirming_exit { + // Second Esc: cancel the confirmation prompt. + self.confirming_exit = false; + ViewAction::None + } else if self.scroll > 0 { + // User scrolled; ask for confirmation before discarding. + self.confirming_exit = true; + ViewAction::None + } else { + ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed) + } + } + // Scroll the plan content when it overflows the popup. + KeyCode::PageUp => { + self.scroll = self.scroll.saturating_sub(6); + ViewAction::None + } + KeyCode::PageDown => { + self.scroll = self.scroll.saturating_add(6); + ViewAction::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll = self.scroll.saturating_sub(6); + ViewAction::None + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll = self.scroll.saturating_add(6); + ViewAction::None + } + // Vim-style scroll keys. + KeyCode::Char('g') if self.pending_g => { + // Second g in sequence → jump to top. + self.scroll = 0; + self.pending_g = false; + ViewAction::None + } + KeyCode::Char('g') => { + self.pending_g = true; + ViewAction::None + } + KeyCode::Char('G') => { + // Vim 'G' (Shift+g) → jump to bottom. Render clamps overshoot. + self.scroll = usize::MAX; + ViewAction::None + } + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll = self.scroll.saturating_add(6); + ViewAction::None + } + KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.scroll = self.scroll.saturating_sub(6); + ViewAction::None + } + KeyCode::Home => { + self.scroll = 0; + ViewAction::None + } + KeyCode::End => { + self.scroll = usize::MAX; + ViewAction::None + } _ => ViewAction::None, } } @@ -215,14 +320,15 @@ impl ModalView for PlanPromptView { "Plan steps:", Style::default().fg(palette::DEEPSEEK_SKY).bold(), ))); - for item in &plan.items { + for (i, item) in plan.items.iter().enumerate() { let status_mark = match item.status { crate::tools::plan::StepStatus::Pending => "\u{b7}", crate::tools::plan::StepStatus::InProgress => "\u{25b6}", crate::tools::plan::StepStatus::Completed => "\u{2713}", }; + let step_text = truncate_step(&item.step, 60); lines.push(Line::from(Span::styled( - format!(" {status_mark} {}", item.step), + format!(" {status_mark} {}. {}", i + 1, step_text), Style::default().fg(palette::TEXT_PRIMARY), ))); } @@ -235,32 +341,114 @@ impl ModalView for PlanPromptView { push_option_lines(&mut lines, self.selected == idx, number, label, description); } - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "1-4 / a / y / r / q", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" close", Style::default().fg(palette::TEXT_MUTED)), - ])); - - let paragraph = Paragraph::new(lines) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .block(modal_block()); - let popup_area = centered_rect(72, 52, area); render_modal_chrome(area, popup_area, buf); - paragraph.render(popup_area, buf); + + // Calculate scroll bounds so long plan content doesn't clip the options. + // Use wrapped_line_count to estimate post-wrap line count. + let content_width = usize::from(popup_area.width.saturating_sub(4).max(1)); + let total_lines = wrapped_line_count(&lines, content_width); + let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1); + let max_scroll = total_lines.saturating_sub(visible_lines); + let scroll = self.scroll.min(max_scroll); + + // Build footer: scroll indicator (left) + data-driven option shortcuts + + // description of the currently selected option (right). + let mut footer_spans: Vec = Vec::new(); + if total_lines > visible_lines { + footer_spans.push(Span::styled( + format!(" [{}/{} PgUp/Dn · Ctrl+U/D] ", scroll + 1, max_scroll + 1), + Style::default().fg(palette::DEEPSEEK_SKY), + )); + } + for (idx, _) in PLAN_OPTIONS.iter().enumerate() { + let shortcut = PLAN_SHORTCUTS[idx]; + let short_label = PLAN_SHORT_LABELS[idx]; + let is_current = self.selected == idx; + let shortcut_style = if is_current { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .bold() + } else { + Style::default().fg(palette::DEEPSEEK_SKY) + }; + footer_spans.push(Span::styled( + format!("[{}/{}] {}", idx + 1, shortcut, short_label), + shortcut_style, + )); + footer_spans.push(Span::raw(" ")); + } + // Selected option description, right-aligned by filling space. + let desc = PLAN_OPTIONS[self.selected].1; + let desc_span = Span::styled( + format!(" → {desc}"), + Style::default().fg(palette::TEXT_MUTED), + ); + footer_spans.push(desc_span); + + // When the user pressed Esc after scrolling, show a confirmation prompt + // instead of the normal plan + options. + if self.confirming_exit { + let confirm_lines = vec![ + Line::from(Span::styled( + "Exit without implementing?", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )), + Line::from(""), + Line::from(Span::styled( + "You've scrolled through the plan content. Are you sure you want to exit?", + Style::default().fg(palette::TEXT_PRIMARY), + )), + Line::from(""), + Line::from(Span::styled( + " y — Yes, exit Plan mode", + Style::default().fg(palette::DEEPSEEK_SKY), + )), + Line::from(Span::styled( + " n / Esc — Cancel, go back to plan", + Style::default().fg(palette::TEXT_MUTED), + )), + ]; + let confirm_footer = Line::from(vec![ + Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), + ]); + let popup_area = centered_rect(66, 34, area); + render_modal_chrome(area, popup_area, buf); + let confirm = Paragraph::new(confirm_lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(confirm_footer)); + confirm.render(popup_area, buf); + } else { + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(Line::from(footer_spans))) + .scroll((scroll as u16, 0)); + + paragraph.render(popup_area, buf); + } + } +} + +/// Truncate a plan step description to `max_len` chars, breaking at a word +/// boundary and appending an ellipsis when truncation occurs. +fn truncate_step(text: &str, max_len: usize) -> String { + if text.chars().count() <= max_len { + return text.to_string(); + } + // Walk back from the cutoff to find a natural word boundary. + let cutoff = max_len.saturating_sub(3); // reserve room for "..." + let truncated: String = text.chars().take(cutoff).collect(); + if let Some(last_space) = truncated.rfind(' ') { + format!("{}...", &truncated[..last_space]) + } else { + format!("{truncated}...") } } @@ -310,6 +498,26 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +/// Estimate the number of display lines after word-wrapping a set of logical +/// lines to `width` columns. Uses Unicode display widths so CJK characters +/// count as 2 columns. +fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { + if width == 0 { + return lines.len().max(1); + } + let mut total = 0usize; + for line in lines { + let text: String = line.iter().map(|s| s.content.as_ref()).collect(); + if text.is_empty() { + total += 1; + continue; + } + let display_width = UnicodeWidthStr::width(text.as_str()); + total += (display_width + width - 1) / width; + } + total +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -351,8 +559,9 @@ mod tests { assert!(rendered.contains("Action required")); assert!(rendered.contains("Choose what should happen after this plan.")); - assert!(rendered.contains("1-4")); - assert!(rendered.contains("Enter")); + // Data-driven footer shows per-option shortcut labels. + assert!(rendered.contains("[1/a]")); + assert!(rendered.contains("[4/q]")); } #[test] @@ -365,4 +574,242 @@ mod tests { assert!(rendered.contains("> 2) Accept plan (YOLO)")); assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)")); } + + #[test] + fn plan_prompt_shows_scroll_indicator_when_content_overflows() { + use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; + + let plan = PlanSnapshot { + explanation: Some("A".repeat(500)), + items: vec![PlanItemArg { + step: "Line 1".into(), + status: StepStatus::Pending, + }; 20], + }; + let view = PlanPromptView::new(Some(plan)); + // Render into a small area so content overflows. + let rendered = render_view(&view, 80, 24); + + assert!( + rendered.contains("PgUp/Dn"), + "scroll indicator should appear when content overflows" + ); + } + + #[test] + fn plan_prompt_page_up_decrements_scroll() { + let mut view = PlanPromptView::new(None); + view.scroll = 12; + + let action = view.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_page_down_increments_scroll() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_ctrl_u_decrements_scroll() { + let mut view = PlanPromptView::new(None); + view.scroll = 12; + + let action = view.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_ctrl_d_increments_scroll() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = view.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_scroll_clamped_in_render() { + use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; + + let plan = PlanSnapshot { + explanation: Some("x".repeat(600)), + items: vec![PlanItemArg { + step: "Step".into(), + status: StepStatus::Pending, + }; 30], + }; + let mut view = PlanPromptView::new(Some(plan)); + // Set scroll far beyond content. + view.scroll = 999; + let rendered = render_view(&view, 80, 20); + + // The rendered view should still contain the last option. + assert!( + rendered.contains("Exit Plan mode"), + "clamped scroll should keep last options visible" + ); + } + + #[test] + fn plan_prompt_gg_jumps_to_top() { + let mut view = PlanPromptView::new(None); + view.scroll = 30; + + // First 'g' sets pending flag, no scroll change. + let action = view.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(view.pending_g); + assert_eq!(view.scroll, 30); + + // Second 'g' jumps to top. + let action = view.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(!view.pending_g); + assert_eq!(view.scroll, 0); + } + + #[test] + fn plan_prompt_capital_g_jumps_to_bottom() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = view.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + // set to MAX so render clamps it. + assert_eq!(view.scroll, usize::MAX); + } + + #[test] + fn plan_prompt_ctrl_f_scrolls_down() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = + view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_ctrl_b_scrolls_up() { + let mut view = PlanPromptView::new(None); + view.scroll = 12; + + let action = + view.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 6); + } + + #[test] + fn plan_prompt_home_jumps_to_top() { + let mut view = PlanPromptView::new(None); + view.scroll = 30; + + let action = view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, 0); + } + + #[test] + fn plan_prompt_end_jumps_to_bottom() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = view.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert_eq!(view.scroll, usize::MAX); + } + + #[test] + fn plan_prompt_pending_g_clears_on_other_key() { + let mut view = PlanPromptView::new(None); + view.scroll = 10; + + // Press g → pending. + view.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert!(view.pending_g); + + // Press Up → pending_g cleared, selected moves. + view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(!view.pending_g); + + // Follow-up g should now set pending again, not jump. + view.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)); + assert!(view.pending_g); + assert_eq!(view.scroll, 10); + } + + #[test] + fn plan_prompt_esc_after_scroll_confirms_then_cancels() { + let mut view = PlanPromptView::new(None); + view.scroll = 5; // simulate user having scrolled + + // First Esc: enters confirmation mode, does not close. + let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(view.confirming_exit); + + // 'n' cancels confirmation, returns to plan. + let action = view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(!view.confirming_exit); + } + + #[test] + fn plan_prompt_esc_then_esc_cancels_confirmation() { + let mut view = PlanPromptView::new(None); + view.scroll = 3; + + // Enter confirmation. + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(view.confirming_exit); + + // Second Esc cancels. + let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(!view.confirming_exit); + } + + #[test] + fn plan_prompt_esc_no_scroll_closes_immediately() { + let mut view = PlanPromptView::new(None); + view.scroll = 0; + + let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::EmitAndClose(_))); + } + + #[test] + fn plan_prompt_confirm_then_y_exits() { + let mut view = PlanPromptView::new(None); + view.scroll = 2; + + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let action = view.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::EmitAndClose(_))); + } + + #[test] + fn plan_prompt_other_keys_ignored_during_confirmation() { + let mut view = PlanPromptView::new(None); + view.scroll = 2; + + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(view.confirming_exit); + + // Random key (e.g. 'a') should be ignored — does not submit option. + let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!(matches!(action, ViewAction::None)); + assert!(view.confirming_exit); + } } From 1669e3c12eaf92bfc58e657c83d8c165e3f69c25 Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:44:14 +0800 Subject: [PATCH 012/209] fix(tui): address code review feedback on plan prompt modal - Use word-wrapping-aware line count to prevent underestimating scroll range (gemini-code-assist / greptile-apps) - Merge PLAN_OPTIONS, PLAN_SHORTCUTS, PLAN_SHORT_LABELS into PlanOption struct (gemini-code-assist) - Remove dead Esc code in handle_key (greptile-apps) - Guard gg/G with modifier checks (gemini-code-assist) - Increase PgUp/PgDn scroll amount from 6 to 12 (greptile-apps) - Use u16::try_from for scroll value to avoid silent truncation (greptile-apps) - Update related unit tests for new scroll values --- crates/tui/src/tui/plan_prompt.rs | 192 ++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 65 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 05c8a9f66..932f673ee 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -10,27 +10,39 @@ use crate::palette; use crate::tools::plan::PlanSnapshot; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -const PLAN_OPTIONS: [(&str, &str); 4] = [ - ( - "Accept plan (Agent)", - "Start implementation in Agent mode with approvals", - ), - ( - "Accept plan (YOLO)", - "Start implementation in YOLO mode (auto-approve)", - ), - ("Revise plan", "Ask follow-ups or request plan changes"), - ( - "Exit Plan mode", - "Return to Agent mode without implementation", - ), -]; - -/// Shortcut letters for each plan option (used in the footer bar). -const PLAN_SHORTCUTS: [char; 4] = ['a', 'y', 'r', 'q']; +struct PlanOption { + label: &'static str, + description: &'static str, + shortcut: char, + short_label: &'static str, +} -/// Compact labels for each plan option (used in the footer bar). -const PLAN_SHORT_LABELS: [&str; 4] = ["Accept", "YOLO", "Revise", "Exit"]; +const PLAN_OPTIONS: [PlanOption; 4] = [ + PlanOption { + label: "Accept plan (Agent)", + description: "Start implementation in Agent mode with approvals", + shortcut: 'a', + short_label: "Accept", + }, + PlanOption { + label: "Accept plan (YOLO)", + description: "Start implementation in YOLO mode (auto-approve)", + shortcut: 'y', + short_label: "YOLO", + }, + PlanOption { + label: "Revise plan", + description: "Ask follow-ups or request plan changes", + shortcut: 'r', + short_label: "Revise", + }, + PlanOption { + label: "Exit Plan mode", + description: "Return to Agent mode without implementation", + shortcut: 'q', + short_label: "Exit", + }, +]; fn modal_block() -> Block<'static> { Block::default() @@ -162,9 +174,7 @@ impl ModalView for PlanPromptView { KeyCode::Char('y') | KeyCode::Char('Y') => { ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed) } - KeyCode::Char('n') - | KeyCode::Char('N') - | KeyCode::Esc => { + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.confirming_exit = false; ViewAction::None } @@ -214,10 +224,7 @@ impl ModalView for PlanPromptView { self.selected = 2; self.submit_selected() } - KeyCode::Char('q') - | KeyCode::Char('Q') - | KeyCode::Char('e') - | KeyCode::Char('E') => { + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Char('e') | KeyCode::Char('E') => { self.selected = 3; self.submit_selected() } @@ -227,11 +234,7 @@ impl ModalView for PlanPromptView { } KeyCode::Enter => self.submit_selected(), KeyCode::Esc => { - if self.confirming_exit { - // Second Esc: cancel the confirmation prompt. - self.confirming_exit = false; - ViewAction::None - } else if self.scroll > 0 { + if self.scroll > 0 { // User scrolled; ask for confirmation before discarding. self.confirming_exit = true; ViewAction::None @@ -241,11 +244,11 @@ impl ModalView for PlanPromptView { } // Scroll the plan content when it overflows the popup. KeyCode::PageUp => { - self.scroll = self.scroll.saturating_sub(6); + self.scroll = self.scroll.saturating_sub(12); ViewAction::None } KeyCode::PageDown => { - self.scroll = self.scroll.saturating_add(6); + self.scroll = self.scroll.saturating_add(12); ViewAction::None } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -256,19 +259,30 @@ impl ModalView for PlanPromptView { self.scroll = self.scroll.saturating_add(6); ViewAction::None } - // Vim-style scroll keys. - KeyCode::Char('g') if self.pending_g => { - // Second g in sequence → jump to top. + // Vim-style scroll keys — only pure 'g'/'G' (no Ctrl/Alt). + KeyCode::Char('g') + if self.pending_g + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { self.scroll = 0; self.pending_g = false; ViewAction::None } - KeyCode::Char('g') => { + KeyCode::Char('g') + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { self.pending_g = true; ViewAction::None } - KeyCode::Char('G') => { - // Vim 'G' (Shift+g) → jump to bottom. Render clamps overshoot. + KeyCode::Char('G') + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { self.scroll = usize::MAX; ViewAction::None } @@ -336,9 +350,15 @@ impl ModalView for PlanPromptView { } } - for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() { + for (idx, option) in PLAN_OPTIONS.iter().enumerate() { let number = idx + 1; - push_option_lines(&mut lines, self.selected == idx, number, label, description); + push_option_lines( + &mut lines, + self.selected == idx, + number, + option.label, + option.description, + ); } let popup_area = centered_rect(72, 52, area); @@ -361,9 +381,9 @@ impl ModalView for PlanPromptView { Style::default().fg(palette::DEEPSEEK_SKY), )); } - for (idx, _) in PLAN_OPTIONS.iter().enumerate() { - let shortcut = PLAN_SHORTCUTS[idx]; - let short_label = PLAN_SHORT_LABELS[idx]; + for (idx, option) in PLAN_OPTIONS.iter().enumerate() { + let shortcut = option.shortcut; + let short_label = option.short_label; let is_current = self.selected == idx; let shortcut_style = if is_current { Style::default() @@ -380,7 +400,7 @@ impl ModalView for PlanPromptView { footer_spans.push(Span::raw(" ")); } // Selected option description, right-aligned by filling space. - let desc = PLAN_OPTIONS[self.selected].1; + let desc = PLAN_OPTIONS[self.selected].description; let desc_span = Span::styled( format!(" → {desc}"), Style::default().fg(palette::TEXT_MUTED), @@ -429,7 +449,7 @@ impl ModalView for PlanPromptView { .alignment(Alignment::Left) .wrap(Wrap { trim: true }) .block(modal_block().title_bottom(Line::from(footer_spans))) - .scroll((scroll as u16, 0)); + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); paragraph.render(popup_area, buf); } @@ -499,8 +519,8 @@ fn wrap_text(text: &str, width: usize) -> Vec { } /// Estimate the number of display lines after word-wrapping a set of logical -/// lines to `width` columns. Uses Unicode display widths so CJK characters -/// count as 2 columns. +/// lines to `width` columns. Simulates ratatui's word-wrapping (breaks at word +/// boundaries) and accounts for CJK display widths via `UnicodeWidthStr`. fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { if width == 0 { return lines.len().max(1); @@ -512,8 +532,46 @@ fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { total += 1; continue; } - let display_width = UnicodeWidthStr::width(text.as_str()); - total += (display_width + width - 1) / width; + let leading_spaces = (text.len() - text.trim_start().len()).min(width.saturating_sub(1)); + let mut line_count = 0; + let mut current_width = leading_spaces; + let mut first_word = true; + for word in text.split_whitespace() { + let word_width = UnicodeWidthStr::width(word); + if first_word { + let total_width = leading_spaces + word_width; + if total_width > width { + let lines_needed = (total_width + width - 1) / width; + line_count = lines_needed; + current_width = total_width % width; + if current_width == 0 { + current_width = width; + } + } else { + current_width = total_width; + line_count = 1; + } + first_word = false; + } else if current_width + 1 + word_width > width { + line_count += 1; + if word_width > width { + let lines_needed = (word_width + width - 1) / width; + line_count += lines_needed - 1; + current_width = word_width % width; + if current_width == 0 { + current_width = width; + } + } else { + current_width = word_width; + } + } else { + current_width += 1 + word_width; + } + } + if line_count == 0 { + line_count = 1; + } + total += line_count; } total } @@ -581,10 +639,13 @@ mod tests { let plan = PlanSnapshot { explanation: Some("A".repeat(500)), - items: vec![PlanItemArg { - step: "Line 1".into(), - status: StepStatus::Pending, - }; 20], + items: vec![ + PlanItemArg { + step: "Line 1".into(), + status: StepStatus::Pending, + }; + 20 + ], }; let view = PlanPromptView::new(Some(plan)); // Render into a small area so content overflows. @@ -603,7 +664,7 @@ mod tests { let action = view.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)); assert!(matches!(action, ViewAction::None)); - assert_eq!(view.scroll, 6); + assert_eq!(view.scroll, 0); } #[test] @@ -613,7 +674,7 @@ mod tests { let action = view.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); assert!(matches!(action, ViewAction::None)); - assert_eq!(view.scroll, 6); + assert_eq!(view.scroll, 12); } #[test] @@ -642,10 +703,13 @@ mod tests { let plan = PlanSnapshot { explanation: Some("x".repeat(600)), - items: vec![PlanItemArg { - step: "Step".into(), - status: StepStatus::Pending, - }; 30], + items: vec![ + PlanItemArg { + step: "Step".into(), + status: StepStatus::Pending, + }; + 30 + ], }; let mut view = PlanPromptView::new(Some(plan)); // Set scroll far beyond content. @@ -693,8 +757,7 @@ mod tests { let mut view = PlanPromptView::new(None); view.scroll = 0; - let action = - view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); + let action = view.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); assert!(matches!(action, ViewAction::None)); assert_eq!(view.scroll, 6); } @@ -704,8 +767,7 @@ mod tests { let mut view = PlanPromptView::new(None); view.scroll = 12; - let action = - view.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + let action = view.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); assert!(matches!(action, ViewAction::None)); assert_eq!(view.scroll, 6); } From 14db9b24662bb1138a0d8866c31f1f18d6963044 Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:18:32 +0800 Subject: [PATCH 013/209] fix(tui): avoid spurious exit-confirmation on short plan after scroll key Use clamped (effective) scroll instead of raw `self.scroll` in the Esc handler so a short plan that fits entirely (max_scroll == 0) never triggers the "exit without implementing?" dialog when the user pressed a scroll key (PgDn/Ctrl-D/G/End) beforehand. --- crates/tui/src/tui/plan_prompt.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 932f673ee..3c7af5389 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -1,5 +1,7 @@ //! Modal prompt for selecting what to do after a plan is generated. +use std::cell::Cell; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; @@ -119,6 +121,10 @@ pub struct PlanPromptView { scroll: usize, /// Tracks a previous 'g' press for the 'gg' (jump to top) combo. pending_g: bool, + /// The effective `max_scroll` computed during the last render, used so + /// the Esc handler can check the clamped scroll (not the raw `self.scroll`) + /// and avoid a spurious exit-confirmation on short plans. + last_max_scroll: Cell, /// When true, an "are you sure?" prompt is shown instead of the option list /// because the user pressed Esc after scrolling away from the top. confirming_exit: bool, @@ -132,6 +138,7 @@ impl PlanPromptView { selected: 0, scroll: 0, pending_g: false, + last_max_scroll: Cell::new(0), confirming_exit: false, plan, } @@ -150,7 +157,7 @@ impl PlanPromptView { fn submit_number(number: u32) -> ViewAction { if (1..=u32::try_from(PLAN_OPTIONS.len()).unwrap_or(0)).contains(&number) { ViewAction::EmitAndClose(ViewEvent::PlanPromptSelected { - option: usize::from(number), + option: number as usize, }) } else { ViewAction::None @@ -234,7 +241,9 @@ impl ModalView for PlanPromptView { } KeyCode::Enter => self.submit_selected(), KeyCode::Esc => { - if self.scroll > 0 { + // Use the effective (clamped) scroll from the last render so a + // short plan that fits entirely never triggers a false positive. + if self.scroll.min(self.last_max_scroll.get()) > 0 { // User scrolled; ask for confirmation before discarding. self.confirming_exit = true; ViewAction::None @@ -370,6 +379,7 @@ impl ModalView for PlanPromptView { let total_lines = wrapped_line_count(&lines, content_width); let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1); let max_scroll = total_lines.saturating_sub(visible_lines); + self.last_max_scroll.set(max_scroll); let scroll = self.scroll.min(max_scroll); // Build footer: scroll indicator (left) + data-driven option shortcuts + @@ -815,6 +825,7 @@ mod tests { fn plan_prompt_esc_after_scroll_confirms_then_cancels() { let mut view = PlanPromptView::new(None); view.scroll = 5; // simulate user having scrolled + view.last_max_scroll.set(5); // First Esc: enters confirmation mode, does not close. let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); @@ -831,6 +842,7 @@ mod tests { fn plan_prompt_esc_then_esc_cancels_confirmation() { let mut view = PlanPromptView::new(None); view.scroll = 3; + view.last_max_scroll.set(3); // Enter confirmation. view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); @@ -855,6 +867,7 @@ mod tests { fn plan_prompt_confirm_then_y_exits() { let mut view = PlanPromptView::new(None); view.scroll = 2; + view.last_max_scroll.set(2); view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let action = view.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); @@ -865,6 +878,7 @@ mod tests { fn plan_prompt_other_keys_ignored_during_confirmation() { let mut view = PlanPromptView::new(None); view.scroll = 2; + view.last_max_scroll.set(2); view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(view.confirming_exit); From 537a8bccf33a716026d5666791878238ff70693e Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:17:09 +0800 Subject: [PATCH 014/209] fix(tui): replace manual div_ceil with usize::div_ceil to satisfy clippy lint --- crates/tui/src/tui/plan_prompt.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 3c7af5389..916555ad2 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -551,7 +551,7 @@ fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { if first_word { let total_width = leading_spaces + word_width; if total_width > width { - let lines_needed = (total_width + width - 1) / width; + let lines_needed = total_width.div_ceil(width); line_count = lines_needed; current_width = total_width % width; if current_width == 0 { @@ -565,7 +565,7 @@ fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { } else if current_width + 1 + word_width > width { line_count += 1; if word_width > width { - let lines_needed = (word_width + width - 1) / width; + let lines_needed = word_width.div_ceil(width); line_count += lines_needed - 1; current_width = word_width % width; if current_width == 0 { From 11c448d66ecc1fc771848329dabb9e049dda75bb Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:13:49 +0800 Subject: [PATCH 015/209] fix(plan_prompt): remove step truncation to allow content overflow into scroll region --- crates/tui/src/tui/plan_prompt.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 916555ad2..217bd0f35 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -349,9 +349,8 @@ impl ModalView for PlanPromptView { crate::tools::plan::StepStatus::InProgress => "\u{25b6}", crate::tools::plan::StepStatus::Completed => "\u{2713}", }; - let step_text = truncate_step(&item.step, 60); lines.push(Line::from(Span::styled( - format!(" {status_mark} {}. {}", i + 1, step_text), + format!(" {status_mark} {}. {}", i + 1, &item.step), Style::default().fg(palette::TEXT_PRIMARY), ))); } @@ -466,22 +465,6 @@ impl ModalView for PlanPromptView { } } -/// Truncate a plan step description to `max_len` chars, breaking at a word -/// boundary and appending an ellipsis when truncation occurs. -fn truncate_step(text: &str, max_len: usize) -> String { - if text.chars().count() <= max_len { - return text.to_string(); - } - // Walk back from the cutoff to find a natural word boundary. - let cutoff = max_len.saturating_sub(3); // reserve room for "..." - let truncated: String = text.chars().take(cutoff).collect(); - if let Some(last_space) = truncated.rfind(' ') { - format!("{}...", &truncated[..last_space]) - } else { - format!("{truncated}...") - } -} - /// Wrap text into lines no wider than `width` characters. fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { From 6d79d55b6cd5233233f535a1a0758d72c38ab819 Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:25:53 +0800 Subject: [PATCH 016/209] fix(plan_prompt): use display-width for leading spaces and de-hardcode wrap width - wrapped_line_count: compute leading-space width via UnicodeWidthStr instead of byte length, so non-ASCII leading whitespace is measured correctly. - render: hoist popup_area / content_width computation above plan rendering so wrap_text can share the same content_width derived from the actual popup geometry instead of a magic 68. --- crates/tui/src/tui/plan_prompt.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 217bd0f35..9c6ec6dfd 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -316,6 +316,8 @@ impl ModalView for PlanPromptView { } fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_area = centered_rect(72, 52, area); + let content_width = usize::from(popup_area.width.saturating_sub(4).max(1)); let mut lines: Vec = Vec::new(); lines.push(Line::from(vec![Span::styled( "Action required", @@ -330,7 +332,7 @@ impl ModalView for PlanPromptView { // v0.8.44: render plan details when update_plan was called (#834) if let Some(ref plan) = self.plan { if let Some(ref explanation) = plan.explanation { - for line in wrap_text(explanation, 68) { + for line in wrap_text(explanation, content_width) { lines.push(Line::from(Span::styled( line, Style::default().fg(palette::TEXT_MUTED), @@ -369,12 +371,10 @@ impl ModalView for PlanPromptView { ); } - let popup_area = centered_rect(72, 52, area); render_modal_chrome(area, popup_area, buf); // Calculate scroll bounds so long plan content doesn't clip the options. // Use wrapped_line_count to estimate post-wrap line count. - let content_width = usize::from(popup_area.width.saturating_sub(4).max(1)); let total_lines = wrapped_line_count(&lines, content_width); let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1); let max_scroll = total_lines.saturating_sub(visible_lines); @@ -525,7 +525,8 @@ fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { total += 1; continue; } - let leading_spaces = (text.len() - text.trim_start().len()).min(width.saturating_sub(1)); + let leading_bytes = text.len() - text.trim_start().len(); + let leading_spaces = UnicodeWidthStr::width(&text[..leading_bytes]).min(width.saturating_sub(1)); let mut line_count = 0; let mut current_width = leading_spaces; let mut first_word = true; From 47c071a0d5cf9c018db8ad102bfffb9399b8c7d9 Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:34:18 +0800 Subject: [PATCH 017/209] chore: apply cargo fmt fix to plan_prompt.rs --- crates/tui/src/tui/plan_prompt.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 9c6ec6dfd..e0fdd2199 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -526,7 +526,8 @@ fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { continue; } let leading_bytes = text.len() - text.trim_start().len(); - let leading_spaces = UnicodeWidthStr::width(&text[..leading_bytes]).min(width.saturating_sub(1)); + let leading_spaces = + UnicodeWidthStr::width(&text[..leading_bytes]).min(width.saturating_sub(1)); let mut line_count = 0; let mut current_width = leading_spaces; let mut first_word = true; From e3a52555ebc0050df08156d356ab19dbc4aa291b Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:38:42 +0800 Subject: [PATCH 018/209] fix(plan_prompt): clear pending_g on Esc, deduplicate render_modal_chrome - Clear pending_g when Esc triggers the exit-confirmation prompt so a stray 'g' press does not leak into and survive the confirmation dialog. - Move render_modal_chrome into the else branch so only one call fires per render pass, eliminating a shadow artifact when confirming_exit is active. --- crates/tui/src/tui/plan_prompt.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index e0fdd2199..c03ba4c6a 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -245,6 +245,9 @@ impl ModalView for PlanPromptView { // short plan that fits entirely never triggers a false positive. if self.scroll.min(self.last_max_scroll.get()) > 0 { // User scrolled; ask for confirmation before discarding. + // Clear a stray pending_g so it doesn't leak into the + // confirm dialog and survive a cancel (#). + self.pending_g = false; self.confirming_exit = true; ViewAction::None } else { @@ -371,8 +374,6 @@ impl ModalView for PlanPromptView { ); } - render_modal_chrome(area, popup_area, buf); - // Calculate scroll bounds so long plan content doesn't clip the options. // Use wrapped_line_count to estimate post-wrap line count. let total_lines = wrapped_line_count(&lines, content_width); @@ -454,6 +455,7 @@ impl ModalView for PlanPromptView { .block(modal_block().title_bottom(confirm_footer)); confirm.render(popup_area, buf); } else { + render_modal_chrome(area, popup_area, buf); let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) From 966b5cf1fb2d595ce1428954e26269c71190b8ac Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:12:58 +0800 Subject: [PATCH 019/209] refactor(plan_prompt): use display-width in wrap_text, skip wasted render work - wrap_text: replace chars().count() with UnicodeWidthStr::width() so CJK text is wrapped by display columns, consistent with wrapped_line_count and ratatui's Paragraph::wrap. Also fix the hard-split loop to use exclusive byte ranges (..end) instead of inclusive (..=i) so multi-byte UTF-8 prefixes are always valid. - render: hoist the confirming_exit branch to an early return so the plan-content construction (lines, scroll bounds, footer) is skipped entirely when the confirmation dialog is visible. --- crates/tui/src/tui/plan_prompt.rs | 124 +++++++++++++++++------------- 1 file changed, 70 insertions(+), 54 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index c03ba4c6a..2cb6d7d06 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -319,6 +319,47 @@ impl ModalView for PlanPromptView { } fn render(&self, area: Rect, buf: &mut Buffer) { + // When the user pressed Esc after scrolling, show a confirmation prompt + // instead of the normal plan + options. Render it early so we skip the + // plan-content construction entirely. + if self.confirming_exit { + let confirm_lines = vec![ + Line::from(Span::styled( + "Exit without implementing?", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )), + Line::from(""), + Line::from(Span::styled( + "You've scrolled through the plan content. Are you sure you want to exit?", + Style::default().fg(palette::TEXT_PRIMARY), + )), + Line::from(""), + Line::from(Span::styled( + " y — Yes, exit Plan mode", + Style::default().fg(palette::DEEPSEEK_SKY), + )), + Line::from(Span::styled( + " n / Esc — Cancel, go back to plan", + Style::default().fg(palette::TEXT_MUTED), + )), + ]; + let confirm_footer = Line::from(vec![ + Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), + ]); + let popup_area = centered_rect(66, 34, area); + render_modal_chrome(area, popup_area, buf); + let confirm = Paragraph::new(confirm_lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(confirm_footer)); + confirm.render(popup_area, buf); + return; + } + let popup_area = centered_rect(72, 52, area); let content_width = usize::from(popup_area.width.saturating_sub(4).max(1)); let mut lines: Vec = Vec::new(); @@ -417,53 +458,14 @@ impl ModalView for PlanPromptView { ); footer_spans.push(desc_span); - // When the user pressed Esc after scrolling, show a confirmation prompt - // instead of the normal plan + options. - if self.confirming_exit { - let confirm_lines = vec![ - Line::from(Span::styled( - "Exit without implementing?", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )), - Line::from(""), - Line::from(Span::styled( - "You've scrolled through the plan content. Are you sure you want to exit?", - Style::default().fg(palette::TEXT_PRIMARY), - )), - Line::from(""), - Line::from(Span::styled( - " y — Yes, exit Plan mode", - Style::default().fg(palette::DEEPSEEK_SKY), - )), - Line::from(Span::styled( - " n / Esc — Cancel, go back to plan", - Style::default().fg(palette::TEXT_MUTED), - )), - ]; - let confirm_footer = Line::from(vec![ - Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), - ]); - let popup_area = centered_rect(66, 34, area); - render_modal_chrome(area, popup_area, buf); - let confirm = Paragraph::new(confirm_lines) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .block(modal_block().title_bottom(confirm_footer)); - confirm.render(popup_area, buf); - } else { - render_modal_chrome(area, popup_area, buf); - let paragraph = Paragraph::new(lines) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .block(modal_block().title_bottom(Line::from(footer_spans))) - .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + render_modal_chrome(area, popup_area, buf); + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(Line::from(footer_spans))) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); - paragraph.render(popup_area, buf); - } + paragraph.render(popup_area, buf); } } @@ -481,21 +483,35 @@ fn wrap_text(text: &str, width: usize) -> Vec { let words: Vec<&str> = paragraph.split_whitespace().collect(); let mut current = String::new(); for word in words { - let word_width = word.chars().count(); + let word_width = UnicodeWidthStr::width(word); if word_width > width { if !current.is_empty() { lines.push(current.trim_end().to_string()); current.clear(); } - let mut chars = word.chars(); - loop { - let segment: String = chars.by_ref().take(width).collect(); - if segment.is_empty() { - break; + // Split an over-width word by display width, not code points, + // so CJK characters are measured consistently with + // wrapped_line_count and ratatui's Paragraph::wrap. + let mut remaining = word; + while !remaining.is_empty() { + let mut split_at = 0usize; + for (i, ch) in remaining.char_indices() { + // Use the exclusive byte range [..end) so the prefix is + // always valid UTF-8, even for multi-byte characters. + let end = i + ch.len_utf8(); + if UnicodeWidthStr::width(&remaining[..end]) > width { + break; + } + split_at = end; + } + if split_at == 0 { + // Even one character is wider than width; take it anyway. + split_at = remaining.chars().next().unwrap().len_utf8(); } - lines.push(segment); + lines.push(remaining[..split_at].to_string()); + remaining = &remaining[split_at..]; } - } else if current.chars().count() + 1 + word_width > width { + } else if UnicodeWidthStr::width(current.as_str()) + 1 + word_width > width { lines.push(current.trim_end().to_string()); current.clear(); current.push_str(word); From 88422f3ad39b02a7eb9cec4c4fc2dacfb35354dd Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:44:00 +0800 Subject: [PATCH 020/209] fix(plan_prompt): pre-wrap CJK+Latin mixed text to avoid forced line-breaks at script boundaries Wrap plan steps via wrap_text() before rendering, breaking only on display-width overflow, not on Latin/CJK Unicode word boundaries. Switch main render path from Wrap { trim: true } to Wrap { trim: false } since all content is pre-wrapped. Replace wrapped_line_count() with lines.len() for accurate scroll bounds. Keep confirm-exit dialog on Wrap { trim: true } (English-only, no risk). --- crates/tui/src/tui/plan_prompt.rs | 119 +++++++++--------------------- 1 file changed, 34 insertions(+), 85 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 2cb6d7d06..b8f1fc02b 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -278,26 +278,30 @@ impl ModalView for PlanPromptView { .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.scroll = 0; self.pending_g = false; + self.scroll = 0; ViewAction::None } - KeyCode::Char('g') + KeyCode::Char('G') if !key .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.pending_g = true; + self.scroll = usize::MAX; ViewAction::None } - KeyCode::Char('G') - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { + KeyCode::Home => { + self.scroll = 0; + ViewAction::None + } + KeyCode::End => { self.scroll = usize::MAX; ViewAction::None } + KeyCode::Char('g') => { + self.pending_g = true; + ViewAction::None + } KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll = self.scroll.saturating_add(6); ViewAction::None @@ -306,14 +310,6 @@ impl ModalView for PlanPromptView { self.scroll = self.scroll.saturating_sub(6); ViewAction::None } - KeyCode::Home => { - self.scroll = 0; - ViewAction::None - } - KeyCode::End => { - self.scroll = usize::MAX; - ViewAction::None - } _ => ViewAction::None, } } @@ -395,10 +391,13 @@ impl ModalView for PlanPromptView { crate::tools::plan::StepStatus::InProgress => "\u{25b6}", crate::tools::plan::StepStatus::Completed => "\u{2713}", }; - lines.push(Line::from(Span::styled( - format!(" {status_mark} {}. {}", i + 1, &item.step), - Style::default().fg(palette::TEXT_PRIMARY), - ))); + let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step); + for line in wrap_text(&step_text, content_width) { + lines.push(Line::from(Span::styled( + line, + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } } lines.push(Line::from("")); } @@ -416,8 +415,9 @@ impl ModalView for PlanPromptView { } // Calculate scroll bounds so long plan content doesn't clip the options. - // Use wrapped_line_count to estimate post-wrap line count. - let total_lines = wrapped_line_count(&lines, content_width); + // Since plan steps are now pre-wrapped via wrap_text(), each Line is + // already width-bounded — use the raw line count directly. + let total_lines = lines.len(); let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1); let max_scroll = total_lines.saturating_sub(visible_lines); self.last_max_scroll.set(max_scroll); @@ -428,7 +428,11 @@ impl ModalView for PlanPromptView { let mut footer_spans: Vec = Vec::new(); if total_lines > visible_lines { footer_spans.push(Span::styled( - format!(" [{}/{} PgUp/Dn · Ctrl+U/D] ", scroll + 1, max_scroll + 1), + format!( + " [{}/{} PgUp/Dn \u{b7} Ctrl+U/D] ", + scroll + 1, + max_scroll + 1 + ), Style::default().fg(palette::DEEPSEEK_SKY), )); } @@ -453,15 +457,20 @@ impl ModalView for PlanPromptView { // Selected option description, right-aligned by filling space. let desc = PLAN_OPTIONS[self.selected].description; let desc_span = Span::styled( - format!(" → {desc}"), + format!(" \u{2192} {desc}"), Style::default().fg(palette::TEXT_MUTED), ); footer_spans.push(desc_span); render_modal_chrome(area, popup_area, buf); + // Wrap { trim: false } — disable ratatui's word-boundary-based line + // wrapping. All content is already pre-wrapped via wrap_text() above, + // which breaks only on display-width overflow, not on script boundaries + // (Latin ↔ CJK). This avoids forced line-breaks between English and + // Chinese characters when there is still room on the current line. let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) + .wrap(Wrap { trim: false }) .block(modal_block().title_bottom(Line::from(footer_spans))) .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); @@ -529,66 +538,6 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } -/// Estimate the number of display lines after word-wrapping a set of logical -/// lines to `width` columns. Simulates ratatui's word-wrapping (breaks at word -/// boundaries) and accounts for CJK display widths via `UnicodeWidthStr`. -fn wrapped_line_count(lines: &[Line<'_>], width: usize) -> usize { - if width == 0 { - return lines.len().max(1); - } - let mut total = 0usize; - for line in lines { - let text: String = line.iter().map(|s| s.content.as_ref()).collect(); - if text.is_empty() { - total += 1; - continue; - } - let leading_bytes = text.len() - text.trim_start().len(); - let leading_spaces = - UnicodeWidthStr::width(&text[..leading_bytes]).min(width.saturating_sub(1)); - let mut line_count = 0; - let mut current_width = leading_spaces; - let mut first_word = true; - for word in text.split_whitespace() { - let word_width = UnicodeWidthStr::width(word); - if first_word { - let total_width = leading_spaces + word_width; - if total_width > width { - let lines_needed = total_width.div_ceil(width); - line_count = lines_needed; - current_width = total_width % width; - if current_width == 0 { - current_width = width; - } - } else { - current_width = total_width; - line_count = 1; - } - first_word = false; - } else if current_width + 1 + word_width > width { - line_count += 1; - if word_width > width { - let lines_needed = word_width.div_ceil(width); - line_count += lines_needed - 1; - current_width = word_width % width; - if current_width == 0 { - current_width = width; - } - } else { - current_width = word_width; - } - } else { - current_width += 1 + word_width; - } - } - if line_count == 0 { - line_count = 1; - } - total += line_count; - } - total -} - fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) From 4401f7a2e550c2e1b2dd22614eefee1d6f079ba4 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:31:05 -0700 Subject: [PATCH 021/209] feat(tools): hide legacy subagent and shell aliases from model catalog (#2683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagent aliases: - Legacy names (agent_spawn, agent_result, agent_cancel, resume_agent, agent_list, agent_send_input, agent_assign, agent_wait, delegate_to_agent) are already NOT registered — they exist as dead code with #[allow(dead_code)] since v0.8.33 - Add test verifying model catalog only advertises canonical subagent tools: agent_open, agent_eval, agent_close, tool_agent Shell aliases: - Hide exec_wait from model catalog (legacy alias for exec_shell_wait) - Hide exec_interact from model catalog (legacy alias for exec_shell_interact) - Both remain callable for saved transcript replay - Add test verifying shell aliases are hidden but callable Verification: cargo test -p codewhale-tui --locked (4040 passed), cargo clippy -D warnings --- crates/tui/src/tools/registry.rs | 38 +++++++++++++++++++++ crates/tui/src/tools/shell.rs | 10 ++++++ crates/tui/src/tools/subagent/tests.rs | 47 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b1dd5bd2f..fd4b067db 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -1670,4 +1670,42 @@ mod tests { "task_shell_wait should be included when allow_shell is true" ); } + + /// #2683 — `exec_wait` and `exec_interact` are legacy aliases for + /// `exec_shell_wait` and `exec_shell_interact`. They must remain + /// callable (for saved transcript replay) but hidden from the + /// model-facing catalog. + #[test] + fn shell_alias_tools_hidden_from_model_catalog() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx); + + // Legacy aliases stay callable. + for alias in ["exec_wait", "exec_interact"] { + assert!(registry.contains(alias), "{alias} should remain callable"); + } + + let api_names: Vec = registry + .to_api_tools() + .into_iter() + .map(|tool| tool.name) + .collect(); + + // Canonical names are model-visible. + for canonical in ["exec_shell_wait", "exec_shell_interact"] { + assert!( + api_names.iter().any(|n| n == canonical), + "{canonical} should be model-visible" + ); + } + + // Legacy aliases are hidden. + for alias in ["exec_wait", "exec_interact"] { + assert!( + api_names.iter().all(|n| n != alias), + "{alias} should be hidden from the model catalog" + ); + } + } } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index d3521d198..5d90c5eba 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -2755,6 +2755,11 @@ impl ToolSpec for ShellWaitTool { self.name } + fn model_visible(&self) -> bool { + // `exec_wait` is a legacy alias; only `exec_shell_wait` is model-visible. + self.name == "exec_shell_wait" + } + fn description(&self) -> &'static str { "Wait for a background shell task and return incremental output. Turn cancellation stops waiting but leaves the background task running." } @@ -2836,6 +2841,11 @@ impl ToolSpec for ShellInteractTool { self.name } + fn model_visible(&self) -> bool { + // `exec_interact` is a legacy alias; only `exec_shell_interact` is model-visible. + self.name == "exec_shell_interact" + } + fn description(&self) -> &'static str { "Send input to a background shell task and return incremental output." } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 41d4bf108..097b23237 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2658,3 +2658,50 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } + +/// #2683 — Verify the model-facing tool catalog only advertises canonical +/// subagent tools and never exposes legacy superseded names. +#[test] +fn model_catalog_only_advertises_canonical_subagent_tools() { + use crate::tools::ToolRegistryBuilder; + + let tmp = tempfile::tempdir().expect("tempdir"); + let runtime = stub_runtime(); + let manager = runtime.manager.clone(); + let ctx = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_subagent_tools(manager, runtime) + .build(ctx); + + let api_names: Vec = registry + .to_api_tools() + .into_iter() + .map(|t| t.name) + .collect(); + + // Canonical tools must be model-visible. + for canonical in ["agent_open", "agent_eval", "agent_close", "tool_agent"] { + assert!( + api_names.iter().any(|n| n == canonical), + "{canonical} should be in the model-facing catalog" + ); + } + + // Legacy/superseded names must NOT appear in the model catalog. + for legacy in [ + "agent_spawn", + "agent_result", + "agent_cancel", + "resume_agent", + "agent_list", + "agent_send_input", + "agent_assign", + "agent_wait", + "delegate_to_agent", + ] { + assert!( + api_names.iter().all(|n| n != legacy), + "{legacy} should be hidden from the model-facing catalog" + ); + } +} From 27db89c25d8e30f9f16893007612c95dac9a4d0d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:31:41 -0700 Subject: [PATCH 022/209] docs: update TOOL_SURFACE.md with v0.9.0 hidden-alias table (#2682, #2683) --- docs/TOOL_SURFACE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index fe0c38587..197b1c90d 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -262,6 +262,20 @@ prompting and tool catalogs. Do not use these names in new active guidance: The old one-shot `rlm` model-facing tool is also replaced by persistent `rlm_open` / `rlm_eval` / `rlm_configure` / `rlm_close` sessions. +v0.9.0 adds the following hidden-compat aliases (#2682, #2683): + +| Hidden alias | Canonical replacement | Status | +|---|---|---| +| `todo_write` | `checklist_write` | Hidden, returns `_deprecation` metadata | +| `todo_add` | `checklist_add` | Hidden, returns `_deprecation` metadata | +| `todo_update` | `checklist_update` | Hidden, returns `_deprecation` metadata | +| `todo_list` | `checklist_list` | Hidden, returns `_deprecation` metadata | +| `exec_wait` | `exec_shell_wait` | Hidden, callable for replay | +| `exec_interact` | `exec_shell_interact` | Hidden, callable for replay | + +All hidden aliases remain registered and callable so saved transcripts can +replay without teaching new sessions the deprecated spelling. + Historical compatibility results may include a `_deprecation` block shaped like this: From 7b2a7e513d7377dab259a8a1256271642df7d342 Mon Sep 17 00:00:00 2001 From: jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:24:20 +0800 Subject: [PATCH 023/209] fix: Windows sub-agent completion halves TUI render width Root cause: AgentComplete unconditionally calls resume_terminal() even when the terminal was never paused, causing a secondary EnterAlternateScreen on Windows that creates a new buffer whose width may differ from the window width. Additionally, ColorCompatBackend had no terminal_size cache, so size() fell through to crossterm::terminal::size() which on Windows returns the WinAPI buffer width rather than the window width. Changes: - AgentComplete: add event_broker.is_paused() guard - resume_terminal(): cache real terminal size before reset_viewport - Resize handler: also set terminal_size alongside forced_size - subagent_routing: 3x mark_history_updated -> bump_history_cell(idx) - color_compat: add terminal_size field, set_terminal_size(), fix size() fallback priority (forced_size > terminal_size) - tests: 3 unit tests for size() fallback chain Review feedback addressed: - forced_size now takes priority over terminal_size (gemini-code-assist) - Redundant map lookups removed in subagent_routing (both bots) - set_terminal_size moved before reset_terminal_viewport (greptile-apps) (cherry picked from commit 4463c46644a6e485e7e20dc2b19c29c2e8eb3c5c) --- crates/tui/src/tui/color_compat.rs | 50 ++++++++++++++++++++++++-- crates/tui/src/tui/subagent_routing.rs | 12 ++++--- crates/tui/src/tui/ui.rs | 16 +++++++-- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index cedaea0bf..0a8107ecc 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -43,6 +43,11 @@ pub(crate) struct ColorCompatBackend { /// Forcing the expected size prevents ratatui's internal `autoresize` from /// shrinking the viewport back to the stale dimension inside `draw()`. forced_size: Option, + /// Cached terminal size from `crossterm::terminal::size()`, set after + /// re-entering alt-screen to avoid stale buffer dimensions on Windows. + /// Used as the primary fallback in `size()` before falling through to + /// the live crossterm query. + terminal_size: Option, render_debug: Option, } @@ -59,6 +64,7 @@ impl ColorCompatBackend { // to a community preset. active_ui_theme: UiTheme::detect(), forced_size: None, + terminal_size: None, render_debug: RenderDebugLog::from_env(), } } @@ -71,6 +77,10 @@ impl ColorCompatBackend { self.forced_size = None; } + pub(crate) fn set_terminal_size(&mut self, size: Size) { + self.terminal_size = Some(size); + } + pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) { self.palette_mode = palette_mode; } @@ -152,10 +162,14 @@ impl Backend for ColorCompatBackend { } fn size(&self) -> io::Result { - match self.forced_size { - Some(size) => Ok(size), - None => self.inner.size(), + // forced_size takes priority: it is set during resize events to prevent + // ratatui's autoresize from shrinking the viewport back to a stale + // dimension. terminal_size is the cached real terminal size used as a + // fallback after alt-screen re-entry (Windows buffer width workaround). + if let Some(size) = self.forced_size.or(self.terminal_size) { + return Ok(size); } + self.inner.size() } fn window_size(&mut self) -> io::Result { @@ -496,4 +510,34 @@ mod tests { assert!(body.contains("diff_cells=1"), "{body}"); assert!(body.contains("sample=3:4"), "{body}"); } + + #[test] + fn size_returns_terminal_size_when_set() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + backend.set_terminal_size(Size::new(120, 40)); + assert_eq!(backend.size().unwrap(), Size::new(120, 40)); + } + + #[test] + fn forced_size_takes_priority_over_terminal_size() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + // forced_size is set during resize events to temporarily override the + // cached terminal_size — it must win to prevent viewport shrinking. + backend.set_terminal_size(Size::new(120, 40)); + backend.force_size(Size::new(80, 25)); + assert_eq!(backend.size().unwrap(), Size::new(80, 25)); + } + + #[test] + fn size_falls_back_to_forced_size_when_terminal_size_unset() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + backend.force_size(Size::new(80, 25)); + assert_eq!(backend.size().unwrap(), Size::new(80, 25)); + } } diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index afe48361c..318203b2e 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -113,7 +113,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox { apply_to_fanout(card, message); app.subagent_card_index.insert(agent_id, idx); - app.mark_history_updated(); + app.bump_history_cell(idx); return; } @@ -129,7 +129,9 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox _ => false, }; if updated { - app.mark_history_updated(); + // idx is already in scope from the outer + // `if let Some(&idx) = app.subagent_card_index.get(&agent_id)`. + app.bump_history_cell(idx); } return; } @@ -168,13 +170,13 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); let idx = app.history.len().saturating_sub(1); - app.subagent_card_index.insert(agent_id, idx); + app.subagent_card_index.insert(agent_id.clone(), idx); // Single delegate consumes the pending dispatch label so a follow-on // tool call doesn't accidentally inherit it. app.pending_subagent_dispatch = None; + // idx was just inserted on the line above — no need to re-query. + app.bump_history_cell(idx); } - - app.mark_history_updated(); } pub(super) fn task_mode_label(mode: AppMode) -> &'static str { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fadf..b22779e41 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2161,7 +2161,7 @@ async fn run_event_loop( subagent_elapsed, ); } - if should_recapture_terminal { + if should_recapture_terminal && event_broker.is_paused() { resume_terminal( terminal, app.use_alt_screen, @@ -2694,7 +2694,9 @@ async fn run_event_loop( // this single draw so the buffer matches the real viewport. { let backend = terminal.backend_mut(); - backend.force_size(Size::new(final_w, final_h)); + let new_size = Size::new(final_w, final_h); + backend.force_size(new_size); + backend.set_terminal_size(new_size); } draw_app_frame_inner(terminal, app, true)?; draws_since_last_full_repaint = 0; @@ -7935,6 +7937,16 @@ fn resume_terminal( use_mouse_capture, use_bracketed_paste, ); + // Cache the real terminal size *before* resetting the viewport, so that + // reset_terminal_viewport → terminal.clear() → autoresize() → backend.size() + // picks up the cached size instead of falling through to + // crossterm::terminal::size() which may return stale buffer metadata + // (especially on Windows after a secondary EnterAlternateScreen). + if let Ok((cols, rows)) = crossterm::terminal::size() { + terminal + .backend_mut() + .set_terminal_size(Size::new(cols, rows)); + } reset_terminal_viewport(terminal, sync_output_enabled)?; Ok(()) } From 159f509dd606851d6bfd53617680a0db598eea86 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:38:19 -0700 Subject: [PATCH 024/209] fix(tui): invalidate fanout card rows on sibling starts --- crates/tui/src/tui/subagent_routing.rs | 2 ++ crates/tui/src/tui/ui/tests.rs | 41 +++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 318203b2e..60168d1b4 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -155,6 +155,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox { card.claim_pending_worker(&agent_id, AgentLifecycle::Running); app.subagent_card_index.insert(agent_id, idx); + app.bump_history_cell(idx); } else { let mut card = FanoutCard::new( dispatch_kind.unwrap_or("rlm_eval").to_string(), @@ -165,6 +166,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox let idx = app.history.len().saturating_sub(1); app.last_fanout_card_index = Some(idx); app.subagent_card_index.insert(agent_id, idx); + app.bump_history_cell(idx); } } else { let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5c916fdd4..38d3c6504 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -18,7 +18,7 @@ use crate::tui::footer_ui::{ friendly_subagent_progress, render_footer_from, }; use crate::tui::history::{ - ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, + ExecCell, ExecSource, GenericToolCell, HistoryCell, SubAgentCell, ToolCell, ToolStatus, }; use crate::tui::views::{ModalView, ViewAction}; use crate::working_set::Workspace; @@ -3254,6 +3254,45 @@ fn subagent_token_usage_is_deduped_by_mailbox_sequence() { assert!(app.session.subagent_cost > first); } +#[test] +fn fanout_started_sibling_bumps_existing_card_revision() { + let mut app = create_test_app(); + app.pending_subagent_dispatch = Some("rlm".to_string()); + + handle_subagent_mailbox( + &mut app, + 1, + &crate::tools::subagent::MailboxMessage::Started { + agent_id: "fanout-a".to_string(), + agent_type: "default".to_string(), + }, + ); + + let fanout_idx = app.last_fanout_card_index.expect("fanout card index"); + let initial_revision = app.history_revisions[fanout_idx]; + + handle_subagent_mailbox( + &mut app, + 2, + &crate::tools::subagent::MailboxMessage::Started { + agent_id: "fanout-b".to_string(), + agent_type: "default".to_string(), + }, + ); + + assert_eq!(app.history.len(), 1, "sibling should reuse fanout card"); + assert_ne!( + app.history_revisions[fanout_idx], initial_revision, + "reused fanout card must invalidate its cached transcript rows" + ); + match &app.history[fanout_idx] { + HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => { + assert_eq!(card.worker_count(), 2); + } + cell => panic!("expected fanout card, got {cell:?}"), + } +} + #[test] fn format_token_count_compact_formats_units() { assert_eq!(format_token_count_compact(999), "999"); From 850278421812c921c27087268e828d71919561a1 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:41:36 -0700 Subject: [PATCH 025/209] fix(xiaomi-mimo): use token-plan api-key auth Harvests the MiMo Token Plan auth-header behavior from #2627 while keeping Xiaomi env-key precedence unchanged so standard endpoints do not accidentally receive a Token Plan key. Harvested from PR #2627 by @xyuai. Co-authored-by: xyuai <281015099+xyuai@users.noreply.github.com> --- crates/tui/src/client.rs | 135 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 3824c1bb3..4f727de8d 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -602,7 +602,8 @@ impl DeepSeekClient { retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay )); - let http_client = Self::build_http_client(&api_key, &http_headers)?; + let http_client = + Self::build_http_client(&api_key, &http_headers, api_provider, &base_url)?; Ok(Self { http_client, @@ -620,8 +621,10 @@ impl DeepSeekClient { fn build_http_client( api_key: &str, extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, ) -> Result { - let headers = build_default_headers(api_key, extra_headers)?; + let headers = build_default_headers(api_key, extra_headers, api_provider, base_url)?; let mut builder = reqwest::Client::builder() .default_headers(headers) .user_agent(concat!( @@ -651,21 +654,52 @@ impl DeepSeekClient { api_key: &str, extra_headers: &HashMap, ) -> Result { - build_default_headers(api_key, extra_headers) + build_default_headers( + api_key, + extra_headers, + ApiProvider::Deepseek, + crate::config::DEFAULT_DEEPSEEK_BASE_URL, + ) + } + + #[cfg(test)] + fn default_headers_for_provider( + api_key: &str, + extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, + ) -> Result { + build_default_headers(api_key, extra_headers, api_provider, base_url) } } fn build_default_headers( api_key: &str, extra_headers: &HashMap, + api_provider: ApiProvider, + base_url: &str, ) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if !api_key.trim().is_empty() { - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {api_key}"))?, - ); + let api_key = api_key.trim(); + let auth_header_name = if !api_key.is_empty() + && api_provider == ApiProvider::XiaomiMimo + && (xiaomi_mimo_base_url_uses_token_plan(base_url) + || xiaomi_mimo_api_key_uses_token_plan(api_key)) + { + Some(HeaderName::from_static("api-key")) + } else if !api_key.is_empty() { + Some(AUTHORIZATION) + } else { + None + }; + if let Some(header_name) = auth_header_name.as_ref() { + let header_value = if *header_name == AUTHORIZATION { + HeaderValue::from_str(&format!("Bearer {api_key}"))? + } else { + HeaderValue::from_str(api_key)? + }; + headers.insert(header_name.clone(), header_value); } for (name, value) in extra_headers { let name = name.trim(); @@ -674,7 +708,10 @@ fn build_default_headers( continue; } let header_name = HeaderName::from_bytes(name.as_bytes())?; - if header_name == AUTHORIZATION || header_name == CONTENT_TYPE { + if header_name == AUTHORIZATION + || header_name == CONTENT_TYPE + || auth_header_name.as_ref() == Some(&header_name) + { continue; } headers.insert(header_name, HeaderValue::from_str(value)?); @@ -682,6 +719,24 @@ fn build_default_headers( Ok(headers) } +fn xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool { + let normalized = base_url.trim().to_ascii_lowercase(); + let without_scheme = normalized + .strip_prefix("https://") + .or_else(|| normalized.strip_prefix("http://")) + .unwrap_or(&normalized); + let host = without_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + let host = host.split(':').next().unwrap_or(host); + host.starts_with("token-plan-") && host.ends_with(".xiaomimimo.com") +} + +fn xiaomi_mimo_api_key_uses_token_plan(api_key: &str) -> bool { + api_key.trim_start().starts_with("tp-") +} + impl DeepSeekClient { /// Returns the API base URL used by this client. pub fn base_url(&self) -> &str { @@ -1628,6 +1683,68 @@ mod tests { assert!(headers.get("x-blank").is_none()); } + #[test] + fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() { + let headers = DeepSeekClient::default_headers_for_provider( + "tp-test", + &HashMap::new(), + ApiProvider::XiaomiMimo, + crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers.get("api-key").and_then(|value| value.to_str().ok()), + Some("tp-test") + ); + assert!( + headers.get(AUTHORIZATION).is_none(), + "Token Plan requires api-key instead of Authorization Bearer" + ); + } + + #[test] + fn xiaomi_mimo_tp_key_uses_api_key_header_with_custom_base_url() { + let mut extra = HashMap::new(); + extra.insert("api-key".to_string(), "wrong".to_string()); + extra.insert("Authorization".to_string(), "Bearer wrong".to_string()); + let headers = DeepSeekClient::default_headers_for_provider( + "tp-custom", + &extra, + ApiProvider::XiaomiMimo, + "https://proxy.example.test/mimo/v1", + ) + .expect("headers"); + + assert_eq!( + headers.get("api-key").and_then(|value| value.to_str().ok()), + Some("tp-custom") + ); + assert!( + headers.get(AUTHORIZATION).is_none(), + "tp-* Token Plan keys should use api-key auth even through custom gateways" + ); + } + + #[test] + fn xiaomi_mimo_pay_as_you_go_endpoint_keeps_bearer_header() { + let headers = DeepSeekClient::default_headers_for_provider( + "sk-test", + &HashMap::new(), + ApiProvider::XiaomiMimo, + crate::config::XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, + ) + .expect("headers"); + + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer sk-test") + ); + assert!(headers.get("api-key").is_none()); + } + #[test] fn chat_messages_keep_current_turn_reasoning_content() { let message = Message { From 44ceabd606165e0af570437cc2304733bc609b3b Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:52:44 -0700 Subject: [PATCH 026/209] docs: refresh README and v0.9 execution map --- README.md | 465 +++++++++++++---------------------- docs/V0_9_0_EXECUTION_MAP.md | 128 ++++++++++ 2 files changed, 297 insertions(+), 296 deletions(-) create mode 100644 docs/V0_9_0_EXECUTION_MAP.md diff --git a/README.md b/README.md index 9b7a60ace..40bb08a63 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,62 @@ # CodeWhale -> Terminal coding agent for DeepSeek V4. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and includes an auto mode that chooses both model and thinking level per turn. +> DeepSeek-first terminal coding agent with a durable harness: approval-gated +> local edits, sub-agents, provider/model routing, live verification, rollback, +> and a v0.9 track for typed WhaleFlow workflows. [简体中文 README](README.zh-CN.md) [日本語 README](README.ja-JP.md) [Tiếng Việt README](README.vi.md) +[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) +[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) +[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) + +![codewhale screenshot](assets/screenshot.png) + +## What CodeWhale Does + +CodeWhale is a terminal-native coding harness for agentic model work. It gives +the model a durable prompt constitution, a typed tool surface, approval gates, +side-git rollback, LSP feedback after edits, cost/cache telemetry, and +concurrent sub-agents that can investigate or implement without blocking the +parent turn. + +It is DeepSeek-first, not DeepSeek-only. The default path targets DeepSeek V4, +while provider routes such as OpenRouter, NVIDIA NIM, Arcee, Xiaomi MiMo, +SiliconFlow, Fireworks, OpenAI-compatible gateways, self-hosted SGLang/vLLM, and +Hugging Face stay explicit. Provider, model, base URL, and credentials are +separate choices so direct-provider APIs do not get blurred with OpenRouter +aliases. + +The active v0.9.0 lane turns that harness into a workflow workbench: +WhaleFlow typed branch/leaf workflows, deterministic replay, pod-style workflow +monitoring, provider/model posture, and evidence-backed profile evolution. The +current execution map lives in +[docs/V0_9_0_EXECUTION_MAP.md](docs/V0_9_0_EXECUTION_MAP.md). + +## Quickstart + +```bash +npm install -g codewhale +codewhale --version +codewhale --model auto +``` + +On first launch, CodeWhale prompts for a DeepSeek API key and saves it to +`~/.codewhale/config.toml`; the legacy `~/.deepseek/config.toml` path is still +read for compatibility. You can also set credentials directly: + +```bash +codewhale auth set --provider deepseek +codewhale auth status +codewhale doctor +``` + +Use `/provider`, `/model`, `/config`, `/statusline`, `/skills`, and `/restore` +inside the TUI. Prefix a composer line with `!` to run a shell command through +the normal approval and sandbox path, for example `! cargo test -p codewhale-tui`. ## Install @@ -67,177 +118,114 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` -> codewhale update now supports --proxy, update through a proxy -> eg: codewhale update --proxy https://localhost:7897 - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) - -![codewhale screenshot](assets/screenshot.png) +`codewhale update --proxy https://localhost:7897` routes update checks and +downloads through a proxy. --- -## What Is It? - -A model answers a question. An agent finishes a task. The difference is -the harness — a system of rules, evidence, and feedback that keeps the -model oriented instead of drifting. - -CodeWhale is that harness, built around DeepSeek V4 and guided by three ideas: +## Harness Model -| Principle | How it works | -|---|---| -| **Start with trust** | Every turn begins with "A" — possibility before certainty, craft before convenience | -| **Clear jurisdiction** | A written Constitution with nine tiers of authority. User intent outranks stale instructions. Verification outranks confidence. | -| **Recursive improvement** | V4 helped write the harness. As the harness improves, V4 becomes more effective — and helps improve the harness further. Each turn starts stronger. | - -It's open source, terminal-native, and packaged as a matched `codewhale` / -`codewhale-tui` Rust binary pair. - -## How the Harness Works - -Agentic models deal with conflicting information at scale: user intent, -project rules, system defaults, tool output, and stale memory all compete -for authority in a single turn. LLM-as-a-judge needs jurisdiction — which -source wins when they disagree? - -CodeWhale answers this with a **Constitution** (`prompts/base.md`). It's a -formal hierarchy of law — Article VII ranks nine tiers from the -Constitution's own articles down to prior-session handoffs. The user's -current message outranks stale project instructions. Live tool output -outranks assumptions. Verification outranks confidence. The model inherits -a clear chain of authority every turn and never has to guess which -directive to follow. - -Six Articles define the model's identity, duties, and agency (Article VII -is the hierarchy itself): a verification mandate (Article V — every action -leaves evidence, never declare success on faith), a coordination legacy -(Article VI — leave the workspace cleaner and the handoff truthful for the -next intelligence), and a primacy-of-truth clause (Article II — -non-negotiable; not even a user request may override the duty of truth). - -DeepSeek V4's prefix caching makes this practical. The Constitution is long -and detailed, but once cached it costs roughly 100× less per turn than a -cold read. The model references it recursively — peeking, scanning, and -querying through RLM sessions — revisiting information on demand rather -than relying on a single memorized pass. It performs more like an -open-book test than a closed one. - -Because the authority structure is explicit, failure isn't hidden. Non-zero -exit codes, type errors from rust-analyzer arriving between turns, sandbox -denials — these are fed back as correction vectors. The model uses its own -drift to self-correct. - -Three modes control the action space. Plan is read-only. Agent gates -destructive operations behind approval. YOLO auto-approves in trusted -workspaces. macOS Seatbelt is the active sandbox; Linux Landlock is -detected but not yet enforced; Windows sandboxing is not yet advertised. - -Fin — a cheap Flash call with thinking off — handles model auto-routing per -turn. `--model auto` is the default. - -Every turn records a side-git snapshot outside your repo's `.git`. -`/restore` and `revert_turn` roll back the workspace. - -Sub-agents run concurrently (up to 20). `agent_open` returns immediately; -results arrive inline as completion sentinels with a summary. Full -transcripts stay behind bounded handles through `agent_eval`. See -[docs/SUBAGENTS.md](docs/SUBAGENTS.md). - -The rest of the surface: LSP diagnostics after every edit (rust-analyzer, -pyright, typescript-language-server, gopls, clangd, jdtls, -vue-language-server), RLM sessions for batched analysis, MCP protocol, -HTTP/SSE runtime API, persistent task queue, ACP adapter for Zed, -SWE-bench export, and live cost tracking with cache hit/miss breakdowns. +A model answers a question. An agent finishes a task. The difference is the +harness: the rules, tools, evidence, and feedback that keep the model oriented +when user intent, repo instructions, tool output, stale memory, and prior +handoffs all compete inside one turn. ---- +CodeWhale's harness has four practical parts: -## The Harness +| Part | What it does | +| --- | --- | +| Prompt constitution | `crates/tui/src/prompts/base.md` gives the model a stable authority hierarchy: live user intent beats stale instructions, live tool output beats assumptions, and verification beats confidence. | +| Typed tool surface | Shell, file, git, web, MCP, RLM, image, and sub-agent tools are registered with explicit schemas, visibility rules, and compatibility aliases. | +| Runtime evidence loop | Side-git snapshots, LSP diagnostics, command output, cost/cache accounting, and task state are fed back into the transcript instead of hidden behind the UI. | +| Approval and sandbox posture | Plan is read-only, Agent uses approval gates, and YOLO auto-approves in trusted workspaces. macOS Seatbelt is enforced; Linux Landlock is detected but not yet enforced; Windows sandboxing is not advertised. | -`codewhale` (dispatcher CLI) → `codewhale-tui` (companion binary) → ratatui interface ↔ async engine ↔ OpenAI-compatible streaming client. Tool calls route through a typed registry (shell, file ops, git, web, sub-agents, MCP, RLM) and results stream back into the transcript. The engine manages session state, turn tracking, the durable task queue, and an LSP subsystem that feeds post-edit diagnostics into the model's context before the next reasoning step. +`codewhale` is the dispatcher CLI. `codewhale-tui` is the companion runtime +binary it launches for interactive sessions. The TUI talks to an async engine, +an OpenAI-compatible streaming client, the tool registry, the durable task +queue, the LSP subsystem, and optional HTTP/SSE or ACP servers. See +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full walkthrough. -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full walkthrough. +### Auto Model Routing -### Sub-agents: Concurrent Background Execution +`--model auto` is the default. Before the real turn is sent, CodeWhale makes a +small `deepseek-v4-flash` routing call with thinking off. That local router +selects the concrete model and thinking level for the real request: -CodeWhale can dispatch multiple sub-agents that run in parallel — like a concurrent task queue: +- Model: `deepseek-v4-flash` or `deepseek-v4-pro` +- Thinking: `off`, `high`, or `max` -- **Non-blocking launch.** `agent_open` returns immediately. The child gets its own fresh context and tool registry and runs independently. The parent keeps working. -- **Background execution.** Sub-agents execute concurrently (default cap: 10, configurable to 20). The engine manages the pool — no polling loop needed. -- **Completion notification.** When a sub-agent finishes, the runtime injects a `` sentinel into the parent's transcript. The human-readable summary — including the child's findings, changed files, and any risks — sits on the line immediately before the sentinel. The parent model reads that summary and integrates findings without an extra tool call. -- **Bounded result retrieval.** The full child transcript lives behind a `transcript_handle` accessible through `agent_eval`. When the summary isn't enough, the parent calls `handle_read` for slices, line ranges, or JSONPath projections — keeping the parent context lean without losing access to the details. +The upstream API never receives `model: "auto"`; it receives the concrete route +chosen for that turn. Use a fixed model or thinking level for repeatable +benchmarking, strict cost ceilings, or exact provider/model mapping. -See [docs/SUBAGENTS.md](docs/SUBAGENTS.md) for the full sub-agent reference. +### Sub-agents ---- +Sub-agents run concurrently in the background. `agent_open` returns immediately; +the child receives its own context and tool registry, then reports back with a +completion sentinel and a human-readable summary. The full child transcript +stays behind a bounded handle that the parent can inspect through `agent_eval`. -## Quickstart +Default concurrency is 10 and configurable up to 20. See +[docs/SUBAGENTS.md](docs/SUBAGENTS.md) for role taxonomy, lifecycle, wait/eval +tools, and transcript-handle details. -```bash -npm install -g codewhale -codewhale --version -codewhale --model auto -``` +## Provider Routes -Prebuilt binary pairs and platform archives are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). +For the full provider registry, model IDs, auth variables, base URLs, and +capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). -On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` also supported) so it works from any directory without OS credential prompts. +Provider and model are deliberately separate choices. `provider` is the route, +account, endpoint, and credential source; `model` is the model ID on that route. +That distinction matters when the same model family appears through direct APIs +and OpenRouter aliases. -You can also set it ahead of time: +| Provider | Typical model IDs | Notes | +| --- | --- | --- | +| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash` | Default direct DeepSeek route. | +| `openrouter` | `deepseek/deepseek-v4-pro`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3` | OpenRouter route; keep these IDs distinct from direct provider IDs. | +| `arcee` | `trinity-large-thinking`, `trinity-large-preview`, `trinity-mini` | Direct Arcee API at `https://api.arcee.ai/api/v1`. | +| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5`, TTS IDs | Token Plan keys (`tp-...`) use `api-key` auth and default to the Token Plan endpoint; pay-as-you-go keys can set the MiMo API endpoint explicitly. | +| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro` | Uses NVIDIA account terms and model IDs. | +| `siliconflow` / `siliconflow-CN` | `deepseek-ai/DeepSeek-V4-Pro` | SiliconFlow global and China routes. | +| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | Fireworks route. | +| `openai` | Your gateway's model ID | Generic OpenAI-compatible endpoint. | +| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro` | Hugging Face router route. | +| `sglang`, `vllm`, `ollama` | Local model IDs/tags | Self-hosted routes. | ```bash -codewhale auth set --provider deepseek # saves to ~/.codewhale/config.toml -codewhale auth status # shows the active credential source - -export DEEPSEEK_API_KEY="YOUR_KEY" # env var alternative; use ~/.zshenv for non-interactive shells -codewhale - -codewhale doctor # verify setup -``` - -If `codewhale doctor` says the rejected key came from `DEEPSEEK_API_KEY`, remove -the stale export from your shell startup file, open a fresh shell, or run -`codewhale auth set --provider deepseek`. Use `codewhale auth status` to see the -config, keyring, and env-var source state without printing the key. Saved config -keys take precedence over the keyring and environment and are easier to rotate. - -> To rotate or remove a saved key: `codewhale auth clear --provider deepseek`. - -### Tencent Cloud / CNB Remote-First Path - -For an always-on workspace you can control from a phone, use the Tencent-native -path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection -bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API -stays bound to localhost; EdgeOne is not used to expose `/v1/*`. - -Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), -then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the -server runbook. - -### Auto Mode - -Use `codewhale --model auto` or `/model auto` when you want codewhale to decide how much model and reasoning power a turn needs. +codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" +codewhale --provider openrouter --model deepseek/deepseek-v4-pro -Auto mode controls two settings together: +codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY" +codewhale --provider arcee --model trinity-large-thinking -- Model: `deepseek-v4-flash` or `deepseek-v4-pro` -- Thinking: `off`, `high`, or `max` +codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" +codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav -Before the real turn is sent, the app makes a small `deepseek-v4-flash` routing call with thinking off. That router looks at the latest request and recent context, then selects a concrete model and thinking level for the real request. Short/simple turns can stay on Flash with thinking off; coding, debugging, release work, architecture, security review, or ambiguous multi-step tasks can move up to Pro and/or higher thinking. +codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" +OPENAI_BASE_URL="https://openai-compatible.example/v4" \ + codewhale --provider openai --model glm-5 -`auto` is local to codewhale. The upstream API never receives `model: "auto"`; it receives the concrete model and thinking setting chosen for that turn. The TUI shows the selected route, and cost tracking is charged against the model that actually ran. If the router call fails or returns an invalid answer, the app falls back to a local heuristic. Sub-agents inherit auto mode unless you assign them an explicit model. +SGLANG_BASE_URL="http://localhost:30000/v1" \ + codewhale --provider sglang --model deepseek-v4-flash +``` -Use a fixed model or fixed thinking level when you want repeatable benchmarking, a strict cost ceiling, or a specific provider/model mapping. +Inside the TUI, `/provider` opens the provider picker and `/model` opens the +model/thinking picker. `/models` fetches live API model lists when the active +provider supports listing. -### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC) +## Platform Notes -`npm i -g codewhale` works on glibc-based ARM64 Linux from v0.8.8 onward. You can also download prebuilt binaries from the [Releases page](https://github.com/Hmbown/CodeWhale/releases) and place them side by side on your `PATH`. +Prebuilt binary pairs and platform archives are published for Linux x64, Linux +ARM64, macOS x64, macOS ARM64, and Windows x64. For other targets, see +[docs/INSTALL.md](docs/INSTALL.md). ### China / Mirror-friendly Installation -If GitHub or npm downloads are slow from mainland China, use a Cargo registry mirror: +If GitHub or npm downloads are slow from mainland China, use +`npm install -g codewhale --registry=https://registry.npmmirror.com`, download +from GitHub Releases, or configure a Cargo registry mirror: ```toml # ~/.cargo/config.toml @@ -248,37 +236,38 @@ replace-with = "tuna" registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/" ``` -Then install both binaries (the dispatcher delegates to the TUI at runtime): +Then install both binaries: ```bash -cargo install codewhale-cli --locked # provides `codewhale` -cargo install codewhale-tui --locked # provides `codewhale-tui` +cargo install codewhale-cli --locked +cargo install codewhale-tui --locked codewhale --version ``` -Prebuilt binaries can also be downloaded from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. +Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. -### Windows (Scoop) +### Windows -[Scoop](https://scoop.sh) is a Windows package manager. The `codewhale` package is listed -in Scoop's main bucket, but that manifest updates independently and can lag the -GitHub/npm/Cargo release. Run `scoop update` first, then verify the installed -version with `codewhale --version`: +The Scoop `codewhale` manifest can lag GitHub/npm/Cargo releases. Run +`scoop update` first, then verify with `codewhale --version`. Use npm or direct +GitHub release downloads when you need the newest release immediately. -```bash -scoop update -scoop install codewhale -codewhale --version -``` +### Remote-first Workspaces -Use npm or direct GitHub release downloads when you need the newest release -before Scoop's manifest catches up. +For an always-on workspace you can control from a phone, use the Tencent-native +path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection +bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API +stays bound to localhost; EdgeOne is not used to expose `/v1/*`. +Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), +then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the +server runbook.
Install from source -Works on any Tier-1 Rust target — including musl, riscv64, FreeBSD, and older ARM64 distros. +Works on any Tier-1 Rust target including musl, riscv64, FreeBSD, and older +ARM64 distros. ```bash # Linux build deps (Debian/Ubuntu/RHEL): @@ -288,137 +277,15 @@ Works on any Tier-1 Rust target — including musl, riscv64, FreeBSD, and older git clone https://github.com/Hmbown/CodeWhale.git cd CodeWhale -cargo install --path crates/cli --locked # requires Rust 1.88+; provides `codewhale` -cargo install --path crates/tui --locked # provides `codewhale-tui` +cargo install --path crates/cli --locked +cargo install --path crates/tui --locked ``` -Both binaries are required. Cross-compilation and platform-specific notes: [docs/INSTALL.md](docs/INSTALL.md). +Both binaries are required. Rust 1.88+ is required because the crates use the +2024 edition.
-### Other API Providers - -For the full shipped provider registry, including model IDs, auth variables, -base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). - -Think of provider and model as separate choices: `provider` is the route, -account, and endpoint; `model` is the model ID on that route. DeepSeek-family -models can be reached through several routes, so `/config` exposes both -`provider` and `provider_url`. - -| Route | Typical DeepSeek model ID | -|-------|---------------------------| -| `deepseek` | `deepseek-v4-pro` | -| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro` | -| `openrouter` | `deepseek/deepseek-v4-pro` | -| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | -| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro` | -| `openai` | Your gateway's model ID | -| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro` | - -```bash -# NVIDIA NIM -codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" -codewhale --provider nvidia-nim - -# AtlasCloud -codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" -codewhale --provider atlascloud -codewhale --provider atlascloud --model vendor/model-id - -# Wanjie Ark -codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" -codewhale --provider wanjie-ark --model deepseek-reasoner - -# OpenRouter -codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" -codewhale --provider openrouter --model deepseek/deepseek-v4-pro -codewhale --provider openrouter --model arcee-ai/trinity-large-thinking -codewhale --provider openrouter --model minimax/minimax-m3 - -Arcee AI offers direct API access to its powerful Trinity models, including the reasoning-capable Trinity-Large Thinking. This section provides comprehensive setup instructions and model comparisons. - -## Configuration - -### API Key -The primary authentication method is the `ARCEE_API_KEY` environment variable or the `[providers.arcee]` configuration section in `~/.codewhale/config.toml`: - -```toml -[providers.arcee] -# api_key = "your-arcee-api-key" -# base_url = "https://api.arcee.ai/api/v1" -# model = "trinity-large-thinking" # or "trinity-large-preview", "trinity-mini" -``` - -### Environment Variables - -- `ARCEE_API_KEY`: Your Arcee API key (required) -- `ARCEE_BASE_URL`: Custom base URL (optional, defaults to `https://api.arcee.ai/api/v1`) -- `ARCEE_MODEL`: Default model to use (optional, defaults to `trinity-large-thinking`) - -### Model Support - -CodeWhale supports three Arcee models: - -| Model | Reasoning | Context Window | Max Output | Best For | -|--------|-----------|----------------|------------|----------| -| `trinity-large-thinking` | ✅ Yes | 262,144 tokens | 262,144 tokens | Complex reasoning, coding, math | -| `trinity-large-preview` | ❌ No | 262,144 tokens | 4,096 tokens | High-accuracy non-reasoning tasks | -| `trinity-mini` | ❌ No | 128,000 tokens | 4,096 tokens | Faster, cost-effective tasks | - -**Note:** The `trinity-large-thinking` model supports reasoning (thinking mode) and can handle very large contexts, making it ideal for complex programming tasks. The other models do not support reasoning but offer larger context windows than many other providers. -codewhale auth set --provider arcee --api-key "YOUR_ARCEE_API_KEY" -codewhale --provider arcee --model trinity-large-thinking -codewhale --provider arcee --model trinity-large-preview - -# Xiaomi MiMo -codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" -# Token Plan (`tp-...`) keys default to https://token-plan-sgp.xiaomimimo.com/v1. -# To force a provider endpoint: /config provider_url token-plan --save -# or /config provider_url pay-as-you-go --save. -codewhale --provider xiaomi-mimo --model mimo-v2.5-pro -codewhale --provider xiaomi-mimo --model mimo-v2.5 -codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav - -# Novita -codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" -codewhale --provider novita --model deepseek/deepseek-v4-pro - -# Fireworks -codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" -codewhale --provider fireworks --model deepseek-v4-pro - -# SiliconFlow -codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY" -codewhale --provider siliconflow --model deepseek-ai/DeepSeek-V4-Pro - -# Generic OpenAI-compatible endpoint -codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" -OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 - -# Custom DeepSeek-compatible endpoint -DEEPSEEK_BASE_URL="https://your-provider.example/v1" \ - DEEPSEEK_MODEL="deepseek-ai/DeepSeek-V4-Pro" \ - codewhale --provider deepseek - -# Self-hosted SGLang -SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash - -# Self-hosted vLLM -VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash -# Trusted LAN vLLM over HTTP -DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash - -# Self-hosted Ollama -ollama pull codewhale-coder:1.3b -codewhale --provider ollama --model codewhale-coder:1.3b -``` - -Inside the TUI, `/provider` opens the provider picker and `/model` opens the -local model/thinking picker. `/provider openrouter` and `/model ` switch -directly, while `/models` explicitly fetches and lists live API models when the -active provider supports model listing. - --- ## Release Notes @@ -499,7 +366,7 @@ volume ownership notes, and non-interactive pipeline usage. ### Zed / ACP -DeepSeek can run as a custom Agent Client Protocol server for editors that +CodeWhale can run as a custom Agent Client Protocol server for editors that spawn local ACP agents over stdio. In Zed, add a custom agent server: ```json @@ -608,19 +475,24 @@ Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_AL --- -## Models & Pricing - -| Model | Context | Input (cache hit) | Input (cache miss) | Output | -|---|---|---|---|---| -| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M | -| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M | +## Models & Cost Tracking -DeepSeek Platform defaults to `https://api.deepseek.com/beta` so beta-gated API features can be tested without extra setup. Set `base_url = "https://api.deepseek.com"` to opt out. +CodeWhale tracks the provider route, concrete model, prompt-cache hit/miss +estimate, input tokens, and output tokens for the turn that actually ran. Auto +mode is resolved before the upstream request, so the footer and session summary +charge against `deepseek-v4-flash`, `deepseek-v4-pro`, or the explicit provider +model selected for that turn. -Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` and retire after July 24, 2026. NVIDIA NIM variants use your NVIDIA account terms. +Pricing changes over time and can vary by account, region, provider route, and +promotion. Use [docs/PROVIDERS.md](docs/PROVIDERS.md) for supported model IDs +and the provider's official pricing pages for billing decisions. Treat the TUI +cost display as a local estimate, not a receipt. -> [!Note] -> DeepSeek's pricing page now lists the V4 Pro rates above as the permanent prices: the previous 75% promotional discount has been folded into a one-quarter base-rate adjustment as the promotion window closes on 15:59 UTC on 31 May 2026. The TUI cost estimator already uses these values, so no behavioural change is required. For any future price changes, consult the official [DeepSeek pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing). +DeepSeek Platform defaults to `https://api.deepseek.com/beta` so beta-gated API +features can be tested without extra setup. Set `base_url = +"https://api.deepseek.com"` to opt out. Legacy aliases `deepseek-chat` / +`deepseek-reasoner` remain compatibility shims; prefer V4 model IDs for new +config. NVIDIA NIM variants use your NVIDIA account terms. --- @@ -678,6 +550,7 @@ without recreating skills the user deliberately deleted. | [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process | | [LOCALIZATION.md](docs/LOCALIZATION.md) | UI locale matrix & switching | | [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery | +| [V0_9_0_EXECUTION_MAP.md](docs/V0_9_0_EXECUTION_MAP.md) | v0.9.0 issue lanes, PR harvest state, and release gates | Full Changelog: [CHANGELOG.md](CHANGELOG.md). diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md new file mode 100644 index 000000000..35fd866f2 --- /dev/null +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -0,0 +1,128 @@ +# v0.9.0 Execution Map + +Snapshot date: 2026-06-04 + +This map tracks the v0.9.0 integration branch and keeps the open-PR harvest +separate from release publishing. It is a working document: update it whenever a +PR is harvested, superseded, deferred, or closed. + +## Live Counts + +- Actual open issues: 444 +- Open PRs: 54 +- Repo API open issue count: 498, because GitHub includes PRs in that total +- Open issues labeled `v0.9.0`: 119 +- Open issues without a milestone: 100 + +## Execution Order + +1. Stabilization and PR harvest: finish #2721 and #2722 before new feature work. +2. Provider/model/auth correctness: land narrow correctness fixes that match the + current provider architecture. +3. File decomposition Phase 1: split safe, test-covered config/provider and TUI + view surfaces before adding larger workflow UX. +4. WhaleFlow MVP: typed IR, executor skeleton, replay, and pod monitor before + teacher/student promotion loops. +5. Model Lab and HarnessProfile MVP: Hugging Face polish and provider/model + posture before automatic harness creation. +6. Release readiness: keep #2729 current and do not tag or publish without + maintainer approval. + +## Current Branch Harvest + +Branch: `codex/v0.9.0-stewardship` + +The branch contains the previous 22-commit v0.9.0 stack plus these fresh +harvest/stewardship commits: + +| PR | Disposition | Evidence / next step | +| --- | --- | --- | +| #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | +| #2634 HarmonyOS port | Defer direct merge; draft has broad platform and TLS/runtime blast radius. | Harvest at most the unused `rustyline` cleanup after local verification; full port needs OHOS target checks and sandbox/security review. | +| #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | + +## PR Harvest Queue + +| PR | State | v0.9.0 disposition | +| --- | --- | --- | +| #1865 Pro Plan mode | Conflicting | Likely superseded by HarnessProfile/model-posture lane; review before closing. | +| #1893 TLS certificate verification toggle | Conflicting | Security-sensitive; review separately, not part of first v0.9 harvest. | +| #2045 NSIS installer and classroom checklist | Conflicting | Defer unless release-readiness needs Windows installer work. | +| #2048 live shell output | Mergeable | Review against current exec/tool card behavior before merge. | +| #2113 independent scroll regions | Conflicting | Defer; likely overlaps current transcript/sidebar work. | +| #2239 i18n Phase 1-4b | Conflicting | Defer until localization lane. | +| #2242 typed persistent tool permission rules | Conflicting | Compare with #2721 stabilization and permissions model. | +| #2256 workspace crate consolidation | Conflicting | Do not merge during v0.9 stabilization. | +| #2269 approval details and shell previews | Conflicting | Review for small UI harvest only. | +| #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. | +| #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. | +| #2476 fork migration parent links | Conflicting | Prior memory says safe candidate; verify against current state before closure/harvest. | +| #2479 ProviderKind/ApiProvider trait collapse | Conflicting | Defer until file decomposition Phase 1 reduces config surface. | +| #2482 WhaleFlow orchestration | Draft/conflicting | Inspect for IR ideas; do not merge wholesale. | +| #2486 WhaleFlow cost tracking | Draft/conflicting | Inspect after #2482; harvest telemetry ideas only. | +| #2491 typed ask permissions schema | Conflicting | Prior memory says safe candidate; verify current permissions work first. | +| #2498 Windows shell process trees | Conflicting | Prior memory says safe candidate; review for #2721 stabilization. | +| #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | +| #2502 web_run RwLock split | Mergeable | Review lock/panic safety before merge. | +| #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | +| #2506 provider path suffix overrides | Draft/conflicting | Partly superseded by current provider path-suffix support; verify. | +| #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | +| #2508 configurable path suffix | Conflicting | Likely superseded by #2506/current code; verify linked issue #2089. | +| #2509 parallel read-only web search | Mergeable | Review for tool-execution scheduler invariants. | +| #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | +| #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | +| #2512 custom completion sounds | Draft/conflicting | Defer. | +| #2513 restore snapshot listing | Draft/mergeable | Review as small UX polish. | +| #2517 turn_meta tail relocation | Mergeable | Already in high-priority harvest list; review prompt/cache implications. | +| #2520 prompt base disk cache | Mergeable | Review after #2687 prompt architecture decision. | +| #2522 hard compaction preserving system segment | Mergeable | Review after #2687 prompt architecture decision. | +| #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | +| #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | +| #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | +| #2530 mention depth cap hint | Draft/mergeable | Small UX candidate. | +| #2576 PrefixCacheChange events | Mergeable | Review after current prefix-cache commits. | +| #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | +| #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | +| #2581 provider fallback chain design doc | Mergeable | Docs-only; review for current provider direction. | +| #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | +| #2627 Xiaomi MiMo Token Plan mode | Conflicting | Partially harvested; leave original open or comment with remaining mode/env scope once branch is public. | +| #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | +| #2632 tool-catalog JSON cache | Mergeable | Already harvested into the 22-commit stack. | +| #2633 capacity reverse scans | Mergeable | Already harvested into the 22-commit stack. | +| #2634 HarmonyOS port | Draft/mergeable | Defer broad port. Review found global TLS/provider-install risk, OHOS clipboard/test cfg issues, and major sandbox/process-security degradations. | +| #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | +| #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | +| #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | +| #2640 workspace field on UpdateThreadRequest | Mergeable | Defer; app-server contract needs focused review. | +| #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | +| #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | +| #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | +| #2730 canonical codewhale settings path | Mergeable | Already harvested into the 22-commit stack. | +| #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | + +## Issue Reduction Strategy + +Issue count should drop through evidence-backed consolidation, not bulk closing. + +- Close fixed issues only after the v0.9 integration branch is pushed or merged + and the relevant tests/checks are named in the closure comment. +- Close obsolete release-harvest PRs/issues after verifying no unique commits or + linked reports remain. +- Supersede older OPENCODE, memory, web, VS Code, and cache-maximalism tickets + into the current v0.9 lanes when their acceptance criteria are now covered by + #2667, #2720-#2729, or a narrower current issue. +- Remove or defer `v0.9.0` scope from valid but non-release-critical roadmap + issues instead of closing them. +- Always credit PR authors, issue reporters, and useful reviewers when a + contributor branch is harvested. + +## Immediate Next Actions + +1. Review #2048, #2502, #2509, #2513, #2530, #2576, and #2581 as the next small + mergeable candidates. +2. Prepare public comments for #2708, #2627, #2634, #2636, #2687, and already-harvested performance + PRs once this integration branch has a remote review surface. +3. Start file decomposition Phase 1 only after the PR harvest table has no + unknown high-priority provider/prompt/cache branches. From 5dc1a63cd413c8c07d5fd3ce8551a20da3f66b13 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:55:14 -0700 Subject: [PATCH 027/209] docs: harvest provider fallback chain RFC Harvested from PR #2581 by @idling11. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- README.md | 1 + docs/V0_9_0_EXECUTION_MAP.md | 5 +- docs/rfcs/2574-provider-fallback-chain.md | 167 ++++++++++++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 docs/rfcs/2574-provider-fallback-chain.md diff --git a/README.md b/README.md index 40bb08a63..c36be8e19 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,7 @@ without recreating skills the user deliberately deleted. | [LOCALIZATION.md](docs/LOCALIZATION.md) | UI locale matrix & switching | | [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery | | [V0_9_0_EXECUTION_MAP.md](docs/V0_9_0_EXECUTION_MAP.md) | v0.9.0 issue lanes, PR harvest state, and release gates | +| [2574-provider-fallback-chain.md](docs/rfcs/2574-provider-fallback-chain.md) | Provider fallback chain RFC | Full Changelog: [CHANGELOG.md](CHANGELOG.md). diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 35fd866f2..ae0ff8799 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -42,6 +42,7 @@ harvest/stewardship commits: | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Defer direct merge; draft has broad platform and TLS/runtime blast radius. | Harvest at most the unused `rustyline` cleanup after local verification; full port needs OHOS target checks and sandbox/security review. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | +| #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | ## PR Harvest Queue @@ -85,7 +86,7 @@ harvest/stewardship commits: | #2576 PrefixCacheChange events | Mergeable | Review after current prefix-cache commits. | | #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | -| #2581 provider fallback chain design doc | Mergeable | Docs-only; review for current provider direction. | +| #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. | | #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | | #2627 Xiaomi MiMo Token Plan mode | Conflicting | Partially harvested; leave original open or comment with remaining mode/env scope once branch is public. | | #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | @@ -120,7 +121,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Review #2048, #2502, #2509, #2513, #2530, #2576, and #2581 as the next small +1. Review #2048, #2502, #2509, #2513, #2530, and #2576 as the next small mergeable candidates. 2. Prepare public comments for #2708, #2627, #2634, #2636, #2687, and already-harvested performance PRs once this integration branch has a remote review surface. diff --git a/docs/rfcs/2574-provider-fallback-chain.md b/docs/rfcs/2574-provider-fallback-chain.md new file mode 100644 index 000000000..7cdeef6e2 --- /dev/null +++ b/docs/rfcs/2574-provider-fallback-chain.md @@ -0,0 +1,167 @@ +# RFC: Provider Fallback Chain + +**Issue:** #2574 +**Reporter:** @hsdbeebou +**Design source:** #2581 by @idling11 +**Status:** Draft for the v0.9 provider-routing lane +**Date:** 2026-06-04 + +## Problem + +CodeWhale can store credentials and defaults for several providers, but a +running session uses one active provider route at a time. When that provider +hits a rate limit, temporary outage, or transport failure, the user must notice +the failure, run `/provider`, choose another route, and resubmit the turn. + +That manual switch is especially disruptive during long-running agentic work. +A provider fallback chain can keep work moving, but it also changes billing +source, model behavior, tool support, context-window limits, and vendor +expectations. The design must make that switch explicit and capability-aware. + +## Principles + +- Fallback is opt-in. No provider switch happens unless the user configured a + fallback chain. +- Billing and vendor changes are visible in the transcript and status UI. +- Normal retry policy runs before fallback. +- Fallback is allowed only before assistant content or tool calls have started + streaming for the failing request. +- Fallback candidates must support the request shape for the current turn. +- Authentication, authorization, malformed request, and model-not-found errors + do not silently switch providers by default. + +## Proposed Config Shape + +Keep the existing root `provider = "..."` setting as the primary route. Add an +ordered fallback list and a small policy section: + +```toml +provider = "nvidia-nim" +fallback_providers = ["deepseek", "openrouter"] + +[provider_fallback] +enabled = true +reset_on_new_session = true +``` + +Rules: + +- `fallback_providers` is ordered and contains provider IDs already accepted by + the provider parser. +- The primary provider is not repeated in the fallback list. +- Duplicate fallback providers are rejected. +- Missing credentials produce a startup warning and make that fallback entry + inactive until credentials appear. +- If `provider_fallback.enabled` is absent, the presence of a non-empty + `fallback_providers` list enables fallback. + +## Fallback Eligibility + +| Failure | Fallback by default? | Notes | +| --- | --- | --- | +| HTTP 429 | Yes | Rate limit or quota exhaustion on the active route. | +| HTTP 502, 503, 504 | Yes | Temporary upstream failure after normal retries. | +| Connect timeout / DNS failure | Yes | Transport path failed before content streamed. | +| HTTP 401 / 403 | No | Usually bad credentials or account permissions. | +| HTTP 400 | No | Usually client request shape or model parameter issue. | +| Model not found | No | Avoid silently switching model families unless a future policy explicitly opts in. | +| Stream interrupted after content | No | The transcript may already contain partial assistant content or tool-call deltas. | + +The first implementation should classify errors centrally and expose tests for +each case before any fallback execution is wired into the turn loop. + +## Capability Gate + +Before switching to a fallback provider/model, CodeWhale checks that the +candidate can support the current request shape: + +| Requirement | Gate | +| --- | --- | +| Tool calls | Candidate provider/model must support tool calling. | +| Reasoning effort | Candidate must support the requested thinking mode, or the switch is blocked. | +| Context size | Candidate context window must fit the estimated current request. | +| Image inputs | Candidate must support vision if the turn includes images. | +| Provider-specific headers | Candidate request must be rebuilt from that provider's own auth/base-url/header rules. | + +If no fallback candidate passes the gate, CodeWhale surfaces the original +provider error with a clear "fallback chain exhausted or incompatible" note. + +## Runtime Behavior + +1. Build the request for the active provider. +2. Run existing retry policy for that provider. +3. If retries exhaust with a fallback-eligible failure and no assistant content + has streamed, evaluate the next fallback provider. +4. Rebuild the request with the fallback provider's model, base URL, auth, and + provider-specific headers. +5. Add a visible transcript marker and status event before the fallback request + starts. +6. Continue through the chain until a provider succeeds, the chain is + exhausted, or a non-eligible failure occurs. + +Suggested transcript marker: + +```text +[provider fallback: nvidia-nim -> deepseek, reason: rate_limit] +``` + +Suggested status text: + +```text +NVIDIA NIM unavailable; switched to DeepSeek fallback +``` + +For multi-request turns, such as tool-call result follow-ups, fallback can be +considered for a later request only if that later request has not started +streaming assistant content yet. The transcript marker must identify that the +turn changed provider between requests. + +## UI and Commands + +- `/provider` should show the primary route and the current fallback position. +- `/provider reset` should return to the primary provider for future requests in + the current session. +- The footer/statusline should surface the concrete provider/model that actually + handled the latest request. +- Session receipts should record both attempted provider and successful + provider so cost and debugging information stay truthful. + +## Implementation Slices + +1. Config schema and validation: + - parse `fallback_providers` and `[provider_fallback]` + - validate known providers, duplicates, missing credentials, and primary + self-reference + - document the config surface +2. Error classification: + - define fallback-eligible error kinds + - add unit tests for HTTP and transport failures +3. Request-shape capability gate: + - evaluate tool, thinking, context, and image requirements + - add tests for incompatible fallbacks +4. Fallback execution: + - run retries per provider before moving to the next provider + - rebuild auth/base-url/header state for each candidate + - block fallback after partial streaming +5. UI/receipt integration: + - status event + - transcript marker + - `/provider reset` + - receipt fields for attempted and selected provider + +## Non-goals + +- No automatic cost optimization or weighted provider selection. +- No silent fallback when authentication or permissions fail. +- No fallback after partial assistant content or tool-call deltas have streamed. +- No provider/model capability downgrades without an explicit future policy. +- No sub-agent-specific fallback policy in the first implementation; sub-agents + inherit the same configured fallback chain unless they are given an explicit + provider/model override. + +## Credit + +This RFC is based on issue #2574 from @hsdbeebou and PR #2581 from @idling11. +The original PR head currently has no net file changes, so this document +preserves the useful design direction while tightening the v0.9 contract around +truthful provider routing, billing visibility, and capability checks. From 111a805eb81bb7906e7bc2f810aacfba2866e84a Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 19:56:48 -0700 Subject: [PATCH 028/209] docs: mark mention depth hint harvested --- docs/V0_9_0_EXECUTION_MAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index ae0ff8799..88d5afe1a 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -43,6 +43,7 @@ harvest/stewardship commits: | #2634 HarmonyOS port | Defer direct merge; draft has broad platform and TLS/runtime blast radius. | Harvest at most the unused `rustyline` cleanup after local verification; full port needs OHOS target checks and sandbox/security review. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | +| #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | ## PR Harvest Queue @@ -82,7 +83,7 @@ harvest/stewardship commits: | #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | | #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | -| #2530 mention depth cap hint | Draft/mergeable | Small UX candidate. | +| #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. | | #2576 PrefixCacheChange events | Mergeable | Review after current prefix-cache commits. | | #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | @@ -121,7 +122,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Review #2048, #2502, #2509, #2513, #2530, and #2576 as the next small +1. Review #2048, #2502, #2509, #2513, and #2576 as the next small mergeable candidates. 2. Prepare public comments for #2708, #2627, #2634, #2636, #2687, and already-harvested performance PRs once this integration branch has a remote review surface. From 311eb4002bd9862dca27119fde02cf35331d51a5 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:00:41 -0700 Subject: [PATCH 029/209] feat(tui): add bounded restore snapshot listing Harvested from PR #2513 by @cyq1017. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 13 ++ crates/tui/src/commands/mod.rs | 2 +- crates/tui/src/commands/restore.rs | 231 +++++++++++++++++++++++++---- docs/MODES.md | 3 +- 4 files changed, 222 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dec9b971b..ed5e141db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `/restore list [N]` so users can inspect more side-git rollback + snapshots with UTC timestamps before choosing a restore point. Plain + `/restore` now shows the 20 most recent snapshots, numeric restore targets can + reach beyond that default listing up to a bounded index, and list requests + above the visible cap fail explicitly instead of silently truncating. + +### Community + +Thanks to **@cyq1017** for the restore-listing implementation (#2513) and +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494). + ## [0.8.53] - 2026-06-03 ### Added diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9a953be62..8a84cd22e 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -495,7 +495,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "restore", aliases: &[], - usage: "/restore [N]", + usage: "/restore [N|list [N]]", description_id: MessageId::CmdRestoreDescription, }, // RLM command diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/restore.rs index 8ea3540e5..d737e7594 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/restore.rs @@ -1,19 +1,23 @@ //! `/restore` slash command — roll back the workspace to a prior snapshot. //! -//! `/restore` (no arg) lists the most recent snapshots so the user can -//! see what's available. `/restore ` restores the *N*th-most-recent -//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to -//! mutate files unless the user has explicitly trusted the workspace -//! (`/trust on` or YOLO) — the user can always view the list, just not -//! one-shot revert without a safety net. +//! `/restore` (no arg) lists the 20 most recent snapshots so the user can +//! see what's available. `/restore list [N]` lists more snapshots, capped +//! at 100. `/restore ` restores the *N*th-most-recent snapshot, where +//! `N=1` is the newest. In non-YOLO mode we refuse to mutate files unless +//! the user has explicitly trusted the workspace (`/trust on` or YOLO) — +//! the user can always view the list, just not one-shot revert without a +//! safety net. use super::CommandResult; -use crate::snapshot::SnapshotRepo; +use crate::snapshot::{Snapshot, SnapshotRepo}; use crate::tui::app::App; +use chrono::TimeZone; -const LIST_LIMIT: usize = 10; +const DEFAULT_LIST_LIMIT: usize = 20; +const MAX_LIST_LIMIT: usize = 100; +const MAX_RESTORE_INDEX: usize = 1000; -/// Entry point for `/restore [N]`. +/// Entry point for `/restore [N|list [N]]`. pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { let workspace = app.workspace.clone(); let repo = match SnapshotRepo::open_or_init(&workspace) { @@ -26,29 +30,51 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { } }; - let snapshots = match repo.list(LIST_LIMIT) { - Ok(s) => s, - Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), - }; - - if snapshots.is_empty() { - return CommandResult::message( - "No snapshots yet. Send a message to create the first pre-turn snapshot.", - ); - } - let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else { + let snapshots = match repo.list(DEFAULT_LIST_LIMIT) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } return CommandResult::message(format_listing(&snapshots)); }; + if let Some(limit) = match parse_list_arg(arg) { + Ok(limit) => limit, + Err(message) => return CommandResult::error(message), + } { + let snapshots = match repo.list(limit) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } + return CommandResult::message(format_listing(&snapshots)); + } + let n: usize = match arg.parse() { - Ok(n) if n >= 1 => n, + Ok(n) if (1..=MAX_RESTORE_INDEX).contains(&n) => n, + Ok(n) if n > MAX_RESTORE_INDEX => { + return CommandResult::error(format!( + "Restore index must be <= {MAX_RESTORE_INDEX}; got {n}. Use /restore list [N] to inspect snapshots first.", + )); + } _ => { return CommandResult::error(format!( - "Usage: /restore (N is 1-based; got '{arg}')", + "Usage: /restore or /restore list [N] (N is 1-based; got '{arg}')", )); } }; + let snapshots = match repo.list(n.max(DEFAULT_LIST_LIMIT)) { + Ok(s) => s, + Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")), + }; + if snapshots.is_empty() { + return no_snapshots_message(); + } if n > snapshots.len() { return CommandResult::error(format!( @@ -81,12 +107,49 @@ pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult { )) } -fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String { - let mut out = String::from("Recent snapshots (newest first; pass /restore to revert):\n"); +fn parse_list_arg(arg: &str) -> Result, String> { + let mut parts = arg.split_whitespace(); + let action = match parts.next() { + Some(action) => action, + None => return Ok(None), + }; + if action != "list" { + return Ok(None); + } + let Some(value) = parts.next() else { + return Ok(Some(DEFAULT_LIST_LIMIT)); + }; + if parts.next().is_some() { + return Err(format!( + "Usage: /restore list [N] (got extra arguments in '{arg}')", + )); + } + match value.parse::() { + Ok(limit @ 1..=MAX_LIST_LIMIT) => Ok(Some(limit)), + Ok(limit) if limit > MAX_LIST_LIMIT => Err(format!( + "Restore list limit must be <= {MAX_LIST_LIMIT}; got {limit}.", + )), + _ => Err(format!( + "Usage: /restore list [N] (N must be >= 1; got '{value}')", + )), + } +} + +fn no_snapshots_message() -> CommandResult { + CommandResult::message( + "No snapshots yet. Send a message to create the first pre-turn snapshot.", + ) +} + +fn format_listing(snapshots: &[Snapshot]) -> String { + let mut out = String::from( + "Recent snapshots (newest first; pass /restore to revert; /restore list 50 shows more):\n", + ); for (i, s) in snapshots.iter().enumerate() { out.push_str(&format!( - " #{:<2} {} {}\n", + " #{:<2} {} {} {}\n", i + 1, + format_snapshot_time(s.timestamp), short_sha(s.id.as_str()), s.label, )); @@ -94,6 +157,13 @@ fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String { out } +fn format_snapshot_time(timestamp: i64) -> String { + match chrono::Utc.timestamp_opt(timestamp, 0).single() { + Some(dt) => dt.format("%Y-%m-%d %H:%M UTC").to_string(), + None => "unknown time".to_string(), + } +} + fn short_sha(sha: &str) -> &str { &sha[..sha.len().min(8)] } @@ -195,6 +265,117 @@ mod tests { assert!(msg.contains("#2")); } + #[test] + fn restore_lists_more_than_ten_snapshots_by_default() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + for i in 0..12 { + std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + + let result = restore(&mut app, None); + let msg = result.message.expect("expected message"); + assert!(msg.contains("#12"), "{msg}"); + assert!(msg.contains("turn:0"), "{msg}"); + } + + #[test] + fn restore_listing_includes_snapshot_utc_time() { + let snapshots = [Snapshot { + id: crate::snapshot::SnapshotId("abcdef123456".to_string()), + label: "turn:demo".to_string(), + timestamp: 1_700_000_000, + }]; + + let msg = format_listing(&snapshots); + + assert!(msg.contains("2023-11-14 22:13 UTC"), "{msg}"); + assert!(msg.contains("abcdef12"), "{msg}"); + assert!(msg.contains("turn:demo"), "{msg}"); + } + + #[test] + fn restore_list_subcommand_accepts_explicit_limit() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + for i in 0..15 { + std::fs::write(app.workspace.join("a.txt"), format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + + let result = restore(&mut app, Some("list 12")); + let msg = result.message.expect("expected message"); + assert!(msg.contains("#12"), "{msg}"); + assert!(!msg.contains("#13"), "{msg}"); + } + + #[test] + fn restore_list_subcommand_rejects_invalid_limit() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("list nope")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /restore list [N]")); + } + + #[test] + fn restore_list_subcommand_rejects_limit_above_cap() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("list 101")); + assert!(result.is_error); + assert!( + result + .message + .unwrap() + .contains("Restore list limit must be <= 100") + ); + } + + #[test] + fn restore_numeric_index_can_target_beyond_default_listing() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap(); + let f = app.workspace.join("a.txt"); + for i in 0..12 { + std::fs::write(&f, format!("v{i}")).unwrap(); + repo.snapshot(&format!("turn:{i}")).unwrap(); + } + std::fs::write(&f, "changed").unwrap(); + + let result = restore(&mut app, Some("12")); + assert!(result.message.unwrap().contains("Restored")); + assert_eq!(std::fs::read_to_string(&f).unwrap(), "v0"); + } + + #[test] + fn restore_numeric_index_rejects_unbounded_query() { + let tmp = TempDir::new().unwrap(); + let _home = scoped_home(&tmp); + let mut app = make_app(&tmp, true); + + let result = restore(&mut app, Some("1001")); + + assert!(result.is_error); + assert!( + result + .message + .unwrap() + .contains("Restore index must be <= 1000") + ); + } + #[test] fn restore_in_yolo_reverts_workspace() { let tmp = TempDir::new().unwrap(); diff --git a/docs/MODES.md b/docs/MODES.md index 3064084c5..98fe0cecd 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -137,7 +137,8 @@ DeepSeek-TUI has three related but intentionally separate recovery paths: - Esc-Esc backtrack rewinds the live transcript to a previous user prompt and restores that prompt into the composer for editing. - `/restore` and the `revert_turn` tool restore workspace files from side-git - snapshots. They do not rewrite conversation history. + snapshots. `/restore list [N]` lists more snapshot options before choosing a + rollback point. They do not rewrite conversation history. A Pi-style in-file tree browser is a larger UI/data-model project. v0.8.40 ships the bounded fork/backtrack primitives and explicit lineage metadata. From 868f99b329f13206f4414e06ed14bc0ea39401d7 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:01:38 -0700 Subject: [PATCH 030/209] docs: update v0.9 PR harvest map --- docs/V0_9_0_EXECUTION_MAP.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 88d5afe1a..41c4cc552 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -44,6 +44,8 @@ harvest/stewardship commits: | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | | #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | +| #2513 restore snapshot listing | Manually harvested as `bb39cf169` with explicit `/restore list 101` cap rejection. | `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | +| #2576 PrefixCacheChange first-freeze event | Already present in the current v0.9 stack through `29acb87a9d`. | `cargo test -p codewhale-tui --locked prefix_cache` passed. Do not close until this integration branch is public or merged. | ## PR Harvest Queue @@ -52,7 +54,7 @@ harvest/stewardship commits: | #1865 Pro Plan mode | Conflicting | Likely superseded by HarnessProfile/model-posture lane; review before closing. | | #1893 TLS certificate verification toggle | Conflicting | Security-sensitive; review separately, not part of first v0.9 harvest. | | #2045 NSIS installer and classroom checklist | Conflicting | Defer unless release-readiness needs Windows installer work. | -| #2048 live shell output | Mergeable | Review against current exec/tool card behavior before merge. | +| #2048 live shell output | Mergeable but build-broken/stale | Defer; PR head fails `cargo check -p codewhale-tui --tests --locked`, matches jobs by command prefix, and misses newer `task_shell_start` / `task_shell_wait` cards. Harvest only via a task-id based rewrite. | | #2113 independent scroll regions | Conflicting | Defer; likely overlaps current transcript/sidebar work. | | #2239 i18n Phase 1-4b | Conflicting | Defer until localization lane. | | #2242 typed persistent tool permission rules | Conflicting | Compare with #2721 stabilization and permissions model. | @@ -72,11 +74,11 @@ harvest/stewardship commits: | #2506 provider path suffix overrides | Draft/conflicting | Partly superseded by current provider path-suffix support; verify. | | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | | #2508 configurable path suffix | Conflicting | Likely superseded by #2506/current code; verify linked issue #2089. | -| #2509 parallel read-only web search | Mergeable | Review for tool-execution scheduler invariants. | +| #2509 parallel read-only web search | Mergeable / already merged via #2504 | Already present in `origin/main` as `a09af2024`; safe to close as harvested/superseded. | | #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | | #2512 custom completion sounds | Draft/conflicting | Defer. | -| #2513 restore snapshot listing | Draft/mergeable | Review as small UX polish. | +| #2513 restore snapshot listing | Draft/mergeable | Manually harvested as `bb39cf169` with cap-rejection polish; close/comment after branch is public, leave #2494 open. | | #2517 turn_meta tail relocation | Mergeable | Already in high-priority harvest list; review prompt/cache implications. | | #2520 prompt base disk cache | Mergeable | Review after #2687 prompt architecture decision. | | #2522 hard compaction preserving system segment | Mergeable | Review after #2687 prompt architecture decision. | @@ -84,7 +86,7 @@ harvest/stewardship commits: | #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | | #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. | -| #2576 PrefixCacheChange events | Mergeable | Review after current prefix-cache commits. | +| #2576 PrefixCacheChange events | Mergeable | Already present locally through `29acb87a9d`; close/comment after branch is public or merged. | | #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | | #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. | @@ -122,9 +124,11 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Review #2048, #2502, #2509, #2513, and #2576 as the next small - mergeable candidates. -2. Prepare public comments for #2708, #2627, #2634, #2636, #2687, and already-harvested performance - PRs once this integration branch has a remote review surface. -3. Start file decomposition Phase 1 only after the PR harvest table has no +1. Review and harvest #2502 after adding panic-safety coverage for the web_run + state split. +2. Review #2517, #2520, and #2522 for prompt/cache implications after #2687 + was deferred. +3. Prepare public comments for #2708, #2513, #2530, #2576, #2581, #2627, + #2634, #2636, #2687, and already-harvested performance PRs. +4. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From 60f8e7d62ec26526949ea8585982230bdca2610e Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:06:08 -0700 Subject: [PATCH 031/209] refactor(web_run): split cache locks for page reads Harvested from PR #2502 by @HUQIANTAO Co-authored-by: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> --- CHANGELOG.md | 10 +- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/tools/web_run.rs | 159 +++++++++++++++++++++++++++----- docs/V0_9_0_EXECUTION_MAP.md | 11 +-- 5 files changed, 150 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5e141db..9ffec2023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reach beyond that default listing up to a bounded index, and list requests above the visible cap fail explicitly instead of silently truncating. +### Changed + +- Split `web_run` session/page cache state so cached page reads use shared + page handles and do not serialize through the mutation path. The harvest also + adds panic-safe state write-back and serializes cache-mutating unit tests so + the global web cache remains stable under normal Cargo test parallelism. + ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and -**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494). +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502). ## [0.8.53] - 2026-06-03 diff --git a/Cargo.lock b/Cargo.lock index 139ac2a7a..1bb146402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,6 +1014,7 @@ dependencies = [ "multimap", "objc2", "objc2-foundation", + "parking_lot", "pdf-extract", "portable-pty", "pretty_assertions", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 6a5775382..ed9e8270c 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -77,6 +77,7 @@ portable-pty = "0.9" zeroize = "1.8.2" ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } +parking_lot = "0.12" pdf-extract = "0.7" tar = "0.4" flate2 = "1.1" diff --git a/crates/tui/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs index edf1f56fb..f1495d36c 100644 --- a/crates/tui/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -15,9 +15,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; +use parking_lot::{RwLock, RwLockWriteGuard}; + const MAX_RESULTS: usize = 10; const DEFAULT_TIMEOUT_MS: u64 = 15_000; const DEFAULT_OPEN_TIMEOUT_MS: u64 = 20_000; @@ -26,7 +28,13 @@ const MAX_PAGES_PER_SESSION: usize = 256; const WEB_RUN_SESSION_TTL: Duration = Duration::from_secs(30 * 60); const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; -static WEB_RUN_STATE: OnceLock> = OnceLock::new(); +static WEB_RUN_STATE: OnceLock = OnceLock::new(); + +#[derive(Default)] +struct WebRunCache { + sessions: RwLock>, + pages: RwLock>, +} #[derive(Default)] struct WebRunState { @@ -53,7 +61,7 @@ impl Default for WebRunSessionState { #[derive(Debug, Clone)] struct StoredWebPage { namespace: String, - page: WebPage, + page: Arc, } impl WebRunState { @@ -148,22 +156,13 @@ impl WebRunState { ref_id.to_string(), StoredWebPage { namespace: namespace.to_string(), - page, + page: Arc::new(page), }, ); for evicted_ref in evicted_refs { self.pages.remove(&evicted_ref); } } - - fn get_page(&mut self, ref_id: &str) -> Option { - self.cleanup(); - let stored = self.pages.get(ref_id)?.clone(); - if let Some(session) = self.sessions.get_mut(&stored.namespace) { - session.last_access = Instant::now(); - } - Some(stored.page) - } } #[derive(Debug, Clone, Serialize)] @@ -576,7 +575,7 @@ impl ToolSpec for WebRunTool { let page = resolve_or_fetch_page(&ref_id, DEFAULT_OPEN_TIMEOUT_MS, context).await?; view_counter += 1; let view_ref = format!("{scope}turn{turn}view{view_counter}"); - store_page(&context.state_namespace, &view_ref, page.clone()); + store_page(&context.state_namespace, &view_ref, (*page).clone()); let view = render_view(&view_ref, &page, lineno, response_length); views.push(view); @@ -607,7 +606,7 @@ impl ToolSpec for WebRunTool { resolve_or_fetch_page(&target, DEFAULT_OPEN_TIMEOUT_MS, context).await?; click_counter += 1; let click_ref = format!("{scope}turn{turn}click{click_counter}"); - store_page(&context.state_namespace, &click_ref, fetched.clone()); + store_page(&context.state_namespace, &click_ref, (*fetched).clone()); let view = render_view(&click_ref, &fetched, 1, response_length); views.push(view); } @@ -653,12 +652,60 @@ impl ToolSpec for WebRunTool { } fn with_state(f: impl FnOnce(&mut WebRunState) -> T) -> T { - let lock = WEB_RUN_STATE.get_or_init(|| Mutex::new(WebRunState::default())); - let mut state = lock - .lock() - .expect("web run state mutex should not be poisoned"); - state.cleanup(); - f(&mut state) + let cache = WEB_RUN_STATE.get_or_init(WebRunCache::default); + let sessions = cache.sessions.write(); + let pages = cache.pages.write(); + let mut guard = WebRunStateWriteBack::new(sessions, pages); + guard.state_mut().cleanup(); + let result = f(guard.state_mut()); + guard.write_back(); + result +} + +struct WebRunStateWriteBack<'a> { + sessions: RwLockWriteGuard<'a, HashMap>, + pages: RwLockWriteGuard<'a, HashMap>, + state: Option, +} + +impl<'a> WebRunStateWriteBack<'a> { + fn new( + mut sessions: RwLockWriteGuard<'a, HashMap>, + mut pages: RwLockWriteGuard<'a, HashMap>, + ) -> Self { + let state = WebRunState { + sessions: std::mem::take(&mut *sessions), + pages: std::mem::take(&mut *pages), + }; + Self { + sessions, + pages, + state: Some(state), + } + } + + fn state_mut(&mut self) -> &mut WebRunState { + self.state + .as_mut() + .expect("web run state should be present until write-back") + } + + fn write_back(mut self) { + self.restore(); + } + + fn restore(&mut self) { + if let Some(state) = self.state.take() { + *self.sessions = state.sessions; + *self.pages = state.pages; + } + } +} + +impl Drop for WebRunStateWriteBack<'_> { + fn drop(&mut self) { + self.restore(); + } } fn scoped_ref_prefix(namespace: &str) -> String { @@ -673,8 +720,19 @@ fn store_page(namespace: &str, ref_id: &str, page: WebPage) { }); } -fn get_page(ref_id: &str) -> Option { - with_state(|state| state.get_page(ref_id)) +fn get_page(ref_id: &str) -> Option> { + let cache = WEB_RUN_STATE.get_or_init(WebRunCache::default); + let stored = { + let pages = cache.pages.read(); + pages.get(ref_id).cloned() + }?; + { + let mut sessions = cache.sessions.write(); + if let Some(session) = sessions.get_mut(&stored.namespace) { + session.last_access = Instant::now(); + } + } + Some(stored.page) } #[cfg(test)] @@ -693,13 +751,13 @@ async fn resolve_or_fetch_page( ref_id: &str, timeout_ms: u64, context: &ToolContext, -) -> Result { +) -> Result, ToolError> { if let Some(page) = get_page(ref_id) { return Ok(page); } if looks_like_url(ref_id) { check_network_policy(ref_id, context)?; - return fetch_page(ref_id, timeout_ms).await; + return fetch_page(ref_id, timeout_ms).await.map(Arc::new); } Err(ToolError::invalid_input(format!( "Unknown ref_id '{ref_id}'" @@ -1637,6 +1695,15 @@ fn url_encode(input: &str) -> String { mod tests { use super::*; use std::path::PathBuf; + use std::sync::{Mutex, MutexGuard}; + + static WEB_RUN_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn lock_web_run_test_state() -> MutexGuard<'static, ()> { + WEB_RUN_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } fn sample_page(url: &str) -> WebPage { WebPage { @@ -1729,6 +1796,7 @@ mod tests { #[test] fn scoped_ref_prefix_is_session_specific() { + let _lock = lock_web_run_test_state(); reset_web_run_state(); let alpha = scoped_ref_prefix("session-alpha"); let beta = scoped_ref_prefix("session-beta"); @@ -1741,6 +1809,7 @@ mod tests { #[test] fn stored_pages_do_not_cross_scoped_sessions() { + let _lock = lock_web_run_test_state(); reset_web_run_state(); let shared_suffix = "turn1search1"; let ref_alpha = format!("{}{}", scoped_ref_prefix("session-alpha"), shared_suffix); @@ -1756,8 +1825,23 @@ mod tests { assert!(get_page(&ref_beta).is_none()); } + #[test] + fn cached_page_reads_share_page_arc() { + let _lock = lock_web_run_test_state(); + reset_web_run_state(); + let namespace = "session-alpha"; + let ref_id = format!("{}turn0search1", scoped_ref_prefix(namespace)); + store_page(namespace, &ref_id, sample_page("https://example.com/alpha")); + + let first = get_page(&ref_id).expect("first page read"); + let second = get_page(&ref_id).expect("second page read"); + + assert!(Arc::ptr_eq(&first, &second)); + } + #[test] fn turn_counters_are_scoped_per_session() { + let _lock = lock_web_run_test_state(); reset_web_run_state(); assert_eq!(next_turn_for_namespace("session-alpha"), 0); @@ -1765,8 +1849,33 @@ mod tests { assert_eq!(next_turn_for_namespace("session-beta"), 0); } + #[test] + fn with_state_restores_cache_after_panic() { + let _lock = lock_web_run_test_state(); + reset_web_run_state(); + let namespace = "session-alpha"; + let ref_id = format!("{}turn0search1", scoped_ref_prefix(namespace)); + store_page(namespace, &ref_id, sample_page("https://example.com/alpha")); + + let panic_result = std::panic::catch_unwind(|| { + with_state(|state| { + let session = state + .sessions + .get_mut(namespace) + .expect("session should exist"); + session.next_turn = 42; + panic!("exercise web_run write-back guard"); + }); + }); + + assert!(panic_result.is_err()); + assert!(get_page(&ref_id).is_some()); + assert_eq!(next_turn_for_namespace(namespace), 42); + } + #[test] fn stale_session_pages_are_evicted() { + let _lock = lock_web_run_test_state(); reset_web_run_state(); let namespace = "session-alpha"; let ref_id = format!("{}turn0search1", scoped_ref_prefix(namespace)); diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 41c4cc552..5611bb1dc 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -46,6 +46,7 @@ harvest/stewardship commits: | #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | | #2513 restore snapshot listing | Manually harvested as `bb39cf169` with explicit `/restore list 101` cap rejection. | `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | | #2576 PrefixCacheChange first-freeze event | Already present in the current v0.9 stack through `29acb87a9d`. | `cargo test -p codewhale-tui --locked prefix_cache` passed. Do not close until this integration branch is public or merged. | +| #2502 web_run RwLock split | Manually harvested with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. | `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | ## PR Harvest Queue @@ -69,7 +70,7 @@ harvest/stewardship commits: | #2491 typed ask permissions schema | Conflicting | Prior memory says safe candidate; verify current permissions work first. | | #2498 Windows shell process trees | Conflicting | Prior memory says safe candidate; review for #2721 stabilization. | | #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | -| #2502 web_run RwLock split | Mergeable | Review lock/panic safety before merge. | +| #2502 web_run RwLock split | Mergeable | Manually harvested with panic-safety and shared cached-page reads; close/comment after branch is public. | | #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | | #2506 provider path suffix overrides | Draft/conflicting | Partly superseded by current provider path-suffix support; verify. | | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | @@ -124,11 +125,9 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Review and harvest #2502 after adding panic-safety coverage for the web_run - state split. -2. Review #2517, #2520, and #2522 for prompt/cache implications after #2687 +1. Review #2517, #2520, and #2522 for prompt/cache implications after #2687 was deferred. -3. Prepare public comments for #2708, #2513, #2530, #2576, #2581, #2627, +2. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, #2634, #2636, #2687, and already-harvested performance PRs. -4. Start file decomposition Phase 1 only after the PR harvest table has no +3. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From 98edba3683c3f75bff074a9af0a1cc4f7ef467dc Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:12:22 -0700 Subject: [PATCH 032/209] refactor(engine): append turn metadata after user text Place user text before volatile turn metadata in outgoing user-message content arrays so provider prefix caches can continue matching the stable user-input prefix across date, model-route, and working-set changes. Also adds wire-level coverage proving tail-positioned turn metadata serializes after user text while preserving turn-meta deduplication. Harvested from PR #2517 by @HUQIANTAO Co-authored-by: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> --- CHANGELOG.md | 6 +- crates/tui/src/client/chat.rs | 40 ++++++++++++ crates/tui/src/core/engine.rs | 16 +++-- crates/tui/src/core/engine/tests.rs | 97 ++++++++++++++++++++++------- docs/V0_9_0_EXECUTION_MAP.md | 29 ++++----- 5 files changed, 145 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffec2023..c250464e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,12 +21,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 page handles and do not serialize through the mutation path. The harvest also adds panic-safe state write-back and serializes cache-mutating unit tests so the global web cache remains stable under normal Cargo test parallelism. +- Appended volatile `` blocks after user text in outgoing user + message content arrays so provider prefix caches can keep matching the stable + user-input prefix across date, route, and working-set changes. ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and -**@HUQIANTAO** for the `web_run` lock-splitting work (#2502). +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata +prefix-cache stability work (#2517). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 3aa217b05..a3ecf4b45 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3062,6 +3062,22 @@ mod stream_decoder_tests { } } + fn user_message_with_tail_turn_meta(task: &str, turn_meta: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ + ContentBlock::Text { + text: task.to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ], + } + } + fn tool_message_content(messages: &[Value], index: usize) -> &str { messages .iter() @@ -3128,6 +3144,30 @@ mod stream_decoder_tests { ); } + #[test] + fn request_builder_keeps_tail_turn_meta_after_user_text_for_wire() { + let turn_meta = "\nCurrent local date: 2026-05-09\n"; + let messages = vec![ + user_message_with_tail_turn_meta("first task", turn_meta), + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "first answer".to_string(), + cache_control: None, + }], + }, + user_message_with_tail_turn_meta("second task", turn_meta), + ]; + + let built = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let first = user_message_content(&built, 0); + let second = user_message_content(&built, 1); + let expected_ref = ""; + + assert_eq!(first, format!("first task\n{turn_meta}")); + assert_eq!(second, format!("second task\n{expected_ref}")); + } + #[test] fn request_builder_keeps_changed_turn_meta_full_and_updates_recent_hash() { let first_meta = "\nCurrent local date: 2026-05-09\n"; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 83cd6e931..b3477950a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1449,9 +1449,21 @@ In {new} mode: {policy}\n\n\ reasoning_effort: Option<&str>, reasoning_effort_auto: bool, ) -> Message { + // Place the user text first and turn_meta last so that the leading + // bytes of each user message stay stable across date / model-route / + // working-set changes. DeepSeek's KV prefix cache matches byte + // sequences from the start of each message; when turn_meta (which + // contains the current date) sits at position 0 the entire user + // message prefix is invalidated at every date boundary. Moving it + // to the tail preserves the user-input prefix and limits cache + // invalidation to the trailing metadata block. Message { role: "user".to_string(), content: vec![ + ContentBlock::Text { + text, + cache_control: None, + }, self.turn_metadata_block( routed_model, mode, @@ -1459,10 +1471,6 @@ In {new} mode: {policy}\n\n\ reasoning_effort, reasoning_effort_auto, ), - ContentBlock::Text { - text, - cache_control: None, - }, ], } } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bed3276a0..9e48a376f 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -2210,11 +2210,11 @@ fn working_set_reaches_model_as_turn_metadata() { engine.session.add_message(user_msg); let messages = engine.messages_with_turn_metadata(); - let first_block = messages + let last_block = messages .last() - .and_then(|message| message.content.first()) + .and_then(|message| message.content.last()) .expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!(text.starts_with("\n")); @@ -2235,11 +2235,11 @@ fn turn_metadata_includes_current_local_date_without_working_set() { engine.session.add_message(user_msg); let messages = engine.messages_with_turn_metadata(); - let first_block = messages + let last_block = messages .last() - .and_then(|message| message.content.first()) + .and_then(|message| message.content.last()) .expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2266,8 +2266,8 @@ fn turn_metadata_includes_auto_model_route() { Some("max"), true, ); - let first_block = user_msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = user_msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2294,8 +2294,11 @@ fn turn_metadata_includes_current_mode() { None, false, ); - let first_block = user_msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + // turn_meta was relocated to the tail of the user message in #2517 + // to keep the leading bytes (user input) stable across date / model + // route / working-set changes. + let last_block = user_msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; @@ -2314,10 +2317,11 @@ fn turn_metadata_mode_updates_with_change_mode_op() { }; let (mut engine, _handle) = Engine::new(config, &Config::default()); - // In agent mode by default + // In agent mode by default. The turn_meta block now sits at the + // *tail* of the user message (see #2517) so we read `content.last()`. let msg = engine.user_text_message_with_turn_metadata("hello".to_string()); - let first_block = msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!( @@ -2328,8 +2332,8 @@ fn turn_metadata_mode_updates_with_change_mode_op() { // Switch to YOLO — user_text_message_with_turn_metadata should reflect the new mode engine.current_mode = AppMode::Yolo; let msg = engine.user_text_message_with_turn_metadata("hello again".to_string()); - let first_block = msg.content.first().expect("turn metadata block"); - let ContentBlock::Text { text, .. } = first_block else { + let last_block = msg.content.last().expect("turn metadata block"); + let ContentBlock::Text { text, .. } = last_block else { panic!("expected text metadata block"); }; assert!( @@ -2377,10 +2381,10 @@ fn user_text_message_keeps_current_turn_input_after_turn_metadata() { let user_msg = engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string()); - let last_text = user_msg + // User text is now at position 0, turn_meta at position 1. + let first_text = user_msg .content .iter() - .rev() .find_map(|block| { if let ContentBlock::Text { text, .. } = block { Some(text.as_str()) @@ -2389,7 +2393,7 @@ fn user_text_message_keeps_current_turn_input_after_turn_metadata() { } }) .expect("user text block"); - assert_eq!(last_text, "explain the cache metrics"); + assert_eq!(first_text, "explain the cache metrics"); } #[test] @@ -2488,15 +2492,59 @@ fn turn_metadata_skips_tool_result_messages() { Some(ContentBlock::ToolResult { .. }) )); - // The earlier real user message already carries the turn_meta prefix. + // The earlier real user message carries user text first, turn_meta last. let real_user = messages.first().expect("first user message"); assert_eq!(real_user.role, "user"); let ContentBlock::Text { text, .. } = real_user.content.first().expect("user text content") else { panic!("expected Text block on real user message"); }; - assert!(text.starts_with("\n")); - assert!(text.contains("src/lib.rs")); + assert_eq!(text, "inspect src/lib.rs"); + // turn_meta is at the tail of the content array. + let last_block = real_user.content.last().expect("turn_meta block"); + let ContentBlock::Text { text: meta, .. } = last_block else { + panic!("expected Text block for turn_meta at tail"); + }; + assert!(meta.starts_with("\n")); +} + +/// User text must appear before turn_meta in the content array so that +/// the leading bytes of each user message stay stable across date changes. +/// DeepSeek's KV prefix cache matches byte sequences from the start of +/// each message; placing the volatile date-bearing turn_meta at position +/// 0 would invalidate the entire user message prefix at every date +/// boundary. Moving it to the tail preserves the user-input prefix. +#[test] +fn user_message_turn_meta_is_appended_not_prepended() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..Default::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + + let msg = engine.user_text_message_with_turn_metadata("hello world".to_string()); + assert_eq!(msg.role, "user"); + assert_eq!(msg.content.len(), 2); + + // First content block: user text. + let ContentBlock::Text { text, .. } = &msg.content[0] else { + panic!("expected Text block at position 0"); + }; + assert_eq!(text, "hello world"); + + // Second content block: turn_meta. + let ContentBlock::Text { text: meta, .. } = &msg.content[1] else { + panic!("expected Text block for turn_meta at position 1"); + }; + assert!( + meta.starts_with("\n"), + "turn_meta must be at the tail" + ); + assert!( + meta.contains("Current local date:"), + "turn_meta must contain the date" + ); } /// When the turn is mid-execution and the trailing user message is a @@ -3747,9 +3795,10 @@ async fn post_edit_hook_injects_diagnostics_message_before_next_request() { let last = engine.session.messages.last().expect("message appended"); assert_eq!(last.role, "user"); - let meta = match &last.content[0] { - crate::models::ContentBlock::Text { text, .. } => text.clone(), - other => panic!("expected text block, got {other:?}"), + // turn_meta is now at the tail of the content array (PR #2517). + let meta = match last.content.last() { + Some(crate::models::ContentBlock::Text { text, .. }) => text.clone(), + other => panic!("expected text block at tail, got {other:?}"), }; assert!(meta.starts_with("\n")); let diagnostic_text = last diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 5611bb1dc..5d0bec7aa 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -19,13 +19,15 @@ PR is harvested, superseded, deferred, or closed. 1. Stabilization and PR harvest: finish #2721 and #2722 before new feature work. 2. Provider/model/auth correctness: land narrow correctness fixes that match the current provider architecture. -3. File decomposition Phase 1: split safe, test-covered config/provider and TUI +3. HarmonyOS/MatePad Edge intake: keep #2634 active, scoped, and credited while + the OHOS/Nix dependency clearance work finishes upstream. +4. File decomposition Phase 1: split safe, test-covered config/provider and TUI view surfaces before adding larger workflow UX. -4. WhaleFlow MVP: typed IR, executor skeleton, replay, and pod monitor before +5. WhaleFlow MVP: typed IR, executor skeleton, replay, and pod monitor before teacher/student promotion loops. -5. Model Lab and HarnessProfile MVP: Hugging Face polish and provider/model +6. Model Lab and HarnessProfile MVP: Hugging Face polish and provider/model posture before automatic harness creation. -6. Release readiness: keep #2729 current and do not tag or publish without +7. Release readiness: keep #2729 current and do not tag or publish without maintainer approval. ## Current Branch Harvest @@ -40,13 +42,14 @@ harvest/stewardship commits: | #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | -| #2634 HarmonyOS port | Defer direct merge; draft has broad platform and TLS/runtime blast radius. | Harvest at most the unused `rustyline` cleanup after local verification; full port needs OHOS target checks and sandbox/security review. | +| #2634 HarmonyOS port | Active HarmonyOS/MatePad Edge lane; do not close. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. PR remains draft/blocked while the author waits on upstream Nix/dependency clearance and carries local patches; full port needs OHOS target checks plus sandbox, TLS, keyring, clipboard, browser-open, and self-update review before merge. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | | #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | | #2513 restore snapshot listing | Manually harvested as `bb39cf169` with explicit `/restore list 101` cap rejection. | `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | | #2576 PrefixCacheChange first-freeze event | Already present in the current v0.9 stack through `29acb87a9d`. | `cargo test -p codewhale-tui --locked prefix_cache` passed. Do not close until this integration branch is public or merged. | | #2502 web_run RwLock split | Manually harvested with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. | `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | +| #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | ## PR Harvest Queue @@ -75,14 +78,14 @@ harvest/stewardship commits: | #2506 provider path suffix overrides | Draft/conflicting | Partly superseded by current provider path-suffix support; verify. | | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | | #2508 configurable path suffix | Conflicting | Likely superseded by #2506/current code; verify linked issue #2089. | -| #2509 parallel read-only web search | Mergeable / already merged via #2504 | Already present in `origin/main` as `a09af2024`; safe to close as harvested/superseded. | +| #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. | | #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | | #2512 custom completion sounds | Draft/conflicting | Defer. | | #2513 restore snapshot listing | Draft/mergeable | Manually harvested as `bb39cf169` with cap-rejection polish; close/comment after branch is public, leave #2494 open. | -| #2517 turn_meta tail relocation | Mergeable | Already in high-priority harvest list; review prompt/cache implications. | -| #2520 prompt base disk cache | Mergeable | Review after #2687 prompt architecture decision. | -| #2522 hard compaction preserving system segment | Mergeable | Review after #2687 prompt architecture decision. | +| #2517 turn_meta tail relocation | Mergeable | Manually harvested on the v0.9 branch; close/comment after branch is public. | +| #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. | +| #2522 hard compaction preserving system segment | Mergeable | Defer. Review found a dormant hard path that would duplicate/cache summaries into the mutable system prompt if wired through current engine flow, and a simple tail split that can break tool-call pair and pinning invariants. | | #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | | #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | @@ -96,7 +99,7 @@ harvest/stewardship commits: | #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | | #2632 tool-catalog JSON cache | Mergeable | Already harvested into the 22-commit stack. | | #2633 capacity reverse scans | Mergeable | Already harvested into the 22-commit stack. | -| #2634 HarmonyOS port | Draft/mergeable | Defer broad port. Review found global TLS/provider-install risk, OHOS clipboard/test cfg issues, and major sandbox/process-security degradations. | +| #2634 HarmonyOS port | Draft/blocked | Keep as active HarmonyOS/MatePad Edge lane. Do not merge wholesale until upstream Nix/dependency clearance, OHOS target checks, and sandbox/TLS/keyring/clipboard/browser/self-update review are complete. | | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | | #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | | #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | @@ -125,9 +128,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Review #2517, #2520, and #2522 for prompt/cache implications after #2687 - was deferred. -2. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, +1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, #2634, #2636, #2687, and already-harvested performance PRs. -3. Start file decomposition Phase 1 only after the PR harvest table has no +2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From 42d27c0095452a22d2e1b5ad2eaa8eb91d6f638f Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:27:14 -0700 Subject: [PATCH 033/209] ci: soften contribution intake gates --- .github/workflows/issue-gate.yml | 41 +++++++++----------------- .github/workflows/pr-gate.yml | 6 ++-- CHANGELOG.md | 6 ++++ CONTRIBUTING.md | 40 ++++++++++++++++--------- README.md | 1 + docs/AGENT_ETHOS.md | 50 ++++++++++++++++++++++++++++++++ docs/SUBAGENTS.md | 13 +++++++++ 7 files changed, 112 insertions(+), 45 deletions(-) create mode 100644 docs/AGENT_ETHOS.md diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 70bab864b..8ca8c4011 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -1,4 +1,4 @@ -name: Contribution gate - issues +name: Contribution intake - issues on: issues: @@ -8,16 +8,11 @@ permissions: contents: read issues: write -env: - # Keep new gates observable first. Switch to "enforce" only after maintainers - # have seeded active contributors and reviewed the dry-run signal. - CONTRIBUTION_GATE_MODE: dry-run - jobs: gate: runs-on: ubuntu-latest steps: - - name: Gate unapproved external issues + - name: Welcome new external issue reporters uses: actions/github-script@v7 with: script: | @@ -25,12 +20,6 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); - const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); - const enforceGate = gateMode === 'enforce'; - - if (!['dry-run', 'enforce'].includes(gateMode)) { - core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); - } if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; @@ -71,29 +60,25 @@ jobs: return; } - const gateMessage = enforceGate - ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' - : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issue.number, + per_page: 100, + }); + if (comments.some(comment => (comment.body || '').includes(marker))) return; await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: [ + marker, `Thanks @${issue.user.login} for the report.`, '', - gateMessage, + 'This issue is staying open for maintainer triage. CodeWhale gets better because people bring us real edge cases from real machines, providers, regions, and workflows.', '', - 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + 'If you can add a reproduction, logs, version output, screenshots, or the provider/model involved, that makes it much easier for us to verify and harvest the fix. Maintainers may comment `/lgtmi` to mark recurring issue reporters as approved so this intake note is skipped next time.', ].join('\n'), }); - - if (!enforceGate) return; - - await github.rest.issues.update({ - owner, - repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned', - }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 3e4052dbd..40afe8c15 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -73,8 +73,8 @@ jobs: } const gateMessage = enforceGate - ? 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' - : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + ? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can reopen or grant access with `/lgtm` after review.' + : 'This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.'; await github.rest.issues.createComment({ owner, @@ -85,7 +85,7 @@ jobs: '', gateMessage, '', - 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.', ].join('\n'), }); diff --git a/CHANGELOG.md b/CHANGELOG.md index c250464e7..08c058c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Appended volatile `` blocks after user text in outgoing user message content arrays so provider prefix caches can keep matching the stable user-input prefix across date, route, and working-set changes. +- Softened contribution intake automation: external issues now receive a warm + triage note and are never auto-closed by the contribution gate, while the PR + gate copy makes clear that dry-run observations are about maintainer safety, + not contributor quality. +- Documented the agent and sub-agent stewardship ethos so future automation + preserves human issue intake, careful PR review, and contributor credit. ### Community diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ed555b9b..66328675a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,16 +172,24 @@ Validation: CodeWhale uses a maintainer-managed contribution gate for the community front door. Maintainers and collaborators bypass this gate automatically. The gate workflows default to dry-run / comment-only mode so maintainers can observe the -signal before closing contributor work. In dry-run mode, unapproved external -issues and pull requests receive a short thank-you / CONTRIBUTING pointer and -remain open. +signal before changing contributor flow. -When maintainers are ready to enforce the gate, set -`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce -mode, external contributors must be listed in -`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain -open. Before enabling enforcement, seed the allowlist broadly enough for active -external contributors who should not be interrupted by the rollout. +The maintainer posture is documented in +[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): automation should reduce load while +keeping good-faith contributors seen, credited, and able to keep helping. + +Issues are never auto-closed by the contribution gate. Unapproved external +issues receive a short welcome note that asks for reproduction details and then +remain open for maintainer triage. CodeWhale depends on real edge cases from +real users, so issue intake should stay warm and open. + +Pull requests are different because they can touch code, CI, release plumbing, +auth, sandboxing, provider policy, and other trust-boundary surfaces. The PR +gate can be switched from dry-run to enforcement when maintainers decide they +need that safety control, but it should be treated as a review-load control, +not a judgment on contributor quality. Before enabling PR enforcement, seed the +allowlist broadly enough for active external contributors who should not be +interrupted by the rollout. The allowlist is scoped: @@ -198,11 +206,10 @@ discussion. Approvals do not edit `main` directly. The approval workflow opens a small allowlist update PR so the new entry is reviewable before it takes effect. -If the gate fires on a good contributor incorrectly, use the same approval flow -to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, -then reopen the affected issue or pull request. If GitHub will not allow the -closed item to be reopened, ask the contributor to resubmit after the allowlist -PR is merged. +If the PR gate fires on a good contributor incorrectly, use the same approval +flow to restore them: comment `/lgtm`, merge the generated allowlist PR, then +reopen the affected pull request. If GitHub will not allow the closed PR to be +reopened, ask the contributor to resubmit after the allowlist PR is merged. ## Agent-Assisted Improvements @@ -213,6 +220,11 @@ from a fresh fork or branch, let the agent find exactly one small friction point and stop after one patch. DeepSeek V4 Pro is the first-class path for this loop today, but the review shape matters more than the provider. +Agents and maintainers should follow the stewardship posture in +[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence, +verification, and narrow patches while keeping the final community decision +human-reviewed. + The useful output is not "ideas for improvement." The useful output is a specific reproduction, a minimal diff, focused checks, and a PR description that explains the trade-off. Do not use an agent to touch auth, credentials, sandbox diff --git a/README.md b/README.md index c36be8e19..b22d3ffcc 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,7 @@ without recreating skills the user deliberately deleted. | [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Tencent/CNB/Lighthouse/Feishu remote-first path | | [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Lighthouse Hong Kong server setup | | [MEMORY.md](docs/MEMORY.md) | User memory feature guide | +| [AGENT_ETHOS.md](docs/AGENT_ETHOS.md) | Maintainer and agent stewardship posture | | [SUBAGENTS.md](docs/SUBAGENTS.md) | Sub-agent role taxonomy and lifecycle | | [KEYBINDINGS.md](docs/KEYBINDINGS.md) | Full shortcut catalog | | [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process | diff --git a/docs/AGENT_ETHOS.md b/docs/AGENT_ETHOS.md new file mode 100644 index 000000000..156b95dad --- /dev/null +++ b/docs/AGENT_ETHOS.md @@ -0,0 +1,50 @@ +# Agent Ethos + +CodeWhale is maintained with agents, but it is not maintained by automation +alone. Treat community reports and patches as real collaboration: people are +bringing us machines, providers, regions, shells, packages, and edge cases we +could not cover by ourselves. + +## Stewardship + +- Verify live truth before acting. Check the current branch, release state, + registry state, CI, and linked issues instead of trusting a handoff. +- Issues are intake, not a privilege boundary. Do not auto-close good-faith + issues because the reporter is not allowlisted. Ask for missing reproduction + detail and leave room for maintainer triage. +- PR gates exist for code review, CI load, and trust-boundary safety. They are + not a quality judgment on the contributor. Keep dry-run mode unless a + maintainer deliberately enables enforcement, and use warm copy when the gate + comments. +- Be generous with recurring contributors. When someone repeatedly brings + useful reports or patches, use `/lgtmi` for issue access or `/lgtm` for PR + access so the automation gets out of their way. +- Preserve contributor credit. When harvesting work, inspect the PR and linked + issues, keep author/co-author attribution where possible, add + `Harvested from PR #N by @handle`, and credit the contributor in the + changelog or release notes. +- Deferral is a maintainer action, not a dismissal. If a PR or issue is not + ready, say what is blocked, what evidence would change the decision, and + which part of the work remains valuable. + +## Agent Workflow + +- Use sub-agents for exploration, review, and verification, but keep a human + maintainer posture in the parent session. Sub-agent output is evidence; the + parent is responsible for the final decision. +- Personally review community PRs before merging, harvesting, closing, or + deferring them. Do not close work based only on title, labels, or an agent's + summary. +- Prefer narrow, reversible changes that match the existing codebase. Avoid + drive-by refactors while harvesting community work. +- Run the smallest meaningful validation first, then broaden tests when a + change touches shared behavior, release plumbing, auth, sandboxing, + providers, or UI workflows. +- Do not tag, publish, push release artifacts, or create GitHub releases + without explicit maintainer approval. + +## Product Tone + +CodeWhale should feel like a capable coding harness with a public community, +not a closed queue. Automation should reduce maintainer load while making +contributors feel seen, credited, and able to keep helping. diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 5c61f4b2e..71aba5964 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -18,6 +18,19 @@ The `type` field on `agent_open` selects a system-prompt posture for the child (`agent_type` is accepted as a compatibility alias). Each role is a distinct stance toward the work — not just a different label. +## Maintainer posture + +Sub-agents help CodeWhale move faster, but the parent agent still owns the +maintainer decision. Use children to gather evidence, review patches, and run +verification while keeping the community posture in +[`AGENT_ETHOS.md`](AGENT_ETHOS.md): issues are open intake, PR gates are +review-load controls, and harvested work needs clear contributor credit. + +When a child reviews community work, the parent should still inspect the PR +diff, linked issues, tests, and CI before merging, harvesting, closing, or +deferring it. A sub-agent's result is a working set, not a substitute for +stewardship. + | Role | Stance | Writes? | Shell posture | Typical use | |---------------|----------------------------------------|---------|---------------|----------------------------------------------| | `general` | flexible; do whatever the parent says | yes | yes | the default; multi-step tasks | From 445a7c81713d4934be6206bbc48cd4c2f0ba78a6 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:29:50 -0700 Subject: [PATCH 034/209] ci: avoid duplicate PR gate comments --- .github/workflows/pr-gate.yml | 29 ++++++++++++++++++++--------- CHANGELOG.md | 3 +++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 40afe8c15..a953b3f65 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -73,21 +73,32 @@ jobs: } const gateMessage = enforceGate - ? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can reopen or grant access with `/lgtm` after review.' + ? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can grant recurring PR access with `/lgtm` after review; once the generated allowlist PR is merged, this pull request can be reopened or resubmitted.' : 'This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.'; - await github.rest.issues.createComment({ + const marker = ''; + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, - body: [ - `Thanks @${pr.user.login} for taking the time to contribute.`, - '', - gateMessage, - '', - 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.', - ].join('\n'), + per_page: 100, }); + const alreadyNoted = comments.some(comment => (comment.body || '').includes(marker)); + if (!alreadyNoted) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + marker, + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + } if (!enforceGate) return; diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c058c13..8d76b6272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 triage note and are never auto-closed by the contribution gate, while the PR gate copy makes clear that dry-run observations are about maintainer safety, not contributor quality. +- Added a PR gate marker guard so reopened unapproved PRs do not get duplicate + intake comments, and clarified that PR reopening should happen after + allowlist approval is merged. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. From 5f51f89c7643a366b5527ca883f0607e4208bfc2 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:45:24 -0700 Subject: [PATCH 035/209] chore: seed APPROVED_CONTRIBUTORS with recurring contributors (>=2 merged PRs) --- .github/APPROVED_CONTRIBUTORS | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 23e1a5d45..290dc212f 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -9,3 +9,48 @@ # issue:username # all:username all:hmbown +all:reidliu41 +all:HUQIANTAO +all:merchloubna70-dot +all:h3c-hexin +all:axobase001 +all:donglovejava +all:Oliver-ZPLiu +all:idling11 +all:angziii +all:aboimpinto +all:encyc +all:Duducoco +all:cyq1017 +all:zlh124 +all:THINKER-ONLY +all:nightt5879 +all:Liu-Vince +all:JiarenWang +all:wdw8276 +all:pengyou200902 +all:linzhiqin2003 +all:LING71671 +all:JasonOA888 +all:Inference1 +all:hongqitai +all:gordonlu +all:gaord +all:zhuangbiaowei +all:yuanchenglu +all:Vishnu1837 +all:sximelon +all:Sskift +all:New2Niu +all:mvanhorn +all:MengZ-super +all:membphis +all:LeoAlex0 +all:Lee-take +all:lbcheng888 +all:kunpeng-ai-lab +all:elowen53 +all:CrepuscularIRIS +all:chnjames +all:ChaceLyee2101 +all:AresNing From 23c9481af11c8ffd02352339206327d6d3e29d7c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:52:35 -0700 Subject: [PATCH 036/209] feat: add HarmonyOS OpenHarmony support Harvest the HarmonyOS/OpenHarmony port from PR #2634 and make it publish-safe by target-gating unsupported host dependencies out of the OHOS TUI graph. Self-update is disabled on OHOS, PTY shell mode reports unsupported, and Starlark execpolicy parsing returns an explicit unsupported-platform error until upstream starlark/rustyline/nix support catches up. Add OHOS SDK setup docs and launcher scripts, install the rustls ring provider for rustls-no-provider entrypoints, and keep the packaged codewhale-tui OHOS graph free of starlark, rustyline, nix@0.28, portable-pty, and arboard. Validation: cargo fmt --all -- --check; git diff --check; git diff --cached --check; cargo check -p codewhale-cli --locked; cargo check -p codewhale-app-server --locked; cargo check -p codewhale-tui --locked; cargo test -p codewhale-cli --locked update::tests::; cargo test -p codewhale-release --locked; cargo test -p codewhale-tui --locked background_tty_command_has_controlling_terminal; cargo test -p codewhale-tui --locked clipboard; cargo package -p codewhale-tui --allow-dirty --no-verify --locked; packaged OHOS cargo tree checks. OHOS target check still requires a loaded OpenHarmony SDK/sysroot and currently stops in ring with missing assert.h when CC/CFLAGS/linker are unset. Harvested from PR #2634 by @shenjackyuanjie. Co-authored-by: shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> --- .cargo/config.toml | 18 + .gitignore | 2 + CHANGELOG.md | 11 +- Cargo.lock | 414 ++++++-------------- Cargo.toml | 3 +- README.ja-JP.md | 2 + README.md | 2 + README.vi.md | 2 + README.zh-CN.md | 2 + crates/app-server/Cargo.toml | 1 + crates/app-server/src/main.rs | 6 + crates/cli/Cargo.toml | 1 + crates/cli/src/lib.rs | 6 + crates/cli/src/update.rs | 8 + crates/release/Cargo.toml | 1 + crates/secrets/Cargo.toml | 2 +- crates/secrets/src/lib.rs | 56 ++- crates/tui/Cargo.toml | 14 +- crates/tui/src/child_env.rs | 1 + crates/tui/src/execpolicy/error.rs | 4 + crates/tui/src/execpolicy/mod.rs | 6 + crates/tui/src/execpolicy/parser_ohos.rs | 26 ++ crates/tui/src/main.rs | 5 + crates/tui/src/sandbox/mod.rs | 40 +- crates/tui/src/sandbox/process_hardening.rs | 6 +- crates/tui/src/tools/diagnostics.rs | 8 +- crates/tui/src/tools/shell.rs | 152 ++++--- crates/tui/src/tools/shell/tests.rs | 2 +- crates/tui/src/tui/clipboard.rs | 122 ++++-- crates/tui/src/utils.rs | 10 +- docs/HarmonyOS.md | 78 ++++ docs/INSTALL.md | 2 + docs/V0_9_0_EXECUTION_MAP.md | 9 +- ohos-clang.ps1 | 23 ++ ohos-clang.sh | 23 ++ ohos-clangxx.ps1 | 23 ++ ohos-clangxx.sh | 23 ++ scripts/ohos-env.ps1 | 57 +++ scripts/ohos-env.sh | 44 +++ 39 files changed, 784 insertions(+), 431 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 crates/tui/src/execpolicy/parser_ohos.rs create mode 100644 docs/HarmonyOS.md create mode 100644 ohos-clang.ps1 create mode 100644 ohos-clang.sh create mode 100644 ohos-clangxx.ps1 create mode 100644 ohos-clangxx.sh create mode 100644 scripts/ohos-env.ps1 create mode 100644 scripts/ohos-env.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..869ef6c5d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,18 @@ +# HarmonyOS/OpenHarmony cross-build paths are intentionally not configured +# here. Cargo does not expand environment variables inside target linker paths +# or CMake toolchain paths, so checked-in absolute SDK paths make the workspace +# machine-specific. +# +# See docs/HarmonyOS.md for setup details. +# +# Set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory, then load one of: +# +# PowerShell: +# . .\scripts\ohos-env.ps1 +# +# Linux/macOS: +# . ./scripts/ohos-env.sh +# +# The setup scripts export Cargo's target-specific linker, AR, CC, CXX, CFLAGS, +# CXXFLAGS, CARGO_ENCODED_RUSTFLAGS, CC_SHELL_ESCAPED_FLAGS, and +# CMAKE_TOOLCHAIN_FILE variables for aarch64-unknown-linux-ohos. diff --git a/.gitignore b/.gitignore index b14ca4b58..eb3b0887e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ docs/*.pdf # Local dev scripts and temp files *.sh *.cmd +!ohos-clang.sh +!ohos-clangxx.sh !scripts/** !.github/scripts/** test.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d76b6272..b6212dc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `/restore` now shows the 20 most recent snapshots, numeric restore targets can reach beyond that default listing up to a bounded index, and list requests above the visible cap fail explicitly instead of silently truncating. +- Added HarmonyOS/OpenHarmony support scaffolding: environment-driven + `OHOS_NATIVE_SDK` setup scripts and compiler wrappers, platform docs, + explicit Rustls ring-provider installation for the no-provider TLS build, and + OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, + execpolicy Starlark parsing, and self-update surfaces. ### Changed @@ -33,13 +38,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 allowlist approval is merged. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. +- Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS + target dependencies so published OpenHarmony builds no longer pull `nix` 0.28 + through `rustyline` or `portable-pty`. ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata -prefix-cache stability work (#2517). +prefix-cache stability work (#2517), and **@shenjackyuanjie** for the +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634). ## [0.8.53] - 2026-06-03 diff --git a/Cargo.lock b/Cargo.lock index 1bb146402..c0f6d0fa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,7 +97,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -160,7 +160,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -322,7 +322,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -362,7 +362,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -379,7 +379,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -403,28 +403,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.8" @@ -527,9 +505,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "block-buffer" @@ -669,8 +647,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -768,7 +744,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -786,15 +762,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "cmp_any" version = "0.8.1" @@ -825,6 +792,7 @@ dependencies = [ "codewhale-protocol", "codewhale-state", "codewhale-tools", + "rustls", "serde", "serde_json", "tempfile", @@ -852,6 +820,7 @@ dependencies = [ "codewhale-state", "dirs", "reqwest", + "rustls", "semver", "serde", "serde_json", @@ -939,6 +908,7 @@ version = "0.8.53" dependencies = [ "anyhow", "reqwest", + "rustls", "semver", "serde", "serde_json", @@ -1022,7 +992,7 @@ dependencies = [ "ratatui", "regex", "reqwest", - "rustyline 15.0.0", + "rustls", "schemars", "schemaui", "serde", @@ -1044,7 +1014,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "uuid", "vt100", "wait-timeout", @@ -1234,7 +1204,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crossterm_winapi", "mio", "parking_lot", @@ -1250,7 +1220,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crossterm_winapi", "derive_more 2.1.1", "document-features", @@ -1336,7 +1306,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1347,7 +1317,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1467,7 +1437,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -1481,7 +1451,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1539,7 +1509,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1559,7 +1529,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", ] @@ -1581,7 +1551,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1605,12 +1575,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dupe" version = "0.9.1" @@ -1628,7 +1592,7 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1654,9 +1618,9 @@ dependencies = [ [[package]] name = "ena" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ "log", ] @@ -1700,7 +1664,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1725,7 +1689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1829,7 +1793,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1944,12 +1908,6 @@ dependencies = [ "num", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.31" @@ -2019,7 +1977,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2088,10 +2046,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -2571,14 +2527,14 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] @@ -2607,7 +2563,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2671,16 +2627,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.83" @@ -2791,9 +2737,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2810,7 +2756,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "libc", "redox_syscall 0.7.4", ] @@ -2832,7 +2778,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] [[package]] @@ -2841,7 +2787,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "libc", ] @@ -2934,12 +2880,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lsp-types" version = "0.94.1" @@ -2996,9 +2936,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmem" @@ -3114,7 +3054,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3126,7 +3066,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3149,7 +3089,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3205,7 +3145,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3282,7 +3222,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3294,7 +3234,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "dispatch2", "objc2", ] @@ -3305,7 +3245,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -3324,7 +3264,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-foundation", ] @@ -3335,7 +3275,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "objc2", "objc2-core-foundation", ] @@ -3475,7 +3415,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3525,7 +3465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand", ] [[package]] @@ -3538,7 +3478,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3585,7 +3525,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "crc32fast", "fdeflate", "flate2", @@ -3696,9 +3636,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3724,67 +3664,11 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.3", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3812,18 +3696,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3833,17 +3707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "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", + "rand_core", ] [[package]] @@ -3855,15 +3719,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rangemap" version = "1.7.1" @@ -3890,7 +3745,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -3901,7 +3756,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3942,7 +3797,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "hashbrown 0.16.1", "indoc", "instability", @@ -3952,7 +3807,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -3961,7 +3816,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] [[package]] @@ -3970,7 +3825,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", ] [[package]] @@ -4012,7 +3867,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4090,7 +3945,6 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4131,7 +3985,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4139,12 +3993,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -4160,7 +4008,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4173,11 +4021,11 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4186,8 +4034,8 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4212,7 +4060,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ - "web-time", "zeroize", ] @@ -4234,7 +4081,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4249,7 +4096,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4267,7 +4113,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "cfg-if", "clipboard-win", "fd-lock", @@ -4283,28 +4129,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustyline" -version = "15.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix 0.29.0", - "radix_trie", - "unicode-segmentation", - "unicode-width 0.2.0", - "utf8parse", - "windows-sys 0.59.0", -] - [[package]] name = "ryu" version = "1.0.22" @@ -4393,7 +4217,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4416,7 +4240,7 @@ dependencies = [ "sha2 0.11.0", "tokio", "toml 1.0.6+spec-1.1.0", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -4438,7 +4262,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand 0.8.6", + "rand", "serde", "sha2 0.10.9", "zbus", @@ -4450,7 +4274,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4463,7 +4287,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4513,7 +4337,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4524,7 +4348,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4560,7 +4384,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4773,7 +4597,7 @@ dependencies = [ "paste", "ref-cast", "regex", - "rustyline 14.0.0", + "rustyline", "serde", "serde_json", "starlark_derive", @@ -4794,7 +4618,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4883,7 +4707,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4905,9 +4729,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4931,7 +4755,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4955,7 +4779,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4998,7 +4822,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.12.1", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -5067,7 +4891,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5078,7 +4902,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5208,7 +5032,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5332,7 +5156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.12.1", "bytes", "futures-core", "futures-util", @@ -5392,7 +5216,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5469,7 +5293,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5513,7 +5337,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -5524,9 +5348,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -5741,7 +5565,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -5777,16 +5601,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -5896,7 +5710,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5971,7 +5785,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5982,7 +5796,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5993,7 +5807,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6406,7 +6220,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6428,7 +6242,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.6", + "rand", "serde", "serde_repr", "sha1", @@ -6451,7 +6265,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -6483,7 +6297,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6503,7 +6317,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -6524,7 +6338,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6557,7 +6371,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6603,7 +6417,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -6615,5 +6429,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index 32f949398..7dae34322 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,8 @@ chrono = { version = "0.4.43", features = ["serde"] } clap = { version = "4.5.54", features = ["derive"] } clap_complete = "4.5" dirs = "6.0.0" -reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls", "socks"] } +reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] } +rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] } rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/README.ja-JP.md b/README.ja-JP.md index 92fdb3c3b..937069f6e 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -143,6 +143,8 @@ codewhale doctor # セットアップを検証 `npm i -g codewhale` は v0.8.8 以降、glibc ベースの ARM64 Linux で動作します。[Releases ページ](https://github.com/Hmbown/CodeWhale/releases) からビルド済みバイナリをダウンロードし、`PATH` 上に並べて配置することもできます。 +HarmonyOS PC と OpenHarmony クロスビルドの設定は [docs/HarmonyOS.md](docs/HarmonyOS.md) を参照してください。 + ### 中国 / ミラーフレンドリーなインストール 中国本土から GitHub または npm のダウンロードが遅い場合は、Cargo レジストリのミラーを利用してください: diff --git a/README.md b/README.md index b22d3ffcc..1f481c47e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ Prebuilt binary pairs and platform archives are published for Linux x64, Linux ARM64, macOS x64, macOS ARM64, and Windows x64. For other targets, see [docs/INSTALL.md](docs/INSTALL.md). +For HarmonyOS PC and OpenHarmony cross-build setup, see [docs/HarmonyOS.md](docs/HarmonyOS.md). + ### China / Mirror-friendly Installation If GitHub or npm downloads are slow from mainland China, use diff --git a/README.vi.md b/README.vi.md index d44044752..c65f3daf4 100644 --- a/README.vi.md +++ b/README.vi.md @@ -183,6 +183,8 @@ Hãy chỉ định mô hình hoặc cấp độ suy nghĩ cố định nếu b Lệnh cài đặt `npm i -g codewhale` hoạt động trên môi trường Linux ARM64 nền glibc từ phiên bản v0.8.8 trở đi. Bạn cũng có thể tải trực tiếp các tệp binary dựng sẵn từ [trang phát hành Releases](https://github.com/Hmbown/CodeWhale/releases) và đặt chúng cạnh nhau trong một thư mục thuộc biến `PATH`. +Xem [docs/HarmonyOS.md](docs/HarmonyOS.md) để cấu hình HarmonyOS PC và cross-build OpenHarmony. + ### Cài đặt thân thiện qua Mirror (Tại Trung Quốc) Nếu việc tải xuống từ GitHub hoặc npm bị chậm từ Trung Quốc đại lục, bạn hãy sử dụng mirror registry cho Cargo: diff --git a/README.zh-CN.md b/README.zh-CN.md index c56953578..7740cb4b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -186,6 +186,8 @@ Auto 模式同时控制两个设置: 从 v0.8.8 起,`npm i -g codewhale` 直接支持 glibc 系的 ARM64 Linux。你也可以从 [Releases 页面](https://github.com/Hmbown/CodeWhale/releases) 下载预编译二进制,放到 `PATH` 目录中。 +HarmonyOS PC 运行和 OpenHarmony 交叉编译配置见 [docs/HarmonyOS.md](docs/HarmonyOS.md)。 + ### 中国大陆 / 镜像友好安装 如果在中国大陆访问 GitHub 或 npm 下载较慢,可以通过 Cargo 注册表镜像安装: diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index aa5a1cf34..39e37022d 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -21,6 +21,7 @@ codewhale-state = { path = "../state", version = "0.8.53" } codewhale-tools = { path = "../tools", version = "0.8.53" } serde.workspace = true serde_json.workspace = true +rustls.workspace = true tokio.workspace = true tower-http.workspace = true uuid.workspace = true diff --git a/crates/app-server/src/main.rs b/crates/app-server/src/main.rs index 9627746e1..8fcb8f198 100644 --- a/crates/app-server/src/main.rs +++ b/crates/app-server/src/main.rs @@ -27,6 +27,8 @@ struct Cli { #[tokio::main] async fn main() -> Result<()> { + install_rustls_crypto_provider(); + let cli = Cli::parse(); let listen: SocketAddr = format!("{}:{}", cli.host, cli.port) .parse() @@ -41,6 +43,10 @@ async fn main() -> Result<()> { .await } +fn install_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + fn app_server_token_from_env() -> Option { std::env::var("CODEWHALE_APP_SERVER_TOKEN") .ok() diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8c23f204d..f567b79b5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -38,6 +38,7 @@ dirs.workspace = true serde.workspace = true serde_json.workspace = true reqwest = { workspace = true, features = ["blocking"] } +rustls.workspace = true semver.workspace = true tokio.workspace = true sha2.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index ba94aeb5d..209a6a435 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -471,7 +471,13 @@ struct AppServerArgs { const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions"; +fn install_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + pub fn run_cli() -> std::process::ExitCode { + install_rustls_crypto_provider(); + match run() { Ok(()) => std::process::ExitCode::SUCCESS, Err(err) => { diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 2c90422b2..c9d9ca7c3 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -20,6 +20,12 @@ use std::io::Write; /// Run the self-update workflow. pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option) -> Result<()> { + #[cfg(target_env = "ohos")] + { + let _ = (beta, check_only, proxy_arg); + bail!("self-update is not supported on HarmonyOS/OpenHarmony yet"); + } + let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); @@ -353,6 +359,8 @@ pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { } fn update_http_client(proxy: Option<&Proxy>) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut builder = reqwest::blocking::Client::builder(); if let Some(proxy) = proxy { builder = builder.proxy(proxy.clone()); diff --git a/crates/release/Cargo.toml b/crates/release/Cargo.toml index 675206863..699419bba 100644 --- a/crates/release/Cargo.toml +++ b/crates/release/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared CodeWhale release discovery and version comparison helpers [dependencies] anyhow.workspace = true reqwest = { workspace = true, features = ["blocking"] } +rustls.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 848174203..7db781eb2 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -19,7 +19,7 @@ keyring = { version = "3", features = ["apple-native"] } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } [dev-dependencies] diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 65c9c185e..d9826b451 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -92,7 +92,7 @@ pub trait KeyringStore: Send + Sync { /// Wraps the platform credential store: /// - **macOS**: Keychain (via `security` framework) /// - **Windows**: Credential Manager -/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus) +/// - **Linux**: Secret Service (GNOME Keyring / kwallet via dbus), excluding OHOS /// /// This backend is opt-in -- set the [`SECRET_BACKEND_ENV`] environment /// variable to `system` or `keyring` to activate it. On platforms without @@ -124,7 +124,11 @@ impl DefaultKeyringStore { /// Probe the OS keyring without writing anything. Returns `Ok(())` if /// a backend is reachable, otherwise an error describing why not. pub fn probe(&self) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { // `Entry::new` is enough to validate the native macOS/Windows // backend path. Avoid a dummy read there because it can trigger @@ -149,7 +153,11 @@ impl DefaultKeyringStore { Err(other) => Err(SecretsError::Keyring(other.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = &self.service; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -159,7 +167,11 @@ impl DefaultKeyringStore { impl KeyringStore for DefaultKeyringStore { fn get(&self, key: &str) -> Result, SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -169,7 +181,11 @@ impl KeyringStore for DefaultKeyringStore { Err(err) => Err(SecretsError::Keyring(err.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = key; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -177,7 +193,11 @@ impl KeyringStore for DefaultKeyringStore { } fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -185,7 +205,11 @@ impl KeyringStore for DefaultKeyringStore { .set_password(value) .map_err(|err| SecretsError::Keyring(err.to_string())) } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = (key, value); Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -193,7 +217,11 @@ impl KeyringStore for DefaultKeyringStore { } fn delete(&self, key: &str) -> Result<(), SecretsError> { - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { let entry = keyring::Entry::new(&self.service, key) .map_err(|err| SecretsError::Keyring(err.to_string()))?; @@ -202,7 +230,11 @@ impl KeyringStore for DefaultKeyringStore { Err(err) => Err(SecretsError::Keyring(err.to_string())), } } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + )))] { let _ = key; Err(SecretsError::Keyring(unsupported_keyring_message())) @@ -214,7 +246,11 @@ impl KeyringStore for DefaultKeyringStore { } } -#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +)))] fn unsupported_keyring_message() -> String { "system keyring backend is unsupported on this platform".to_string() } diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index ed9e8270c..60ba24114 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -26,7 +26,6 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" -arboard = "3.4" codewhale-config = { path = "../config", version = "0.8.53" } codewhale-protocol = { path = "../protocol", version = "0.8.53" } codewhale-release = { path = "../release", version = "0.8.53" } @@ -47,10 +46,10 @@ fd-lock = "4.0.4" futures-util = "0.3.31" ratatui = "0.30" regex = "1.11" -reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls", "http2", "gzip", "brotli"] } +reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls-no-provider", "http2", "gzip", "brotli"] } +rustls.workspace = true qrcode = { version = "0.14", default-features = false } similar = "2" -rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["preserve_order"] } schemars = { version = "1.2.1", features = ["derive", "preserve_order"] } @@ -71,9 +70,7 @@ tower-http = { version = "0.6", features = ["cors"] } wait-timeout = "0.2" multimap = "0.10.0" shlex = "1.3.0" -starlark = "0.13.0" tiny_http = "0.12" -portable-pty = "0.9" zeroize = "1.8.2" ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } @@ -91,6 +88,13 @@ vt100 = "0.15" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies] +arboard = "3.4" + +[target.'cfg(not(target_env = "ohos"))'.dependencies] +portable-pty = "0.9" +starlark = "0.13.0" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] } diff --git a/crates/tui/src/child_env.rs b/crates/tui/src/child_env.rs index 21add459f..70e4a2bc7 100644 --- a/crates/tui/src/child_env.rs +++ b/crates/tui/src/child_env.rs @@ -62,6 +62,7 @@ where } } +#[cfg(not(target_env = "ohos"))] pub fn apply_to_pty_command(cmd: &mut portable_pty::CommandBuilder, overrides: I) where I: IntoIterator, diff --git a/crates/tui/src/execpolicy/error.rs b/crates/tui/src/execpolicy/error.rs index 9664e71a5..17f6c8f2f 100644 --- a/crates/tui/src/execpolicy/error.rs +++ b/crates/tui/src/execpolicy/error.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_env = "ohos"))] use starlark::Error as StarlarkError; use thiserror::Error; @@ -23,6 +24,9 @@ pub enum Error { }, #[error("expected example to not match rule `{rule}`: {example}")] ExampleDidMatch { rule: String, example: String }, + #[error("{0}")] + UnsupportedPlatform(String), #[error("starlark error: {0}")] + #[cfg(not(target_env = "ohos"))] Starlark(StarlarkError), } diff --git a/crates/tui/src/execpolicy/mod.rs b/crates/tui/src/execpolicy/mod.rs index 00ced1aaa..3f5926cf4 100644 --- a/crates/tui/src/execpolicy/mod.rs +++ b/crates/tui/src/execpolicy/mod.rs @@ -6,7 +6,10 @@ pub mod decision; pub mod error; pub mod execpolicycheck; pub mod matcher; +#[cfg(not(target_env = "ohos"))] pub mod parser; +#[cfg(target_env = "ohos")] +pub mod parser_ohos; pub mod policy; pub mod rule; pub mod rules; @@ -17,7 +20,10 @@ pub use decision::Decision; pub use error::Error; pub use error::Result; pub use execpolicycheck::ExecPolicyCheckCommand; +#[cfg(not(target_env = "ohos"))] pub use parser::PolicyParser; +#[cfg(target_env = "ohos")] +pub use parser_ohos::PolicyParser; pub use policy::Evaluation; pub use policy::Policy; pub use rule::Rule; diff --git a/crates/tui/src/execpolicy/parser_ohos.rs b/crates/tui/src/execpolicy/parser_ohos.rs new file mode 100644 index 000000000..6400cd6b4 --- /dev/null +++ b/crates/tui/src/execpolicy/parser_ohos.rs @@ -0,0 +1,26 @@ +use super::error::Error; +use super::error::Result; + +pub struct PolicyParser; + +impl Default for PolicyParser { + fn default() -> Self { + Self::new() + } +} + +impl PolicyParser { + pub fn new() -> Self { + Self + } + + pub fn parse(&mut self, _policy_identifier: &str, _policy_file_contents: &str) -> Result<()> { + Err(Error::UnsupportedPlatform( + "Starlark execpolicy files are not supported on HarmonyOS/OpenHarmony yet because upstream starlark-rust still depends on a rustyline/nix chain that does not compile for OHOS.".to_string(), + )) + } + + pub fn build(self) -> super::policy::Policy { + super::policy::Policy::empty() + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 56be54f1a..6185232c8 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -109,6 +109,10 @@ fn configure_windows_console_utf8() { #[cfg(not(windows))] fn configure_windows_console_utf8() {} +fn install_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + #[derive(Parser, Debug)] #[command( name = "codewhale-tui", @@ -846,6 +850,7 @@ enum SandboxCommand { #[tokio::main] async fn main() -> Result<()> { configure_windows_console_utf8(); + install_rustls_crypto_provider(); // ── Process hardening (#2183) ───────────────────────────────────────── // MUST run before Tokio is booted and before any threads are spawned. diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 22864c60a..6f2be8640 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -35,13 +35,13 @@ pub mod process_hardening; #[cfg(target_os = "macos")] pub mod seatbelt; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] pub mod landlock; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] pub mod seccomp; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] pub mod bwrap; #[cfg(target_os = "windows")] @@ -223,7 +223,7 @@ pub enum SandboxType { MacosSeatbelt, /// Linux Landlock sandboxing (kernel 5.13+). - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] LinuxLandlock, /// Windows process-containment helper. @@ -240,7 +240,7 @@ impl std::fmt::Display for SandboxType { SandboxType::None => write!(f, "none"), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => write!(f, "macos-seatbelt"), - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] SandboxType::LinuxLandlock => write!(f, "linux-landlock"), #[cfg(target_os = "windows")] SandboxType::Windows => write!(f, "windows-sandbox"), @@ -305,7 +305,7 @@ pub fn get_platform_sandbox() -> Option { } } - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { if landlock::is_available() { return Some(SandboxType::LinuxLandlock); @@ -410,7 +410,7 @@ impl SandboxManager { #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => Self::prepare_seatbelt(spec), - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] SandboxType::LinuxLandlock => self.prepare_landlock(spec), #[cfg(target_os = "windows")] @@ -467,7 +467,7 @@ impl SandboxManager { /// If `prefer_bwrap` is set and `/usr/bin/bwrap` is available, routes the /// command through bubblewrap for stronger filesystem isolation (#2184). /// Otherwise falls back to Landlock markers. - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] fn prepare_landlock(&self, spec: &CommandSpec) -> ExecEnv { // Check if bwrap passthrough should be used (#2184). if self.prefer_bwrap && bwrap::is_available() { @@ -539,7 +539,10 @@ impl SandboxManager { /// This helps distinguish between legitimate command failures and /// sandbox-blocked operations. pub fn was_denied(sandbox_type: SandboxType, exit_code: i32, stderr: &str) -> bool { - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + all(target_os = "linux", not(target_env = "ohos")) + )))] let _ = (exit_code, stderr); match sandbox_type { @@ -548,7 +551,7 @@ impl SandboxManager { #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr), - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] SandboxType::LinuxLandlock => landlock::detect_denial(exit_code, stderr), #[cfg(target_os = "windows")] @@ -558,7 +561,10 @@ impl SandboxManager { /// Get a human-readable description of why a command was blocked. pub fn denial_message(sandbox_type: SandboxType, stderr: &str) -> String { - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(any( + target_os = "macos", + all(target_os = "linux", not(target_env = "ohos")) + )))] let _ = stderr; match sandbox_type { @@ -578,7 +584,7 @@ impl SandboxManager { } } - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] SandboxType::LinuxLandlock => { // Seccomp patterns checked first because they are more specific (#2182). if stderr.contains("Bad system call") @@ -825,7 +831,7 @@ mod tests { } #[test] - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] fn test_parity_linux_landlock_available() { let st = get_platform_sandbox(); assert!(matches!(st, Some(SandboxType::LinuxLandlock))); @@ -844,7 +850,7 @@ mod tests { 0, "" )); - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] assert!(!SandboxManager::was_denied( SandboxType::LinuxLandlock, 0, @@ -855,7 +861,7 @@ mod tests { } #[test] - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] fn test_parity_seccomp_sigsys_detected() { assert!(SandboxManager::was_denied( SandboxType::LinuxLandlock, @@ -891,7 +897,7 @@ mod tests { let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) .with_policy(SandboxPolicy::default()); let env = manager.prepare(&spec); - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { let marker = env.env.get("DEEPSEEK_SANDBOX"); assert!(marker.is_none_or(|v| v != "bwrap")); @@ -905,7 +911,7 @@ mod tests { let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5)) .with_policy(SandboxPolicy::default()); let env = manager.prepare(&spec); - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { if crate::sandbox::bwrap::is_available() { let marker = env.env.get("DEEPSEEK_SANDBOX"); diff --git a/crates/tui/src/sandbox/process_hardening.rs b/crates/tui/src/sandbox/process_hardening.rs index 0c95b48aa..aa90dc078 100644 --- a/crates/tui/src/sandbox/process_hardening.rs +++ b/crates/tui/src/sandbox/process_hardening.rs @@ -45,18 +45,18 @@ /// hardening is defense-in-depth — the sandbox still protects child processes /// even if these prctls fail (e.g., in a container where some are restricted). pub fn apply_process_hardening() { - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { apply_linux_hardening(); } - #[cfg(not(target_os = "linux"))] + #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] { tracing::debug!("Process hardening skipped: not on Linux"); } } /// Linux-specific hardening implementation. -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] fn apply_linux_hardening() { // ── PR_SET_DUMPABLE = 0 ──────────────────────────────────────────────── // diff --git a/crates/tui/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs index f09d00841..6a3d8db34 100644 --- a/crates/tui/src/tools/diagnostics.rs +++ b/crates/tui/src/tools/diagnostics.rs @@ -155,18 +155,18 @@ fn probe_git(workspace: &Path) -> GitProbe { } fn probe_bwrap_available() -> bool { - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { crate::sandbox::bwrap::is_available() } - #[cfg(not(target_os = "linux"))] + #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] { false } } fn probe_cgroup_version() -> Option { - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { let path = std::path::Path::new("/sys/fs/cgroup/cgroup.controllers"); if path.exists() { @@ -178,7 +178,7 @@ fn probe_cgroup_version() -> Option { } None } - #[cfg(not(target_os = "linux"))] + #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] { None } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 5d90c5eba..78c973331 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -34,6 +34,7 @@ use windows::Win32::System::JobObjects::{ #[cfg(windows)] use windows::core::PCWSTR; +#[cfg(not(target_env = "ohos"))] use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use super::shell_output::{summarize_output, truncate_with_meta}; @@ -130,6 +131,7 @@ pub struct ShellDeltaResult { enum ShellChild { Process(Child), + #[cfg(not(target_env = "ohos"))] Pty(Box), } @@ -165,7 +167,7 @@ fn kill_child_process_group(child: &mut Child) -> std::io::Result<()> { /// path (`kill_child_process_group` from the cancellation token) still /// handles normal shutdown; abnormal exit can leak children — tracked as a /// follow-up watchdog item per the original issue's acceptance criteria. -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] fn install_parent_death_signal(cmd: &mut Command) { use std::os::unix::process::CommandExt; // SAFETY: `pre_exec` runs in the child between fork and exec. The closure @@ -227,7 +229,7 @@ fn push_shell_args(cmd: &mut Command, _program: &str, args: &[String]) { cmd.args(args); } -#[cfg(not(target_os = "linux"))] +#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] fn install_parent_death_signal(_cmd: &mut Command) { // No kernel-level equivalent on macOS / Windows. The cooperative // cancellation + process_group SIGKILL path covers normal shutdown; @@ -363,6 +365,7 @@ impl ShellExitStatus { } } + #[cfg(not(target_env = "ohos"))] fn from_pty(status: portable_pty::ExitStatus) -> Self { let code = i32::try_from(status.exit_code()).unwrap_or(i32::MAX); Self { @@ -378,6 +381,7 @@ impl ShellChild { ShellChild::Process(child) => child .try_wait() .map(|status| status.map(ShellExitStatus::from_std)), + #[cfg(not(target_env = "ohos"))] ShellChild::Pty(child) => child .try_wait() .map(|status| status.map(ShellExitStatus::from_pty)), @@ -387,6 +391,7 @@ impl ShellChild { fn wait(&mut self) -> std::io::Result { match self { ShellChild::Process(child) => child.wait().map(ShellExitStatus::from_std), + #[cfg(not(target_env = "ohos"))] ShellChild::Pty(child) => child.wait().map(ShellExitStatus::from_pty), } } @@ -397,6 +402,7 @@ impl ShellChild { ShellChild::Process(child) => kill_child_process_group(child), #[cfg(not(unix))] ShellChild::Process(child) => child.kill(), + #[cfg(not(target_env = "ohos"))] ShellChild::Pty(child) => child.kill(), } } @@ -404,6 +410,7 @@ impl ShellChild { enum StdinWriter { Pipe(ChildStdin), + #[cfg(not(target_env = "ohos"))] Pty(Box), } @@ -411,6 +418,7 @@ impl StdinWriter { fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> { match self { StdinWriter::Pipe(stdin) => stdin.write_all(data), + #[cfg(not(target_env = "ohos"))] StdinWriter::Pty(writer) => writer.write_all(data), } } @@ -418,6 +426,7 @@ impl StdinWriter { fn flush(&mut self) -> std::io::Result<()> { match self { StdinWriter::Pipe(stdin) => stdin.flush(), + #[cfg(not(target_env = "ohos"))] StdinWriter::Pty(writer) => writer.flush(), } } @@ -523,8 +532,14 @@ impl BackgroundShell { // Without this kill, handle.join() blocks indefinitely, freezing the UI // event loop that calls list_jobs() → poll() → collect_output(). #[cfg(unix)] - if let Some(ShellChild::Process(ref mut proc)) = self.child { - let _ = kill_child_process_group(proc); + if let Some(child) = self.child.as_mut() { + match child { + ShellChild::Process(proc) => { + let _ = kill_child_process_group(proc); + } + #[cfg(not(target_env = "ohos"))] + ShellChild::Pty(_) => {} + } } #[cfg(windows)] terminate_and_close_windows_job(self.windows_job.take()); @@ -619,21 +634,25 @@ impl BackgroundShell { /// Kill the process fn kill(&mut self) -> Result<()> { if let Some(ref mut child) = self.child { - if let ShellChild::Process(proc) = child { - #[cfg(windows)] - { - terminate_windows_job(self.windows_job.as_ref(), proc) - .context("Failed to kill process tree")?; - let _ = proc.wait(); + match child { + ShellChild::Process(proc) => { + #[cfg(windows)] + { + terminate_windows_job(self.windows_job.as_ref(), proc) + .context("Failed to kill process tree")?; + let _ = proc.wait(); + } + #[cfg(not(windows))] + { + proc.kill().context("Failed to kill process")?; + let _ = proc.wait(); + } } - #[cfg(not(windows))] - { - proc.kill().context("Failed to kill process")?; - let _ = proc.wait(); + #[cfg(not(target_env = "ohos"))] + ShellChild::Pty(child) => { + child.kill().context("Failed to kill process")?; + let _ = child.wait(); } - } else { - child.kill().context("Failed to kill process")?; - let _ = child.wait(); } } self.status = ShellStatus::Killed; @@ -717,10 +736,14 @@ impl Drop for BackgroundShell { && let Some(ref mut child) = self.child { #[cfg(windows)] - if let ShellChild::Process(proc) = child { - let _ = terminate_windows_job(self.windows_job.as_ref(), proc); - } else { - let _ = child.kill(); + match child { + ShellChild::Process(proc) => { + let _ = terminate_windows_job(self.windows_job.as_ref(), proc); + } + #[cfg(not(target_env = "ohos"))] + ShellChild::Pty(child) => { + let _ = child.kill(); + } } #[cfg(not(windows))] let _ = child.kill(); @@ -1276,6 +1299,13 @@ impl ShellManager { let program = exec_env.program(); let args = exec_env.args(); + #[cfg(target_env = "ohos")] + if tty { + return Err(anyhow!( + "TTY shell mode is not supported on HarmonyOS/OpenHarmony yet." + )); + } + let stdout_buffer = Arc::new(Mutex::new(Vec::new())); let stderr_buffer = if tty { None @@ -1287,45 +1317,51 @@ impl ShellManager { let mut windows_job = None; let (child, stdin, stdout_thread, stderr_thread) = if tty { - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .context("Failed to open PTY")?; + #[cfg(target_env = "ohos")] + unreachable!("OHOS TTY mode returns before PTY setup"); - let mut cmd = CommandBuilder::new(program); - for arg in args { - cmd.arg(arg); + #[cfg(not(target_env = "ohos"))] + { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .context("Failed to open PTY")?; + + let mut cmd = CommandBuilder::new(program); + for arg in args { + cmd.arg(arg); + } + cmd.cwd(working_dir); + child_env::apply_to_pty_command(&mut cmd, child_env::string_map_env(&exec_env.env)); + + let child = pair + .slave + .spawn_command(cmd) + .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?; + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .context("Failed to clone PTY reader")?; + let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer))); + let writer = pair + .master + .take_writer() + .context("Failed to take PTY writer")?; + + ( + ShellChild::Pty(child), + Some(StdinWriter::Pty(writer)), + stdout_thread, + None, + ) } - cmd.cwd(working_dir); - child_env::apply_to_pty_command(&mut cmd, child_env::string_map_env(&exec_env.env)); - - let child = pair - .slave - .spawn_command(cmd) - .with_context(|| format!("Failed to spawn PTY command: {original_command}"))?; - drop(pair.slave); - - let reader = pair - .master - .try_clone_reader() - .context("Failed to clone PTY reader")?; - let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer))); - let writer = pair - .master - .take_writer() - .context("Failed to take PTY writer")?; - - ( - ShellChild::Pty(child), - Some(StdinWriter::Pty(writer)), - stdout_thread, - None, - ) } else { let mut cmd = Command::new(program); push_shell_args(&mut cmd, program, args); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 18d8f2212..46b7c35b3 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -285,7 +285,7 @@ fn test_write_stdin_streams_output() { } #[test] -#[cfg(unix)] +#[cfg(all(unix, not(target_env = "ohos")))] fn background_tty_command_has_controlling_terminal() { let tmp = tempdir().expect("tempdir"); let mut manager = ShellManager::new(tmp.path().to_path_buf()); diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index ac63e7093..5e87846a6 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -12,17 +12,34 @@ use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; #[cfg(any( all(test, unix), - all( - any(target_os = "macos", target_os = "windows", target_os = "linux"), - not(test) - ) + all(not(test), target_os = "macos"), + all(not(test), target_os = "windows"), + all(not(test), target_os = "linux", not(target_env = "ohos")) ))] use std::process::{Command, Stdio}; +#[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +))] use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; +#[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +))] use arboard::{Clipboard, ImageData}; use base64::Engine as _; +#[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +))] use image::{ImageBuffer, Rgba}; const OSC52_MAX_BYTES: usize = 100 * 1024; @@ -53,6 +70,7 @@ impl PastedImage { } /// Clipboard payloads supported by the TUI. +#[cfg_attr(all(target_env = "ohos", not(test)), allow(dead_code))] pub enum ClipboardContent { Text(String), Image(PastedImage), @@ -60,7 +78,19 @@ pub enum ClipboardContent { /// Clipboard reader/writer helper. pub struct ClipboardHandler { + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] clipboard: Option, + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] clipboard_init_attempted: bool, #[cfg(test)] written_text: Vec, @@ -74,7 +104,19 @@ impl ClipboardHandler { /// server (headless, WSL2) never blocks the TUI event loop. pub fn new() -> Self { Self { + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] clipboard: None, + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] clipboard_init_attempted: false, #[cfg(test)] written_text: Vec::new(), @@ -89,6 +131,12 @@ impl ClipboardHandler { /// temporary thread and give it 500 ms; if it doesn't return in time the /// handler stays in fallback/no-op mode and `read`/`write_text` fall /// through to their OSC 52 and pbcopy/powershell fallbacks. + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] fn ensure_clipboard(&mut self) { if self.clipboard_init_attempted { return; @@ -110,23 +158,32 @@ impl ClipboardHandler { /// `workspace` is used as a fallback location when `~/.codewhale/` cannot /// be resolved (e.g. running with a stripped HOME in CI sandboxes). pub fn read(&mut self, workspace: &Path) -> Option { - #[cfg(all(target_os = "linux", not(test)))] + #[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))] if let Ok(text) = read_text_with_wlpaste() { return Some(ClipboardContent::Text(text)); } - self.ensure_clipboard(); - let clipboard = self.clipboard.as_mut()?; - if let Ok(text) = clipboard.get_text() { - return Some(ClipboardContent::Text(text)); - } - - if let Ok(image) = clipboard.get_image() - && let Ok(pasted) = save_image_as_png(workspace, &image) + #[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { - return Some(ClipboardContent::Image(pasted)); + self.ensure_clipboard(); + let clipboard = self.clipboard.as_mut()?; + if let Ok(text) = clipboard.get_text() { + return Some(ClipboardContent::Text(text)); + } + + if let Ok(image) = clipboard.get_image() + && let Ok(pasted) = save_image_as_png(workspace, &image) + { + return Some(ClipboardContent::Image(pasted)); + } } + let _ = workspace; None } @@ -140,16 +197,23 @@ impl ClipboardHandler { #[cfg(not(test))] { - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] if write_text_with_wlcopy(text).is_ok() { return Ok(()); } - self.ensure_clipboard(); - if let Some(clipboard) = self.clipboard.as_mut() - && clipboard.set_text(text.to_string()).is_ok() + #[cfg(any( + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) + ))] { - return Ok(()); + self.ensure_clipboard(); + if let Some(clipboard) = self.clipboard.as_mut() + && clipboard.set_text(text.to_string()).is_ok() + { + return Ok(()); + } } #[cfg(target_os = "macos")] @@ -215,17 +279,17 @@ fn write_text_with_stdin_command( Ok(()) } -#[cfg(all(target_os = "linux", not(test)))] +#[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))] fn write_text_with_wlcopy(text: &str) -> Result<()> { write_text_with_wlcopy_using_argv("wl-copy", text) } -#[cfg(all(target_os = "linux", not(test)))] +#[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))] fn read_text_with_wlpaste() -> Result { read_text_with_wlpaste_using_argv("wl-paste") } -#[cfg(any(all(test, unix), target_os = "linux"))] +#[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))] fn read_text_with_wlpaste_using_argv(program: &str) -> Result { let output = Command::new(program) .arg("--no-newline") @@ -241,7 +305,7 @@ fn read_text_with_wlpaste_using_argv(program: &str) -> Result { String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text") } -#[cfg(all(target_os = "linux", not(test)))] +#[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))] fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> { let mut child = Command::new(program) .stdin(Stdio::piped()) @@ -310,12 +374,24 @@ fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathB /// Encode an RGBA `ImageData` from arboard as PNG and persist it. Returns /// the resulting path along with metadata used to render the paste hint. +#[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +))] fn save_image_as_png(workspace: &Path, image: &ImageData) -> Result { save_image_as_png_in(&clipboard_images_dir(workspace), image) } /// Lower-level variant that writes into an explicit directory. Exposed so the /// unit tests don't have to scribble inside the user's real home directory. +#[cfg(any( + test, + target_os = "macos", + target_os = "windows", + all(target_os = "linux", not(target_env = "ohos")) +))] fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result { std::fs::create_dir_all(dir).context("create clipboard-images dir")?; diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index d674ca773..8fdf019db 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -242,7 +242,7 @@ fn browser_open_command(url: &str) -> Result { Ok(command) } - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { let mut command = Command::new("xdg-open"); command.arg(url); @@ -256,7 +256,11 @@ fn browser_open_command(url: &str) -> Result { Ok(cmd) } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + #[cfg(not(any( + target_os = "macos", + all(target_os = "linux", not(target_env = "ohos")), + target_os = "windows" + )))] Err(anyhow::anyhow!( "browser opening is unsupported on this platform" )) @@ -863,7 +867,7 @@ mod project_mapping_tests { ); } - #[cfg(target_os = "linux")] + #[cfg(all(target_os = "linux", not(target_env = "ohos")))] { assert_eq!(command.get_program(), "xdg-open"); assert_eq!( diff --git a/docs/HarmonyOS.md b/docs/HarmonyOS.md new file mode 100644 index 000000000..f3c091f89 --- /dev/null +++ b/docs/HarmonyOS.md @@ -0,0 +1,78 @@ +# HarmonyOS and OpenHarmony + +This page covers CodeWhale on HarmonyOS PC and OpenHarmony cross-build setups. + +## Running On HarmonyOS PC + +HarmonyOS PC can use the normal Linux ARM64 package when its userspace is +glibc-compatible: + +```bash +npm i -g codewhale +codewhale --version +``` + +You can also download `codewhale-linux-arm64` and +`codewhale-tui-linux-arm64` from the GitHub Releases page and place both +binaries on `PATH`. + +## Cross-Compiling To OpenHarmony + +The repository does not check in machine-specific SDK paths. Set +`OHOS_NATIVE_SDK` to the OpenHarmony native SDK directory, the directory that +contains `llvm/bin`, `sysroot`, and `build/cmake/ohos.toolchain.cmake`. + +On Windows PowerShell: + +```powershell +$env:OHOS_NATIVE_SDK="" +. .\scripts\ohos-env.ps1 +rustup target add aarch64-unknown-linux-ohos +cargo build --target aarch64-unknown-linux-ohos -p codewhale-cli +``` + +On Linux or macOS: + +```bash +export OHOS_NATIVE_SDK=/path/to/openharmony/native +. ./scripts/ohos-env.sh +rustup target add aarch64-unknown-linux-ohos +cargo build --target aarch64-unknown-linux-ohos -p codewhale-cli +``` + +The setup scripts export Cargo's target-specific `linker`, `AR`, `CC`, `CXX`, +`CFLAGS`, `CXXFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CC_SHELL_ESCAPED_FLAGS`, and +CMake toolchain variables for `aarch64-unknown-linux-ohos`. + +## Compiler Wrappers + +For ad-hoc compiler calls, use the root wrappers. They read the same +`OHOS_NATIVE_SDK` variable and do not contain local paths. + +Windows PowerShell: + +```powershell +.\ohos-clang.ps1 --version +.\ohos-clangxx.ps1 --version +``` + +Linux or macOS: + +```bash +sh ./ohos-clang.sh --version +sh ./ohos-clangxx.sh --version +``` + +If you want to run the POSIX wrappers directly as `./ohos-clang.sh`, make them +executable first: + +```bash +chmod +x ./ohos-clang.sh ./ohos-clangxx.sh +``` + +## Cargo Config + +`.cargo/config.toml` intentionally does not set a checked-in linker path. +Cargo cannot expand environment variables inside `linker` or CMake toolchain +path values there, so those values are exported by `scripts/ohos-env.ps1` and +`scripts/ohos-env.sh` instead. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 6afab7549..11be2e52b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -44,6 +44,8 @@ systems such as Alpine should use [Build from source](#7-build-from-source). > and `codewhale-tui-linux-arm64`, so a plain `npm i -g codewhale` works > on any glibc-based ARM64 Linux. If you're stuck on v0.8.7, jump to > [Build from source](#7-build-from-source) — `cargo install` works fine. +> For HarmonyOS PC and OpenHarmony cross-build setup, see +> [HarmonyOS and OpenHarmony](HarmonyOS.md). --- diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 5d0bec7aa..35a8b46bc 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -19,8 +19,9 @@ PR is harvested, superseded, deferred, or closed. 1. Stabilization and PR harvest: finish #2721 and #2722 before new feature work. 2. Provider/model/auth correctness: land narrow correctness fixes that match the current provider architecture. -3. HarmonyOS/MatePad Edge intake: keep #2634 active, scoped, and credited while - the OHOS/Nix dependency clearance work finishes upstream. +3. HarmonyOS/MatePad Edge intake: keep #2634 credited while the local harvest + clears the OHOS/Nix dependency chain; full target-build success still needs a + host with the OpenHarmony native SDK loaded. 4. File decomposition Phase 1: split safe, test-covered config/provider and TUI view surfaces before adding larger workflow UX. 5. WhaleFlow MVP: typed IR, executor skeleton, replay, and pod monitor before @@ -42,7 +43,7 @@ harvest/stewardship commits: | #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | -| #2634 HarmonyOS port | Active HarmonyOS/MatePad Edge lane; do not close. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. PR remains draft/blocked while the author waits on upstream Nix/dependency clearance and carries local patches; full port needs OHOS target checks plus sandbox, TLS, keyring, clipboard, browser-open, and self-update review before merge. | +| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | | #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | @@ -99,7 +100,7 @@ harvest/stewardship commits: | #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | | #2632 tool-catalog JSON cache | Mergeable | Already harvested into the 22-commit stack. | | #2633 capacity reverse scans | Mergeable | Already harvested into the 22-commit stack. | -| #2634 HarmonyOS port | Draft/blocked | Keep as active HarmonyOS/MatePad Edge lane. Do not merge wholesale until upstream Nix/dependency clearance, OHOS target checks, and sandbox/TLS/keyring/clipboard/browser/self-update review are complete. | +| #2634 HarmonyOS port | Draft / locally harvested | Harvested with credit and extra Nix-chain fixes. Keep the original PR open for now; comment after the integration branch is public and request a real OHOS SDK build confirmation from the contributor before closing. | | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | | #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | | #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | diff --git a/ohos-clang.ps1 b/ohos-clang.ps1 new file mode 100644 index 000000000..72b1dbb6d --- /dev/null +++ b/ohos-clang.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + [Console]::Error.WriteLine("error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm\bin and sysroot.") + exit 1 +} + +$sdk = $env:OHOS_NATIVE_SDK +$clang = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") + +if (-not (Test-Path -LiteralPath $clang -PathType Leaf -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain llvm\bin\clang.exe: $sdk") + exit 1 +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain sysroot: $sdk") + exit 1 +} + +& $clang -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ @args +exit $LASTEXITCODE diff --git a/ohos-clang.sh b/ohos-clang.sh new file mode 100644 index 000000000..9ca800597 --- /dev/null +++ b/ohos-clang.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm/bin and sysroot." >&2 + exit 1 +fi + +sdk=$OHOS_NATIVE_SDK +clang=$sdk/llvm/bin/clang +sysroot=$sdk/sysroot + +if [ ! -x "$clang" ]; then + echo "error: OHOS_NATIVE_SDK does not contain executable llvm/bin/clang: $sdk" >&2 + exit 1 +fi + +if [ ! -d "$sysroot" ]; then + echo "error: OHOS_NATIVE_SDK does not contain sysroot: $sdk" >&2 + exit 1 +fi + +exec "$clang" -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ "$@" diff --git a/ohos-clangxx.ps1 b/ohos-clangxx.ps1 new file mode 100644 index 000000000..f1c48e175 --- /dev/null +++ b/ohos-clangxx.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + [Console]::Error.WriteLine("error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm\bin and sysroot.") + exit 1 +} + +$sdk = $env:OHOS_NATIVE_SDK +$clangxx = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang++.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") + +if (-not (Test-Path -LiteralPath $clangxx -PathType Leaf -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain llvm\bin\clang++.exe: $sdk") + exit 1 +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("error: OHOS_NATIVE_SDK does not contain sysroot: $sdk") + exit 1 +} + +& $clangxx -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ @args +exit $LASTEXITCODE diff --git a/ohos-clangxx.sh b/ohos-clangxx.sh new file mode 100644 index 000000000..db8818237 --- /dev/null +++ b/ohos-clangxx.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory. It must contain llvm/bin and sysroot." >&2 + exit 1 +fi + +sdk=$OHOS_NATIVE_SDK +clangxx=$sdk/llvm/bin/clang++ +sysroot=$sdk/sysroot + +if [ ! -x "$clangxx" ]; then + echo "error: OHOS_NATIVE_SDK does not contain executable llvm/bin/clang++: $sdk" >&2 + exit 1 +fi + +if [ ! -d "$sysroot" ]; then + echo "error: OHOS_NATIVE_SDK does not contain sysroot: $sdk" >&2 + exit 1 +fi + +exec "$clangxx" -target aarch64-linux-ohos "--sysroot=$sysroot" -D__MUSL__ "$@" diff --git a/scripts/ohos-env.ps1 b/scripts/ohos-env.ps1 new file mode 100644 index 000000000..99f8373a4 --- /dev/null +++ b/scripts/ohos-env.ps1 @@ -0,0 +1,57 @@ +$ErrorActionPreference = "Stop" + +function Stop-OhosEnv { + param([string]$Message) + + [Console]::Error.WriteLine("error: $Message") + throw "OpenHarmony Cargo environment setup failed." +} + +if ([string]::IsNullOrWhiteSpace($env:OHOS_NATIVE_SDK)) { + Stop-OhosEnv "set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory." +} + +if (-not (Test-Path -LiteralPath $env:OHOS_NATIVE_SDK -PathType Container -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "OHOS_NATIVE_SDK does not exist: $env:OHOS_NATIVE_SDK" +} + +$sdk = (Resolve-Path -LiteralPath $env:OHOS_NATIVE_SDK -ErrorAction Stop).Path +$clang = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang.exe") +$clangxx = [System.IO.Path]::Combine($sdk, "llvm", "bin", "clang++.exe") +$ar = [System.IO.Path]::Combine($sdk, "llvm", "bin", "llvm-ar.exe") +$sysroot = [System.IO.Path]::Combine($sdk, "sysroot") +$cmakeToolchain = [System.IO.Path]::Combine($sdk, "build", "cmake", "ohos.toolchain.cmake") + +$requiredFiles = @($clang, $clangxx, $ar, $cmakeToolchain) +foreach ($path in $requiredFiles) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "required OpenHarmony SDK file is missing: $path" + } +} + +if (-not (Test-Path -LiteralPath $sysroot -PathType Container -ErrorAction SilentlyContinue)) { + Stop-OhosEnv "required OpenHarmony SDK sysroot is missing: $sysroot" +} + +$target = "aarch64_unknown_linux_ohos" +$targetUpper = "AARCH64_UNKNOWN_LINUX_OHOS" +$commonFlags = "-target aarch64-linux-ohos --sysroot=`"$sysroot`" -D__MUSL__" + +$env:CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER = $clang +$env:AR_aarch64_unknown_linux_ohos = $ar +$env:CC_aarch64_unknown_linux_ohos = $clang +$env:CXX_aarch64_unknown_linux_ohos = $clangxx +$env:CC_SHELL_ESCAPED_FLAGS = "1" +Set-Item -Path "Env:CFLAGS_$target" -Value $commonFlags +Set-Item -Path "Env:CXXFLAGS_$target" -Value $commonFlags +Set-Item -Path "Env:CMAKE_TOOLCHAIN_FILE_$target" -Value $cmakeToolchain + +$separator = [char]0x1f +$env:CARGO_ENCODED_RUSTFLAGS = @( + "-Clink-arg=-target", + "-Clink-arg=aarch64-linux-ohos", + "-Clink-arg=--sysroot=$sysroot", + "-Clink-arg=-D__MUSL__" +) -join $separator + +Write-Host "Configured OpenHarmony Cargo environment for $targetUpper from $sdk" diff --git a/scripts/ohos-env.sh b/scripts/ohos-env.sh new file mode 100644 index 000000000..bf1e1eea2 --- /dev/null +++ b/scripts/ohos-env.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh + +if [ -z "${OHOS_NATIVE_SDK:-}" ]; then + echo "error: set OHOS_NATIVE_SDK to the OpenHarmony native SDK directory." >&2 + return 1 2>/dev/null || exit 1 +fi + +if [ ! -d "$OHOS_NATIVE_SDK" ]; then + echo "error: OHOS_NATIVE_SDK does not exist: $OHOS_NATIVE_SDK" >&2 + return 1 2>/dev/null || exit 1 +fi + +sdk=$(cd "$OHOS_NATIVE_SDK" && pwd) +clang=$sdk/llvm/bin/clang +clangxx=$sdk/llvm/bin/clang++ +ar=$sdk/llvm/bin/llvm-ar +sysroot=$sdk/sysroot +cmake_toolchain=$sdk/build/cmake/ohos.toolchain.cmake + +for file in "$clang" "$clangxx" "$ar" "$cmake_toolchain"; do + if [ ! -f "$file" ]; then + echo "error: required OpenHarmony SDK file is missing: $file" >&2 + return 1 2>/dev/null || exit 1 + fi +done + +if [ ! -d "$sysroot" ]; then + echo "error: required OpenHarmony SDK sysroot is missing: $sysroot" >&2 + return 1 2>/dev/null || exit 1 +fi + +export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER=$clang +export AR_aarch64_unknown_linux_ohos=$ar +export CC_aarch64_unknown_linux_ohos=$clang +export CXX_aarch64_unknown_linux_ohos=$clangxx +export CC_SHELL_ESCAPED_FLAGS=1 +export CFLAGS_aarch64_unknown_linux_ohos="-target aarch64-linux-ohos --sysroot=\"$sysroot\" -D__MUSL__" +export CXXFLAGS_aarch64_unknown_linux_ohos="-target aarch64-linux-ohos --sysroot=\"$sysroot\" -D__MUSL__" +export CMAKE_TOOLCHAIN_FILE_aarch64_unknown_linux_ohos=$cmake_toolchain + +sep=$(printf '\037') +export CARGO_ENCODED_RUSTFLAGS="-Clink-arg=-target${sep}-Clink-arg=aarch64-linux-ohos${sep}-Clink-arg=--sysroot=$sysroot${sep}-Clink-arg=-D__MUSL__" + +echo "Configured OpenHarmony Cargo environment for AARCH64_UNKNOWN_LINUX_OHOS from $sdk" From fb86737a8c0dc130a194d51c96c2c3145ca2db68 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 20:56:00 -0700 Subject: [PATCH 037/209] test(settings): assert migrated settings display canonical path Extend the #2730 settings migration harvest with the missing platform-config fallback display assertion from review, and keep the v0.9 execution map/changelog credit current. Validation: cargo fmt --all -- --check; git diff --check; cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture; cargo test -p codewhale-tui --bin codewhale-tui --locked display_localizes_header_and_config_file_label -- --nocapture. Harvested from PR #2730 by @xyuai. Co-authored-by: xyuai <281015099+xyuai@users.noreply.github.com> --- CHANGELOG.md | 6 +++++- crates/tui/src/settings.rs | 5 +++++ docs/V0_9_0_EXECUTION_MAP.md | 11 ++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6212dc7a..0af57fe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI + settings while still reading legacy DeepSeek-branded settings fallbacks and + migrating them into the CodeWhale home on load. - Split `web_run` session/page cache state so cached page reads use shared page handles and do not serialize through the mutation path. The harvest also adds panic-safe state write-back and serializes cache-mutating unit tests so @@ -47,7 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata -prefix-cache stability work (#2517), and **@shenjackyuanjie** for the +prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale +settings-path migration work (#2730), and **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 301ab753b..24636e1e3 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -2451,6 +2451,11 @@ mod tests { primary.exists(), "legacy fallback should be copied into primary" ); + let display = loaded.display(crate::localization::Locale::En); + assert!( + display.contains(&format!("Config file: {}", primary.display())), + "settings display should surface the canonical codewhale path:\n{display}" + ); } #[test] diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 35a8b46bc..08d697776 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -8,11 +8,11 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts -- Actual open issues: 444 -- Open PRs: 54 -- Repo API open issue count: 498, because GitHub includes PRs in that total +- Actual open issues: 445 +- Open PRs: 55 +- Repo API open issue count: 500, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 -- Open issues without a milestone: 100 +- Open issues without a milestone: 101 ## Execution Order @@ -42,6 +42,7 @@ harvest/stewardship commits: | --- | --- | --- | | #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -108,7 +109,7 @@ harvest/stewardship commits: | #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | | #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | | #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | -| #2730 canonical codewhale settings path | Mergeable | Already harvested into the 22-commit stack. | +| #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | ## Issue Reduction Strategy From 002f8f0ba1c970b8d7d220cbb9606020084a55cb Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:07:33 -0700 Subject: [PATCH 038/209] ci: enforce mappable co-author credit Add AUTHOR_MAP plus a lightweight co-author trailer checker so harvested commits use numeric GitHub noreply identities, reject bot/tool trailers, and require machine-readable credit when a commit says it was harvested from a PR. Also normalize the local unpushed v0.9 harvest range so existing contributor authors/trailers for HUQIANTAO, Implementist, jrcjrcc, xyuai, cyq1017, idling11, and shenjackyuanjie use GitHub-mappable identities before the branch is published. Validation: python3 scripts/check-coauthor-trailers.py --author-map .github/AUTHOR_MAP --range origin/main..HEAD --check-authors; python3 -m py_compile scripts/check-coauthor-trailers.py; ruby -e 'require "yaml"; YAML.load_file(".github/workflows/ci.yml")'; git diff --check; negative in-process validation for raw email, missing harvested credit, and bot author cases. --- .github/AUTHOR_MAP | 90 +++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ci.yml | 18 +++ CHANGELOG.md | 3 + CONTRIBUTING.md | 6 +- docs/AGENT_ETHOS.md | 6 + docs/V0_9_0_EXECUTION_MAP.md | 1 + scripts/check-coauthor-trailers.py | 245 +++++++++++++++++++++++++++++ 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 .github/AUTHOR_MAP create mode 100644 scripts/check-coauthor-trailers.py diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP new file mode 100644 index 000000000..26bca8cf6 --- /dev/null +++ b/.github/AUTHOR_MAP @@ -0,0 +1,90 @@ +# Contributor credit identity map. +# +# Format: +# alias = Display Name +# +# The right-hand side must use GitHub's numeric noreply address so harvested +# co-author credit lands in the contributor graph. The left-hand side may be a +# GitHub login, old-style noreply address, raw email from a contributor commit, +# or local machine email seen in older harvested history. + +hmbown = Hmbown <101357273+Hmbown@users.noreply.github.com> +reidliu41 = reidliu41 <61492567+reidliu41@users.noreply.github.com> +reid201711@gmail.com = reidliu41 <61492567+reidliu41@users.noreply.github.com> +HUQIANTAO = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com> +h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> +axobase001 = axobase001 <138223345+axobase001@users.noreply.github.com> +donglovejava = donglovejava <211940267+donglovejava@users.noreply.github.com> +Oliver-ZPLiu = Oliver-ZPLiu <47081637+Oliver-ZPLiu@users.noreply.github.com> +idling11 = idling11 <8055620+idling11@users.noreply.github.com> +Hanmiao Li = idling11 <8055620+idling11@users.noreply.github.com> +894876246@qq.com = idling11 <8055620+idling11@users.noreply.github.com> +angziii = angziii <177907677+angziii@users.noreply.github.com> +aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +encyc = encyc <62669951+encyc@users.noreply.github.com> +Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com> +cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com> +cyq = cyq1017 <61975706+cyq1017@users.noreply.github.com> +15000851237@163.com = cyq1017 <61975706+cyq1017@users.noreply.github.com> +zlh124 = zlh124 <56312993+zlh124@users.noreply.github.com> +THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com> +nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com> +Liu-Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +liuwenchang.x@qq.com = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com> +JiarenWang = JiarenWang <33421508+JiarenWang@users.noreply.github.com> +wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com> +pengyou200902 = pengyou200902 <35026241+pengyou200902@users.noreply.github.com> +linzhiqin2003 = linzhiqin2003 <123250980+linzhiqin2003@users.noreply.github.com> +LING71671 = LING71671 <231181387+LING71671@users.noreply.github.com> +JasonOA888 = JasonOA888 <101583541+JasonOA888@users.noreply.github.com> +Inference1 = Inference1 <68734681+Inference1@users.noreply.github.com> +hongqitai = hongqitai <188678175+hongqitai@users.noreply.github.com> +gordonlu = gordonlu <3125629+gordonlu@users.noreply.github.com> +gaord = gaord <9567937+gaord@users.noreply.github.com> +Ben Gao = gaord <9567937+gaord@users.noreply.github.com> +bengao168@msn.com = gaord <9567937+gaord@users.noreply.github.com> +zhuangbiaowei = zhuangbiaowei <93194+zhuangbiaowei@users.noreply.github.com> +yuanchenglu = yuanchenglu <4088730+yuanchenglu@users.noreply.github.com> +Vishnu1837 = Vishnu1837 <104626273+Vishnu1837@users.noreply.github.com> +sximelon = sximelon <15710511+sximelon@users.noreply.github.com> +Sskift = Sskift <163287349+Sskift@users.noreply.github.com> +New2Niu = New2Niu <19551155+New2Niu@users.noreply.github.com> +mvanhorn = mvanhorn <455140+mvanhorn@users.noreply.github.com> +MengZ-super = MengZ-super <121712068+MengZ-super@users.noreply.github.com> +membphis = membphis <6814606+membphis@users.noreply.github.com> +LeoAlex0 = LeoAlex0 <31839998+LeoAlex0@users.noreply.github.com> +Lee-take = Lee-take <210963840+Lee-take@users.noreply.github.com> +lbcheng888 = lbcheng888 <6716643+lbcheng888@users.noreply.github.com> +kunpeng-ai-lab = kunpeng-ai-lab <16793595+kunpeng-ai-lab@users.noreply.github.com> +elowen53 = elowen53 <88364845+elowen53@users.noreply.github.com> +Elowen = elowen53 <88364845+elowen53@users.noreply.github.com> +xrnc@outlook.com = elowen53 <88364845+elowen53@users.noreply.github.com> +CrepuscularIRIS = CrepuscularIRIS <126939795+CrepuscularIRIS@users.noreply.github.com> +chnjames = chnjames <44110547+chnjames@users.noreply.github.com> +ChaceLyee2101 = ChaceLyee2101 <95995339+ChaceLyee2101@users.noreply.github.com> +AresNing = AresNing <49557311+AresNing@users.noreply.github.com> + +shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +shenjack = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +3695888@qq.com = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> +xyuai = xyuai <281015099+xyuai@users.noreply.github.com> +Implementist = Implementist <24910011+Implementist@users.noreply.github.com> +implecao = Implementist <24910011+Implementist@users.noreply.github.com> +yuyuyu4993@qq.com = Implementist <24910011+Implementist@users.noreply.github.com> +jrcjrcc = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +jrcjrcc@users.noreply.github.com = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> +RefuseOdd = RefuseOdd <192543033+RefuseOdd@users.noreply.github.com> +wywsoor = wywsoor <26341601+wywsoor@users.noreply.github.com> +hsdbeebou = hsdbeebou <284843096+hsdbeebou@users.noreply.github.com> +tdccccc = tdccccc <79492752+tdccccc@users.noreply.github.com> +greyfreedom = greyfreedom <11493871+greyfreedom@users.noreply.github.com> +greyfreedom@163.com = greyfreedom <11493871+greyfreedom@users.noreply.github.com> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index db22692be..f5ab2949c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,3 +11,4 @@ - [ ] Updated docs or comments as needed - [ ] Added or updated tests where relevant - [ ] Verified TUI behavior manually if UI changes +- [ ] Harvested/co-authored credit uses a GitHub numeric noreply address diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55d518325..7fafe26ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -50,6 +52,22 @@ jobs: run: cargo clippy --workspace --all-features --locked -- -D warnings - name: Check provider registry drift run: python3 scripts/check-provider-registry.py + - name: Check harvested contributor credit + if: github.event_name != 'schedule' + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + git fetch --no-tags origin "${{ github.base_ref }}" + RANGE="origin/${{ github.base_ref }}..HEAD" + elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + RANGE="${{ github.event.before }}..${{ github.sha }}" + else + RANGE="HEAD~1..HEAD" + fi + python3 scripts/check-coauthor-trailers.py \ + --author-map .github/AUTHOR_MAP \ + --range "$RANGE" \ + --check-authors - name: Linux clippy location run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches." diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af57fe87..5ed859a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 explicit Rustls ring-provider installation for the no-provider TLS build, and OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, execpolicy Starlark parsing, and self-update surfaces. +- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested + commits use GitHub-mappable numeric noreply identities instead of `.local`, + placeholder, bot/tool, or raw third-party emails. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66328675a..7f8d3c678 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,8 +98,12 @@ When this happens: - If the maintainer copies or adapts your code, the harvested commit also keeps attribution with the original author identity when possible: either by preserving the commit author on a cherry-pick or by adding a - `Co-authored-by: Name ` trailer from the original PR commit. This is + `Co-authored-by: Name ` trailer. This is what lets GitHub's contribution surfaces recognize more than prose credit. + Maintainers should use `.github/AUTHOR_MAP`, or run + `gh api users/ --jq '"\(.id)+\(.login)@users.noreply.github.com"'`, + rather than copying raw, `.local`, or old-style noreply emails from a + contributor's machine. - The `CHANGELOG.md` entry for the next release credits you by handle. - The auto-close workflow closes your PR with a templated thank-you and a link to the commit on `main`. diff --git a/docs/AGENT_ETHOS.md b/docs/AGENT_ETHOS.md index 156b95dad..73a3507a8 100644 --- a/docs/AGENT_ETHOS.md +++ b/docs/AGENT_ETHOS.md @@ -23,6 +23,12 @@ could not cover by ourselves. issues, keep author/co-author attribution where possible, add `Harvested from PR #N by @handle`, and credit the contributor in the changelog or release notes. +- Make credit machine-readable. If a harvested commit cannot preserve the + contributor as the author, add a `Co-authored-by` trailer with the GitHub + numeric noreply address from `.github/AUTHOR_MAP` or + `gh api users/ --jq '"\(.id)+\(.login)@users.noreply.github.com"'`. + Do not use `.local`, placeholder, bot/tool, or raw third-party emails for + human contributor credit. - Deferral is a maintainer action, not a dismissal. If a PR or issue is not ready, say what is blocked, what evidence would change the decision, and which part of the work remains valuable. diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 08d697776..d4cd7789f 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -43,6 +43,7 @@ harvest/stewardship commits: | #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | +| Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | diff --git a/scripts/check-coauthor-trailers.py b/scripts/check-coauthor-trailers.py new file mode 100644 index 000000000..527c204f1 --- /dev/null +++ b/scripts/check-coauthor-trailers.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Validate that harvested contributor credit is GitHub-mappable. + +The check is intentionally scoped to new commits. Historical commits may carry +raw or local emails, but new harvested commits should use GitHub's numeric +`id+login@users.noreply.github.com` address so co-author credit lands in the +contributor graph. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_AUTHOR_MAP = ROOT / ".github" / "AUTHOR_MAP" + +IDENTITY_RE = re.compile(r"^\s*(?P.+?)\s*<(?P[^<>]+)>\s*$") +CANONICAL_NOREPLY_RE = re.compile( + r"^[0-9]+\+[^@\s]+@users\.noreply\.github\.com$", re.IGNORECASE +) +COAUTHOR_RE = re.compile( + r"^Co-authored-by:\s*(?P.*?)\s*<(?P[^<>]+)>\s*$", + re.IGNORECASE | re.MULTILINE, +) +HARVEST_RE = re.compile(r"Harvested from PR #[0-9]+ by @([A-Za-z0-9-]+)") + +BOT_EMAILS = { + "codex@local", + "codex@example.com", + "cursoragent@cursor.com", + "noreply@anthropic.com", +} +BOT_NAMES = ("claude", "codex", "cursor") + + +@dataclass(frozen=True) +class Identity: + name: str + email: str + + def trailer(self) -> str: + return f"Co-authored-by: {self.name} <{self.email}>" + + def author(self) -> str: + return f"{self.name} <{self.email}>" + + +@dataclass(frozen=True) +class Commit: + sha: str + author_name: str + author_email: str + subject: str + body: str + + +def norm_key(value: str) -> str: + return value.strip().lower() + + +def github_login_from_noreply(email: str) -> str | None: + if not CANONICAL_NOREPLY_RE.match(email): + return None + local = email.split("@", 1)[0] + return local.split("+", 1)[1] + + +def parse_identity(raw: str, context: str) -> Identity: + match = IDENTITY_RE.match(raw) + if not match: + raise ValueError(f"{context}: expected 'Name '") + identity = Identity(match.group("name").strip(), match.group("email").strip()) + if not CANONICAL_NOREPLY_RE.match(identity.email): + raise ValueError( + f"{context}: right-hand email must be numeric GitHub noreply, got {identity.email}" + ) + return identity + + +def load_author_map(path: Path) -> dict[str, Identity]: + aliases: dict[str, Identity] = {} + for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + if "=" not in line: + raise ValueError(f"{path}:{lineno}: expected 'alias = Name '") + alias, raw_identity = [part.strip() for part in line.split("=", 1)] + identity = parse_identity(raw_identity, f"{path}:{lineno}") + key = norm_key(alias) + if key in aliases and aliases[key] != identity: + raise ValueError(f"{path}:{lineno}: duplicate alias {alias!r}") + aliases[key] = identity + aliases.setdefault(norm_key(identity.email), identity) + aliases.setdefault(norm_key(identity.name), identity) + if login := github_login_from_noreply(identity.email): + aliases.setdefault(norm_key(login), identity) + return aliases + + +def git_log(commit_range: str) -> list[Commit]: + try: + raw = subprocess.check_output( + [ + "git", + "log", + "--format=%H%x00%an%x00%ae%x00%s%x00%B%x1e", + commit_range, + ], + cwd=ROOT, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"failed to read git range {commit_range!r}: {exc}") from exc + + commits: list[Commit] = [] + for record in raw.split("\x1e"): + if not record.strip(): + continue + parts = record.split("\x00", 4) + if len(parts) != 5: + raise RuntimeError("failed to parse git log output") + commits.append(Commit(*parts)) + return commits + + +def is_bot_identity(name: str, email: str) -> bool: + lowered_name = name.strip().lower() + lowered_email = email.strip().lower() + return lowered_email in BOT_EMAILS or any( + lowered_name == bot or lowered_name.startswith(f"{bot} ") for bot in BOT_NAMES + ) + + +def lookup_identity(aliases: dict[str, Identity], *values: str) -> Identity | None: + for value in values: + identity = aliases.get(norm_key(value)) + if identity is not None: + return identity + return None + + +def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: bool) -> list[str]: + errors: list[str] = [] + for commit in commits: + prefix = f"{commit.sha[:10]} {commit.subject}" + coauthors = [ + Identity(match.group("name").strip(), match.group("email").strip()) + for match in COAUTHOR_RE.finditer(commit.body) + ] + + if check_authors: + if is_bot_identity(commit.author_name, commit.author_email): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> is a " + "bot/tool identity. Human harvested work should preserve the contributor " + "as author or use a human co-author trailer." + ) + elif ( + (expected := lookup_identity(aliases, commit.author_email, commit.author_name)) + and norm_key(commit.author_email) != norm_key(expected.email) + ): + errors.append( + f"{prefix}: author {commit.author_name} <{commit.author_email}> " + f"matches AUTHOR_MAP but is not canonical. Use author {expected.author()}." + ) + + for coauthor in coauthors: + if CANONICAL_NOREPLY_RE.match(coauthor.email): + continue + if is_bot_identity(coauthor.name, coauthor.email): + errors.append( + f"{prefix}: remove bot/tool co-author trailer " + f"{coauthor.name} <{coauthor.email}>; contributor trailers are for humans." + ) + continue + expected = lookup_identity(aliases, coauthor.email, coauthor.name) + if expected: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + f"GitHub-mappable. Use `{expected.trailer()}`." + ) + else: + errors.append( + f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not " + "numeric GitHub noreply and has no AUTHOR_MAP entry. Add an alias " + "or use `gh api users/ --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`." + ) + + coauthor_emails = {norm_key(coauthor.email) for coauthor in coauthors} + for login in HARVEST_RE.findall(commit.body): + expected = lookup_identity(aliases, login) + if expected is None: + errors.append( + f"{prefix}: harvested contributor @{login} is missing from .github/AUTHOR_MAP." + ) + continue + if ( + norm_key(commit.author_email) != norm_key(expected.email) + and norm_key(expected.email) not in coauthor_emails + ): + errors.append( + f"{prefix}: `Harvested from PR ... by @{login}` needs machine-readable " + f"credit. Add `{expected.trailer()}` or preserve the contributor as author." + ) + return errors + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--author-map", type=Path, default=DEFAULT_AUTHOR_MAP) + parser.add_argument("--range", default="origin/main..HEAD", help="git commit range to check") + parser.add_argument( + "--check-authors", + action="store_true", + help="also reject commit author emails that match known AUTHOR_MAP aliases", + ) + args = parser.parse_args(argv) + + try: + aliases = load_author_map(args.author_map) + commits = git_log(args.range) + errors = validate(commits, aliases, args.check_authors) + except Exception as exc: + print(f"co-author credit check failed to run: {exc}", file=sys.stderr) + return 2 + + if errors: + print("Co-author credit check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Co-author credit check passed for {len(commits)} commit(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 66c88ddfaef0d27c05d7d31c12decec6e34459d8 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:11:55 -0700 Subject: [PATCH 039/209] feat(runtime): allow thread workspace updates Harvest the UpdateThreadRequest workspace field from PR #2640 while keeping the engine-cache correctness fix: PATCH /v1/threads/{id} now persists workspace changes, emits the workspace change in thread.updated, rejects empty paths, rejects workspace changes while a turn is active, and evicts idle cached engines so the next turn starts in the new workspace. Validation: cargo fmt --all -- --check; git diff --check; cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture; cargo clippy -p codewhale-tui --locked -- -D warnings; python3 scripts/check-coauthor-trailers.py --author-map .github/AUTHOR_MAP --range origin/main..HEAD --check-authors. Harvested from PR #2640 by @gaord. Co-authored-by: gaord <9567937+gaord@users.noreply.github.com> --- CHANGELOG.md | 6 +- crates/tui/src/runtime_threads.rs | 208 ++++++++++++++++++++++++++++++ docs/V0_9_0_EXECUTION_MAP.md | 3 +- 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed859a5b..069fbbfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI settings while still reading legacy DeepSeek-branded settings fallbacks and migrating them into the CodeWhale home on load. +- `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for + GUI/runtime clients. Workspace changes reject active turns and evict idle + cached engines so the next turn starts in the new workspace. - Split `web_run` session/page cache state so cached page reads use shared page handles and do not serialize through the mutation path. The harvest also adds panic-safe state write-back and serializes cache-mutating unit tests so @@ -54,7 +57,8 @@ Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale -settings-path migration work (#2730), and **@shenjackyuanjie** for the +settings-path migration work (#2730), **@gaord** for the runtime thread +workspace update API (#2640), and **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 6973a9c3d..69e12e15d 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -596,6 +596,7 @@ pub struct UpdateThreadRequest { pub mode: Option, pub title: Option, pub system_prompt: Option, + pub workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1089,6 +1090,7 @@ impl RuntimeThreadManager { && req.mode.is_none() && req.title.is_none() && req.system_prompt.is_none() + && req.workspace.is_none() { bail!("At least one thread field is required"); } @@ -1103,6 +1105,11 @@ impl RuntimeThreadManager { { bail!("mode must not be empty"); } + if let Some(workspace) = req.workspace.as_ref() + && workspace.as_os_str().is_empty() + { + bail!("workspace must not be empty"); + } let mut thread = self.get_thread(id).await?; let mut changes = serde_json::Map::new(); @@ -1166,10 +1173,24 @@ impl RuntimeThreadManager { changes.insert("system_prompt".to_string(), json!(new_sys)); } } + if let Some(workspace) = req.workspace + && thread.workspace != workspace + { + changes.insert("workspace".to_string(), json!(workspace)); + thread.workspace = workspace; + } if !changes.is_empty() { + let workspace_changed = changes.contains_key("workspace"); + if workspace_changed { + self.ensure_thread_has_no_active_turn(&thread.id).await?; + } + thread.updated_at = Utc::now(); self.store.save_thread(&thread)?; + if workspace_changed { + self.evict_cached_engine(&thread.id).await; + } self.emit_event( &thread.id, None, @@ -1186,6 +1207,30 @@ impl RuntimeThreadManager { Ok(thread) } + async fn ensure_thread_has_no_active_turn(&self, thread_id: &str) -> Result<()> { + let active = self.active.lock().await; + if active + .engines + .get(thread_id) + .and_then(|state| state.active_turn.as_ref()) + .is_some() + { + bail!("workspace cannot be changed while the thread has an active turn"); + } + Ok(()) + } + + async fn evict_cached_engine(&self, thread_id: &str) { + let engine = { + let mut active = self.active.lock().await; + active.lru.retain(|id| id != thread_id); + active.engines.remove(thread_id).map(|state| state.engine) + }; + if let Some(engine) = engine { + let _ = engine.send(Op::Shutdown).await; + } + } + pub async fn get_thread_detail(&self, id: &str) -> Result { let thread = self.get_thread(id).await?; let turns = self.store.list_turns_for_thread(id)?; @@ -3777,6 +3822,169 @@ mod tests { Ok(()) } + #[tokio::test] + async fn update_thread_workspace_persists_event_and_evicts_idle_engine() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let old_workspace = std::env::temp_dir().join("codewhale-runtime-old-workspace"); + let new_workspace = std::env::temp_dir().join("codewhale-runtime-new-workspace"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(old_workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let updated = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace.clone()), + ..UpdateThreadRequest::default() + }, + ) + .await?; + + assert_eq!(updated.workspace, new_workspace); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace, + new_workspace + ); + { + let active = manager.active.lock().await; + assert!( + !active.engines.contains_key(&thread.id), + "workspace changes must evict the stale cached engine" + ); + assert!(!active.lru.iter().any(|id| id == &thread.id)); + } + + match tokio::time::timeout(Duration::from_secs(1), rx_op.recv()).await { + Ok(Some(Op::Shutdown)) => {} + other => panic!("expected cached engine shutdown, got {other:?}"), + } + + let events = manager.events_since(&thread.id, None)?; + let event = events + .iter() + .rev() + .find(|event| event.event == "thread.updated") + .expect("thread.updated event"); + let workspace_value = serde_json::to_value(&updated.workspace)?; + assert_eq!( + event + .payload + .get("changes") + .and_then(|changes| changes.get("workspace")), + Some(&workspace_value) + ); + Ok(()) + } + + #[tokio::test] + async fn update_thread_workspace_rejects_empty_path() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(PathBuf::new()), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("empty workspace must be rejected"); + assert!(format!("{err:#}").contains("workspace must not be empty")); + Ok(()) + } + + #[tokio::test] + async fn update_thread_workspace_rejects_active_turn() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let old_workspace = std::env::temp_dir().join("codewhale-runtime-active-old"); + let new_workspace = std::env::temp_dir().join("codewhale-runtime-active-new"); + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: Some(old_workspace.clone()), + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + task_id: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + { + let mut active = manager.active.lock().await; + let state = active.engines.get_mut(&thread.id).expect("mock engine"); + state.active_turn = Some(ActiveTurnState { + turn_id: "turn_live".to_string(), + interrupt_requested: false, + auto_approve: false, + trust_mode: false, + }); + } + + let err = manager + .update_thread( + &thread.id, + UpdateThreadRequest { + workspace: Some(new_workspace), + ..UpdateThreadRequest::default() + }, + ) + .await + .expect_err("workspace update during active turn must fail"); + + assert!(format!("{err:#}").contains("active turn")); + assert_eq!( + manager.store.load_thread(&thread.id)?.workspace, + old_workspace + ); + { + let active = manager.active.lock().await; + assert!( + active.engines.contains_key(&thread.id), + "active engine should stay cached after rejected update" + ); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), rx_op.recv()) + .await + .is_err(), + "rejected workspace update must not shut down the active engine" + ); + Ok(()) + } + #[tokio::test] async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> { let manager = test_manager(test_runtime_dir())?; diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index d4cd7789f..6c49a789d 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -44,6 +44,7 @@ harvest/stewardship commits: | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | +| #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -106,7 +107,7 @@ harvest/stewardship commits: | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | | #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | | #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | -| #2640 workspace field on UpdateThreadRequest | Mergeable | Defer; app-server contract needs focused review. | +| #2640 workspace field on UpdateThreadRequest | Mergeable | Harvested locally with extra tests and engine-cache invalidation. Comment/close original after integration branch is public, crediting @gaord. | | #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | | #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | | #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | From 7ac8063b6b61c11b333a49c1dff505772ce4882d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:31:09 -0700 Subject: [PATCH 040/209] feat(plan): preserve rich PlanArtifact context Harvested from PR #2733 by @idling11. Adds richer update_plan artifact fields for grounded Plan-mode review, renders them in the transcript and Plan confirmation prompt, and carries them through /relay, fork-state, and saved-session replay. Verification: cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture Verification: cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture Verification: cargo clippy -p codewhale-tui --locked -- -D warnings Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 9 +- crates/tui/src/commands/mod.rs | 60 +++- crates/tui/src/core/engine.rs | 41 ++- crates/tui/src/core/engine/tests.rs | 40 +++ crates/tui/src/prompts/modes/plan.md | 4 + crates/tui/src/session_manager.rs | 59 ++++ crates/tui/src/tools/plan.rs | 458 +++++++++++++++++++++++++-- crates/tui/src/tui/app.rs | 1 + crates/tui/src/tui/history.rs | 212 ++++++++++--- crates/tui/src/tui/plan_prompt.rs | 302 ++++++++++++++++-- crates/tui/src/tui/sidebar.rs | 8 +- crates/tui/src/tui/tool_routing.rs | 94 ++++-- docs/GUIDE.md | 5 + docs/TOOL_SURFACE.md | 16 + docs/V0_9_0_EXECUTION_MAP.md | 2 + 15 files changed, 1170 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069fbbfe3..9b552328e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. +- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry + grounded objectives, context, sources, critical files, constraints, + verification, risks, and handoff notes through the transcript card, Plan + confirmation prompt, `/relay`, fork-state, and saved-session replay. ### Changed @@ -58,8 +62,9 @@ Thanks to **@cyq1017** for the restore-listing implementation (#2513) and **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread -workspace update API (#2640), and **@shenjackyuanjie** for the -HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634). +workspace update API (#2640), **@shenjackyuanjie** for the +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), and +**@idling11** for the PlanArtifact direction in Plan mode (#2733). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 8a84cd22e..bccc8e65e 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -854,11 +854,35 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { if let Ok(plan) = app.plan_state.try_lock() { let snapshot = plan.snapshot(); - if snapshot.explanation.is_some() || !snapshot.items.is_empty() { + if !snapshot.is_empty() { let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); - if let Some(explanation) = snapshot.explanation.as_deref() { - let _ = writeln!(out, "- Explanation: {explanation}"); - } + write_plan_field(&mut out, "Title", snapshot.title.as_deref()); + write_plan_field(&mut out, "Objective", snapshot.objective.as_deref()); + write_plan_field(&mut out, "Context", snapshot.context_summary.as_deref()); + write_plan_field(&mut out, "Explanation", snapshot.explanation.as_deref()); + write_plan_list(&mut out, "Source", &snapshot.sources_used); + write_plan_list(&mut out, "Critical file", &snapshot.critical_files); + write_plan_list(&mut out, "Constraint", &snapshot.constraints); + write_plan_field( + &mut out, + "Recommended approach", + snapshot.recommended_approach.as_deref(), + ); + write_plan_field( + &mut out, + "Verification plan", + snapshot.verification_plan.as_deref(), + ); + write_plan_field( + &mut out, + "Risks and unknowns", + snapshot.risks_and_unknowns.as_deref(), + ); + write_plan_field( + &mut out, + "Handoff packet", + snapshot.handoff_packet.as_deref(), + ); for item in snapshot.items { let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); } @@ -904,6 +928,21 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { out } +fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + let _ = writeln!(out, "- {label}: {value}"); + } +} + +fn write_plan_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + let _ = writeln!(out, "- {label}: {value}"); + } + } +} + fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { match status { crate::tools::plan::StepStatus::Pending => "pending", @@ -1166,11 +1205,18 @@ mod tests { { let mut plan = app.plan_state.try_lock().expect("plan lock"); plan.update(UpdatePlanArgs { + objective: Some("Keep relays grounded".to_string()), explanation: Some("RLM-style strategy".to_string()), + sources_used: vec!["transcript context".to_string()], + critical_files: vec!["crates/tui/src/commands/mod.rs".to_string()], + constraints: vec!["Do not invent verification".to_string()], + verification_plan: Some("Check relay prompt assertions".to_string()), + handoff_packet: Some("Next thread should read the Work checklist".to_string()), plan: vec![PlanItemArg { step: "keep checklist primary".to_string(), status: StepStatus::InProgress, }], + ..UpdatePlanArgs::default() }); } @@ -1197,7 +1243,13 @@ mod tests { assert!(message.contains("#1 [completed] inspect workspace")); assert!(message.contains("#2 [in_progress] patch relay command")); assert!(message.contains("Optional strategy metadata from update_plan")); + assert!(message.contains("Objective: Keep relays grounded")); assert!(message.contains("Explanation: RLM-style strategy")); + assert!(message.contains("Source: transcript context")); + assert!(message.contains("Critical file: crates/tui/src/commands/mod.rs")); + assert!(message.contains("Constraint: Do not invent verification")); + assert!(message.contains("Verification plan: Check relay prompt assertions")); + assert!(message.contains("Handoff packet: Next thread should read the Work checklist")); assert!(message.contains("[in_progress] keep checklist primary")); } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index b3477950a..79cfb9b10 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -168,9 +168,29 @@ impl StructuredState { if let Some(plan) = self.plan_snapshot.as_ref() { out.push_str("\nStrategy metadata\n"); - if let Some(explanation) = plan.explanation.as_ref() { - out.push_str(&format!("{explanation}\n\n")); - } + append_plan_field(&mut out, "Title", plan.title.as_deref()); + append_plan_field(&mut out, "Objective", plan.objective.as_deref()); + append_plan_field(&mut out, "Context", plan.context_summary.as_deref()); + append_plan_field(&mut out, "Explanation", plan.explanation.as_deref()); + append_plan_list(&mut out, "Source", &plan.sources_used); + append_plan_list(&mut out, "Critical file", &plan.critical_files); + append_plan_list(&mut out, "Constraint", &plan.constraints); + append_plan_field( + &mut out, + "Recommended approach", + plan.recommended_approach.as_deref(), + ); + append_plan_field( + &mut out, + "Verification plan", + plan.verification_plan.as_deref(), + ); + append_plan_field( + &mut out, + "Risks and unknowns", + plan.risks_and_unknowns.as_deref(), + ); + append_plan_field(&mut out, "Handoff packet", plan.handoff_packet.as_deref()); for item in &plan.items { let marker = match item.status { crate::tools::plan::StepStatus::Pending => "[ ]", @@ -204,6 +224,21 @@ impl StructuredState { } } +fn append_plan_field(out: &mut String, label: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + out.push_str(&format!("- {label}: {value}\n")); + } +} + +fn append_plan_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + out.push_str(&format!("- {label}: {value}\n")); + } + } +} + // === Types === /// Configuration for the engine diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 9e48a376f..186ba2d0e 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3,6 +3,7 @@ use super::*; use super::context::TURN_MAX_OUTPUT_TOKENS; use crate::models::SystemBlock; use crate::test_support::lock_test_env; +use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; use crate::tools::spec::ToolCapability; use serde_json::json; use std::collections::{HashMap, HashSet}; @@ -84,6 +85,45 @@ fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine { engine } +#[test] +fn structured_state_block_includes_rich_plan_artifact() { + let state = StructuredState { + mode_label: "Plan".to_string(), + workspace: PathBuf::from("/workspace/codewhale"), + cwd: None, + working_set_summary: None, + todo_snapshot: None, + plan_snapshot: Some(PlanSnapshot { + objective: Some("Make Plan mode reviewable".to_string()), + context_summary: Some("Grounded in issue #2691".to_string()), + sources_used: vec!["gh issue view 2691".to_string()], + critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()], + constraints: vec!["Preserve legacy payloads".to_string()], + recommended_approach: Some("Enrich update_plan".to_string()), + verification_plan: Some("Run focused tests".to_string()), + risks_and_unknowns: Some("Replay may drift".to_string()), + handoff_packet: Some("Next agent should inspect replay".to_string()), + items: vec![PlanItemArg { + step: "Render rich artifact".to_string(), + status: StepStatus::InProgress, + }], + ..PlanSnapshot::default() + }), + subagent_snapshots: Vec::new(), + }; + + let block = state.to_system_block().expect("fork state block"); + + assert!(block.contains("Objective: Make Plan mode reviewable")); + assert!(block.contains("Context: Grounded in issue #2691")); + assert!(block.contains("Source: gh issue view 2691")); + assert!(block.contains("Critical file: crates/tui/src/tools/plan.rs")); + assert!(block.contains("Constraint: Preserve legacy payloads")); + assert!(block.contains("Verification plan: Run focused tests")); + assert!(block.contains("Handoff packet: Next agent should inspect replay")); + assert!(block.contains("- [~] Render rich artifact")); +} + #[test] fn env_only_auth_error_gets_recovery_hint() { let _guard = lock_test_env(); diff --git a/crates/tui/src/prompts/modes/plan.md b/crates/tui/src/prompts/modes/plan.md index bcbfb229f..3ade5c73a 100644 --- a/crates/tui/src/prompts/modes/plan.md +++ b/crates/tui/src/prompts/modes/plan.md @@ -5,6 +5,10 @@ You are running in Plan mode — design before implementing. Investigate first, act later. Use `checklist_write` for visible, granular progress on multi-step investigations. When you are ready to present the implementation plan, call `update_plan` with the final plan; that is the handoff signal that lets the UI show the accept / revise / exit prompt. +For non-trivial work, make the plan artifact grounded: include the objective, a short context +summary, sources used, critical files, constraints, recommended approach, verification plan, +risks or unknowns, and any concise handoff packet another agent would need. Do not include +secrets in sources, file lists, or handoff text. All writes and patches are blocked — you can read the world but you can't change it. Shell and code execution are unavailable. diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index cb0282258..1220a948d 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -1003,6 +1003,8 @@ fn format_age(dt: &DateTime) -> String { mod tests { use super::*; use crate::models::ContentBlock; + use crate::tools::plan::StepStatus; + use crate::tui::history::{HistoryCell, ToolCell, history_cells_from_message}; use std::fs; use tempfile::tempdir; @@ -1106,6 +1108,63 @@ mod tests { assert_eq!(loaded.messages.len(), 2); } + #[test] + fn save_and_load_session_preserves_rich_update_plan_tool_payload() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let messages = vec![ + make_test_message("user", "plan this carefully"), + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "plan-1".to_string(), + name: "update_plan".to_string(), + input: serde_json::json!({ + "objective": "Make Plan mode reviewable", + "sources_used": ["gh issue view 2691"], + "critical_files": ["crates/tui/src/tools/plan.rs"], + "constraints": ["Preserve legacy update_plan payloads"], + "verification_plan": "Run focused plan tests", + "handoff_packet": "Next agent should inspect replay", + "plan": [ + { "step": "render replay card", "status": "completed" } + ] + }), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "plan-1".to_string(), + content: "Plan updated".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + let session = create_saved_session(&messages, "deepseek-v4-flash", tmp.path(), 42, None); + let session_id = session.metadata.id.clone(); + + manager.save_session(&session).expect("save"); + let loaded = manager.load_session(&session_id).expect("load"); + + assert_eq!(loaded.messages.len(), 3); + let cells = history_cells_from_message(&loaded.messages[1]); + let Some(HistoryCell::Tool(ToolCell::PlanUpdate(cell))) = cells.first() else { + panic!("expected loaded update_plan to replay as a PlanUpdate cell"); + }; + assert_eq!( + cell.snapshot.objective.as_deref(), + Some("Make Plan mode reviewable") + ); + assert_eq!( + cell.snapshot.critical_files, + vec!["crates/tui/src/tools/plan.rs"] + ); + assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed); + } + #[test] fn save_session_preserves_large_tool_outputs_for_cache_fidelity() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/plan.rs b/crates/tui/src/tools/plan.rs index 17caab4f6..1863a4ec9 100644 --- a/crates/tui/src/tools/plan.rs +++ b/crates/tui/src/tools/plan.rs @@ -54,10 +54,31 @@ pub struct PlanItemArg { } /// Update payload used by the plan tool. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UpdatePlanArgs { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub objective: Option, + #[serde(default)] + pub context_summary: Option, #[serde(default)] pub explanation: Option, + #[serde(default)] + pub sources_used: Vec, + #[serde(default)] + pub critical_files: Vec, + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub recommended_approach: Option, + #[serde(default)] + pub verification_plan: Option, + #[serde(default)] + pub risks_and_unknowns: Option, + #[serde(default)] + pub handoff_packet: Option, + #[serde(default)] pub plan: Vec, } @@ -115,16 +136,110 @@ impl PlanStep { } /// Serializable snapshot for display -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PlanSnapshot { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub objective: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub explanation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources_used: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub critical_files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub constraints: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommended_approach: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification_plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub risks_and_unknowns: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handoff_packet: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub items: Vec, } +impl PlanSnapshot { + #[must_use] + pub fn is_empty(&self) -> bool { + self.title.is_none() + && self.objective.is_none() + && self.context_summary.is_none() + && self.explanation.is_none() + && self.sources_used.is_empty() + && self.critical_files.is_empty() + && self.constraints.is_empty() + && self.recommended_approach.is_none() + && self.verification_plan.is_none() + && self.risks_and_unknowns.is_none() + && self.handoff_packet.is_none() + && self.items.is_empty() + } + + /// Parse the user/model-facing `update_plan` payload into a displayable + /// snapshot. This is intentionally tolerant so saved transcript replay can + /// keep legacy and partially streamed payloads visible. + #[must_use] + pub fn from_tool_input(input: &serde_json::Value) -> Self { + let mut items = Vec::new(); + if let Some(plan_items) = input.get("plan").and_then(|v| v.as_array()) { + for item in plan_items { + let step = item + .get("step") + .and_then(|v| v.as_str()) + .map(str::trim) + .unwrap_or(""); + if step.is_empty() { + continue; + } + let status = item + .get("status") + .and_then(|v| v.as_str()) + .and_then(StepStatus::from_str) + .unwrap_or(StepStatus::Pending); + items.push(PlanItemArg { + step: step.to_string(), + status, + }); + } + } + + Self { + title: clean_optional(string_field(input, "title")), + objective: clean_optional(string_field(input, "objective")), + context_summary: clean_optional(string_field(input, "context_summary")), + explanation: clean_optional(string_field(input, "explanation")), + sources_used: clean_list(string_vec_field(input, "sources_used")), + critical_files: clean_list(string_vec_field(input, "critical_files")), + constraints: clean_list(string_vec_field(input, "constraints")), + recommended_approach: clean_optional(string_field(input, "recommended_approach")), + verification_plan: clean_optional(string_field(input, "verification_plan")), + risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")), + handoff_packet: clean_optional(string_field(input, "handoff_packet")), + items, + } + } +} + /// State tracking for the current plan #[derive(Debug, Clone, Default)] pub struct PlanState { + title: Option, + objective: Option, + context_summary: Option, explanation: Option, + sources_used: Vec, + critical_files: Vec, + constraints: Vec, + recommended_approach: Option, + verification_plan: Option, + risks_and_unknowns: Option, + handoff_packet: Option, steps: Vec, } @@ -132,19 +247,44 @@ impl PlanState { /// Check whether the plan is empty. #[must_use] pub fn is_empty(&self) -> bool { - self.steps.is_empty() && self.explanation.as_deref().unwrap_or("").is_empty() + self.steps.is_empty() + && self.title.is_none() + && self.objective.is_none() + && self.context_summary.is_none() + && self.explanation.is_none() + && self.sources_used.is_empty() + && self.critical_files.is_empty() + && self.constraints.is_empty() + && self.recommended_approach.is_none() + && self.verification_plan.is_none() + && self.risks_and_unknowns.is_none() + && self.handoff_packet.is_none() } pub fn update(&mut self, args: UpdatePlanArgs) { - self.explanation = args.explanation.filter(|s| !s.trim().is_empty()); + self.title = clean_optional(args.title); + self.objective = clean_optional(args.objective); + self.context_summary = clean_optional(args.context_summary); + self.explanation = clean_optional(args.explanation); + self.sources_used = clean_list(args.sources_used); + self.critical_files = clean_list(args.critical_files); + self.constraints = clean_list(args.constraints); + self.recommended_approach = clean_optional(args.recommended_approach); + self.verification_plan = clean_optional(args.verification_plan); + self.risks_and_unknowns = clean_optional(args.risks_and_unknowns); + self.handoff_packet = clean_optional(args.handoff_packet); let now = Instant::now(); let mut new_steps = Vec::new(); let mut in_progress_seen = false; for item in args.plan { + let step_text = item.step.trim(); + if step_text.is_empty() { + continue; + } // Try to find existing step to preserve timing - let existing = self.steps.iter().find(|s| s.text == item.step); + let existing = self.steps.iter().find(|s| s.text == step_text); let mut status = item.status; // Enforce single in_progress @@ -171,7 +311,7 @@ impl PlanState { s } else { - let mut s = PlanStep::new(item.step, status.clone()); + let mut s = PlanStep::new(step_text.to_string(), status.clone()); if status == StepStatus::InProgress { s.started_at = Some(now); } @@ -186,7 +326,17 @@ impl PlanState { pub fn snapshot(&self) -> PlanSnapshot { PlanSnapshot { + title: self.title.clone(), + objective: self.objective.clone(), + context_summary: self.context_summary.clone(), explanation: self.explanation.clone(), + sources_used: self.sources_used.clone(), + critical_files: self.critical_files.clone(), + constraints: self.constraints.clone(), + recommended_approach: self.recommended_approach.clone(), + verification_plan: self.verification_plan.clone(), + risks_and_unknowns: self.risks_and_unknowns.clone(), + handoff_packet: self.handoff_packet.clone(), items: self .steps .iter() @@ -236,6 +386,20 @@ impl PlanState { } } +fn clean_optional(value: Option) -> Option { + value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn clean_list(values: Vec) -> Vec { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() +} + /// Validation result for plan transitions #[derive(Debug)] #[allow(dead_code)] @@ -306,16 +470,59 @@ impl ToolSpec for UpdatePlanTool { } fn description(&self) -> &'static str { - "Update optional high-level strategy metadata for complex initiatives. Use checklist_write for primary Work progress; update_plan should capture phase-level approach changes, not duplicate checklist items. Each strategy step has a description and status (pending, in_progress, completed). Optionally include an explanation of the overall approach." + "Update optional high-level strategy metadata for complex initiatives. Use checklist_write for primary Work progress; update_plan should capture phase-level approach changes, not duplicate checklist items. Include sources, critical files, constraints, verification, risks, and handoff context when they help the user review or continue the plan. Each strategy step has a description and status (pending, in_progress, completed)." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { + "title": { + "type": "string", + "description": "Optional short title for the plan artifact" + }, + "objective": { + "type": "string", + "description": "What the plan is trying to accomplish" + }, + "context_summary": { + "type": "string", + "description": "Brief summary of the evidence and current state behind the plan" + }, "explanation": { "type": "string", - "description": "Optional high-level explanation of the plan or approach" + "description": "Legacy-compatible high-level explanation of the plan or approach" + }, + "sources_used": { + "type": "array", + "description": "Files, issues, PRs, commands, or other evidence used to ground the plan. Do not include secrets.", + "items": { "type": "string" } + }, + "critical_files": { + "type": "array", + "description": "Repo paths or surfaces likely to be edited or verified. Do not include secrets.", + "items": { "type": "string" } + }, + "constraints": { + "type": "array", + "description": "Hard requirements, user preferences, or boundaries the implementation must respect", + "items": { "type": "string" } + }, + "recommended_approach": { + "type": "string", + "description": "Recommended implementation strategy and important trade-offs" + }, + "verification_plan": { + "type": "string", + "description": "Tests, checks, or manual verification expected before the work is considered done" + }, + "risks_and_unknowns": { + "type": "string", + "description": "Known risks, blockers, or unresolved questions" + }, + "handoff_packet": { + "type": "string", + "description": "Concise continuation notes for another agent or a later session" }, "plan": { "type": "array", @@ -336,8 +543,7 @@ impl ToolSpec for UpdatePlanTool { "required": ["step", "status"] } } - }, - "required": ["plan"] + } }) } @@ -354,15 +560,13 @@ impl ToolSpec for UpdatePlanTool { input: serde_json::Value, _context: &ToolContext, ) -> Result { - let explanation = input - .get("explanation") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - - let plan_items = input - .get("plan") - .and_then(|v| v.as_array()) - .ok_or_else(|| ToolError::invalid_input("Missing or invalid 'plan' array"))?; + let empty_plan = Vec::new(); + let plan_items = match input.get("plan") { + Some(value) => value + .as_array() + .ok_or_else(|| ToolError::invalid_input("Invalid 'plan' array"))?, + None => &empty_plan, + }; let mut plan_args = Vec::new(); for item in plan_items { @@ -385,7 +589,17 @@ impl ToolSpec for UpdatePlanTool { } let args = UpdatePlanArgs { - explanation, + title: string_field(&input, "title"), + objective: string_field(&input, "objective"), + context_summary: string_field(&input, "context_summary"), + explanation: string_field(&input, "explanation"), + sources_used: string_vec_field(&input, "sources_used"), + critical_files: string_vec_field(&input, "critical_files"), + constraints: string_vec_field(&input, "constraints"), + recommended_approach: string_field(&input, "recommended_approach"), + verification_plan: string_field(&input, "verification_plan"), + risks_and_unknowns: string_field(&input, "risks_and_unknowns"), + handoff_packet: string_field(&input, "handoff_packet"), plan: plan_args, }; @@ -404,3 +618,207 @@ impl ToolSpec for UpdatePlanTool { ))) } } + +fn string_field(input: &serde_json::Value, field: &str) -> Option { + input + .get(field) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +fn string_vec_field(input: &serde_json::Value, field: &str) -> Vec { + input + .get(field) + .and_then(|v| v.as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(std::string::ToString::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::spec::{ToolContext, ToolSpec}; + use serde_json::json; + + #[test] + fn plan_state_treats_every_artifact_field_as_non_empty() { + let cases = vec![ + UpdatePlanArgs { + title: Some("Title".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + objective: Some("Objective".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + context_summary: Some("Context".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + explanation: Some("Explanation".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + sources_used: vec!["gh issue view 2691".to_string()], + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()], + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + constraints: vec!["Preserve legacy payloads".to_string()], + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + recommended_approach: Some("Do the narrow slice".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + verification_plan: Some("Run focused tests".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + risks_and_unknowns: Some("Replay may drift".to_string()), + ..UpdatePlanArgs::default() + }, + UpdatePlanArgs { + handoff_packet: Some("Next agent should inspect rendering".to_string()), + ..UpdatePlanArgs::default() + }, + ]; + + for args in cases { + let mut state = PlanState::default(); + state.update(args); + assert!( + !state.is_empty(), + "artifact metadata must keep plan state visible" + ); + } + } + + #[test] + fn plan_state_snapshot_trims_blank_artifact_values() { + let mut state = PlanState::default(); + state.update(UpdatePlanArgs { + title: Some(" Rich plan ".to_string()), + sources_used: vec![" ".to_string(), " gh issue view 2691 ".to_string()], + critical_files: vec![" crates/tui/src/tools/plan.rs ".to_string()], + constraints: vec!["".to_string(), " no secrets ".to_string()], + plan: vec![ + PlanItemArg { + step: " ".to_string(), + status: StepStatus::Pending, + }, + PlanItemArg { + step: " render sections ".to_string(), + status: StepStatus::InProgress, + }, + ], + ..UpdatePlanArgs::default() + }); + + let snapshot = state.snapshot(); + assert_eq!(snapshot.title.as_deref(), Some("Rich plan")); + assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]); + assert_eq!( + snapshot.critical_files, + vec!["crates/tui/src/tools/plan.rs"] + ); + assert_eq!(snapshot.constraints, vec!["no secrets"]); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].step, "render sections"); + assert_eq!(snapshot.items[0].status, StepStatus::InProgress); + } + + #[test] + fn snapshot_serde_skips_empty_fields_and_deserializes_legacy() { + let snapshot = PlanSnapshot { + objective: Some("Ship PlanArtifact".to_string()), + items: vec![PlanItemArg { + step: "keep legacy replay working".to_string(), + status: StepStatus::Completed, + }], + ..PlanSnapshot::default() + }; + + let value = serde_json::to_value(&snapshot).expect("serialize snapshot"); + assert!(value.get("objective").is_some()); + assert!(value.get("title").is_none()); + assert!(value.get("sources_used").is_none()); + assert!(value.get("constraints").is_none()); + + let legacy: PlanSnapshot = serde_json::from_value(json!({ + "explanation": "Legacy explanation", + "items": [ + { "step": "legacy step", "status": "pending" } + ] + })) + .expect("legacy snapshot should deserialize"); + assert_eq!(legacy.explanation.as_deref(), Some("Legacy explanation")); + assert_eq!(legacy.items.len(), 1); + assert!(legacy.sources_used.is_empty()); + } + + #[tokio::test] + async fn legacy_update_plan_still_works() { + let state = new_shared_plan_state(); + let tool = UpdatePlanTool::new(state.clone()); + let context = ToolContext::new(std::env::temp_dir()); + + tool.execute( + json!({ + "explanation": "Legacy shape", + "plan": [ + { "step": "inspect", "status": "completed" }, + { "step": "patch", "status": "in_progress" } + ] + }), + &context, + ) + .await + .expect("legacy update_plan should succeed"); + + let snapshot = state.lock().await.snapshot(); + assert_eq!(snapshot.explanation.as_deref(), Some("Legacy shape")); + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[0].status, StepStatus::Completed); + assert_eq!(snapshot.items[1].status, StepStatus::InProgress); + } + + #[tokio::test] + async fn update_plan_tool_accepts_metadata_only_payload() { + let state = new_shared_plan_state(); + let tool = UpdatePlanTool::new(state.clone()); + let context = ToolContext::new(std::env::temp_dir()); + + let result = tool + .execute( + json!({ + "objective": "Make Plan mode reviewable", + "sources_used": ["gh issue view 2691"], + "critical_files": ["crates/tui/src/tools/plan.rs"], + "verification_plan": "Run focused plan tests" + }), + &context, + ) + .await + .expect("metadata-only update_plan should succeed"); + + assert!(result.content.contains("Make Plan mode reviewable")); + let snapshot = state.lock().await.snapshot(); + assert!(!snapshot.is_empty()); + assert!(snapshot.items.is_empty()); + assert_eq!( + snapshot.critical_files, + vec!["crates/tui/src/tools/plan.rs"] + ); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3eb494f16..1449c430d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -5918,6 +5918,7 @@ mod tests { step: "step 1".to_string(), status: StepStatus::InProgress, }], + ..UpdatePlanArgs::default() }); assert!(!plan.is_empty()); } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index c8388f3d8..30e92dfcc 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -11,6 +11,7 @@ use unicode_width::UnicodeWidthStr; use crate::deepseek_theme::active_theme; use crate::models::{ContentBlock, Message}; use crate::palette; +use crate::tools::plan::{PlanSnapshot, StepStatus}; use crate::tools::review::ReviewOutput; use crate::tui::app::TranscriptSpacing; use crate::tui::diff_render; @@ -651,6 +652,12 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { }); } } + ContentBlock::ToolUse { name, input, .. } if name == "update_plan" => { + cells.push(HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { + snapshot: PlanSnapshot::from_tool_input(input), + status: ToolStatus::Success, + }))); + } _ => {} } } @@ -883,8 +890,7 @@ pub struct ExploringEntry { /// Cell for plan updates emitted by the plan tool. #[derive(Debug, Clone)] pub struct PlanUpdateCell { - pub explanation: Option, - pub steps: Vec, + pub snapshot: PlanSnapshot, pub status: ToolStatus, } @@ -900,39 +906,68 @@ impl PlanUpdateCell { low_motion, )); - if let Some(explanation) = self.explanation.as_ref() { - lines.extend(render_message( - "", - system_label_style(), - system_body_style(), - explanation, - width, - )); - } - - for step in &self.steps { - let marker = match step.status.as_str() { - "completed" => "done", - "in_progress" => "live", - _ => "next", - }; - lines.extend(render_compact_kv( - marker, - &step.step, - tool_value_style(), - width, - )); - } + render_plan_snapshot_lines(&self.snapshot, &mut lines, width); lines } } -/// Single plan step rendered in the UI. -#[derive(Debug, Clone)] -pub struct PlanStep { - pub step: String, - pub status: String, +fn render_plan_snapshot_lines(snapshot: &PlanSnapshot, lines: &mut Vec>, width: u16) { + render_plan_optional(lines, "title", snapshot.title.as_deref(), width); + render_plan_optional(lines, "objective", snapshot.objective.as_deref(), width); + render_plan_optional(lines, "context", snapshot.context_summary.as_deref(), width); + render_plan_optional(lines, "explain", snapshot.explanation.as_deref(), width); + render_plan_list(lines, "source", &snapshot.sources_used, width); + render_plan_list(lines, "file", &snapshot.critical_files, width); + render_plan_list(lines, "constraint", &snapshot.constraints, width); + render_plan_optional( + lines, + "approach", + snapshot.recommended_approach.as_deref(), + width, + ); + render_plan_optional( + lines, + "verify", + snapshot.verification_plan.as_deref(), + width, + ); + render_plan_optional(lines, "risk", snapshot.risks_and_unknowns.as_deref(), width); + render_plan_optional(lines, "handoff", snapshot.handoff_packet.as_deref(), width); + + for step in &snapshot.items { + let marker = match step.status { + StepStatus::Completed => "done", + StepStatus::InProgress => "live", + StepStatus::Pending => "next", + }; + lines.extend(render_compact_kv( + marker, + &step.step, + tool_value_style(), + width, + )); + } +} + +fn render_plan_optional( + lines: &mut Vec>, + label: &str, + value: Option<&str>, + width: u16, +) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + lines.extend(render_compact_kv(label, value, tool_value_style(), width)); + } +} + +fn render_plan_list(lines: &mut Vec>, label: &str, values: &[String], width: u16) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + lines.extend(render_compact_kv(label, value, tool_value_style(), width)); + } + } } /// Cell for patch summaries emitted by the patch tool. @@ -3434,8 +3469,8 @@ fn looks_like_file_path(s: &str) -> bool { #[cfg(test)] mod tests { use super::{ - ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanStep, - PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS, + ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, PlanUpdateCell, + REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions, USER_GLYPH, assistant_label_style_for, extract_reasoning_summary, render_thinking, running_status_label_with_elapsed, @@ -3443,6 +3478,7 @@ mod tests { use crate::deepseek_theme::Theme; use crate::models::{ContentBlock, Message}; use crate::palette; + use crate::tools::plan::{PlanSnapshot, StepStatus}; use ratatui::style::Modifier; use std::time::{Duration, Instant}; @@ -3923,6 +3959,40 @@ mod tests { assert_eq!(summary, "Summary body"); } + #[test] + fn history_replays_update_plan_tool_use_as_plan_card() { + let msg = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "plan-1".to_string(), + name: "update_plan".to_string(), + input: serde_json::json!({ + "objective": "Make Plan mode reviewable", + "sources_used": ["gh issue view 2691"], + "critical_files": ["crates/tui/src/tools/plan.rs"], + "plan": [ + { "step": "render replay card", "status": "completed" } + ] + }), + caller: None, + }], + }; + + let cells = super::history_cells_from_message(&msg); + assert_eq!(cells.len(), 1); + let HistoryCell::Tool(ToolCell::PlanUpdate(cell)) = &cells[0] else { + panic!("expected update_plan replay cell"); + }; + + assert_eq!(cell.status, ToolStatus::Success); + assert_eq!( + cell.snapshot.objective.as_deref(), + Some("Make Plan mode reviewable") + ); + assert_eq!(cell.snapshot.sources_used, vec!["gh issue view 2691"]); + assert_eq!(cell.snapshot.items[0].status, StepStatus::Completed); + } + #[test] fn render_thinking_collapsed_shows_details_affordance() { let lines = render_thinking( @@ -4602,21 +4672,23 @@ mod tests { fn plan_update_cell_renders_with_dark_theme_tokens() { let theme = Theme::dark(); let cell = PlanUpdateCell { - explanation: None, - steps: vec![ - PlanStep { - step: "scan repo".to_string(), - status: "completed".to_string(), - }, - PlanStep { - step: "extract theme".to_string(), - status: "in_progress".to_string(), - }, - PlanStep { - step: "land tests".to_string(), - status: "pending".to_string(), - }, - ], + snapshot: PlanSnapshot { + items: vec![ + crate::tools::plan::PlanItemArg { + step: "scan repo".to_string(), + status: StepStatus::Completed, + }, + crate::tools::plan::PlanItemArg { + step: "extract theme".to_string(), + status: StepStatus::InProgress, + }, + crate::tools::plan::PlanItemArg { + step: "land tests".to_string(), + status: StepStatus::Pending, + }, + ], + ..PlanSnapshot::default() + }, status: ToolStatus::Running, }; @@ -4691,6 +4763,52 @@ mod tests { assert_eq!(visible[3].trim_end(), "▏ next: land tests"); } + #[test] + fn plan_update_cell_renders_rich_artifact_metadata() { + let cell = PlanUpdateCell { + snapshot: PlanSnapshot { + objective: Some("Make Plan mode reviewable".to_string()), + context_summary: Some("Grounded in issue #2691".to_string()), + sources_used: vec!["gh issue view 2691".to_string()], + critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()], + constraints: vec!["Keep checklist primary".to_string()], + recommended_approach: Some( + "Enrich update_plan without breaking legacy calls".to_string(), + ), + verification_plan: Some("Run focused renderer tests".to_string()), + risks_and_unknowns: Some("Metadata-only plans can disappear".to_string()), + handoff_packet: Some("Next agent should inspect relay output".to_string()), + items: vec![crate::tools::plan::PlanItemArg { + step: "Render artifact sections".to_string(), + status: StepStatus::InProgress, + }], + ..PlanSnapshot::default() + }, + status: ToolStatus::Success, + }; + + let visible = cell + .lines_with_motion(120, true) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(visible.contains("objective:")); + assert!(visible.contains("Make Plan mode reviewable")); + assert!(visible.contains("source:")); + assert!(visible.contains("gh issue view 2691")); + assert!(visible.contains("file:")); + assert!(visible.contains("verify:")); + assert!(visible.contains("handoff:")); + assert!(visible.contains("Render artifact sections")); + } + #[test] fn exec_cell_failed_status_renders_with_dark_theme_tokens() { let theme = Theme::dark(); diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index b8f1fc02b..7e49a4468 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -9,7 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; use unicode_width::UnicodeWidthStr; use crate::palette; -use crate::tools::plan::PlanSnapshot; +use crate::tools::plan::{PlanSnapshot, StepStatus}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; struct PlanOption { @@ -371,36 +371,7 @@ impl ModalView for PlanPromptView { // v0.8.44: render plan details when update_plan was called (#834) if let Some(ref plan) = self.plan { - if let Some(ref explanation) = plan.explanation { - for line in wrap_text(explanation, content_width) { - lines.push(Line::from(Span::styled( - line, - Style::default().fg(palette::TEXT_MUTED), - ))); - } - lines.push(Line::from("")); - } - if !plan.items.is_empty() { - lines.push(Line::from(Span::styled( - "Plan steps:", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ))); - for (i, item) in plan.items.iter().enumerate() { - let status_mark = match item.status { - crate::tools::plan::StepStatus::Pending => "\u{b7}", - crate::tools::plan::StepStatus::InProgress => "\u{25b6}", - crate::tools::plan::StepStatus::Completed => "\u{2713}", - }; - let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step); - for line in wrap_text(&step_text, content_width) { - lines.push(Line::from(Span::styled( - line, - Style::default().fg(palette::TEXT_PRIMARY), - ))); - } - } - lines.push(Line::from("")); - } + push_plan_snapshot_lines(&mut lines, plan, content_width); } for (idx, option) in PLAN_OPTIONS.iter().enumerate() { @@ -418,10 +389,15 @@ impl ModalView for PlanPromptView { // Since plan steps are now pre-wrapped via wrap_text(), each Line is // already width-bounded — use the raw line count directly. let total_lines = lines.len(); + // Borders and padding consume rows inside the modal. Slice the visible + // lines ourselves instead of relying on Paragraph's internal clamp so + // bottom-jump scrolling reliably reaches the action rows. let visible_lines = usize::from(popup_area.height).saturating_sub(4).max(1); let max_scroll = total_lines.saturating_sub(visible_lines); self.last_max_scroll.set(max_scroll); let scroll = self.scroll.min(max_scroll); + let rendered_lines: Vec> = + lines.into_iter().skip(scroll).take(visible_lines).collect(); // Build footer: scroll indicator (left) + data-driven option shortcuts + // description of the currently selected option (right). @@ -468,16 +444,213 @@ impl ModalView for PlanPromptView { // which breaks only on display-width overflow, not on script boundaries // (Latin ↔ CJK). This avoids forced line-breaks between English and // Chinese characters when there is still room on the current line. - let paragraph = Paragraph::new(lines) + let paragraph = Paragraph::new(rendered_lines) .alignment(Alignment::Left) .wrap(Wrap { trim: false }) - .block(modal_block().title_bottom(Line::from(footer_spans))) - .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + .block(modal_block().title_bottom(Line::from(footer_spans))); paragraph.render(popup_area, buf); } } +fn push_plan_snapshot_lines( + lines: &mut Vec>, + plan: &PlanSnapshot, + content_width: usize, +) { + let show_empty = plan_uses_rich_artifact_shape(plan); + push_plan_text( + lines, + "Title", + plan.title.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Objective", + plan.objective.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Context", + plan.context_summary.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Explanation", + plan.explanation.as_deref(), + content_width, + show_empty, + ); + push_plan_list( + lines, + "Sources used", + &plan.sources_used, + content_width, + show_empty, + ); + push_plan_list( + lines, + "Critical files", + &plan.critical_files, + content_width, + show_empty, + ); + push_plan_list( + lines, + "Constraints", + &plan.constraints, + content_width, + show_empty, + ); + push_plan_text( + lines, + "Recommended approach", + plan.recommended_approach.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Verification plan", + plan.verification_plan.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Risks and unknowns", + plan.risks_and_unknowns.as_deref(), + content_width, + show_empty, + ); + push_plan_text( + lines, + "Handoff packet", + plan.handoff_packet.as_deref(), + content_width, + show_empty, + ); + + if !plan.items.is_empty() { + lines.push(Line::from(Span::styled( + "Plan steps:", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + for (i, item) in plan.items.iter().enumerate() { + let status_mark = match item.status { + StepStatus::Pending => "\u{b7}", + StepStatus::InProgress => "\u{25b6}", + StepStatus::Completed => "\u{2713}", + }; + let step_text = format!(" {status_mark} {}. {}", i + 1, &item.step); + for line in wrap_text(&step_text, content_width) { + lines.push(Line::from(Span::styled( + line, + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } + } + lines.push(Line::from("")); + } else if show_empty { + lines.push(Line::from(Span::styled( + "Plan steps:", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + lines.push(Line::from(Span::styled( + " Not provided", + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + lines.push(Line::from("")); + } +} + +fn plan_uses_rich_artifact_shape(plan: &PlanSnapshot) -> bool { + plan.title.is_some() + || plan.objective.is_some() + || plan.context_summary.is_some() + || !plan.sources_used.is_empty() + || !plan.critical_files.is_empty() + || !plan.constraints.is_empty() + || plan.recommended_approach.is_some() + || plan.verification_plan.is_some() + || plan.risks_and_unknowns.is_some() + || plan.handoff_packet.is_some() +} + +fn push_plan_text( + lines: &mut Vec>, + label: &'static str, + value: Option<&str>, + content_width: usize, + show_empty: bool, +) { + let value = value.map(str::trim).filter(|value| !value.is_empty()); + if value.is_none() && !show_empty { + return; + }; + lines.push(Line::from(Span::styled( + format!("{label}:"), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + let (value, style) = value.map_or_else( + || { + ( + "Not provided", + Style::default().fg(palette::TEXT_MUTED).italic(), + ) + }, + |value| (value, Style::default().fg(palette::TEXT_MUTED)), + ); + for line in wrap_text(value, content_width) { + lines.push(Line::from(Span::styled(format!(" {line}"), style))); + } + lines.push(Line::from("")); +} + +fn push_plan_list( + lines: &mut Vec>, + label: &'static str, + values: &[String], + content_width: usize, + show_empty: bool, +) { + let values: Vec<&str> = values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect(); + if values.is_empty() && !show_empty { + return; + } + lines.push(Line::from(Span::styled( + format!("{label}:"), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ))); + if values.is_empty() { + lines.push(Line::from(Span::styled( + " Not provided", + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + lines.push(Line::from("")); + return; + } + for value in values { + for line in wrap_text(&format!("- {value}"), content_width) { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } + lines.push(Line::from("")); +} + /// Wrap text into lines no wider than `width` characters. fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { @@ -595,6 +768,63 @@ mod tests { assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)")); } + #[test] + fn plan_prompt_renders_rich_plan_artifact_sections() { + use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; + + let plan = PlanSnapshot { + title: Some("PlanArtifact rollout".to_string()), + objective: Some("Make Plan mode reviewable".to_string()), + context_summary: Some("Issue #2691 asks for grounded plan artifacts.".to_string()), + sources_used: vec!["gh issue view 2691".to_string()], + critical_files: vec!["crates/tui/src/tools/plan.rs".to_string()], + constraints: vec!["Preserve legacy update_plan payloads".to_string()], + recommended_approach: Some( + "Keep checklist primary and enrich update_plan.".to_string(), + ), + verification_plan: Some("Run focused plan prompt tests.".to_string()), + risks_and_unknowns: Some("Avoid dropping metadata-only plans.".to_string()), + handoff_packet: Some("Continue with transcript replay checks.".to_string()), + items: vec![PlanItemArg { + step: "Render rich sections".to_string(), + status: StepStatus::InProgress, + }], + ..PlanSnapshot::default() + }; + let view = PlanPromptView::new(Some(plan)); + let rendered = render_view(&view, 160, 120); + + assert!(rendered.contains("Objective:")); + assert!(rendered.contains("Make Plan mode reviewable")); + assert!(rendered.contains("Sources used:")); + assert!(rendered.contains("gh issue view 2691")); + assert!(rendered.contains("Critical files:")); + assert!(rendered.contains("Verification plan:")); + assert!(rendered.contains("Handoff packet:")); + assert!(rendered.contains("Render rich sections")); + } + + #[test] + fn plan_prompt_renders_empty_artifact_sections_for_rich_plans() { + use crate::tools::plan::PlanSnapshot; + + let plan = PlanSnapshot { + objective: Some("Review grounded plan".to_string()), + ..PlanSnapshot::default() + }; + let view = PlanPromptView::new(Some(plan)); + let rendered = render_view(&view, 160, 120); + + assert!(rendered.contains("Objective:")); + assert!(rendered.contains("Review grounded plan")); + assert!(rendered.contains("Sources used:")); + assert!(rendered.contains("Critical files:")); + assert!(rendered.contains("Verification plan:")); + assert!(rendered.contains("Risks and unknowns:")); + assert!(rendered.contains("Plan steps:")); + assert!(rendered.contains("Not provided")); + } + #[test] fn plan_prompt_shows_scroll_indicator_when_content_overflows() { use crate::tools::plan::{PlanItemArg, PlanSnapshot, StepStatus}; @@ -608,6 +838,7 @@ mod tests { }; 20 ], + ..PlanSnapshot::default() }; let view = PlanPromptView::new(Some(plan)); // Render into a small area so content overflows. @@ -672,10 +903,11 @@ mod tests { }; 30 ], + ..PlanSnapshot::default() }; let mut view = PlanPromptView::new(Some(plan)); // Set scroll far beyond content. - view.scroll = 999; + view.scroll = usize::MAX; let rendered = render_view(&view, 80, 20); // The rendered view should still contain the last option. diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fa1b26ce9..20bfffa7a 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -949,9 +949,13 @@ fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option { name: "update_plan".to_string(), status: plan.status, summary: plan - .explanation + .snapshot + .objective .as_deref() - .or_else(|| plan.steps.first().map(|step| step.step.as_str())) + .or(plan.snapshot.title.as_deref()) + .or(plan.snapshot.explanation.as_deref()) + .or(plan.snapshot.recommended_approach.as_deref()) + .or_else(|| plan.snapshot.items.first().map(|step| step.step.as_str())) .unwrap_or("") .to_string(), duration_ms: None, diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index f76e1cd16..697c50f4c 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -5,14 +5,15 @@ use std::time::Instant; use crate::hooks::HookEvent; use crate::tools::ReviewOutput; +use crate::tools::plan::PlanSnapshot; use crate::tools::spec::{ToolError, ToolResult}; use crate::tui::active_cell::ActiveCell; use crate::tui::app::{App, ToolDetailRecord, ToolEvidence}; use crate::tui::history::{ DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, - McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, - ViewImageCell, WebSearchCell, output_looks_like_diff, summarize_mcp_output, - summarize_tool_args, summarize_tool_output, + McpToolCell, PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell, + WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args, + summarize_tool_output, }; #[allow(clippy::too_many_lines)] @@ -142,15 +143,14 @@ pub(super) fn handle_tool_call_started( } if name == "update_plan" { - let (explanation, steps) = parse_plan_input(input); + let snapshot = parse_plan_input(input); push_active_tool_cell( app, &id, name, input, HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { - explanation, - steps, + snapshot, status: ToolStatus::Running, })), ); @@ -936,28 +936,8 @@ fn review_target_label(input: &serde_json::Value) -> String { target.to_string() } -fn parse_plan_input(input: &serde_json::Value) -> (Option, Vec) { - let explanation = input - .get("explanation") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - let mut steps = Vec::new(); - if let Some(items) = input.get("plan").and_then(|v| v.as_array()) { - for item in items { - let step = item.get("step").and_then(|v| v.as_str()).unwrap_or(""); - let status = item - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("pending"); - if !step.is_empty() { - steps.push(PlanStep { - step: step.to_string(), - status: status.to_string(), - }); - } - } - } - (explanation, steps) +fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot { + PlanSnapshot::from_tool_input(input) } fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { @@ -1186,3 +1166,61 @@ fn exec_is_background(input: &serde_json::Value) -> bool { .and_then(serde_json::Value::as_bool) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::plan::StepStatus; + use serde_json::json; + + #[test] + fn parse_plan_input_accepts_legacy_payload() { + let snapshot = parse_plan_input(&json!({ + "explanation": "Legacy explanation", + "plan": [ + { "step": "inspect", "status": "completed" }, + { "step": "patch", "status": "in_progress" } + ] + })); + + assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation")); + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[0].status, StepStatus::Completed); + assert_eq!(snapshot.items[1].status, StepStatus::InProgress); + } + + #[test] + fn parse_plan_input_extracts_rich_artifact_fields() { + let snapshot = parse_plan_input(&json!({ + "title": " PlanArtifact ", + "objective": "Make Plan mode reviewable", + "context_summary": "Grounded in issue #2691", + "sources_used": [" gh issue view 2691 ", ""], + "critical_files": ["crates/tui/src/tools/plan.rs"], + "constraints": ["No secrets"], + "recommended_approach": "Enrich update_plan", + "verification_plan": "Run focused tests", + "risks_and_unknowns": "Replay may drift", + "handoff_packet": "Continue with session replay", + "plan": [ + { "step": " ", "status": "completed" }, + { "step": "render all fields", "status": "weird" } + ] + })); + + assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact")); + assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]); + assert_eq!( + snapshot.critical_files, + vec!["crates/tui/src/tools/plan.rs"] + ); + assert_eq!(snapshot.constraints, vec!["No secrets"]); + assert_eq!( + snapshot.verification_plan.as_deref(), + Some("Run focused tests") + ); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].step, "render all fields"); + assert_eq!(snapshot.items[0].status, StepStatus::Pending); + } +} diff --git a/docs/GUIDE.md b/docs/GUIDE.md index d09d414b3..1eaecfd78 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -232,6 +232,11 @@ Or switch directly: Plan mode is the safest place to start in an unfamiliar repository. It is for inspection and decision-making, not file edits. +For non-trivial work, Plan mode's confirmation prompt can show a grounded +PlanArtifact: objective, context, sources used, critical files, constraints, +approach, verification plan, risks, and handoff notes. Empty sections are +visible when the agent uses the rich artifact shape, so you can ask for a +revision instead of accepting an under-specified plan. Agent mode is the default for most contribution work. It lets CodeWhale read, run checks, and edit files while keeping risky actions behind approval gates. diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 197b1c90d..25bd4d759 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -118,6 +118,16 @@ by exact name, but they are not part of the model-visible catalog; compatibility results include `_deprecation.use_instead = checklist_*` and `_deprecation.removed_in = 0.9.0`. +`update_plan` accepts both the legacy shape (`explanation` plus `plan` steps) +and a richer PlanArtifact shape for Plan mode review. The richer fields are +optional and should be filled only when grounded in evidence: `title`, +`objective`, `context_summary`, `sources_used`, `critical_files`, +`constraints`, `recommended_approach`, `verification_plan`, +`risks_and_unknowns`, and `handoff_packet`. The transcript card, Plan-mode +confirmation prompt, `/relay`, and fork-state handoff all render the same +artifact so a plan can be reviewed, accepted, revised, replayed, or delegated +without losing its source context. + ### Verification gates and artifacts | Tool | Niche | @@ -233,6 +243,12 @@ Aliases: `/batonpass`, `/接力`. Use it before a long break, compaction, or moving work to a fresh session. The relay should preserve the goal, current Work checklist item, changed files, decisions, verification state, and one concrete next action. +Treat it as the deliberate counterpart to automatic compaction: both exist to +preserve continuity for the next session or sub-agent, but `/relay` lets the +current agent inspect live evidence and choose the durable handoff facts +explicitly. When `update_plan` has a rich PlanArtifact, `/relay` includes that +strategy metadata so manual relay, fork-state, and compacted continuity do not +drift into separate stories. ### Parallel fan-out: cost-class caps diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 6c49a789d..312c6bc6e 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -45,6 +45,7 @@ harvest/stewardship commits: | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -113,6 +114,7 @@ harvest/stewardship commits: | #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | +| #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | ## Issue Reduction Strategy From 9719b45cd3bfe24eafe15a56e1b3e080f578b97f Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:39:15 -0700 Subject: [PATCH 041/209] fix(skills): merge configured and workspace skill dirs Harvested from PR #2737 by @h3c-hexin. Co-authored-by: h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> --- CHANGELOG.md | 6 +- crates/tui/src/prompts.rs | 87 ++++++++++++++++++++++--- crates/tui/src/skills/mod.rs | 119 +++++++++++++++++++++++++++++++---- docs/V0_9_0_EXECUTION_MAP.md | 6 +- 4 files changed, 198 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b552328e..cb088d30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS target dependencies so published OpenHarmony builds no longer pull `nix` 0.28 through `rustyline` or `portable-pty`. +- Explicit `skills_dir` configuration is now unioned with workspace skill + discovery instead of being shadowed by workspace-local skills, and configured + skills take precedence over global defaults when prompt space is constrained. ### Community @@ -64,7 +67,8 @@ prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update API (#2640), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), and -**@idling11** for the PlanArtifact direction in Plan mode (#2733). +**@idling11** for the PlanArtifact direction in Plan mode (#2733), and +**@h3c-hexin** for the configured `skills_dir` merge fix (#2737). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 00584cd38..f505338f0 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1068,13 +1068,16 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // skills directory (`.agents/skills`, `skills`, // `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus global // `~/.agents/skills` / `~/.deepseek/skills` so skills installed for any - // AI-tool convention show up in the catalogue. The legacy - // single-`skills_dir` path is - // honoured as a fallback for callers that don't supply a - // workspace-aware view; it falls through to the same merged - // registry when available. - let skills_block = crate::skills::render_available_skills_context_for_workspace(workspace) - .or_else(|| skills_dir.and_then(crate::skills::render_available_skills_context)); + // AI-tool convention show up in the catalogue. When an explicit + // `skills_dir` is configured, union it with the workspace view instead of + // treating it as a fallback; the workspace view often returns Some and + // would otherwise shadow the configured directory entirely. + let skills_block = match skills_dir { + Some(dir) => { + crate::skills::render_available_skills_context_for_workspace_and_dir(workspace, dir) + } + None => crate::skills::render_available_skills_context_for_workspace(workspace), + }; if let Some(block) = skills_block { full_prompt = format!("{full_prompt}\n\n{block}"); } @@ -1483,6 +1486,76 @@ mod tests { ); } + #[test] + fn system_prompt_merges_workspace_and_configured_skills_dir() { + let _env_guard = crate::test_support::lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let _home = ScopedHome::set(tmp.path().join("home")); + let workspace = tmp.path().join("workspace"); + let configured_dir = tmp.path().join("configured-skills"); + write_test_skill( + &workspace.join(".claude").join("skills"), + "workspace-skill", + "workspace skill", + ); + write_test_skill(&configured_dir, "configured-skill", "configured skill"); + + let text = match system_prompt_for_mode_with_context_and_skills( + AppMode::Plan, + &workspace, + None, + Some(&configured_dir), + None, + None, + ) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + + assert!(text.contains("workspace-skill")); + assert!(text.contains("configured-skill")); + } + + struct ScopedHome { + previous: Option, + } + + impl ScopedHome { + fn set(path: std::path::PathBuf) -> Self { + let previous = std::env::var_os("HOME"); + // Safety: this test serializes environment access with + // lock_test_env and restores HOME in Drop. + unsafe { + std::env::set_var("HOME", path); + } + Self { previous } + } + } + + impl Drop for ScopedHome { + fn drop(&mut self) { + // Safety: this test serializes environment access with + // lock_test_env and restores HOME in Drop. + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("HOME", previous); + } else { + std::env::remove_var("HOME"); + } + } + } + } + + fn write_test_skill(root: &std::path::Path, name: &str, description: &str) { + let dir = root.join(name); + std::fs::create_dir_all(&dir).expect("skill dir"); + std::fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("skill file"); + } + #[test] fn calm_personality_declares_tier_8_subordination() { assert!( diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index d2c2f6add..783d4586a 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -567,20 +567,41 @@ pub fn discover_in_workspace(workspace: &Path) -> SkillRegistry { } /// Discover skills from the workspace search set plus the configured install -/// directory. Workspace/global directories keep their normal precedence; a -/// custom configured directory is appended when it is outside that set. +/// directory. Workspace-local directories keep their normal precedence; a +/// custom configured directory is inserted before global defaults when it is +/// outside that set so explicit configuration cannot be buried by large global +/// libraries. #[must_use] pub fn discover_for_workspace_and_dir(workspace: &Path, skills_dir: &Path) -> SkillRegistry { - let dirs = skills_directories(workspace); - discover_for_workspace_dirs_and_dir(dirs, skills_dir) + let mut dirs = skills_directories(workspace); + insert_configured_skills_dir(&mut dirs, workspace, skills_dir); + discover_from_directories(dirs) } -fn discover_for_workspace_dirs_and_dir(mut dirs: Vec, skills_dir: &Path) -> SkillRegistry { - if skills_dir.is_dir() && !dirs.iter().any(|p| p == skills_dir) { - dirs.push(skills_dir.to_path_buf()); +fn insert_configured_skills_dir(dirs: &mut Vec, workspace: &Path, skills_dir: &Path) { + if !skills_dir.is_dir() || dirs.iter().any(|p| paths_refer_to_same_dir(p, skills_dir)) { + return; } - discover_from_directories(dirs) + let workspace_root = fs::canonicalize(workspace).ok(); + let insert_at = workspace_root + .as_ref() + .and_then(|root| { + dirs.iter() + .position(|dir| fs::canonicalize(dir).map_or(true, |dir| !dir.starts_with(root))) + }) + .unwrap_or(dirs.len()); + dirs.insert(insert_at, skills_dir.to_path_buf()); +} + +fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } } pub(crate) fn discover_from_directories(dirs: impl IntoIterator) -> SkillRegistry { @@ -605,8 +626,9 @@ fn discover_for_workspace_and_dir_with_home( skills_dir: &Path, home_dir: Option<&Path>, ) -> SkillRegistry { - let dirs = skills_directories_with_home(workspace, home_dir); - discover_for_workspace_dirs_and_dir(dirs, skills_dir) + let mut dirs = skills_directories_with_home(workspace, home_dir); + insert_configured_skills_dir(&mut dirs, workspace, skills_dir); + discover_from_directories(dirs) } /// Render the system-prompt skills block from every workspace @@ -626,12 +648,24 @@ pub fn render_available_skills_context_for_workspace(workspace: &Path) -> Option /// Single-directory variant — use /// [`render_available_skills_context_for_workspace`] when scanning /// a workspace for cross-tool skill folders (#432). +#[cfg(test)] #[must_use] -pub fn render_available_skills_context(skills_dir: &Path) -> Option { +fn render_available_skills_context(skills_dir: &Path) -> Option { let registry = SkillRegistry::discover(skills_dir); render_skills_block(®istry) } +/// Union variant: merge skills discovered in the `workspace` (cross-tool skill +/// folders) and an explicitly-configured `skills_dir`. +#[must_use] +pub fn render_available_skills_context_for_workspace_and_dir( + workspace: &Path, + skills_dir: &Path, +) -> Option { + let registry = discover_for_workspace_and_dir(workspace, skills_dir); + render_skills_block(®istry) +} + fn render_skills_block(registry: &SkillRegistry) -> Option { if registry.is_empty() { return None; @@ -1197,6 +1231,69 @@ mod tests { assert!(rendered.contains("from-claude")); } + #[test] + fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() { + let tmpdir = TempDir::new().unwrap(); + let workspace = tmpdir.path().join("workspace"); + let home = tmpdir.path().join("home"); + let configured_dir = tmpdir.path().join("configured-skills"); + std::fs::create_dir_all(&workspace).unwrap(); + write_skill( + &workspace.join(".claude").join("skills"), + "workspace-skill", + "workspace visible skill", + "body", + ); + write_skill( + &configured_dir, + "configured-skill", + "configured visible skill", + "body", + ); + + let registry = super::discover_for_workspace_and_dir_with_home( + &workspace, + &configured_dir, + Some(&home), + ); + let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect(); + + assert!(names.contains(&"workspace-skill")); + assert!(names.contains(&"configured-skill")); + } + + #[test] + fn explicit_configured_skills_dir_precedes_global_defaults() { + let tmpdir = TempDir::new().unwrap(); + let workspace = tmpdir.path().join("workspace"); + let home = tmpdir.path().join("home"); + let configured_dir = tmpdir.path().join("configured-skills"); + std::fs::create_dir_all(&workspace).unwrap(); + write_skill( + &home.join(".agents").join("skills"), + "shared-skill", + "global skill", + "global body", + ); + write_skill( + &configured_dir, + "shared-skill", + "configured skill", + "configured body", + ); + + let registry = super::discover_for_workspace_and_dir_with_home( + &workspace, + &configured_dir, + Some(&home), + ); + let skill = registry + .get("shared-skill") + .expect("shared skill discovered"); + + assert_eq!(skill.description, "configured skill"); + } + /// Regression for the GitHub issue where users organize skills under /// vendor / category subdirectories (e.g. cloned skill repos that /// bundle several skills together). The old single-level `read_dir` diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 312c6bc6e..04f4b96c1 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -46,6 +46,7 @@ harvest/stewardship commits: | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -115,6 +116,9 @@ harvest/stewardship commits: | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | +| #2736 sub-agent model inheritance | Mergeable | Harvest next with tighter tests: `ToolAgent` should inherit the parent runtime model instead of hard-coding `deepseek-v4-flash`, while preserving the current reasoning-effort behavior unless provider-specific request shaping proves unsafe. Credit @h3c-hexin. | +| #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | +| #2738 dense tool-call transcript collapse | Mergeable | Do not merge as-is. The compaction idea matches the `/relay` direction, but the PR currently bypasses normal rendering, lacks expansion wiring, defaults to expanded mode, and has cache-key/index maintenance risks. Harvest only after completing those behaviors. | ## Issue Reduction Strategy @@ -135,6 +139,6 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, - #2634, #2636, #2687, and already-harvested performance PRs. + #2634, #2636, #2687, #2737, and already-harvested performance PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From 55024a16d8118403b4999e565fd486b48a394d3d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 21:43:18 -0700 Subject: [PATCH 042/209] fix(subagent): inherit tool-agent model route Harvested from PR #2736 by @h3c-hexin. Co-authored-by: h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++---- crates/tui/src/client.rs | 23 +++++++++++++++++++++++ crates/tui/src/tools/subagent/mod.rs | 9 ++++++--- crates/tui/src/tools/subagent/tests.rs | 25 ++++++++++++++++++++++--- docs/V0_9_0_EXECUTION_MAP.md | 5 +++-- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb088d30e..f86956936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,18 +57,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explicit `skills_dir` configuration is now unioned with workspace skill discovery instead of being shadowed by workspace-local skills, and configured skills take precedence over global defaults when prompt space is constrained. +- Tool-agent sub-agent routing now inherits the parent session model, or an + explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; + the fast lane still disables thinking through provider-aware request shaping. ### Community -Thanks to **@cyq1017** for the restore-listing implementation (#2513) and -**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), and +Thanks to **@cyq1017** for the restore-listing implementation (#2513), +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update API (#2640), **@shenjackyuanjie** for the -HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), and +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), **@idling11** for the PlanArtifact direction in Plan mode (#2733), and -**@h3c-hexin** for the configured `skills_dir` merge fix (#2737). +**@h3c-hexin** for the tool-agent model inheritance and configured +`skills_dir` fixes (#2736, #2737). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 4f727de8d..e53f1877f 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2437,6 +2437,29 @@ mod tests { assert!(body.get("extra_body").is_none()); } + #[test] + fn reasoning_effort_off_is_omitted_for_strict_openai_like_providers() { + for provider in [ + ApiProvider::Openai, + ApiProvider::Atlascloud, + ApiProvider::WanjieArk, + ApiProvider::Arcee, + ApiProvider::Huggingface, + ApiProvider::Moonshot, + ApiProvider::Ollama, + ApiProvider::Fireworks, + ] { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("off"), provider); + + assert_eq!( + body, + json!({}), + "provider {provider:?} should not receive unsupported reasoning-off fields" + ); + } + } + #[test] fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() { let mut body = json!({}); diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 9713a1562..80bdf3e3c 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -4697,7 +4697,7 @@ pub(crate) async fn resolve_subagent_assignment_route( agent_type: &SubAgentType, ) -> SubAgentResolvedRoute { if matches!(agent_type, SubAgentType::ToolAgent) { - return tool_agent_route(); + return tool_agent_route(&runtime.model, configured_model); } let explicit_model = configured_model.is_some(); @@ -4720,9 +4720,12 @@ pub(crate) async fn resolve_subagent_assignment_route( route } -fn tool_agent_route() -> SubAgentResolvedRoute { +fn tool_agent_route(parent_model: &str, configured_model: Option) -> SubAgentResolvedRoute { SubAgentResolvedRoute { - model: "deepseek-v4-flash".to_string(), + // The tool-agent fast lane is defined by disabling thinking, not by a + // DeepSeek-specific model id. Honor explicit role/spawn overrides when + // present, otherwise inherit the already provider-resolved parent model. + model: configured_model.unwrap_or_else(|| parent_model.to_string()), reasoning_effort: Some("off".to_string()), } } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 097b23237..0ed218842 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -845,10 +845,28 @@ fn subagent_auto_route_respects_explicit_or_role_model() { } #[tokio::test] -async fn tool_agent_route_forces_flash_with_thinking_off() { - let runtime = stub_runtime() +async fn tool_agent_route_inherits_parent_model_with_thinking_off() { + let mut runtime = stub_runtime() .with_auto_model(false) .with_reasoning_effort(Some("max".to_string()), false); + runtime.model = "local-provider/tool-fast".to_string(); + + let route = resolve_subagent_assignment_route( + &runtime, + None, + "run OCR on this screenshot", + &SubAgentType::ToolAgent, + ) + .await; + + assert_eq!(route.model, "local-provider/tool-fast"); + assert_eq!(route.reasoning_effort.as_deref(), Some("off")); +} + +#[tokio::test] +async fn tool_agent_route_respects_explicit_model_with_thinking_off() { + let mut runtime = stub_runtime().with_auto_model(false); + runtime.model = "local-provider/tool-fast".to_string(); let route = resolve_subagent_assignment_route( &runtime, @@ -858,7 +876,7 @@ async fn tool_agent_route_forces_flash_with_thinking_off() { ) .await; - assert_eq!(route.model, "deepseek-v4-flash"); + assert_eq!(route.model, "deepseek-v4-pro"); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } @@ -2210,6 +2228,7 @@ fn stub_runtime() -> SubAgentRuntime { /// `Option<...>`. `Config::default()` is enough — `DeepSeekClient::new` /// only validates that an API key field exists, not that the key works. fn stub_client() -> DeepSeekClient { + let _ = rustls::crypto::ring::default_provider().install_default(); let config = crate::config::Config { api_key: Some("test-key".to_string()), ..crate::config::Config::default() diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 04f4b96c1..af2465225 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -46,6 +46,7 @@ harvest/stewardship commits: | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | @@ -116,7 +117,7 @@ harvest/stewardship commits: | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | -| #2736 sub-agent model inheritance | Mergeable | Harvest next with tighter tests: `ToolAgent` should inherit the parent runtime model instead of hard-coding `deepseek-v4-flash`, while preserving the current reasoning-effort behavior unless provider-specific request shaping proves unsafe. Credit @h3c-hexin. | +| #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2738 dense tool-call transcript collapse | Mergeable | Do not merge as-is. The compaction idea matches the `/relay` direction, but the PR currently bypasses normal rendering, lacks expansion wiring, defaults to expanded mode, and has cache-key/index maintenance risks. Harvest only after completing those behaviors. | @@ -139,6 +140,6 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, - #2634, #2636, #2687, #2737, and already-harvested performance PRs. + #2634, #2636, #2687, #2736, #2737, and already-harvested performance PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From c76ec47526c56e5ef372993483ce2eda71ec5948 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 22:00:46 -0700 Subject: [PATCH 043/209] feat(transcript): collapse dense tool runs Harvested from PR #2738 by @idling11. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 7 +- crates/tui/src/commands/config.rs | 9 + crates/tui/src/settings.rs | 49 +++++ crates/tui/src/tui/app.rs | 149 ++++++++++++++- crates/tui/src/tui/history.rs | 298 ++++++++++++++++++++++++++++++ crates/tui/src/tui/mouse_ui.rs | 27 ++- crates/tui/src/tui/ui.rs | 19 +- crates/tui/src/tui/views/mod.rs | 9 + crates/tui/src/tui/widgets/mod.rs | 227 +++++++++++++++++++++-- docs/V0_9_0_EXECUTION_MAP.md | 6 +- 10 files changed, 771 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f86956936..f85fd0106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tool-agent sub-agent routing now inherits the parent session model, or an explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; the fast lane still disables thinking through provider-aware request shaping. +- Dense successful read/search/list tool runs now collapse into a single + expandable transcript row by default, while running, failed, shell, patch, + review, diff, and other risky tool cells remain visible. The setting + `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. ### Community @@ -70,7 +74,8 @@ prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update API (#2640), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), -**@idling11** for the PlanArtifact direction in Plan mode (#2733), and +**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the +dense tool-call transcript collapse direction (#2738, #2692), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 36d5e2fd0..c7718fb65 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -204,6 +204,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { "max_history" | "history" => Some(app.max_input_history.to_string()), "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()), "sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()), + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + Some(app.tool_collapse_mode.as_setting().to_string()) + } "context_panel" | "context" | "session_panel" => { Some(if app.context_panel { "true" } else { "false" }.to_string()) } @@ -847,6 +850,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); app.mark_history_updated(); } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + app.tool_collapse_mode = + crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode); + app.expanded_tool_runs.clear(); + app.mark_history_updated(); + } "default_mode" | "mode" => { let mode = AppMode::from_setting(&settings.default_mode); app.set_mode(mode); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 24636e1e3..ebcb34f56 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -203,6 +203,8 @@ pub struct Settings { pub auto_compact_threshold_percent: f64, /// Reduce status noise and collapse details more aggressively pub calm_mode: bool, + /// Dense tool-run collapse mode: compact, expanded, or calm. + pub tool_collapse_mode: String, /// Streaming pacing mode. `true` pins the chunker to one-character-per- /// commit-tick (typewriter); `false` drains the upstream cadence (each /// commit flushes everything queued, which matches V4-pro's burst pattern @@ -330,6 +332,7 @@ impl Default for Settings { auto_compact: false, auto_compact_threshold_percent: 80.0, calm_mode: false, + tool_collapse_mode: "compact".to_string(), low_motion: false, fancy_animations: true, bracketed_paste: true, @@ -401,6 +404,7 @@ impl Settings { s.default_mode = normalize_mode(&s.default_mode).to_string(); s.composer_density = normalize_composer_density(&s.composer_density).to_string(); s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string(); + s.tool_collapse_mode = normalize_tool_collapse_mode(&s.tool_collapse_mode).to_string(); s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string(); s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string(); s.synchronized_output = @@ -565,6 +569,15 @@ impl Settings { "calm_mode" | "calm" => { self.calm_mode = parse_bool(value)?; } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + let normalized = normalize_tool_collapse_mode(value); + if !matches!(normalized, "compact" | "expanded" | "calm") { + return Err(anyhow::anyhow!( + "Failed to update setting: invalid tool collapse mode '{value}'. Expected: compact, expanded, or calm." + )); + } + self.tool_collapse_mode = normalized.to_string(); + } "low_motion" | "motion" => { self.low_motion = parse_bool(value)?; } @@ -774,6 +787,7 @@ impl Settings { self.auto_compact_threshold_percent )); lines.push(format!(" calm_mode: {}", self.calm_mode)); + lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode)); lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" fancy_animations: {}", self.fancy_animations)); lines.push(format!(" bracketed_paste: {}", self.bracketed_paste)); @@ -849,6 +863,10 @@ impl Settings { "Auto-compact trigger threshold percent when auto_compact is on: 10-100 (default 80)", ), ("calm_mode", "Calmer UI defaults: on/off"), + ( + "tool_collapse", + "Dense tool-run collapse mode: compact, expanded, calm", + ), ( "low_motion", "Streaming pacing: on = typewriter (one char/tick), off = upstream cadence", @@ -1178,6 +1196,15 @@ fn normalize_transcript_spacing(value: &str) -> &str { } } +fn normalize_tool_collapse_mode(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "default" | "on" | "true" => "compact", + "expanded" | "expand" | "off" | "none" | "false" => "expanded", + "calm" | "calm_mode" | "calm-mode" | "calm_only" | "calm-only" => "calm", + _ => value, + } +} + /// Normalize the `status_indicator` header chip setting. Accepts the /// canonical names plus common aliases ("none"/"hidden" → "off", /// "dot" → "dots"). Unknown values fall through unchanged so the parser @@ -1542,6 +1569,28 @@ mod tests { assert!(!settings.context_panel); } + #[test] + fn tool_collapse_mode_is_configurable() { + let mut settings = Settings::default(); + assert_eq!(settings.tool_collapse_mode, "compact"); + + settings + .set("tool_collapse", "expanded") + .expect("expanded mode"); + assert_eq!(settings.tool_collapse_mode, "expanded"); + + settings.set("collapse", "calm-only").expect("calm alias"); + assert_eq!(settings.tool_collapse_mode, "calm"); + + settings.set("collapse", "off").expect("off alias"); + assert_eq!(settings.tool_collapse_mode, "expanded"); + + let err = settings + .set("tool_collapse", "mystery") + .expect_err("invalid collapse mode"); + assert!(err.to_string().contains("invalid tool collapse mode")); + } + #[test] fn display_localizes_header_and_config_file_label() { let settings = Settings::default(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 1449c430d..f82a200e6 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -327,6 +327,46 @@ impl SidebarFocus { } } +/// Controls how dense tool-call runs are collapsed in the transcript. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCollapseMode { + /// Collapse qualifying tool runs by default. + Compact, + /// Never collapse tool runs automatically. + Expanded, + /// Collapse only when calm mode is active. + Calm, +} + +impl ToolCollapseMode { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "expanded" | "off" | "none" => Self::Expanded, + "calm" | "calm-mode" | "calm_only" | "calm-only" => Self::Calm, + _ => Self::Compact, + } + } + + #[must_use] + pub fn as_setting(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Expanded => "expanded", + Self::Calm => "calm", + } + } + + #[must_use] + pub fn is_active(self, calm_mode: bool) -> bool { + match self { + Self::Compact => true, + Self::Expanded => false, + Self::Calm => calm_mode, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusToastLevel { Info, @@ -1320,6 +1360,12 @@ pub struct App { pub sidebar_width_dirty: bool, /// Whether the session-context panel is enabled (#504). pub context_panel: bool, + /// Minimum number of consecutive safe tool cells needed for auto-collapse. + pub tool_collapse_threshold: usize, + /// Tool runs the user explicitly expanded. Stores original history indices. + pub expanded_tool_runs: HashSet, + /// Current dense tool-run collapse behavior. + pub tool_collapse_mode: ToolCollapseMode, /// File-tree pane state. `None` when hidden; `Some` when visible. pub file_tree: Option, /// Whether the file-tree pane was actually rendered in the last frame. @@ -2048,6 +2094,9 @@ impl App { sidebar_resize_total_width: 0, sidebar_width_dirty: false, context_panel: settings.context_panel, + tool_collapse_threshold: 3, + expanded_tool_runs: HashSet::new(), + tool_collapse_mode: ToolCollapseMode::from_setting(&settings.tool_collapse_mode), file_tree: None, file_tree_visible: false, compact_threshold, @@ -2607,6 +2656,10 @@ impl App { .into_iter() .filter_map(|idx| if idx >= n { Some(idx - n) } else { None }) .collect(); + self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs) + .into_iter() + .filter_map(|idx| if idx >= n { Some(idx - n) } else { None }) + .collect(); self.collapsed_cell_map.clear(); } @@ -2701,6 +2754,7 @@ impl App { self.session_context_references.clear(); self.session_artifacts.clear(); self.collapsed_cells.clear(); + self.expanded_tool_runs.clear(); self.collapsed_cell_map.clear(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; @@ -2713,6 +2767,8 @@ impl App { self.history_revisions.pop(); self.context_references_by_cell.remove(&self.history.len()); self.rebuild_session_context_references(); + self.expanded_tool_runs + .retain(|idx| *idx < self.history.len()); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } @@ -2750,11 +2806,42 @@ impl App { } // Drop collapsed cells that reference indices past the new tail. self.collapsed_cells.retain(|idx| *idx < new_len); + self.expanded_tool_runs.retain(|idx| *idx < new_len); self.collapsed_cell_map.clear(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } + #[must_use] + pub fn tool_collapse_active(&self) -> bool { + self.tool_collapse_threshold > 0 && self.tool_collapse_mode.is_active(self.calm_mode) + } + + #[must_use] + pub fn tool_run_start_for_history_index(&self, index: usize) -> Option { + if !self.tool_collapse_active() || index >= self.history.len() { + return None; + } + crate::tui::history::detect_tool_runs(&self.history, self.tool_collapse_threshold) + .into_iter() + .find(|run| index >= run.start && index < run.start.saturating_add(run.count)) + .map(|run| run.start) + } + + pub fn toggle_tool_run_expansion_at(&mut self, index: usize) -> bool { + let Some(start) = self.tool_run_start_for_history_index(index) else { + return false; + }; + if self.expanded_tool_runs.remove(&start) { + self.status_message = Some("Tool group collapsed".to_string()); + } else { + self.expanded_tool_runs.insert(start); + self.status_message = Some("Tool group expanded".to_string()); + } + self.mark_history_updated(); + true + } + /// Bump the active-cell revision counter and request a redraw. /// /// Use this whenever an entry inside `active_cell` is mutated. The @@ -2787,6 +2874,14 @@ impl App { self.virtual_cell_count() } + #[must_use] + pub fn original_cell_index_for_rendered(&self, rendered_index: usize) -> usize { + self.collapsed_cell_map + .get(rendered_index) + .copied() + .unwrap_or(rendered_index) + } + /// Resolve a virtual cell index to either a committed history cell or an /// active-cell entry. Used by the pager / details lookup code so it can /// transparently address still-in-flight cells. @@ -2842,7 +2937,7 @@ impl App { .ordered_endpoints() .and_then(|(start, _)| line_meta.get(start.line_index)) .and_then(TranscriptLineMeta::cell_line) - .map(|(cell_index, _)| cell_index) + .map(|(cell_index, _)| self.original_cell_index_for_rendered(cell_index)) .filter(|&idx| self.cell_has_detail_target(idx)); if selected_cell.is_some() { return selected_cell; @@ -2854,6 +2949,7 @@ impl App { let Some((cell_index, _)) = meta.cell_line() else { continue; }; + let cell_index = self.original_cell_index_for_rendered(cell_index); if self.cell_has_detail_target(cell_index) { return Some(cell_index); } @@ -4996,6 +5092,7 @@ mod tests { use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; use crate::tui::clipboard::PastedImage; + use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus}; fn test_options(yolo: bool) -> TuiOptions { TuiOptions { @@ -6155,6 +6252,56 @@ mod tests { assert!(app.history_version > initial_version); } + #[test] + fn expanded_tool_runs_rebase_when_history_prefix_shifts() { + let mut app = App::new(test_options(false), &Config::default()); + app.expanded_tool_runs = std::collections::HashSet::from([2usize, 6usize]); + + app.shift_history_maps_down(3); + + assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([3])); + } + + #[test] + fn expanded_tool_runs_prune_when_history_is_truncated() { + let mut app = App::new(test_options(false), &Config::default()); + for idx in 0..5 { + app.add_message(HistoryCell::System { + content: format!("cell {idx}"), + }); + } + app.expanded_tool_runs = std::collections::HashSet::from([1usize, 4usize]); + + app.truncate_history_to(3); + + assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([1])); + } + + #[test] + fn tool_run_expansion_toggle_opens_and_closes_run() { + let mut app = App::new(test_options(false), &Config::default()); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + for name in ["read_file", "list_dir", "web_search"] { + app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("ok".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + }))); + } + + assert!(app.toggle_tool_run_expansion_at(0)); + assert!(app.expanded_tool_runs.contains(&0)); + assert!(app.toggle_tool_run_expansion_at(2)); + assert!(!app.expanded_tool_runs.contains(&0)); + assert!(!app.toggle_tool_run_expansion_at(99)); + } + #[test] fn test_scroll_operations() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 30e92dfcc..1f77083f1 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -683,6 +683,68 @@ pub enum ToolCell { } impl ToolCell { + /// Status for cells that have a concrete lifecycle state. + pub fn status(&self) -> Option { + match self { + ToolCell::Exec(cell) => Some(cell.status), + ToolCell::Exploring(cell) => { + let has_running = cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Running); + let has_failed = cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Failed); + Some(if has_running { + ToolStatus::Running + } else if has_failed { + ToolStatus::Failed + } else { + ToolStatus::Success + }) + } + ToolCell::PlanUpdate(cell) => Some(cell.status), + ToolCell::PatchSummary(cell) => Some(cell.status), + ToolCell::Review(cell) => Some(cell.status), + ToolCell::Mcp(cell) => Some(cell.status), + ToolCell::WebSearch(cell) => Some(cell.status), + ToolCell::Generic(cell) => Some(cell.status), + ToolCell::DiffPreview(_) | ToolCell::ViewImage(_) => Some(ToolStatus::Success), + } + } + + #[must_use] + pub fn is_success(&self) -> bool { + self.status() == Some(ToolStatus::Success) + } + + #[must_use] + pub fn is_running(&self) -> bool { + self.status() == Some(ToolStatus::Running) + } + + #[must_use] + pub fn is_failed(&self) -> bool { + self.status() == Some(ToolStatus::Failed) + } + + /// Whether this cell should stay visible even inside a dense tool run. + #[must_use] + pub fn is_collapsible_guard(&self) -> bool { + self.is_running() + || self.is_failed() + || matches!( + self, + ToolCell::Exec(_) + | ToolCell::PatchSummary(_) + | ToolCell::Review(_) + | ToolCell::DiffPreview(_) + | ToolCell::PlanUpdate(_) + ) + || matches!(self, ToolCell::Generic(cell) if generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff) + } + /// Render the tool cell into lines. pub fn lines(&self, width: u16) -> Vec> { self.lines_with_motion(width, false) @@ -715,6 +777,104 @@ impl ToolCell { } } +// ── Tool-run grouping for transcript collapse (#2692) ────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolRun { + /// Original index of the first tool cell in `App::history`. + pub start: usize, + /// Number of collapsed cells in the run. + pub count: usize, + /// Dominant tool names, deduplicated and capped for summary rendering. + pub tool_families: Vec, +} + +/// Detect contiguous runs of successful, low-risk tool cells. +/// +/// Failed, running, shell, patch, review, diff, and plan-update cells split +/// runs so important state never disappears into a summary row. +pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec { + if min_size == 0 { + return Vec::new(); + } + + let mut runs = Vec::new(); + let mut index = 0; + while index < history.len() { + if !is_collapsible_tool_cell(&history[index]) { + index += 1; + continue; + } + + let start = index; + let mut names: Vec = Vec::new(); + while index < history.len() && is_collapsible_tool_cell(&history[index]) { + if let HistoryCell::Tool(tool) = &history[index] { + let name = tool_display_name(tool); + if !names.iter().any(|existing| existing == name) { + names.push(name.to_string()); + } + } + index += 1; + } + + let count = index - start; + if count >= min_size { + names.truncate(3); + runs.push(ToolRun { + start, + count, + tool_families: names, + }); + } + } + + runs +} + +fn is_collapsible_tool_cell(cell: &HistoryCell) -> bool { + matches!(cell, HistoryCell::Tool(tool) if tool.is_success() && !tool.is_collapsible_guard()) +} + +fn generic_tool_name_is_collapse_guard(name: &str) -> bool { + let normalized = name.trim().to_ascii_lowercase(); + normalized.contains("patch") + || normalized.contains("write") + || normalized.contains("edit") + || normalized.contains("delete") + || normalized.contains("remove") + || normalized.contains("commit") + || normalized.contains("push") + || normalized.contains("shell") + || normalized.contains("exec") + || normalized.contains("review") +} + +fn tool_display_name(tool: &ToolCell) -> &str { + match tool { + ToolCell::Generic(cell) => cell.name.as_str(), + ToolCell::Mcp(cell) => cell.tool.as_str(), + ToolCell::WebSearch(_) => "web_search", + ToolCell::ViewImage(_) => "view_image", + ToolCell::Exploring(_) => "explore", + ToolCell::Exec(_) => "shell", + ToolCell::PlanUpdate(_) => "update_plan", + ToolCell::PatchSummary(_) => "apply_patch", + ToolCell::Review(_) => "review", + ToolCell::DiffPreview(_) => "diff", + } +} + +#[must_use] +pub fn tool_run_summary(run: &ToolRun) -> String { + let tools = if run.tool_families.is_empty() { + "tools".to_string() + } else { + run.tool_families.join(", ") + }; + format!("{} tools ({tools}) · all ok", run.count) +} + /// Overall status for a tool execution. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolStatus { @@ -5393,4 +5553,142 @@ mod tests { assert_eq!(label_span.content.as_ref(), "Info"); assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM)); } + + fn success_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: Some(format!("args for {name}")), + output: Some(format!("output for {name}")), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn failed_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Failed, + input_summary: None, + output: Some("failed".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn running_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Running, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn shell_tool(command: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: command.to_string(), + status: ToolStatus::Success, + output: Some("ok".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })) + } + + #[test] + fn detect_tool_runs_finds_contiguous_successful_safe_tools() { + let history = vec![ + HistoryCell::User { + content: "go".to_string(), + }, + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + HistoryCell::Assistant { + content: "done".to_string(), + streaming: false, + }, + ]; + + let runs = super::detect_tool_runs(&history, 3); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].start, 1); + assert_eq!(runs[0].count, 3); + assert_eq!( + runs[0].tool_families, + vec!["read_file", "list_dir", "web_search"] + ); + } + + #[test] + fn detect_tool_runs_honors_threshold_and_boundaries() { + let short = vec![ + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + ]; + assert!(super::detect_tool_runs(&short, 3).is_empty()); + + let with_assistant_boundary = vec![ + success_generic_tool("read_file"), + HistoryCell::Assistant { + content: "pause".to_string(), + streaming: false, + }, + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + ]; + assert!(super::detect_tool_runs(&with_assistant_boundary, 3).is_empty()); + } + + #[test] + fn detect_tool_runs_keeps_failed_running_and_shell_cells_visible() { + let history = vec![ + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + failed_generic_tool("web_search"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + running_generic_tool("web_search"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + shell_tool("rm -rf target"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + ]; + + let runs = super::detect_tool_runs(&history, 3); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].start, 9); + assert_eq!(runs[0].count, 3); + } + + #[test] + fn tool_run_summary_reports_compact_success_group() { + let run = super::ToolRun { + start: 4, + count: 5, + tool_families: vec!["read_file".to_string(), "list_dir".to_string()], + }; + + let summary = super::tool_run_summary(&run); + + assert!(summary.contains("5 tools")); + assert!(summary.contains("read_file")); + assert!(summary.contains("list_dir")); + assert!(summary.contains("all ok")); + } } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 8e455d42b..26aec0943 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -42,6 +42,20 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool { + if !app.tool_collapse_active() { + return false; + } + let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else { + return false; + }; + let original_idx = app.original_cell_index_for_rendered(rendered_idx); + if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) { + return false; + } + app.toggle_tool_run_expansion_at(original_idx) +} + /// Handle mouse events on the sidebar resize handle (the 1-col vertical bar /// between the chat area and the sidebar). Returns true when the event was /// consumed so other handlers skip it. @@ -367,6 +381,10 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec Vec + { + continue; + } KeyCode::Char('l') if key_shortcuts::alt_nav_modifiers(key.modifiers) && app.input.is_empty() @@ -3259,6 +3267,9 @@ async fn run_event_loop( if key.modifiers == KeyModifiers::NONE && app.input.is_empty() => { if let Some(idx) = detail_target_cell_index(app) { + if app.toggle_tool_run_expansion_at(idx) { + continue; + } let is_thinking = app .history .get(idx) @@ -8286,7 +8297,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool let current_cell = line_meta .get(top) .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line) - .map(|(cell_index, _)| cell_index); + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)); let mut scan_indices = Vec::new(); match direction { @@ -8302,6 +8313,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool let Some((cell_index, _)) = line_meta[idx].cell_line() else { continue; }; + let cell_index = app.original_cell_index_for_rendered(cell_index); if current_cell.is_some_and(|current| current == cell_index) { continue; } @@ -8579,7 +8591,7 @@ fn selected_transcript_cell_index(app: &App) -> Option { .line_meta() .get(start.line_index) .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index) + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)) }) } @@ -9124,7 +9136,7 @@ fn detail_target_cell_index(app: &App) -> Option { .line_meta() .get(start.line_index) .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index); + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)); } app.detail_cell_index_for_viewport( @@ -9171,6 +9183,7 @@ fn activity_footer_target_cell_index(app: &App) -> Option { let Some((cell_index, _)) = meta.cell_line() else { continue; }; + let cell_index = app.original_cell_index_for_rendered(cell_index); if app .cell_at_virtual_index(cell_index) .is_some_and(is_meaningful_activity_cell) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index f11d846e6..d041885aa 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -745,6 +745,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "tool_collapse".to_string(), + value: settings.tool_collapse_mode.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "composer_density".to_string(), @@ -1244,6 +1251,7 @@ fn config_hint_for_key(key: &str) -> &'static str { | "composer_border" | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "tool_collapse" => "compact | expanded | calm", "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", @@ -2392,6 +2400,7 @@ mod tests { assert!(keys.contains(&"status_indicator")); assert!(keys.contains(&"synchronized_output")); assert!(keys.contains(&"auto_compact")); + assert!(keys.contains(&"tool_collapse")); assert!(keys.contains(&"composer_border")); assert!(keys.contains(&"composer_vim_mode")); assert!(keys.contains(&"bracketed_paste")); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9bed..03acf4aa7 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -22,6 +22,7 @@ pub use footer::{ pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame}; pub use renderable::Renderable; +use std::collections::HashSet; use std::time::Duration; use crate::localization::Locale; @@ -30,7 +31,7 @@ use crate::tui::app::{App, AppMode, ComposerDensity, VimMode}; use crate::tui::approval::{ ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory, }; -use crate::tui::history::HistoryCell; +use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus}; use crate::tui::scrolling::TranscriptLineMeta; use crate::{ commands, @@ -129,7 +130,25 @@ impl ChatWidget { .map_or(&[], |active| active.entries()); let history_len = app.history.len(); - let has_collapsed = !app.collapsed_cells.is_empty(); + let tool_runs = if app.tool_collapse_active() { + crate::tui::history::detect_tool_runs(&app.history, app.tool_collapse_threshold) + } else { + Vec::new() + }; + let collapsed_run_starts: HashSet = tool_runs + .iter() + .filter_map(|run| (!app.expanded_tool_runs.contains(&run.start)).then_some(run.start)) + .collect(); + let mut collapsed_tool_indices: HashSet = HashSet::new(); + for run in &tool_runs { + if !collapsed_run_starts.contains(&run.start) { + continue; + } + for offset in 1..run.count { + collapsed_tool_indices.insert(run.start + offset); + } + } + let has_collapsed = !app.collapsed_cells.is_empty() || !collapsed_run_starts.is_empty(); // Fast path: no collapsed cells — use original slices directly. if !has_collapsed { @@ -174,6 +193,18 @@ impl ChatWidget { if app.collapsed_cells.contains(&idx) { continue; } + if collapsed_tool_indices.contains(&idx) { + continue; + } + if let Some(run) = tool_runs + .iter() + .find(|run| run.start == idx && collapsed_run_starts.contains(&idx)) + { + filtered_cells.push(tool_run_summary_cell(run)); + filtered_revs.push(tool_run_summary_revision(run, &app.history_revisions)); + filtered_to_original.push(idx); + continue; + } filtered_cells.push(cell.clone()); filtered_revs.push(app.history_revisions[idx]); filtered_to_original.push(idx); @@ -277,14 +308,26 @@ impl ChatWidget { && let Some(send_at) = app.last_send_at { if send_at.elapsed() < SEND_FLASH_DURATION { - apply_send_flash(&mut lines, top, &app.history, line_meta); + apply_send_flash( + &mut lines, + top, + &app.history, + line_meta, + &app.collapsed_cell_map, + ); } else { app.last_send_at = None; } } if let Some(target_cell) = detail_target_cell { - apply_detail_target_highlight(&mut lines, top, target_cell, line_meta); + apply_detail_target_highlight( + &mut lines, + top, + target_cell, + line_meta, + &app.collapsed_cell_map, + ); } apply_selection(&mut lines, top, app); @@ -323,6 +366,27 @@ impl ChatWidget { } } +fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "activity_group".to_string(), + status: ToolStatus::Success, + input_summary: Some(crate::tui::history::tool_run_summary(run)), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) +} + +fn tool_run_summary_revision(run: &ToolRun, revisions: &[u64]) -> u64 { + let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64); + for idx in run.start..run.start.saturating_add(run.count) { + revision = revision.rotate_left(7) ^ revisions.get(idx).copied().unwrap_or(u64::MAX); + } + revision +} + impl Renderable for ChatWidget { fn render(&self, _area: Rect, buf: &mut Buffer) { // Use the passed render area, not self.content_area — those can @@ -1741,12 +1805,17 @@ fn apply_detail_target_highlight( top: usize, target_cell: usize, line_meta: &[TranscriptLineMeta], + original_index_map: &[usize], ) { let highlight_bg = Color::Reset; for (idx, line) in lines.iter_mut().enumerate() { let line_index = top + idx; if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index) - && *cell_index == target_cell + && original_index_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index) + == target_cell { for span in &mut line.spans { span.style = span.style.bg(highlight_bg); @@ -1761,6 +1830,7 @@ fn apply_send_flash( top: usize, history: &[HistoryCell], line_meta: &[TranscriptLineMeta], + original_index_map: &[usize], ) { // Find the last User cell index. let last_user_cell = history @@ -1775,7 +1845,11 @@ fn apply_send_flash( for (idx, line) in lines.iter_mut().enumerate() { let line_index = top + idx; if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index) - && *cell_index == target_cell + && original_index_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index) + == target_cell { for span in &mut line.spans { span.style = span.style.bg(flash_bg); @@ -2611,22 +2685,22 @@ fn line_spans_with_selection<'a>( mod tests { use super::{ ApprovalWidget, COMPOSER_PANEL_HEIGHT, ChatWidget, ComposerWidget, Renderable, - SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height, - composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area, - cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines, - push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines, - wrap_text, + SlashMenuEntry, apply_detail_target_highlight, apply_selection_to_line, apply_send_flash, + build_empty_state_lines, composer_height, composer_max_height, composer_min_input_rows, + composer_top_padding, compute_takeover_area, cursor_row_col, layout_input, + pad_lines_to_bottom, placeholder_visual_lines, push_command_entry, + should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text, }; use crate::config::{ApiProvider, Config}; use crate::localization::Locale; use crate::palette; - use crate::tui::app::{App, ComposerDensity, TuiOptions}; + use crate::tui::app::{App, ComposerDensity, ToolCollapseMode, TuiOptions}; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; - use crate::tui::scrolling::TranscriptScroll; + use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll}; use ratatui::{ buffer::Buffer, layout::Rect, - style::Style, + style::{Color, Style}, text::{Line, Span}, }; use std::path::PathBuf; @@ -2668,6 +2742,131 @@ mod tests { text } + fn success_tool_cell(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: Some(format!("path: {name}.txt")), + output: Some(format!("full output from {name}")), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn add_dense_tool_run(app: &mut App) { + app.add_message(success_tool_cell("read_file")); + app.add_message(success_tool_cell("list_dir")); + app.add_message(success_tool_cell("web_search")); + } + + #[test] + fn send_flash_uses_original_index_map_for_collapsed_rows() { + let history = vec![ + success_tool_cell("read_file"), + success_tool_cell("list_dir"), + HistoryCell::User { + content: "sent".to_string(), + }, + ]; + let mut lines = vec![Line::from("sent")]; + let line_meta = vec![TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + copy_prefix_width: 0, + copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline, + }]; + let original_index_map = vec![2]; + + apply_send_flash(&mut lines, 0, &history, &line_meta, &original_index_map); + + assert_eq!(lines[0].spans[0].style.bg, Some(Color::Rgb(30, 40, 55))); + } + + #[test] + fn detail_highlight_uses_original_index_map_for_collapsed_rows() { + let mut lines = vec![Line::from("tool group")]; + let line_meta = vec![TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + copy_prefix_width: 0, + copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline, + }]; + let original_index_map = vec![4]; + + apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map); + + assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset)); + } + + #[test] + fn chat_widget_collapses_dense_tool_runs_by_default() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 8, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert_eq!(app.collapsed_cell_map, vec![0]); + assert!(rendered.contains("3 tools"), "{rendered}"); + assert!( + !rendered.contains("full output from list_dir"), + "{rendered}" + ); + } + + #[test] + fn chat_widget_expands_dense_tool_runs_on_demand() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + app.expanded_tool_runs.insert(0); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 12, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]); + assert!(rendered.contains("full output from list_dir"), "{rendered}"); + } + + #[test] + fn chat_widget_expanded_mode_leaves_dense_tool_runs_visible() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Expanded; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 12, + }; + let _widget = ChatWidget::new(&mut app, area); + + assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]); + } + #[test] fn pad_lines_to_bottom_noop_when_already_filled() { let mut lines = vec![Line::from("one"), Line::from("two")]; diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index af2465225..9d378b38c 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -48,6 +48,7 @@ harvest/stewardship commits: | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | +| #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -119,7 +120,7 @@ harvest/stewardship commits: | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | | #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | -| #2738 dense tool-call transcript collapse | Mergeable | Do not merge as-is. The compaction idea matches the `/relay` direction, but the PR currently bypasses normal rendering, lacks expansion wiring, defaults to expanded mode, and has cache-key/index maintenance risks. Harvest only after completing those behaviors. | +| #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | ## Issue Reduction Strategy @@ -140,6 +141,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, - #2634, #2636, #2687, #2736, #2737, and already-harvested performance PRs. + #2634, #2636, #2687, #2736, #2737, #2738, and already-harvested performance + PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From e92202cabeb4103ddf96efcde5096ba33bfc6776 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 22:04:48 -0700 Subject: [PATCH 044/209] docs(release): update v0.9 stabilization ledger --- docs/V0_9_0_EXECUTION_MAP.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 9d378b38c..400055918 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -59,6 +59,20 @@ harvest/stewardship commits: | #2502 web_run RwLock split | Manually harvested with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. | `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | | #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +## Stabilization Gate Evidence (#2721) + +This ledger is not closed yet. It records the evidence already attached to the +v0.9 branch so the remaining Windows/manual checks are explicit. + +| Area | Current disposition | Evidence / remaining check | +| --- | --- | --- | +| Windows width/resize (#2708, #582 class) | Partially fixed on this branch. | #2708 is cherry-picked plus the fanout-card cache invalidation follow-up. `cargo test -p codewhale-tui --bin codewhale-tui --locked terminal_size -- --nocapture` passed. Still needs a real Windows Terminal resize smoke for #582 before #2721 closes. | +| Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 should close as harvested, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | +| Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed with `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture`. Still missing a synthetic many-file startup smoke that exercises first-turn latency end to end. | +| Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | +| Queued/live input feedback (#2054/#1786 adjacent) | Partially covered. | `cargo test -p codewhale-tui --bin codewhale-tui --locked queued -- --nocapture` passed for queued-message recovery/editing. Still needs one release-note/manual-smoke pass for live shell/work-queue feedback before closing the broader #1786 bucket. | +| Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | + ## PR Harvest Queue | PR | State | v0.9.0 disposition | @@ -74,12 +88,12 @@ harvest/stewardship commits: | #2269 approval details and shell previews | Conflicting | Review for small UI harvest only. | | #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. | | #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. | -| #2476 fork migration parent links | Conflicting | Prior memory says safe candidate; verify against current state before closure/harvest. | +| #2476 fork migration parent links | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Close/comment original after the integration branch is public, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. | | #2479 ProviderKind/ApiProvider trait collapse | Conflicting | Defer until file decomposition Phase 1 reduces config surface. | | #2482 WhaleFlow orchestration | Draft/conflicting | Inspect for IR ideas; do not merge wholesale. | | #2486 WhaleFlow cost tracking | Draft/conflicting | Inspect after #2482; harvest telemetry ideas only. | | #2491 typed ask permissions schema | Conflicting | Prior memory says safe candidate; verify current permissions work first. | -| #2498 Windows shell process trees | Conflicting | Prior memory says safe candidate; review for #2721 stabilization. | +| #2498 Windows shell process trees | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch through the Windows JobObject cleanup commits. Close/comment PR #2498 as harvested, crediting @aboimpinto; leave issue #1812 open because this fixes descendant pipe-handle hangs but not every reported Windows input-poll freeze mode. | | #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | | #2502 web_run RwLock split | Mergeable | Manually harvested with panic-safety and shared cached-page reads; close/comment after branch is public. | | #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | @@ -140,8 +154,8 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, - #2634, #2636, #2687, #2736, #2737, #2738, and already-harvested performance - PRs. +1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, + #2576, #2581, #2627, #2634, #2636, #2687, #2736, #2737, #2738, and + already-harvested performance PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From 333275162fd091a8736063d24f9ba030099a858d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 22:16:02 -0700 Subject: [PATCH 045/209] feat(runtime): save completed threads as sessions Harvested from PR #2639 by @gaord. Adds POST /v1/sessions for runtime clients to persist completed threads as managed saved sessions, with a 409 guard for queued or active turn/item state and focused session endpoint coverage. Also makes MCP HTTP tests install the rustls ring provider before constructing reqwest clients so filtered no-provider test runs are deterministic. Co-authored-by: gaord <9567937+gaord@users.noreply.github.com> --- CHANGELOG.md | 7 +- README.md | 55 ++++- crates/tui/src/mcp.rs | 27 +- crates/tui/src/runtime_api.rs | 449 +++++++++++++++++++++++++++++++++- docs/V0_9_0_EXECUTION_MAP.md | 20 +- 5 files changed, 525 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85fd0106..f0bf3a678 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan confirmation prompt, `/relay`, fork-state, and saved-session replay. +- Added `POST /v1/sessions` for runtime clients to save a completed thread as a + managed session. The endpoint preserves thread title/model/mode/workspace + metadata, maps missing threads to 404, and returns 409 instead of snapshotting + queued or active turns. ### Changed @@ -72,7 +76,8 @@ Thanks to **@cyq1017** for the restore-listing implementation (#2513), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread -workspace update API (#2640), **@shenjackyuanjie** for the +workspace update and completed-thread save APIs (#2640, #2639), +**@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), **@idling11** for the PlanArtifact direction in Plan mode (#2733) and the dense tool-call transcript collapse direction (#2738, #2692), and diff --git a/README.md b/README.md index 1f481c47e..7701acde3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > DeepSeek-first terminal coding agent with a durable harness: approval-gated > local edits, sub-agents, provider/model routing, live verification, rollback, -> and a v0.9 track for typed WhaleFlow workflows. +> relay/continuity handoffs, and a v0.9 track for typed WhaleFlow workflows. [简体中文 README](README.zh-CN.md) [日本語 README](README.ja-JP.md) @@ -30,10 +30,26 @@ Hugging Face stay explicit. Provider, model, base URL, and credentials are separate choices so direct-provider APIs do not get blurred with OpenRouter aliases. -The active v0.9.0 lane turns that harness into a workflow workbench: -WhaleFlow typed branch/leaf workflows, deterministic replay, pod-style workflow -monitoring, provider/model posture, and evidence-backed profile evolution. The -current execution map lives in +## Active v0.9 Track + +v0.9.0 is not released yet. The current branch is a stewardship lane for making +long-running CodeWhale work easier to continue, review, and hand off without +turning the README into release notes. + +The v0.9 track keeps the same DeepSeek-first harness and adds work in these +areas: + +| Track | What is changing | +| --- | --- | +| Relay and continuity | `/relay`, fork-state handoff, and rich PlanArtifact context preserve the goal, why it matters, evidence, constraints, blockers, changed files, verification state, and the next action. | +| Transcript calmness | Dense read/search/list-style tool runs can collapse into expandable groups, while failures, running work, shell commands, writes, diffs, plans, and reviews stay visible. | +| Runtime sessions and workspaces | Branch work extends session/thread runtime APIs, including workspace-aware thread updates, completed-thread session saves, and safer guards around active turns. Treat this as v0.9-track capability until the release ships. | +| HarmonyOS / OHOS | The lane carries safe OpenHarmony setup, OHOS platform guards, self-update disablement on OHOS, and target gating for PTY and Starlark execpolicy paths. Full OHOS target builds still require a host with the OpenHarmony native SDK configured. | +| Nix and Starlark compatibility | Dependency stewardship keeps OHOS builds from pulling incompatible Nix-chain crates through PTY or Starlark paths where those features are gated. | +| Contributor stewardship | Harvested PRs stay credited, contributor identity mapping is machine-readable, and community gates remain dry-run and human-toned while the branch is reviewed. | +| WhaleFlow | Typed branch/leaf workflows, deterministic replay, pod-style workflow monitoring, provider/model posture, and evidence-backed profile evolution remain the larger v0.9 workbench goal. | + +The current execution map lives in [docs/V0_9_0_EXECUTION_MAP.md](docs/V0_9_0_EXECUTION_MAP.md). ## Quickstart @@ -139,6 +155,19 @@ CodeWhale's harness has four practical parts: | Runtime evidence loop | Side-git snapshots, LSP diagnostics, command output, cost/cache accounting, and task state are fed back into the transcript instead of hidden behind the UI. | | Approval and sandbox posture | Plan is read-only, Agent uses approval gates, and YOLO auto-approves in trusted workspaces. macOS Seatbelt is enforced; Linux Landlock is detected but not yet enforced; Windows sandboxing is not advertised. | +### Relay And Continuity + +Relay is intentional compaction for human and agent handoff. Use `/relay` before +a long break, a fresh thread, a fork, or a handoff to another agent. It keeps the +important story small: the objective, why the work is being done, current state, +changed files, evidence checked, constraints, blockers, and the next concrete +action. + +Automatic compaction protects context windows. Relay protects continuity. In +the v0.9 track, rich PlanArtifact fields feed the transcript card, Plan-mode +confirmation, `/relay`, fork-state handoff, and saved-session replay so the +plan, the evidence, and the next step do not become separate stories. + `codewhale` is the dispatcher CLI. `codewhale-tui` is the companion runtime binary it launches for interactive sessions. The TUI talks to an async engine, an OpenAI-compatible streaming client, the tool registry, the durable task @@ -567,7 +596,21 @@ Full Changelog: [CHANGELOG.md](CHANGELOG.md). - **[OpenWarp](https://github.com/zerx-lab/warp)** — thank you for prioritizing codewhale support and for collaborating on a better terminal-agent experience. - **[Open Design](https://github.com/nexu-io/open-design)** — thank you for support and collaboration around design-forward agent workflows. -This project ships with help from a growing community of contributors: +This project ships with help from a growing community of contributors. The +maintainer rule is simple: reports and PRs are real project work, even when the +final patch has to be narrowed, delayed, or harvested into a maintainer branch. + +For the v0.9 track, harvested PRs should keep visible credit in the commit or +PR body, changelog or release notes, and relevant issue/PR comments. Contributor +credit should use mappable GitHub identities from `.github/AUTHOR_MAP` or +numeric noreply addresses, not placeholder local emails. The contribution gate +is kept in dry-run mode unless a maintainer deliberately enables enforcement; +when it comments, the tone should be warm and practical rather than treating +the reporter as the problem. Recurring contributors should be recognized so the +automation gets out of their way and the public record shows their repeated +help. + +Current and recurring contributors include: - **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 PRs spanning features, fixes, and VS Code extension scaffolding (#645–#681) - **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Markdown rendering for tables, bold/italic, and horizontal rules (#579) diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 08baea295..32a342b1a 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2920,6 +2920,11 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::{Arc, Mutex, OnceLock}; + fn test_http_client() -> reqwest::Client { + let _ = rustls::crypto::ring::default_provider().install_default(); + reqwest::Client::new() + } + async fn lock_mcp_loopback_tests() -> tokio::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| tokio::sync::Mutex::new(())) @@ -3057,7 +3062,7 @@ mod tests { #[test] fn default_mcp_http_get_accepts_json_and_event_stream() { - let client = reqwest::Client::new(); + let client = test_http_client(); let request = with_default_mcp_http_headers(client.get("https://example.invalid/mcp"), false) .build() @@ -3074,7 +3079,7 @@ mod tests { #[test] fn default_mcp_http_post_accepts_json_and_event_stream() { - let client = reqwest::Client::new(); + let client = test_http_client(); let request = with_default_mcp_http_headers(client.post("https://example.invalid/mcp"), true) .build() @@ -3094,7 +3099,7 @@ mod tests { #[test] fn streamable_http_transport_stores_headers() { - let client = reqwest::Client::new(); + let client = test_http_client(); let mut headers = HashMap::new(); headers.insert("Authorization".to_string(), "Bearer xyz".to_string()); let transport = StreamableHttpTransport::new( @@ -4235,7 +4240,7 @@ mod tests { } }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/sse"); let mut transport = SseTransport::connect( client, @@ -4326,7 +4331,7 @@ mod tests { } }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/sse"); let mut transport = SseTransport::connect( client, @@ -4426,7 +4431,7 @@ mod tests { } }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/sse"); let mut headers = HashMap::new(); headers.insert("X-Custom-Auth".to_string(), "my-test-token".to_string()); @@ -4517,7 +4522,7 @@ mod tests { } }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/sse"); let mut transport = SseTransport::connect( client, @@ -4763,7 +4768,7 @@ mod tests { let (_sender, receiver) = mpsc::unbounded_channel(); let mut transport = SseTransport { - client: reqwest::Client::new(), + client: test_http_client(), base_url: format!("http://{addr}/sse"), headers: HashMap::new(), endpoint_url: Some(format!("http://{addr}/messages")), @@ -5001,7 +5006,7 @@ mod tests { #[test] fn session_id_starts_none() { let transport = StreamableHttpTransport::new( - reqwest::Client::new(), + test_http_client(), "https://example.invalid/mcp".to_string(), HashMap::new(), ); @@ -5050,7 +5055,7 @@ mod tests { .unwrap(); }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/mcp"); let mut transport = StreamableHttpTransport::new(client, url, HashMap::new()); @@ -5117,7 +5122,7 @@ mod tests { .unwrap(); }); - let client = reqwest::Client::new(); + let client = test_http_client(); let url = format!("http://{addr}/mcp"); let mut headers = HashMap::new(); headers.insert("X-Custom-Auth".to_string(), "my-test-token".to_string()); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 523b7f3ac..6d604b157 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1,6 +1,6 @@ //! Runtime HTTP/SSE API for local DeepSeek automation. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::convert::Infallible; use std::fs; use std::net::{SocketAddr, UdpSocket}; @@ -35,13 +35,17 @@ use crate::automation_manager::{ }; use crate::config::{Config, DEFAULT_TEXT_MODEL}; use crate::mcp::{McpConfig, McpPool}; +use crate::models::{ContentBlock, Message}; use crate::runtime_threads::{ CompactThreadRequest, CreateThreadRequest, ExternalApprovalDecision, RuntimeThreadManager, - RuntimeThreadManagerConfig, SharedRuntimeThreadManager, StartTurnRequest, SteerTurnRequest, - ThreadDetail, ThreadListFilter, ThreadRecord, TurnItemKind, TurnRecord, UpdateThreadRequest, - UsageGroupBy, + RuntimeThreadManagerConfig, RuntimeTurnStatus, SharedRuntimeThreadManager, StartTurnRequest, + SteerTurnRequest, ThreadDetail, ThreadListFilter, ThreadRecord, TurnItemKind, + TurnItemLifecycleStatus, TurnRecord, UpdateThreadRequest, UsageGroupBy, +}; +use crate::session_manager::{ + SavedSession, SessionManager, SessionMetadata, create_saved_session_with_id_and_mode, + default_sessions_dir, }; -use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir}; use crate::skill_state::SkillStateStore; use crate::task_manager::{ NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary, @@ -176,6 +180,20 @@ struct SessionDetailResponse { system_prompt: Option, } +#[derive(Debug, Deserialize)] +struct CreateSessionRequest { + thread_id: String, + title: Option, +} + +#[derive(Debug, Serialize)] +struct CreateSessionResponse { + session_id: String, + thread_id: String, + message_count: usize, + title: String, +} + #[derive(Debug, Deserialize)] struct ResumeSessionRequest { model: Option, @@ -514,7 +532,10 @@ pub async fn run_http_server( pub fn build_router(state: RuntimeApiState) -> Router { let api_routes = Router::new() - .route("/v1/sessions", get(list_sessions)) + .route( + "/v1/sessions", + get(list_sessions).post(create_session_from_thread), + ) .route("/v1/sessions/{id}", get(get_session).delete(delete_session)) .route( "/v1/sessions/{id}/resume-thread", @@ -845,6 +866,150 @@ async fn resume_session_thread( )) } +async fn create_session_from_thread( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let thread_id = req.thread_id.trim(); + if thread_id.is_empty() { + return Err(ApiError::bad_request("thread_id is required")); + } + + let detail = state + .runtime_threads + .get_thread_detail(thread_id) + .await + .map_err(map_thread_err)?; + + if thread_detail_has_live_work(&detail) { + return Err(ApiError { + status: StatusCode::CONFLICT, + message: format!( + "Thread {thread_id} has a queued or active turn; wait for completion before saving as a session" + ), + }); + } + + let messages = messages_from_thread_detail(&detail); + if messages.is_empty() { + return Err(ApiError::bad_request(format!( + "Thread {thread_id} has no user or assistant messages to save" + ))); + } + + let manager = SessionManager::new(state.sessions_dir.clone()) + .map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?; + let total_tokens = total_tokens_from_thread_detail(&detail); + let session_id = uuid::Uuid::new_v4().to_string(); + let mut session = create_saved_session_with_id_and_mode( + session_id.clone(), + &messages, + &detail.thread.model, + &detail.thread.workspace, + total_tokens, + None, + Some(&detail.thread.mode), + ); + session.system_prompt = detail.thread.system_prompt.clone(); + + if let Some(title) = + session_title_override(req.title.as_deref(), detail.thread.title.as_deref()) + { + session.metadata.title = title; + } + let title = session.metadata.title.clone(); + let message_count = session.metadata.message_count; + + manager + .save_session(&session) + .map_err(|e| ApiError::internal(format!("Failed to save session: {e}")))?; + + Ok(( + StatusCode::CREATED, + Json(CreateSessionResponse { + session_id, + thread_id: detail.thread.id, + message_count, + title, + }), + )) +} + +fn thread_detail_has_live_work(detail: &ThreadDetail) -> bool { + detail.turns.iter().any(|turn| { + matches!( + turn.status, + RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress + ) + }) || detail.items.iter().any(|item| { + matches!( + item.status, + TurnItemLifecycleStatus::Queued | TurnItemLifecycleStatus::InProgress + ) + }) +} + +fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec { + let items_by_id: HashMap<&str, _> = detail + .items + .iter() + .map(|item| (item.id.as_str(), item)) + .collect(); + let mut messages = Vec::new(); + + for turn in &detail.turns { + for item_id in &turn.item_ids { + let Some(item) = items_by_id.get(item_id.as_str()) else { + continue; + }; + let role = match item.kind { + TurnItemKind::UserMessage => "user", + TurnItemKind::AgentMessage => "assistant", + _ => continue, + }; + let Some(text) = item.detail.as_deref().map(str::trim) else { + continue; + }; + if text.is_empty() { + continue; + } + messages.push(Message { + role: role.to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + }); + } + } + + messages +} + +fn total_tokens_from_thread_detail(detail: &ThreadDetail) -> u64 { + detail + .turns + .iter() + .filter_map(|turn| turn.usage.as_ref()) + .map(|usage| u64::from(usage.input_tokens) + u64::from(usage.output_tokens)) + .sum() +} + +fn session_title_override(requested: Option<&str>, thread_title: Option<&str>) -> Option { + requested + .and_then(nonempty_title) + .or_else(|| thread_title.and_then(nonempty_title)) +} + +fn nonempty_title(title: &str) -> Option { + let trimmed = title.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_text(trimmed, 50)) + } +} + async fn delete_session( State(state): State, Path(id): Path, @@ -2152,7 +2317,7 @@ mod tests { use futures_util::StreamExt; use std::fs; use std::sync::Arc; - use tokio::sync::{Mutex, mpsc}; + use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::time::sleep; use uuid::Uuid; @@ -2357,6 +2522,7 @@ mod tests { tokio::task::JoinHandle<()>, )>, > { + let _ = rustls::crypto::ring::default_provider().install_default(); fs::create_dir_all(&sessions_dir)?; let manager = TaskManager::start_with_executor( TaskManagerConfig { @@ -2522,6 +2688,34 @@ mod tests { } } + async fn wait_for_in_progress_item( + client: &reqwest::Client, + addr: SocketAddr, + thread_id: &str, + timeout: Duration, + ) -> Result<()> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/threads/{thread_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + if detail["items"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["status"] == "in_progress")) + { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + bail!("timed out waiting for in-progress item in thread {thread_id}"); + } + sleep(Duration::from_millis(25)).await; + } + } + #[tokio::test] async fn health_and_tasks_endpoints_work() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { @@ -3646,6 +3840,247 @@ mod tests { Ok(()) } + #[tokio::test] + async fn session_create_from_completed_thread_saves_messages() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-thread-session-{}", Uuid::new_v4())); + let sessions_dir = root.join("sessions"); + let Some((addr, runtime_threads, handle)) = + spawn_test_server_with_root(root.clone(), sessions_dir).await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model": "deepseek-v4-pro", + "mode": "plan", + "workspace": root.join("workspace") + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"] + .as_str() + .context("missing thread id")? + .to_string(); + + let patched: serde_json::Value = client + .patch(format!("http://{addr}/v1/threads/{thread_id}")) + .json(&json!({ "title": "Thread title fallback" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(patched["title"], "Thread title fallback"); + + runtime_threads + .seed_thread_from_messages( + &thread_id, + &[ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Please save this runtime thread".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Saved replies should round-trip.".to_string(), + cache_control: None, + }], + }, + ], + ) + .await?; + + let resp = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": thread_id })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let saved: serde_json::Value = resp.json().await?; + assert_eq!(saved["thread_id"], thread_id); + assert_eq!(saved["message_count"], 2); + assert_eq!(saved["title"], "Thread title fallback"); + let session_id = saved["session_id"] + .as_str() + .context("missing session id")? + .to_string(); + + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/sessions/{session_id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["metadata"]["title"], "Thread title fallback"); + assert_eq!(detail["metadata"]["model"], "deepseek-v4-pro"); + assert_eq!(detail["metadata"]["mode"], "plan"); + assert_eq!(detail["metadata"]["message_count"], 2); + assert_eq!(detail["messages"][0]["role"], "user"); + assert_eq!( + detail["messages"][0]["content"][0]["text"], + "Please save this runtime thread" + ); + assert_eq!(detail["messages"][1]["role"], "assistant"); + + let manual_title: serde_json::Value = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ + "thread_id": thread_id, + "title": "Manual saved title" + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(manual_title["title"], "Manual saved title"); + assert_ne!(manual_title["session_id"], session_id); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn session_create_from_thread_returns_404_for_missing_thread() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": "thr_missing" })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn session_create_from_thread_rejects_active_turn() -> Result<()> { + let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({})) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"] + .as_str() + .context("missing thread id")? + .to_string(); + + let harness = crate::core::engine::mock_engine_handle(); + runtime_threads + .install_test_engine(&thread_id, harness.handle.clone()) + .await?; + let mut rx_op = harness.rx_op; + let tx_event = harness.tx_event; + let (active_tx, active_rx) = oneshot::channel(); + let (finish_tx, finish_rx) = oneshot::channel(); + tokio::spawn(async move { + if !matches!(rx_op.recv().await, Some(Op::SendMessage { .. })) { + return; + } + let _ = tx_event + .send(EngineEvent::TurnStarted { + turn_id: "mock_active_session_save".to_string(), + }) + .await; + let _ = tx_event + .send(EngineEvent::MessageStarted { index: 0 }) + .await; + let _ = active_tx.send(()); + let _ = finish_rx.await; + let _ = tx_event + .send(EngineEvent::MessageDelta { + index: 0, + content: "now complete".to_string(), + }) + .await; + let _ = tx_event + .send(EngineEvent::MessageComplete { index: 0 }) + .await; + let _ = tx_event + .send(EngineEvent::TurnComplete { + usage: Usage { + input_tokens: 2, + output_tokens: 1, + ..Usage::default() + }, + status: TurnOutcomeStatus::Completed, + error: None, + tool_catalog: None, + base_url: None, + }) + .await; + }); + + let started: serde_json::Value = client + .post(format!("http://{addr}/v1/threads/{thread_id}/turns")) + .json(&json!({ "prompt": "save me while active" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let turn_id = started["turn"]["id"] + .as_str() + .context("missing turn id")? + .to_string(); + tokio::time::timeout(Duration::from_secs(2), active_rx) + .await + .context("timed out waiting for mock active turn")? + .context("mock active turn sender dropped")?; + wait_for_in_progress_item(&client, addr, &thread_id, Duration::from_secs(2)).await?; + + let resp = client + .post(format!("http://{addr}/v1/sessions")) + .json(&json!({ "thread_id": thread_id })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body: serde_json::Value = resp.json().await?; + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("queued or active turn")) + ); + + let _ = finish_tx.send(()); + let terminal = wait_for_terminal_turn_status( + &client, + addr, + &thread_id, + &turn_id, + Duration::from_secs(2), + ) + .await?; + assert_eq!(terminal, "completed"); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn session_delete_returns_404_for_missing_id() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 400055918..3de17522c 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -8,11 +8,11 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts -- Actual open issues: 445 -- Open PRs: 55 -- Repo API open issue count: 500, because GitHub includes PRs in that total -- Open issues labeled `v0.9.0`: 119 -- Open issues without a milestone: 101 +- Actual open issues: 446 +- Open PRs: 56 +- Repo API open issue count: 502, because GitHub includes PRs in that total +- Open issues labeled `v0.9.0`: 133 +- Open issues without a milestone: 102 ## Execution Order @@ -45,6 +45,7 @@ harvest/stewardship commits: | #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2639 POST /v1/sessions endpoint | Locally harvested with the unsafe active-turn snapshot fixed. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord; comment/close the original after the integration branch is public. | | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | @@ -66,11 +67,14 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Area | Current disposition | Evidence / remaining check | | --- | --- | --- | +| Windows IME/input recovery (#1835) | Partially fixed, still release-blocking. | Current branch has Windows IME recovery and char-routing tests, but the issue remains open with Windows/WSL reports. Needs a real Windows Terminal IME smoke for focus loss, idle, mode switch, first keystroke, and Esc recovery. | | Windows width/resize (#2708, #582 class) | Partially fixed on this branch. | #2708 is cherry-picked plus the fanout-card cache invalidation follow-up. `cargo test -p codewhale-tui --bin codewhale-tui --locked terminal_size -- --nocapture` passed. Still needs a real Windows Terminal resize smoke for #582 before #2721 closes. | | Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 should close as harvested, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | | Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed with `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture`. Still missing a synthetic many-file startup smoke that exercises first-turn latency end to end. | | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | -| Queued/live input feedback (#2054/#1786 adjacent) | Partially covered. | `cargo test -p codewhale-tui --bin codewhale-tui --locked queued -- --nocapture` passed for queued-message recovery/editing. Still needs one release-note/manual-smoke pass for live shell/work-queue feedback before closing the broader #1786 bucket. | +| Sub-agent checkpoint/resume (#2029) | Still release-blocking. | Session projection/transcript handles exist, but no checkpoint/continue status or resume contract has landed. Needs a child checkpoint/timeout/resume test that preserves policy and completes. | +| Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Shell containment and turn-liveness tests exist, but orphaned PID/session-load reaping and long-running shell LIVE-state recovery remain open. Needs stale PID reaping and live-state regression coverage. | +| Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | `cargo test -p codewhale-tui --bin codewhale-tui --locked queued -- --nocapture` passed for queued-message recovery/editing, but pending rows still need clear delivery-mode labels and cancel/edit-mode clarity tests. | | Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | ## PR Harvest Queue @@ -124,7 +128,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2634 HarmonyOS port | Draft / locally harvested | Harvested with credit and extra Nix-chain fixes. Keep the original PR open for now; comment after the integration branch is public and request a real OHOS SDK build confirmation from the contributor before closing. | | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | | #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | -| #2639 POST /v1/sessions endpoint | Mergeable | Defer; app-server contract needs focused review. | +| #2639 POST /v1/sessions endpoint | Mergeable / locally harvested | Harvested with a 409 guard for queued/in-progress turns/items, 404 missing-thread mapping, saved-session metadata preservation, and focused session endpoint tests. Comment/close after the integration branch is public, crediting @gaord. | | #2640 workspace field on UpdateThreadRequest | Mergeable | Harvested locally with extra tests and engine-cache invalidation. Comment/close original after integration branch is public, crediting @gaord. | | #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | | #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | @@ -155,7 +159,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, - #2576, #2581, #2627, #2634, #2636, #2687, #2736, #2737, #2738, and + #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, and already-harvested performance PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From e14fc4712c14a9a752510127814e43bf15df1996 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 22:19:22 -0700 Subject: [PATCH 046/209] fix(tui): label pending input delivery modes Harvested from PR #2532 by @cyq1017. Pending input rows now distinguish steer-pending, rejected-steer, and queued-follow-up states, with continuation rows aligned under the delivery label. Refs #2054; leaves the broader cancel/edit affordance work open. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 6 +- crates/tui/src/tui/app.rs | 3 +- crates/tui/src/tui/ui.rs | 3 +- .../src/tui/widgets/pending_input_preview.rs | 82 ++++++++++++++++++- docs/V0_9_0_EXECUTION_MAP.md | 3 +- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0bf3a678..27afa4093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,10 +68,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 expandable transcript row by default, while running, failed, shell, patch, review, diff, and other risky tool cells remain visible. The setting `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. +- Pending-input preview rows now label delivery mode explicitly as steer + pending, rejected steer, or queued follow-up, with wrapped continuation rows + aligned under the label so busy-turn input state is easier to read (#2054). ### Community -Thanks to **@cyq1017** for the restore-listing implementation (#2513), +Thanks to **@cyq1017** for the restore-listing implementation (#2513) and +pending-input delivery-mode label work (#2532, #2054), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f82a200e6..136abf75f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1548,7 +1548,8 @@ pub struct App { /// cancelled cleanly). Surfaced in the pending-input preview so the user /// knows the steer was deferred to end-of-turn. Today no engine path /// produces these; the field is scaffolding for a future signalling - /// channel and the bucket renders identically when populated. + /// channel and the bucket renders with a rejected-steer label when + /// populated. pub rejected_steers: VecDeque, /// Legacy resend flag for pending steer recovery. pub submit_pending_steers_after_interrupt: bool, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 882bf2b98..d1f3abc94 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6491,7 +6491,8 @@ async fn handle_plan_choice( /// - `pending_steers` — typed during a running turn + Esc; held until the /// abort lands and gets resubmitted as a fresh merged turn. /// - `rejected_steers` — engine declined a mid-turn steer (scaffolding; -/// no engine path produces these yet but the bucket renders identically). +/// no engine path produces these yet but the bucket renders with a distinct +/// rejected-steer label). /// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at /// end-of-turn. fn build_pending_input_preview(app: &App) -> PendingInputPreview { diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index cc3829dbc..78aabd0ca 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -24,6 +24,9 @@ use crate::tui::widgets::Renderable; /// Per-item line cap before we collapse the rest into a `…` overflow row. const PREVIEW_LINE_LIMIT: usize = 3; +const PENDING_STEER_PREFIX: &str = " ↳ Steer pending: "; +const REJECTED_STEER_PREFIX: &str = " ↳ Rejected steer: "; +const QUEUED_MESSAGE_PREFIX: &str = " ↳ Queued follow-up: "; /// Description of the keybinding the hint line at the bottom should advertise /// for the "edit last queued message" action. @@ -109,14 +112,38 @@ impl PendingInputPreview { &mut lines, Line::from(vec![Span::raw("• "), Span::raw("Pending inputs")]), ); + let pending_steer_indent = continuation_indent(PENDING_STEER_PREFIX); for steer in &self.pending_steers { - push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); + push_truncated_item( + &mut lines, + steer, + width, + dim, + PENDING_STEER_PREFIX, + &pending_steer_indent, + ); } + let rejected_steer_indent = continuation_indent(REJECTED_STEER_PREFIX); for steer in &self.rejected_steers { - push_truncated_item(&mut lines, steer, width, dim, " ↳ ", " "); + push_truncated_item( + &mut lines, + steer, + width, + dim, + REJECTED_STEER_PREFIX, + &rejected_steer_indent, + ); } + let queued_message_indent = continuation_indent(QUEUED_MESSAGE_PREFIX); for message in &self.queued_messages { - push_truncated_item(&mut lines, message, width, dim_italic, " ↳ ", " "); + push_truncated_item( + &mut lines, + message, + width, + dim_italic, + QUEUED_MESSAGE_PREFIX, + &queued_message_indent, + ); } if !self.queued_messages.is_empty() { lines.push(Line::from(vec![Span::styled( @@ -154,6 +181,10 @@ impl Renderable for PendingInputPreview { } } +fn continuation_indent(prefix: &str) -> String { + " ".repeat(display_width(prefix)) +} + fn push_section_header(lines: &mut Vec>, header: Line<'static>) { lines.push(header); } @@ -423,6 +454,51 @@ mod tests { assert!(rows.iter().any(|r| r.contains("↑"))); } + #[test] + fn pending_input_rows_label_each_delivery_mode() { + let mut preview = PendingInputPreview::new(); + preview.pending_steers.push("steer".to_string()); + preview.rejected_steers.push("rejected".to_string()); + preview.queued_messages.push("queued".to_string()); + + let rows = render_to_string(&preview, 80); + + assert!( + rows.iter().any(|row| row.contains("Steer pending: steer")), + "missing pending-steer label: {rows:?}" + ); + assert!( + rows.iter() + .any(|row| row.contains("Rejected steer: rejected")), + "missing rejected-steer label: {rows:?}" + ); + assert!( + rows.iter() + .any(|row| row.contains("Queued follow-up: queued")), + "missing queued-follow-up label: {rows:?}" + ); + } + + #[test] + fn wrapped_pending_input_aligns_continuation_under_label() { + let mut preview = PendingInputPreview::new(); + preview + .queued_messages + .push("alpha beta gamma delta epsilon zeta".to_string()); + + let rows = render_to_string(&preview, 34); + + assert!(rows[1].contains("Queued follow-up: alpha")); + assert!( + rows[2].starts_with(&continuation_indent(QUEUED_MESSAGE_PREFIX)), + "continuation should align under label: {rows:?}" + ); + assert!( + !rows[2].trim().is_empty(), + "continuation should keep wrapped text: {rows:?}" + ); + } + #[test] fn message_truncates_to_three_visible_lines() { let mut preview = PendingInputPreview::new(); diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 3de17522c..6a90463c6 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -50,6 +50,7 @@ harvest/stewardship commits: | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | +| #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -74,7 +75,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | | Sub-agent checkpoint/resume (#2029) | Still release-blocking. | Session projection/transcript handles exist, but no checkpoint/continue status or resume contract has landed. Needs a child checkpoint/timeout/resume test that preserves policy and completes. | | Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Shell containment and turn-liveness tests exist, but orphaned PID/session-load reaping and long-running shell LIVE-state recovery remain open. Needs stale PID reaping and live-state regression coverage. | -| Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | `cargo test -p codewhale-tui --bin codewhale-tui --locked queued -- --nocapture` passed for queued-message recovery/editing, but pending rows still need clear delivery-mode labels and cancel/edit-mode clarity tests. | +| Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | Queued-message recovery/editing and pending-input delivery-mode labels are covered by `queued` and `pending_input_preview` focused tests. Still needs cancel/edit-mode affordance clarity and a repro for accidentally entering queued-draft edit while a turn is loading. | | Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | ## PR Harvest Queue From 8d4eb0c2c9360ae930002ed76b910359359ba777 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:10:50 -0700 Subject: [PATCH 047/209] fix(context): bound auto-generated project context Refs #697 and #1827. Reported by @NASLXTO and @wuxixing. Prior context-cap and startup-diagnosis work by @linzhiqin2003 and @merchloubna70-dot shaped this fallback. --- CHANGELOG.md | 11 +++- crates/tui/src/project_context.rs | 83 +++++++++++++++++++++++++------ docs/V0_9_0_EXECUTION_MAP.md | 9 ++-- 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27afa4093..a7e2f1a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pending-input preview rows now label delivery mode explicitly as steer pending, rejected steer, or queued follow-up, with wrapped continuation rows aligned under the label so busy-turn input state is easier to read (#2054). +- Auto-generated project instructions now reuse the bounded Project Context + Pack data instead of running an unbounded summary/tree scan when no + `.codewhale/instructions.md` file exists. The fallback keeps later + top-level folders visible in noisy large workspaces while the dynamic + `` marker remains controlled by its own setting + (#697, #1827). ### Community @@ -86,7 +92,10 @@ HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), **@idling11** for the PlanArtifact direction in Plan mode (#2733) and the dense tool-call transcript collapse direction (#2738, #2692), and **@h3c-hexin** for the tool-agent model inheritance and configured -`skills_dir` fixes (#2736, #2737). +`skills_dir` fixes (#2736, #2737). Thanks also to **@NASLXTO** and +**@wuxixing** for the large-workspace startup reports (#697, #1827), and to +**@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and +startup-diagnosis work that shaped this bounded fallback. ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index affd746f6..c4b806243 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -359,6 +359,22 @@ struct ReadmePack { /// sorted entries, bounded README text, and sorted JSON object fields. It does /// not include timestamps, random ids, absolute temp paths, or live git state. pub fn generate_project_context_pack(workspace: &Path) -> Option { + let pack = build_project_context_pack(workspace)?; + let json = serde_json::to_string_pretty(&pack).ok()?; + Some(format!( + "## Project Context Pack\n\n\n{json}\n" + )) +} + +fn generate_bounded_project_overview(workspace: &Path) -> Option { + let pack = build_project_context_pack(workspace)?; + let json = serde_json::to_string_pretty(&pack).ok()?; + Some(format!( + "## Bounded Project Overview\n\n```json\n{json}\n```" + )) +} + +fn build_project_context_pack(workspace: &Path) -> Option { let mut entries = Vec::new(); collect_pack_entries(workspace, workspace, 0, &mut entries); sort_pack_paths(&mut entries); @@ -386,7 +402,7 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { counts.insert("directory_entries".to_string(), entries.len()); counts.insert("key_source_files".to_string(), key_source_files.len()); - let pack = ProjectContextPack { + Some(ProjectContextPack { project_name: workspace .file_name() .and_then(|name| name.to_str()) @@ -397,12 +413,7 @@ pub fn generate_project_context_pack(workspace: &Path) -> Option { config_files, key_source_files, counts, - }; - - let json = serde_json::to_string_pretty(&pack).ok()?; - Some(format!( - "## Project Context Pack\n\n\n{json}\n" - )) + }) } fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec) { @@ -704,7 +715,7 @@ fn load_project_context_with_parents_and_home( } } - // Auto-generate .deepseek/instructions.md when no context file exists anywhere. + // Auto-generate .codewhale/instructions.md when no context file exists anywhere. // This avoids the per-turn filesystem scan fallback in prompts.rs that // breaks KV prefix cache stability. if !ctx.has_instructions() @@ -823,15 +834,13 @@ fn auto_generate_context(workspace: &Path) -> Option { return None; } - let summary = crate::utils::summarize_project(workspace); - let tree = crate::utils::project_tree(workspace, 2); + let overview = generate_bounded_project_overview(workspace)?; let content = format!( - "# Project Structure (Auto-generated)\n\n\ + "# Project Context (Auto-generated)\n\n\ > This file was automatically generated by CodeWhale.\n\ > You can edit or delete it at any time.\n\n\ - **Summary:** {summary}\n\n\ - **Tree:**\n```\n{tree}\n```" + {overview}" ); // Create .codewhale/ directory @@ -1379,6 +1388,52 @@ mod tests { ); } + #[test] + fn auto_generated_context_is_bounded_for_many_file_workspace() { + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + let noisy = workspace.path().join("aaa-many-files"); + fs::create_dir_all(&noisy).expect("mkdir noisy"); + for i in 0..1000 { + fs::write(noisy.join(format!("file-{i:04}.rs")), "fn noisy() {}").expect("write noisy"); + } + fs::create_dir_all(workspace.path().join("zzz-important")).expect("mkdir important"); + fs::write( + workspace.path().join("zzz-important").join("main.rs"), + "fn important() {}", + ) + .expect("write important"); + + let start = std::time::Instant::now(); + let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path())); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(2), + "auto-generated context should stay bounded, took {elapsed:?}" + ); + assert!(ctx.has_instructions()); + + let generated_path = workspace.path().join(".codewhale").join("instructions.md"); + assert_eq!(ctx.source_path.as_deref(), Some(generated_path.as_path())); + let generated = fs::read_to_string(&generated_path).expect("read generated"); + assert!(generated.contains("Project Context (Auto-generated)")); + assert!(generated.contains("Bounded Project Overview")); + assert!(!generated.contains("")); + assert!( + generated.contains("\"zzz-important/\""), + "later top-level project areas should remain visible:\n{generated}" + ); + let noisy_count = generated.matches("aaa-many-files/file-").count(); + assert!( + noisy_count < 300, + "generated context should not list the whole noisy directory; saw {noisy_count}" + ); + assert!( + !generated.contains("file-0999.rs"), + "bounded context should omit the tail of the noisy directory" + ); + } + #[test] fn project_context_pack_sort_is_cross_platform_and_priority_aware() { let mut unix_paths = vec![ @@ -1657,7 +1712,7 @@ mod tests { ctx.instructions .as_ref() .unwrap() - .contains("Project Structure (Auto-generated)") + .contains("Project Context (Auto-generated)") ); } } diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 6a90463c6..f9aeed2ba 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -9,9 +9,9 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts - Actual open issues: 446 -- Open PRs: 56 -- Repo API open issue count: 502, because GitHub includes PRs in that total -- Open issues labeled `v0.9.0`: 133 +- Open PRs: 57 +- Repo API open issue count: 503, because GitHub includes PRs in that total +- Open issues labeled `v0.9.0`: 119 - Open issues without a milestone: 102 ## Execution Order @@ -51,6 +51,7 @@ harvest/stewardship commits: | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | +| #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -71,7 +72,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Windows IME/input recovery (#1835) | Partially fixed, still release-blocking. | Current branch has Windows IME recovery and char-routing tests, but the issue remains open with Windows/WSL reports. Needs a real Windows Terminal IME smoke for focus loss, idle, mode switch, first keystroke, and Esc recovery. | | Windows width/resize (#2708, #582 class) | Partially fixed on this branch. | #2708 is cherry-picked plus the fanout-card cache invalidation follow-up. `cargo test -p codewhale-tui --bin codewhale-tui --locked terminal_size -- --nocapture` passed. Still needs a real Windows Terminal resize smoke for #582 before #2721 closes. | | Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 should close as harvested, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | -| Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed with `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture`. Still missing a synthetic many-file startup smoke that exercises first-turn latency end to end. | +| Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed, and the auto-generated fallback now has a synthetic 1000-file startup smoke with `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture`. Still needs a real massive-repo/manual startup benchmark before closing #697 or #1827. | | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | | Sub-agent checkpoint/resume (#2029) | Still release-blocking. | Session projection/transcript handles exist, but no checkpoint/continue status or resume contract has landed. Needs a child checkpoint/timeout/resume test that preserves policy and completes. | | Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Shell containment and turn-liveness tests exist, but orphaned PID/session-load reaping and long-running shell LIVE-state recovery remain open. Needs stale PID reaping and live-state regression coverage. | From 185beb5c12c814aa445187d24d9cf6bce0b7bb3a Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:13:12 -0700 Subject: [PATCH 048/209] docs: expand v0.9 stewardship README Describe the v0.9 continuity track, bounded project-context work, contribution review posture, and visible current-track credits. --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 7701acde3..182cbfef7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ Hugging Face stay explicit. Provider, model, base URL, and credentials are separate choices so direct-provider APIs do not get blurred with OpenRouter aliases. +The product goal is practical continuity. A long CodeWhale task should survive +model routing, compaction, shell noise, branch experiments, contributor review, +and a fresh maintainer session without losing the reason the work started or +who helped move it forward. + ## Active v0.9 Track v0.9.0 is not released yet. The current branch is a stewardship lane for making @@ -44,6 +49,7 @@ areas: | Relay and continuity | `/relay`, fork-state handoff, and rich PlanArtifact context preserve the goal, why it matters, evidence, constraints, blockers, changed files, verification state, and the next action. | | Transcript calmness | Dense read/search/list-style tool runs can collapse into expandable groups, while failures, running work, shell commands, writes, diffs, plans, and reviews stay visible. | | Runtime sessions and workspaces | Branch work extends session/thread runtime APIs, including workspace-aware thread updates, completed-thread session saves, and safer guards around active turns. Treat this as v0.9-track capability until the release ships. | +| Project context stability | Bounded project-context packs and generated instructions keep large/noisy repositories from turning the first turn into an unbounded filesystem walk. | | HarmonyOS / OHOS | The lane carries safe OpenHarmony setup, OHOS platform guards, self-update disablement on OHOS, and target gating for PTY and Starlark execpolicy paths. Full OHOS target builds still require a host with the OpenHarmony native SDK configured. | | Nix and Starlark compatibility | Dependency stewardship keeps OHOS builds from pulling incompatible Nix-chain crates through PTY or Starlark paths where those features are gated. | | Contributor stewardship | Harvested PRs stay credited, contributor identity mapping is machine-readable, and community gates remain dry-run and human-toned while the branch is reviewed. | @@ -610,6 +616,28 @@ the reporter as the problem. Recurring contributors should be recognized so the automation gets out of their way and the public record shows their repeated help. +Current v0.9 track credits: + +- **[xyuai](https://github.com/xyuai)** — canonical CodeWhale settings path, + provider persistence, provider picker, logout-scope, and MiMo auth cleanup + work (#2730, #2714, #2715, #2717, #2718) +- **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — HarmonyOS / + OpenHarmony porting work and MatePad Edge validation trail (#2634) +- **[HUQIANTAO](https://github.com/HUQIANTAO)** — `web_run` cache-state + lock-splitting and turn-metadata prefix-cache stability work (#2502, #2517) +- **[idling11](https://github.com/idling11)** — PlanArtifact continuity and + dense tool-call transcript collapse direction (#2733, #2738, #2692) +- **[h3c-hexin](https://github.com/h3c-hexin)** — sub-agent model inheritance, + configured `skills_dir` discovery, and prompt-environment stability work + (#2736, #2737) +- **[gaord](https://github.com/gaord)** — runtime thread workspace updates and + completed-thread saved-session API work (#2640, #2639) +- **[cyq1017](https://github.com/cyq1017)** — restore-listing and + pending-input delivery-mode label work (#2513, #2532, #2054) +- **[NASLXTO](https://github.com/NASLXTO)** and + **[wuxixing](https://github.com/wuxixing)** — large-workspace startup + reports that shaped the bounded project-context fallback (#697, #1827) + Current and recurring contributors include: - **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 PRs spanning features, fixes, and VS Code extension scaffolding (#645–#681) @@ -722,6 +750,21 @@ credit: **[@buko](https://github.com/buko)**, **[@yyyCode](https://github.com/yy See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome — check the [open issues](https://github.com/Hmbown/CodeWhale/issues) for good first contributions. +CodeWhale gets a lot of good reports and PRs. The maintainer posture is to keep +that door open while protecting release quality: + +- Issues should stay human-readable and actionable. Intake automation is + advisory unless a maintainer deliberately enables enforcement. +- PRs are reviewed from code, tests, linked issues, and runtime behavior, not + from title alone. +- If a PR is too broad to merge directly, maintainers may harvest the safe part + into a narrower branch, then credit the author and explain what landed. +- Co-author trailers should use mappable GitHub noreply identities from + `.github/AUTHOR_MAP`; reporters and repro authors should be thanked in + changelogs, release notes, and closure comments. +- Recurring contributors can be added to `.github/APPROVED_CONTRIBUTORS` so + dry-run gates stay out of their way. + Support: [Buy me a coffee](https://www.buymeacoffee.com/hmbown). > [!Note] From 3cb49233ee103e67eea8fea58e899e61b4e6912b Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:26:08 -0700 Subject: [PATCH 049/209] feat(sidebar): show full details for truncated rows Harvested from PR #2734 by @idling11 with reviewer fixes for row-source fidelity, row-authoritative hit testing, and display-width popover sizing. Refs #2694. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 7 +- crates/tui/src/tui/app.rs | 17 ++ crates/tui/src/tui/mouse_ui.rs | 36 ++- crates/tui/src/tui/sidebar.rs | 474 ++++++++++++++++++++++++++++++++- crates/tui/src/tui/ui.rs | 71 +++-- docs/V0_9_0_EXECUTION_MAP.md | 2 + 6 files changed, 566 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e2f1a9c..e730cb411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pending-input preview rows now label delivery mode explicitly as steer pending, rejected steer, or queued follow-up, with wrapped continuation rows aligned under the label so busy-turn input state is easier to read (#2054). +- Sidebar hover details now use row-level metadata for truncated Work, Tasks, + and Agents rows. Mouse hover opens a bordered, wrapping popover with the full + underlying row text, long turn/agent ids, and current sub-agent progress + instead of repeating the already-ellipsized sidebar label (#2694, #2734). - Auto-generated project instructions now reuse the bounded Project Context Pack data instead of running an unbounded summary/tree scan when no `.codewhale/instructions.md` file exists. The fallback keeps later @@ -90,7 +94,8 @@ workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), **@idling11** for the PlanArtifact direction in Plan mode (#2733) and the -dense tool-call transcript collapse direction (#2738, #2692), and +dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, +#2692, #2694), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and to diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 136abf75f..7385cdada 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1143,6 +1143,21 @@ pub struct SidebarHoverState { pub sections: Vec, } +/// Per-row metadata for sidebar detail popovers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidebarHoverRow { + /// Absolute row position in the terminal. + pub row_y: u16, + /// Text shown in the compact sidebar row. + pub display_text: String, + /// Full untruncated text for the popover. + pub full_text: String, + /// Optional additional detail line. + pub detail: Option, + /// Whether the compact row lost information. + pub is_truncated: bool, +} + /// Per-section metadata for sidebar hover detection. #[derive(Debug, Clone)] pub struct SidebarHoverSection { @@ -1150,6 +1165,8 @@ pub struct SidebarHoverSection { pub content_area: Rect, /// Full original text for each content line rendered. pub lines: Vec, + /// Per-row metadata for rich hover popovers. + pub rows: Vec, } impl Default for SessionState { diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 26aec0943..47a3e5421 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -303,11 +303,9 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec= section.content_area.x @@ -323,17 +321,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec section.content_area.width as usize; - let desired = truncated.then(|| full.clone()); + if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) { + let desired = row.is_truncated.then(|| { + if let Some(detail) = row.detail.as_deref() + && !detail.trim().is_empty() + { + format!("{}\n{detail}", row.full_text) + } else { + row.full_text.clone() + } + }); if app.sidebar_hover_tooltip != desired { app.sidebar_hover_tooltip = desired; app.needs_redraw = true; } found = true; break; + } else if section.rows.is_empty() { + let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize; + if let Some(full) = section.lines.get(line_idx) { + let truncated = + text_display_width(full) > section.content_area.width as usize; + let desired = truncated.then(|| full.clone()); + if app.sidebar_hover_tooltip != desired { + app.sidebar_hover_tooltip = desired; + app.needs_redraw = true; + } + found = true; + break; + } } } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 20bfffa7a..410b1f1f1 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -24,7 +24,9 @@ use crate::tools::plan::StepStatus; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::TodoStatus; -use super::app::{App, SidebarFocus, SidebarHoverSection, SidebarHoverState, TaskPanelEntry}; +use super::app::{ + App, SidebarFocus, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, TaskPanelEntry, +}; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; use super::ui_text::{concise_shell_command_label, truncate_line_to_width}; @@ -331,6 +333,155 @@ fn work_panel_lines( lines } +fn work_panel_hover_texts( + summary: &SidebarWorkSummary, + content_width: usize, + max_rows: usize, +) -> Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + if let Some(objective) = summary.goal_objective.as_deref() + && !objective.trim().is_empty() + && texts.len() < max_rows + { + let icon = if summary.goal_completed { "✓" } else { "◆" }; + texts.push(format!("{icon} {objective}")); + + if let Some(started) = summary.goal_started_at + && texts.len() < max_rows + { + let elapsed = crate::tui::notifications::humanize_duration(started.elapsed()); + let elapsed_str = if summary.goal_completed { + format!("completed in {elapsed}") + } else { + format!("elapsed: {elapsed}") + }; + texts.push(elapsed_str); + } + + if let Some(budget) = summary.goal_token_budget + && texts.len() < max_rows + { + let pct = if budget > 0 { + ((summary.tokens_used as f64 / budget as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + let bar_width = content_width.min(20); + let filled = ((pct / 100.0) * bar_width as f64) as usize; + let bar = format!( + "[{}{}] {:.0}%", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)), + pct + ); + texts.push(format!( + "tokens: {}/{} {}", + summary.tokens_used, budget, bar + )); + } + } + + if summary.state_updating && texts.len() < max_rows { + texts.push("Work state updating...".to_string()); + } + + if !summary.checklist_items.is_empty() && texts.len() < max_rows { + let total = summary.checklist_items.len(); + let completed = summary + .checklist_items + .iter() + .filter(|item| item.status == TodoStatus::Completed) + .count(); + texts.push(format!( + "{}% complete ({completed}/{total})", + summary.checklist_completion_pct + )); + + let reserve_for_strategy = if summary.has_strategy() { 2 } else { 0 }; + let available_item_rows = max_rows + .saturating_sub(texts.len()) + .saturating_sub(reserve_for_strategy) + .min(summary.checklist_items.len()); + let max_items = + if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 { + available_item_rows - 1 + } else { + available_item_rows + }; + let start = checklist_window_start(&summary.checklist_items, max_items); + let end = start + .saturating_add(max_items) + .min(summary.checklist_items.len()); + for item in summary.checklist_items[start..end].iter() { + let prefix = match item.status { + TodoStatus::Pending => "[ ]", + TodoStatus::InProgress => "[~]", + TodoStatus::Completed => "[✓]", + }; + texts.push(format!("{prefix} #{} {}", item.id, item.content)); + } + + let earlier = start; + let later = summary.checklist_items.len().saturating_sub(end); + let remaining = earlier.saturating_add(later); + if remaining > 0 && texts.len() < max_rows { + let label = match (earlier, later) { + (0, later) => format!("+{later} more checklist items"), + (earlier, 0) => format!("+{earlier} earlier checklist items"), + (earlier, later) => format!("+{earlier} earlier, +{later} later"), + }; + texts.push(label); + } + } + + if summary.has_strategy() && texts.len() < max_rows { + if summary.checklist_items.is_empty() && !summary.strategy_steps.is_empty() { + let (pending, in_progress, completed) = summary.strategy_counts(); + let total = pending + in_progress + completed; + texts.push(format!( + "Strategy metadata {}% complete ({completed}/{total})", + summary.strategy_progress_percent() + )); + } else { + texts.push("Strategy metadata".to_string()); + } + + if let Some(explanation) = summary.strategy_explanation.as_deref() + && texts.len() < max_rows + { + texts.push(explanation.to_string()); + } + + let max_steps = max_rows + .saturating_sub(texts.len()) + .min(summary.strategy_steps.len()); + for step in summary.strategy_steps.iter().take(max_steps) { + let prefix = match step.status { + StepStatus::Pending => "[ ]", + StepStatus::InProgress => "[~]", + StepStatus::Completed => "[✓]", + }; + let mut text = format!("{prefix} {}", step.text); + if !step.elapsed.is_empty() { + let _ = write!(text, " ({})", step.elapsed); + } + texts.push(text); + } + + let remaining = summary.strategy_steps.len().saturating_sub(max_steps); + if remaining > 0 && texts.len() < max_rows { + texts.push(format!("+{remaining} more strategy steps")); + } + } + + if texts.is_empty() { + texts.push("No active work".to_string()); + } + + texts +} + fn push_work_goal_lines( summary: &SidebarWorkSummary, content_width: usize, @@ -587,7 +738,7 @@ fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) { &app.ui_theme, ); - let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + let full_texts = work_panel_hover_texts(&summary, content_width.max(1), usable_rows); render_sidebar_section(f, area, "Work", lines, full_texts, app); } @@ -600,7 +751,7 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &mut App) { let usable_rows = area.height.saturating_sub(3) as usize; let lines = task_panel_lines(app, content_width.max(1), usable_rows.max(1)); - let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + let full_texts = task_panel_hover_texts(app, usable_rows.max(1)); render_sidebar_section(f, area, "Tasks", lines, full_texts, app); } @@ -738,6 +889,86 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + if let Some(turn_id) = app.runtime_turn_id.as_ref() { + let status = app.runtime_turn_status.as_deref().unwrap_or("unknown"); + texts.push(format!("turn {turn_id} ({status})")); + } + + let active_rows = active_tool_rows(app); + if !active_rows.is_empty() && texts.len() < max_rows { + texts.push("Live tools".to_string()); + push_tool_row_hover_texts(&mut texts, &active_rows, max_rows); + } + + let background_rows = background_task_rows(app, &active_rows); + if !background_rows.is_empty() && texts.len() < max_rows { + let running = background_rows + .iter() + .filter(|task| task.status == "running") + .count(); + let done = background_rows.len().saturating_sub(running); + let label = if running == 0 { + format!("Background commands: {done} completed") + } else if done == 0 { + format!("Background commands: {running} running") + } else { + format!("Background commands: {running} running, {done} completed") + }; + texts.push(label); + + let max_items = max_rows.saturating_sub(texts.len()); + for task in background_rows.iter().take(max_items) { + let duration = task + .duration_ms + .map(format_duration_ms) + .unwrap_or_else(|| "-".to_string()); + let (label, detail) = background_task_labels(task, &duration); + texts.push(label); + if texts.len() >= max_rows { + break; + } + texts.push(format!(" {detail}")); + } + + if texts.len() < max_rows + && background_rows + .iter() + .any(|task| task.id.starts_with("shell_") && task.status == "running") + { + texts.push("Ctrl+K -> /jobs cancel-all".to_string()); + } + } + + if texts.len() < max_rows { + let recent_rows = recent_tool_rows(app, 4); + if !recent_rows.is_empty() { + texts.push("Recent tools".to_string()); + push_tool_row_hover_texts(&mut texts, &recent_rows, max_rows); + } + } + + if texts.len() + 1 < max_rows + && app.runtime_turn_id.is_some() + && app.sidebar_focus == SidebarFocus::Tasks + { + texts.push("y -> copy turn id · Y -> copy full status".to_string()); + } + + if texts.is_empty() + || (texts.len() == 1 + && app.runtime_turn_id.is_some() + && active_rows.is_empty() + && background_rows.is_empty()) + { + texts.push("No live tools or background jobs".to_string()); + } + + texts +} + fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: &palette::UiTheme) { lines.push(Line::from(Span::styled( label.to_string(), @@ -745,6 +976,24 @@ fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: ))); } +fn push_tool_row_hover_texts(texts: &mut Vec, rows: &[SidebarToolRow], max_rows: usize) { + for row in rows { + if texts.len() >= max_rows { + break; + } + let (marker, _) = tool_status_marker(row.status, &palette::UI_THEME); + let label = if let Some(duration_ms) = row.duration_ms { + format!("{marker} {} {}", row.name, format_duration_ms(duration_ms)) + } else { + format!("{marker} {}", row.name) + }; + texts.push(label); + if !row.summary.trim().is_empty() && texts.len() < max_rows { + texts.push(format!(" {}", row.summary)); + } + } +} + fn background_task_labels(task: &TaskPanelEntry, duration: &str) -> (String, String) { if let Some(command) = task.prompt_summary.strip_prefix("shell: ") { let command = concise_shell_command_label(command, 96); @@ -1520,8 +1769,9 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { usable_rows.max(1), &app.ui_theme, ); + let full_texts = subagent_panel_hover_texts(&summary, &rows, usable_rows.max(1)); - render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); + render_sidebar_section(f, area, "Agents", lines, full_texts, app); } /// Minimal projection of the data the sub-agent sidebar needs. Lifted out @@ -1747,6 +1997,84 @@ pub fn subagent_panel_lines( lines } +fn subagent_panel_hover_texts( + summary: &SidebarSubagentSummary, + rows: &[SidebarAgentRow], + max_rows: usize, +) -> Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + let fanout_total = summary.fanout_total.unwrap_or(0); + if summary.cached_total == 0 + && summary.progress_only_count == 0 + && fanout_total == 0 + && !summary.foreground_rlm_running + { + texts.push("No agents".to_string()); + return texts; + } + + let (live_running, total) = if let Some(total) = summary.fanout_total { + (summary.fanout_running, total) + } else { + ( + summary.cached_running + summary.progress_only_count, + summary.cached_total + summary.progress_only_count, + ) + }; + let done = total.saturating_sub(live_running); + if live_running > 0 { + texts.push(format!("{live_running} running / {total}")); + } else { + texts.push(format!("{done} done")); + } + + if !summary.role_counts.is_empty() && texts.len() < max_rows { + let mix: Vec = summary + .role_counts + .iter() + .map(|(role, count)| format!("{count} {role}")) + .collect(); + texts.push(mix.join(" · ")); + } + + for row in rows { + if texts.len() >= max_rows { + break; + } + let (marker, _) = agent_status_marker(row.status.as_str(), &palette::UI_THEME); + texts.push(format!("{marker} {} {}", row.role, row.name)); + + if row.status == "done" { + continue; + } + + if texts.len() >= max_rows { + break; + } + let mut detail_parts = Vec::new(); + detail_parts.push(row.id.clone()); + if row.steps_taken > 0 { + detail_parts.push(format!("{} step(s)", row.steps_taken)); + } + if let Some(duration) = row.duration_ms { + detail_parts.push(format_duration_ms(duration)); + } + if let Some(progress) = row.progress.as_deref() + && !progress.trim().is_empty() + { + detail_parts.push(summarize_tool_output(progress)); + } + texts.push(format!(" {}", detail_parts.join(" · "))); + } + + if summary.foreground_rlm_running && texts.len() < max_rows { + texts.push("RLM foreground work active".to_string()); + } + + texts +} + fn agent_status_marker( status: &str, theme: &palette::UiTheme, @@ -1922,9 +2250,26 @@ fn render_sidebar_section( width: area.width.saturating_sub(2 + padding.left + padding.right), height: area.height.saturating_sub(2 + padding.top + padding.bottom), }; + let display_texts: Vec = lines + .iter() + .map(|line| spans_to_text(&line.spans)) + .collect(); + let hover_texts: Vec = display_texts + .iter() + .enumerate() + .map(|(idx, display)| { + full_texts + .get(idx) + .filter(|text| !text.trim().is_empty()) + .cloned() + .unwrap_or_else(|| display.clone()) + }) + .collect(); + let rows = sidebar_hover_rows(content_area, &display_texts, &hover_texts); app.sidebar_hover.sections.push(SidebarHoverSection { content_area, - lines: full_texts, + lines: hover_texts, + rows, }); // Truncate the panel title so it always fits within the section width // even after a resize. The title occupies up to 4 chars of border chrome @@ -1964,15 +2309,42 @@ fn render_sidebar_section( f.render_widget(section, area); } +fn sidebar_hover_rows( + content_area: Rect, + display_texts: &[String], + hover_texts: &[String], +) -> Vec { + display_texts + .iter() + .zip(hover_texts.iter()) + .enumerate() + .map(|(idx, (display_text, full_text))| { + let row_y = content_area.y.saturating_add(idx as u16); + let display_width = unicode_width::UnicodeWidthStr::width(display_text.as_str()); + let full_width = unicode_width::UnicodeWidthStr::width(full_text.as_str()); + SidebarHoverRow { + row_y, + display_text: display_text.clone(), + full_text: full_text.clone(), + detail: None, + is_truncated: display_width > content_area.width as usize + || full_width > content_area.width as usize + || display_text != full_text, + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::{ ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel, - AutoSidebarState, SidebarAgentRow, SidebarHoverSection, SidebarHoverState, + AutoSidebarState, SidebarAgentRow, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows, - normalize_activity_text, sidebar_work_summary, subagent_panel_lines, task_panel_lines, - work_panel_empty_hint, work_panel_lines, + normalize_activity_text, sidebar_hover_rows, sidebar_work_summary, + subagent_panel_hover_texts, subagent_panel_lines, task_panel_lines, work_panel_empty_hint, + work_panel_hover_texts, work_panel_lines, }; use crate::config::Config; use crate::palette; @@ -2991,6 +3363,7 @@ mod tests { let section = SidebarHoverSection { content_area: Rect::new(1, 1, 38, 8), lines: vec!["line 1".to_string(), "line 2".to_string()], + rows: vec![], }; assert_eq!(section.lines.len(), 2); assert_eq!(section.lines[0], "line 1"); @@ -3007,6 +3380,7 @@ mod tests { "second".to_string(), "third".to_string(), ], + rows: vec![], }; // Mouse within content area, first line @@ -3020,4 +3394,88 @@ mod tests { // Mouse outside content area (above) — row < content_area.y assert!((1u16) < section.content_area.y); } + + #[test] + fn work_hover_text_preserves_full_checklist_item() { + let long_item = + "Add ProviderKind::HuggingFace direct route with all auth and docs coverage"; + let summary = SidebarWorkSummary { + checklist_completion_pct: 0, + checklist_items: vec![SidebarWorkChecklistItem { + id: 7, + content: long_item.to_string(), + status: TodoStatus::InProgress, + }], + ..SidebarWorkSummary::default() + }; + + let display = lines_to_text(&work_panel_lines( + &summary, + 18, + 4, + PaletteMode::Dark, + &palette::UI_THEME, + )); + let hover = work_panel_hover_texts(&summary, 18, 4); + + assert!( + display.iter().any(|line| line.contains("...")), + "compact Work row should be ellipsized in this fixture: {display:?}" + ); + assert!( + hover.iter().any(|line| line.contains(long_item)), + "hover text should retain the full checklist item: {hover:?}" + ); + } + + #[test] + fn sidebar_hover_rows_mark_source_text_diff_as_truncated() { + use ratatui::layout::Rect; + let display = vec!["[~] agent imple…".to_string()]; + let full = vec!["[~] agent implementation-worker-for-sidebar-detail-popover".to_string()]; + let rows = sidebar_hover_rows(Rect::new(62, 5, 16, 4), &display, &full); + + let expected = SidebarHoverRow { + row_y: 5, + display_text: display[0].clone(), + full_text: full[0].clone(), + detail: None, + is_truncated: true, + }; + assert_eq!(rows, vec![expected]); + } + + #[test] + fn subagent_hover_text_preserves_full_agent_id_and_progress() { + let mut role_counts = std::collections::BTreeMap::new(); + role_counts.insert("worker".to_string(), 1); + let summary = SidebarSubagentSummary { + cached_total: 1, + cached_running: 1, + role_counts, + ..SidebarSubagentSummary::default() + }; + let long_id = "019e9142-83f6-7713-87f1-28902e74bf05"; + let long_progress = + "currently reviewing sidebar hover popover wrapping and hitbox metadata"; + let rows = vec![SidebarAgentRow { + id: long_id.to_string(), + name: "sidebar-detail-worker-with-long-name".to_string(), + role: "worker".to_string(), + status: "running".to_string(), + progress: Some(long_progress.to_string()), + steps_taken: 9, + duration_ms: Some(12_345), + }]; + + let hover = subagent_panel_hover_texts(&summary, &rows, 5); + assert!( + hover.iter().any(|line| line.contains(long_id)), + "hover text should include the full agent id: {hover:?}" + ); + assert!( + hover.iter().any(|line| line.contains(long_progress)), + "hover text should include the full progress before popover wrapping: {hover:?}" + ); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d1f3abc94..f9ca48c4c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -106,7 +106,9 @@ use crate::tui::tool_routing::exploring_label; use crate::tui::tool_routing::{ handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview, }; -use crate::tui::ui_text::{history_cell_to_text, line_to_plain, truncate_line_to_width}; +use crate::tui::ui_text::{ + history_cell_to_text, line_to_plain, text_display_width, truncate_line_to_width, +}; use crate::tui::user_input::UserInputView; use crate::tui::views::subagent_view_agents; use crate::tui::vim_mode; @@ -3447,6 +3449,10 @@ async fn run_event_loop( app.mention_menu_hidden = true; app.mention_menu_selected = 0; } + KeyCode::Esc if app.sidebar_hover_tooltip.is_some() => { + app.sidebar_hover_tooltip = None; + app.needs_redraw = true; + } KeyCode::Esc => { match next_escape_action(app, slash_menu_open) { EscapeAction::CloseSlashMenu => { @@ -6778,34 +6784,55 @@ fn render(f: &mut Frame, app: &mut App) { } } - // Render sidebar hover tooltip if active. + // Render sidebar hover popover if active. if let Some(ref tooltip_text) = app.sidebar_hover_tooltip && let Some((mouse_col, mouse_row)) = app.last_mouse_pos { - let text_width = (tooltip_text.len() as u16).clamp(10, 60); - let tooltip_height = 1u16; - let x = mouse_col - .saturating_add(2) - .min(size.width.saturating_sub(text_width)); - // Sit one row BELOW the cursor so the tooltip never paints over - // the row above the hovered line (which read as corruption). - let y = mouse_row - .saturating_add(1) - .min(size.height.saturating_sub(tooltip_height)); - if text_width > 0 && tooltip_height > 0 { + let max_popup_width = 72u16.min(size.width.saturating_sub(4)); + if max_popup_width >= 10 && size.height >= 3 { + let popup_width = tooltip_text + .lines() + .map(text_display_width) + .max() + .unwrap_or(0) + .saturating_add(2) + .clamp(12, max_popup_width as usize) + as u16; + let inner_width = popup_width.saturating_sub(2).max(1) as usize; + let wrapped_rows = tooltip_text.lines().fold(0u16, |rows, line| { + let width = text_display_width(line); + rows.saturating_add(((width.max(1) - 1) / inner_width + 1) as u16) + }); + let popup_content_height = wrapped_rows.clamp(1, 10); + let popup_height = popup_content_height.saturating_add(2); + let x = mouse_col + .saturating_add(2) + .min(size.width.saturating_sub(popup_width)); + // Sit one row BELOW the cursor so the tooltip never paints over + // the row above the hovered line (which read as corruption). + let y = mouse_row + .saturating_add(1) + .min(size.height.saturating_sub(popup_height)); let tooltip_area = Rect { x, y, - width: text_width, - height: tooltip_height, + width: popup_width, + height: popup_height, }; - // Neutral elevated-surface styling so the tooltip reads as a - // tooltip, not a warning highlight (was STATUS_WARNING). - let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()).style( - Style::default() - .bg(palette::SURFACE_ELEVATED) - .fg(palette::TEXT_PRIMARY), - ); + // Neutral elevated-surface styling so the popover reads as a + // detail surface, not a warning highlight. + let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block( + Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(palette::DEEPSEEK_BLUE)) + .style( + Style::default() + .bg(palette::SURFACE_ELEVATED) + .fg(palette::TEXT_PRIMARY), + ), + ); f.render_widget(tooltip, tooltip_area); } } diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index f9aeed2ba..3d009c38e 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -50,6 +50,7 @@ harvest/stewardship commits: | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | +| #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | @@ -138,6 +139,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | +| #2734 sidebar detail popovers | Mergeable / locally harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Comment on the original after the integration branch is public, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | | #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | From ad3d61936bc62039ca5358e35986fc8a18d66e39 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:27:20 -0700 Subject: [PATCH 050/209] feat(subagent): preserve checkpoints for timeout continuation Refs #2029. Reported by @qiyuanlicn. This lands live per-step API-timeout checkpoint continuation and preserves checkpoint metadata through projections, transcripts, and persistence; cold-restart child-task rehydration remains out of scope. --- CHANGELOG.md | 9 +- crates/tui/src/tools/subagent/mod.rs | 358 ++++++++++++++++++++++++- crates/tui/src/tools/subagent/tests.rs | 313 +++++++++++++++++++++ crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/views/mod.rs | 2 + docs/V0_9_0_EXECUTION_MAP.md | 3 +- 6 files changed, 680 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e730cb411..139c39e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and Agents rows. Mouse hover opens a bordered, wrapping popover with the full underlying row text, long turn/agent ids, and current sub-agent progress instead of repeating the already-ellipsized sidebar label (#2694, #2734). +- Sub-agents now preserve checkpoint metadata around long model calls. A + per-step API timeout marks the child as interrupted with a continuable + checkpoint instead of ending as a null failed result, and `agent_eval` can + explicitly continue a live checkpointed interrupted child while normal + completed/failed/cancelled follow-up behavior stays unchanged (#2029). - Auto-generated project instructions now reuse the bounded Project Context Pack data instead of running an unbounded summary/tree scan when no `.codewhale/instructions.md` file exists. The fallback keeps later @@ -97,7 +102,9 @@ HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, #2694), and **@h3c-hexin** for the tool-agent model inheritance and configured -`skills_dir` fixes (#2736, #2737). Thanks also to **@NASLXTO** and +`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the +checkpoint/resume report that shaped the sub-agent recovery slice (#2029), +and to **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and to **@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that shaped this bounded fallback. diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 80bdf3e3c..9e528d1f3 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -594,6 +594,8 @@ pub struct SubAgentResult { pub status: SubAgentStatus, pub result: Option, pub steps_taken: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, pub duration_ms: u64, /// `true` when this agent was loaded from a prior-session persisted /// state file rather than spawned in the current session (#405). @@ -691,6 +693,21 @@ struct AssignRequest { interrupt: bool, } +/// Durable recovery point for an interrupted sub-agent session. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SubAgentCheckpoint { + pub checkpoint_id: String, + pub agent_id: String, + pub continuation_handle: String, + pub reason: String, + pub continuable: bool, + pub steps_taken: u32, + pub message_count: usize, + pub created_at_ms: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub messages: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct PersistedSubAgent { id: String, @@ -708,6 +725,8 @@ struct PersistedSubAgent { status: SubAgentStatus, result: Option, steps_taken: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + checkpoint: Option, duration_ms: u64, allowed_tools: Vec, updated_at_ms: u64, @@ -1034,6 +1053,7 @@ pub struct SubAgent { pub status: SubAgentStatus, pub result: Option, pub steps_taken: u32, + pub checkpoint: Option, pub started_at: Instant, pub last_activity_at: Instant, /// `None` = full registry inheritance, with approval-gated tools still @@ -1078,6 +1098,7 @@ impl SubAgent { status: SubAgentStatus::Running, result: None, steps_taken: 0, + checkpoint: None, started_at, last_activity_at: started_at, allowed_tools, @@ -1102,6 +1123,7 @@ impl SubAgent { status: self.status.clone(), result: self.result.clone(), steps_taken: self.steps_taken, + checkpoint: self.checkpoint.clone(), duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), // Snapshots from the agent itself don't know the manager's // current boot id, so default to false. The manager fills @@ -1199,6 +1221,7 @@ impl SubAgentManager { status: agent.status.clone(), result: agent.result.clone(), steps_taken: agent.steps_taken, + checkpoint: agent.checkpoint.clone(), duration_ms: u64::try_from(agent.started_at.elapsed().as_millis()) .unwrap_or(u64::MAX), // Backward-compat: Vec on disk. None → empty vec; Some(list) → list. @@ -1278,6 +1301,7 @@ impl SubAgentManager { status, result: persisted.result, steps_taken: persisted.steps_taken, + checkpoint: persisted.checkpoint, started_at, last_activity_at: started_at, allowed_tools, @@ -1848,6 +1872,7 @@ impl SubAgentManager { agent.assignment = result.assignment; agent.result = result.result; agent.steps_taken = result.steps_taken; + agent.checkpoint = result.checkpoint; agent.task_handle = None; changed = true; } @@ -1868,6 +1893,83 @@ impl SubAgentManager { self.persist_state_best_effort(); } } + + fn update_checkpoint(&mut self, agent_id: &str, checkpoint: SubAgentCheckpoint) -> bool { + let Some(agent) = self.agents.get_mut(agent_id) else { + return false; + }; + agent.steps_taken = checkpoint.steps_taken; + agent.checkpoint = Some(checkpoint); + agent.last_activity_at = Instant::now(); + self.persist_state_best_effort(); + true + } + + fn interrupt_with_checkpoint( + &mut self, + agent_id: &str, + reason: String, + checkpoint: SubAgentCheckpoint, + ) -> Result { + let snapshot = { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + agent.status = SubAgentStatus::Interrupted(reason.clone()); + agent.result = Some(reason); + agent.steps_taken = checkpoint.steps_taken; + agent.checkpoint = Some(checkpoint); + agent.last_activity_at = Instant::now(); + release_resident_leases_for(agent_id); + agent.snapshot() + }; + self.persist_state_best_effort(); + Ok(snapshot) + } + + fn continue_checkpointed( + &mut self, + agent_id: &str, + message: Option, + interrupt: bool, + ) -> Result { + let snapshot = { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + if !matches!(agent.status, SubAgentStatus::Interrupted(_)) { + return Err(anyhow!( + "Agent {agent_id} is not interrupted; checkpoint continuation is only available for interrupted sessions" + )); + } + let checkpoint = agent + .checkpoint + .as_ref() + .ok_or_else(|| anyhow!("Agent {agent_id} has no checkpoint to continue"))?; + if !checkpoint.continuable || checkpoint.messages.is_empty() { + return Err(anyhow!("Agent {agent_id} checkpoint is not continuable")); + } + let tx = agent.input_tx.as_ref().ok_or_else(|| { + anyhow!( + "Agent {agent_id} checkpoint is persisted, but no live child task is available to continue" + ) + })?; + tx.send(SubAgentInput { + text: message.unwrap_or_default(), + interrupt, + }) + .map_err(|_| anyhow!("Failed to continue checkpointed agent {agent_id}"))?; + + agent.status = SubAgentStatus::Running; + agent.result = None; + agent.last_activity_at = Instant::now(); + agent.snapshot() + }; + self.persist_state_best_effort(); + Ok(snapshot) + } } /// Thread-safe wrapper for `SubAgentManager`. @@ -1885,6 +1987,10 @@ pub struct SubAgentSessionProjection { pub prefix_cache: SubAgentPrefixCacheProjection, pub transcript_handle: VarHandle, pub snapshot: SubAgentResult, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub continuable: bool, #[serde(default, skip_serializing_if = "is_false")] pub timed_out: bool, } @@ -1912,6 +2018,14 @@ fn subagent_prefix_cache_projection(snapshot: &SubAgentResult) -> SubAgentPrefix } } +fn subagent_checkpoint_is_continuable(snapshot: &SubAgentResult) -> bool { + matches!(snapshot.status, SubAgentStatus::Interrupted(_)) + && snapshot + .checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.continuable && !checkpoint.messages.is_empty()) +} + async fn subagent_session_projection( snapshot: SubAgentResult, timed_out: bool, @@ -1929,6 +2043,7 @@ async fn subagent_session_projection( "steps_taken": snapshot.steps_taken, "duration_ms": snapshot.duration_ms, "assignment": snapshot.assignment.clone(), + "checkpoint": snapshot.checkpoint.clone(), "snapshot": snapshot.clone(), }); let transcript_handle = { @@ -1960,6 +2075,8 @@ async fn subagent_session_projection( fork_context: snapshot.fork_context, prefix_cache: subagent_prefix_cache_projection(&snapshot), transcript_handle, + checkpoint: snapshot.checkpoint.clone(), + continuable: subagent_checkpoint_is_continuable(&snapshot), snapshot, timed_out, } @@ -2609,7 +2726,7 @@ impl ToolSpec for AgentEvalTool { } fn description(&self) -> &'static str { - "Fetch or wait on a child sub-agent session. Optionally deliver a message/items to a running session, then return the latest session projection. With block=true (default), waits for the session to reach a terminal boundary; block=false is a non-blocking status fetch. Terminal projections expose a handle_read-compatible transcript_handle for the full child transcript." + "Fetch or wait on a child sub-agent session. Optionally deliver a message/items to a running session, then return the latest session projection. With continue=true, resume only a checkpointed interrupted session. With block=true (default), waits for the session to reach a terminal boundary; block=false is a non-blocking status fetch. Terminal projections expose a handle_read-compatible transcript_handle for the full child transcript." } fn input_schema(&self) -> Value { @@ -2649,6 +2766,14 @@ impl ToolSpec for AgentEvalTool { "type": "boolean", "description": "When sending input, prioritize it over pending inputs" }, + "continue": { + "type": "boolean", + "description": "Resume a checkpointed interrupted session. Only valid when the projection has continuable=true." + }, + "resume": { + "type": "boolean", + "description": "Alias for continue" + }, "block": { "type": "boolean", "description": "Wait for a terminal boundary before returning (default true)" @@ -2677,6 +2802,8 @@ impl ToolSpec for AgentEvalTool { .ok_or_else(|| ToolError::missing_field("name"))?; let message = parse_optional_text_or_items(&input, &["message", "input"], "items")?; let interrupt = optional_bool(&input, "interrupt", false); + let continue_from_checkpoint = + optional_bool(&input, "continue", false) || optional_bool(&input, "resume", false); let block = optional_bool(&input, "block", true); let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_RESULT_TIMEOUT_MS) .clamp(1000, MAX_RESULT_TIMEOUT_MS); @@ -2696,7 +2823,16 @@ impl ToolSpec for AgentEvalTool { // completed session returns 'not running', no way to recover the full // child output". let mut message_delivery: Option = None; - if let Some(message) = message { + if continue_from_checkpoint { + let mut manager = self.manager.write().await; + manager + .continue_checkpointed(&agent_id, message, interrupt) + .map_err(|e| ToolError::execution_failed(e.to_string()))?; + message_delivery = Some(json!({ + "delivered": true, + "continued_from_checkpoint": true + })); + } else if let Some(message) = message { let terminal = { let manager = self.manager.read().await; manager @@ -3846,6 +3982,7 @@ async fn insert_subagent_full_transcript_handle( assignment: &SubAgentAssignment, status: &SubAgentStatus, result: Option<&String>, + checkpoint: Option<&SubAgentCheckpoint>, messages: &[Message], steps_taken: u32, duration_ms: u64, @@ -3862,12 +3999,50 @@ async fn insert_subagent_full_transcript_handle( "steps_taken": steps_taken, "duration_ms": duration_ms, "assignment": assignment, + "checkpoint": checkpoint, "messages": messages, }); let mut store = runtime.context.runtime.handle_store.lock().await; store.insert_json(format!("agent:{agent_id}"), "full_transcript", payload) } +fn build_subagent_checkpoint( + agent_id: &str, + reason: impl Into, + messages: &[Message], + steps_taken: u32, + continuable: bool, +) -> SubAgentCheckpoint { + let created_at_ms = epoch_millis_now(); + let checkpoint_id = format!("{agent_id}:step:{steps_taken}:ts:{created_at_ms}"); + SubAgentCheckpoint { + checkpoint_id: checkpoint_id.clone(), + agent_id: agent_id.to_string(), + continuation_handle: format!("agent:{agent_id}:checkpoint:{checkpoint_id}"), + reason: reason.into(), + continuable, + steps_taken, + message_count: messages.len(), + created_at_ms, + messages: messages.to_vec(), + } +} + +async fn checkpoint_subagent_progress( + runtime: &SubAgentRuntime, + agent_id: &str, + reason: impl Into, + messages: &[Message], + steps_taken: u32, + continuable: bool, +) -> SubAgentCheckpoint { + let checkpoint = + build_subagent_checkpoint(agent_id, reason, messages, steps_taken, continuable); + let mut manager = runtime.manager.write().await; + manager.update_checkpoint(agent_id, checkpoint.clone()); + checkpoint +} + fn record_agent_progress(runtime: &SubAgentRuntime, agent_id: &str, message: impl Into) { if let Ok(mut manager) = runtime.manager.try_write() { manager.touch(agent_id); @@ -3934,8 +4109,9 @@ async fn run_subagent( let mut final_result: Option = None; let mut pending_inputs: VecDeque = VecDeque::new(); let mut consecutive_truncated_responses = 0; + let mut latest_checkpoint: Option = None; - for _step in 0..max_steps { + 'steps_loop: for _step in 0..max_steps { // Cooperative cancellation: bail if this session's token was cancelled // while we were between steps. Top-level model-visible sub-agents use // a detached token so parent turn cancellation does not stop them. @@ -3959,6 +4135,7 @@ async fn run_subagent( &assignment, &status, None, + latest_checkpoint.as_ref(), &messages, steps, duration_ms, @@ -3982,6 +4159,7 @@ async fn run_subagent( status, result: None, steps_taken: steps, + checkpoint: latest_checkpoint.clone(), duration_ms, from_prior_session: false, }); @@ -4027,6 +4205,17 @@ async fn run_subagent( temperature: None, top_p: None, }; + latest_checkpoint = Some( + checkpoint_subagent_progress( + runtime, + &agent_id, + "before_api_request", + &messages, + steps, + true, + ) + .await, + ); // Race the API call against the cancellation token so a parent // cancel during a long thinking turn doesn't have to wait for the @@ -4053,6 +4242,7 @@ async fn run_subagent( &assignment, &status, None, + latest_checkpoint.as_ref(), &messages, steps, duration_ms, @@ -4071,12 +4261,130 @@ async fn run_subagent( status, result: None, steps_taken: steps, + checkpoint: latest_checkpoint.clone(), duration_ms, from_prior_session: false, }); } api = tokio::time::timeout(runtime.step_api_timeout, runtime.client.create_message(request)) => { - api.map_err(|_| anyhow!("API call timed out after {}s", runtime.step_api_timeout.as_secs()))?? + match api { + Ok(response) => response?, + Err(_) => { + let reason = format!( + "API call timed out after {}ms; checkpoint preserved for continuation", + runtime.step_api_timeout.as_millis() + ); + let checkpoint = checkpoint_subagent_progress( + runtime, + &agent_id, + "api_timeout", + &messages, + steps, + true, + ) + .await; + latest_checkpoint = Some(checkpoint.clone()); + record_agent_progress( + runtime, + &agent_id, + format!("step {steps}/{max_steps}: interrupted; {reason}"), + ); + let status = SubAgentStatus::Interrupted(reason.clone()); + let duration_ms = + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + insert_subagent_full_transcript_handle( + runtime, + &agent_id, + &agent_type, + &assignment, + &status, + Some(&reason), + Some(&checkpoint), + &messages, + steps, + duration_ms, + fork_context_enabled, + ) + .await; + let interrupted_snapshot = { + let mut manager = runtime.manager.write().await; + manager.interrupt_with_checkpoint( + &agent_id, + reason.clone(), + checkpoint.clone(), + )? + }; + + let next_input = tokio::select! { + biased; + () = runtime.cancel_token.cancelled() => { + record_agent_progress( + runtime, + &agent_id, + format!("step {steps}/{max_steps}: cancelled while interrupted"), + ); + if let Some(mb) = runtime.mailbox.as_ref() { + let _ = mb.send(MailboxMessage::Cancelled { + agent_id: agent_id.clone(), + }); + } + let status = SubAgentStatus::Cancelled; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()) + .unwrap_or(u64::MAX); + insert_subagent_full_transcript_handle( + runtime, + &agent_id, + &agent_type, + &assignment, + &status, + None, + latest_checkpoint.as_ref(), + &messages, + steps, + duration_ms, + fork_context_enabled, + ) + .await; + return Ok(SubAgentResult { + name: agent_id.clone(), + agent_id: agent_id.clone(), + context_mode: if fork_context_enabled { "forked" } else { "fresh" }.to_string(), + fork_context: fork_context_enabled, + agent_type: agent_type.clone(), + assignment: assignment.clone(), + model: runtime.model.clone(), + nickname: None, + status, + result: None, + steps_taken: steps, + checkpoint: latest_checkpoint.clone(), + duration_ms, + from_prior_session: false, + }); + } + input = input_rx.recv() => input, + }; + let Some(input) = next_input else { + return Ok(interrupted_snapshot); + }; + if input.interrupt { + pending_inputs.clear(); + } + pending_inputs.push_back(input); + latest_checkpoint = Some( + checkpoint_subagent_progress( + runtime, + &agent_id, + "continued_after_api_timeout", + &messages, + steps, + true, + ) + .await, + ); + continue 'steps_loop; + } + } } }; @@ -4109,6 +4417,17 @@ async fn run_subagent( role: "assistant".to_string(), content: response.content.clone(), }); + latest_checkpoint = Some( + checkpoint_subagent_progress( + runtime, + &agent_id, + "after_model_response", + &messages, + steps, + true, + ) + .await, + ); if response_was_truncated(&response) { final_result = None; @@ -4134,6 +4453,17 @@ async fn run_subagent( truncated_response_tool_results(&tool_uses) }, }); + latest_checkpoint = Some( + checkpoint_subagent_progress( + runtime, + &agent_id, + "after_truncated_response_retry_message", + &messages, + steps, + true, + ) + .await, + ); continue; } reset_truncated_subagent_responses(&mut consecutive_truncated_responses); @@ -4217,12 +4547,30 @@ async fn run_subagent( role: "user".to_string(), content: tool_results, }); + latest_checkpoint = Some( + checkpoint_subagent_progress( + runtime, + &agent_id, + "after_tool_results", + &messages, + steps, + true, + ) + .await, + ); } } release_resident_leases_for(&agent_id); let status = SubAgentStatus::Completed; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + latest_checkpoint = Some(build_subagent_checkpoint( + &agent_id, + "completed", + &messages, + steps, + false, + )); insert_subagent_full_transcript_handle( runtime, &agent_id, @@ -4230,6 +4578,7 @@ async fn run_subagent( &assignment, &status, final_result.as_ref(), + latest_checkpoint.as_ref(), &messages, steps, duration_ms, @@ -4254,6 +4603,7 @@ async fn run_subagent( status, result: final_result, steps_taken: steps, + checkpoint: latest_checkpoint, duration_ms, from_prior_session: false, }) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 0ed218842..2b536d6e0 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1,4 +1,6 @@ use super::*; +use axum::{Json, Router, routing::post}; +use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::tempdir; fn make_assignment() -> SubAgentAssignment { @@ -18,11 +20,26 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { status, result: None, steps_taken: 0, + checkpoint: None, duration_ms: 0, from_prior_session: false, } } +fn text_message(role: &str, text: &str) -> Message { + Message { + role: role.to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } +} + +fn make_checkpoint(agent_id: &str, steps_taken: u32, messages: Vec) -> SubAgentCheckpoint { + build_subagent_checkpoint(agent_id, "test_checkpoint", &messages, steps_taken, true) +} + fn message_text(message: &Message) -> &str { match message.content.first() { Some(ContentBlock::Text { text, .. }) => text.as_str(), @@ -30,6 +47,74 @@ fn message_text(message: &Message) -> &str { } } +async fn delayed_chat_client( + first_delay: Duration, + response_text: &str, +) -> ( + DeepSeekClient, + Arc, + Arc>>, +) { + let calls = Arc::new(AtomicUsize::new(0)); + let bodies = Arc::new(std::sync::Mutex::new(Vec::new())); + let response_text = response_text.to_string(); + let app = Router::new().route( + "/{*path}", + post({ + let calls = Arc::clone(&calls); + let bodies = Arc::clone(&bodies); + move |Json(body): Json| { + let calls = Arc::clone(&calls); + let bodies = Arc::clone(&bodies); + let response_text = response_text.clone(); + async move { + let attempt = calls.fetch_add(1, Ordering::SeqCst) + 1; + bodies + .lock() + .expect("request body recorder mutex poisoned") + .push(body); + if attempt == 1 { + tokio::time::sleep(first_delay).await; + } + Json(json!({ + "id": format!("chatcmpl-test-{attempt}"), + "model": "deepseek-v4-flash", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": response_text + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })) + } + } + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake chat server"); + let addr = listener.local_addr().expect("fake chat server addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let config = crate::config::Config { + api_key: Some("test-key".to_string()), + base_url: Some(format!("http://{addr}/v1")), + ..crate::config::Config::default() + }; + let client = DeepSeekClient::new(&config).expect("fake chat client"); + (client, calls, bodies) +} + fn estimate_tool_description_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } @@ -426,6 +511,51 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { assert_eq!(projection.transcript_handle.name, "full_transcript"); } +#[tokio::test] +async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { + let mut snapshot = make_snapshot(SubAgentStatus::Interrupted( + "API call timed out after 10ms".to_string(), + )); + let checkpoint = make_checkpoint( + &snapshot.agent_id, + 1, + vec![text_message("user", "inspect checkpoint recovery")], + ); + snapshot.steps_taken = checkpoint.steps_taken; + snapshot.checkpoint = Some(checkpoint.clone()); + + let ctx = ToolContext::new("."); + let projection = subagent_session_projection(snapshot, false, &ctx).await; + + assert_eq!(projection.status, "interrupted"); + assert!(projection.terminal); + assert!(projection.continuable); + assert_eq!( + projection + .checkpoint + .as_ref() + .expect("checkpoint projected") + .continuation_handle, + checkpoint.continuation_handle + ); + assert_eq!( + projection + .snapshot + .checkpoint + .as_ref() + .map(|cp| cp.message_count), + Some(1) + ); + assert_eq!( + projection + .checkpoint + .as_ref() + .and_then(|cp| cp.messages.first()) + .map(message_text), + Some("inspect checkpoint recovery") + ); +} + #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); @@ -1089,6 +1219,143 @@ async fn agent_eval_resolves_session_via_agent_name_alias() { assert_eq!(projection.status, "completed"); } +#[tokio::test] +async fn api_timeout_preserves_checkpoint_and_agent_eval_continues_from_it() { + let tmp = tempdir().expect("tempdir"); + let manager = Arc::new(RwLock::new(SubAgentManager::new( + tmp.path().to_path_buf(), + 2, + ))); + let agent_id = "agent_checkpoint_timeout".to_string(); + let (task_input_tx, task_input_rx) = mpsc::unbounded_channel(); + let agent = SubAgent::new( + agent_id.clone(), + SubAgentType::General, + "Inspect checkpoint behavior".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec![]), + task_input_tx, + "boot_test".to_string(), + ); + manager.write().await.agents.insert(agent_id.clone(), agent); + + let (client, calls, bodies) = + delayed_chat_client(Duration::from_millis(80), "resumed answer").await; + let mut runtime = stub_runtime().with_step_api_timeout(Duration::from_millis(10)); + runtime.client = client; + runtime.manager = Arc::clone(&manager); + runtime.context = ToolContext::new(tmp.path()); + + let task = SubAgentTask { + manager_handle: Arc::clone(&manager), + runtime: runtime.clone(), + agent_id: agent_id.clone(), + agent_type: SubAgentType::General, + prompt: "Inspect checkpoint behavior".to_string(), + assignment: make_assignment(), + allowed_tools: Some(vec![]), + fork_context: false, + started_at: Instant::now(), + max_steps: 3, + input_rx: task_input_rx, + }; + let task_handle = tokio::spawn(run_subagent_task(task)); + + let interrupted = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = { + let manager = manager.read().await; + manager + .get_result(&agent_id) + .expect("agent should stay registered") + }; + if matches!(snapshot.status, SubAgentStatus::Interrupted(_)) { + return snapshot; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("agent should become interrupted after API timeout"); + + let checkpoint = interrupted + .checkpoint + .as_ref() + .expect("timeout should preserve checkpoint"); + assert!(checkpoint.continuable); + assert_eq!(checkpoint.steps_taken, 1); + assert!( + checkpoint + .messages + .iter() + .any(|message| message_text(message).contains("Inspect checkpoint behavior")), + "checkpoint should preserve local child prompt: {checkpoint:?}" + ); + + let ctx = runtime.context.clone(); + let tool = AgentEvalTool::new(Arc::clone(&manager)); + let result = tool + .execute(json!({ "agent_id": agent_id, "block": false }), &ctx) + .await + .expect("agent_eval should project interrupted checkpoint"); + let projection: SubAgentSessionProjection = + serde_json::from_str(&result.content).expect("projection deserializes"); + assert_eq!(projection.status, "interrupted"); + assert!(projection.continuable); + assert!(projection.checkpoint.is_some()); + + let result = tool + .execute( + json!({ + "agent_id": agent_id, + "continue": true, + "message": "Please continue with the prior checkpoint.", + "timeout_ms": 2000 + }), + &ctx, + ) + .await + .expect("agent_eval should continue checkpointed interrupted session"); + let meta = result.metadata.expect("metadata present"); + assert_eq!( + meta["message_delivery"]["continued_from_checkpoint"], + json!(true) + ); + let projection: SubAgentSessionProjection = + serde_json::from_str(&result.content).expect("projection deserializes"); + assert_eq!(projection.status, "completed"); + assert_eq!( + projection.snapshot.result.as_deref(), + Some("resumed answer") + ); + assert!( + projection + .checkpoint + .as_ref() + .expect("completed projection keeps latest checkpoint") + .messages + .iter() + .any(|message| message_text(message) + .contains("Please continue with the prior checkpoint.")), + "continuation instruction should be part of resumed transcript" + ); + + task_handle.await.expect("sub-agent task should finish"); + assert!( + calls.load(Ordering::SeqCst) >= 2, + "continuation should make a second API request" + ); + let bodies = bodies + .lock() + .expect("request body recorder mutex poisoned") + .clone(); + let second_request = serde_json::to_string(&bodies[1]).expect("second request body serializes"); + assert!(second_request.contains("Inspect checkpoint behavior")); + assert!(second_request.contains("Please continue with the prior checkpoint.")); +} + #[tokio::test] async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a @@ -1468,6 +1735,52 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { )); } +#[test] +fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + let state_path = default_state_path(tmp.path()); + + let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut running = SubAgent::new( + "test_agent_checkpoint_reload".to_string(), + SubAgentType::General, + "work".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + "boot_test".to_string(), + ); + running.checkpoint = Some(make_checkpoint( + &running.id, + 2, + vec![ + text_message("user", "initial task"), + text_message("assistant", "partial progress"), + ], + )); + let running_id = running.id.clone(); + manager.agents.insert(running_id.clone(), running); + manager.persist_state().expect("persist state"); + + let mut reloaded = + SubAgentManager::new(workspace, 2).with_state_path(default_state_path(tmp.path())); + reloaded.load_state().expect("load state"); + let snapshot = reloaded + .get_result(&running_id) + .expect("reloaded agent should exist"); + + assert!(matches!(snapshot.status, SubAgentStatus::Interrupted(_))); + let checkpoint = snapshot.checkpoint.expect("checkpoint should reload"); + assert!(checkpoint.continuable); + assert_eq!(checkpoint.steps_taken, 2); + assert_eq!(checkpoint.messages.len(), 2); + assert_eq!(message_text(&checkpoint.messages[1]), "partial progress"); +} + #[test] fn test_interrupted_status_name_and_summary() { let snapshot = make_snapshot(SubAgentStatus::Interrupted( diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 38d3c6504..2c100a5c0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3112,6 +3112,7 @@ fn make_subagent( status, result: None, steps_taken: 0, + checkpoint: None, duration_ms: 0, from_prior_session: false, } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index d041885aa..042357e84 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1778,6 +1778,7 @@ fn live_subagent_result( status, result: None, steps_taken: 0, + checkpoint: None, duration_ms: 0, from_prior_session: false, } @@ -2276,6 +2277,7 @@ mod tests { status, result: None, steps_taken: 1, + checkpoint: None, duration_ms: 10, from_prior_session: false, } diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 3d009c38e..678e52cc8 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -52,6 +52,7 @@ harvest/stewardship commits: | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | | #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | +| #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | @@ -75,7 +76,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 should close as harvested, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | | Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed, and the auto-generated fallback now has a synthetic 1000-file startup smoke with `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture`. Still needs a real massive-repo/manual startup benchmark before closing #697 or #1827. | | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | -| Sub-agent checkpoint/resume (#2029) | Still release-blocking. | Session projection/transcript handles exist, but no checkpoint/continue status or resume contract has landed. Needs a child checkpoint/timeout/resume test that preserves policy and completes. | +| Sub-agent checkpoint/resume (#2029) | Partially covered. | Live per-step API timeout now preserves a continuable checkpoint and `agent_eval { continue: true }` resumes the parked child; `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture` passed with checkpoint/projection/persistence/continuation coverage. Cold-restart continuation is not implemented because persisted child tasks are not rehydrated; decide whether #2029 can close as live-timeout recovery or should remain open for restart-resume UX. | | Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Shell containment and turn-liveness tests exist, but orphaned PID/session-load reaping and long-running shell LIVE-state recovery remain open. Needs stale PID reaping and live-state regression coverage. | | Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | Queued-message recovery/editing and pending-input delivery-mode labels are covered by `queued` and `pending_input_preview` focused tests. Still needs cancel/edit-mode affordance clarity and a repro for accidentally entering queued-draft edit while a turn is loading. | | Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | From 5b3ee9db674e7db78e7fe7e0ae77c41de58f1785 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:30:16 -0700 Subject: [PATCH 051/209] fix(tasks): fail stale running tasks after restart Refs #1786. Reported by @bevis-wong. This lands the durable restart-safety slice: persisted running tasks and running tool rows are marked failed with a recovery note instead of being requeued as live work after a prior process exits. --- CHANGELOG.md | 5 ++ crates/tui/src/task_manager.rs | 124 +++++++++++++++++++++++++++++++-- docs/V0_9_0_EXECUTION_MAP.md | 3 +- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139c39e5b..764ed4874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 checkpoint instead of ending as a null failed result, and `agent_eval` can explicitly continue a live checkpointed interrupted child while normal completed/failed/cancelled follow-up behavior stays unchanged (#2029). +- Durable task recovery no longer requeues tasks that were `running` when the + previous CodeWhale process exited. On restart those records are marked failed + with a recovery note, and any running tool-call summaries are marked failed + too, so stale shell/task state cannot silently become live work again (#1786). - Auto-generated project instructions now reuse the bounded Project Context Pack data instead of running an unbounded summary/tree scan when no `.codewhale/instructions.md` file exists. The fallback keeps later @@ -104,6 +108,7 @@ dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), +to **@bevis-wong** for the long-running shell/task liveness report (#1786), and to **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and to **@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 4920917f2..95db71f8c 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -1512,14 +1512,34 @@ fn load_state( ); } if task.status == TaskStatus::Running { - task.status = TaskStatus::Queued; - task.started_at = None; - task.ended_at = None; - task.duration_ms = None; + let now = Utc::now(); + let duration_ms = task.started_at.and_then(|started| { + u64::try_from(now.signed_duration_since(started).num_milliseconds()).ok() + }); + task.status = TaskStatus::Failed; + task.ended_at = Some(now); + task.duration_ms = duration_ms; + task.error = Some( + "Interrupted by process restart; prior process is not attached".to_string(), + ); + for tool in &mut task.tool_calls { + if tool.status == TaskToolStatus::Running { + tool.status = TaskToolStatus::Failed; + tool.ended_at = Some(now); + tool.duration_ms = duration_ms.or_else(|| { + u64::try_from( + now.signed_duration_since(tool.started_at) + .num_milliseconds(), + ) + .ok() + }); + } + } task.timeline.push(TaskTimelineEntry { - timestamp: Utc::now(), + timestamp: now, kind: "recovered".to_string(), - summary: "Recovered from restart and re-queued".to_string(), + summary: "Interrupted by process restart; prior process is not attached" + .to_string(), detail_path: None, }); } @@ -1790,6 +1810,98 @@ mod tests { Ok(()) } + #[test] + fn running_tasks_are_not_requeued_after_restart() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); + let tasks_dir = root.join("tasks"); + fs::create_dir_all(&tasks_dir)?; + let queue_path = root.join("queue.json"); + let task_id = "task_stale_running".to_string(); + let started_at = Utc::now() - chrono::Duration::seconds(30); + let task = TaskRecord { + schema_version: CURRENT_TASK_SCHEMA_VERSION, + id: task_id.clone(), + prompt: "long-running shell work".to_string(), + model: "deepseek-v4-flash".to_string(), + workspace: PathBuf::from("."), + mode: "agent".to_string(), + allow_shell: true, + trust_mode: false, + auto_approve: false, + status: TaskStatus::Running, + created_at: started_at, + started_at: Some(started_at), + ended_at: None, + duration_ms: None, + result_summary: None, + result_detail_path: None, + error: None, + thread_id: Some("thr_stale".to_string()), + turn_id: Some("turn_stale".to_string()), + runtime_event_count: 0, + checklist: TaskChecklistState::default(), + gates: Vec::new(), + attempts: Vec::new(), + artifacts: Vec::new(), + github_events: Vec::new(), + tool_calls: vec![TaskToolCallSummary { + id: "tool_shell".to_string(), + name: "task_shell_start".to_string(), + status: TaskToolStatus::Running, + started_at, + ended_at: None, + duration_ms: None, + input_summary: Some("shell: sleep 999".to_string()), + output_summary: None, + detail_path: None, + patch_ref: None, + }], + timeline: vec![TaskTimelineEntry { + timestamp: started_at, + kind: "running".to_string(), + summary: "Task started".to_string(), + detail_path: None, + }], + }; + fs::write( + tasks_dir.join(format!("{task_id}.json")), + serde_json::to_string_pretty(&task)?, + )?; + fs::write( + &queue_path, + serde_json::to_string_pretty(&QueueFile { + queue: vec![task_id.clone()], + })?, + )?; + + let (tasks, queue) = load_state(&tasks_dir, &queue_path)?; + let recovered = tasks.get(&task_id).expect("task loaded"); + + assert!(queue.is_empty(), "stale running task must not be requeued"); + assert_eq!(recovered.status, TaskStatus::Failed); + assert!( + recovered + .error + .as_deref() + .is_some_and(|err| err.contains("prior process is not attached")), + "recovered task should explain stale process ownership: {recovered:?}" + ); + assert!(recovered.ended_at.is_some()); + assert!(recovered.duration_ms.is_some()); + assert_eq!(recovered.tool_calls[0].status, TaskToolStatus::Failed); + assert!(recovered.tool_calls[0].ended_at.is_some()); + assert!( + recovered + .timeline + .iter() + .any(|entry| entry.kind == "recovered" + && entry.summary.contains("prior process is not attached")), + "recovery timeline should explain why the task is terminal: {:?}", + recovered.timeline + ); + Ok(()) + } + #[tokio::test] async fn default_workspace_updates_for_future_tasks() -> Result<()> { let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 678e52cc8..3faae403b 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -53,6 +53,7 @@ harvest/stewardship commits: | #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | +| #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | @@ -77,7 +78,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed, and the auto-generated fallback now has a synthetic 1000-file startup smoke with `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture`. Still needs a real massive-repo/manual startup benchmark before closing #697 or #1827. | | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | | Sub-agent checkpoint/resume (#2029) | Partially covered. | Live per-step API timeout now preserves a continuable checkpoint and `agent_eval { continue: true }` resumes the parked child; `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture` passed with checkpoint/projection/persistence/continuation coverage. Cold-restart continuation is not implemented because persisted child tasks are not rehydrated; decide whether #2029 can close as live-timeout recovery or should remain open for restart-resume UX. | -| Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Shell containment and turn-liveness tests exist, but orphaned PID/session-load reaping and long-running shell LIVE-state recovery remain open. Needs stale PID reaping and live-state regression coverage. | +| Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Durable task restart recovery now fails stale persisted `running` tasks instead of requeueing them, covered by `running_tasks_are_not_requeued_after_restart` and broader `task_manager` tests. Foreground shell hang root cause and LIVE-state watchdog recovery remain open; avoid aborting legitimate foreground `exec_shell` commands while adding stale-card recovery. | | Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | Queued-message recovery/editing and pending-input delivery-mode labels are covered by `queued` and `pending_input_preview` focused tests. Still needs cancel/edit-mode affordance clarity and a repro for accidentally entering queued-draft edit while a turn is loading. | | Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | From 5d006a901e8d932b06224f1ddf6da97509e8145d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:32:29 -0700 Subject: [PATCH 052/209] docs: record superseded transcript collapse PR --- docs/V0_9_0_EXECUTION_MAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 3faae403b..5c19e7a4c 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -50,6 +50,7 @@ harvest/stewardship commits: | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | +| #2740 dense tool-run collapse follow-up | Superseded by the local #2738 harvest. | The PR carries the same #2692 product direction but its reviewed head still depended on folded-thinking state before collapse could render and omitted MCP status/name handling. The local #2738 harvest already covers common-case collapse, MCP success/tool-name grouping, expansion/cell-map behavior, and `tool_collapse` modes with focused transcript-collapse tests. Credit @idling11; comment/close after the integration branch is public. | | #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | @@ -145,6 +146,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | +| #2740 dense tool-run collapse follow-up | Mergeable / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Comment/close after the integration branch is public, crediting @idling11. | ## Issue Reduction Strategy From 8d9cd4407817b3199a8e3f96b6eebd7c7635ea2c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:36:42 -0700 Subject: [PATCH 053/209] fix(tui): make queued follow-up edits recoverable --- CHANGELOG.md | 4 ++ crates/tui/src/tui/app.rs | 39 ++++++++++++ crates/tui/src/tui/composer_ui.rs | 4 +- crates/tui/src/tui/ui.rs | 13 +++- crates/tui/src/tui/ui/tests.rs | 63 +++++++++++++++++++ .../src/tui/widgets/pending_input_preview.rs | 51 +++++++++++++++ docs/V0_9_0_EXECUTION_MAP.md | 4 +- 7 files changed, 172 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764ed4874..1ae1baa71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pending-input preview rows now label delivery mode explicitly as steer pending, rejected steer, or queued follow-up, with wrapped continuation rows aligned under the label so busy-turn input state is easier to read (#2054). +- Editing a queued follow-up is now an explicit pending-input state. Pressing + `Esc` while editing a queued follow-up restores the original queued message + instead of cancelling the active turn or silently dropping the queued work + (#2054). - Sidebar hover details now use row-level metadata for truncated Work, Tasks, and Agents rows. Mouse hover opens a bordered, wrapping popover with the full underlying row text, long turn/agent ids, and current sub-agent progress diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7385cdada..024ce2134 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -4706,6 +4706,18 @@ impl App { true } + /// Stop editing a queued follow-up and put the original queued message back + /// at the tail where [`Self::pop_last_queued_into_draft`] took it from. + pub fn cancel_queued_draft_edit(&mut self) -> bool { + let Some(draft) = self.queued_draft.take() else { + return false; + }; + self.queued_messages.push_back(draft); + self.clear_input_recoverable(); + self.needs_redraw = true; + true + } + /// Park a legacy pending steer. New keyboard handling routes running-turn /// drafts through Enter (same-turn steer) or Tab (next-turn follow-up). #[allow(dead_code)] @@ -7174,6 +7186,33 @@ mod tests { assert!(app.queued_draft.is_none()); } + #[test] + fn cancel_queued_draft_edit_restores_original_message() { + let mut app = App::new(test_options(false), &Config::default()); + app.queue_message(QueuedMessage::new("first".to_string(), None)); + app.queue_message(QueuedMessage::new( + "original follow-up".to_string(), + Some("skill".to_string()), + )); + assert!(app.pop_last_queued_into_draft()); + app.input = "edited but not submitted".to_string(); + app.cursor_position = char_count(&app.input); + + assert!(app.cancel_queued_draft_edit()); + + assert!(app.input.is_empty()); + assert!(app.queued_draft.is_none()); + assert_eq!(app.queued_messages.len(), 2); + let restored = app.queued_messages.back().expect("restored message"); + assert_eq!(restored.display, "original follow-up"); + assert_eq!(restored.skill_instruction.as_deref(), Some("skill")); + assert_eq!( + app.clear_undo_buffer.as_deref(), + Some("edited but not submitted"), + "the interrupted edit remains recoverable via normal draft recovery" + ); + } + #[test] fn finalize_streaming_assistant_marks_existing_cell_interrupted() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 708f4f97b..122f293a5 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -16,10 +16,10 @@ pub(crate) enum EscapeAction { pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { if slash_menu_open { EscapeAction::CloseSlashMenu + } else if app.queued_draft.is_some() { + EscapeAction::DiscardQueuedDraft } else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) { EscapeAction::CancelRequest - } else if app.queued_draft.is_some() && app.input.is_empty() { - EscapeAction::DiscardQueuedDraft } else if !app.input.is_empty() { EscapeAction::ClearInput } else { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f9ca48c4c..a810b6711 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3471,8 +3471,10 @@ async fn run_event_loop( } EscapeAction::DiscardQueuedDraft => { app.backtrack.reset(); - app.queued_draft = None; - app.status_message = Some("Stopped editing queued message".to_string()); + if app.cancel_queued_draft_edit() { + app.status_message = + Some("Queued edit canceled; follow-up restored".to_string()); + } } EscapeAction::ClearInput => { app.backtrack.reset(); @@ -6540,6 +6542,13 @@ fn build_pending_input_preview(app: &App) -> PendingInputPreview { .iter() .map(|m| m.display.clone()) .collect(); + preview.editing_queued_message = app.queued_draft.as_ref().map(|draft| { + if app.input.trim().is_empty() { + draft.display.clone() + } else { + app.input.clone() + } + }); preview } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 2c100a5c0..5ceb5b9d0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3995,6 +3995,22 @@ fn test_esc_discards_queued_draft_before_clearing_input() { ); } +#[test] +fn test_esc_prioritizes_queued_draft_edit_over_loading_cancel() { + let mut app = create_test_app(); + app.is_loading = true; + app.input = "editing queued follow-up".to_string(); + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "original queued follow-up".to_string(), + None, + )); + + assert_eq!( + next_escape_action(&app, false), + EscapeAction::DiscardQueuedDraft + ); +} + #[test] fn test_esc_is_noop_when_idle() { let mut app = create_test_app(); @@ -4200,6 +4216,17 @@ fn test_esc_priority_order_matches_cancel_stack() { app.input.clear(); assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "queued draft".to_string(), + None, + )); + app.input = "editing queued draft".to_string(); + assert_eq!( + next_escape_action(&app, false), + EscapeAction::DiscardQueuedDraft + ); + + app.queued_draft = None; app.is_loading = false; app.input = "draft".to_string(); assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput); @@ -7201,6 +7228,42 @@ fn build_pending_input_preview_populates_all_three_buckets() { assert_eq!(preview.queued_messages, vec!["queued-msg".to_string()]); } +#[test] +fn accidental_queue_edit_while_loading_is_labeled_and_recoverable() { + let mut app = create_test_app(); + app.is_loading = true; + app.queue_message(QueuedMessage::new( + "original queued follow-up".to_string(), + Some("skill body".to_string()), + )); + + assert!(app.pop_last_queued_into_draft()); + assert_eq!(app.input, "original queued follow-up"); + app.input = "edited queued follow-up".to_string(); + app.cursor_position = app.input.chars().count(); + + let preview = build_pending_input_preview(&app); + assert_eq!( + preview.editing_queued_message.as_deref(), + Some("edited queued follow-up") + ); + assert!( + preview.queued_messages.is_empty(), + "the popped message should be shown as editing, not a second queued row" + ); + assert_eq!( + next_escape_action(&app, false), + EscapeAction::DiscardQueuedDraft, + "Esc should cancel the queued edit before cancelling the live turn" + ); + + assert!(app.cancel_queued_draft_edit()); + assert!(app.input.is_empty()); + let restored = app.queued_messages.back().expect("follow-up restored"); + assert_eq!(restored.display, "original queued follow-up"); + assert_eq!(restored.skill_instruction.as_deref(), Some("skill body")); +} + #[test] fn build_pending_input_preview_includes_current_context_chips() { let tmpdir = TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/tui/widgets/pending_input_preview.rs b/crates/tui/src/tui/widgets/pending_input_preview.rs index 78aabd0ca..a9e2cb4fb 100644 --- a/crates/tui/src/tui/widgets/pending_input_preview.rs +++ b/crates/tui/src/tui/widgets/pending_input_preview.rs @@ -27,6 +27,7 @@ const PREVIEW_LINE_LIMIT: usize = 3; const PENDING_STEER_PREFIX: &str = " ↳ Steer pending: "; const REJECTED_STEER_PREFIX: &str = " ↳ Rejected steer: "; const QUEUED_MESSAGE_PREFIX: &str = " ↳ Queued follow-up: "; +const EDITING_QUEUED_PREFIX: &str = " ↳ Editing queued follow-up: "; /// Description of the keybinding the hint line at the bottom should advertise /// for the "edit last queued message" action. @@ -46,6 +47,7 @@ pub struct PendingInputPreview { pub pending_steers: Vec, pub rejected_steers: Vec, pub queued_messages: Vec, + pub editing_queued_message: Option, pub edit_binding: EditBinding, } @@ -69,6 +71,7 @@ impl PendingInputPreview { pending_steers: Vec::new(), rejected_steers: Vec::new(), queued_messages: Vec::new(), + editing_queued_message: None, edit_binding: EditBinding::UP, } } @@ -77,6 +80,7 @@ impl PendingInputPreview { !self.pending_steers.is_empty() || !self.rejected_steers.is_empty() || !self.queued_messages.is_empty() + || self.editing_queued_message.is_some() } /// Build the (possibly empty) ordered line list this widget would render @@ -134,6 +138,21 @@ impl PendingInputPreview { &rejected_steer_indent, ); } + if let Some(draft) = self.editing_queued_message.as_deref() { + let editing_indent = continuation_indent(EDITING_QUEUED_PREFIX); + push_truncated_item( + &mut lines, + draft, + width, + dim_italic, + EDITING_QUEUED_PREFIX, + &editing_indent, + ); + lines.push(Line::from(vec![Span::styled( + " Esc restores queued follow-up".to_string(), + dim, + )])); + } let queued_message_indent = continuation_indent(QUEUED_MESSAGE_PREFIX); for message in &self.queued_messages { push_truncated_item( @@ -371,6 +390,32 @@ mod tests { assert!(rows[2].contains("edit last queued message")); } + #[test] + fn editing_queued_message_renders_explicit_state_and_restore_hint() { + let mut preview = PendingInputPreview::new(); + preview.editing_queued_message = Some("revise before sending".to_string()); + + let rows = render_to_string(&preview, 80); + + assert!(rows[0].contains("Pending inputs")); + assert!( + rows.iter() + .any(|row| row.contains("Editing queued follow-up: revise before sending")), + "missing editing label: {rows:?}" + ); + assert!( + rows.iter() + .any(|row| row.contains("Esc restores queued follow-up")), + "missing restore hint: {rows:?}" + ); + assert!( + !rows + .iter() + .any(|row| row.contains("edit last queued message")), + "editing mode should not also advertise opening a queued edit: {rows:?}" + ); + } + #[test] fn context_items_render_before_queue_buckets() { let mut preview = PendingInputPreview::new(); @@ -460,6 +505,7 @@ mod tests { preview.pending_steers.push("steer".to_string()); preview.rejected_steers.push("rejected".to_string()); preview.queued_messages.push("queued".to_string()); + preview.editing_queued_message = Some("editing".to_string()); let rows = render_to_string(&preview, 80); @@ -477,6 +523,11 @@ mod tests { .any(|row| row.contains("Queued follow-up: queued")), "missing queued-follow-up label: {rows:?}" ); + assert!( + rows.iter() + .any(|row| row.contains("Editing queued follow-up: editing")), + "missing queued-edit label: {rows:?}" + ); } #[test] diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 5c19e7a4c..b5a0a06b8 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -52,7 +52,7 @@ harvest/stewardship commits: | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | | #2740 dense tool-run collapse follow-up | Superseded by the local #2738 harvest. | The PR carries the same #2692 product direction but its reviewed head still depended on folded-thinking state before collapse could render and omitted MCP status/name handling. The local #2738 harvest already covers common-case collapse, MCP success/tool-name grouping, expansion/cell-map behavior, and `tool_collapse` modes with focused transcript-collapse tests. Credit @idling11; comment/close after the integration branch is public. | | #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | -| #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | +| #2532 pending-input delivery-mode labels plus #2054 queued-edit recovery | Locally re-harvested and extended for #2054. | Pending-input preview rows label steer-pending, rejected-steer, queued-follow-up, and editing-queued-follow-up delivery modes. The accidental ↑ edit path is test-covered while loading, and `Esc` restores the original queued follow-up before cancelling the active turn. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture`, `... queued_draft ...`, and `... accidental_queue_edit_while_loading_is_labeled_and_recoverable ...` passed. Credit @cyq1017; leave #2054 open only if row-level edit/drop/send controls are still required beyond the composer recovery fix. | | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | | #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | @@ -80,7 +80,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | | Sub-agent checkpoint/resume (#2029) | Partially covered. | Live per-step API timeout now preserves a continuable checkpoint and `agent_eval { continue: true }` resumes the parked child; `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture` passed with checkpoint/projection/persistence/continuation coverage. Cold-restart continuation is not implemented because persisted child tasks are not rehydrated; decide whether #2029 can close as live-timeout recovery or should remain open for restart-resume UX. | | Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Durable task restart recovery now fails stale persisted `running` tasks instead of requeueing them, covered by `running_tasks_are_not_requeued_after_restart` and broader `task_manager` tests. Foreground shell hang root cause and LIVE-state watchdog recovery remain open; avoid aborting legitimate foreground `exec_shell` commands while adding stale-card recovery. | -| Queued/live input feedback (#2054) | Partially covered; UX clarity still blocking. | Queued-message recovery/editing and pending-input delivery-mode labels are covered by `queued` and `pending_input_preview` focused tests. Still needs cancel/edit-mode affordance clarity and a repro for accidentally entering queued-draft edit while a turn is loading. | +| Queued/live input feedback (#2054) | Common accidental edit path covered. | Queued-message recovery/editing, pending-input delivery-mode labels, explicit editing-queued-follow-up preview state, and Esc restore semantics are covered by `queued_draft`, `pending_input_preview`, and `accidental_queue_edit_while_loading_is_labeled_and_recoverable` focused tests. Keep open only if v0.9 also requires row-level edit/drop/send controls rather than composer-level recovery. | | Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | ## PR Harvest Queue From 6a7063c912ff09e70abe29ec6a8a69022a579a02 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:41:21 -0700 Subject: [PATCH 054/209] ci(ohos): guard unsupported target dependencies --- .cnb.yml | 3 + .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 2 + CHANGELOG.md | 3 + crates/tui/CHANGELOG.md | 114 +++++++++++++++++++++++++++++ docs/HarmonyOS.md | 14 ++++ docs/RELEASE_CHECKLIST.md | 3 + docs/V0_9_0_EXECUTION_MAP.md | 2 +- scripts/release/check-ohos-deps.sh | 41 +++++++++++ scripts/release/check-versions.sh | 18 ++++- 10 files changed, 198 insertions(+), 4 deletions(-) create mode 100755 scripts/release/check-ohos-deps.sh diff --git a/.cnb.yml b/.cnb.yml index ef440d1aa..f1c4d5f80 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -38,6 +38,7 @@ script: | set -eu ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo fmt --all -- --check cargo check --workspace --all-targets --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -75,6 +76,7 @@ script: | set -eu ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo fmt --all -- --check cargo check --workspace --all-targets --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -123,6 +125,7 @@ $: apt-get install -y git libdbus-1-dev nodejs pkg-config ./scripts/release/check-versions.sh + ./scripts/release/check-ohos-deps.sh cargo build --release --locked -p codewhale-cli -p codewhale-tui mkdir -p target/cnb-release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fafe26ed..1eb681cfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: node-version: 20 - name: Check version drift run: ./scripts/release/check-versions.sh + - name: Check OHOS dependency graph + run: ./scripts/release/check-ohos-deps.sh lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc9aa4161..3edca98fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,8 @@ jobs: run: cargo fmt --all -- --check - name: Compile check run: cargo check --workspace --all-targets --locked + - name: OHOS dependency graph + run: ./scripts/release/check-ohos-deps.sh - name: Clippy run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - name: Workspace tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae1baa71..4af0523b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 explicit Rustls ring-provider installation for the no-provider TLS build, and OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, execpolicy Starlark parsing, and self-update surfaces. +- Added `scripts/release/check-ohos-deps.sh` and wired it into CI/release + preflight so the OpenHarmony target graph fails if unsupported `nix`, + `portable-pty`, `starlark`, `arboard`, or `keyring` dependencies re-enter. - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index dec9b971b..4af0523b3 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added `/restore list [N]` so users can inspect more side-git rollback + snapshots with UTC timestamps before choosing a restore point. Plain + `/restore` now shows the 20 most recent snapshots, numeric restore targets can + reach beyond that default listing up to a bounded index, and list requests + above the visible cap fail explicitly instead of silently truncating. +- Added HarmonyOS/OpenHarmony support scaffolding: environment-driven + `OHOS_NATIVE_SDK` setup scripts and compiler wrappers, platform docs, + explicit Rustls ring-provider installation for the no-provider TLS build, and + OHOS fallbacks for unsupported keyring, clipboard, sandbox, browser-open, TTY, + execpolicy Starlark parsing, and self-update surfaces. +- Added `scripts/release/check-ohos-deps.sh` and wired it into CI/release + preflight so the OpenHarmony target graph fails if unsupported `nix`, + `portable-pty`, `starlark`, `arboard`, or `keyring` dependencies re-enter. +- Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested + commits use GitHub-mappable numeric noreply identities instead of `.local`, + placeholder, bot/tool, or raw third-party emails. +- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry + grounded objectives, context, sources, critical files, constraints, + verification, risks, and handoff notes through the transcript card, Plan + confirmation prompt, `/relay`, fork-state, and saved-session replay. +- Added `POST /v1/sessions` for runtime clients to save a completed thread as a + managed session. The endpoint preserves thread title/model/mode/workspace + metadata, maps missing threads to 404, and returns 409 instead of snapshotting + queued or active turns. + +### Changed + +- `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI + settings while still reading legacy DeepSeek-branded settings fallbacks and + migrating them into the CodeWhale home on load. +- `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for + GUI/runtime clients. Workspace changes reject active turns and evict idle + cached engines so the next turn starts in the new workspace. +- Split `web_run` session/page cache state so cached page reads use shared + page handles and do not serialize through the mutation path. The harvest also + adds panic-safe state write-back and serializes cache-mutating unit tests so + the global web cache remains stable under normal Cargo test parallelism. +- Appended volatile `` blocks after user text in outgoing user + message content arrays so provider prefix caches can keep matching the stable + user-input prefix across date, route, and working-set changes. +- Softened contribution intake automation: external issues now receive a warm + triage note and are never auto-closed by the contribution gate, while the PR + gate copy makes clear that dry-run observations are about maintainer safety, + not contributor quality. +- Added a PR gate marker guard so reopened unapproved PRs do not get duplicate + intake comments, and clarified that PR reopening should happen after + allowlist approval is merged. +- Documented the agent and sub-agent stewardship ethos so future automation + preserves human issue intake, careful PR review, and contributor credit. +- Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS + target dependencies so published OpenHarmony builds no longer pull `nix` 0.28 + through `rustyline` or `portable-pty`. +- Explicit `skills_dir` configuration is now unioned with workspace skill + discovery instead of being shadowed by workspace-local skills, and configured + skills take precedence over global defaults when prompt space is constrained. +- Tool-agent sub-agent routing now inherits the parent session model, or an + explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; + the fast lane still disables thinking through provider-aware request shaping. +- Dense successful read/search/list tool runs now collapse into a single + expandable transcript row by default, while running, failed, shell, patch, + review, diff, and other risky tool cells remain visible. The setting + `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. +- Pending-input preview rows now label delivery mode explicitly as steer + pending, rejected steer, or queued follow-up, with wrapped continuation rows + aligned under the label so busy-turn input state is easier to read (#2054). +- Editing a queued follow-up is now an explicit pending-input state. Pressing + `Esc` while editing a queued follow-up restores the original queued message + instead of cancelling the active turn or silently dropping the queued work + (#2054). +- Sidebar hover details now use row-level metadata for truncated Work, Tasks, + and Agents rows. Mouse hover opens a bordered, wrapping popover with the full + underlying row text, long turn/agent ids, and current sub-agent progress + instead of repeating the already-ellipsized sidebar label (#2694, #2734). +- Sub-agents now preserve checkpoint metadata around long model calls. A + per-step API timeout marks the child as interrupted with a continuable + checkpoint instead of ending as a null failed result, and `agent_eval` can + explicitly continue a live checkpointed interrupted child while normal + completed/failed/cancelled follow-up behavior stays unchanged (#2029). +- Durable task recovery no longer requeues tasks that were `running` when the + previous CodeWhale process exited. On restart those records are marked failed + with a recovery note, and any running tool-call summaries are marked failed + too, so stale shell/task state cannot silently become live work again (#1786). +- Auto-generated project instructions now reuse the bounded Project Context + Pack data instead of running an unbounded summary/tree scan when no + `.codewhale/instructions.md` file exists. The fallback keeps later + top-level folders visible in noisy large workspaces while the dynamic + `` marker remains controlled by its own setting + (#697, #1827). + +### Community + +Thanks to **@cyq1017** for the restore-listing implementation (#2513) and +pending-input delivery-mode label work (#2532, #2054), +**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata +prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale +settings-path migration work (#2730), **@gaord** for the runtime thread +workspace update and completed-thread save APIs (#2640, #2639), +**@shenjackyuanjie** for the +HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), +**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the +dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, +#2692, #2694), and +**@h3c-hexin** for the tool-agent model inheritance and configured +`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the +checkpoint/resume report that shaped the sub-agent recovery slice (#2029), +to **@bevis-wong** for the long-running shell/task liveness report (#1786), +and to **@NASLXTO** and +**@wuxixing** for the large-workspace startup reports (#697, #1827), and to +**@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and +startup-diagnosis work that shaped this bounded fallback. + ## [0.8.53] - 2026-06-03 ### Added diff --git a/docs/HarmonyOS.md b/docs/HarmonyOS.md index f3c091f89..2c84eb888 100644 --- a/docs/HarmonyOS.md +++ b/docs/HarmonyOS.md @@ -76,3 +76,17 @@ chmod +x ./ohos-clang.sh ./ohos-clangxx.sh Cargo cannot expand environment variables inside `linker` or CMake toolchain path values there, so those values are exported by `scripts/ohos-env.ps1` and `scripts/ohos-env.sh` instead. + +## Dependency Guard + +Release prep runs a no-SDK dependency check: + +```bash +./scripts/release/check-ohos-deps.sh +``` + +The guard resolves the `codewhale-tui` dependency graph for +`aarch64-unknown-linux-ohos` and fails if unsupported host/UI crates re-enter +the target graph: `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, or +`keyring`. This does not replace a real SDK/sysroot build, but it catches the +known `starlark -> rustyline -> nix` and PTY/keyring regressions before release. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 626aafb7e..dce3bd8f8 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -39,6 +39,9 @@ publish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md). - [ ] `Cargo.lock` is refreshed (`cargo update --workspace --offline`). - [ ] `./scripts/release/check-versions.sh` reports `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, lockfile in sync.` +- [ ] `./scripts/release/check-ohos-deps.sh` reports that the OpenHarmony + target graph does not pull the unsupported `nix` 0.28/0.29, + `portable-pty`, `starlark`, `arboard`, or `keyring` crates. ## 3. Preflight gates diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index b5a0a06b8..af7d803de 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -57,7 +57,7 @@ harvest/stewardship commits: | #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | -| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | +| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `./scripts/release/check-ohos-deps.sh` now guards the OHOS graph against `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, and `keyring`; `cargo check --workspace --all-features --locked` and focused PTY/clipboard tests passed. Full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | | #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | diff --git a/scripts/release/check-ohos-deps.sh b/scripts/release/check-ohos-deps.sh new file mode 100755 index 000000000..0d1bf9f59 --- /dev/null +++ b/scripts/release/check-ohos-deps.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Guard the OpenHarmony target dependency graph. +# +# This check intentionally does not require an OpenHarmony SDK or sysroot. It +# only asks Cargo to resolve the codewhale-tui dependency graph for the OHOS +# target and fails if crates known to break or be unsupported on OHOS re-enter +# that graph. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +target="${1:-aarch64-unknown-linux-ohos}" +package="${CODEWHALE_OHOS_DEP_PACKAGE:-codewhale-tui}" + +tree="$( + cargo tree \ + --locked \ + --package "${package}" \ + --all-features \ + --target "${target}" \ + --prefix none \ + --no-dedupe +)" + +disallowed="$( + grep -E '^(nix v0\.(28|29)\.|portable-pty v|starlark v|arboard v|keyring v)' <<<"${tree}" || true +)" + +if [[ -n "${disallowed}" ]]; then + { + echo "::error::OHOS target graph for ${package} includes unsupported dependencies:" + echo "${disallowed}" + echo + echo "The OpenHarmony port avoids the rustyline/starlark/portable-pty/nix chain" + echo "by target-gating those crates away from target_env=ohos. Keep this graph" + echo "clean unless a real OHOS-compatible dependency update lands." + } >&2 + exit 1 +fi + +echo "OHOS dependency graph OK for ${package} on ${target}." diff --git a/scripts/release/check-versions.sh b/scripts/release/check-versions.sh index f260b803c..241289e45 100755 --- a/scripts/release/check-versions.sh +++ b/scripts/release/check-versions.sh @@ -96,10 +96,22 @@ if [[ -z "${compare_line}" ]]; then fail=1 fi +unreleased_section="$( + awk ' + index($0, "## [Unreleased]") == 1 { in_section = 1; print; next } + in_section && /^## \[/ { exit } + in_section { print } + ' CHANGELOG.md +)" +credit_sections="${current_section} +${unreleased_section}" + # 6) Contributor-credit cross-check for README additions on the release branch. # This cannot prove every external PR author has been credited, but it does # catch the common release-polish failure mode: adding a README contributor row -# without mentioning that credit/correction in the current release entry. +# without mentioning that credit/correction in the current release entry. While +# a release branch is still unbumped, `[Unreleased]` is also a valid credit +# surface. previous_tag="" current_tag="v${workspace_version}" if [[ "${compare_line}" =~ compare/(v[0-9]+\.[0-9]+\.[0-9]+)\.\.\.${current_tag} ]]; then @@ -114,8 +126,8 @@ if [[ -n "${previous_tag}" ]]; then [[ -z "${line}" ]] && continue handle="$(sed -E 's#.*github.com/([^)/]+).*#\1#' <<<"${line}")" if [[ -n "${handle}" && "${handle}" != "${line}" ]]; then - if ! grep -Fq "github.com/${handle}" <<<"${current_section}" && ! grep -Fq "@${handle}" <<<"${current_section}"; then - echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} does not mention that credit." >&2 + if ! grep -Fq "github.com/${handle}" <<<"${credit_sections}" && ! grep -Fq "@${handle}" <<<"${credit_sections}"; then + echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} or [Unreleased] does not mention that credit." >&2 fail=1 fi fi From e18f072a5a589ea528191a54aa00b575ec2f8461 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:49:08 -0700 Subject: [PATCH 055/209] perf(context): cache project context with content signatures Harvested from PR #2636 by @HUQIANTAO with widened cache invalidation for constitution files, generated context, trust state, canonical paths, and same-length overwrites. Co-authored-by: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> --- CHANGELOG.md | 11 +- README.md | 3 +- crates/tui/CHANGELOG.md | 11 +- crates/tui/src/config.rs | 14 ++ crates/tui/src/main.rs | 1 + crates/tui/src/project_context.rs | 234 ++++++++++++++++++++++-- crates/tui/src/project_context_cache.rs | 220 ++++++++++++++++++++++ docs/V0_9_0_EXECUTION_MAP.md | 2 +- 8 files changed, 475 insertions(+), 21 deletions(-) create mode 100644 crates/tui/src/project_context_cache.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af0523b3..22cd94306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,14 +97,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 top-level folders visible in noisy large workspaces while the dynamic `` marker remains controlled by its own setting (#697, #1827). +- Project context loading now uses a bounded process-local content-signature + cache for repeated hot-path loads. The cache covers workspace/parent + instructions, global AGENTS/WHALE fallbacks, repo constitution files, + generated-context targets, trust markers, and trust config paths, and it + stores post-load signatures so auto-generated context deletion/regeneration + stays correct (#2636). ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and pending-input delivery-mode label work (#2532, #2054), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), -**@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata -prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata +prefix-cache stability work (#2517), and project-context cache direction +(#2636), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the diff --git a/README.md b/README.md index 182cbfef7..a8edb2fbd 100644 --- a/README.md +++ b/README.md @@ -624,7 +624,8 @@ Current v0.9 track credits: - **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — HarmonyOS / OpenHarmony porting work and MatePad Edge validation trail (#2634) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — `web_run` cache-state - lock-splitting and turn-metadata prefix-cache stability work (#2502, #2517) + lock-splitting, turn-metadata prefix-cache stability, and project-context + cache work (#2502, #2517, #2636) - **[idling11](https://github.com/idling11)** — PlanArtifact continuity and dense tool-call transcript collapse direction (#2733, #2738, #2692) - **[h3c-hexin](https://github.com/h3c-hexin)** — sub-agent model inheritance, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 4af0523b3..22cd94306 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -97,14 +97,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 top-level folders visible in noisy large workspaces while the dynamic `` marker remains controlled by its own setting (#697, #1827). +- Project context loading now uses a bounded process-local content-signature + cache for repeated hot-path loads. The cache covers workspace/parent + instructions, global AGENTS/WHALE fallbacks, repo constitution files, + generated-context targets, trust markers, and trust config paths, and it + stores post-load signatures so auto-generated context deletion/regeneration + stays correct (#2636). ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and pending-input delivery-mode label work (#2532, #2054), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), -**@HUQIANTAO** for the `web_run` lock-splitting work (#2502) and turn-metadata -prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale +**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata +prefix-cache stability work (#2517), and project-context cache direction +(#2636), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index f71400b74..6d7f17acd 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2936,6 +2936,20 @@ fn home_config_path() -> Option { }) } +pub(crate) fn workspace_trust_config_candidate_paths() -> Vec { + if let Some(path) = env_config_path() { + return vec![path]; + } + + let Some(home) = effective_home_dir() else { + return Vec::new(); + }; + vec![ + home.join(".codewhale").join("config.toml"), + home.join(".deepseek").join("config.toml"), + ] +} + #[must_use] pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool { let Some(config_path) = default_config_path() else { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 6185232c8..751d6e4f4 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -51,6 +51,7 @@ mod palette; mod prefix_cache; mod pricing; mod project_context; +mod project_context_cache; mod project_doc; mod prompt_zones; mod prompts; diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index c4b806243..016bd8cec 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -660,7 +660,23 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { /// /// This allows for monorepo setups where a root AGENTS.md applies to all subdirectories. pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext { - load_project_context_with_parents_and_home(workspace, dirs::home_dir().as_deref()) + load_project_context_with_parents_cached_and_home(workspace, dirs::home_dir().as_deref()) +} + +fn load_project_context_with_parents_cached_and_home( + workspace: &Path, + home_dir: Option<&Path>, +) -> ProjectContext { + let workspace = canonicalize_workspace_or_keep(workspace); + let pre_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir); + if let Some(ctx) = crate::project_context_cache::lookup(&pre_load_key) { + return ctx; + } + + let ctx = load_project_context_with_parents_and_home(&workspace, home_dir); + let post_load_key = crate::project_context_cache::compute_cache_key(&workspace, home_dir); + crate::project_context_cache::store(post_load_key, ctx.clone()); + ctx } fn load_project_context_with_parents_and_home( @@ -746,6 +762,80 @@ fn load_project_context_with_parents_and_home( ctx } +pub(crate) fn project_context_cache_candidate_paths( + workspace: &Path, + home_dir: Option<&Path>, +) -> Vec { + let workspace = canonicalize_workspace_or_keep(workspace); + let mut paths = Vec::new(); + + let mut current = Some(workspace.as_path()); + while let Some(dir) = current { + for filename in PROJECT_CONTEXT_FILES { + paths.push(dir.join(filename)); + } + current = dir.parent(); + } + + if let Some(home) = home_dir { + for candidate in global_context_relative_paths() { + paths.push(join_relative_components(home, candidate)); + } + } + + paths.extend(repo_constitution_candidate_paths(&workspace)); + paths.push(workspace.join(".deepseek").join("trusted")); + paths.push(workspace.join(".deepseek").join("trust.json")); + paths.extend(crate::config::workspace_trust_config_candidate_paths()); + + paths +} + +fn repo_constitution_candidate_paths(workspace: &Path) -> Vec { + let git_root = crate::project_doc::find_git_root(workspace); + let mut current = workspace.to_path_buf(); + let mut paths = Vec::new(); + loop { + paths.push(join_relative_components( + ¤t, + REPO_CONSTITUTION_RELATIVE_PATH, + )); + if let Some(ref root) = git_root + && current == *root + { + break; + } + match current.parent() { + Some(parent) if parent != current => current = parent.to_path_buf(), + _ => break, + } + } + paths +} + +fn global_context_relative_paths() -> [&'static [&'static str]; 6] { + [ + GLOBAL_AGENTS_RELATIVE_PATH, + GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH, + GLOBAL_AGENTS_LEGACY_PATH, + GLOBAL_WHALE_RELATIVE_PATH, + GLOBAL_WHALE_VENDOR_NEUTRAL_PATH, + GLOBAL_WHALE_LEGACY_PATH, + ] +} + +fn join_relative_components(base: &Path, relative: &[&str]) -> PathBuf { + let mut path = base.to_path_buf(); + for component in relative { + path.push(component); + } + path +} + +fn canonicalize_workspace_or_keep(workspace: &Path) -> PathBuf { + fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()) +} + /// Combine global user-wide preferences with a project-local /// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so /// workspace-specific rules can override it — the model reads in declared @@ -776,22 +866,10 @@ fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Opti // 4. ~/.codewhale/WHALE.md (deprecated, legacy fallback) // 5. ~/.agents/WHALE.md (deprecated, vendor-neutral legacy) // 6. ~/.deepseek/WHALE.md (deprecated, legacy) - let candidates: &[&[&str]] = &[ - GLOBAL_AGENTS_RELATIVE_PATH, - GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH, - GLOBAL_AGENTS_LEGACY_PATH, - GLOBAL_WHALE_RELATIVE_PATH, - GLOBAL_WHALE_VENDOR_NEUTRAL_PATH, - GLOBAL_WHALE_LEGACY_PATH, - ]; - let mut warnings = Vec::new(); - for candidate in candidates { - let mut path = home.to_path_buf(); - for component in *candidate { - path.push(component); - } + for candidate in global_context_relative_paths() { + let path = join_relative_components(home, candidate); if path.exists() && path.is_file() { match load_context_file(&path) { @@ -1434,6 +1512,132 @@ mod tests { ); } + #[test] + fn cached_context_reflects_overwritten_agents_md() { + crate::project_context_cache::clear(); + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + let agents = workspace.path().join("AGENTS.md"); + fs::write(&agents, "alpha").expect("write alpha"); + + let first = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!( + first + .instructions + .as_deref() + .is_some_and(|s| s.contains("alpha")), + "expected alpha instructions: {:?}", + first.instructions + ); + + fs::write(&agents, "bravo").expect("write bravo"); + let second = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + + assert!( + second + .instructions + .as_deref() + .is_some_and(|s| s.contains("bravo")), + "cache must invalidate on same-length content overwrite: {:?}", + second.instructions + ); + } + + #[test] + fn cached_context_reflects_constitution_json_change() { + crate::project_context_cache::clear(); + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + fs::create_dir(workspace.path().join(".git")).expect("mkdir git"); + fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale"); + let constitution = workspace + .path() + .join(".codewhale") + .join("constitution.json"); + fs::write( + &constitution, + r#"{"schema_version":1,"authority":["alpha authority"]}"#, + ) + .expect("write alpha constitution"); + + let first = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!( + first + .constitution_block + .as_deref() + .is_some_and(|s| s.contains("alpha authority")), + "expected alpha constitution block: {:?}", + first.constitution_block + ); + + fs::write( + &constitution, + r#"{"schema_version":1,"authority":["bravo authority"]}"#, + ) + .expect("write bravo constitution"); + let second = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + + assert!( + second + .constitution_block + .as_deref() + .is_some_and(|s| s.contains("bravo authority")), + "cache must invalidate when constitution changes: {:?}", + second.constitution_block + ); + } + + #[test] + fn cached_context_regenerates_after_auto_generated_context_is_deleted() { + crate::project_context_cache::clear(); + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + + let first = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!(first.has_instructions()); + let generated_path = workspace.path().join(".codewhale").join("instructions.md"); + assert!(generated_path.is_file(), "expected generated instructions"); + + fs::remove_file(&generated_path).expect("remove generated instructions"); + assert!(!generated_path.exists()); + + let second = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!(second.has_instructions()); + assert!( + generated_path.is_file(), + "cache hit under the missing-file signature would skip regeneration" + ); + } + + #[test] + fn cached_context_reflects_trust_marker_created() { + crate::project_context_cache::clear(); + let workspace = tempdir().expect("workspace tempdir"); + let home = tempdir().expect("home tempdir"); + fs::write(workspace.path().join("AGENTS.md"), "instructions").expect("write agents"); + + let first = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!(!first.is_trusted); + + let trust_dir = workspace.path().join(".deepseek"); + fs::create_dir(&trust_dir).expect("mkdir trust dir"); + fs::write(trust_dir.join("trusted"), "").expect("write trust marker"); + + let second = + load_project_context_with_parents_cached_and_home(workspace.path(), Some(home.path())); + assert!( + second.is_trusted, + "cache must invalidate when trust marker appears" + ); + } + #[test] fn project_context_pack_sort_is_cross_platform_and_priority_aware() { let mut unix_paths = vec![ diff --git a/crates/tui/src/project_context_cache.rs b/crates/tui/src/project_context_cache.rs new file mode 100644 index 000000000..722c64cb3 --- /dev/null +++ b/crates/tui/src/project_context_cache.rs @@ -0,0 +1,220 @@ +//! Process-local cache for project context loading. +//! +//! The project-context loader sits on prompt/session hot paths and repeatedly +//! checks the same workspace, parent, global, constitution, and trust files. +//! This cache avoids rereading unchanged context while keeping the signature +//! broad enough for the loader's side effects and authority surfaces. + +use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::project_context::ProjectContext; + +const DEFAULT_CAPACITY: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct CacheKey { + workspace: PathBuf, + signature: ContentSignature, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +struct ContentSignature { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ContentEntry { + path: PathBuf, + fingerprint: Option, +} + +#[derive(Debug, Default)] +struct WorkspaceCache { + by_key: HashMap, + order: VecDeque, +} + +thread_local! { + static CACHE: RefCell = RefCell::new(WorkspaceCache::default()); +} + +pub(crate) fn lookup(key: &CacheKey) -> Option { + CACHE.with(|cache| cache.borrow().by_key.get(key).cloned()) +} + +pub(crate) fn store(key: CacheKey, value: ProjectContext) { + CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.by_key.insert(key.clone(), value).is_none() { + cache.order.push_back(key); + } + while cache.by_key.len() > DEFAULT_CAPACITY { + let Some(oldest) = cache.order.pop_front() else { + break; + }; + cache.by_key.remove(&oldest); + } + }); +} + +#[cfg(test)] +pub(crate) fn clear() { + CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + cache.by_key.clear(); + cache.order.clear(); + }); +} + +#[must_use] +pub(crate) fn compute_cache_key(workspace: &Path, home_dir: Option<&Path>) -> CacheKey { + let workspace = canonicalize_or_keep(workspace); + CacheKey { + signature: ContentSignature::for_loader(&workspace, home_dir), + workspace, + } +} + +impl ContentSignature { + fn for_loader(workspace: &Path, home_dir: Option<&Path>) -> Self { + let mut entries: Vec = + crate::project_context::project_context_cache_candidate_paths(workspace, home_dir) + .into_iter() + .map(|path| ContentEntry { + fingerprint: file_fingerprint(&path), + path, + }) + .collect(); + + entries.sort_by(|a, b| a.path.cmp(&b.path)); + entries.dedup_by(|a, b| a.path == b.path); + + Self { entries } + } +} + +fn file_fingerprint(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() { + return Some("non-file".to_string()); + } + + match std::fs::read(path) { + Ok(bytes) => { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("sha256:{}", to_hex(&hasher.finalize()))) + } + Err(error) => { + let modified = metadata + .modified() + .ok() + .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| format!("{}:{}", duration.as_secs(), duration.subsec_nanos())) + .unwrap_or_else(|| "unknown".to_string()); + Some(format!( + "unreadable:{}:{}:{error}", + metadata.len(), + modified + )) + } + } +} + +fn canonicalize_or_keep(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn to_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn cache_round_trip() { + clear(); + let key = CacheKey { + workspace: PathBuf::from("/tmp/context-cache-round-trip"), + signature: ContentSignature::default(), + }; + let ctx = ProjectContext::empty(PathBuf::from("/tmp/context-cache-round-trip")); + + store(key.clone(), ctx.clone()); + + let got = lookup(&key).expect("cache hit"); + assert_eq!(got.project_root, ctx.project_root); + } + + #[test] + fn store_does_not_grow_unbounded() { + clear(); + for i in 0..(DEFAULT_CAPACITY + 4) { + let key = CacheKey { + workspace: PathBuf::from(format!("/tmp/workspace-{i}")), + signature: ContentSignature::default(), + }; + store(key, ProjectContext::empty(PathBuf::from("/tmp"))); + } + + let count = CACHE.with(|cache| cache.borrow().by_key.len()); + assert!(count <= DEFAULT_CAPACITY, "cache held {count} entries"); + } + + #[test] + fn cache_key_canonicalizes_equivalent_workspace_paths() { + let workspace = tempdir().expect("workspace"); + let home = tempdir().expect("home"); + let plain = compute_cache_key(workspace.path(), Some(home.path())); + let dotted = compute_cache_key(&workspace.path().join("."), Some(home.path())); + + assert_eq!(plain, dotted); + } + + #[test] + fn signature_changes_when_agents_md_is_overwritten_same_length() { + let workspace = tempdir().expect("workspace"); + let home = tempdir().expect("home"); + fs::write(workspace.path().join("AGENTS.md"), "alpha").expect("write alpha"); + let before = compute_cache_key(workspace.path(), Some(home.path())); + + fs::write(workspace.path().join("AGENTS.md"), "bravo").expect("write bravo"); + let after = compute_cache_key(workspace.path(), Some(home.path())); + + assert_ne!(before, after); + } + + #[test] + fn signature_changes_when_constitution_json_changes() { + let workspace = tempdir().expect("workspace"); + let home = tempdir().expect("home"); + fs::create_dir(workspace.path().join(".git")).expect("mkdir git"); + fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale"); + let constitution = workspace + .path() + .join(".codewhale") + .join("constitution.json"); + fs::write(&constitution, r#"{"schema_version":1,"authority":["a"]}"#) + .expect("write constitution a"); + let before = compute_cache_key(workspace.path(), Some(home.path())); + + fs::write(&constitution, r#"{"schema_version":1,"authority":["b"]}"#) + .expect("write constitution b"); + let after = compute_cache_key(workspace.path(), Some(home.path())); + + assert_ne!(before, after); + } +} diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index af7d803de..52d50ca82 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -56,7 +56,7 @@ harvest/stewardship commits: | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | | #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | -| #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | +| #2636 project-context context-signature cache | Locally harvested with widened invalidation. | Project context hot-path loads now use a bounded process-local cache keyed by canonical workspace plus content fingerprints for workspace/parent instructions, global AGENTS/WHALE fallbacks, repo constitution candidates, generated-context targets, trust markers, and trust config paths. The wrapper stores under a post-load signature so auto-generated `.codewhale/instructions.md` deletion/regeneration stays correct. `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context -- --nocapture` passed. Credit @HUQIANTAO; comment/close #2636 after the integration branch is public. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `./scripts/release/check-ohos-deps.sh` now guards the OHOS graph against `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, and `keyring`; `cargo check --workspace --all-features --locked` and focused PTY/clipboard tests passed. Full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | | #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | From f5e6d468481bc0ce310a62f23e23f085450e4971 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:52:31 -0700 Subject: [PATCH 056/209] docs: add agent stewardship guidance --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a265d8186 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Repository Agent Guidance + +## CodeWhale Stewardship + +- Treat community contributors as partners. Good-faith PRs, issue reports, + repros, logs, reviews, and verification comments are maintainer evidence, + not queue noise. +- Keep gates warm and dry-run unless Hunter explicitly approves enforcement. + Gate copy should guide contributors clearly and respectfully. +- Credit every harvested PR, issue report, or comment that materially shaped a + fix. Preserve authorship when possible; otherwise use mappable GitHub + noreply `Co-authored-by` trailers from `.github/AUTHOR_MAP`. +- Do not tag, publish, create a GitHub Release, or push release artifacts + without Hunter approval. +- Use CodeWhale branding while keeping DeepSeek support first-class. Retiring + legacy `deepseek-tui` names must never read as deprecating DeepSeek models or + provider support. +- Review PRs from code, tests, linked issues, comments, and check results. + Never merge, close, harvest, or defer community work from title or labels + alone. +- Respect concurrent work in the tree. Do not revert or rewrite unrelated + edits by other people or agents. From 13cabac077c9ca4b429cf5232302b3eac9a19ce6 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 23:56:40 -0700 Subject: [PATCH 057/209] docs(config): clarify provider path suffix support Records that #2506/#2508 are superseded by the safer #2558 path_suffix implementation, credits the original #1874 report and follow-up PR review trail, and documents that suffix overrides only affect chat completions while model and beta paths keep built-in routing. --- CHANGELOG.md | 16 +++++++++++----- README.md | 5 +++++ crates/tui/CHANGELOG.md | 16 +++++++++++----- docs/CONFIGURATION.md | 14 ++++++++++++++ docs/V0_9_0_EXECUTION_MAP.md | 4 ++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cd94306..8a1cd1a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 generated-context targets, trust markers, and trust config paths, and it stores post-load signatures so auto-generated context deletion/regeneration stays correct (#2636). +- Configuration docs now show the provider-local `path_suffix` escape hatch + for OpenAI-compatible gateways that accept `/chat/completions` but reject + `/v1/chat/completions`, while making clear that model listing and DeepSeek + beta routes keep their built-in paths (#1874). ### Community @@ -122,11 +126,13 @@ dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), -to **@bevis-wong** for the long-running shell/task liveness report (#1786), -and to **@NASLXTO** and -**@wuxixing** for the large-workspace startup reports (#697, #1827), and to -**@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and -startup-diagnosis work that shaped this bounded fallback. +**@bevis-wong** for the long-running shell/task liveness report (#1786), +**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report +(#1874), **@hongqitai** and **@cyq1017** for the follow-up path-suffix PR +review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the +large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and +**@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that +shaped this bounded fallback. ## [0.8.53] - 2026-06-03 diff --git a/README.md b/README.md index a8edb2fbd..7254de1c0 100644 --- a/README.md +++ b/README.md @@ -638,6 +638,11 @@ Current v0.9 track credits: - **[NASLXTO](https://github.com/NASLXTO)** and **[wuxixing](https://github.com/wuxixing)** — large-workspace startup reports that shaped the bounded project-context fallback (#697, #1827) +- **[shuxiangxuebiancheng](https://github.com/shuxiangxuebiancheng)**, + **[hongqitai](https://github.com/hongqitai)**, and + **[cyq1017](https://github.com/cyq1017)** — third-party + OpenAI-compatible path-suffix report and follow-up review trail (#1874, + #2508, #2506) Current and recurring contributors include: diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 22cd94306..8a1cd1a82 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -103,6 +103,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 generated-context targets, trust markers, and trust config paths, and it stores post-load signatures so auto-generated context deletion/regeneration stays correct (#2636). +- Configuration docs now show the provider-local `path_suffix` escape hatch + for OpenAI-compatible gateways that accept `/chat/completions` but reject + `/v1/chat/completions`, while making clear that model listing and DeepSeek + beta routes keep their built-in paths (#1874). ### Community @@ -122,11 +126,13 @@ dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), -to **@bevis-wong** for the long-running shell/task liveness report (#1786), -and to **@NASLXTO** and -**@wuxixing** for the large-workspace startup reports (#697, #1827), and to -**@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and -startup-diagnosis work that shaped this bounded fallback. +**@bevis-wong** for the long-running shell/task liveness report (#1786), +**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report +(#1874), **@hongqitai** and **@cyq1017** for the follow-up path-suffix PR +review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the +large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and +**@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that +shaped this bounded fallback. ## [0.8.53] - 2026-06-03 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ea75ae62a..72ba9ca80 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -209,6 +209,19 @@ legacy top-level `base_url`, so the OpenAI-compatible provider receives it. provider tables in one config, `[providers.openai].model` can be used as the OpenAI-provider-specific override. +If the gateway accepts `POST /chat/completions` but rejects +`/v1/chat/completions`, set a provider-local `path_suffix`: + +```toml +[providers.openai] +base_url = "https://your-gateway.example/v1" +path_suffix = "/chat/completions" +``` + +The suffix applies only to chat-completion requests. Model listing and +DeepSeek beta paths keep their built-in routing so a generic gateway override +does not accidentally rewrite `/models` or `/beta/completions`. + Local HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default when they use localhost or loopback addresses. For a non-local `http://` gateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network: @@ -744,6 +757,7 @@ If you are upgrading from older releases: - `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint while sharing `[providers.siliconflow]`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled. diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 52d50ca82..e0b331c66 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -107,9 +107,9 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | | #2502 web_run RwLock split | Mergeable | Manually harvested with panic-safety and shared cached-page reads; close/comment after branch is public. | | #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | -| #2506 provider path suffix overrides | Draft/conflicting | Partly superseded by current provider path-suffix support; verify. | +| #2506 provider path suffix overrides | Draft/conflicting / superseded | The current branch already contains provider-table `path_suffix` support from #2558 with the safer constrained behavior: only `chat/completions` uses the override, while `models` and DeepSeek `beta/*` keep their built-in routing. `cargo test -p codewhale-tui --bin codewhale-tui --locked api_url_with_suffix -- --nocapture` passed. Credit @cyq1017 for the earlier design/review trail; comment/close after branch is public, keeping #1874 tied to the shipped #2558 implementation/docs. | | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | -| #2508 configurable path suffix | Conflicting | Likely superseded by #2506/current code; verify linked issue #2089. | +| #2508 configurable path suffix | Conflicting / superseded | #2089 is already closed. The current implementation covers #1874's third-party gateway need without the broader env/CLI surface from #2508. Docs now show `[providers.openai].path_suffix = "/chat/completions"` and state that model/beta paths are not rewritten. Credit @hongqitai for the follow-up PR and @shuxiangxuebiancheng for the original #1874 report; close/comment after branch is public. | | #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. | | #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | From 586640a4379bddfdef00946e0dd9cc7953ad31d4 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 00:00:22 -0700 Subject: [PATCH 058/209] feat(config): add typed harness posture profiles Harvested from PR #2741 by @idling11 for #2693, with review fixes folded in: typed compaction/tool/safety enums, no silent unknown-kind fallback, unknown profile keys rejected, and whole-struct equality for future reload/runtime checks. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 12 +- README.md | 6 +- crates/config/src/lib.rs | 267 +++++++++++++++++++++++++++++++++++ crates/tui/CHANGELOG.md | 12 +- docs/CONFIGURATION.md | 24 ++++ docs/V0_9_0_EXECUTION_MAP.md | 5 +- 6 files changed, 316 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1cd1a82..6b76460c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for OpenAI-compatible gateways that accept `/chat/completions` but reject `/v1/chat/completions`, while making clear that model listing and DeepSeek beta routes keep their built-in paths (#1874). +- The config crate now carries the v0.9 HarnessPosture data model: + `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety + enums. The schema rejects misspelled posture names or unknown profile keys + instead of silently falling back to `custom`; runtime provider/model posture + selection remains a follow-up (#2693, #2741). ### Community @@ -120,9 +125,10 @@ settings-path migration work (#2730), **@gaord** for the runtime thread workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), -**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the -dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, -#2692, #2694), and +**@idling11** for the PlanArtifact direction in Plan mode (#2733), the dense +tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, +#2694), and the HarnessPosture config model for provider/model posture (#2741, +#2693), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), diff --git a/README.md b/README.md index 7254de1c0..2c2e2578f 100644 --- a/README.md +++ b/README.md @@ -626,8 +626,10 @@ Current v0.9 track credits: - **[HUQIANTAO](https://github.com/HUQIANTAO)** — `web_run` cache-state lock-splitting, turn-metadata prefix-cache stability, and project-context cache work (#2502, #2517, #2636) -- **[idling11](https://github.com/idling11)** — PlanArtifact continuity and - dense tool-call transcript collapse direction (#2733, #2738, #2692) +- **[idling11](https://github.com/idling11)** — PlanArtifact continuity, + dense tool-call transcript collapse, sidebar detail popovers, and + HarnessPosture provider/model policy direction (#2733, #2738, #2734, + #2741, #2692, #2694, #2693) - **[h3c-hexin](https://github.com/h3c-hexin)** — sub-agent model inheritance, configured `skills_dir` discovery, and prompt-environment stability work (#2736, #2737) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 7135300d0..96a6b95dd 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -306,6 +306,138 @@ impl ProvidersToml { } } +/// Kinds of built-in harness postures. +/// +/// A posture names the runtime strategy CodeWhale should use for a +/// provider/model route: how much context to preload, how aggressively to lean +/// on sub-agents, and how to balance prompt-cache stability against quick +/// exploration. Runtime selection is wired in later v0.9 slices; this config +/// model intentionally keeps the policy data explicit first. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessPostureKind { + /// Full-featured default: rich constitution, broad tool catalog, and normal + /// sub-agent posture. + #[default] + Standard, + /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context. + CacheHeavy, + /// Lean: smaller starting context, faster compaction, and stronger + /// exploration/delegation bias. + Lean, + /// User-defined posture assembled from explicit knobs below. + Custom, +} + +/// How this posture should approach compaction and prompt-cache stability. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessCompactionStrategy { + #[default] + Default, + PrefixCache, + Aggressive, +} + +/// Which tool catalog shape this posture prefers. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessToolSurface { + #[default] + Full, + ReadOnly, + Auto, +} + +/// Safety posture applied when the runtime consumes a harness profile. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HarnessSafetyPosture { + #[default] + Standard, + Strict, + Permissive, +} + +/// A concrete harness posture with policy knobs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HarnessPosture { + /// Named posture kind. + #[serde(default)] + pub kind: HarnessPostureKind, + /// Maximum number of concurrent sub-agents (0 = runtime default). + #[serde(default)] + pub max_subagents: usize, + /// Prefer search-based/on-demand context over always-on documentation. + #[serde(default)] + pub prefer_codebase_search: bool, + /// Compaction and prompt-cache strategy. + #[serde(default)] + pub compaction_strategy: HarnessCompactionStrategy, + /// Preferred tool catalog shape. + #[serde(default)] + pub tool_surface: HarnessToolSurface, + /// Safety posture for runtime consumers. + #[serde(default)] + pub safety_posture: HarnessSafetyPosture, +} + +impl Default for HarnessPosture { + fn default() -> Self { + Self { + kind: HarnessPostureKind::Standard, + max_subagents: 0, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::default(), + tool_surface: HarnessToolSurface::default(), + safety_posture: HarnessSafetyPosture::default(), + } + } +} + +impl HarnessPosture { + /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models. + #[must_use] + pub fn cache_heavy() -> Self { + Self { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + } + + /// A lean posture for smaller-context or weaker tool-use models. + #[must_use] + pub fn lean() -> Self { + Self { + kind: HarnessPostureKind::Lean, + max_subagents: 20, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Aggressive, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + } +} + +/// A harness profile binds a posture to a provider route and model pattern. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HarnessProfile { + /// Provider route this profile applies to, e.g. "deepseek" or + /// "xiaomi-mimo". + pub provider_route: String, + /// Regex or glob pattern for model names, e.g. "deepseek-v4.*". + pub model_pattern: String, + /// The posture to apply. + #[serde(default)] + pub posture: HarnessPosture, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigToml { /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek` @@ -349,6 +481,10 @@ pub struct ConfigToml { /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] pub lsp: Option, + /// Per-model harness profiles (#2693). Runtime wiring lands in follow-up + /// v0.9 slices; this is the durable config data model. + #[serde(default)] + pub harness_profiles: Vec, /// App-server hook sink configuration. Kept separate from the TUI /// lifecycle `[hooks]` table so config rewrites preserve existing hooks. #[serde(default)] @@ -5087,4 +5223,135 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.api_key.as_deref(), Some("cli-key")); assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); } + + #[test] + fn harness_posture_default_is_standard() { + let posture = HarnessPosture::default(); + + assert_eq!( + posture, + HarnessPosture { + kind: HarnessPostureKind::Standard, + max_subagents: 0, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::Default, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + } + + #[test] + fn harness_posture_factories_are_typed() { + assert_eq!( + HarnessPosture::cache_heavy(), + HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + assert_eq!( + HarnessPosture::lean(), + HarnessPosture { + kind: HarnessPostureKind::Lean, + max_subagents: 20, + prefer_codebase_search: true, + compaction_strategy: HarnessCompactionStrategy::Aggressive, + tool_surface: HarnessToolSurface::Full, + safety_posture: HarnessSafetyPosture::Standard, + } + ); + } + + #[test] + fn harness_profile_serde_round_trips_as_a_whole_struct() { + let profile = HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + let json = serde_json::to_string(&profile).expect("serialize profile"); + let round_tripped: HarnessProfile = + serde_json::from_str(&json).expect("deserialize profile"); + + assert_eq!(round_tripped, profile); + } + + #[test] + fn config_toml_accepts_harness_profiles() { + let config: ConfigToml = toml::from_str( + r#" +provider = "deepseek" +model = "deepseek-v4-pro" + +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cache-heavy" +max_subagents = 10 +compaction_strategy = "prefix-cache" +tool_surface = "read-only" +safety_posture = "strict" +"#, + ) + .expect("parse harness profiles"); + + assert_eq!( + config.harness_profiles, + vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4.*".to_string(), + posture: HarnessPosture { + kind: HarnessPostureKind::CacheHeavy, + max_subagents: 10, + prefer_codebase_search: false, + compaction_strategy: HarnessCompactionStrategy::PrefixCache, + tool_surface: HarnessToolSurface::ReadOnly, + safety_posture: HarnessSafetyPosture::Strict, + }, + }] + ); + } + + #[test] + fn harness_posture_kind_rejects_unknown_values() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cahce-heavy" +"#, + ) + .expect_err("misspelled kind should not deserialize as custom"); + + assert!(err.to_string().contains("cahce-heavy")); + } + + #[test] + fn harness_posture_rejects_unknown_policy_keys() { + let err = toml::from_str::( + r#" +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "custom" +unknown_policy = "surprise" +"#, + ) + .expect_err("unknown posture keys should not be ignored"); + + assert!(err.to_string().contains("unknown_policy")); + } } diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 8a1cd1a82..6b76460c3 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -107,6 +107,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for OpenAI-compatible gateways that accept `/chat/completions` but reject `/v1/chat/completions`, while making clear that model listing and DeepSeek beta routes keep their built-in paths (#1874). +- The config crate now carries the v0.9 HarnessPosture data model: + `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety + enums. The schema rejects misspelled posture names or unknown profile keys + instead of silently falling back to `custom`; runtime provider/model posture + selection remains a follow-up (#2693, #2741). ### Community @@ -120,9 +125,10 @@ settings-path migration work (#2730), **@gaord** for the runtime thread workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), -**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the -dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, -#2692, #2694), and +**@idling11** for the PlanArtifact direction in Plan mode (#2733), the dense +tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, +#2694), and the HarnessPosture config model for provider/model posture (#2741, +#2693), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 72ba9ca80..31004ce21 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -365,6 +365,30 @@ Select a profile with: If a profile is selected but missing, codewhale exits with an error listing available profiles. +## Harness Profiles + +v0.9 adds a config data model for model-specific harness posture. This is a +preview schema: it can be parsed and tested, but runtime provider/model +selection and prompt/tool behavior are wired in later v0.9 slices. + +```toml +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4.*" + +[harness_profiles.posture] +kind = "cache-heavy" # standard | cache-heavy | lean | custom +max_subagents = 10 # 0 means runtime default +prefer_codebase_search = false +compaction_strategy = "prefix-cache" # default | prefix-cache | aggressive +tool_surface = "full" # full | read-only | auto +safety_posture = "standard" # standard | strict | permissive +``` + +Unknown posture names or unknown keys inside a harness profile fail config +deserialization instead of silently becoming `custom`. That is intentional: +once runtime wiring consumes these profiles, a typo should be visible. + ## Environment Variables Most runtime environment variables override config values. API-key variables are diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index e0b331c66..3ee659148 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -9,8 +9,8 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts - Actual open issues: 446 -- Open PRs: 57 -- Repo API open issue count: 503, because GitHub includes PRs in that total +- Open PRs: 59 +- Repo API open issue count: 505, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 - Open issues without a milestone: 102 @@ -47,6 +47,7 @@ harvest/stewardship commits: | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2639 POST /v1/sessions endpoint | Locally harvested with the unsafe active-turn snapshot fixed. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord; comment/close the original after the integration branch is public. | | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2741 HarnessPosture data model | Locally harvested with stricter config validation. | Adds typed `HarnessPostureKind`, compaction/tool/safety enums, `HarnessPosture`, `HarnessProfile`, and `ConfigToml.harness_profiles` as the durable v0.9 config model for #2693. The harvest removes the PR's silent unknown-kind catch-all, rejects unknown posture/profile keys, derives whole-struct equality, and keeps runtime wiring as an explicit follow-up. `cargo test -p codewhale-config --locked harness_posture -- --nocapture`, `cargo test -p codewhale-config --locked harness_profile -- --nocapture`, `cargo test -p codewhale-config --locked config_toml_accepts_harness_profiles -- --nocapture`, and `cargo clippy -p codewhale-config --locked -- -D warnings` passed. Credit @idling11; close/comment after the integration branch is public. Keep #2693 open for provider/model selection, prompt/tool/runtime behavior, telemetry, and docs once wiring lands. | | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | From 0d66ef34d1b40100a6f9327e67ab03cccec8bce8 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 00:09:01 -0700 Subject: [PATCH 059/209] feat(hooks): add turn_end observer hook Harvested the narrow Rust/docs slice of PR #2578 by @AresNing for #1364. The event uses the maintained structured observer path: JSON stdin, stdout ignored, warn-only failures, and no ability to block or mutate the turn. The hook fires after post-turn app state, usage totals, cost, notification, receipt, and queue-recovery state are updated, before queued follow-up dispatch. Docs, RFC notes, /hooks discovery, and v0.9 tracking now describe the observer-only contract. Co-authored-by: AresNing <49557311+AresNing@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 2 +- crates/tui/CHANGELOG.md | 4 + crates/tui/src/commands/hooks.rs | 7 ++ crates/tui/src/hooks.rs | 192 ++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 41 ++++++- docs/CONFIGURATION.md | 55 +++++++++ docs/V0_9_0_EXECUTION_MAP.md | 2 +- docs/rfcs/1364-hooks-lifecycle.md | 11 +- 9 files changed, 313 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b76460c3..7ec610e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/README.md b/README.md index 2c2e2578f..2bfcf1557 100644 --- a/README.md +++ b/README.md @@ -720,7 +720,7 @@ Current and recurring contributors include: - **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460) - **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356) -- **[AresNing](https://github.com/AresNing)** — first-run guide and message-submit hook transform design harvested into the maintained hooks path (#2278, #2318, #2434) +- **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578) - **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439) - **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430) - **[AdityaVG13](https://github.com/AdityaVG13)** — Xiaomi MiMo provider support (#2246) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6b76460c3..7ec610e17 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `.github/AUTHOR_MAP` and a CI co-author credit check so harvested commits use GitHub-mappable numeric noreply identities instead of `.local`, placeholder, bot/tool, or raw third-party emails. +- Added a `turn_end` observer hook that fires after post-turn TUI state and + token totals are updated. Hooks receive structured JSON with status, usage, + totals, duration, tool count, and queued-message count on stdin; stdout is + ignored and failures are warn-only (#1364, #2578). - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index e837e477c..d01a52ca4 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -43,6 +43,10 @@ fn events() -> CommandResult { let ordered = [ (HookEvent::SessionStart, "fires once when the TUI launches"), (HookEvent::SessionEnd, "fires once on graceful shutdown"), + ( + HookEvent::TurnEnd, + "fires after a turn completes (observer-only)", + ), ( HookEvent::MessageSubmit, "fires before model dispatch; can transform or block submitted text", @@ -146,6 +150,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -266,6 +271,7 @@ mod tests { let positions: Vec<(usize, &str)> = [ "session_start", "session_end", + "turn_end", "message_submit", "tool_call_before", "tool_call_after", @@ -310,6 +316,7 @@ mod tests { assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); + assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); assert_eq!(event_label(HookEvent::SubagentSpawn), "subagent_spawn"); assert_eq!( event_label(HookEvent::SubagentComplete), diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index a528bc1ad..58fe4f1d0 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -7,6 +7,7 @@ //! - Mode changes //! - Message submission //! - Error events +//! - Turn completion //! //! Configuration is done via `[[hooks.hooks]]` in config.toml. @@ -41,6 +42,8 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered after a turn completes and post-turn state has been updated + TurnEnd, /// Triggered when a sub-agent is spawned SubagentSpawn, /// Triggered when a sub-agent reaches a terminal state @@ -66,6 +69,7 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::TurnEnd => "turn_end", HookEvent::SubagentSpawn => "subagent_spawn", HookEvent::SubagentComplete => "subagent_complete", HookEvent::ShellEnv => "shell_env", @@ -480,6 +484,28 @@ enum MessageSubmitStdout { Invalid(String), } +/// Post-turn accumulated totals included in the `turn_end` observer payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TurnEndTotals { + pub session_tokens: u32, + pub conversation_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Input used to build the structured `turn_end` observer payload. +pub struct TurnEndPayloadInput<'a> { + pub context: &'a HookContext, + pub turn_id: Option<&'a str>, + pub status: &'a str, + pub error: Option<&'a str>, + pub duration: Duration, + pub usage: &'a crate::models::Usage, + pub totals: TurnEndTotals, + pub tool_count: usize, + pub queued_message_count: usize, +} + /// Executor for running hooks #[derive(Debug, Clone)] pub struct HookExecutor { @@ -1121,6 +1147,41 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu }) } +pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { + json!({ + "event": HookEvent::TurnEnd.as_str(), + "session_id": input.context.session_id.as_deref(), + "workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()), + "mode": input.context.mode.as_deref(), + "model": input.context.model.as_deref(), + "turn_id": input.turn_id, + "status": input.status, + "error": input.error, + "duration_ms": duration_ms_saturating(input.duration), + "usage": { + "input_tokens": input.usage.input_tokens, + "output_tokens": input.usage.output_tokens, + "prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens, + "reasoning_tokens": input.usage.reasoning_tokens, + "reasoning_replay_tokens": input.usage.reasoning_replay_tokens, + }, + "totals": { + "session_tokens": input.totals.session_tokens, + "conversation_tokens": input.totals.conversation_tokens, + "input_tokens": input.totals.input_tokens, + "output_tokens": input.totals.output_tokens, + }, + "tool_count": input.tool_count, + "queued_message_count": input.queued_message_count, + "stop_hook_active": false, + }) +} + +fn duration_ms_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout { let trimmed = stdout.trim(); if trimmed.is_empty() { @@ -1343,10 +1404,70 @@ NOEQUAL line dropped assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); + assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end"); assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn"); assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete"); } + #[test] + fn turn_end_payload_contains_post_turn_observer_fields() { + let context = HookContext::new() + .with_session_id("sess_test") + .with_workspace(PathBuf::from("/tmp/codewhale")) + .with_mode("agent") + .with_model("deepseek-v4") + .with_tokens(125); + let usage = crate::models::Usage { + input_tokens: 40, + output_tokens: 9, + prompt_cache_hit_tokens: Some(10), + prompt_cache_miss_tokens: Some(30), + reasoning_tokens: Some(4), + reasoning_replay_tokens: Some(2), + server_tool_use: None, + }; + + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_123"), + status: "completed", + error: None, + duration: Duration::from_millis(321), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 125, + conversation_tokens: 100, + input_tokens: 100, + output_tokens: 25, + }, + tool_count: 2, + queued_message_count: 1, + }); + + assert_eq!(payload["event"], "turn_end"); + assert_eq!(payload["session_id"], "sess_test"); + assert_eq!(payload["workspace"], "/tmp/codewhale"); + assert_eq!(payload["mode"], "agent"); + assert_eq!(payload["model"], "deepseek-v4"); + assert_eq!(payload["turn_id"], "turn_123"); + assert_eq!(payload["status"], "completed"); + assert_eq!(payload["error"], serde_json::Value::Null); + assert_eq!(payload["duration_ms"], 321); + assert_eq!(payload["usage"]["input_tokens"], 40); + assert_eq!(payload["usage"]["output_tokens"], 9); + assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10); + assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30); + assert_eq!(payload["usage"]["reasoning_tokens"], 4); + assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2); + assert_eq!(payload["totals"]["session_tokens"], 125); + assert_eq!(payload["totals"]["conversation_tokens"], 100); + assert_eq!(payload["totals"]["input_tokens"], 100); + assert_eq!(payload["totals"]["output_tokens"], 25); + assert_eq!(payload["tool_count"], 2); + assert_eq!(payload["queued_message_count"], 1); + assert_eq!(payload["stop_hook_active"], false); + } + #[test] fn test_hook_context_to_env_vars() { let ctx = HookContext::new() @@ -1578,6 +1699,76 @@ cat > "{}" assert_eq!(captured["prompt_truncated"], false); } + #[cfg(not(windows))] + #[test] + fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("turn_end.json"); + let command = write_hook_script( + &dir, + "capture_turn_end.sh", + &format!( + r#"#!/bin/sh +cat > "{}" +printf '%s\n' '{{"text":"stdout is not a mutation contract"}}' +"#, + out.display() + ), + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::TurnEnd, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let usage = crate::models::Usage { + input_tokens: 12, + output_tokens: 3, + prompt_cache_hit_tokens: None, + prompt_cache_miss_tokens: None, + reasoning_tokens: None, + reasoning_replay_tokens: None, + server_tool_use: None, + }; + let context = submit_context(&dir).with_tokens(15); + let payload = super::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_observed"), + status: "completed", + error: None, + duration: Duration::from_millis(7), + usage: &usage, + totals: TurnEndTotals { + session_tokens: 15, + conversation_tokens: 15, + input_tokens: 12, + output_tokens: 3, + }, + tool_count: 0, + queued_message_count: 0, + }); + + let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload); + + assert_eq!(results.len(), 1); + assert!(results[0].success); + assert!( + results[0] + .stdout + .contains("stdout is not a mutation contract"), + "stdout is still captured for diagnostics" + ); + let captured: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(out).expect("payload written")) + .expect("valid JSON payload"); + assert_eq!(captured["event"], "turn_end"); + assert_eq!(captured["turn_id"], "turn_observed"); + assert_eq!(captured["totals"]["input_tokens"], 12); + assert_eq!(captured["totals"]["output_tokens"], 3); + } + #[cfg(not(windows))] #[test] fn json_observer_hook_failure_does_not_stop_later_hooks() { @@ -1912,6 +2103,7 @@ exit 7 HookEvent::ToolCallAfter, HookEvent::ModeChange, HookEvent::OnError, + HookEvent::TurnEnd, HookEvent::SubagentSpawn, HookEvent::SubagentComplete, ] { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a810b6711..03d2ee816 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -49,7 +49,7 @@ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEve use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; -use crate::hooks::{HookEvent, HookExecutor}; +use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals}; use crate::llm_client::LlmClient; use crate::localization::{MessageId, tr}; use crate::models::{ @@ -699,6 +699,41 @@ fn execute_subagent_observer_hook( }); } +fn execute_turn_end_observer_hook( + app: &App, + usage: &Usage, + duration: Duration, + error: Option<&str>, +) { + if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { + return; + } + + let context = app.base_hook_context(); + let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: app.runtime_turn_id.as_deref(), + status: app.runtime_turn_status.as_deref().unwrap_or("unknown"), + error, + duration, + usage, + totals: TurnEndTotals { + session_tokens: app.session.total_tokens, + conversation_tokens: app.session.total_conversation_tokens, + input_tokens: app.session.total_input_tokens, + output_tokens: app.session.total_output_tokens, + }, + tool_count: app.tool_evidence.len(), + queued_message_count: app.queued_message_count(), + }); + let hooks = app.hooks.clone(); + let _ = std::thread::Builder::new() + .name("turn_end-observer-hook".to_string()) + .spawn(move || { + let _ = hooks.execute_json_observer(HookEvent::TurnEnd, &context, &payload); + }); +} + fn bounded_subagent_hook_preview(text: &str) -> (String, bool) { if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT { return (text.to_string(), false); @@ -1769,7 +1804,7 @@ async fn run_event_loop( reasoning_replay_tokens: usage.reasoning_replay_tokens, recorded_at: Instant::now(), }); - if let Some(error) = error { + if let Some(error) = error.as_deref() { // Only show "Turn failed:" in the composer status // area when an EngineEvent::Error has NOT already // posted the same message into the transcript. @@ -1940,6 +1975,8 @@ async fn run_event_loop( } } + execute_turn_end_observer_hook(app, &usage, turn_elapsed, error.as_deref()); + if queued_to_send.is_none() { queued_to_send = app.pop_queued_message(); } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 31004ce21..347054d37 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -592,6 +592,61 @@ the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; the JSON stdout contract applies only to `message_submit`. +### Turn-end observer hooks + +`turn_end` hooks observe the end of each model turn after post-turn +state, usage totals, cost accounting, notifications, receipts, and +queue recovery have been updated. They receive JSON on stdin and are +observer-only: stdout is ignored, failures are logged as warnings, and +the hook cannot block user input, mutate the transcript, or change the +next queued follow-up. + +```toml +[[hooks.hooks]] +event = "turn_end" +command = "~/.codewhale/hooks/turn-audit.sh" +timeout_secs = 2 +continue_on_error = true +``` + +The payload includes common hook metadata plus post-turn accounting: + +```json +{ + "event": "turn_end", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "turn_id": "turn_12345678", + "status": "completed", + "error": null, + "duration_ms": 1834, + "usage": { + "input_tokens": 1200, + "output_tokens": 180, + "prompt_cache_hit_tokens": 900, + "prompt_cache_miss_tokens": 300, + "reasoning_tokens": null, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1380, + "conversation_tokens": 1380, + "input_tokens": 1200, + "output_tokens": 180 + }, + "tool_count": 2, + "queued_message_count": 1, + "stop_hook_active": false +} +``` + +For `interrupted` or `failed` turns, `status` reflects that terminal +state and `error` carries the engine error string when one is available. +`stop_hook_active` is reserved for future re-entry protection and is +currently always `false`. + ### Sub-agent lifecycle hooks `subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 3ee659148..d299d59e9 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -124,7 +124,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | | #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. | | #2576 PrefixCacheChange events | Mergeable | Already present locally through `29acb87a9d`; close/comment after branch is public or merged. | -| #2578 turn_end observer hook | Conflicting | Defer to hook lifecycle lane. | +| #2578 turn_end observer hook | Conflicting / locally harvested | Narrow Rust/docs slice landed in the hook lifecycle lane: `turn_end` now uses the existing structured observer path, fires after post-turn state updates and before queued follow-up dispatch, and includes status, usage, totals, duration, tool count, and queued-message count. Close/comment after branch is public, crediting @AresNing and #1364 reporter @esinecan. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | | #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. | | #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index f7f759c11..6256f13d6 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -64,6 +64,13 @@ Non-goals: - no blocking of user input - no transcript mutation from `turn_end` +Implementation note for the v0.9 branch: the narrow #2578 harvest uses the +shared structured observer path introduced for sub-agent lifecycle hooks. It +fires before queued follow-up dispatch, after queue-recovery state is known, so +the payload can report the queued-message count without letting a hook change +what gets sent next. Stdout is ignored for `turn_end`; only `message_submit` +has a stdout mutation contract. + ### PR 3: Subagent lifecycle observer hooks Expose subagent start and completion as observer-only hook events. @@ -251,7 +258,9 @@ transcript content in the first version. - Existing observer-only hooks keep working. - Existing env vars remain available. - `shell_env` keeps its existing stdout `KEY=VALUE` contract. -- Structured stdout is interpreted only by `message_submit` in PR 1. +- Structured stdout is interpreted only by `message_submit` in PR 1. Structured + observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete` + receive JSON on stdin, but their stdout is ignored by the caller. ## 6. Review checkpoints From 56f8044cf319ee3c2bd8872b008aec675a2cc305 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 00:17:39 -0700 Subject: [PATCH 060/209] feat(tui): show focused approval details Harvested the narrow approval-detail and shell-preview slice from #1991/#2269 by @tdccccc. Approval cards now show prominent command, dir, file, path, or target rows before falling back to raw params, and shell approvals preserve long command tails while splitting common shell chains for review. The maintained path keeps the existing #2381 intent-summary block visible and does not take the broader diff-preview/pager rewrite from #2269. Live shell companion tools are classified as shell so their approval cards use the same review posture. Co-authored-by: tdccccc <79492752+tdccccc@users.noreply.github.com> --- CHANGELOG.md | 9 +- README.md | 1 + crates/tui/CHANGELOG.md | 9 +- crates/tui/src/tui/approval.rs | 383 +++++++++++++++++++++++++++++- crates/tui/src/tui/widgets/mod.rs | 140 +++++++++-- docs/V0_9_0_EXECUTION_MAP.md | 2 +- 6 files changed, 525 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec610e17..c3907c4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Esc` while editing a queued follow-up restores the original queued message instead of cancelling the active turn or silently dropping the queued work (#2054). +- Approval prompts now render prominent command, directory, file, path, or + target rows before falling back to raw JSON params. Shell approvals preserve + long command tails, split common shell chains for review, and show compact + `printf > file` previews while keeping intent summaries visible (#1991, + #2269). - Sidebar hover details now use row-level metadata for truncated Work, Tasks, and Agents rows. Mouse hover opens a bordered, wrapping popover with the full underlying row text, long turn/agent ids, and current sub-agent progress @@ -134,7 +139,9 @@ tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, #2694), and the HarnessPosture config model for provider/model posture (#2741, #2693), and **@h3c-hexin** for the tool-agent model inheritance and configured -`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the +`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook +work (#2578), and **@tdccccc** for the approval key-detail and shell-preview +work (#1991, #2269). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), **@bevis-wong** for the long-running shell/task liveness report (#1786), **@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report diff --git a/README.md b/README.md index 2bfcf1557..c633420b2 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,7 @@ Current and recurring contributors include: - **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460) - **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356) +- **[tdccccc](https://github.com/tdccccc)** — approval prompt key-detail and shell-preview work harvested into the maintained approval path (#1991, #2269) - **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578) - **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439) - **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 7ec610e17..c3907c4cb 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Esc` while editing a queued follow-up restores the original queued message instead of cancelling the active turn or silently dropping the queued work (#2054). +- Approval prompts now render prominent command, directory, file, path, or + target rows before falling back to raw JSON params. Shell approvals preserve + long command tails, split common shell chains for review, and show compact + `printf > file` previews while keeping intent summaries visible (#1991, + #2269). - Sidebar hover details now use row-level metadata for truncated Work, Tasks, and Agents rows. Mouse hover opens a bordered, wrapping popover with the full underlying row text, long turn/agent ids, and current sub-agent progress @@ -134,7 +139,9 @@ tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692, #2694), and the HarnessPosture config model for provider/model posture (#2741, #2693), and **@h3c-hexin** for the tool-agent model inheritance and configured -`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the +`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook +work (#2578), and **@tdccccc** for the approval key-detail and shell-preview +work (#1991, #2269). Thanks also to **@qiyuanlicn** for the checkpoint/resume report that shaped the sub-agent recovery slice (#2029), **@bevis-wong** for the long-running shell/task liveness report (#1786), **@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index a8abe8390..361cb751c 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -140,6 +140,16 @@ pub struct ApprovalRequest { pub intent_summary: Option, } +/// Key approval details rendered prominently in the approval card. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalDetail { + pub label: String, + pub value: String, + /// Preformatted shell lines for commands that benefit from safe wrapping + /// or a compact write-file preview. `value` remains the original command. + pub shell_lines: Option>, +} + impl ApprovalRequest { #[cfg(test)] pub fn new( @@ -207,6 +217,18 @@ impl ApprovalRequest { _ => self.impacts.clone(), } } + + /// Extract the most important params for the approval card. + #[must_use] + pub fn prominent_detail_items(&self, locale: Locale) -> Vec { + build_prominent_details(self.category, &self.params) + .into_iter() + .map(|mut detail| { + detail.label = localize_detail_label(&detail.label, locale).to_string(); + detail + }) + .collect() + } } /// Get the category for a tool by name @@ -215,7 +237,16 @@ pub fn get_tool_category(name: &str) -> ToolCategory { ToolCategory::FileWrite } else if matches!(name, "web_run" | "web_search" | "fetch_url") { ToolCategory::Network - } else if name == "exec_shell" { + } else if matches!( + name, + "exec_shell" + | "task_shell_start" + | "task_shell_wait" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_wait" + | "exec_interact" + ) { ToolCategory::Shell } else if name.starts_with("list_mcp_") || name.starts_with("read_mcp_") @@ -470,6 +501,287 @@ fn build_impact_summary_zh_hans( } } +fn build_prominent_details(category: ToolCategory, params: &Value) -> Vec { + let mut details = Vec::new(); + match category { + ToolCategory::Shell => { + if let Some(command) = param_text(params, &["command", "cmd"]) { + details.push(ApprovalDetail { + label: "Command".to_string(), + shell_lines: Some(format_shell_command_for_approval(&command)), + value: command, + }); + } + if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) { + details.push(ApprovalDetail { + label: "Dir".to_string(), + value: workdir, + shell_lines: None, + }); + } + } + ToolCategory::FileWrite => { + if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) { + details.push(ApprovalDetail { + label: "File".to_string(), + value: path, + shell_lines: None, + }); + } + } + ToolCategory::Safe => { + if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) { + details.push(ApprovalDetail { + label: "Path".to_string(), + value: path, + shell_lines: None, + }); + } + } + ToolCategory::Network => { + if let Some(target) = + param_preview(params, &["url", "q", "query", "location", "repo"], 200) + { + details.push(ApprovalDetail { + label: "Target".to_string(), + value: target, + shell_lines: None, + }); + } + } + ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => { + if let Some(input) = param_preview( + params, + &["command", "cmd", "path", "url", "q", "query", "ref_id"], + 200, + ) { + details.push(ApprovalDetail { + label: "Input".to_string(), + value: input, + shell_lines: None, + }); + } + } + } + details +} + +fn param_text(params: &Value, keys: &[&str]) -> Option { + let Value::Object(map) = params else { + return None; + }; + + for key in keys { + let Some(value) = map.get(*key) else { + continue; + }; + match value { + Value::String(text) => return Some(text.clone()), + Value::Number(number) => return Some(number.to_string()), + Value::Bool(flag) => return Some(flag.to_string()), + other => return Some(other.to_string()), + } + } + + None +} + +fn localize_detail_label(label: &str, locale: Locale) -> &str { + match locale { + Locale::ZhHans => match label { + "Command" => "命令", + "Dir" => "目录", + "File" => "文件", + "Path" => "路径", + "Target" => "目标", + "Input" => "输入", + _ => label, + }, + _ => label, + } +} + +pub(crate) fn format_shell_command_for_approval(command: &str) -> Vec { + if let Some(preview) = parse_printf_write_file_command(command) { + return format_printf_write_file_preview(preview); + } + + let mut out = Vec::new(); + for raw_line in command.lines() { + split_shell_display_line(raw_line, &mut out); + } + if out.is_empty() && !command.trim().is_empty() { + out.push(command.trim().to_string()); + } + out +} + +fn split_shell_display_line(line: &str, out: &mut Vec) { + let mut quote: Option = None; + let mut escaped = false; + let mut current = String::new(); + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + + if ch == '\\' { + current.push(ch); + escaped = true; + continue; + } + + if matches!(ch, '"' | '\'') { + if quote == Some(ch) { + quote = None; + } else if quote.is_none() { + quote = Some(ch); + } + current.push(ch); + continue; + } + + if quote.is_none() { + match ch { + '&' if chars.peek() == Some(&'&') => { + chars.next(); + push_shell_clause(out, &mut current, Some("&&")); + continue; + } + '|' if chars.peek() == Some(&'|') => { + chars.next(); + push_shell_clause(out, &mut current, Some("||")); + continue; + } + '|' => { + push_shell_clause(out, &mut current, Some("|")); + continue; + } + ';' => { + push_shell_clause(out, &mut current, Some(";")); + continue; + } + _ => {} + } + } + + current.push(ch); + } + + push_shell_clause(out, &mut current, None); +} + +fn push_shell_clause(out: &mut Vec, current: &mut String, operator: Option<&str>) { + let trimmed = current.trim(); + if trimmed.is_empty() { + if let Some(operator) = operator { + out.push(operator.to_string()); + } + } else if let Some(operator) = operator { + out.push(format!("{trimmed} {operator}")); + } else { + out.push(trimmed.to_string()); + } + current.clear(); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PrintfWriteFilePreview { + target: String, + lines: Vec, +} + +fn parse_printf_write_file_command(command: &str) -> Option { + let (before_redirect, after_redirect) = split_unquoted_redirect(command)?; + let before_redirect = before_redirect.trim(); + if !before_redirect.starts_with("printf") { + return None; + } + + let tokens = shlex::split(before_redirect)?; + if tokens.first()?.as_str() != "printf" { + return None; + } + let target_parts = shlex::split(after_redirect.trim())?; + if target_parts.len() != 1 { + return None; + } + let target = target_parts + .into_iter() + .next()? + .trim_matches(|ch| ch == '"' || ch == '\'') + .to_string(); + if target.is_empty() { + return None; + } + + let args = &tokens[1..]; + if args.is_empty() { + return None; + } + let values = if args.len() >= 2 && args[0].contains('%') { + &args[1..] + } else { + args + }; + let mut lines = Vec::new(); + for value in values { + let normalized = value.replace("\\n", "\n"); + for line in normalized.lines() { + lines.push(line.to_string()); + } + } + if lines.is_empty() { + lines.push(String::new()); + } + + Some(PrintfWriteFilePreview { target, lines }) +} + +fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec { + const MAX_PREVIEW_LINES: usize = 12; + let mut out = vec![format!("printf > {}", preview.target)]; + let total = preview.lines.len(); + for line in preview.lines.into_iter().take(MAX_PREVIEW_LINES) { + out.push(format!(" {line}")); + } + if total > MAX_PREVIEW_LINES { + out.push(format!(" ... (+{} more lines)", total - MAX_PREVIEW_LINES)); + } + out +} + +fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> { + let mut quote: Option = None; + let mut escaped = false; + for (idx, ch) in command.char_indices() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if matches!(ch, '"' | '\'') { + if quote == Some(ch) { + quote = None; + } else if quote.is_none() { + quote = Some(ch); + } + continue; + } + if quote.is_none() && ch == '>' { + return Some((&command[..idx], &command[idx + ch.len_utf8()..])); + } + } + None +} + /// Indices into the option list shared by both variants. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApprovalOption { @@ -970,6 +1282,15 @@ mod tests { #[test] fn test_get_tool_category_shell_tools() { assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell); + assert_eq!(get_tool_category("task_shell_start"), ToolCategory::Shell); + assert_eq!(get_tool_category("task_shell_wait"), ToolCategory::Shell); + assert_eq!(get_tool_category("exec_shell_wait"), ToolCategory::Shell); + assert_eq!( + get_tool_category("exec_shell_interact"), + ToolCategory::Shell + ); + assert_eq!(get_tool_category("exec_wait"), ToolCategory::Shell); + assert_eq!(get_tool_category("exec_interact"), ToolCategory::Shell); assert_eq!( get_tool_category("mcp_linear_save_issue"), ToolCategory::McpAction @@ -1126,6 +1447,66 @@ mod tests { ); } + #[test] + fn test_prominent_details_shell_does_not_truncate_long_command() { + let command = format!("printf '{}\\n' > /tmp/x && cat /tmp/x", "x".repeat(300)); + let request = ApprovalRequest::new( + "test-id", + "exec_shell", + "Run a shell command", + &json!({"command": command, "cwd": "/tmp/project"}), + "test_key", + ); + + let details = request.prominent_detail_items(Locale::En); + + assert_eq!(details[0].label, "Command"); + assert_eq!(details[0].value, command); + assert!( + details[0] + .shell_lines + .as_ref() + .is_some_and(|lines| lines.iter().any(|line| line.contains("cat /tmp/x"))), + "shell preview should preserve the dangerous tail of long commands" + ); + assert_eq!(details[1].label, "Dir"); + assert_eq!(details[1].value, "/tmp/project"); + } + + #[test] + fn test_prominent_details_file_write() { + let request = ApprovalRequest::new( + "test-id", + "write_file", + "Write a file to disk", + &json!({"path": "src/main.rs", "content": "fn main() {}"}), + "test_key", + ); + + let details = request.prominent_detail_items(Locale::En); + + assert_eq!(details[0].label, "File"); + assert_eq!(details[0].value, "src/main.rs"); + assert!(details[0].shell_lines.is_none()); + } + + #[test] + fn test_shell_formatter_preserves_logical_or_operator() { + let lines = format_shell_command_for_approval("cargo build || echo fallback"); + + assert_eq!(lines, vec!["cargo build ||", "echo fallback"]); + } + + #[test] + fn test_shell_formatter_detects_printf_write_file_preview() { + let lines = + format_shell_command_for_approval("printf '%s\\n' 'hello' 'world' > src/main.rs"); + + assert_eq!(lines[0], "printf > src/main.rs"); + assert!(lines.iter().any(|line| line.contains("hello"))); + assert!(lines.iter().any(|line| line.contains("world"))); + } + // ======================================================================== // ApprovalView Tests — Benign Variant (single-key approve) // ======================================================================== diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 03acf4aa7..55b8cff8d 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1270,21 +1270,35 @@ impl Renderable for ApprovalWidget<'_> { } lines.push(Line::from("")); - let params_str = self.request.params_display(); - let params_width = card_area.width.saturating_sub(14) as usize; - let params_truncated = - crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "..."); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - label_params(locale), - Style::default().fg(palette::TEXT_HINT), - ), - Span::styled( - params_truncated, - Style::default().fg(palette::TEXT_SECONDARY), - ), - ])); + let details = self.request.prominent_detail_items(locale); + if details.is_empty() { + let params_str = self.request.params_display(); + let params_width = card_area.width.saturating_sub(14) as usize; + let params_truncated = + crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "..."); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + label_params(locale), + Style::default().fg(palette::TEXT_HINT), + ), + Span::styled( + params_truncated, + Style::default().fg(palette::TEXT_SECONDARY), + ), + ])); + } else { + for detail in details.iter().take(4) { + if self.request.category == ToolCategory::Shell + && matches!(detail.label.as_str(), "Command" | "命令") + && let Some(shell_lines) = detail.shell_lines.as_deref() + { + push_shell_command_lines(&mut lines, &detail.label, shell_lines); + } else { + push_detail_line(&mut lines, &detail.label, &detail.value); + } + } + } lines.push(Line::from("")); @@ -1507,6 +1521,43 @@ fn label_params(locale: Locale) -> &'static str { } } +fn push_detail_line(lines: &mut Vec>, label: &str, value: &str) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{label:<7} "), + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ), + Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)), + ])); +} + +fn push_shell_command_lines(lines: &mut Vec>, label: &str, command_lines: &[String]) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{label}:"), + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ), + ])); + + for line in command_lines { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + line.clone(), + Style::default() + .fg(palette::TEXT_BODY) + .add_modifier(Modifier::BOLD), + ), + ])); + } +} + fn footer_controls(locale: Locale) -> &'static str { match locale { Locale::ZhHans => " · v:完整参数 · Esc:终止", @@ -4025,6 +4076,65 @@ mod tests { ); } + #[test] + fn approval_shell_command_detects_printf_write_file_preview() { + let request = crate::tui::approval::ApprovalRequest::new( + "approval-1", + "exec_shell", + "Run shell command", + &serde_json::json!({ + "command": "printf '%s\\n' 'alpha' 'beta' > src/generated.txt", + "cwd": "/tmp/project", + }), + "exec_shell:printf", + ); + let view = crate::tui::approval::ApprovalView::new(request.clone()); + let widget = ApprovalWidget::new(&request, &view); + let area = Rect::new(0, 0, 110, 32); + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert!(rendered.contains("Command:"), "{rendered}"); + assert!( + rendered.contains("printf > src/generated.txt"), + "{rendered}" + ); + assert!(rendered.contains("alpha"), "{rendered}"); + assert!(rendered.contains("beta"), "{rendered}"); + assert!(rendered.contains("Dir"), "{rendered}"); + assert!(rendered.contains("/tmp/project"), "{rendered}"); + } + + #[test] + fn approval_intent_summary_still_renders_with_shell_details() { + let request = crate::tui::approval::ApprovalRequest::new_with_intent( + "approval-1", + "exec_shell", + "Run shell command", + &serde_json::json!({ + "command": "cargo build || echo fallback", + "cwd": "/tmp/project", + }), + "exec_shell:cargo", + Some("Need to verify the fallback build path before editing files."), + ); + let view = crate::tui::approval::ApprovalView::new(request.clone()); + let widget = ApprovalWidget::new(&request, &view); + let area = Rect::new(0, 0, 120, 34); + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert!(rendered.contains("Intent:"), "{rendered}"); + assert!(rendered.contains("fallback build path"), "{rendered}"); + assert!(rendered.contains("Command:"), "{rendered}"); + assert!(rendered.contains("cargo build ||"), "{rendered}"); + assert!(rendered.contains("echo fallback"), "{rendered}"); + } + /// Regression for issue #65: after `App::handle_resize`, the chat widget /// must produce a clean render at the new width — no stale wrapping, /// no panic, no content exceeding the requested width. Cycling through diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index d299d59e9..6a8a18304 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -96,7 +96,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2239 i18n Phase 1-4b | Conflicting | Defer until localization lane. | | #2242 typed persistent tool permission rules | Conflicting | Compare with #2721 stabilization and permissions model. | | #2256 workspace crate consolidation | Conflicting | Do not merge during v0.9 stabilization. | -| #2269 approval details and shell previews | Conflicting | Review for small UI harvest only. | +| #2269 approval details and shell previews | Conflicting / locally harvested | Narrow UI slice landed manually: approval cards now show prominent command/dir/file/path/target rows, preserve #2381 intent summaries, classify live shell companion tools as shell, split common shell chains, and show compact simple `printf > file` previews. Do not merge the broader diff-preview/pager rewrite. Close/comment after branch is public, crediting @tdccccc for #1991/#2269. | | #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. | | #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. | | #2476 fork migration parent links | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Close/comment original after the integration branch is public, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. | From 15b129ca38449a91fc3af3a75cba16c237a51c89 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 00:18:38 -0700 Subject: [PATCH 061/209] docs: clarify v0.9 README release status Adds a near-top release-status boundary so v0.9 integration work is not mistaken for a published artifact, and expands the v0.9 track table with sub-agent recovery and HarnessProfile limits. The wording keeps release-channel verification and maintainer approval boundaries explicit while preserving the existing DeepSeek-first and contributor-stewardship framing. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index c633420b2..873765269 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,31 @@ areas: | Relay and continuity | `/relay`, fork-state handoff, and rich PlanArtifact context preserve the goal, why it matters, evidence, constraints, blockers, changed files, verification state, and the next action. | | Transcript calmness | Dense read/search/list-style tool runs can collapse into expandable groups, while failures, running work, shell commands, writes, diffs, plans, and reviews stay visible. | | Runtime sessions and workspaces | Branch work extends session/thread runtime APIs, including workspace-aware thread updates, completed-thread session saves, and safer guards around active turns. Treat this as v0.9-track capability until the release ships. | +| Sub-agent recovery | Live per-step timeout recovery can preserve checkpoint metadata and let `agent_eval { continue: true }` resume an interrupted child in the same runtime. Cold-restart continuation is still a follow-up; persisted child tasks are not rehydrated yet. | | Project context stability | Bounded project-context packs and generated instructions keep large/noisy repositories from turning the first turn into an unbounded filesystem walk. | | HarmonyOS / OHOS | The lane carries safe OpenHarmony setup, OHOS platform guards, self-update disablement on OHOS, and target gating for PTY and Starlark execpolicy paths. Full OHOS target builds still require a host with the OpenHarmony native SDK configured. | | Nix and Starlark compatibility | Dependency stewardship keeps OHOS builds from pulling incompatible Nix-chain crates through PTY or Starlark paths where those features are gated. | +| HarnessProfile | The branch carries the typed `HarnessPosture` / `HarnessProfile` config data model and strict schema validation. Provider/model posture selection, prompt/tool/runtime behavior, telemetry, and docs remain follow-up work. | | Contributor stewardship | Harvested PRs stay credited, contributor identity mapping is machine-readable, and community gates remain dry-run and human-toned while the branch is reviewed. | | WhaleFlow | Typed branch/leaf workflows, deterministic replay, pod-style workflow monitoring, provider/model posture, and evidence-backed profile evolution remain the larger v0.9 workbench goal. | The current execution map lives in [docs/V0_9_0_EXECUTION_MAP.md](docs/V0_9_0_EXECUTION_MAP.md). +## Release Status + +The latest published release line is still separate from the v0.9 integration +branch. v0.9.0 work in this README describes the current integration track, not +a published release artifact. Release-specific detail belongs in +[CHANGELOG.md](CHANGELOG.md); this README summarizes the current user-facing +surface and links to deeper docs. + +Release channels can lag each other. Before making release claims, verify the +intended surface directly: GitHub Releases and checksums, npm `codewhale`, +Cargo crates, Docker/GHCR images, CNB mirrors, and any legacy Homebrew formula. +No tag, GitHub Release, npm/Cargo publish, Docker publish, or release artifact +push should happen without explicit maintainer approval. + ## Quickstart ```bash From 960bdc91c7ffa9c1babc1271c189337a84676568 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:21:30 -0700 Subject: [PATCH 062/209] docs(providers): document Xiaomi MiMo Token Plan region endpoints Clarify that xiaomi-mimo Token Plan keys (tp-...) default to the Singapore endpoint https://token-plan-sgp.xiaomimimo.com/v1, that pay-as-you-go keys use https://api.xiaomimimo.com/v1, and that China-region Token Plan accounts must set base_url = https://token-plan-cn.xiaomimimo.com/v1 explicitly. Also note that a generic [vision_model] OpenAI-compatible block does not auto-select MiMo endpoints. Matches the branch resolve_xiaomi_mimo_base_url behavior. Harvested from PR #2756 by @xyuai. Fixes #2735. Co-authored-by: xyuai <281015099+xyuai@users.noreply.github.com> --- docs/CONFIGURATION.md | 11 +++++++++-- docs/PROVIDERS.md | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 347054d37..9119cbba1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -259,6 +259,13 @@ api_key = "YOUR_XIAOMI_KEY" base_url = "https://api.xiaomimimo.com/v1" ``` +The example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint. +If you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set +`base_url` explicitly because this generic OpenAI-compatible block does not +auto-select MiMo endpoints. Use +`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts or +`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts. + To bootstrap MCP and skills directories at their resolved paths, run `codewhale-tui setup`. To only scaffold MCP, run `codewhale-tui mcp init`. @@ -833,9 +840,9 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint while sharing `[providers.siliconflow]`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys; set `base_url` explicitly if your Token Plan account uses the China region; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint while sharing `[providers.siliconflow]`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` explicitly if your Xiaomi MiMo Token Plan account is provisioned in the China region. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 4c99e882d..d2f89f567 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -137,6 +137,12 @@ work. The chat picker also exposes the latest Omni model `mimo-v2.5`. Xiaomi MiM TTS is available through `codewhale --provider xiaomi-mimo speech "text" --model tts` (or the `tts` alias) plus model-visible `speech` / `tts` tools in Agent/YOLO mode. + +Token Plan keys default to the Singapore endpoint +`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned +for the China region, set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` +explicitly in `[providers.xiaomi_mimo]`. + Voice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and `mimo-v2.5-tts-voiceclone`. Xiaomi's current [image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding) From 9e29c221b9cc110db6480a16237f45723c1dd9b3 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:22:46 -0700 Subject: [PATCH 063/209] fix(mcp): preserve underscored MCP server names in tool routing parse_prefixed_name now matches the qualified mcp__ name against the set of registered server names (connections + configured servers) and prefers the longest matching server name, instead of naively splitting on the first underscore. Tools on servers whose names contain underscores (e.g. "my_db") are now reachable, and an overlapping pair like "my" and "my_db" routes to the correct server. Falls back to the legacy first-underscore split when no registered server matches, preserving backward compatibility. Harvested from PR #2747 by @cyq1017; supersedes the equivalent fix in PR #2746 by @puneetdixit200. Both contributors diagnosed and fixed issue #2744; #2747 landed for its longest-match tie-break test coverage. Fixes #2744. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> Co-authored-by: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> --- .github/AUTHOR_MAP | 1 + crates/tui/src/mcp.rs | 132 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index 26bca8cf6..d1997277e 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -88,3 +88,4 @@ hsdbeebou = hsdbeebou <284843096+hsdbeebou@users.noreply.github.com> tdccccc = tdccccc <79492752+tdccccc@users.noreply.github.com> greyfreedom = greyfreedom <11493871+greyfreedom@users.noreply.github.com> greyfreedom@163.com = greyfreedom <11493871+greyfreedom@users.noreply.github.com> +puneetdixit200 = puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 32a342b1a..038bfe164 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2241,10 +2241,30 @@ impl McpPool { /// Parse a prefixed name into (server_name, tool_name) fn parse_prefixed_name<'a>(&self, prefixed_name: &'a str) -> Result<(&'a str, &'a str)> { - if !prefixed_name.starts_with("mcp_") { + let Some(rest) = prefixed_name.strip_prefix("mcp_") else { anyhow::bail!("Invalid MCP tool name: {prefixed_name}"); + }; + + let mut best_match: Option<(&str, &str)> = None; + for server in self.connections.keys().chain(self.config.servers.keys()) { + let Some(tool) = rest + .strip_prefix(server) + .and_then(|tail| tail.strip_prefix('_')) + else { + continue; + }; + if tool.is_empty() { + continue; + } + if best_match.is_none_or(|(matched, _)| server.len() > matched.len()) { + best_match = Some((&rest[..server.len()], tool)); + } + } + + if let Some((server, tool)) = best_match { + return Ok((server, tool)); } - let rest = &prefixed_name[4..]; + let Some((server, tool)) = rest.split_once('_') else { anyhow::bail!("Invalid MCP tool name format: {prefixed_name}"); }; @@ -3643,6 +3663,114 @@ mod tests { ); } + #[tokio::test] + async fn mcp_pool_call_tool_preserves_server_names_with_underscores() { + let sent = Arc::new(Mutex::new(Vec::new())); + let transport = ScriptedValueTransport { + sent: Arc::clone(&sent), + responses: VecDeque::from([json_frame(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {"ok": true} + }))]), + }; + let mut conn = test_connection(Box::new(transport)); + conn.name = "my_db".to_string(); + conn.tools = vec![McpTool { + name: "execute_sql".to_string(), + description: None, + input_schema: serde_json::json!({}), + }]; + + let mut pool = McpPool::new(McpConfig { + timeouts: McpTimeouts::default(), + servers: HashMap::new(), + }); + pool.connections.insert("my_db".to_string(), conn); + + let result = pool + .call_tool( + "mcp_my_db_execute_sql", + serde_json::json!({"query": "select 1"}), + ) + .await + .unwrap(); + + assert_eq!(result, serde_json::json!({"ok": true})); + let sent = sent.lock().unwrap(); + assert_eq!(sent[0]["method"], "tools/call"); + assert_eq!(sent[0]["params"]["name"], "execute_sql"); + assert_eq!( + sent[0]["params"]["arguments"], + serde_json::json!({"query": "select 1"}) + ); + } + + #[tokio::test] + async fn mcp_pool_call_tool_prefers_longest_matching_server_name() { + let sent_short = Arc::new(Mutex::new(Vec::new())); + let short_transport = ScriptedValueTransport { + sent: Arc::clone(&sent_short), + responses: VecDeque::from([json_frame(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {"short": true} + }))]), + }; + let mut short_conn = test_connection(Box::new(short_transport)); + short_conn.name = "my".to_string(); + short_conn.tools = vec![McpTool { + name: "db_execute_sql".to_string(), + description: None, + input_schema: serde_json::json!({}), + }]; + + let sent_long = Arc::new(Mutex::new(Vec::new())); + let long_transport = ScriptedValueTransport { + sent: Arc::clone(&sent_long), + responses: VecDeque::from([json_frame(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {"long": true} + }))]), + }; + let mut long_conn = test_connection(Box::new(long_transport)); + long_conn.name = "my_db".to_string(); + long_conn.tools = vec![McpTool { + name: "execute_sql".to_string(), + description: None, + input_schema: serde_json::json!({}), + }]; + + let mut pool = McpPool::new(McpConfig { + timeouts: McpTimeouts::default(), + servers: HashMap::new(), + }); + pool.connections.insert("my".to_string(), short_conn); + pool.connections.insert("my_db".to_string(), long_conn); + + let result = pool + .call_tool( + "mcp_my_db_execute_sql", + serde_json::json!({"query": "select 1"}), + ) + .await + .unwrap(); + + assert_eq!(result, serde_json::json!({"long": true})); + assert!( + sent_short.lock().unwrap().is_empty(), + "the shorter server name must not receive the tool call" + ); + let sent_long = sent_long.lock().unwrap(); + assert_eq!(sent_long[0]["method"], "tools/call"); + assert_eq!(sent_long[0]["params"]["name"], "execute_sql"); + assert_eq!( + sent_long[0]["params"]["arguments"], + serde_json::json!({"query": "select 1"}) + ); + } + #[tokio::test] async fn json_rpc_session_error_is_marked_stale() { let sent = Arc::new(Mutex::new(Vec::new())); From 9d139606030ca1d4328c489be01513c7c0130b3b Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:23:49 -0700 Subject: [PATCH 064/209] feat(pricing): price Xiaomi MiMo primary models at matching DeepSeek V4 rates Add cost-estimate pricing for the Xiaomi MiMo chat models that were previously unpriced: mimo-v2.5-pro / xiaomi/mimo-v2.5-pro reuse the DeepSeek V4-Pro rate table, and mimo-v2.5 / xiaomi/mimo-v2.5 reuse the DeepSeek V4-Flash rates. The DeepSeek V4 pro/flash rate tables are extracted into deepseek_v4_pro_pricing() and deepseek_v4_flash_pricing() helpers so the MiMo aliases stay aligned with DeepSeek. Existing DeepSeek pricing behavior is unchanged (deepseek + non-v4pro still maps to Flash), and unrelated models still return None. Harvested from PR #2750 by @cyq1017. Fixes #2731. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- crates/tui/src/pricing.rs | 96 ++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 9ac958900..2da0eab99 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -117,39 +117,52 @@ fn pricing_for_model_at(model: &str, _now: DateTime) -> Option return Some(deepseek_v4_pro_pricing()), + "xiaomi/mimo-v2.5" | "mimo-v2.5" => return Some(deepseek_v4_flash_pricing()), + _ => {} } - if lower.contains("v4-pro") || lower.contains("v4pro") { - // DeepSeek's pricing page says the V4-Pro promotional 75% discount - // becomes the official one-quarter base price after 2026-05-31 15:59 - // UTC. Keep using the adjusted rate after that cutoff (#2489). - Some(ModelPricing { - usd: CurrencyPricing { - input_cache_hit_per_million: 0.003625, - input_cache_miss_per_million: 0.435, - output_per_million: 0.87, - }, - cny: CurrencyPricing { - input_cache_hit_per_million: 0.025, - input_cache_miss_per_million: 3.0, - output_per_million: 6.0, - }, - }) + if lower.contains("deepseek") { + if lower.contains("v4-pro") || lower.contains("v4pro") { + // DeepSeek's pricing page says the V4-Pro promotional 75% discount + // becomes the official one-quarter base price after 2026-05-31 15:59 + // UTC. Keep using the adjusted rate after that cutoff (#2489). + Some(deepseek_v4_pro_pricing()) + } else { + Some(deepseek_v4_flash_pricing()) + } } else { - // deepseek-v4-flash pricing. - Some(ModelPricing { - usd: CurrencyPricing { - input_cache_hit_per_million: 0.0028, - input_cache_miss_per_million: 0.14, - output_per_million: 0.28, - }, - cny: CurrencyPricing { - input_cache_hit_per_million: 0.02, - input_cache_miss_per_million: 1.0, - output_per_million: 2.0, - }, - }) + None + } +} + +fn deepseek_v4_pro_pricing() -> ModelPricing { + ModelPricing { + usd: CurrencyPricing { + input_cache_hit_per_million: 0.003625, + input_cache_miss_per_million: 0.435, + output_per_million: 0.87, + }, + cny: CurrencyPricing { + input_cache_hit_per_million: 0.025, + input_cache_miss_per_million: 3.0, + output_per_million: 6.0, + }, + } +} + +fn deepseek_v4_flash_pricing() -> ModelPricing { + ModelPricing { + usd: CurrencyPricing { + input_cache_hit_per_million: 0.0028, + input_cache_miss_per_million: 0.14, + output_per_million: 0.28, + }, + cny: CurrencyPricing { + input_cache_hit_per_million: 0.02, + input_cache_miss_per_million: 1.0, + output_per_million: 2.0, + }, } } @@ -340,6 +353,27 @@ mod tests { assert_eq!(pricing.cny.output_per_million, 2.0); } + #[test] + fn xiaomi_mimo_primary_models_use_matching_deepseek_v4_rates() { + let now = Utc.with_ymd_and_hms(2026, 6, 4, 0, 0, 0).single().unwrap(); + + let pro_pricing = pricing_for_model_at("mimo-v2.5-pro", now).unwrap(); + assert_eq!(pro_pricing.usd.input_cache_hit_per_million, 0.003625); + assert_eq!(pro_pricing.usd.input_cache_miss_per_million, 0.435); + assert_eq!(pro_pricing.usd.output_per_million, 0.87); + assert_eq!(pro_pricing.cny.input_cache_hit_per_million, 0.025); + assert_eq!(pro_pricing.cny.input_cache_miss_per_million, 3.0); + assert_eq!(pro_pricing.cny.output_per_million, 6.0); + + let flash_pricing = pricing_for_model_at("xiaomi/mimo-v2.5", now).unwrap(); + assert_eq!(flash_pricing.usd.input_cache_hit_per_million, 0.0028); + assert_eq!(flash_pricing.usd.input_cache_miss_per_million, 0.14); + assert_eq!(flash_pricing.usd.output_per_million, 0.28); + assert_eq!(flash_pricing.cny.input_cache_hit_per_million, 0.02); + assert_eq!(flash_pricing.cny.input_cache_miss_per_million, 1.0); + assert_eq!(flash_pricing.cny.output_per_million, 2.0); + } + #[test] fn cost_estimate_calculates_usd_and_cny() { let estimate = calculate_turn_cost_estimate("deepseek-v4-flash", 1_000_000, 500_000) From 74b32685219251dee71f32a3efaa5afb3d9ed8cc Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:26:37 -0700 Subject: [PATCH 065/209] fix(tui): render hydrated deferred-tool results as loaded, not run done When a deferred tool is used for the first time, the engine returns a schema-hydration result (event tool.schema_hydrated, executed=false, retry_required=true) instead of executing the tool. The transcript and sidebar previously rendered this as a completed run ("run done"), which was indistinguishable from a real successful execution and misled both the user and the model. Hydrated results now render as "tool loaded - retry required" via a dedicated ToolStatus::Hydrated, threaded through tool routing, history, sidebar, and theme. A successful real execution still renders as run done, a failed tool with hydration metadata stays Failed. Local correction on top of the PR: a hydrated row ranks with active work (ToolStatus::Running) in the sidebar rather than alongside completed successes, matching the "not run done" intent. The contributor's hydration detection and missing-metadata handling are kept as-is (the sole emitter always sets executed=false, consistent with the engine's own check). Harvested from PR #2757 by @mvanhorn. Fixes #2648. Co-authored-by: mvanhorn <455140+mvanhorn@users.noreply.github.com> --- crates/tui/src/deepseek_theme.rs | 5 + crates/tui/src/tui/history.rs | 21 +++- crates/tui/src/tui/sidebar.rs | 5 +- crates/tui/src/tui/tool_routing.rs | 82 ++++++++------ crates/tui/src/tui/ui.rs | 8 ++ crates/tui/src/tui/ui/tests.rs | 168 +++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 39 deletions(-) diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs index 614f1a626..32e2ea7c7 100644 --- a/crates/tui/src/deepseek_theme.rs +++ b/crates/tui/src/deepseek_theme.rs @@ -182,6 +182,7 @@ impl Theme { match status { ToolStatus::Running => self.tool_running_accent, ToolStatus::Success => self.tool_success_accent, + ToolStatus::Hydrated => self.tool_running_accent, ToolStatus::Failed => self.tool_failed_accent, } } @@ -278,6 +279,10 @@ mod tests { theme.tool_status_color(ToolStatus::Success), theme.tool_success_accent ); + assert_eq!( + theme.tool_status_color(ToolStatus::Hydrated), + theme.tool_running_accent + ); assert_eq!( theme.tool_status_color(ToolStatus::Failed), theme.tool_failed_accent diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 1f77083f1..47f7db0c7 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -880,6 +880,7 @@ pub fn tool_run_summary(run: &ToolRun) -> String { pub enum ToolStatus { Running, Success, + Hydrated, Failed, } @@ -1001,8 +1002,16 @@ impl ExploringCell { .entries .iter() .all(|entry| entry.status != ToolStatus::Running); + let any_hydrated = self + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Hydrated); let status = if all_done { - ToolStatus::Success + if any_hydrated { + ToolStatus::Hydrated + } else { + ToolStatus::Success + } } else { ToolStatus::Running }; @@ -1010,7 +1019,11 @@ impl ExploringCell { lines.push(render_tool_header_with_summary( "Workspace", header_summary.as_deref(), - if all_done { "done" } else { "running" }, + if all_done { + tool_status_label(status) + } else { + "running" + }, status, None, low_motion, @@ -1020,6 +1033,7 @@ impl ExploringCell { let prefix = match entry.status { ToolStatus::Running => "live", ToolStatus::Success => "done", + ToolStatus::Hydrated => "loaded", ToolStatus::Failed => "issue", }; lines.extend(render_compact_kv( @@ -3161,7 +3175,7 @@ fn status_symbol(started_at: Option, status: ToolStatus, low_motion: bo .map_or(0, |d| d % (TOOL_RUNNING_SYMBOLS.len() as u128)); TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string() } - ToolStatus::Success => TOOL_DONE_SYMBOL.to_string(), + ToolStatus::Success | ToolStatus::Hydrated => TOOL_DONE_SYMBOL.to_string(), ToolStatus::Failed => TOOL_FAILED_SYMBOL.to_string(), } } @@ -3452,6 +3466,7 @@ fn tool_status_label(status: ToolStatus) -> &'static str { match status { ToolStatus::Running => "running", ToolStatus::Success => "done", + ToolStatus::Hydrated => "tool loaded - retry required", ToolStatus::Failed => "issue", } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 410b1f1f1..b4a6aa2b8 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -1651,7 +1651,9 @@ fn normalize_activity_text(text: &str) -> String { fn tool_row_rank(row: &SidebarToolRow) -> u8 { match row.status { ToolStatus::Failed => 0, - ToolStatus::Running => 1, + // A schema-hydrated deferred tool is not "run done" — it must be + // retried — so it ranks with active work, not completed successes. + ToolStatus::Running | ToolStatus::Hydrated => 1, ToolStatus::Success if is_low_value_tool(&row.name) => 3, ToolStatus::Success => 2, } @@ -1701,6 +1703,7 @@ fn tool_status_marker( match status { ToolStatus::Running => ("[~]", theme.warning), ToolStatus::Success => ("[✓]", theme.success), + ToolStatus::Hydrated => ("[~]", theme.warning), ToolStatus::Failed => ("[!]", theme.error_fg), } } diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index 697c50f4c..bb88b73ff 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -469,10 +469,7 @@ pub(super) fn handle_tool_call_complete( app.cell_at_virtual_index_mut(cell_index) && let Some(entry) = cell.entries.get_mut(entry_index) { - entry.status = match result.as_ref() { - Ok(tool_result) if tool_result.success => ToolStatus::Success, - Ok(_) | Err(_) => ToolStatus::Failed, - }; + entry.status = tool_status_from_result(result); app.mark_history_updated(); // Mutating the in-flight exploring cell needs an active-cell // revision bump so the transcript cache invalidates the synthetic @@ -501,26 +498,7 @@ pub(super) fn handle_tool_call_complete( store_tool_detail_output(app, id, cell_index, result); let in_active = cell_index >= app.history.len(); - let status = match result.as_ref() { - Ok(tool_result) => match tool_result.metadata.as_ref() { - Some(meta) - if meta - .get("status") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == "Running") => - { - ToolStatus::Running - } - _ => { - if tool_result.success { - ToolStatus::Success - } else { - ToolStatus::Failed - } - } - }, - Err(_) => ToolStatus::Failed, - }; + let status = tool_status_from_result(result); if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { match cell { @@ -610,7 +588,9 @@ pub(super) fn handle_tool_call_complete( match result.as_ref() { Ok(tool_result) => { let summary = summarize_mcp_output(&tool_result.content); - if summary.is_error == Some(true) { + if status == ToolStatus::Hydrated { + mcp.status = status; + } else if summary.is_error == Some(true) { mcp.status = ToolStatus::Failed; } else { mcp.status = status; @@ -764,16 +744,7 @@ fn push_orphan_tool_completion( name: &str, result: &Result, ) { - let status = match result.as_ref() { - Ok(tool_result) => { - if tool_result.success { - ToolStatus::Success - } else { - ToolStatus::Failed - } - } - Err(_) => ToolStatus::Failed, - }; + let status = tool_status_from_result(result); let output = match result.as_ref() { Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)), Err(err) => Some(err.to_string()), @@ -836,6 +807,47 @@ fn push_orphan_tool_completion( } } +fn tool_status_from_result(result: &Result) -> ToolStatus { + match result.as_ref() { + Ok(tool_result) if is_deferred_schema_hydration(tool_result) => ToolStatus::Hydrated, + Ok(tool_result) => match tool_result.metadata.as_ref() { + Some(meta) + if meta + .get("status") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "Running") => + { + ToolStatus::Running + } + _ => { + if tool_result.success { + ToolStatus::Success + } else { + ToolStatus::Failed + } + } + }, + Err(_) => ToolStatus::Failed, + } +} + +fn is_deferred_schema_hydration(tool_result: &ToolResult) -> bool { + if !tool_result.success { + return false; + } + let Some(metadata) = tool_result.metadata.as_ref() else { + return false; + }; + metadata + .get("event") + .and_then(serde_json::Value::as_str) + .is_some_and(|event| event == "tool.schema_hydrated") + && metadata + .get("executed") + .and_then(serde_json::Value::as_bool) + .is_some_and(|executed| !executed) +} + fn is_exploring_tool(name: &str) -> bool { matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files") } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 03d2ee816..8985b2fb0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -8698,6 +8698,7 @@ fn activity_cell_rank(cell: &HistoryCell) -> Option { HistoryCell::Tool(tool) => match tool_status_for_activity(tool) { Some(ToolStatus::Running) => Some(0), Some(ToolStatus::Failed) => Some(1), + Some(ToolStatus::Hydrated) => Some(2), Some(ToolStatus::Success) => Some(2), None => Some(2), }, @@ -8929,6 +8930,12 @@ fn tool_status_for_activity(tool: &ToolCell) -> Option { .any(|entry| entry.status == ToolStatus::Failed) { Some(ToolStatus::Failed) + } else if cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Hydrated) + { + Some(ToolStatus::Hydrated) } else { Some(ToolStatus::Success) } @@ -8964,6 +8971,7 @@ fn activity_status_label(status: ToolStatus) -> &'static str { match status { ToolStatus::Running => "running", ToolStatus::Success => "done", + ToolStatus::Hydrated => "tool loaded - retry required", ToolStatus::Failed => "failed", } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5ceb5b9d0..88ff3b9d6 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5529,6 +5529,174 @@ fn ok_result( Ok(crate::tools::spec::ToolResult::success(content)) } +fn hydrated_result( + content: &str, +) -> Result { + Ok( + crate::tools::spec::ToolResult::success(content).with_metadata(serde_json::json!({ + "event": "tool.schema_hydrated", + "tool": "exec_shell", + "executed": false, + "retry_required": true, + "deferred_tool_loaded": true, + "tool_name": "exec_shell", + })), + ) +} + +fn rendered_text(lines: &[ratatui::text::Line<'_>]) -> String { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn completed_exec_tool_result_still_renders_run_done() { + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "shell-ok", + "exec_shell", + &serde_json::json!({"command": "echo hi"}), + ); + handle_tool_call_complete(&mut app, "shell-ok", "exec_shell", &ok_result("hi")); + + let exec = app + .active_cell + .as_ref() + .expect("active cell") + .entries() + .iter() + .find_map(|cell| match cell { + HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec), + _ => None, + }) + .expect("exec cell"); + + assert_eq!(exec.status, ToolStatus::Success); + let text = rendered_text(&exec.lines_with_motion(100, true)); + assert!(text.contains("run done"), "{text}"); + assert!(!text.contains("tool loaded - retry required"), "{text}"); +} + +#[test] +fn hydrated_exec_tool_result_renders_retry_required_not_run_done() { + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "shell-hydrated", + "exec_shell", + &serde_json::json!({"command": "cargo test"}), + ); + handle_tool_call_complete( + &mut app, + "shell-hydrated", + "exec_shell", + &hydrated_result( + "Tool exec_shell was deferred and has now been loaded.\n\ + The tool was not executed. Retry with the loaded schema.", + ), + ); + + let exec = app + .active_cell + .as_ref() + .expect("active cell") + .entries() + .iter() + .find_map(|cell| match cell { + HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec), + _ => None, + }) + .expect("exec cell"); + + assert_eq!(exec.status, ToolStatus::Hydrated); + let text = rendered_text(&exec.lines_with_motion(120, true)); + assert!(text.contains("run tool loaded - retry required"), "{text}"); + assert!(!text.contains("run done"), "{text}"); +} + +#[test] +fn hydrated_tool_with_validation_body_still_uses_hydrated_status() { + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "generic-hydrated", + "deferred_tool", + &serde_json::json!({"unexpected": true}), + ); + handle_tool_call_complete( + &mut app, + "generic-hydrated", + "deferred_tool", + &hydrated_result( + "Tool deferred_tool was deferred and has now been loaded.\n\n\ + Missing required fields:\n command\n\n\ + Unexpected fields:\n unexpected", + ), + ); + + let generic = app + .active_cell + .as_ref() + .expect("active cell") + .entries() + .iter() + .find_map(|cell| match cell { + HistoryCell::Tool(ToolCell::Generic(generic)) => Some(generic), + _ => None, + }) + .expect("generic cell"); + + assert_eq!(generic.status, ToolStatus::Hydrated); + let text = rendered_text(&HistoryCell::Tool(ToolCell::Generic(generic.clone())).lines(120)); + assert!(text.contains("tool loaded - retry required"), "{text}"); + assert!(!text.contains("tool done"), "{text}"); +} + +#[test] +fn failed_tool_result_with_hydration_metadata_stays_failed() { + let mut app = create_test_app(); + handle_tool_call_started( + &mut app, + "generic-failed", + "deferred_tool", + &serde_json::json!({}), + ); + let result = Ok(crate::tools::spec::ToolResult::error("boom").with_metadata( + serde_json::json!({ + "event": "tool.schema_hydrated", + "executed": false, + "retry_required": true, + }), + )); + handle_tool_call_complete(&mut app, "generic-failed", "deferred_tool", &result); + + let generic = app + .active_cell + .as_ref() + .expect("active cell") + .entries() + .iter() + .find_map(|cell| match cell { + HistoryCell::Tool(ToolCell::Generic(generic)) => Some(generic), + _ => None, + }) + .expect("generic cell"); + + assert_eq!(generic.status, ToolStatus::Failed); + let text = rendered_text(&HistoryCell::Tool(ToolCell::Generic(generic.clone())).lines(120)); + assert!(text.contains("tool issue"), "{text}"); + assert!(!text.contains("tool loaded - retry required"), "{text}"); +} + #[test] fn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() { let mut app = create_test_app(); From 70adeeeae6cc633dd51608ace109316d02922b1f Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:28:40 -0700 Subject: [PATCH 066/209] docs(v0.9): record #2746/#2747, #2750, #2756, #2757 harvests and #2742/#2751/#2755 dispositions Log the new community-PR harvests in CHANGELOG.md and crates/tui/CHANGELOG.md (MCP underscore server names, Xiaomi MiMo pricing, hydrated deferred-tool render, Token Plan region docs) with contributor credit, and update docs/V0_9_0_EXECUTION_MAP.md with evidence-backed dispositions for the newly-reviewed PRs, including the deferred #2742 and forwarded #2751/#2755. --- CHANGELOG.md | 26 +++++++++++++++++++++++++- crates/tui/CHANGELOG.md | 26 +++++++++++++++++++++++++- docs/V0_9_0_EXECUTION_MAP.md | 20 +++++++++++++++++--- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3907c4cb..73c45011e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting queued or active turns. +- Added cost-estimate pricing for the Xiaomi MiMo primary chat models, which + were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the + DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the + DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). ### Changed @@ -122,6 +126,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of silently falling back to `custom`; runtime provider/model posture selection remains a follow-up (#2693, #2741). +### Fixed + +- MCP tool routing now preserves server names that contain underscores. + `parse_prefixed_name` matches the qualified `mcp__` name against + the set of registered server names and prefers the longest match, so tools on + a server like `my_db` are reachable and an overlapping `my` / `my_db` pair + routes correctly. Falls back to the legacy first-underscore split when no + registered server matches (#2744). +- Schema-hydrated deferred tools no longer render as a completed run. The first + use of a deferred tool returns a schema-hydration result instead of executing; + the transcript and sidebar now show "tool loaded — retry required" via a + dedicated hydrated status, so it is no longer indistinguishable from a real + successful execution. A hydrated row also ranks with active work rather than + completed successes (#2648). + ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and @@ -149,7 +168,12 @@ checkpoint/resume report that shaped the sub-agent recovery slice (#2029), review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that -shaped this bounded fallback. +shaped this bounded fallback. Thanks also to **@cyq1017** for the MCP +underscore-server-name fix and Xiaomi MiMo pricing (#2747, #2744, #2750, #2731) +and **@puneetdixit200** for independently diagnosing and fixing the same MCP +underscore issue (#2746, #2744), **@mvanhorn** for the hydrated deferred-tool +render fix (#2757, #2648), and **@xyuai** for the Xiaomi MiMo Token Plan region +documentation (#2756, #2735). ## [0.8.53] - 2026-06-03 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index c3907c4cb..73c45011e 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -37,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting queued or active turns. +- Added cost-estimate pricing for the Xiaomi MiMo primary chat models, which + were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the + DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the + DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). ### Changed @@ -122,6 +126,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of silently falling back to `custom`; runtime provider/model posture selection remains a follow-up (#2693, #2741). +### Fixed + +- MCP tool routing now preserves server names that contain underscores. + `parse_prefixed_name` matches the qualified `mcp__` name against + the set of registered server names and prefers the longest match, so tools on + a server like `my_db` are reachable and an overlapping `my` / `my_db` pair + routes correctly. Falls back to the legacy first-underscore split when no + registered server matches (#2744). +- Schema-hydrated deferred tools no longer render as a completed run. The first + use of a deferred tool returns a schema-hydration result instead of executing; + the transcript and sidebar now show "tool loaded — retry required" via a + dedicated hydrated status, so it is no longer indistinguishable from a real + successful execution. A hydrated row also ranks with active work rather than + completed successes (#2648). + ### Community Thanks to **@cyq1017** for the restore-listing implementation (#2513) and @@ -149,7 +168,12 @@ checkpoint/resume report that shaped the sub-agent recovery slice (#2029), review trail (#2508, #2506), **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and **@linzhiqin2003** and **@merchloubna70-dot** for earlier context-cap and startup-diagnosis work that -shaped this bounded fallback. +shaped this bounded fallback. Thanks also to **@cyq1017** for the MCP +underscore-server-name fix and Xiaomi MiMo pricing (#2747, #2744, #2750, #2731) +and **@puneetdixit200** for independently diagnosing and fixing the same MCP +underscore issue (#2746, #2744), **@mvanhorn** for the hydrated deferred-tool +render fix (#2757, #2648), and **@xyuai** for the Xiaomi MiMo Token Plan region +documentation (#2756, #2735). ## [0.8.53] - 2026-06-03 diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 6a8a18304..513ae876e 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -148,6 +148,13 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | | #2740 dense tool-run collapse follow-up | Mergeable / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Comment/close after the integration branch is public, crediting @idling11. | +| #2742 Ollama default model in completions | Mergeable / deferred | Real picker inconsistency (Ollama completion list returned hosted-only `OFFICIAL_DEEPSEEK_MODELS`), but the PR's fix pins the stale `deepseek-coder:1.3b` into another surface. Deferred per maintainer direction: do not entrench the V1-era local default; the live Ollama `/models` path (`client.rs`) already lists actually-installed models. Better fix is dynamic/current-model discovery. Keep #2742 open with a respectful comment; treat "should the Ollama default move off `deepseek-coder:1.3b`" as a separate maintainer decision. Credit @reidliu41. | +| #2746 / #2747 MCP underscore server names | Mergeable / locally harvested | Two competing fixes for #2744. Harvested #2747 (@cyq1017) as `parse_prefixed_name` longest-registered-server match with first-underscore fallback, because it adds the overlapping `my`/`my_db` tie-break test; #2746 (@puneetdixit200) is the equivalent narrower fix and is superseded. `cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool` (3 pass). Comment/close both after the branch is public, crediting both contributors. Fixes #2744. | +| #2750 Xiaomi MiMo pricing | Mergeable / locally harvested | Harvested: `mimo-v2.5-pro`/`xiaomi/mimo-v2.5-pro` reuse DeepSeek V4-Pro rates, `mimo-v2.5`/`xiaomi/mimo-v2.5` reuse V4-Flash rates, via extracted `deepseek_v4_pro_pricing()`/`deepseek_v4_flash_pricing()` helpers. DeepSeek pricing behavior unchanged. `cargo test -p codewhale-tui --bin codewhale-tui --locked pricing` (16 pass). Credit @cyq1017; comment/close after branch is public. Fixes #2731. | +| #2751 merge workspace MCP config | Draft/mergeable / forwarded | Implements project-level MCP config merging (#2749) with path-normalization tests, but is a 365+/112- refactor across mcp.rs/main.rs/engine.rs/runtime_api.rs. Help-forward to maintainer review for MCP pool init ordering in runtime_api/doctor flows before harvest. Credit @cyq1017. | +| #2755 roll back provider after auth failure | Draft / forwarded | Snapshot+rollback of provider/model on auth failure (#2754). Design is sound and tested, but author opened it as draft noting they could not reproduce the live Moonshot auth failure end-to-end. Help-forward: needs maintainer validation against a real provider auth failure (engine respawn + model restore). Credit @cyq1017. | +| #2756 Xiaomi MiMo Token Plan region docs | Mergeable / locally harvested | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai; comment/close after branch is public. Fixes #2735. | +| #2757 hydrated deferred-tool render | Mergeable / locally harvested | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn; comment/close after branch is public. Fixes #2648. | ## Issue Reduction Strategy @@ -168,7 +175,14 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, - #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, and - already-harvested performance PRs. -2. Start file decomposition Phase 1 only after the PR harvest table has no + #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, #2746, + #2747, #2750, #2756, #2757, and already-harvested performance PRs. +2. Help-forward #2751 (workspace MCP config merge) and #2755 (provider auth + rollback): maintainer review of MCP init ordering and a live provider + auth-failure smoke before harvest. +3. Decide the Ollama default-model question raised on #2742: keep the V1-era + `deepseek-coder:1.3b` local default, or move it to a current small model the + completion picker and `default_model()` both reflect. Do not surface the old + model in additional pick-lists in the meantime. +4. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches. From de86cc18602ddc8b8cff432a095a6ff908919a6f Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:50:20 -0700 Subject: [PATCH 067/209] fix(tui): install rustls provider before HTTP clients Install the ring rustls provider through a shared TUI helper and route reqwest client construction through it so no-provider TLS builds do not panic in engine, runtime API, tool, MCP, config, and test paths. Keep the skill-installer integration include compatible with a local helper, and pin prompt byte-stability tests to an isolated home/skills environment under the shared env lock. Verification: cargo fmt --all -- --check; git diff --check; ./scripts/release/check-versions.sh; cargo clippy --workspace --all-features --locked -- -D warnings; cargo test --workspace --all-features --locked; focused skill_install, finance, goal-tool, and MCP reruns. --- CHANGELOG.md | 7 ++++ crates/tui/CHANGELOG.md | 7 ++++ crates/tui/src/client.rs | 2 +- crates/tui/src/config.rs | 2 +- crates/tui/src/config_ui.rs | 2 +- crates/tui/src/core/engine.rs | 2 + crates/tui/src/main.rs | 3 +- crates/tui/src/mcp.rs | 6 +-- crates/tui/src/prompts.rs | 7 +++- crates/tui/src/runtime_api.rs | 58 +++++++++++++-------------- crates/tui/src/sandbox/opensandbox.rs | 2 +- crates/tui/src/skills/install.rs | 15 +++++-- crates/tui/src/tls.rs | 19 +++++++++ crates/tui/src/tools/fetch_url.rs | 2 +- crates/tui/src/tools/finance.rs | 4 +- crates/tui/src/tools/web_run.rs | 6 +-- crates/tui/src/tools/web_search.rs | 12 +++--- crates/tui/src/tui/ui.rs | 2 +- crates/tui/src/vision/tools.rs | 2 +- docs/V0_9_0_EXECUTION_MAP.md | 1 + 20 files changed, 106 insertions(+), 55 deletions(-) create mode 100644 crates/tui/src/tls.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c45011e..64ce52e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dedicated hydrated status, so it is no longer indistinguishable from a real successful execution. A hydrated row also ranks with active work rather than completed successes (#2648). +- TUI HTTP clients now install the Rustls ring crypto provider before building + `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill + download paths. This keeps the no-provider TLS build from panicking during + tests or embedded startup paths that do not enter through the main binary. +- Prompt byte-stability tests now pin their temporary home and skills + environment under the shared test-env lock so global skill directories cannot + perturb deterministic prompt bytes during parallel test runs. ### Community diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 73c45011e..64ce52e0c 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -140,6 +140,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dedicated hydrated status, so it is no longer indistinguishable from a real successful execution. A hydrated row also ranks with active work rather than completed successes (#2648). +- TUI HTTP clients now install the Rustls ring crypto provider before building + `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill + download paths. This keeps the no-provider TLS build from panicking during + tests or embedded startup paths that do not enter through the main binary. +- Prompt byte-stability tests now pin their temporary home and skills + environment under the shared test-env lock so global skill directories cannot + perturb deterministic prompt bytes during parallel test runs. ### Community diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index e53f1877f..81745b8c3 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -625,7 +625,7 @@ impl DeepSeekClient { base_url: &str, ) -> Result { let headers = build_default_headers(api_key, extra_headers, api_provider, base_url)?; - let mut builder = reqwest::Client::builder() + let mut builder = crate::tls::reqwest_client_builder() .default_headers(headers) .user_agent(concat!( "Mozilla/5.0 (compatible; codewhale/", diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 6d7f17acd..3b3bf8623 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5202,7 +5202,7 @@ fn refresh_kimi_oauth_token(refresh_token: &str) -> Result .or_else(|_| std::env::var("KIMI_OAUTH_HOST")) .unwrap_or_else(|_| "https://auth.kimi.com".to_string()); let url = format!("{}/api/oauth/token", oauth_host.trim_end_matches('/')); - let client = reqwest::blocking::Client::builder() + let client = crate::tls::reqwest_blocking_client_builder() .timeout(Duration::from_secs(15)) .build() .context("Failed to build Kimi OAuth refresh client")?; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index d5632befe..0e7a1a6e3 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -405,7 +405,7 @@ pub async fn start_web_editor(app: &App, config: &Config) -> Result = Some(app_snapshot); loop { tokio::time::sleep(Duration::from_millis(750)).await; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 79cfb9b10..27960fe85 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -621,6 +621,8 @@ impl Engine { /// Create a new engine with the given configuration pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { + crate::tls::ensure_rustls_crypto_provider(); + if let Some(objective) = normalized_goal_objective(config.goal_objective.as_deref()) { sync_goal_state_from_host(&config.goal_state, Some(&objective), None, false); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 751d6e4f4..3b024b2c0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -78,6 +78,7 @@ mod task_manager; #[cfg(test)] mod test_support; mod theme_qa_audit; +mod tls; mod tool_output_receipts; mod tools; mod tui; @@ -111,7 +112,7 @@ fn configure_windows_console_utf8() { fn configure_windows_console_utf8() {} fn install_rustls_crypto_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); + crate::tls::ensure_rustls_crypto_provider(); } #[derive(Parser, Debug)] diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 038bfe164..c25fbe32d 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1319,8 +1319,8 @@ impl McpConnection { // local Clash / Shadowsocks tunnel, etc. previously had MCP // HTTP traffic bypass the proxy entirely while every other // tool on the box (curl, npm, …) used it. - let mut client_builder = - reqwest::Client::builder().timeout(Duration::from_secs(connect_timeout_secs)); + let mut client_builder = crate::tls::reqwest_client_builder() + .timeout(Duration::from_secs(connect_timeout_secs)); let env_proxy_url = std::env::var("HTTPS_PROXY") .or_else(|_| std::env::var("https_proxy")) .or_else(|_| std::env::var("HTTP_PROXY")) @@ -2942,7 +2942,7 @@ mod tests { fn test_http_client() -> reqwest::Client { let _ = rustls::crypto::ring::default_provider().install_default(); - reqwest::Client::new() + crate::tls::reqwest_client() } async fn lock_mcp_loopback_tests() -> tokio::sync::MutexGuard<'static, ()> { diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index f505338f0..850e7944f 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -2566,7 +2566,7 @@ mod tests { // in the cached prefix must produce identical bytes given identical // inputs across calls. - use crate::test_support::assert_byte_identical; + use crate::test_support::{EnvVarGuard, assert_byte_identical}; #[test] fn compose_prompt_is_byte_stable_across_calls() { @@ -2592,7 +2592,12 @@ mod tests { // identical bytes. This pins the most representative production // surface (engine.rs builds the system prompt via this fn or // its sibling _and_skills variant on every turn). + let _env_guard = crate::test_support::lock_test_env(); let tmp = tempdir().expect("tempdir"); + let home = tmp.path().join("home"); + let _home = EnvVarGuard::set("HOME", home.as_os_str()); + let _userprofile = EnvVarGuard::set("USERPROFILE", home.as_os_str()); + let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); let workspace = tmp.path(); for mode in [AppMode::Agent, AppMode::Yolo, AppMode::Plan] { diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 6d604b157..8dbf52c29 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -2721,7 +2721,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let health: serde_json::Value = client .get(format!("http://{addr}/health")) @@ -2786,7 +2786,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let health = client .get(format!("http://{addr}/health")) @@ -2825,7 +2825,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let workspace: serde_json::Value = client .get(format!("http://{addr}/v1/workspace/status")) @@ -2949,7 +2949,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!("http://{addr}/v1/stream")) @@ -2966,7 +2966,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -3238,7 +3238,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -3364,7 +3364,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -3587,7 +3587,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); // Create a thread and install a mock engine so /v1/stream doesn't call the real API. let created: serde_json::Value = client @@ -3704,7 +3704,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .get(format!("http://{addr}/v1/sessions/nonexistent_id")) @@ -3721,7 +3721,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let get_resp = client .get(format!("http://{addr}/v1/sessions/invalid%20id")) @@ -3753,7 +3753,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!( @@ -3808,7 +3808,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!( @@ -3849,7 +3849,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -3956,7 +3956,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!("http://{addr}/v1/sessions")) @@ -3974,7 +3974,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -4086,7 +4086,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .delete(format!("http://{addr}/v1/sessions/nonexistent-id")) .send() @@ -4121,7 +4121,7 @@ mod tests { let _ = axum::serve(listener, router).await; }); - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); // The user-supplied origin is allowed. let resp = client @@ -4191,7 +4191,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let created: serde_json::Value = client .post(format!("http://{addr}/v1/threads")) @@ -4277,7 +4277,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); // Two threads — keep one active, archive the other. let active: serde_json::Value = client @@ -4388,7 +4388,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let body: serde_json::Value = client .get(format!("http://{addr}/v1/usage")) @@ -4447,7 +4447,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let info: serde_json::Value = client .get(format!("http://{addr}/v1/runtime/info")) .send() @@ -4478,7 +4478,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let disabled = client.get(format!("http://{addr}/mobile")).send().await?; assert_eq!(disabled.status(), StatusCode::NOT_FOUND); handle.abort(); @@ -4517,7 +4517,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let unauthorized = client.get(format!("http://{addr}/mobile")).send().await?; assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); @@ -4552,7 +4552,7 @@ mod tests { else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let page = client .get(format!("http://{addr}/mobile")) @@ -4577,7 +4577,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!("http://{addr}/v1/approvals/no_such_id")) .json(&json!({ "decision": "allow" })) @@ -4594,7 +4594,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!("http://{addr}/v1/approvals/whatever")) .json(&json!({ "decision": "yolo" })) @@ -4611,7 +4611,7 @@ mod tests { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let rx = runtime_threads.register_pending_approval_for_test("ext_id"); let resp = client @@ -4640,7 +4640,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let body: serde_json::Value = client .get(format!("http://{addr}/v1/skills")) .send() @@ -4663,7 +4663,7 @@ mod tests { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { return Ok(()); }; - let client = reqwest::Client::new(); + let client = crate::tls::reqwest_client(); let resp = client .post(format!("http://{addr}/v1/skills/no-such-skill")) .json(&json!({ "enabled": false })) diff --git a/crates/tui/src/sandbox/opensandbox.rs b/crates/tui/src/sandbox/opensandbox.rs index b2ffa4c3b..5a49eb625 100644 --- a/crates/tui/src/sandbox/opensandbox.rs +++ b/crates/tui/src/sandbox/opensandbox.rs @@ -54,7 +54,7 @@ impl OpenSandboxBackend { /// `Authorization: Bearer ` when set. `timeout_secs` controls the /// HTTP request timeout. pub fn new(base_url: String, api_key: Option, timeout_secs: u64) -> Result { - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_secs(timeout_secs)) .build() .context("failed to construct HTTP client for OpenSandbox backend")?; diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 8262aa372..ace55ed4d 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -45,6 +45,11 @@ use thiserror::Error; use crate::network_policy::{Decision, NetworkPolicy, host_from_url}; +fn reqwest_client() -> reqwest::Client { + let _ = rustls::crypto::ring::default_provider().install_default(); + reqwest::Client::new() +} + /// Cache directory for registry-synced skills. /// /// Lives at `~/.codewhale/cache/skills/` so it's separate from user-installed @@ -497,7 +502,9 @@ pub async fn fetch_registry( Decision::Deny => return Ok(RegistryFetchResult::Denied(host)), Decision::Prompt => return Ok(RegistryFetchResult::NeedsApproval(host)), } - let body = reqwest::get(registry_url) + let body = reqwest_client() + .get(registry_url) + .send() .await .with_context(|| format!("failed to fetch registry {registry_url}"))? .error_for_status() @@ -665,7 +672,7 @@ async fn sync_one_skill( .flatten(); // Build the request — add If-None-Match if we have a cached ETag. - let client = reqwest::Client::new(); + let client = reqwest_client(); let mut req = client.get(url); if let Some(ref meta) = existing_meta && let Some(ref etag) = meta.etag @@ -981,7 +988,9 @@ enum DownloadAttempt { /// would push the buffer over `max_size * 4` (the *4 accounts for compression; /// the unpack step still enforces `max_size` on the *uncompressed* bytes). async fn download_with_cap(url: &str, max_size: u64) -> Result { - let resp = reqwest::get(url) + let resp = reqwest_client() + .get(url) + .send() .await .with_context(|| format!("failed to GET {url}"))?; let status = resp.status(); diff --git a/crates/tui/src/tls.rs b/crates/tui/src/tls.rs new file mode 100644 index 000000000..448f6e51c --- /dev/null +++ b/crates/tui/src/tls.rs @@ -0,0 +1,19 @@ +pub(crate) fn ensure_rustls_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +#[allow(dead_code)] +pub(crate) fn reqwest_client() -> reqwest::Client { + ensure_rustls_crypto_provider(); + reqwest::Client::new() +} + +pub(crate) fn reqwest_client_builder() -> reqwest::ClientBuilder { + ensure_rustls_crypto_provider(); + reqwest::Client::builder() +} + +pub(crate) fn reqwest_blocking_client_builder() -> reqwest::blocking::ClientBuilder { + ensure_rustls_crypto_provider(); + reqwest::blocking::Client::builder() +} diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index 194392af6..a013b0eab 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -163,7 +163,7 @@ impl ToolSpec for FetchUrlTool { let resp = loop { let dns_pinning = validate_fetch_target(¤t_url, context).await?; - let mut client_builder = reqwest::Client::builder() + let mut client_builder = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .redirect(reqwest::redirect::Policy::none()); diff --git a/crates/tui/src/tools/finance.rs b/crates/tui/src/tools/finance.rs index 02331f316..95d705d2c 100644 --- a/crates/tui/src/tools/finance.rs +++ b/crates/tui/src/tools/finance.rs @@ -151,7 +151,7 @@ impl FinanceTool { pub fn new() -> Self { Self { endpoints: FinanceEndpoints::default(), - client: Client::builder() + client: crate::tls::reqwest_client_builder() .user_agent(USER_AGENT) .build() .expect("failed to build HTTP client"), @@ -165,7 +165,7 @@ impl FinanceTool { quote_base: quote_base.into(), chart_base: chart_base.into(), }, - client: Client::builder() + client: crate::tls::reqwest_client_builder() .user_agent(USER_AGENT) .build() .expect("failed to build HTTP client"), diff --git a/crates/tui/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs index f1495d36c..73c176122 100644 --- a/crates/tui/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -774,7 +774,7 @@ async fn run_search( timeout_ms: u64, domains: &[String], ) -> Result<(Vec, String, Option), ToolError> { - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -970,7 +970,7 @@ async fn run_image_search( timeout_ms: u64, domains: &[String], ) -> Result<(Vec, Option), ToolError> { - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -1123,7 +1123,7 @@ fn check_network_policy(url: &str, context: &ToolContext) -> Result<(), ToolErro } async fn fetch_page(url: &str, timeout_ms: u64) -> Result { - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index f5f8595f3..5984d7916 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -242,7 +242,7 @@ impl ToolSpec for WebSearchTool { } let decider = context.network_policy.as_ref(); - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -382,7 +382,7 @@ impl WebSearchTool { ) })?; - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { @@ -479,7 +479,7 @@ impl WebSearchTool { ) })?; - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { @@ -588,7 +588,7 @@ impl WebSearchTool { .or(env_key.as_deref()) .unwrap_or(METASO_DEFAULT_API_KEY); - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { @@ -693,7 +693,7 @@ impl WebSearchTool { ) })?; - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { @@ -778,7 +778,7 @@ impl WebSearchTool { // when it exceeds 90_000 ms. let effective_timeout = timeout_ms.max(90_000); - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .connect_timeout(Duration::from_secs(15)) .timeout(Duration::from_millis(effective_timeout)) .tcp_keepalive(Some(Duration::from_secs(30))) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 8985b2fb0..f957a10b5 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1080,7 +1080,7 @@ const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60); /// Shared `reqwest::Client` for balance fetches so connection pools are /// reused across successive background polls. static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| { - ::reqwest::Client::builder() + crate::tls::reqwest_client_builder() .timeout(Duration::from_secs(10)) .build() .unwrap_or_default() diff --git a/crates/tui/src/vision/tools.rs b/crates/tui/src/vision/tools.rs index f80ad3961..94911b249 100644 --- a/crates/tui/src/vision/tools.rs +++ b/crates/tui/src/vision/tools.rs @@ -23,7 +23,7 @@ pub struct ImageAnalyzeTool { impl ImageAnalyzeTool { #[must_use] pub fn new(config: VisionModelConfig) -> Self { - let client = reqwest::Client::builder() + let client = crate::tls::reqwest_client_builder() .timeout(Duration::from_secs(120)) .build() .expect("Failed to build HTTP client"); diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 513ae876e..dc6d3d8cb 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -155,6 +155,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2755 roll back provider after auth failure | Draft / forwarded | Snapshot+rollback of provider/model on auth failure (#2754). Design is sound and tested, but author opened it as draft noting they could not reproduce the live Moonshot auth failure end-to-end. Help-forward: needs maintainer validation against a real provider auth failure (engine respawn + model restore). Credit @cyq1017. | | #2756 Xiaomi MiMo Token Plan region docs | Mergeable / locally harvested | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai; comment/close after branch is public. Fixes #2735. | | #2757 hydrated deferred-tool render | Mergeable / locally harvested | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn; comment/close after branch is public. Fixes #2648. | +| Local verification sweep stabilizer | Added after the full workspace verification sweep found test-only no-provider TLS panics and prompt byte instability. | Shared TUI Rustls provider helpers now wrap `reqwest` client construction across engine, runtime API, tool, MCP, config, and skill paths; the skill-installer integration include keeps its own local helper. Prompt byte-stability tests pin home and skills env under the shared test-env lock. Evidence: `cargo fmt --all -- --check`, `git diff --check`, `./scripts/release/check-versions.sh`, `cargo clippy --workspace --all-features --locked -- -D warnings`, focused skill/finance/goal/MCP reruns, and `cargo test --workspace --all-features --locked` all passed locally. | ## Issue Reduction Strategy From 47577d59e9a711998ee417a8285c2a7ec8a70a7c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 18:56:25 -0700 Subject: [PATCH 068/209] fix(tui): #2760 correct sessions resume footer Harvested from PR #2760 by @sximelon Fixes #2758 Show the canonical 'codewhale resume ' subcommand in the sessions footer instead of the invalid dispatcher form, and add a parser/footer regression test tying the hint to the actual Resume command. Verification: cargo fmt --all -- --check; git diff --check; ./scripts/release/check-versions.sh; cargo test -p codewhale-tui --bin codewhale-tui --locked sessions_footer_points_to_resume_subcommand -- --nocapture; cargo clippy -p codewhale-tui --bin codewhale-tui --locked -- -D warnings. Co-authored-by: sximelon <15710511+sximelon@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++-- crates/tui/CHANGELOG.md | 8 ++++++-- crates/tui/src/main.rs | 19 ++++++++++++++++++- docs/V0_9_0_EXECUTION_MAP.md | 12 +++++++----- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ce52e0c..04805cf38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dedicated hydrated status, so it is no longer indistinguishable from a real successful execution. A hydrated row also ranks with active work rather than completed successes (#2648). +- `codewhale sessions` now shows `codewhale resume ` in the footer + instead of the invalid dispatcher command `codewhale --resume ` + (#2758, #2760). - TUI HTTP clients now install the Rustls ring crypto provider before building `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill download paths. This keeps the no-provider TLS build from panicking during @@ -150,8 +153,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community -Thanks to **@cyq1017** for the restore-listing implementation (#2513) and -pending-input delivery-mode label work (#2532, #2054), +Thanks to **@sximelon** for reporting and fixing the saved-session resume +footer hint (#2758, #2760), **@cyq1017** for the restore-listing implementation +(#2513) and pending-input delivery-mode label work (#2532, #2054), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 64ce52e0c..04805cf38 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -140,6 +140,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dedicated hydrated status, so it is no longer indistinguishable from a real successful execution. A hydrated row also ranks with active work rather than completed successes (#2648). +- `codewhale sessions` now shows `codewhale resume ` in the footer + instead of the invalid dispatcher command `codewhale --resume ` + (#2758, #2760). - TUI HTTP clients now install the Rustls ring crypto provider before building `reqwest` clients, covering engine, runtime API, tool, MCP, config, and skill download paths. This keeps the no-provider TLS build from panicking during @@ -150,8 +153,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community -Thanks to **@cyq1017** for the restore-listing implementation (#2513) and -pending-input delivery-mode label work (#2532, #2054), +Thanks to **@sximelon** for reporting and fixing the saved-session resume +footer hint (#2758, #2760), **@cyq1017** for the restore-listing implementation +(#2513) and pending-input delivery-mode label work (#2532, #2054), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 3b024b2c0..cb06f5a77 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3827,6 +3827,10 @@ fn rustc_version() -> String { } /// List saved sessions +fn sessions_resume_command() -> &'static str { + "codewhale resume" +} + fn list_sessions(limit: usize, search: Option) -> Result<()> { use crate::palette; use colored::Colorize; @@ -3881,7 +3885,7 @@ fn list_sessions(limit: usize, search: Option) -> Result<()> { println!(); println!( "Resume with: {} {}", - "codewhale --resume".truecolor(blue_r, blue_g, blue_b), + sessions_resume_command().truecolor(blue_r, blue_g, blue_b), "".dimmed() ); println!( @@ -6513,6 +6517,19 @@ mod terminal_mode_tests { assert!(args.continue_session); } + #[test] + fn sessions_footer_points_to_resume_subcommand() { + let cli = parse_cli(&["codewhale", "resume", "abc123"]); + let Some(Commands::Resume { session_id, last }) = cli.command else { + panic!("expected resume command"); + }; + + assert_eq!(session_id.as_deref(), Some("abc123")); + assert!(!last); + assert_eq!(sessions_resume_command(), "codewhale resume"); + assert!(!sessions_resume_command().contains("--resume")); + } + #[test] fn swebench_run_accepts_instance_issue_and_prediction_path() { let cli = parse_cli(&[ diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index dc6d3d8cb..39726eee6 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -1,6 +1,6 @@ # v0.9.0 Execution Map -Snapshot date: 2026-06-04 +Snapshot date: 2026-06-05 This map tracks the v0.9.0 integration branch and keeps the open-PR harvest separate from release publishing. It is a working document: update it whenever a @@ -8,11 +8,12 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts -- Actual open issues: 446 -- Open PRs: 59 -- Repo API open issue count: 505, because GitHub includes PRs in that total +- Actual open issues: 452 +- Open PRs: 71 +- Repo API open issue count: 523, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 -- Open issues without a milestone: 102 +- Open `v0.9.0` milestone items: 135 open, 8 closed +- Open issues without a milestone: 108 ## Execution Order @@ -155,6 +156,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2755 roll back provider after auth failure | Draft / forwarded | Snapshot+rollback of provider/model on auth failure (#2754). Design is sound and tested, but author opened it as draft noting they could not reproduce the live Moonshot auth failure end-to-end. Help-forward: needs maintainer validation against a real provider auth failure (engine respawn + model restore). Credit @cyq1017. | | #2756 Xiaomi MiMo Token Plan region docs | Mergeable / locally harvested | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai; comment/close after branch is public. Fixes #2735. | | #2757 hydrated deferred-tool render | Mergeable / locally harvested | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn; comment/close after branch is public. Fixes #2648. | +| #2760 sessions footer resume command | Harvested on review branch `codex/harvest-2760-session-resume-footer`. | Corrects the `codewhale sessions` footer from `codewhale --resume ` to `codewhale resume `, matching the actual CLI subcommand and fixing the repro from #2758. Added a parser/footer regression test. Credit @sximelon as both reporter and PR author; close/comment after the integration PR is merged. Fixes #2758. | | Local verification sweep stabilizer | Added after the full workspace verification sweep found test-only no-provider TLS panics and prompt byte instability. | Shared TUI Rustls provider helpers now wrap `reqwest` client construction across engine, runtime API, tool, MCP, config, and skill paths; the skill-installer integration include keeps its own local helper. Prompt byte-stability tests pin home and skills env under the shared test-env lock. Evidence: `cargo fmt --all -- --check`, `git diff --check`, `./scripts/release/check-versions.sh`, `cargo clippy --workspace --all-features --locked -- -D warnings`, focused skill/finance/goal/MCP reruns, and `cargo test --workspace --all-features --locked` all passed locally. | ## Issue Reduction Strategy From 1f703bafb375f9c412c922add3a39d3a5719f865 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:01:37 -0700 Subject: [PATCH 069/209] docs(v0.9): record recent harvest closures Update the execution map after closing harvested/superseded PRs #2746, #2747, #2750, #2756, #2757, and #2760, and refresh the live PR count. --- docs/V0_9_0_EXECUTION_MAP.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 39726eee6..daa1d2355 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -9,8 +9,8 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts - Actual open issues: 452 -- Open PRs: 71 -- Repo API open issue count: 523, because GitHub includes PRs in that total +- Open PRs: 65 +- Repo API open issue count: 517, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 - Open `v0.9.0` milestone items: 135 open, 8 closed - Open issues without a milestone: 108 @@ -150,13 +150,13 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | | #2740 dense tool-run collapse follow-up | Mergeable / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Comment/close after the integration branch is public, crediting @idling11. | | #2742 Ollama default model in completions | Mergeable / deferred | Real picker inconsistency (Ollama completion list returned hosted-only `OFFICIAL_DEEPSEEK_MODELS`), but the PR's fix pins the stale `deepseek-coder:1.3b` into another surface. Deferred per maintainer direction: do not entrench the V1-era local default; the live Ollama `/models` path (`client.rs`) already lists actually-installed models. Better fix is dynamic/current-model discovery. Keep #2742 open with a respectful comment; treat "should the Ollama default move off `deepseek-coder:1.3b`" as a separate maintainer decision. Credit @reidliu41. | -| #2746 / #2747 MCP underscore server names | Mergeable / locally harvested | Two competing fixes for #2744. Harvested #2747 (@cyq1017) as `parse_prefixed_name` longest-registered-server match with first-underscore fallback, because it adds the overlapping `my`/`my_db` tie-break test; #2746 (@puneetdixit200) is the equivalent narrower fix and is superseded. `cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool` (3 pass). Comment/close both after the branch is public, crediting both contributors. Fixes #2744. | -| #2750 Xiaomi MiMo pricing | Mergeable / locally harvested | Harvested: `mimo-v2.5-pro`/`xiaomi/mimo-v2.5-pro` reuse DeepSeek V4-Pro rates, `mimo-v2.5`/`xiaomi/mimo-v2.5` reuse V4-Flash rates, via extracted `deepseek_v4_pro_pricing()`/`deepseek_v4_flash_pricing()` helpers. DeepSeek pricing behavior unchanged. `cargo test -p codewhale-tui --bin codewhale-tui --locked pricing` (16 pass). Credit @cyq1017; comment/close after branch is public. Fixes #2731. | +| #2746 / #2747 MCP underscore server names | Harvested; originals closed on 2026-06-05 after public integration branch. | Two competing fixes for #2744. Harvested #2747 (@cyq1017) as `parse_prefixed_name` longest-registered-server match with first-underscore fallback, because it adds the overlapping `my`/`my_db` tie-break test; #2746 (@puneetdixit200) is the equivalent narrower fix and is superseded. `cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool` (3 pass). Both contributors are credited in commit `9e29c221b`; both original PRs were closed with evidence comments. Fixes #2744. | +| #2750 Xiaomi MiMo pricing | Harvested; original closed on 2026-06-05 after public integration branch. | Harvested: `mimo-v2.5-pro`/`xiaomi/mimo-v2.5-pro` reuse DeepSeek V4-Pro rates, `mimo-v2.5`/`xiaomi/mimo-v2.5` reuse V4-Flash rates, via extracted `deepseek_v4_pro_pricing()`/`deepseek_v4_flash_pricing()` helpers. DeepSeek pricing behavior unchanged. `cargo test -p codewhale-tui --bin codewhale-tui --locked pricing` (16 pass). Credit @cyq1017 in commit `9d1396060`. Fixes #2731. | | #2751 merge workspace MCP config | Draft/mergeable / forwarded | Implements project-level MCP config merging (#2749) with path-normalization tests, but is a 365+/112- refactor across mcp.rs/main.rs/engine.rs/runtime_api.rs. Help-forward to maintainer review for MCP pool init ordering in runtime_api/doctor flows before harvest. Credit @cyq1017. | | #2755 roll back provider after auth failure | Draft / forwarded | Snapshot+rollback of provider/model on auth failure (#2754). Design is sound and tested, but author opened it as draft noting they could not reproduce the live Moonshot auth failure end-to-end. Help-forward: needs maintainer validation against a real provider auth failure (engine respawn + model restore). Credit @cyq1017. | -| #2756 Xiaomi MiMo Token Plan region docs | Mergeable / locally harvested | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai; comment/close after branch is public. Fixes #2735. | -| #2757 hydrated deferred-tool render | Mergeable / locally harvested | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn; comment/close after branch is public. Fixes #2648. | -| #2760 sessions footer resume command | Harvested on review branch `codex/harvest-2760-session-resume-footer`. | Corrects the `codewhale sessions` footer from `codewhale --resume ` to `codewhale resume `, matching the actual CLI subcommand and fixing the repro from #2758. Added a parser/footer regression test. Credit @sximelon as both reporter and PR author; close/comment after the integration PR is merged. Fixes #2758. | +| #2756 Xiaomi MiMo Token Plan region docs | Harvested; original closed on 2026-06-05 after public integration branch. | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai in commit `960bdc91c`. Fixes #2735. | +| #2757 hydrated deferred-tool render | Harvested; original closed on 2026-06-05 after public integration branch. | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn in commit `74b326852`. Fixes #2648. | +| #2760 sessions footer resume command | Harvested through review PR #2761, merged into the integration branch on 2026-06-05; original #2760 closed as harvested. | Corrects the `codewhale sessions` footer from `codewhale --resume ` to `codewhale resume `, matching the actual CLI subcommand and fixing the repro from #2758. Added `sessions_footer_points_to_resume_subcommand`; review PR checks (`gate`, GitGuardian) passed and local focused test/clippy/credit audits passed. Credit @sximelon as both reporter and PR author in commit `47577d59e`. Fixes #2758. | | Local verification sweep stabilizer | Added after the full workspace verification sweep found test-only no-provider TLS panics and prompt byte instability. | Shared TUI Rustls provider helpers now wrap `reqwest` client construction across engine, runtime API, tool, MCP, config, and skill paths; the skill-installer integration include keeps its own local helper. Prompt byte-stability tests pin home and skills env under the shared test-env lock. Evidence: `cargo fmt --all -- --check`, `git diff --check`, `./scripts/release/check-versions.sh`, `cargo clippy --workspace --all-features --locked -- -D warnings`, focused skill/finance/goal/MCP reruns, and `cargo test --workspace --all-features --locked` all passed locally. | ## Issue Reduction Strategy @@ -178,8 +178,8 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, - #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, #2746, - #2747, #2750, #2756, #2757, and already-harvested performance PRs. + #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, and + already-harvested performance PRs. 2. Help-forward #2751 (workspace MCP config merge) and #2755 (provider auth rollback): maintainer review of MCP init ordering and a live provider auth-failure smoke before harvest. From 01e5c42bd8678aafc5d85c300f83fc168ee7fd33 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:04:26 -0700 Subject: [PATCH 070/209] docs(v0.9): record plan and tool harvest closures Update the execution map after closing harvested or superseded PRs #2733, #2734, #2736, #2737, #2740, and #2741, and refresh the live PR count. --- docs/V0_9_0_EXECUTION_MAP.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index daa1d2355..2123428a2 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -9,8 +9,8 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts - Actual open issues: 452 -- Open PRs: 65 -- Repo API open issue count: 517, because GitHub includes PRs in that total +- Open PRs: 59 +- Repo API open issue count: 511, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 - Open `v0.9.0` milestone items: 135 open, 8 closed - Open issues without a milestone: 108 @@ -47,13 +47,13 @@ harvest/stewardship commits: | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | | #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2639 POST /v1/sessions endpoint | Locally harvested with the unsafe active-turn snapshot fixed. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord; comment/close the original after the integration branch is public. | -| #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | -| #2741 HarnessPosture data model | Locally harvested with stricter config validation. | Adds typed `HarnessPostureKind`, compaction/tool/safety enums, `HarnessPosture`, `HarnessProfile`, and `ConfigToml.harness_profiles` as the durable v0.9 config model for #2693. The harvest removes the PR's silent unknown-kind catch-all, rejects unknown posture/profile keys, derives whole-struct equality, and keeps runtime wiring as an explicit follow-up. `cargo test -p codewhale-config --locked harness_posture -- --nocapture`, `cargo test -p codewhale-config --locked harness_profile -- --nocapture`, `cargo test -p codewhale-config --locked config_toml_accepts_harness_profiles -- --nocapture`, and `cargo clippy -p codewhale-config --locked -- -D warnings` passed. Credit @idling11; close/comment after the integration branch is public. Keep #2693 open for provider/model selection, prompt/tool/runtime behavior, telemetry, and docs once wiring lands. | -| #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | -| #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | -| #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | -| #2740 dense tool-run collapse follow-up | Superseded by the local #2738 harvest. | The PR carries the same #2692 product direction but its reviewed head still depended on folded-thinking state before collapse could render and omitted MCP status/name handling. The local #2738 harvest already covers common-case collapse, MCP success/tool-name grouping, expansion/cell-map behavior, and `tool_collapse` modes with focused transcript-collapse tests. Credit @idling11; comment/close after the integration branch is public. | -| #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | +| #2733 PlanArtifact for Plan mode | Harvested; original closed on 2026-06-05 after public integration branch. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @idling11 in commit `7ac8063b6`; keep #2691 open only for remaining PlanReview product scope. | +| #2741 HarnessPosture data model | Harvested; original closed on 2026-06-05 after public integration branch. | Adds typed `HarnessPostureKind`, compaction/tool/safety enums, `HarnessPosture`, `HarnessProfile`, and `ConfigToml.harness_profiles` as the durable v0.9 config model for #2693. The harvest removes the PR's silent unknown-kind catch-all, rejects unknown posture/profile keys, derives whole-struct equality, and keeps runtime wiring as an explicit follow-up. `cargo test -p codewhale-config --locked harness_posture -- --nocapture`, `cargo test -p codewhale-config --locked harness_profile -- --nocapture`, `cargo test -p codewhale-config --locked config_toml_accepts_harness_profiles -- --nocapture`, and `cargo clippy -p codewhale-config --locked -- -D warnings` passed. Credit @idling11 in commit `586640a43`; keep #2693 open for provider/model selection, prompt/tool/runtime behavior, telemetry, and docs once wiring lands. | +| #2736 sub-agent model inheritance | Harvested; original closed on 2026-06-05 after public integration branch. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin in commit `55024a16d`. | +| #2737 configured `skills_dir` discovery | Harvested; original closed on 2026-06-05 after public integration branch. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin in commit `9719b45cd`. | +| #2738 dense tool-call transcript collapse | Harvested; original already closed, and #2740 follow-up closed as superseded on 2026-06-05. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692 in commit `c76ec4752`. | +| #2740 dense tool-run collapse follow-up | Closed as superseded by the local #2738 harvest on 2026-06-05. | The PR carries the same #2692 product direction but its reviewed head still depended on folded-thinking state before collapse could render and omitted MCP status/name handling. The local #2738 harvest already covers common-case collapse, MCP success/tool-name grouping, expansion/cell-map behavior, and `tool_collapse` modes with focused transcript-collapse tests. Credit @idling11 in changelog/execution-map notes. | +| #2734 sidebar detail popovers | Harvested; original closed on 2026-06-05 after public integration branch. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11 in commit `3cb49233e`; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels plus #2054 queued-edit recovery | Locally re-harvested and extended for #2054. | Pending-input preview rows label steer-pending, rejected-steer, queued-follow-up, and editing-queued-follow-up delivery modes. The accidental ↑ edit path is test-covered while loading, and `Esc` restores the original queued follow-up before cancelling the active turn. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture`, `... queued_draft ...`, and `... accidental_queue_edit_while_loading_is_labeled_and_recoverable ...` passed. Credit @cyq1017; leave #2054 open only if row-level edit/drop/send controls are still required beyond the composer recovery fix. | | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | | #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | @@ -143,12 +143,12 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | -| #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | -| #2734 sidebar detail popovers | Mergeable / locally harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Comment on the original after the integration branch is public, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | -| #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | -| #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | -| #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | -| #2740 dense tool-run collapse follow-up | Mergeable / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Comment/close after the integration branch is public, crediting @idling11. | +| #2733 PlanArtifact UI | Closed / harvested | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Original closed on 2026-06-05, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | +| #2734 sidebar detail popovers | Closed / harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Original closed on 2026-06-05, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | +| #2736 sub-agent model inheritance | Closed / harvested | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Original closed on 2026-06-05, crediting @h3c-hexin. | +| #2737 configured `skills_dir` discovery | Closed / harvested | Locally harvested with extra configured-before-global precedence tests. Original closed on 2026-06-05, crediting @h3c-hexin. | +| #2738 dense tool-call transcript collapse | Closed / harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Original was already closed; #2740 follow-up is now closed as superseded. | +| #2740 dense tool-run collapse follow-up | Closed / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Closed on 2026-06-05, crediting @idling11. | | #2742 Ollama default model in completions | Mergeable / deferred | Real picker inconsistency (Ollama completion list returned hosted-only `OFFICIAL_DEEPSEEK_MODELS`), but the PR's fix pins the stale `deepseek-coder:1.3b` into another surface. Deferred per maintainer direction: do not entrench the V1-era local default; the live Ollama `/models` path (`client.rs`) already lists actually-installed models. Better fix is dynamic/current-model discovery. Keep #2742 open with a respectful comment; treat "should the Ollama default move off `deepseek-coder:1.3b`" as a separate maintainer decision. Credit @reidliu41. | | #2746 / #2747 MCP underscore server names | Harvested; originals closed on 2026-06-05 after public integration branch. | Two competing fixes for #2744. Harvested #2747 (@cyq1017) as `parse_prefixed_name` longest-registered-server match with first-underscore fallback, because it adds the overlapping `my`/`my_db` tie-break test; #2746 (@puneetdixit200) is the equivalent narrower fix and is superseded. `cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool` (3 pass). Both contributors are credited in commit `9e29c221b`; both original PRs were closed with evidence comments. Fixes #2744. | | #2750 Xiaomi MiMo pricing | Harvested; original closed on 2026-06-05 after public integration branch. | Harvested: `mimo-v2.5-pro`/`xiaomi/mimo-v2.5-pro` reuse DeepSeek V4-Pro rates, `mimo-v2.5`/`xiaomi/mimo-v2.5` reuse V4-Flash rates, via extracted `deepseek_v4_pro_pricing()`/`deepseek_v4_flash_pricing()` helpers. DeepSeek pricing behavior unchanged. `cargo test -p codewhale-tui --bin codewhale-tui --locked pricing` (16 pass). Credit @cyq1017 in commit `9d1396060`. Fixes #2731. | @@ -178,8 +178,8 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, - #2576, #2581, #2627, #2634, #2636, #2639, #2687, #2736, #2737, #2738, and - already-harvested performance PRs. + #2576, #2581, #2627, #2634, #2636, #2639, #2687, and already-harvested + performance PRs. 2. Help-forward #2751 (workspace MCP config merge) and #2755 (provider auth rollback): maintainer review of MCP init ordering and a live provider auth-failure smoke before harvest. From 28d6b107697ca85f354922771fcda20f56a67eb0 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:08:20 -0700 Subject: [PATCH 071/209] docs(v0.9): record runtime and legacy harvest closures Update the execution map after closing harvested or superseded PRs #2476, #2498, #2502, #2513, #2530, #2576, #2581, #2636, #2639, #2640, #2708, and #2730, and refresh the live PR count. --- docs/V0_9_0_EXECUTION_MAP.md | 53 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 2123428a2..5a0c7fb58 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -9,8 +9,8 @@ PR is harvested, superseded, deferred, or closed. ## Live Counts - Actual open issues: 452 -- Open PRs: 59 -- Repo API open issue count: 511, because GitHub includes PRs in that total +- Open PRs: 47 +- Repo API open issue count: 499, because GitHub includes PRs in that total - Open issues labeled `v0.9.0`: 119 - Open `v0.9.0` milestone items: 135 open, 8 closed - Open issues without a milestone: 108 @@ -41,12 +41,12 @@ harvest/stewardship commits: | PR | Disposition | Evidence / next step | | --- | --- | --- | -| #2708 Windows sub-agent completion halves TUI render width | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. | `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2708 Windows sub-agent completion halves TUI render width | Harvested; original closed on 2026-06-05 after public integration branch. | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Broader Windows resize/IME manual smoke remains in #2721. | | #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | -| #2730 canonical codewhale settings path | Already harvested as `9e15805f6`; follow-up reviewer assertion added on this branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | +| #2730 canonical CodeWhale settings path | Harvested; original closed on 2026-06-05 after public integration branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. Harvested as `9e15805f6`; follow-up assertion `fb86737a8` covers the platform-config fallback display. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | | Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | -| #2640 workspace field on UpdateThreadRequest | Harvested with the stale-engine fix restored. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | -| #2639 POST /v1/sessions endpoint | Locally harvested with the unsafe active-turn snapshot fixed. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord; comment/close the original after the integration branch is public. | +| #2640 workspace field on UpdateThreadRequest | Harvested; original closed on 2026-06-05 after public integration branch. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @gaord in commit `66c88ddfa`. | +| #2639 POST /v1/sessions endpoint | Harvested; original closed on 2026-06-05 after public integration branch. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord in commit `333275162`. | | #2733 PlanArtifact for Plan mode | Harvested; original closed on 2026-06-05 after public integration branch. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @idling11 in commit `7ac8063b6`; keep #2691 open only for remaining PlanReview product scope. | | #2741 HarnessPosture data model | Harvested; original closed on 2026-06-05 after public integration branch. | Adds typed `HarnessPostureKind`, compaction/tool/safety enums, `HarnessPosture`, `HarnessProfile`, and `ConfigToml.harness_profiles` as the durable v0.9 config model for #2693. The harvest removes the PR's silent unknown-kind catch-all, rejects unknown posture/profile keys, derives whole-struct equality, and keeps runtime wiring as an explicit follow-up. `cargo test -p codewhale-config --locked harness_posture -- --nocapture`, `cargo test -p codewhale-config --locked harness_profile -- --nocapture`, `cargo test -p codewhale-config --locked config_toml_accepts_harness_profiles -- --nocapture`, and `cargo clippy -p codewhale-config --locked -- -D warnings` passed. Credit @idling11 in commit `586640a43`; keep #2693 open for provider/model selection, prompt/tool/runtime behavior, telemetry, and docs once wiring lands. | | #2736 sub-agent model inheritance | Harvested; original closed on 2026-06-05 after public integration branch. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin in commit `55024a16d`. | @@ -58,14 +58,14 @@ harvest/stewardship commits: | #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | | #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | -| #2636 project-context context-signature cache | Locally harvested with widened invalidation. | Project context hot-path loads now use a bounded process-local cache keyed by canonical workspace plus content fingerprints for workspace/parent instructions, global AGENTS/WHALE fallbacks, repo constitution candidates, generated-context targets, trust markers, and trust config paths. The wrapper stores under a post-load signature so auto-generated `.codewhale/instructions.md` deletion/regeneration stays correct. `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context -- --nocapture` passed. Credit @HUQIANTAO; comment/close #2636 after the integration branch is public. | +| #2636 project-context context-signature cache | Harvested; original closed on 2026-06-05 after public integration branch. | Project context hot-path loads now use a bounded process-local cache keyed by canonical workspace plus content fingerprints for workspace/parent instructions, global AGENTS/WHALE fallbacks, repo constitution candidates, generated-context targets, trust markers, and trust config paths. The wrapper stores under a post-load signature so auto-generated `.codewhale/instructions.md` deletion/regeneration stays correct. `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context -- --nocapture` passed. Credit @HUQIANTAO in commit `e18f072a5`. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `./scripts/release/check-ohos-deps.sh` now guards the OHOS graph against `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, and `keyring`; `cargo check --workspace --all-features --locked` and focused PTY/clipboard tests passed. Full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | -| #2581 provider fallback chain design doc | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head has no net file changes. | Keep issue #2574 open for implementation; close/comment on #2581 after the integration branch is public, crediting @idling11 and reporter @hsdbeebou. | -| #2530 mention depth-cap hint | Already present in the current v0.9 stack as `a97675824` and `29f57665e`. | `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | -| #2513 restore snapshot listing | Manually harvested as `bb39cf169` with explicit `/restore list 101` cap rejection. | `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | -| #2576 PrefixCacheChange first-freeze event | Already present in the current v0.9 stack through `29acb87a9d`. | `cargo test -p codewhale-tui --locked prefix_cache` passed. Do not close until this integration branch is public or merged. | -| #2502 web_run RwLock split | Manually harvested with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. | `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | +| #2581 provider fallback chain design doc | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head had no net file changes. Credit @idling11 in commit `5dc1a63cd`; keep issue #2574 open for implementation. | +| #2530 mention depth-cap hint | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack as `a97675824` and `29f57665e`. `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | +| #2513 restore snapshot listing | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `311eb4002` with explicit `/restore list 101` cap rejection. `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | +| #2576 PrefixCacheChange first-freeze event | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack through `29acb87a9d`. `cargo test -p codewhale-tui --locked prefix_cache` passed. | +| #2502 web_run RwLock split | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `60f8e7d62` with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | | #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | ## Stabilization Gate Evidence (#2721) @@ -77,7 +77,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | --- | --- | --- | | Windows IME/input recovery (#1835) | Partially fixed, still release-blocking. | Current branch has Windows IME recovery and char-routing tests, but the issue remains open with Windows/WSL reports. Needs a real Windows Terminal IME smoke for focus loss, idle, mode switch, first keystroke, and Esc recovery. | | Windows width/resize (#2708, #582 class) | Partially fixed on this branch. | #2708 is cherry-picked plus the fanout-card cache invalidation follow-up. `cargo test -p codewhale-tui --bin codewhale-tui --locked terminal_size -- --nocapture` passed. Still needs a real Windows Terminal resize smoke for #582 before #2721 closes. | -| Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 should close as harvested, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | +| Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 closed as harvested on 2026-06-05, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | | Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed, and the auto-generated fallback now has a synthetic 1000-file startup smoke with `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture`. Still needs a real massive-repo/manual startup benchmark before closing #697 or #1827. | | Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | | Sub-agent checkpoint/resume (#2029) | Partially covered. | Live per-step API timeout now preserves a continuable checkpoint and `agent_eval { continue: true }` resumes the parked child; `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture` passed with checkpoint/projection/persistence/continuation coverage. Cold-restart continuation is not implemented because persisted child tasks are not rehydrated; decide whether #2029 can close as live-timeout recovery or should remain open for restart-resume UX. | @@ -100,14 +100,14 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2269 approval details and shell previews | Conflicting / locally harvested | Narrow UI slice landed manually: approval cards now show prominent command/dir/file/path/target rows, preserve #2381 intent summaries, classify live shell companion tools as shell, split common shell chains, and show compact simple `printf > file` previews. Do not merge the broader diff-preview/pager rewrite. Close/comment after branch is public, crediting @tdccccc for #1991/#2269. | | #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. | | #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. | -| #2476 fork migration parent links | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Close/comment original after the integration branch is public, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. | +| #2476 fork migration parent links | Closed / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Original closed on 2026-06-05, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. | | #2479 ProviderKind/ApiProvider trait collapse | Conflicting | Defer until file decomposition Phase 1 reduces config surface. | | #2482 WhaleFlow orchestration | Draft/conflicting | Inspect for IR ideas; do not merge wholesale. | | #2486 WhaleFlow cost tracking | Draft/conflicting | Inspect after #2482; harvest telemetry ideas only. | | #2491 typed ask permissions schema | Conflicting | Prior memory says safe candidate; verify current permissions work first. | -| #2498 Windows shell process trees | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch through the Windows JobObject cleanup commits. Close/comment PR #2498 as harvested, crediting @aboimpinto; leave issue #1812 open because this fixes descendant pipe-handle hangs but not every reported Windows input-poll freeze mode. | +| #2498 Windows shell process trees | Closed / already harvested | Patch-equivalent work is already present on `origin/main` and this branch through the Windows JobObject cleanup commits. Original closed on 2026-06-05, crediting @aboimpinto; leave issue #1812 open because this fixes descendant pipe-handle hangs but not every reported Windows input-poll freeze mode. | | #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | -| #2502 web_run RwLock split | Mergeable | Manually harvested with panic-safety and shared cached-page reads; close/comment after branch is public. | +| #2502 web_run RwLock split | Closed / harvested | Manually harvested with panic-safety and shared cached-page reads; original closed on 2026-06-05. | | #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | | #2506 provider path suffix overrides | Draft/conflicting / superseded | The current branch already contains provider-table `path_suffix` support from #2558 with the safer constrained behavior: only `chat/completions` uses the override, while `models` and DeepSeek `beta/*` keep their built-in routing. `cargo test -p codewhale-tui --bin codewhale-tui --locked api_url_with_suffix -- --nocapture` passed. Credit @cyq1017 for the earlier design/review trail; comment/close after branch is public, keeping #1874 tied to the shipped #2558 implementation/docs. | | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | @@ -116,18 +116,18 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | | #2512 custom completion sounds | Draft/conflicting | Defer. | -| #2513 restore snapshot listing | Draft/mergeable | Manually harvested as `bb39cf169` with cap-rejection polish; close/comment after branch is public, leave #2494 open. | +| #2513 restore snapshot listing | Closed / harvested | Manually harvested as `311eb4002` with cap-rejection polish; original closed on 2026-06-05, leave #2494 open. | | #2517 turn_meta tail relocation | Mergeable | Manually harvested on the v0.9 branch; close/comment after branch is public. | | #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. | | #2522 hard compaction preserving system segment | Mergeable | Defer. Review found a dormant hard path that would duplicate/cache summaries into the mutable system prompt if wired through current engine flow, and a simple tail split that can break tool-call pair and pinning invariants. | | #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | | #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | -| #2530 mention depth cap hint | Draft/mergeable | Already present locally as `a97675824` and `29f57665e`; close/comment after branch is public. | -| #2576 PrefixCacheChange events | Mergeable | Already present locally through `29acb87a9d`; close/comment after branch is public or merged. | +| #2530 mention depth cap hint | Closed / already present | Already present locally as `a97675824` and `29f57665e`; original closed on 2026-06-05. | +| #2576 PrefixCacheChange events | Closed / already present | Already present locally through `29acb87a9d`; original closed on 2026-06-05. | | #2578 turn_end observer hook | Conflicting / locally harvested | Narrow Rust/docs slice landed in the hook lifecycle lane: `turn_end` now uses the existing structured observer path, fires after post-turn state updates and before queued follow-up dispatch, and includes status, usage, totals, duration, tool count, and queued-message count. Close/comment after branch is public, crediting @AresNing and #1364 reporter @esinecan. | | #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | -| #2581 provider fallback chain design doc | Mergeable / empty diff | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; close original PR after branch is public, keep #2574 open for implementation. | +| #2581 provider fallback chain design doc | Closed / harvested | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; original closed on 2026-06-05, keep #2574 open for implementation. | | #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | | #2627 Xiaomi MiMo Token Plan mode | Conflicting | Partially harvested; leave original open or comment with remaining mode/env scope once branch is public. | | #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | @@ -135,13 +135,13 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2633 capacity reverse scans | Mergeable | Already harvested into the 22-commit stack. | | #2634 HarmonyOS port | Draft / locally harvested | Harvested with credit and extra Nix-chain fixes. Keep the original PR open for now; comment after the integration branch is public and request a real OHOS SDK build confirmation from the contributor before closing. | | #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | -| #2636 project-context cache | Conflicting | Defer/harvest only after cache correctness fixes. | -| #2639 POST /v1/sessions endpoint | Mergeable / locally harvested | Harvested with a 409 guard for queued/in-progress turns/items, 404 missing-thread mapping, saved-session metadata preservation, and focused session endpoint tests. Comment/close after the integration branch is public, crediting @gaord. | -| #2640 workspace field on UpdateThreadRequest | Mergeable | Harvested locally with extra tests and engine-cache invalidation. Comment/close original after integration branch is public, crediting @gaord. | +| #2636 project-context cache | Closed / harvested | Harvested after widened invalidation fixes; original closed on 2026-06-05, crediting @HUQIANTAO. | +| #2639 POST /v1/sessions endpoint | Closed / harvested | Harvested with a 409 guard for queued/in-progress turns/items, 404 missing-thread mapping, saved-session metadata preservation, and focused session endpoint tests. Original closed on 2026-06-05, crediting @gaord. | +| #2640 workspace field on UpdateThreadRequest | Closed / harvested | Harvested locally with extra tests and engine-cache invalidation. Original closed on 2026-06-05, crediting @gaord. | | #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | | #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | -| #2708 Windows width fix | Mergeable | Cherry-picked and patched locally. | -| #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | +| #2708 Windows width fix | Closed / harvested | Cherry-picked and patched locally; original closed on 2026-06-05. Broader Windows resize smoke remains in #2721. | +| #2730 canonical CodeWhale settings path | Closed / harvested | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Original closed on 2026-06-05, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | | #2733 PlanArtifact UI | Closed / harvested | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Original closed on 2026-06-05, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | | #2734 sidebar detail popovers | Closed / harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Original closed on 2026-06-05, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | @@ -177,8 +177,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions -1. Prepare public comments for #2476, #2498, #2708, #2502, #2513, #2530, - #2576, #2581, #2627, #2634, #2636, #2639, #2687, and already-harvested +1. Prepare public comments for #2627, #2634, #2687, and already-harvested performance PRs. 2. Help-forward #2751 (workspace MCP config merge) and #2755 (provider auth rollback): maintainer review of MCP init ordering and a live provider From 1e54be062050839409cf926dc467626d4b7e3c3e Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:26:26 -0700 Subject: [PATCH 072/209] fix(tui): refresh branch status after shell changes --- crates/tui/src/tui/tool_routing.rs | 18 +++ crates/tui/src/tui/ui/tests.rs | 163 ++++++++++++++++++++++++ crates/tui/src/tui/workspace_context.rs | 14 ++ 3 files changed, 195 insertions(+) diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index bb88b73ff..1c69043a1 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -15,6 +15,7 @@ use crate::tui::history::{ WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args, summarize_tool_output, }; +use crate::tui::workspace_context; #[allow(clippy::too_many_lines)] pub(super) fn handle_tool_call_started( @@ -647,6 +648,10 @@ pub(super) fn handle_tool_call_complete( refresh_active_tool_completion_timestamp(app, cell_index); } + if refreshes_workspace_context_on_completion(name) && status != ToolStatus::Running { + workspace_context::refresh_now(app, Instant::now()); + } + // #455 (observer-only): fire `tool_call_after` hooks once the // result has settled. Hooks see tool_name + the result content // (or error message) + success flag. Read-only — they cannot @@ -859,6 +864,19 @@ fn is_exec_tool(name: &str) -> bool { ) } +fn refreshes_workspace_context_on_completion(name: &str) -> bool { + matches!( + name, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_wait" + | "exec_interact" + | "task_shell_start" + | "task_shell_wait" + ) +} + pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String { let fallback = format!("{name} tool"); let obj = input.as_object(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 88ff3b9d6..03f26e5e0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4504,6 +4504,169 @@ fn workspace_context_refresh_respects_ttl_before_requerying_git() { assert_ne!(refreshed, initial); } +#[test] +fn completed_exec_tool_refreshes_workspace_context_before_ttl() { + let repo = init_git_repo(); + let checkout = Command::new("git") + .args(["checkout", "-b", "feature/old-branch"]) + .current_dir(repo.path()) + .output() + .expect("git checkout should run"); + assert!( + checkout.status.success(), + "git checkout failed: {}", + String::from_utf8_lossy(&checkout.stderr) + ); + + let mut app = create_test_app(); + app.workspace = repo.path().to_path_buf(); + + let start = Instant::now(); + crate::tui::workspace_context::refresh_if_needed(&mut app, start, true); + let initial = app + .workspace_context + .clone() + .expect("initial refresh should populate context"); + assert!( + initial.contains("feature/old-branch"), + "expected initial branch in {initial:?}" + ); + + let checkout = Command::new("git") + .args(["checkout", "-b", "feature/new-branch"]) + .current_dir(repo.path()) + .output() + .expect("git checkout should run"); + assert!( + checkout.status.success(), + "git checkout failed: {}", + String::from_utf8_lossy(&checkout.stderr) + ); + + let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1); + crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true); + assert_eq!( + app.workspace_context.as_deref(), + Some(initial.as_str()), + "normal refresh should still respect the TTL" + ); + + handle_tool_call_started( + &mut app, + "shell-branch", + "exec_shell", + &serde_json::json!({"command": "git checkout -b feature/new-branch"}), + ); + handle_tool_call_complete( + &mut app, + "shell-branch", + "exec_shell", + &ok_result("switched"), + ); + + let refreshed = app + .workspace_context + .as_deref() + .expect("shell completion should refresh context"); + assert!( + refreshed.contains("feature/new-branch"), + "expected refreshed branch in {refreshed:?}" + ); +} + +#[test] +fn completed_task_shell_wait_refreshes_workspace_context_before_ttl() { + let repo = init_git_repo(); + let checkout = Command::new("git") + .args(["checkout", "-b", "feature/task-old"]) + .current_dir(repo.path()) + .output() + .expect("git checkout should run"); + assert!( + checkout.status.success(), + "git checkout failed: {}", + String::from_utf8_lossy(&checkout.stderr) + ); + + let mut app = create_test_app(); + app.workspace = repo.path().to_path_buf(); + + let start = Instant::now(); + crate::tui::workspace_context::refresh_if_needed(&mut app, start, true); + let initial = app + .workspace_context + .clone() + .expect("initial refresh should populate context"); + assert!( + initial.contains("feature/task-old"), + "expected initial branch in {initial:?}" + ); + + let checkout = Command::new("git") + .args(["checkout", "-b", "feature/task-new"]) + .current_dir(repo.path()) + .output() + .expect("git checkout should run"); + assert!( + checkout.status.success(), + "git checkout failed: {}", + String::from_utf8_lossy(&checkout.stderr) + ); + + let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1); + crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true); + assert_eq!( + app.workspace_context.as_deref(), + Some(initial.as_str()), + "normal refresh should still respect the TTL" + ); + + handle_tool_call_started( + &mut app, + "task-shell-branch", + "task_shell_wait", + &serde_json::json!({"task_id": "shell_1"}), + ); + handle_tool_call_complete( + &mut app, + "task-shell-branch", + "task_shell_wait", + &ok_result("completed"), + ); + + let refreshed = app + .workspace_context + .as_deref() + .expect("task shell completion should refresh context"); + assert!( + refreshed.contains("feature/task-new"), + "expected refreshed branch in {refreshed:?}" + ); +} + +#[test] +fn workspace_context_drain_requests_redraw_when_context_changes() { + let mut app = create_test_app(); + app.workspace_context = Some("feature/old | clean".to_string()); + app.workspace_context_refreshed_at = Some(Instant::now()); + app.needs_redraw = false; + { + let mut cell = app.workspace_context_cell.lock().expect("context cell"); + *cell = Some("feature/new | clean".to_string()); + } + + crate::tui::workspace_context::refresh_if_needed(&mut app, Instant::now(), false); + + assert_eq!( + app.workspace_context.as_deref(), + Some("feature/new | clean") + ); + assert!( + app.needs_redraw, + "draining a changed async context should redraw the footer" + ); +} + #[tokio::test] async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/workspace_context.rs b/crates/tui/src/tui/workspace_context.rs index 696ac83ef..1db22bcc8 100644 --- a/crates/tui/src/tui/workspace_context.rs +++ b/crates/tui/src/tui/workspace_context.rs @@ -27,6 +27,9 @@ pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool if let Ok(mut cell) = app.workspace_context_cell.lock() && let Some(ctx) = cell.take() { + if app.workspace_context.as_deref() != Some(ctx.as_str()) { + app.needs_redraw = true; + } app.workspace_context = Some(ctx); } @@ -63,6 +66,17 @@ pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool app.workspace_context_refreshed_at = Some(now); } +/// Force a workspace-context re-query on the next render tick, bypassing the +/// normal TTL. Keeps the current value visible while the background git query +/// is running. +pub(super) fn refresh_now(app: &mut App, now: Instant) { + if let Ok(mut cell) = app.workspace_context_cell.lock() { + *cell = None; + } + app.workspace_context_refreshed_at = None; + refresh_if_needed(app, now, true); +} + #[derive(Debug, Default, Clone, Copy)] struct ChangeSummary { staged: usize, From 494c8a53516fb235499f048afb276b9d656c1c75 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:30:38 -0700 Subject: [PATCH 073/209] fix(tui): gate shell child kill helper off Windows --- crates/tui/src/tools/shell.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 78c973331..c8eacca91 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -396,6 +396,7 @@ impl ShellChild { } } + #[cfg(not(windows))] fn kill(&mut self) -> std::io::Result<()> { match self { #[cfg(unix)] From cbb114249767bb8eff28040347453b0364490c0e Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:34:52 -0700 Subject: [PATCH 074/209] test(shell): #2528 widen background completion wait Widen the focused background-shell completion wait used by shell tests so slow Windows runners do not leave lightweight background commands reported as Running before assertions fire. Refs #2525 Refs #2526 Harvested from PR #2528 by @cyq1017 Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- crates/tui/src/tools/shell/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 46b7c35b3..9708ea562 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -21,6 +21,8 @@ fn env_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000; + #[cfg(windows)] const JOB_OBJECT_QUERY_ACCESS: u32 = 0x0004; @@ -133,7 +135,7 @@ fn failed_network_shell_result(stdout: &str, stderr: &str) -> ShellResult { } fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellResult { - let deadline = Instant::now() + Duration::from_secs(20); + let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS); loop { let result = manager @@ -801,7 +803,7 @@ async fn test_completed_background_shell_releases_process_handles() { json!({ "task_id": task_id.clone(), "wait": true, - "timeout_ms": 5_000 + "timeout_ms": BACKGROUND_COMPLETION_WAIT_MS }), &ctx, ) From 933637bb1c2c0237e233ae536659aa353d41217d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:48:45 -0700 Subject: [PATCH 075/209] feat(search): harvest custom duckduckgo endpoint Add optional [search].base_url support for DuckDuckGo-compatible private search endpoints, including a preferred CODEWHALE_SEARCH_BASE_URL env override and the legacy DEEPSEEK_SEARCH_BASE_URL alias. Network policy now gates the configured endpoint host, custom endpoints do not fall back to public Bing, non-DuckDuckGo provider/base_url combinations and challenge pages return explicit errors, and custom endpoint results report the configured host as their source. Fixes #2436 Reported by @Artenx Harvested from PR #2510 by @cyq1017 Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 11 +- config.example.toml | 3 + crates/tui/CHANGELOG.md | 11 +- crates/tui/src/config.rs | 85 ++++++++++++ crates/tui/src/core/engine.rs | 4 + crates/tui/src/main.rs | 3 + crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/spec.rs | 5 + crates/tui/src/tools/web_search.rs | 208 ++++++++++++++++++++++++++--- crates/tui/src/tui/ui.rs | 1 + docs/CONFIGURATION.md | 8 ++ docs/V0_9_0_EXECUTION_MAP.md | 6 +- 12 files changed, 323 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04805cf38..d03fc62c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for + DuckDuckGo-compatible private search endpoints, while keeping + `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by + their configured host, do not fall back to public Bing, and report the custom + host as the result source for diagnostics (#2436, #2510). ### Changed @@ -154,8 +159,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@sximelon** for reporting and fixing the saved-session resume -footer hint (#2758, #2760), **@cyq1017** for the restore-listing implementation -(#2513) and pending-input delivery-mode label work (#2532, #2054), +footer hint (#2758, #2760), **@cyq1017** for the custom +DuckDuckGo-compatible search endpoint, restore-listing implementation, and +pending-input delivery-mode label work (#2510, #2513, #2532, #2054), +**@Artenx** for the private-search endpoint report (#2436), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/config.example.toml b/config.example.toml index b53435359..f4b8bd791 100644 --- a/config.example.toml +++ b/config.example.toml @@ -409,6 +409,7 @@ max_subagents = 10 # optional (1-20) # # baidu: 百度 AI Search via qianfan.baidubce.com,需 api_key # # volcengine: 火山引擎 Ark web_search (免费 2 万次/月), 需 api_key # # 也回退到 VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY 环境变量 +# base_url = "https://search.example/html/" # optional DuckDuckGo-compatible HTML endpoint # api_key = "YOUR_SEARCH_KEY" # required for tavily, bocha, and baidu; optional for metaso # # WARNING: treat config.toml like a secret file when # # storing API keys. Prefer env vars for local smoke tests. @@ -416,6 +417,8 @@ max_subagents = 10 # optional (1-20) # Env-var overrides: # DEEPSEEK_SEARCH_PROVIDER → search.provider # DEEPSEEK_SEARCH_API_KEY → search.api_key +# CODEWHALE_SEARCH_BASE_URL → search.base_url +# DEEPSEEK_SEARCH_BASE_URL → search.base_url (legacy alias) # METASO_API_KEY → metaso key fallback # BAIDU_SEARCH_API_KEY → baidu key fallback diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 04805cf38..d03fc62c9 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -41,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 were previously unpriced: `mimo-v2.5-pro` / `xiaomi/mimo-v2.5-pro` reuse the DeepSeek V4-Pro rate table and `mimo-v2.5` / `xiaomi/mimo-v2.5` reuse the DeepSeek V4-Flash rates. Existing DeepSeek pricing is unchanged (#2731, #2750). +- Added optional `[search].base_url` / `CODEWHALE_SEARCH_BASE_URL` support for + DuckDuckGo-compatible private search endpoints, while keeping + `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by + their configured host, do not fall back to public Bing, and report the custom + host as the result source for diagnostics (#2436, #2510). ### Changed @@ -154,8 +159,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Community Thanks to **@sximelon** for reporting and fixing the saved-session resume -footer hint (#2758, #2760), **@cyq1017** for the restore-listing implementation -(#2513) and pending-input delivery-mode label work (#2532, #2054), +footer hint (#2758, #2760), **@cyq1017** for the custom +DuckDuckGo-compatible search endpoint, restore-listing implementation, and +pending-input delivery-mode label work (#2510, #2513, #2532, #2054), +**@Artenx** for the private-search endpoint report (#2436), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 3b3bf8623..184b3da98 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1116,6 +1116,11 @@ pub struct SearchConfig { /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso` | `baidu` | `volcengine`. Default: `duckduckgo`. #[serde(default)] pub provider: Option, + /// Optional DuckDuckGo-compatible HTML endpoint. When set with the + /// DuckDuckGo provider, `web_search` appends the `q` query parameter to + /// this URL instead of using `https://html.duckduckgo.com/html/`. + #[serde(default)] + pub base_url: Option, /// API key for Tavily, Bocha, Metaso, Baidu, or Volcengine. Not required for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in default. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY` env var. @@ -3803,6 +3808,12 @@ fn apply_env_overrides(config: &mut Config) { .get_or_insert_with(SearchConfig::default) .api_key = Some(value); } + if let Ok(value) = codewhale_env_var("CODEWHALE_SEARCH_BASE_URL", "DEEPSEEK_SEARCH_BASE_URL") { + config + .search + .get_or_insert_with(SearchConfig::default) + .base_url = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") { config.requirements_path = Some(value); } @@ -5524,6 +5535,25 @@ mod tests { ); } + #[test] + fn search_config_preserves_custom_base_url() { + let config: Config = toml::from_str( + r#" + [search] + provider = "duckduckgo" + base_url = "https://search.internal.example/html/" + "#, + ) + .expect("search config"); + + let search = config.search.expect("search table"); + assert_eq!(search.provider, Some(SearchProvider::DuckDuckGo)); + assert_eq!( + search.base_url.as_deref(), + Some("https://search.internal.example/html/") + ); + } + #[test] fn explicit_baidu_search_provider_is_preserved() { let config: Config = toml::from_str( @@ -5667,6 +5697,61 @@ mod tests { ); } + #[test] + fn apply_env_overrides_sets_search_base_url() { + let _guard = lock_test_env(); + let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL"); + let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL"); + unsafe { + env::remove_var("CODEWHALE_SEARCH_BASE_URL"); + env::set_var( + "DEEPSEEK_SEARCH_BASE_URL", + "https://search.internal.example/html/", + ) + }; + let mut config = Config::default(); + + apply_env_overrides(&mut config); + + unsafe { + EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale); + EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek); + } + assert_eq!( + config.search.and_then(|search| search.base_url), + Some("https://search.internal.example/html/".to_string()) + ); + } + + #[test] + fn codewhale_search_base_url_env_wins_over_legacy_alias() { + let _guard = lock_test_env(); + let prev_codewhale = env::var_os("CODEWHALE_SEARCH_BASE_URL"); + let prev_deepseek = env::var_os("DEEPSEEK_SEARCH_BASE_URL"); + unsafe { + env::set_var( + "CODEWHALE_SEARCH_BASE_URL", + "https://codewhale-search.example/html/", + ); + env::set_var( + "DEEPSEEK_SEARCH_BASE_URL", + "https://legacy-search.example/html/", + ); + } + let mut config = Config::default(); + + apply_env_overrides(&mut config); + + unsafe { + EnvGuard::restore_var("CODEWHALE_SEARCH_BASE_URL", prev_codewhale); + EnvGuard::restore_var("DEEPSEEK_SEARCH_BASE_URL", prev_deepseek); + } + assert_eq!( + config.search.and_then(|search| search.base_url), + Some("https://codewhale-search.example/html/".to_string()) + ); + } + #[test] fn search_provider_resolution_ignores_invalid_env_override() { let _guard = lock_test_env(); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 27960fe85..ff7552a66 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -344,6 +344,8 @@ pub struct EngineConfig { /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. pub search_api_key: Option, + /// Optional DuckDuckGo-compatible HTML endpoint override. + pub search_base_url: Option, /// Per-step DeepSeek API timeout for sub-agent `create_message` requests. /// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=1800) /// once at engine construction, then threaded onto every @@ -408,6 +410,7 @@ impl Default for EngineConfig { workshop: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + search_base_url: None, subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), @@ -2251,6 +2254,7 @@ In {new} mode: {policy}\n\n\ // Wire search provider config. ctx.search_provider = self.config.search_provider; ctx.search_api_key = self.config.search_api_key.clone(); + ctx.search_base_url = self.config.search_base_url.clone(); let policy = sandbox_policy_for_mode(mode, &self.session.workspace); let mut ctx = ctx.with_elevated_sandbox_policy(policy); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index cb06f5a77..2e238c193 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5745,6 +5745,7 @@ async fn run_exec_agent( workshop: config.workshop.clone(), search_provider: config.search_provider(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), + search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()), tools_always_load: config.tools_always_load(), tools: config.tools.clone(), }; @@ -6317,6 +6318,7 @@ mod doctor_endpoint_tests { let config = Config { search: Some(crate::config::SearchConfig { provider: Some(crate::config::SearchProvider::DuckDuckGo), + base_url: None, api_key: None, }), ..Default::default() @@ -6356,6 +6358,7 @@ mod doctor_endpoint_tests { let config = Config { search: Some(crate::config::SearchConfig { provider: Some(crate::config::SearchProvider::Bing), + base_url: None, api_key: None, }), ..Default::default() diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 69e12e15d..48bf3e44e 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2083,6 +2083,7 @@ impl RuntimeThreadManager { workshop: self.config.workshop.clone(), search_provider: self.config.search_provider(), search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()), + search_base_url: self.config.search.as_ref().and_then(|s| s.base_url.clone()), tools_always_load: self.config.tools_always_load(), tools: self.config.tools.clone(), }; diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 52553cdfb..63ac165b1 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -169,6 +169,8 @@ pub struct ToolContext { /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. pub search_api_key: Option, + /// Optional DuckDuckGo-compatible HTML endpoint override for `web_search`. + pub search_base_url: Option, /// Per-session workshop variable store (#548). Holds the raw content of /// the most recent large-tool routing event so the parent can call @@ -210,6 +212,7 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + search_base_url: None, workshop_vars: None, } } @@ -247,6 +250,7 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + search_base_url: None, workshop_vars: None, } } @@ -284,6 +288,7 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + search_base_url: None, workshop_vars: None, } } diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 5984d7916..cc33276ce 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -7,6 +7,7 @@ //! //! Set `[search]` in config.toml to switch providers: //! provider = "duckduckgo" # or tavily/bocha/metaso/baidu/volcengine +//! base_url = "https://search.example/html/" # optional DDG-compatible URL //! api_key = "tvly-..." use super::spec::{ @@ -22,7 +23,7 @@ use serde_json::{Value, json}; use std::sync::OnceLock; use std::time::Duration; -const DUCKDUCKGO_HOST: &str = "html.duckduckgo.com"; +const DUCKDUCKGO_ENDPOINT: &str = "https://html.duckduckgo.com/html/"; const BING_HOST: &str = "www.bing.com"; const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search"; const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/ai/search"; @@ -139,7 +140,7 @@ impl ToolSpec for WebSearchTool { } fn description(&self) -> &'static str { - "Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"baidu\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." + "Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"baidu\"` in config.toml to switch backends, or `[search] base_url` for a DuckDuckGo-compatible endpoint. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." } fn input_schema(&self) -> Value { @@ -200,6 +201,15 @@ impl ToolSpec for WebSearchTool { let max_results = max_results.clamp(1, MAX_RESULTS); let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000); + if configured_search_base_url(context.search_base_url.as_deref()).is_some() + && !matches!(context.search_provider, SearchProvider::DuckDuckGo) + { + return Err(ToolError::invalid_input(format!( + "[search].base_url is only supported with provider = \"duckduckgo\"; current provider is \"{}\"", + context.search_provider.as_str() + ))); + } + // Dispatch to the configured API-backed search providers before // building the HTML-scraping client used by Bing/DuckDuckGo. match context.search_provider { @@ -265,13 +275,16 @@ impl ToolSpec for WebSearchTool { } // Per-domain network policy gate (#135). The "host" for web search is - // the upstream search engine domain — DuckDuckGo first, Bing on - // fallback. We gate DuckDuckGo here; Bing is gated separately inside - // the fallback path so a deny on one engine doesn't block the other. - check_policy(decider, DUCKDUCKGO_HOST)?; + // the upstream search engine domain — DuckDuckGo-compatible first, + // Bing on fallback. We gate the configured endpoint here; Bing is + // gated separately inside the fallback path so a deny on one engine + // doesn't silently allow the other. + let (url, duckduckgo_host) = + duckduckgo_search_url(context.search_base_url.as_deref(), &query)?; + let allow_bing_fallback = + duckduckgo_allows_bing_fallback(context.search_base_url.as_deref()); + check_policy(decider, &duckduckgo_host)?; - let encoded = url_encode(&query); - let url = format!("https://html.duckduckgo.com/html/?q={encoded}"); let resp = client .get(&url) .header( @@ -297,7 +310,11 @@ impl ToolSpec for WebSearchTool { } let mut results = parse_duckduckgo_results(&body, max_results); - let mut source = "duckduckgo"; + let mut source = if allow_bing_fallback { + "duckduckgo".to_string() + } else { + duckduckgo_host.clone() + }; let mut message_suffix: Option<&str> = None; // When Bing returned zero and we fell through to DuckDuckGo, surface @@ -306,15 +323,21 @@ impl ToolSpec for WebSearchTool { message_suffix = Some("Bing returned no results; used DuckDuckGo fallback"); } - if results.is_empty() { - let duckduckgo_blocked = is_duckduckgo_challenge(&body); + let duckduckgo_blocked = is_duckduckgo_challenge(&body); + if results.is_empty() && duckduckgo_blocked && !allow_bing_fallback { + return Err(ToolError::execution_failed(format!( + "DuckDuckGo-compatible search endpoint at {duckduckgo_host} returned a bot challenge; check the private search service, credentials, or network policy" + ))); + } + + if results.is_empty() && allow_bing_fallback { // Bing is a separate host — gate it independently so a deny on // DuckDuckGo doesn't silently let Bing through (and vice versa). check_policy(decider, BING_HOST)?; match run_bing_search(&client, &query, max_results).await { Ok(fallback_results) if !fallback_results.is_empty() => { results = fallback_results; - source = "bing"; + source = "bing".to_string(); message_suffix = Some(if duckduckgo_blocked { "DuckDuckGo returned a bot challenge; used Bing fallback" } else { @@ -341,7 +364,7 @@ impl ToolSpec for WebSearchTool { fn search_tool_result( query: String, - source: &'static str, + source: impl Into, results: Vec, message_suffix: Option<&str>, ) -> Result { @@ -355,7 +378,7 @@ fn search_tool_result( let response = WebSearchResponse { query, - source: source.to_string(), + source: source.into(), count: results.len(), message, results, @@ -1336,6 +1359,31 @@ fn normalize_bing_url(href: &str) -> String { href.to_string() } +fn duckduckgo_search_url( + base_url: Option<&str>, + query: &str, +) -> Result<(String, String), ToolError> { + let raw = configured_search_base_url(base_url).unwrap_or(DUCKDUCKGO_ENDPOINT); + let mut url = reqwest::Url::parse(raw).map_err(|err| { + ToolError::invalid_input(format!( + "Invalid DuckDuckGo-compatible search base_url: {err}" + )) + })?; + url.query_pairs_mut().append_pair("q", query); + let host = url.host_str().ok_or_else(|| { + ToolError::invalid_input("DuckDuckGo-compatible search base_url must include a host") + })?; + Ok((url.to_string(), host.to_string())) +} + +fn configured_search_base_url(base_url: Option<&str>) -> Option<&str> { + base_url.map(str::trim).filter(|value| !value.is_empty()) +} + +fn duckduckgo_allows_bing_fallback(base_url: Option<&str>) -> bool { + configured_search_base_url(base_url).is_none() +} + fn normalize_text(text: &str) -> String { let stripped = strip_html_tags(text); let decoded = decode_html_entities(&stripped); @@ -1439,9 +1487,9 @@ fn extract_query_param(url: &str, key: &str) -> Option { mod tests { use super::{ ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, baidu_search_payload, - decode_html_entities, extract_search_query, is_likely_spam_results, normalize_bing_url, - optional_search_max_results, parse_baidu_results, root_domain, sanitize_error_body, - truncate_error_body, volcengine_extract_text, + decode_html_entities, duckduckgo_search_url, extract_search_query, is_likely_spam_results, + normalize_bing_url, optional_search_max_results, parse_baidu_results, root_domain, + sanitize_error_body, truncate_error_body, volcengine_extract_text, }; use serde_json::json; @@ -1979,4 +2027,130 @@ mod tests { "should not complain about missing API key (built-in default); got `{msg}`" ); } + + #[test] + fn duckduckgo_compatible_url_uses_custom_base_url_and_preserves_query() { + let (url, host) = duckduckgo_search_url( + Some("https://search.internal.example/html/?region=us"), + "rust async", + ) + .expect("custom duckduckgo-compatible url"); + + assert_eq!(host, "search.internal.example"); + assert_eq!( + url, + "https://search.internal.example/html/?region=us&q=rust+async" + ); + } + + #[test] + fn custom_duckduckgo_endpoint_disables_public_bing_fallback() { + assert!(super::duckduckgo_allows_bing_fallback(None)); + assert!(super::duckduckgo_allows_bing_fallback(Some(" "))); + assert!(!super::duckduckgo_allows_bing_fallback(Some( + "https://search.internal.example/html/" + ))); + } + + #[tokio::test] + async fn custom_duckduckgo_results_report_custom_host_source() { + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/html/")) + .and(query_param("q", "rust async")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#" + + Rust async +
Async Rust result
+ + "#, + )) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::DuckDuckGo; + let base_url = format!("{}/html/", server.uri()); + let expected_host = reqwest::Url::parse(&base_url) + .expect("mock server url") + .host_str() + .expect("mock server host") + .to_string(); + ctx.search_base_url = Some(base_url); + + let result = WebSearchTool + .execute(json!({"query": "rust async"}), &ctx) + .await + .expect("custom endpoint should return results"); + let value: serde_json::Value = + serde_json::from_str(&result.content).expect("web search json response"); + + assert_eq!(value["source"].as_str(), Some(expected_host.as_str())); + assert_eq!(value["count"].as_u64(), Some(1)); + } + + #[tokio::test] + async fn custom_duckduckgo_challenge_returns_actionable_error() { + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/html/")) + .and(query_param("q", "rust async")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"
Unfortunately, bots use DuckDuckGo too
"#, + )) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::DuckDuckGo; + ctx.search_base_url = Some(format!("{}/html/", server.uri())); + + let err = WebSearchTool + .execute(json!({"query": "rust async"}), &ctx) + .await + .expect_err("custom endpoint challenge should error"); + let msg = err.to_string(); + assert!( + msg.contains("DuckDuckGo-compatible search endpoint") + && msg.contains("bot challenge") + && msg.contains("private search service"), + "got `{msg}`" + ); + } + + #[tokio::test] + async fn search_base_url_with_non_duckduckgo_provider_is_explicit_error() { + use crate::config::SearchProvider; + use crate::tools::spec::{ToolContext, ToolSpec}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let mut ctx = ToolContext::new(tmp.path().to_path_buf()); + ctx.search_provider = SearchProvider::Tavily; + ctx.search_base_url = Some("https://search.internal.example/html/".to_string()); + + let err = WebSearchTool + .execute(json!({"query": "rust async"}), &ctx) + .await + .expect_err("non-duckduckgo provider with base_url should error"); + let msg = err.to_string(); + assert!( + msg.contains("[search].base_url") + && msg.contains("provider = \"duckduckgo\"") + && msg.contains("tavily"), + "got `{msg}`" + ); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f957a10b5..ed07841c4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -920,6 +920,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { workshop: config.workshop.clone(), search_provider: config.search_provider(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), + search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()), tools_always_load: config.tools_always_load(), tools: config.tools.clone(), } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9119cbba1..4e75b264a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1081,6 +1081,13 @@ parseable results. Bing remains selectable for users who explicitly want it, and Tavily, Bocha, Metaso, or Baidu can be selected when an API-backed provider is preferred. +For a private/internal search service that serves DuckDuckGo-compatible HTML, +keep `provider = "duckduckgo"` and set `base_url`; CodeWhale appends the `q` +query parameter to that endpoint and applies network policy to its host. +Custom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL` +can override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as +the legacy alias. + **Metaso** ([metaso.cn](https://metaso.cn)) has a 100 searches/day free quota; set `METASO_API_KEY` or `[search] api_key` for a higher quota. @@ -1092,6 +1099,7 @@ only; it does not add a Baidu model provider. ```toml [search] provider = "baidu" # duckduckgo | bing | tavily | bocha | metaso | baidu +# base_url = "https://search.example/html/" # optional with provider = "duckduckgo" # api_key = "YOUR_KEY" # required for tavily, bocha, and baidu; optional for metaso ``` diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index 5a0c7fb58..a6462e83a 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -64,9 +64,11 @@ harvest/stewardship commits: | #2581 provider fallback chain design doc | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head had no net file changes. Credit @idling11 in commit `5dc1a63cd`; keep issue #2574 open for implementation. | | #2530 mention depth-cap hint | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack as `a97675824` and `29f57665e`. `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | | #2513 restore snapshot listing | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `311eb4002` with explicit `/restore list 101` cap rejection. `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | +| #2510 custom DuckDuckGo-compatible endpoint | Harvested into a focused review branch; close original after review PR lands. | Adds `[search].base_url`, preferred `CODEWHALE_SEARCH_BASE_URL`, and legacy `DEEPSEEK_SEARCH_BASE_URL` for private DDG-compatible HTML endpoints. Network policy gates the configured host, custom endpoints do not fall back to public Bing, non-DDG provider/base_url combinations and challenge pages return explicit errors, and custom results report the configured host as `source`. Credit @cyq1017 for #2510 and @Artenx for the DDG-style endpoint clarification in #2436. | | #2576 PrefixCacheChange first-freeze event | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack through `29acb87a9d`. `cargo test -p codewhale-tui --locked prefix_cache` passed. | | #2502 web_run RwLock split | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `60f8e7d62` with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | | #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | +| #2528 background completion wait | Harvested through review PR #2765; original closed as harvested. | Widened the focused background-shell completion wait to 30 seconds so slow Windows runners do not leave lightweight completed background commands reported as `Running` before assertions fire. `cargo test -p codewhale-tui --bin codewhale-tui --locked test_background_execution -- --nocapture`, `... test_completed_background_shell_releases_process_handles ...`, and `cargo clippy -p codewhale-tui --bin codewhale-tui --locked -- -D warnings` passed. Credit @cyq1017; refs #2525/#2526. | ## Stabilization Gate Evidence (#2721) @@ -113,7 +115,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | | #2508 configurable path suffix | Conflicting / superseded | #2089 is already closed. The current implementation covers #1874's third-party gateway need without the broader env/CLI surface from #2508. Docs now show `[providers.openai].path_suffix = "/chat/completions"` and state that model/beta paths are not rewritten. Credit @hongqitai for the follow-up PR and @shuxiangxuebiancheng for the original #1874 report; close/comment after branch is public. | | #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. | -| #2510 custom DuckDuckGo endpoint | Draft/mergeable | Low priority; defer unless docs/search lane takes it. | +| #2510 custom DuckDuckGo endpoint | Draft/mergeable / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @Artenx. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | | #2512 custom completion sounds | Draft/conflicting | Defer. | | #2513 restore snapshot listing | Closed / harvested | Manually harvested as `311eb4002` with cap-rejection polish; original closed on 2026-06-05, leave #2494 open. | @@ -121,7 +123,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. | | #2522 hard compaction preserving system segment | Mergeable | Defer. Review found a dormant hard path that would duplicate/cache summaries into the mutable system prompt if wired through current engine flow, and a simple tail split that can break tool-call pair and pinning invariants. | | #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | -| #2528 background completion wait | Draft/conflicting | Defer unless failing tests prove need. | +| #2528 background completion wait | Closed / harvested | Harvested through #2765 with a 30-second focused wait for background-shell completion tests. Original closed as harvested, crediting @cyq1017; refs #2525/#2526. | | #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | | #2530 mention depth cap hint | Closed / already present | Already present locally as `a97675824` and `29f57665e`; original closed on 2026-06-05. | | #2576 PrefixCacheChange events | Closed / already present | Already present locally through `29acb87a9d`; original closed on 2026-06-05. | From 91215d5f4f2e013a07de2df9e7a39ea7e3a85ea5 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 19:56:51 -0700 Subject: [PATCH 076/209] feat(tui): harvest custom completion sound files Add completion_sound = "file" with [notifications].sound_file for Windows custom WAV completion sounds without changing the global Windows sound scheme. The Windows path uses PlaySoundW asynchronously with no default fallback. Non-Windows file mode warns and no-ops, missing paths warn once, and setting a valid path resets the missing-path warning latch so later misconfiguration is visible again. Fixes #2484 Reported by @LHqweasd Harvested from PR #2512 by @cyq1017 Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 9 +- config.example.toml | 9 +- crates/tui/CHANGELOG.md | 9 +- crates/tui/Cargo.toml | 2 +- crates/tui/src/config.rs | 33 +++++++- crates/tui/src/tui/notifications.rs | 127 ++++++++++++++++++++++++++-- crates/tui/src/tui/ui/tests.rs | 2 + docs/CONFIGURATION.md | 19 +++-- docs/V0_9_0_EXECUTION_MAP.md | 3 +- 9 files changed, 188 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d03fc62c9..f014f225f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by their configured host, do not fall back to public Bing, and report the custom host as the result source for diagnostics (#2436, #2510). +- Added `completion_sound = "file"` with `[notifications].sound_file` so + Windows users can play a custom WAV file for turn-completion sounds without + changing the global Windows sound scheme (#2484, #2512). ### Changed @@ -160,9 +163,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks to **@sximelon** for reporting and fixing the saved-session resume footer hint (#2758, #2760), **@cyq1017** for the custom -DuckDuckGo-compatible search endpoint, restore-listing implementation, and -pending-input delivery-mode label work (#2510, #2513, #2532, #2054), +DuckDuckGo-compatible search endpoint, custom completion sound file support, +restore-listing implementation, and pending-input delivery-mode label work +(#2510, #2512, #2513, #2532, #2054), **@Artenx** for the private-search endpoint report (#2436), +**@LHqweasd** for the Windows custom notification sound request (#2484), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/config.example.toml b/config.example.toml index f4b8bd791..a2129c05e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -620,21 +620,20 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # method = "auto" # auto | osc9 | bel | off # auto: OSC 9 for iTerm.app / Ghostty / WezTerm. # On macOS / Linux, falls back to BEL. -# On Windows, falls back to "off" — BEL maps to the -# system error chime (SystemAsterisk / MB_OK), which -# sounds like an error popup. Set method = "bel" -# explicitly to opt back in (#583). +# On Windows, BEL is routed through MessageBeep(MB_OK). # osc9: \x1b]9;\x07 (iTerm2-style; shows macOS notification) # bel: plain \x07 beep # off: disable entirely # threshold_secs = 30 # only notify when the turn took >= this many seconds # include_summary = false # include elapsed time + cost in the notification body -# completion_sound = "beep" # off | beep | bell — sound on turn completion (✅ marker) +# completion_sound = "beep" # off | beep | bell | file — sound on turn completion (✅ marker) +# sound_file = "E:\\google\\downloads\\notify.wav" # WAV used when completion_sound = "file" (Windows) [notifications] # method = "auto" # threshold_secs = 30 # include_summary = false # completion_sound = "beep" +# sound_file = "E:\\google\\downloads\\notify.wav" # ───────────────────────────────────────────────────────────────────────────────── # Workspace Snapshots (#137) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index d03fc62c9..f014f225f 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by their configured host, do not fall back to public Bing, and report the custom host as the result source for diagnostics (#2436, #2510). +- Added `completion_sound = "file"` with `[notifications].sound_file` so + Windows users can play a custom WAV file for turn-completion sounds without + changing the global Windows sound scheme (#2484, #2512). ### Changed @@ -160,9 +163,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks to **@sximelon** for reporting and fixing the saved-session resume footer hint (#2758, #2760), **@cyq1017** for the custom -DuckDuckGo-compatible search endpoint, restore-listing implementation, and -pending-input delivery-mode label work (#2510, #2513, #2532, #2054), +DuckDuckGo-compatible search endpoint, custom completion sound file support, +restore-listing implementation, and pending-input delivery-mode label work +(#2510, #2512, #2513, #2532, #2054), **@Artenx** for the private-search endpoint report (#2436), +**@LHqweasd** for the Windows custom notification sound request (#2484), **@wywsoor** for the broader macOS/iTerm rollback UX report (#2494), **@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata prefix-cache stability work (#2517), and project-context cache direction diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 60ba24114..52a05004c 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -100,4 +100,4 @@ objc2 = "0.6.3" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] } [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Media_Audio", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 184b3da98..2f65bc51d 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -915,6 +915,8 @@ pub enum CompletionSound { Beep, /// Terminal BEL character (`\x07`). Bell, + /// Play a configured WAV sound file. + File, } /// Desktop-notification configuration (OSC 9 / BEL on turn completion). @@ -922,9 +924,9 @@ pub enum CompletionSound { pub struct NotificationsConfig { /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`. /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux - /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux - /// it falls back to BEL, and on Windows it falls back to `Off` so the - /// post-turn notification doesn't ring the system error chime (#583). + /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); otherwise it + /// falls back to BEL. On Windows the BEL path is routed through + /// `MessageBeep(MB_OK)`. /// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`). #[serde(default)] @@ -937,10 +939,14 @@ pub struct NotificationsConfig { #[serde(default)] pub include_summary: bool, - /// Completion sound: `"off"` | `"beep"` | `"bell"`. Default: `"beep"`. + /// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`. /// Plays a sound when every turn finishes (alongside the ✅ marker). #[serde(default)] pub completion_sound: CompletionSound, + + /// Path to the WAV sound file used when `completion_sound = "file"`. + #[serde(default)] + pub sound_file: Option, } fn default_snapshots_enabled() -> bool { @@ -10220,4 +10226,23 @@ model = "deepseek-ai/deepseek-v4-pro" assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); Ok(()) } + + #[test] + fn notifications_parse_custom_completion_sound_file() { + let config: Config = toml::from_str( + r#" + [notifications] + completion_sound = "file" + sound_file = "E:\\google\\downloads\\xm4114.wav" + "#, + ) + .expect("custom completion sound config should parse"); + + let notifications = config.notifications_config(); + assert_eq!(notifications.completion_sound, CompletionSound::File); + assert_eq!( + notifications.sound_file.as_deref(), + Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav")) + ); + } } diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 84f43191c..cfa0f8f63 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -17,10 +17,19 @@ use windows::Win32::System::Diagnostics::Debug::MessageBeep; use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE; use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicU8; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; +#[cfg(target_os = "windows")] +use std::os::windows::ffi::OsStrExt; +#[cfg(target_os = "windows")] +use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_FILENAME, SND_NODEFAULT}; +#[cfg(target_os = "windows")] +use windows::core::PCWSTR; + /// Notification delivery method. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Method { @@ -354,18 +363,31 @@ pub fn reset_title_on_interaction() { } } -/// Completion sound mode (0 = off, 1 = beep, 2 = bell). +/// Completion sound mode (0 = off, 1 = beep, 2 = bell, 3 = file). static COMPLETION_SOUND_MODE: AtomicU8 = AtomicU8::new(1); +static COMPLETION_SOUND_FILE: OnceLock>> = OnceLock::new(); +#[cfg(not(target_os = "windows"))] +static COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED: AtomicBool = AtomicBool::new(false); +static COMPLETION_SOUND_FILE_MISSING_WARNED: AtomicBool = AtomicBool::new(false); -/// Set the completion sound mode from config. -/// Call once at startup or on `/settings` change. -pub fn set_completion_sound_mode(mode: crate::config::CompletionSound) { +fn completion_sound_file_slot() -> &'static Mutex> { + COMPLETION_SOUND_FILE.get_or_init(|| Mutex::new(None)) +} + +fn set_completion_sound(mode: crate::config::CompletionSound, sound_file: Option) { let val = match mode { crate::config::CompletionSound::Off => 0u8, crate::config::CompletionSound::Beep => 1u8, crate::config::CompletionSound::Bell => 2u8, + crate::config::CompletionSound::File => 3u8, }; COMPLETION_SOUND_MODE.store(val, Ordering::SeqCst); + if let Ok(mut slot) = completion_sound_file_slot().lock() { + if sound_file.is_some() { + COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst); + } + *slot = sound_file; + } } /// Play the configured completion sound (if not `Off`). @@ -378,6 +400,9 @@ pub fn play_completion_sound() { 2 => { bell_sound(); } + 3 => { + file_sound(); + } _ => {} } } @@ -402,6 +427,54 @@ fn bell_sound() { let _ = io::stdout().write_all(b"\x07"); } +fn configured_sound_file() -> Option { + completion_sound_file_slot() + .lock() + .ok() + .and_then(|slot| slot.clone()) +} + +#[cfg(target_os = "windows")] +fn play_sound_file(path: &Path) { + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + // Best-effort and async: notification sound failure should not block or + // fail a completed agent turn. + unsafe { + let _ = PlaySoundW( + PCWSTR(wide.as_ptr()), + None, + SND_FILENAME | SND_ASYNC | SND_NODEFAULT, + ); + } +} + +#[cfg(not(target_os = "windows"))] +fn play_sound_file(_path: &Path) { + if !COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED.swap(true, Ordering::SeqCst) { + tracing::warn!("completion_sound = \"file\" is currently supported on Windows only"); + } +} + +fn file_sound() { + if let Some(path) = configured_sound_file() { + play_sound_file(&path); + } else if !COMPLETION_SOUND_FILE_MISSING_WARNED.swap(true, Ordering::SeqCst) { + tracing::warn!("completion_sound = \"file\" requires [notifications].sound_file"); + } +} + +#[cfg(test)] +fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option) { + let mode = match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) { + 0 => crate::config::CompletionSound::Off, + 1 => crate::config::CompletionSound::Beep, + 2 => crate::config::CompletionSound::Bell, + 3 => crate::config::CompletionSound::File, + _ => crate::config::CompletionSound::Off, + }; + (mode, configured_sound_file()) +} + /// Show a macOS Notification Center alert via `osascript`. /// /// Runs on a dedicated background thread so the caller is not blocked. @@ -585,7 +658,7 @@ use crate::tui::app::App; pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> { let notif = config.notifications_config(); // Initialize completion sound mode from config. - set_completion_sound_mode(notif.completion_sound); + set_completion_sound(notif.completion_sound, notif.sound_file); let method = match notif.method { crate::config::NotificationMethod::Auto => Method::Auto, crate::config::NotificationMethod::Osc9 => Method::Osc9, @@ -1187,4 +1260,48 @@ mod tests { "3w 2d" ); } + + #[test] + fn settings_installs_custom_completion_sound_file() { + let config: crate::config::Config = toml::from_str( + r#" + [notifications] + completion_sound = "file" + sound_file = "E:\\google\\downloads\\xm4114.wav" + "#, + ) + .expect("custom completion sound config should parse"); + + let _ = settings(&config); + + let (mode, file) = completion_sound_state_for_tests(); + assert_eq!(mode, crate::config::CompletionSound::File); + assert_eq!( + file.as_deref(), + Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav")) + ); + } + + #[test] + fn setting_valid_sound_file_resets_missing_file_warning_latch() { + let _lock = env_lock(); + COMPLETION_SOUND_FILE_MISSING_WARNED.store(true, Ordering::SeqCst); + + set_completion_sound( + crate::config::CompletionSound::File, + Some(std::path::PathBuf::from( + "E:\\google\\downloads\\xm4114.wav", + )), + ); + + assert!(!COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst)); + + set_completion_sound(crate::config::CompletionSound::File, None); + file_sound(); + + assert!(COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst)); + + set_completion_sound(crate::config::CompletionSound::Beep, None); + COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst); + } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 03f26e5e0..5106859d1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -8369,6 +8369,7 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() { method: crate::config::NotificationMethod::Bel, threshold_secs: 120, completion_sound: crate::config::CompletionSound::Beep, + sound_file: None, include_summary: true, }), ..Config::default() @@ -8401,6 +8402,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() { method: crate::config::NotificationMethod::Osc9, threshold_secs: 45, completion_sound: crate::config::CompletionSound::Beep, + sound_file: None, include_summary: false, }), ..Config::default() diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4e75b264a..42cd3bd48 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -951,15 +951,18 @@ If you are upgrading from older releases: turns whose elapsed time meets `threshold_secs`; failed and cancelled turns are silent. `auto` resolves to `osc9` for `iTerm.app`, `Ghostty`, and `WezTerm` (detected via `$TERM_PROGRAM`). Otherwise the fallback is - `bel` on macOS / Linux and `off` on Windows (where BEL maps to the - system error chime — see the [Notifications](#notifications) section - for the full rationale, #583). + `bel`; on Windows the BEL path is routed through `MessageBeep(MB_OK)`. - `[notifications].threshold_secs` (int, optional): defaults to `30`. Only completed turns whose elapsed time meets or exceeds this fire a notification. - `[notifications].include_summary` (bool, optional): defaults to `false`. When `true`, the notification body includes the elapsed duration and the turn's cost in the configured display currency. +- `[notifications].completion_sound` (string, optional): `off`, `beep`, + `bell`, or `file`. Defaults to `beep`. `file` plays the WAV path from + `[notifications].sound_file` on Windows. +- `[notifications].sound_file` (path, optional): path to a custom WAV file + used when `completion_sound = "file"`. - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport. - `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely. @@ -1022,16 +1025,22 @@ The TUI can emit a desktop notification (OSC 9 escape or plain BEL) when a turn method = "auto" # auto | osc9 | bel | off threshold_secs = 30 # only notify when the turn took >= this many seconds include_summary = false # include elapsed time + cost in the notification body +completion_sound = "beep" # off | beep | bell | file +sound_file = "E:\\google\\downloads\\notify.wav" # for completion_sound = "file" ``` Method semantics: -- `auto` (default) — picks `osc9` for `iTerm.app`, `Ghostty`, and `WezTerm` (detected via `$TERM_PROGRAM`). On macOS and Linux it falls back to `bel`. **On Windows the fallback is `off`** instead of `bel`, because the Windows audio stack maps `\x07` to the `SystemAsterisk` / `MB_OK` chime — the same sound application error popups use, so a successful-turn notification ends up sounding like an error (#583). +- `auto` (default) — picks `osc9` for `iTerm.app`, `Ghostty`, and `WezTerm` (detected via `$TERM_PROGRAM`). Otherwise it falls back to `bel`; on Windows that BEL path is routed through `MessageBeep(MB_OK)`. - `osc9` — emit `\x1b]9;\x07`. Inside tmux the sequence is wrapped in DCS passthrough so it reaches the outer terminal. - `bel` — emit a single `\x07` byte. Use this on Windows only if you actively want the chime back. - `off` — disable post-turn notifications entirely. -Windows users who run inside a known OSC-9 terminal (e.g. WezTerm on Windows) keep getting OSC-9 notifications; the `off` fallback only applies when no recognised `TERM_PROGRAM` is detected. +Windows users who run inside a known OSC-9 terminal (e.g. WezTerm on Windows) keep getting OSC-9 notifications. Set `method = "off"` to disable threshold-based desktop notifications entirely. + +`completion_sound = "file"` is for Windows users who want a per-application +completion sound without changing the global Windows sound scheme. It plays the +configured WAV `sound_file` asynchronously via the native Windows audio API. ### Parsed but currently unused (reserved for future versions) diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index a6462e83a..672422aed 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -65,6 +65,7 @@ harvest/stewardship commits: | #2530 mention depth-cap hint | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack as `a97675824` and `29f57665e`. `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | | #2513 restore snapshot listing | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `311eb4002` with explicit `/restore list 101` cap rejection. `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | | #2510 custom DuckDuckGo-compatible endpoint | Harvested into a focused review branch; close original after review PR lands. | Adds `[search].base_url`, preferred `CODEWHALE_SEARCH_BASE_URL`, and legacy `DEEPSEEK_SEARCH_BASE_URL` for private DDG-compatible HTML endpoints. Network policy gates the configured host, custom endpoints do not fall back to public Bing, non-DDG provider/base_url combinations and challenge pages return explicit errors, and custom results report the configured host as `source`. Credit @cyq1017 for #2510 and @Artenx for the DDG-style endpoint clarification in #2436. | +| #2512 custom completion sound files | Harvested into a focused review branch; close original after review PR lands. | Adds `completion_sound = "file"` plus `[notifications].sound_file` so Windows users can play a per-app custom WAV through `PlaySoundW(SND_FILENAME | SND_ASYNC | SND_NODEFAULT)`. Non-Windows file mode warns and no-ops, missing paths warn once, and setting a valid path resets the missing-path warning latch so later misconfiguration is visible again. Credit @cyq1017 for #2512 and @LHqweasd for the Windows custom notification request in #2484. | | #2576 PrefixCacheChange first-freeze event | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack through `29acb87a9d`. `cargo test -p codewhale-tui --locked prefix_cache` passed. | | #2502 web_run RwLock split | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `60f8e7d62` with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | | #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | @@ -117,7 +118,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. | | #2510 custom DuckDuckGo endpoint | Draft/mergeable / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @Artenx. | | #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | -| #2512 custom completion sounds | Draft/conflicting | Defer. | +| #2512 custom completion sounds | Draft/conflicting / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @LHqweasd. | | #2513 restore snapshot listing | Closed / harvested | Manually harvested as `311eb4002` with cap-rejection polish; original closed on 2026-06-05, leave #2494 open. | | #2517 turn_meta tail relocation | Mergeable | Manually harvested on the v0.9 branch; close/comment after branch is public. | | #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. | From 54f7556359fb0a513ceb244e0c7185ac640a1e57 Mon Sep 17 00:00:00 2001 From: cyq1017 <61975706+cyq1017@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:15:25 -0700 Subject: [PATCH 077/209] fix(tui): harvest provider auth rollback Fixes #2754 Reported by @Dr3259. Harvested from PR #2755 by @cyq1017. Adds maintainer verification for persisted provider/model settings after rollback. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 6 ++ crates/tui/CHANGELOG.md | 6 ++ crates/tui/src/tui/app.rs | 15 ++++ crates/tui/src/tui/ui.rs | 123 ++++++++++++++++++++++++++++++--- crates/tui/src/tui/ui/tests.rs | 84 ++++++++++++++++++++++ 5 files changed, 224 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f014f225f..d02fc1c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI settings while still reading legacy DeepSeek-branded settings fallbacks and migrating them into the CodeWhale home on load. +- Provider switches now roll back transactionally when the first request to a + newly selected provider fails authentication: CodeWhale restores the previous + provider/model, model-ID passthrough, onboarding/API-key state, runtime + config, persisted provider selection, and engine handle so users can return + to DeepSeek after a failed Moonshot/Kimi switch (#2754, #2755). Thanks + @Dr3259 for the Windows repro and @cyq1017 for the draft fix. - `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for GUI/runtime clients. Workspace changes reject active turns and evict idle cached engines so the next turn starts in the new workspace. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f014f225f..d02fc1c4a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -55,6 +55,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `/config` now reports the canonical `~/.codewhale/settings.toml` path for TUI settings while still reading legacy DeepSeek-branded settings fallbacks and migrating them into the CodeWhale home on load. +- Provider switches now roll back transactionally when the first request to a + newly selected provider fails authentication: CodeWhale restores the previous + provider/model, model-ID passthrough, onboarding/API-key state, runtime + config, persisted provider selection, and engine handle so users can return + to DeepSeek after a failed Moonshot/Kimi switch (#2754, #2755). Thanks + @Dr3259 for the Windows repro and @cyq1017 for the draft fix. - `PATCH /v1/threads/{id}` can now update a thread's persisted workspace for GUI/runtime clients. Workspace changes reject active turns and evict idle cached engines so the next turn starts in the new workspace. diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 024ce2134..d2315448d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1216,6 +1216,17 @@ pub struct ToolEvidence { pub summary: String, } +#[derive(Debug, Clone)] +pub(crate) struct PendingProviderSwitch { + pub previous_provider: ApiProvider, + pub previous_model: String, + pub previous_model_ids_passthrough: bool, + pub previous_config: Config, + pub previous_onboarding: OnboardingState, + pub previous_onboarding_needs_api_key: bool, + pub previous_api_key_env_only: bool, +} + /// Global UI state for the TUI. #[allow(clippy::struct_excessive_bools)] pub struct App { @@ -1272,6 +1283,9 @@ pub struct App { /// True when the active provider/base URL accepts arbitrary model IDs /// verbatim rather than DeepSeek-only aliases. pub model_ids_passthrough: bool, + /// Pending provider transition for transactional rollback when the next + /// auth failure indicates the new provider cannot be used. + pub pending_provider_switch: Option, /// Current reasoning-effort tier for DeepSeek thinking mode. /// Cycled via Shift+Tab; initialized from config at startup. pub reasoning_effort: ReasoningEffort, @@ -2067,6 +2081,7 @@ impl App { last_effective_model: None, api_provider: provider, model_ids_passthrough, + pending_provider_switch: None, reasoning_effort, last_effective_reasoning_effort: None, workspace, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ed07841c4..1a814bd58 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -117,8 +117,8 @@ use crate::tui::workspace_context; use super::key_actions; use super::app::{ - App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus, - StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, + App, AppAction, AppMode, OnboardingState, PendingProviderSwitch, QueuedMessage, + ReasoningEffort, SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, looks_like_slash_command_input, shell_command_from_bang_input, }; use super::approval::{ @@ -1307,6 +1307,7 @@ async fn run_event_loop( let mut received_engine_event = false; let mut transcript_batch_updated = false; let mut queued_to_send: Option = None; + let mut respawn_after_provider_rollback: Option = None; { let mut rx = engine_handle.rx_event.write().await; loop { @@ -1720,6 +1721,7 @@ async fn run_event_loop( } app.is_loading = false; app.dispatch_started_at = None; + app.pending_provider_switch = None; app.offline_mode = false; app.streaming_state.reset(); if was_locally_cancelled { @@ -1986,7 +1988,18 @@ async fn run_event_loop( envelope, recoverable: _, } => { + let rollback_after_auth_failure = + matches!( + envelope.category, + crate::error_taxonomy::ErrorCategory::Authentication + ) && app.pending_provider_switch.is_some(); apply_engine_error_to_app(app, envelope); + if rollback_after_auth_failure + && let Some(rollback_warning) = + rollback_provider_after_auth_failure(app, config) + { + respawn_after_provider_rollback = Some(rollback_warning); + } } EngineEvent::Status { message } => { app.status_message = Some(message); @@ -2406,6 +2419,29 @@ async fn run_event_loop( } } } + if let Some(rollback_warning) = respawn_after_provider_rollback { + let _ = engine_handle.send(Op::Shutdown).await; + let engine_config = build_engine_config(app, config); + engine_handle = spawn_engine(engine_config, config); + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + session_id: app.current_session_id.clone(), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + system_prompt_override: false, + model: app.model.clone(), + workspace: app.workspace.clone(), + }) + .await; + } + let _ = engine_handle + .send(Op::SetCompaction { + config: app.compaction_config(), + }) + .await; + app.status_message = Some(rollback_warning); + } if let Some(index) = app.streaming_message_index { let committed = app.streaming_state.commit_text(0); if !committed.is_empty() { @@ -4569,6 +4605,68 @@ pub(crate) fn apply_engine_error_to_app( // toast in the footer — that duplicates the transcript entry. } +fn rollback_provider_after_auth_failure(app: &mut App, config: &mut Config) -> Option { + let pending = app.pending_provider_switch.take()?; + let PendingProviderSwitch { + previous_provider, + previous_model, + previous_model_ids_passthrough, + previous_config, + previous_onboarding, + previous_onboarding_needs_api_key, + previous_api_key_env_only, + } = pending; + + *config = previous_config; + app.api_provider = previous_provider; + app.set_model_selection(previous_model.clone()); + app.provider_models + .insert(previous_provider.as_str().to_string(), previous_model); + app.model_ids_passthrough = previous_model_ids_passthrough; + app.update_model_compaction_budget(); + app.clear_model_scoped_telemetry(); + app.offline_mode = false; + app.onboarding = previous_onboarding; + app.onboarding_needs_api_key = previous_onboarding_needs_api_key; + app.api_key_env_only = previous_api_key_env_only; + + let persistence_error = (|| -> anyhow::Result<()> { + commands::persist_root_string_key( + app.config_path.as_deref(), + "provider", + previous_provider.as_str(), + )?; + let mut settings = crate::settings::Settings::load()?; + settings.default_provider = Some(previous_provider.as_str().to_string()); + settings.set_model_for_provider( + previous_provider.as_str(), + &app.model_selection_for_persistence(), + ); + if matches!( + previous_provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN + ) { + settings.set("default_model", &app.model_selection_for_persistence())?; + } + settings.save()?; + Ok(()) + })() + .err() + .map(|err| format!("provider rollback not fully persisted: {err}")); + + Some(match persistence_error { + Some(warning) => format!( + "Provider switch failed and has been rolled back to {}. {}", + previous_provider.as_str(), + warning + ), + None => format!( + "Provider switch failed and has been rolled back to {}.", + previous_provider.as_str() + ), + }) +} + fn persist_offline_queue_state(app: &App) { if app.queued_messages.is_empty() && app.queued_draft.is_none() { persistence_actor::persist(PersistRequest::ClearOfflineQueue); @@ -5339,10 +5437,17 @@ async fn switch_provider( ) { let previous_provider = app.api_provider; let previous_model = app.model.clone(); - let previous_provider_str = config.provider.clone(); - let previous_base_url = config.base_url.clone(); - let previous_default_text_model = config.default_text_model.clone(); - let previous_providers = config.providers.clone(); + let previous_model_ids_passthrough = app.model_ids_passthrough; + let previous_config = config.clone(); + app.pending_provider_switch = Some(PendingProviderSwitch { + previous_provider, + previous_model: previous_model.clone(), + previous_model_ids_passthrough, + previous_config: previous_config.clone(), + previous_onboarding: app.onboarding, + previous_onboarding_needs_api_key: app.onboarding_needs_api_key, + previous_api_key_env_only: app.api_key_env_only, + }); config.provider = Some(target.as_str().to_string()); if matches!(target, ApiProvider::NvidiaNim) @@ -5368,10 +5473,8 @@ async fn switch_provider( } if let Err(err) = DeepSeekClient::new(config) { - config.provider = previous_provider_str; - config.base_url = previous_base_url; - config.default_text_model = previous_default_text_model; - config.providers = previous_providers; + app.pending_provider_switch = None; + *config = previous_config; app.add_message(HistoryCell::System { content: format!( "Failed to switch provider to {}: {err}\nProvider unchanged ({}).", diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5106859d1..f45686c1c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -7300,6 +7300,89 @@ fn recoverable_engine_error_does_not_enter_offline_mode() { let _ = ErrorEnvelope::transient(""); } +#[tokio::test] +async fn provider_switch_auth_error_restores_previous_provider_and_model() { + use crate::error_taxonomy::ErrorEnvelope; + + let _home = SettingsHomeGuard::new(); + let mut app = create_test_app(); + app.api_provider = ApiProvider::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.model_ids_passthrough = false; + app.onboarding = OnboardingState::None; + app.onboarding_needs_api_key = false; + app.api_key_env_only = true; + let mut engine = mock_engine_handle(); + let mut config = Config { + provider: Some("deepseek".to_string()), + api_key: Some("deepseek-key".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + providers: Some(ProvidersConfig { + deepseek: ProviderConfig { + api_key: Some("deepseek-key".to_string()), + ..Default::default() + }, + moonshot: ProviderConfig { + api_key: Some("kimi-key".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + + switch_provider( + &mut app, + &mut engine.handle, + &mut config, + ApiProvider::Moonshot, + Some("kimi-k2.6".to_string()), + ) + .await; + assert_eq!(app.api_provider, ApiProvider::Moonshot); + assert_eq!(config.provider.as_deref(), Some("moonshot")); + assert!(app.pending_provider_switch.is_some()); + + apply_engine_error_to_app( + &mut app, + ErrorEnvelope::fatal_auth("Authentication failed: invalid API key"), + ); + let rollback_status = rollback_provider_after_auth_failure(&mut app, &mut config) + .expect("auth failure after provider switch should roll back"); + + assert_eq!(app.api_provider, ApiProvider::Deepseek); + assert_eq!(app.model, "deepseek-v4-pro"); + assert!(!app.model_ids_passthrough); + assert!(!app.offline_mode); + assert_eq!(app.onboarding, OnboardingState::None); + assert!(!app.onboarding_needs_api_key); + assert!(app.api_key_env_only); + assert_eq!(config.provider.as_deref(), Some("deepseek")); + assert_eq!( + config.default_text_model.as_deref(), + Some("deepseek-v4-pro") + ); + let settings = crate::settings::Settings::load().expect("load settings"); + assert_eq!(settings.default_provider.as_deref(), Some("deepseek")); + assert_eq!( + settings + .provider_models + .as_ref() + .and_then(|models| models.get("deepseek")) + .map(String::as_str), + Some("deepseek-v4-pro") + ); + assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-pro")); + assert!(app.pending_provider_switch.is_none()); + assert!(rollback_status.contains("Provider switch failed")); + assert!( + app.status_message + .as_deref() + .map_or(true, |status| !status.contains("Provider switch failed")), + "status message is set by the async event loop after engine respawn" + ); +} + #[test] fn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() { use crate::error_taxonomy::ErrorEnvelope; @@ -7363,6 +7446,7 @@ fn non_recoverable_engine_error_enters_offline_mode() { app.status_message.is_none(), "non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error" ); + assert!(app.pending_provider_switch.is_none()); } #[test] From c36e4d7d20d39439868cd23dce1992d6885d7b72 Mon Sep 17 00:00:00 2001 From: cyq1017 <61975706+cyq1017@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:31:26 -0700 Subject: [PATCH 078/209] fix(mcp): harvest trusted workspace MCP config Merge global MCP config with trusted workspace .codewhale/mcp.json files so project MCP servers appear in TUI, CLI, doctor, and runtime API flows. Project stdio servers default cwd to the workspace, project cwd escapes are rejected, and project MCP is ignored until workspace trust is recorded in user-owned config. Fixes #2749 Reported by @yekern Harvested from PR #2751 by @cyq1017 --- crates/tui/src/core/engine.rs | 7 +- crates/tui/src/main.rs | 161 ++++++---- crates/tui/src/mcp.rs | 550 +++++++++++++++++++++++++++++++--- crates/tui/src/runtime_api.rs | 15 +- 4 files changed, 621 insertions(+), 112 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ff7552a66..5f58d925e 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2270,8 +2270,11 @@ In {new} mode: {policy}\n\n\ if let Some(pool) = self.mcp_pool.as_ref() { return Ok(Arc::clone(pool)); } - let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) - .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; + let mut pool = McpPool::from_config_path_with_workspace( + &self.session.mcp_config_path, + &self.session.workspace, + ) + .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; if let Some(decider) = self.config.network_policy.as_ref() { pool = pool.with_network_policy(decider.clone()); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 2e238c193..7c9815b7d 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1027,7 +1027,8 @@ async fn main() -> Result<()> { Commands::Eval(args) => run_eval(args), Commands::Mcp { command } => { let config = load_config_from_cli(&cli)?; - run_mcp_command(&config, command).await + let workspace = resolve_workspace(&cli); + run_mcp_command(&config, &workspace, command).await } Commands::Execpolicy(command) => { let config = load_config_from_cli(&cli)?; @@ -1540,6 +1541,7 @@ fn mcp_template_json() -> Result { command: Some("node".to_string()), args: vec!["./path/to/your-mcp-server.js".to_string()], env: std::collections::HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -2078,14 +2080,21 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { println!(" · default_text_model: {model}"); let mcp_path = config.mcp_config_path(); - let mcp_count = match load_mcp_config(&mcp_path) { + let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace); + let mcp_count = match crate::mcp::load_config_with_workspace(&mcp_path, workspace) { Ok(cfg) => cfg.servers.len(), Err(_) => 0, }; let mcp_present = if mcp_path.exists() { "" } else { " (missing)" }; + let project_mcp_present = if project_mcp_path.exists() { + "" + } else { + " (missing)" + }; println!( - " · mcp servers: {mcp_count} at {}{mcp_present}", - mcp_path.display() + " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}", + mcp_path.display(), + project_mcp_path.display() ); let skills_dir = config.skills_dir(); @@ -2575,68 +2584,85 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt } let mcp_config_path = config.mcp_config_path(); + let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace); if mcp_config_path.exists() { println!( " {} MCP config found at {}", "✓".truecolor(aqua_r, aqua_g, aqua_b), crate::utils::display_path(&mcp_config_path) ); - match load_mcp_config(&mcp_config_path) { - Ok(cfg) if cfg.servers.is_empty() => { - println!(" {} 0 server(s) configured", "·".dimmed()); - } - Ok(cfg) => { - println!( - " {} {} server(s) configured", - "·".dimmed(), - cfg.servers.len() - ); - for (name, server) in &cfg.servers { - let status = doctor_check_mcp_server(server); - let icon = match status { - McpServerDoctorStatus::Ok(ref detail) => { - format!( - " {} {name}: {}", - "✓".truecolor(aqua_r, aqua_g, aqua_b), - detail - ) - } - McpServerDoctorStatus::Warning(ref detail) => { - format!( - " {} {name}: {}", - "!".truecolor(sky_r, sky_g, sky_b), - detail - ) - } - McpServerDoctorStatus::Error(ref detail) => { - format!( - " {} {name}: {}", - "✗".truecolor(red_r, red_g, red_b), - detail - ) - } - }; - println!("{icon}"); - if !server.enabled { - println!(" (disabled)"); - } - } - } - Err(err) => { - println!( - " {} MCP config parse error: {}", - "✗".truecolor(red_r, red_g, red_b), - err - ); - } - } } else { println!( " {} MCP config not found at {}", "·".dimmed(), crate::utils::display_path(&mcp_config_path) ); - println!(" Run `codewhale mcp init` or `codewhale setup --mcp`."); + } + if project_mcp_config_path.exists() { + println!( + " {} Project MCP config found at {}", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + crate::utils::display_path(&project_mcp_config_path) + ); + } else { + println!( + " {} Project MCP config not found at {}", + "·".dimmed(), + crate::utils::display_path(&project_mcp_config_path) + ); + } + + match crate::mcp::load_config_with_workspace(&mcp_config_path, workspace) { + Ok(cfg) if cfg.servers.is_empty() => { + println!(" {} 0 merged server(s) configured", "·".dimmed()); + if !mcp_config_path.exists() && !project_mcp_config_path.exists() { + println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`."); + } + } + Ok(cfg) => { + println!( + " {} {} merged server(s) configured", + "·".dimmed(), + cfg.servers.len() + ); + for (name, server) in &cfg.servers { + let status = doctor_check_mcp_server(server); + let icon = match status { + McpServerDoctorStatus::Ok(ref detail) => { + format!( + " {} {name}: {}", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + detail + ) + } + McpServerDoctorStatus::Warning(ref detail) => { + format!( + " {} {name}: {}", + "!".truecolor(sky_r, sky_g, sky_b), + detail + ) + } + McpServerDoctorStatus::Error(ref detail) => { + format!( + " {} {name}: {}", + "✗".truecolor(red_r, red_g, red_b), + detail + ) + } + }; + println!("{icon}"); + if !server.enabled { + println!(" (disabled)"); + } + } + } + Err(err) => { + println!( + " {} MCP config parse error: {}", + "✗".truecolor(red_r, red_g, red_b), + err + ); + } } // Skills configuration @@ -3144,8 +3170,10 @@ fn run_doctor_json( }; let mcp_config_path = config.mcp_config_path(); + let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace); let mcp_present = mcp_config_path.exists(); - let mcp_summary = match load_mcp_config(&mcp_config_path) { + let project_mcp_present = project_mcp_config_path.exists(); + let mcp_summary = match crate::mcp::load_config_with_workspace(&mcp_config_path, workspace) { Ok(cfg) => { let servers: Vec = cfg .servers @@ -3168,12 +3196,16 @@ fn run_doctor_json( json!({ "config_path": mcp_config_path.display().to_string(), "present": mcp_present, + "project_config_path": project_mcp_config_path.display().to_string(), + "project_present": project_mcp_present, "servers": servers, }) } Err(err) => json!({ "config_path": mcp_config_path.display().to_string(), "present": mcp_present, + "project_config_path": project_mcp_config_path.display().to_string(), + "project_present": project_mcp_present, "servers": [], "error": err.to_string(), }), @@ -4440,7 +4472,7 @@ fn read_patch_from_stdin() -> Result { Ok(buffer) } -async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { +async fn run_mcp_command(config: &Config, workspace: &Path, command: McpCommand) -> Result<()> { let config_path = config.mcp_config_path(); match command { McpCommand::Init { force } => { @@ -4463,9 +4495,13 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::List => { - let cfg = load_mcp_config(&config_path)?; + let cfg = crate::mcp::load_config_with_workspace(&config_path, workspace)?; if cfg.servers.is_empty() { - println!("No MCP servers configured in {}", config_path.display()); + println!( + "No MCP servers configured in {} or {}", + config_path.display(), + crate::mcp::workspace_mcp_config_path(workspace).display() + ); return Ok(()); } println!("MCP servers ({}):", cfg.servers.len()); @@ -4493,7 +4529,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Connect { server } => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?; if let Some(name) = server { pool.get_or_connect(&name).await?; println!("Connected to MCP server: {name}"); @@ -4510,7 +4546,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Tools { server } => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?; if let Some(name) = server { let conn = pool.get_or_connect(&name).await?; if conn.tools().is_empty() { @@ -4569,6 +4605,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { command, args, env: std::collections::HashMap::new(), + cwd: None, url, transport, connect_timeout: None, @@ -4620,7 +4657,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Validate => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path_with_workspace(&config_path, workspace)?; let errors = pool.connect_all().await; if errors.is_empty() { println!("MCP config is valid. All enabled servers connected."); @@ -4656,6 +4693,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { command: Some(exe_str.clone()), args, env: std::collections::HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -7472,6 +7510,7 @@ mod doctor_mcp_tests { command: command.map(String::from), args: args.iter().map(|s| s.to_string()).collect(), env: std::collections::HashMap::new(), + cwd: None, url: url.map(String::from), transport: None, connect_timeout: None, diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index c25fbe32d..ea090af11 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use std::fs; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; @@ -263,6 +263,9 @@ pub struct McpServerConfig { pub args: Vec, #[serde(default)] pub env: HashMap, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, pub url: Option, /// Optional explicit HTTP transport override. /// @@ -1391,6 +1394,9 @@ impl McpConnection { .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); + if let Some(cwd) = &config.cwd { + cmd.current_dir(cwd); + } // MCP stdio servers are user-configured integrations. Use the // wider MCP allowlist so common Node/Python/proxy/CA-bundle @@ -1899,19 +1905,18 @@ pub struct McpPool { connections: HashMap, config: McpConfig, network_policy: Option, - /// Source path the config was loaded from, when `from_config_path` was - /// used. `None` for pools constructed directly via `new` (tests, ad-hoc - /// snapshots). Drives the lazy-reload check (#1267 part 2): when the - /// file's mtime moves, the pool re-reads the config and compares its - /// content hash to decide whether to drop existing connections. - config_source: Option, + /// Source paths the config was loaded from. Empty for pools constructed + /// directly via `new` (tests, ad-hoc snapshots). Workspace-aware pools + /// track both global and project-level MCP config paths so lazy reload sees + /// either file appear or change. + config_sources: Vec, + workspace: Option, /// 64-bit content hash of the active config (`hash_mcp_config`). Compared /// against the freshly-loaded config after an mtime change to skip /// reloading when the file was merely touched. config_hash: u64, - /// Most recently observed mtime of `config_source`. Updated whenever the - /// reload check runs (whether or not it triggered a reload). - last_mtime: Option, + /// Most recently observed mtime for `config_sources`. + last_mtimes: Vec>, } impl McpPool { @@ -1922,27 +1927,41 @@ impl McpPool { connections: HashMap::new(), config, network_policy: None, - config_source: None, + config_sources: Vec::new(), + workspace: None, config_hash, - last_mtime: None, + last_mtimes: Vec::new(), } } - /// Create a pool from a configuration file path + /// Create a pool from a configuration file path. + #[cfg(test)] pub fn from_config_path(path: &std::path::Path) -> Result { - validate_mcp_config_path(path)?; - let config = if path.exists() { - let contents = fs::read_to_string(path) - .with_context(|| format!("Failed to read MCP config: {}", path.display()))?; - serde_json::from_str(&contents) - .with_context(|| format!("Failed to parse MCP config: {}", path.display()))? - } else { - McpConfig::default() - }; - let last_mtime = mcp_config_mtime(path); + let config = load_config(path)?; + let mut pool = Self::new(config); + pool.config_sources = vec![path.to_path_buf()]; + pool.last_mtimes = vec![mcp_config_mtime(path)]; + Ok(pool) + } + + /// Create a pool from global MCP config plus workspace-local + /// `.codewhale/mcp.json`. Project servers override same-name global + /// servers and default stdio `cwd` to the workspace root. + pub fn from_config_path_with_workspace( + path: &std::path::Path, + workspace: &Path, + ) -> Result { + let config = load_config_with_workspace(path, workspace)?; let mut pool = Self::new(config); - pool.config_source = Some(path.to_path_buf()); - pool.last_mtime = last_mtime; + pool.config_sources = vec![path.to_path_buf(), workspace_mcp_config_path(workspace)]; + pool.config_sources + .extend(crate::config::workspace_trust_config_candidate_paths()); + pool.last_mtimes = pool + .config_sources + .iter() + .map(|source| mcp_config_mtime(source)) + .collect(); + pool.workspace = Some(workspace.to_path_buf()); Ok(pool) } @@ -1967,29 +1986,31 @@ impl McpPool { /// or remote filesystems where mtime granularity is poor, the hash /// compare keeps us from churning connections on every check. pub async fn reload_if_config_changed(&mut self) -> Result { - let Some(path) = self.config_source.clone() else { + if self.config_sources.is_empty() { return Ok(false); - }; - let current_mtime = match mcp_config_mtime(&path) { - Some(m) => m, - None => return Ok(false), - }; - if Some(current_mtime) == self.last_mtime { + } + let current_mtimes: Vec<_> = self + .config_sources + .iter() + .map(|path| mcp_config_mtime(path)) + .collect(); + if current_mtimes == self.last_mtimes { return Ok(false); } // mtime moved — we owe a re-read. - let new_config: McpConfig = if path.exists() { - let contents = fs::read_to_string(&path) - .with_context(|| format!("Failed to re-read MCP config: {}", path.display()))?; - serde_json::from_str(&contents) - .with_context(|| format!("Failed to re-parse MCP config: {}", path.display()))? + let primary = self + .config_sources + .first() + .context("MCP config source list unexpectedly empty")?; + let new_config = if let Some(workspace) = self.workspace.as_deref() { + load_config_with_workspace(primary, workspace)? } else { - McpConfig::default() + load_config(primary)? }; let new_hash = hash_mcp_config(&new_config); - // Always advance last_mtime so a touched-but-unchanged file doesn't + // Always advance mtimes so a touched-but-unchanged file doesn't // make us re-read on every subsequent call. - self.last_mtime = Some(current_mtime); + self.last_mtimes = current_mtimes; if new_hash == self.config_hash { return Ok(false); } @@ -2604,6 +2625,95 @@ pub fn load_config(path: &Path) -> Result { .with_context(|| format!("Failed to parse MCP config {}", path.display())) } +pub fn workspace_mcp_config_path(workspace: &Path) -> PathBuf { + normalize_workspace_path(workspace) + .join(".codewhale") + .join("mcp.json") +} + +pub fn load_config_with_workspace(global_path: &Path, workspace: &Path) -> Result { + let mut merged = load_config(global_path)?; + let workspace = normalize_workspace_path(workspace); + let project_path = workspace_mcp_config_path(&workspace); + if !project_path.exists() || paths_refer_to_same_config(global_path, &project_path) { + return Ok(merged); + } + // Workspace-local MCP can spawn stdio servers, so it is only honored after + // the user has trusted this workspace in user-owned config. Do not accept + // project-local legacy trust markers here: a repository could carry those + // files itself and silently reintroduce the project-scope `mcp_config_path` + // risk denied in #417. + if !workspace_allows_project_mcp_config(&workspace) { + return Ok(merged); + } + + let mut project = load_config(&project_path)?; + for server in project.servers.values_mut() { + if server.command.is_some() && server.url.is_none() { + let cwd = match server.cwd.as_deref() { + Some(cwd) if cwd.is_relative() => normalize_path_components(&workspace.join(cwd)), + Some(cwd) => normalize_path_components(cwd), + None => workspace.to_path_buf(), + }; + if !cwd.starts_with(&workspace) { + anyhow::bail!( + "Project MCP server cwd must stay within workspace: {}", + cwd.display() + ); + } + server.cwd = Some(cwd); + } + } + merged.servers.extend(project.servers); + Ok(merged) +} + +fn workspace_allows_project_mcp_config(workspace: &Path) -> bool { + crate::config::is_workspace_trusted(workspace) +} + +fn normalize_workspace_path(workspace: &Path) -> PathBuf { + if let Ok(canonical) = workspace.canonicalize() { + return canonical; + } + let absolute = if workspace.is_absolute() { + workspace.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(workspace) + }; + normalize_path_components(&absolute) +} + +fn normalize_path_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + +fn paths_refer_to_same_config(left: &Path, right: &Path) -> bool { + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => normalize_workspace_path(left) == normalize_workspace_path(right), + } +} + /// 64-bit content hash of an [`McpConfig`]. Used by [`McpPool`] to decide /// whether a freshly-read config differs from the one currently driving the /// live connections. Hashing the JSON serialization avoids forcing every @@ -2654,6 +2764,7 @@ fn mcp_template_json() -> Result { command: Some("node".to_string()), args: vec!["./path/to/your-mcp-server.js".to_string()], env: HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -2709,6 +2820,7 @@ pub fn add_server_config( command, args, env: HashMap::new(), + cwd: None, url, transport, connect_timeout: None, @@ -2952,6 +3064,56 @@ mod tests { .await } + struct WorkspaceTrustConfigGuard { + config_path: PathBuf, + _codewhale_config_path: crate::test_support::EnvVarGuard, + _deepseek_config_path: crate::test_support::EnvVarGuard, + _env_lock: std::sync::MutexGuard<'static, ()>, + } + + fn workspace_trust_config_guard(workspace: &Path) -> WorkspaceTrustConfigGuard { + let env_lock = crate::test_support::lock_test_env(); + let config_path = workspace + .parent() + .unwrap_or(workspace) + .join("user-config") + .join("config.toml"); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let codewhale_config_path = + crate::test_support::EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str()); + let deepseek_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"); + + WorkspaceTrustConfigGuard { + config_path, + _codewhale_config_path: codewhale_config_path, + _deepseek_config_path: deepseek_config_path, + _env_lock: env_lock, + } + } + + fn write_workspace_trust_config(config_path: &Path, workspace: &Path) { + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let key = workspace + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + fs::write( + config_path, + format!("[projects.\"{key}\"]\ntrust_level = \"trusted\"\n"), + ) + .unwrap(); + } + + fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard { + let guard = workspace_trust_config_guard(workspace); + write_workspace_trust_config(&guard.config_path, workspace); + guard + } + #[test] fn test_mcp_config_defaults() { let config = McpConfig::default(); @@ -3021,6 +3183,7 @@ mod tests { command: Some("node".into()), args: vec!["server.js".into()], env: HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -3157,6 +3320,307 @@ mod tests { assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled")); } + #[test] + fn workspace_mcp_config_merges_with_project_overrides() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write( + &global_path, + r#"{ + "servers": { + "global": {"command": "node", "args": ["global.js"]}, + "shared": {"command": "node", "args": ["global-shared.js"]} + } + }"#, + ) + .unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{ + "servers": { + "project": {"command": "php", "args": ["artisan", "boost:mcp"]}, + "shared": {"command": "php", "args": ["artisan", "shared:mcp"]} + } + }"#, + ) + .unwrap(); + + let cfg = load_config_with_workspace(&global_path, &workspace).unwrap(); + let workspace = workspace.canonicalize().unwrap(); + + assert!(cfg.servers.contains_key("global")); + let project = cfg.servers.get("project").unwrap(); + assert_eq!(project.command.as_deref(), Some("php")); + assert_eq!(project.cwd.as_deref(), Some(workspace.as_path())); + let shared = cfg.servers.get("shared").unwrap(); + assert_eq!(shared.args, vec!["artisan", "shared:mcp"]); + assert_eq!(shared.cwd.as_deref(), Some(workspace.as_path())); + } + + #[test] + fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + let cfg = load_config_with_workspace(&global_path, &workspace).unwrap(); + + assert!(cfg.servers.contains_key("global")); + assert!(!cfg.servers.contains_key("project")); + } + + #[test] + fn workspace_mcp_config_ignores_project_local_legacy_trust_marker() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + fs::create_dir_all(workspace.join(".deepseek")).unwrap(); + fs::write(workspace.join(".deepseek").join("trusted"), "").unwrap(); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + let cfg = load_config_with_workspace(&global_path, &workspace).unwrap(); + + assert!(cfg.servers.contains_key("global")); + assert!(!cfg.servers.contains_key("project")); + } + + #[test] + fn workspace_mcp_config_ignores_invalid_untrusted_project_file() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + fs::write(&global_path, r#"{"servers": {}}"#).unwrap(); + fs::write(project_dir.join("mcp.json"), "{ not json").unwrap(); + + let cfg = load_config_with_workspace(&global_path, &workspace).unwrap(); + + assert!(cfg.servers.is_empty()); + } + + #[test] + fn workspace_mcp_config_normalizes_parent_components() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write(&global_path, r#"{"servers": {}}"#).unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "node", "args": ["server.js"]}}}"#, + ) + .unwrap(); + + let workspace_with_parent = workspace.join("..").join("workspace"); + let cfg = load_config_with_workspace(&global_path, &workspace_with_parent).unwrap(); + let workspace = workspace.canonicalize().unwrap(); + + assert!(cfg.servers.contains_key("project")); + let project = cfg.servers.get("project").unwrap(); + assert_eq!(project.cwd.as_deref(), Some(workspace.as_path())); + } + + #[test] + fn workspace_mcp_config_resolves_relative_cwd_from_workspace() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write(&global_path, r#"{"servers": {}}"#).unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools/mcp"}}}"#, + ) + .unwrap(); + + let cfg = load_config_with_workspace(&global_path, &workspace).unwrap(); + let workspace = workspace.canonicalize().unwrap(); + + let project = cfg.servers.get("project").unwrap(); + assert_eq!( + project.cwd.as_deref(), + Some(workspace.join("tools/mcp").as_path()) + ); + } + + #[test] + fn workspace_mcp_config_rejects_project_cwd_escape() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write(&global_path, r#"{"servers": {}}"#).unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "../outside"}}}"#, + ) + .unwrap(); + + let err = load_config_with_workspace(&global_path, &workspace) + .expect_err("project MCP cwd escape must be rejected"); + + assert!( + err.to_string() + .contains("Project MCP server cwd must stay within workspace"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn workspace_mcp_pool_reload_picks_up_project_config_creation() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&workspace).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + + let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap(); + assert_eq!(pool.server_names(), vec!["global"]); + + fs::create_dir_all(&project_dir).unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + assert!(pool.reload_if_config_changed().await.unwrap()); + let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect(); + let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect(); + assert_eq!(names, expected); + } + + #[tokio::test] + async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let trust_env = workspace_trust_config_guard(&workspace); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap(); + assert_eq!(pool.server_names(), vec!["global"]); + + write_workspace_trust_config(&trust_env.config_path, &workspace); + + assert!(pool.reload_if_config_changed().await.unwrap()); + let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect(); + let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect(); + assert_eq!(names, expected); + } + + #[tokio::test] + async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_removed() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let trust = mark_workspace_trusted(&workspace); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + fs::write( + project_dir.join("mcp.json"), + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap(); + let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect(); + let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect(); + assert_eq!(names, expected); + + fs::remove_file(&trust.config_path).unwrap(); + + assert!(pool.reload_if_config_changed().await.unwrap()); + assert_eq!(pool.server_names(), vec!["global"]); + } + + #[tokio::test] + async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() { + let dir = tempfile::tempdir().unwrap(); + let global_path = dir.path().join("global-mcp.json"); + let workspace = dir.path().join("workspace"); + let project_dir = workspace.join(".codewhale"); + fs::create_dir_all(&project_dir).unwrap(); + let _trust = mark_workspace_trusted(&workspace); + fs::write( + &global_path, + r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#, + ) + .unwrap(); + let project_path = project_dir.join("mcp.json"); + fs::write( + &project_path, + r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#, + ) + .unwrap(); + + let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap(); + let names: std::collections::BTreeSet<_> = pool.server_names().into_iter().collect(); + let expected: std::collections::BTreeSet<_> = ["global", "project"].into_iter().collect(); + assert_eq!(names, expected); + + fs::remove_file(project_path).unwrap(); + + assert!(pool.reload_if_config_changed().await.unwrap()); + assert_eq!(pool.server_names(), vec!["global"]); + } + #[test] fn test_mcp_config_rejects_traversal_path() { let err = load_config(Path::new("../mcp.json")).expect_err("traversal path should fail"); @@ -3257,6 +3721,7 @@ mod tests { command: Some("test".to_string()), args: vec![], env: HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: Some(20), @@ -3368,6 +3833,7 @@ mod tests { command: Some("mock".to_string()), args: Vec::new(), env: HashMap::new(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -3557,6 +4023,7 @@ mod tests { command: Some("/bin/echo".into()), args: vec!["hi".into()], env: Default::default(), + cwd: None, url: None, transport: None, connect_timeout: None, @@ -4081,6 +4548,7 @@ mod tests { command: None, args: vec![], env: HashMap::new(), + cwd: None, url: Some(format!("http://{addr}/mcp")), transport: None, connect_timeout: Some(2), @@ -4829,6 +5297,7 @@ mod tests { command: None, args: Vec::new(), env: HashMap::new(), + cwd: None, url: Some(format!("http://{addr}/mcp")), transport: None, connect_timeout: Some(10), @@ -5100,6 +5569,7 @@ mod tests { command: None, args: Vec::new(), env: HashMap::new(), + cwd: None, url: Some(format!("http://{addr}/sse")), transport: Some("sse".to_string()), connect_timeout: Some(10), diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 8dbf52c29..921cc42a2 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -34,7 +34,7 @@ use crate::automation_manager::{ CreateAutomationRequest, SharedAutomationManager, UpdateAutomationRequest, spawn_scheduler, }; use crate::config::{Config, DEFAULT_TEXT_MODEL}; -use crate::mcp::{McpConfig, McpPool}; +use crate::mcp::McpPool; use crate::models::{ContentBlock, Message}; use crate::runtime_threads::{ CompactThreadRequest, CreateThreadRequest, ExternalApprovalDecision, RuntimeThreadManager, @@ -1388,7 +1388,8 @@ async fn runtime_info(State(state): State) -> Json, ) -> Result, ApiError> { - let config = load_mcp_config_or_default(&state.mcp_config_path)?; + let config = crate::mcp::load_config_with_workspace(&state.mcp_config_path, &state.workspace) + .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; let mut pool = McpPool::new(config.clone()); let _errors = pool.connect_all().await; let connected: HashSet = pool @@ -1419,8 +1420,9 @@ async fn list_mcp_tools( State(state): State, Query(query): Query, ) -> Result, ApiError> { - let mut pool = McpPool::from_config_path(&state.mcp_config_path) - .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; + let mut pool = + McpPool::from_config_path_with_workspace(&state.mcp_config_path, &state.workspace) + .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; let _errors = pool.connect_all().await; let mut tools = Vec::new(); @@ -2126,11 +2128,6 @@ fn format_skill_search_paths(directories: &[PathBuf]) -> String { .join(", ") } -fn load_mcp_config_or_default(path: &std::path::Path) -> Result { - crate::mcp::load_config(path) - .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}"))) -} - #[derive(Debug, Deserialize)] struct UsageQuery { /// ISO-8601 lower bound (inclusive). When omitted, no lower bound. From b527bd507ad208451a3759cde78cbb8feeba351b Mon Sep 17 00:00:00 2001 From: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:39:48 -0700 Subject: [PATCH 079/209] feat(init): harvest LLM-guided AGENTS.md init Replace the static AGENTS.md template with a context-gathering /init flow that delegates customized project-guide generation to the agent. Keep the successor PR polish for credential-safe git remotes, devDependency framework detection, workspace Cargo context, and dead untracked-counter cleanup. This harvest also finishes the maintainer review items by preserving SSH remotes, handling nested git workspaces, sorting collected context deterministically, and detecting SvelteKit via @sveltejs/kit. Harvested from PR #2759 by @HUQIANTAO Includes original /init implementation from PR #2745 by @punkcanyang Co-authored-by: HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> Co-authored-by: Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> --- .github/AUTHOR_MAP | 3 + crates/tui/src/commands/init.rs | 1377 +++++++++++++++++++++++++------ 2 files changed, 1148 insertions(+), 232 deletions(-) diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index d1997277e..6b55ca302 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -16,6 +16,9 @@ Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com> +punkcanyang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +Punkcan Yang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> +bucunzai@gmail.com = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com> merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com> h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com> diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/init.rs index 7ca53ec92..3ce9d092f 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/init.rs @@ -1,14 +1,22 @@ //! /init command - Generate AGENTS.md for project +//! +//! Gathers rich project context (directory structure, build system, git info, CI/CD, +//! test frameworks) and delegates AGENTS.md generation to the LLM agent via +//! `AppAction::SendMessage`. This mirrors Claude Code's `/init` behavior — the agent +//! reads key source files, understands the architecture, and produces a customized, +//! comprehensive project guide. -use std::fmt::Write; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::process::Command; -use crate::tui::app::App; +use crate::project_context; +use crate::tui::app::{App, AppAction}; use super::CommandResult; -/// Generate an AGENTS.md file for the current project +/// Generate an AGENTS.md file for the current project by gathering context and +/// delegating content generation to the LLM agent. pub fn init(app: &mut App) -> CommandResult { let workspace = &app.workspace; @@ -19,20 +27,31 @@ pub fn init(app: &mut App) -> CommandResult { let agents_path = workspace.join("AGENTS.md"); let already_exists = agents_path.exists(); - // Detect project type and generate appropriate content - let content = generate_project_doc(workspace); - - // Write the file - match std::fs::write(&agents_path, &content) { - Ok(()) => { - let verb = if already_exists { "Updated" } else { "Created" }; - CommandResult::message(format!( - "{verb} AGENTS.md at {}\n\nEdit this file to customize agent behavior for your project.", - agents_path.display() - )) - } - Err(e) => CommandResult::error(format!("Failed to write AGENTS.md: {e}")), - } + // Gather rich project context for the agent. + let context = gather_project_context(workspace); + + // Read existing AGENTS.md content if updating. + let existing_content = if already_exists { + read_existing_agents_md(workspace) + } else { + None + }; + + // Construct the prompt for the LLM agent. + let prompt = build_init_prompt(&context, existing_content.as_deref(), already_exists); + + // Display message to user AND send the prompt to the agent. + let verb = if already_exists { + "Updating" + } else { + "Creating" + }; + let msg = format!( + "{verb} AGENTS.md at {}\n\nThe agent will analyze the codebase and generate a customized project guide.", + agents_path.display() + ); + + CommandResult::with_message_and_action(msg, AppAction::SendMessage(prompt)) } /// If `workspace` is inside a git repository, ensure workspace-local CodeWhale @@ -42,12 +61,11 @@ pub fn init(app: &mut App) -> CommandResult { /// committable (a directory exclude cannot be overridden, so `.codewhale/*` plus /// a negation is required). fn ensure_deepseek_gitignored(workspace: &Path) { - // Only act if this workspace is a git repo. - if !workspace.join(".git").exists() { + let Some(git_root) = git_root(workspace) else { return; - } + }; - let gitignore = workspace.join(".gitignore"); + let gitignore = git_root.join(".gitignore"); let entries = [ "**/.codewhale/*", "!**/.codewhale/constitution.json", @@ -98,171 +116,688 @@ fn ensure_deepseek_gitignored(workspace: &Path) { } } -/// Generate project documentation based on detected project type -fn generate_project_doc(workspace: &Path) -> String { - let mut doc = String::new(); - - // Header - doc.push_str("# Project Instructions\n\n"); - doc.push_str("This file provides context for AI assistants working on this project.\n\n"); - - // Detect project type - let project_info = detect_project_type(workspace); - doc.push_str(&project_info); - - // Agent behavior — conventions, gotchas, testing - doc.push_str("## Agent Guidance\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("- **CodeWhale reads this file as:** AGENTS.md (canonical cross-agent project instructions). \n"); - doc.push_str( - "- **Read-only surface:** \n", - ); - doc.push_str( - "- **Never edit:** \n", - ); - doc.push_str("- **Always test with:** \n"); - doc.push('\n'); - - // Architecture — the "big picture" that requires reading multiple files - doc.push_str("## Architecture\n\n"); - doc.push_str("\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Entry Points\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("### Key Modules\n"); - doc.push_str("\n"); - doc.push('\n'); - doc.push_str("### Data Flow\n"); - doc.push_str("\n"); - doc.push('\n'); - - // Cache-aware editing — helps maintain prefix-cache hit rates - doc.push_str("## Cache Stability\n\n"); - doc.push_str("\n"); - doc.push_str( - "\n", - ); - doc.push('\n'); - doc.push_str("- **Frequently-rebuilt files:** \n"); - doc.push_str("- **Stable scaffolding:** \n"); - doc.push_str("- **Append, don't reorder:** \n"); - doc.push('\n'); - - // Guidelines - doc.push_str("## Guidelines\n\n"); - doc.push_str("- Follow existing code style and patterns\n"); - doc.push_str("- Write tests for new functionality\n"); - doc.push_str("- Keep changes focused and atomic\n"); - doc.push_str("- Document public APIs\n"); - doc.push_str("- Update this file when project conventions change\n"); - - doc +// --------------------------------------------------------------------------- +// Context gathering functions +// --------------------------------------------------------------------------- + +/// Orchestrate all context gathering and return structured Markdown for the agent prompt. +fn gather_project_context(workspace: &Path) -> String { + let mut ctx = String::new(); + + // Project type summary (from existing utility). + let summary = crate::utils::summarize_project(workspace); + ctx.push_str("## Project Summary\n\n"); + ctx.push_str(&summary); + ctx.push_str("\n\n"); + + // Cargo.toml analysis. + if let Some(info) = parse_cargo_toml(workspace) { + ctx.push_str("## Rust / Cargo\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // package.json analysis. + if let Some(info) = parse_package_json(workspace) { + ctx.push_str("## Node.js / npm\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // Git repository info. + if let Some(info) = gather_git_info(workspace) { + ctx.push_str("## Git Repository\n\n"); + ctx.push_str(&info); + ctx.push_str("\n\n"); + } + + // CI/CD systems. + let ci = detect_ci_systems(workspace); + if !ci.is_empty() { + ctx.push_str("## CI/CD\n\n"); + for system in &ci { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } + + // Build systems. + let build = detect_build_systems(workspace); + if !build.is_empty() { + ctx.push_str("## Additional Build Systems\n\n"); + for system in &build { + let _ = std::fmt::write(&mut ctx, format_args!("- {system}\n")); + } + ctx.push('\n'); + } + + // Test frameworks. + let tests = detect_test_frameworks(workspace); + if !tests.is_empty() { + ctx.push_str("## Test Frameworks\n\n"); + for framework in &tests { + let _ = std::fmt::write(&mut ctx, format_args!("- {framework}\n")); + } + ctx.push('\n'); + } + + // Directory tree (from existing utility). + let tree = crate::utils::project_tree(workspace, 3); + ctx.push_str("## Directory Structure (depth 3)\n\n```\n"); + ctx.push_str(&tree); + ctx.push_str("\n```\n\n"); + + // Structured project context pack (from existing utility). + if let Some(pack) = project_context::generate_project_context_pack(workspace) { + ctx.push_str("## Detailed Project Context\n\n```json\n"); + ctx.push_str(&pack); + ctx.push_str("\n```\n\n"); + } + + ctx } -/// Detect project type and return relevant information -fn detect_project_type(workspace: &Path) -> String { - let mut info = String::new(); - - // Check for Rust project - if workspace.join("Cargo.toml").exists() { - info.push_str("## Project Type: Rust\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `cargo build`\n"); - info.push_str("- Test: `cargo test`\n"); - info.push_str("- Run: `cargo run`\n"); - info.push_str("- Check: `cargo check`\n"); - info.push_str("- Format: `cargo fmt`\n"); - info.push_str("- Lint: `cargo clippy`\n\n"); - - // Try to extract project name from Cargo.toml - if let Some(name) = std::fs::read_to_string(workspace.join("Cargo.toml")) - .ok() - .and_then(|content| extract_cargo_name(&content)) - { - let _ = write!(info, "### Project: {name}\n\n"); +/// Parse `Cargo.toml` and return a human-readable summary of the Rust project structure. +fn parse_cargo_toml(workspace: &Path) -> Option { + let cargo_path = workspace.join("Cargo.toml"); + let raw = std::fs::read_to_string(&cargo_path).ok()?; + let doc: toml::Value = toml::from_str(&raw).ok()?; + + let mut lines: Vec = Vec::new(); + + // Package info. + if let Some(package) = doc.get("package") { + if let Some(name) = package.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + if let Some(version) = package.get("version").and_then(|v| v.as_str()) { + lines.push(format!("- Version: {version}")); + } + if let Some(edition) = package.get("edition").and_then(|v| v.as_str()) { + lines.push(format!("- Rust edition: {edition}")); } } - // Check for Node.js project - else if workspace.join("package.json").exists() { - info.push_str("## Project Type: Node.js\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Install: `npm install`\n"); - info.push_str("- Test: `npm test`\n"); - info.push_str("- Build: `npm run build`\n"); - info.push_str("- Start: `npm start`\n\n"); - - // Check for common frameworks - if workspace.join("next.config.js").exists() || workspace.join("next.config.ts").exists() { - info.push_str("### Framework: Next.js\n\n"); - } else if workspace.join("vite.config.js").exists() - || workspace.join("vite.config.ts").exists() - { - info.push_str("### Framework: Vite\n\n"); + + // Workspace info. + if let Some(workspace_section) = doc.get("workspace") { + lines.push("- **This is a workspace root**".to_string()); + if let Some(members) = workspace_section.get("members").and_then(|v| v.as_array()) { + let mut member_names: Vec<&str> = members.iter().filter_map(|m| m.as_str()).collect(); + member_names.sort_unstable(); + if !member_names.is_empty() { + lines.push(format!("- Workspace members: {}", member_names.join(", "))); + } } } - // Check for Python project - else if workspace.join("pyproject.toml").exists() || workspace.join("setup.py").exists() { - info.push_str("## Project Type: Python\n\n"); - info.push_str("### Commands\n"); - if workspace.join("pyproject.toml").exists() { - info.push_str("- Install: `pip install -e .`\n"); + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_table()) { + let mut dep_names: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_names.sort_unstable(); + if !dep_names.is_empty() { + lines.push(format!("- Key dependencies: {}", dep_names.join(", "))); } - info.push_str("- Test: `pytest`\n"); - info.push_str("- Format: `black .`\n"); - info.push_str("- Lint: `ruff check .`\n\n"); } - // Check for Go project - else if workspace.join("go.mod").exists() { - info.push_str("## Project Type: Go\n\n"); - info.push_str("### Commands\n"); - info.push_str("- Build: `go build`\n"); - info.push_str("- Test: `go test ./...`\n"); - info.push_str("- Run: `go run .`\n"); - info.push_str("- Format: `go fmt ./...`\n\n"); + + // Dev dependencies — test frameworks. + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + let mut dev_names: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_names.sort_unstable(); + if !dev_names.is_empty() { + lines.push(format!("- Dev dependencies: {}", dev_names.join(", "))); + } } - // Unknown project type - else { - info.push_str("## Project Type: Unknown\n\n"); - info.push_str("\n\n"); + + // Workspace-level dependencies (shared across workspace members). + if let Some(ws_deps) = doc + .get("workspace") + .and_then(|w| w.get("dependencies")) + .and_then(|v| v.as_table()) + { + let mut ws_dep_names: Vec<&str> = ws_deps.keys().map(|k| k.as_str()).collect(); + ws_dep_names.sort_unstable(); + if !ws_dep_names.is_empty() { + lines.push(format!( + "- Workspace dependencies: {}", + ws_dep_names.join(", ") + )); + } } - // Check for README - if workspace.join("README.md").exists() { - info.push_str("### Documentation\n"); - info.push_str("See README.md for project overview.\n\n"); + // Features. + if let Some(features) = doc.get("features").and_then(|v| v.as_table()) { + let mut feat_names: Vec<&str> = features.keys().map(|k| k.as_str()).collect(); + feat_names.sort_unstable(); + if !feat_names.is_empty() { + lines.push(format!("- Features: {}", feat_names.join(", "))); + } } - // Check for .gitignore - if workspace.join(".gitignore").exists() { - info.push_str("### Version Control\n"); - info.push_str("This project uses Git. See .gitignore for excluded files.\n\n"); + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Parse `package.json` and return a human-readable summary of the Node.js project. +fn parse_package_json(workspace: &Path) -> Option { + let pkg_path = workspace.join("package.json"); + let raw = std::fs::read_to_string(&pkg_path).ok()?; + let doc: serde_json::Value = serde_json::from_str(&raw).ok()?; + + let mut lines: Vec = Vec::new(); + + if let Some(name) = doc.get("name").and_then(|v| v.as_str()) { + lines.push(format!("- Package name: `{name}`")); + } + + // Scripts. + if let Some(scripts) = doc.get("scripts").and_then(|v| v.as_object()) { + let mut script_names: Vec<&str> = scripts.keys().map(|k| k.as_str()).collect(); + script_names.sort_unstable(); + if !script_names.is_empty() { + lines.push(format!("- Scripts: {}", script_names.join(", "))); + } + } + + // Dependencies. + if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_object()) { + let mut dep_keys: Vec<&str> = deps.keys().map(|k| k.as_str()).collect(); + dep_keys.sort_unstable(); + if !dep_keys.is_empty() { + // Detect frameworks from runtime deps. + let frameworks = detect_js_frameworks(&dep_keys); + if !frameworks.is_empty() { + lines.push(format!("- Frameworks detected: {}", frameworks.join(", "))); + } + lines.push(format!("- Dependencies: {}", dep_keys.join(", "))); + } + } + + // Dev dependencies. + if let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) { + let mut dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + dev_keys.sort_unstable(); + if !dev_keys.is_empty() { + // Also detect build-tool/framework entries from devDependencies + // (Vite, webpack, esbuild, Turbopack, etc.). + let dev_frameworks = detect_js_frameworks(&dev_keys); + if !dev_frameworks.is_empty() { + lines.push(format!( + "- Dev frameworks/tools: {}", + dev_frameworks.join(", ") + )); + } + lines.push(format!("- Dev dependencies: {}", dev_keys.join(", "))); + } } - info + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } } -/// Extract project name from Cargo.toml -fn extract_cargo_name(content: &str) -> Option { - for line in content.lines() { - let line = line.trim(); - if line.starts_with("name") && line.contains('=') { - let parts: Vec<&str> = line.splitn(2, '=').collect(); - if parts.len() == 2 { - let name = parts[1].trim().trim_matches('"').trim_matches('\''); - return Some(name.to_string()); +/// Detect JS frameworks from dependency names. +fn detect_js_frameworks(deps: &[&str]) -> Vec { + let mut found: Vec = Vec::new(); + let candidates: &[(&str, &str)] = &[ + ("react", "React"), + ("next", "Next.js"), + ("vue", "Vue"), + ("nuxt", "Nuxt"), + ("@sveltejs/kit", "SvelteKit"), + ("svelte", "Svelte"), + ("sveltekit", "SvelteKit"), + ("astro", "Astro"), + ("express", "Express"), + ("fastify", "Fastify"), + ("hono", "Hono"), + ("vite", "Vite"), + ("webpack", "Webpack"), + ("esbuild", "esbuild"), + ("turbo", "Turbopack"), + ("tailwindcss", "Tailwind CSS"), + ]; + for dep in deps { + let lower = dep.to_lowercase(); + for (key, label) in candidates { + if lower == *key && !found.contains(&label.to_string()) { + found.push((*label).to_string()); } } } - None + found } +/// Strip userinfo (username:password or username) from a URL to avoid leaking +/// embedded credentials into the LLM prompt. +fn strip_url_credentials(url: &str) -> String { + // Handle SSH-style URLs: git@host:org/repo.git — no embedded password. + if url.contains('@') && !url.contains("://") { + return url.to_string(); + } + // HTTP(S) remotes: strip only authority userinfo. `@` in a path, query, + // or fragment is repository data, not credentials. SSH remotes such as + // `git@host:org/repo.git` and `ssh://git@host/org/repo.git` keep their + // user component because it is protocol syntax, not an embedded token. + if let Some(scheme_end) = url.find("://") { + let scheme_name = url[..scheme_end].to_ascii_lowercase(); + if scheme_name != "http" && scheme_name != "https" { + return url.to_string(); + } + let scheme = &url[..scheme_end + 3]; + let after_scheme = &url[scheme_end + 3..]; + let authority_end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + let (authority, suffix) = after_scheme.split_at(authority_end); + if let Some(at_pos) = authority.rfind('@') { + return format!("{scheme}{}{suffix}", &authority[at_pos + 1..]); + } + } + url.to_string() +} + +/// Find the enclosing git repository root. Works for nested workspaces and +/// worktrees where `.git` is a file instead of a directory. +fn git_root(workspace: &Path) -> Option { + let direct_git_marker = workspace.join(".git"); + let discovered = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(workspace) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + } else { + None + } + }); + discovered.or_else(|| direct_git_marker.exists().then(|| workspace.to_path_buf())) +} + +/// Gather git repository information via subprocess calls. +fn gather_git_info(workspace: &Path) -> Option { + let git_root = git_root(workspace)?; + + let run = |args: &[&str]| -> Option { + Command::new("git") + .args(args) + .current_dir(&git_root) + .output() + .ok() + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) + }; + + let mut lines: Vec = Vec::new(); + + // Remote URL (strip embedded credentials to avoid leaking tokens to the LLM). + if let Some(url) = run(&["remote", "get-url", "origin"]) { + let sanitized = strip_url_credentials(&url); + lines.push(format!("- Remote: {sanitized}")); + } + + // Current branch. + if let Some(branch) = run(&["rev-parse", "--abbrev-ref", "HEAD"]) { + lines.push(format!("- Branch: {branch}")); + } + + // Status summary. + let status_output = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=no"]) + .current_dir(&git_root) + .output() + .ok(); + if let Some(out) = status_output + && out.status.success() + { + let status_str = String::from_utf8_lossy(&out.stdout); + let staged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[0] != b' ' && b[0] != b'?' + }) + .count(); + let unstaged = status_str + .lines() + .filter(|l| { + let b = l.as_bytes(); + b.len() >= 2 && b[1] != b' ' && b[1] != b'?' + }) + .count(); + if staged > 0 || unstaged > 0 { + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{staged} staged")); + } + if unstaged > 0 { + parts.push(format!("{unstaged} modified")); + } + lines.push(format!("- Working tree: {}", parts.join(", "))); + } + } + + // Recent commits. + if let Some(log) = run(&["log", "--oneline", "-5"]) { + let commits: Vec<&str> = log.lines().collect(); + if !commits.is_empty() { + lines.push("- Recent commits:".to_string()); + for c in commits { + lines.push(format!(" - {c}")); + } + } + } + + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +/// Detect CI/CD systems configured in the project. +fn detect_ci_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join(".github").join("workflows").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join(".github").join("workflows")) + { + let files: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.ends_with(".yml") || name.ends_with(".yaml") { + Some(name) + } else { + None + } + }) + .collect(); + let mut files = files; + files.sort_unstable(); + if files.is_empty() { + found.push("GitHub Actions".to_string()); + } else { + found.push(format!("GitHub Actions ({})", files.join(", "))); + } + } + if workspace.join(".gitlab-ci.yml").exists() { + found.push("GitLab CI".to_string()); + } + if workspace.join("Jenkinsfile").exists() { + found.push("Jenkins".to_string()); + } + if workspace.join(".circleci").join("config.yml").exists() { + found.push("CircleCI".to_string()); + } + if workspace.join(".travis.yml").exists() { + found.push("Travis CI".to_string()); + } + if workspace.join("azure-pipelines.yml").exists() { + found.push("Azure Pipelines".to_string()); + } + + found +} + +/// Detect additional build systems beyond Cargo/npm. +fn detect_build_systems(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + if workspace.join("Makefile").exists() { + found.push("Makefile".to_string()); + } + if workspace.join("Justfile").exists() { + found.push("Justfile".to_string()); + } + if workspace.join("CMakeLists.txt").exists() { + found.push("CMake".to_string()); + } + if workspace.join("meson.build").exists() { + found.push("Meson".to_string()); + } + if workspace.join("BUILD.bazel").exists() || workspace.join("BUILD").exists() { + found.push("Bazel".to_string()); + } + if workspace.join("scripts").is_dir() + && let Ok(entries) = std::fs::read_dir(workspace.join("scripts")) + { + let scripts: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + let path = e.path(); + if (name.ends_with(".sh") || name.ends_with(".py") || name.ends_with(".js")) + && path.is_file() + { + Some(name) + } else { + None + } + }) + .collect(); + let mut scripts = scripts; + scripts.sort_unstable(); + if !scripts.is_empty() { + found.push(format!("scripts/ ({})", scripts.join(", "))); + } + } + + found +} + +/// Detect test frameworks from project configuration. +fn detect_test_frameworks(workspace: &Path) -> Vec { + let mut found: Vec = Vec::new(); + + // Rust: check Cargo.toml dev-dependencies (both crate and workspace level). + if let Ok(raw) = std::fs::read_to_string(workspace.join("Cargo.toml")) + && let Ok(doc) = toml::from_str::(&raw) + { + let mut dep_keys: Vec<&str> = Vec::new(); + if let Some(dev_deps) = doc.get("dev-dependencies").and_then(|v| v.as_table()) { + dep_keys.extend(dev_deps.keys().map(|k| k.as_str())); + } + if let Some(ws_dev_deps) = doc + .get("workspace") + .and_then(|w| w.get("dev-dependencies")) + .and_then(|v| v.as_table()) + { + dep_keys.extend(ws_dev_deps.keys().map(|k| k.as_str())); + } + + let rust_test_frameworks: &[(&str, &str)] = &[ + ("tokio-test", "tokio-test"), + ("proptest", "proptest"), + ("quickcheck", "quickcheck"), + ("rstest", "rstest"), + ("criterion", "criterion (benchmark)"), + ("mockall", "mockall"), + ("pretty_assertions", "pretty_assertions"), + ]; + for (dep_key, label) in rust_test_frameworks { + if dep_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Node.js: check package.json devDependencies. + if let Ok(raw) = std::fs::read_to_string(workspace.join("package.json")) + && let Ok(doc) = serde_json::from_str::(&raw) + && let Some(dev_deps) = doc.get("devDependencies").and_then(|v| v.as_object()) + { + let dev_keys: Vec<&str> = dev_deps.keys().map(|k| k.as_str()).collect(); + + let js_test_frameworks: &[(&str, &str)] = &[ + ("jest", "Jest"), + ("vitest", "Vitest"), + ("mocha", "Mocha"), + ("jasmine", "Jasmine"), + ("ava", "AVA"), + ("playwright", "Playwright"), + ("cypress", "Cypress"), + ("@testing-library/react", "Testing Library"), + ]; + for (dep_key, label) in js_test_frameworks { + if dev_keys.contains(dep_key) { + found.push((*label).to_string()); + } + } + } + + // Python: check common test config files. + if workspace.join("pytest.ini").exists() + || workspace.join("tox.ini").exists() + || workspace.join("conftest.py").exists() + || (workspace.join("pyproject.toml").exists() + && std::fs::read_to_string(workspace.join("pyproject.toml")) + .ok() + .is_some_and(|raw| raw.contains("[tool.pytest"))) + { + found.push("pytest".to_string()); + } + + found +} + +/// Read existing AGENTS.md content (up to 100KB) for in-place update. +fn read_existing_agents_md(workspace: &Path) -> Option { + let path = workspace.join("AGENTS.md"); + let meta = std::fs::metadata(&path).ok()?; + let limit = 100 * 1024; + let len = meta.len() as usize; + let content = if len > limit { + let mut f = std::fs::File::open(&path).ok()?; + let mut buf = vec![0u8; limit]; + f.read_exact(&mut buf).ok()?; + String::from_utf8_lossy(&buf).into_owned() + } else { + std::fs::read_to_string(&path).ok()? + }; + if content.trim().is_empty() { + None + } else { + Some(content) + } +} + +// --------------------------------------------------------------------------- +// Prompt builder +// --------------------------------------------------------------------------- + +/// Build the SendMessage prompt instructing the agent to analyze and generate AGENTS.md. +fn build_init_prompt( + context: &str, + existing_content: Option<&str>, + already_exists: bool, +) -> String { + let mut prompt = String::new(); + + prompt.push_str( + "You are generating a comprehensive AGENTS.md file for this project. \ + Your task is to deeply analyze the codebase and produce a customized, \ + actionable project guide that will help future AI agents work effectively here.\n\n", + ); + + prompt.push_str("## Project Context (pre-gathered)\n\n"); + prompt.push_str(context); + prompt.push('\n'); + + if let Some(existing) = existing_content { + prompt.push_str("## Existing AGENTS.md\n\n"); + prompt.push_str("Below is the current AGENTS.md content. "); + if already_exists { + prompt.push_str( + "Update it in place: preserve any custom sections that still apply, \ + replace stale or incorrect information with your fresh analysis. ", + ); + } + prompt.push_str("\n\n```markdown\n"); + prompt.push_str(existing); + prompt.push_str("\n```\n\n"); + } + + prompt.push_str("## Instructions\n\n"); + + prompt.push_str( + "1. **Read key source files** to understand the architecture:\n\ + - Start with the main entry point(s) (e.g., main.rs, index.ts, app.py)\n\ + - Read the top-level module structure to understand component boundaries\n\ + - Read a few representative files from each major module or crate\n\ + - Read config files (config.example.toml, tsconfig.json, etc.) to understand settings\n\n\ + 2. **Generate AGENTS.md** at the workspace root. Use `AGENTS.md` as the filename. \ + Include these sections as applicable:\n\n\ + ### Build / Test / Lint\n\ + - Exact commands for: build, test (all + single), lint, format, run, install deps\n\ + - Be specific — if there's a Justfile, use `just `; if nextest, use `cargo nextest run`\n\n\ + ### Architecture\n\ + - High-level description of the project's purpose\n\ + - Component or module tree with 1-2 sentence descriptions each\n\ + - Data flow through the system (if determinable)\n\n\ + ### Key Files & Directories\n\ + - What each top-level directory contains\n\ + - Important config files and what they control\n\n\ + ### Coding Conventions\n\ + - What you observe from reading source files: naming, error handling patterns, \ + module organization, test patterns\n\ + - Code generation (build.rs, protobuf, etc.) if present\n\n\ + ### Git Workflow\n\ + - Branch naming conventions (if observable from recent commits)\n\ + - Commit message style\n\n\ + ### CI/CD\n\ + - How tests run in CI, what's checked on PRs\n\n\ + ### Tips for AI Agents\n\ + - Common pitfalls in the codebase structure\n\ + - Where to look for specific kinds of things\n\ + - Any gotchas in the build setup\n\n\ + 3. **Style requirements**:\n\ + - Be concise and actionable. This is a reference document, not a tutorial.\n\ + - Use markdown headings, code blocks, and bullet lists.\n\ + - Keep the total under ~150 lines unless the project genuinely needs more.\n\ + - Write in English.\n\ + - Do NOT include placeholder HTML comments like \"\".\n\ + - If you cannot determine something with confidence, omit that section rather than guessing.\n\n\ + 4. **Write the file** using the file write tool. \ + The file should be named `AGENTS.md` at the workspace root.\n\n", + ); + + if already_exists { + prompt.push_str( + "The file already exists — update it in place, \ + preserving custom content that still applies but replacing stale information.\n\n", + ); + } + + prompt.push_str( + "5. After writing, briefly summarize what you learned and what you put into AGENTS.md.\n", + ); + + prompt +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -295,110 +830,486 @@ mod tests { App::new(options, &Config::default()) } + // --- init() integration tests --- + #[test] - fn test_init_creates_agents_md() { + fn init_returns_send_message_action() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); let result = init(&mut app); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Created AGENTS.md")); - let agents_path = tmpdir.path().join("AGENTS.md"); - assert!(agents_path.exists()); + assert!(msg.contains("Creating AGENTS.md")); + assert!( + matches!(result.action, Some(AppAction::SendMessage(_))), + "expected SendMessage action" + ); + } + + #[test] + fn init_says_updating_when_agents_md_exists() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write(tmpdir.path().join("AGENTS.md"), "existing content").unwrap(); + let result = init(&mut app); + assert!(result.message.unwrap().contains("Updating AGENTS.md")); + assert!(matches!(result.action, Some(AppAction::SendMessage(_)))); } #[test] - fn test_init_updates_if_exists() { + fn init_includes_gitignore_handling() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); - // Create file first with stale content - let agents_path = tmpdir.path().join("AGENTS.md"); - std::fs::write(&agents_path, "existing stale content").unwrap(); + std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); let result = init(&mut app); assert!(!result.is_error); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Updated AGENTS.md")); - let new_content = std::fs::read_to_string(&agents_path).unwrap(); - assert!(new_content.contains("# Project Instructions")); - assert!(!new_content.contains("existing stale content")); + // Should have added .deepseek/ to .gitignore. + let gi = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(gi.contains(".deepseek/")); } #[test] - fn test_detect_project_type_rust() { + fn init_prompt_includes_context_for_rust_project() { let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); std::fs::write( tmpdir.path().join("Cargo.toml"), - "[package]\nname = \"test\"", + "[package]\nname = \"test-crate\"\nversion = \"0.1.0\"\n", ) .unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Rust")); - assert!(info.contains("cargo build")); - assert!(info.contains("cargo test")); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!( + prompt.contains("test-crate"), + "prompt should mention crate name" + ); + assert!( + prompt.contains("Read key source files"), + "should have instructions" + ); + assert!( + prompt.contains("AGENTS.md"), + "should mention AGENTS.md filename" + ); } #[test] - fn test_detect_project_type_node() { + fn init_prompt_includes_existing_content() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Node.js")); - assert!(info.contains("npm install")); + let mut app = create_test_app_with_tmpdir(&tmpdir); + std::fs::write( + tmpdir.path().join("AGENTS.md"), + "# My Project\n\nCustom instructions here.", + ) + .unwrap(); + let result = init(&mut app); + let Some(AppAction::SendMessage(prompt)) = result.action else { + panic!("expected SendMessage action"); + }; + assert!(prompt.contains("Custom instructions here")); + assert!(prompt.contains("update it in place")); } + // --- parse_cargo_toml tests --- + #[test] - fn test_detect_project_type_python() { + fn parse_cargo_toml_single_crate() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Python")); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n\ + [dependencies]\ntokio = \"1\"\nserde = \"1\"\n", + ) + .unwrap(); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("my-crate")); + assert!(info.contains("1.0.0")); + assert!(info.contains("2021")); + assert!(info.contains("tokio")); + assert!(info.contains("serde")); } #[test] - fn test_detect_project_type_go() { + fn parse_cargo_toml_workspace() { let tmpdir = TempDir::new().unwrap(); - std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Go")); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/cli\", \"crates/tui\"]\n\n\ + [workspace.dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + let info = parse_cargo_toml(tmpdir.path()).unwrap(); + assert!(info.contains("workspace root")); + assert!(info.contains("crates/cli")); + assert!(info.contains("crates/tui")); } #[test] - fn test_detect_project_type_unknown() { + fn parse_cargo_toml_missing() { let tmpdir = TempDir::new().unwrap(); - let info = detect_project_type(tmpdir.path()); - assert!(info.contains("Project Type: Unknown")); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } #[test] - fn test_extract_cargo_name() { - let cargo = r#" -[package] -name = "my-project" -version = "1.0.0" -"#; - assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string())); + fn parse_cargo_toml_invalid() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Cargo.toml"), "not valid toml {{{").unwrap(); + assert!(parse_cargo_toml(tmpdir.path()).is_none()); } + // --- parse_package_json tests --- + #[test] - fn test_extract_cargo_name_single_quotes() { - let cargo = r#"name = 'single-quoted'"#; - assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string())); + fn parse_package_json_basic() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"name":"my-app","scripts":{"build":"tsc","test":"jest"},"dependencies":{"react":"^18"},"devDependencies":{"jest":"^29"}}"#, + ) + .unwrap(); + let info = parse_package_json(tmpdir.path()).unwrap(); + assert!(info.contains("my-app")); + assert!(info.contains("build")); + assert!(info.contains("test")); + assert!(info.contains("React")); + assert!(info.contains("jest")); } #[test] - fn test_extract_cargo_name_not_found() { - let cargo = "[package]\nversion = \"1.0.0\""; - assert_eq!(extract_cargo_name(cargo), None); + fn parse_package_json_sorts_context_keys() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{ + "scripts":{"zeta":"node z.js","alpha":"node a.js"}, + "dependencies":{"react":"^18","axios":"^1"}, + "devDependencies":{"vitest":"^1","@sveltejs/kit":"^2"} + }"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("- Scripts: alpha, zeta")); + assert!(info.contains("- Dependencies: axios, react")); + assert!(info.contains("- Dev dependencies: @sveltejs/kit, vitest")); } + #[test] + fn parse_package_json_detects_sveltekit_from_dev_dependencies() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"@sveltejs/kit":"^2","vite":"^5"}}"#, + ) + .unwrap(); + + let info = parse_package_json(tmpdir.path()).unwrap(); + + assert!(info.contains("SvelteKit")); + assert!(info.contains("Vite")); + } + + #[test] + fn parse_package_json_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(parse_package_json(tmpdir.path()).is_none()); + } + + // --- gather_git_info tests --- + + #[test] + fn strip_url_credentials_removes_authority_userinfo() { + assert_eq!( + strip_url_credentials("https://user:token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + assert_eq!( + strip_url_credentials("https://token@github.com/org/repo.git"), + "https://github.com/org/repo.git" + ); + } + + #[test] + fn strip_url_credentials_preserves_non_authority_at_signs() { + assert_eq!( + strip_url_credentials("https://github.com/org/repo@feature.git"), + "https://github.com/org/repo@feature.git" + ); + assert_eq!( + strip_url_credentials("https://github.com/org/repo.git?ref=user@example.com"), + "https://github.com/org/repo.git?ref=user@example.com" + ); + assert_eq!( + strip_url_credentials("git@github.com:org/repo.git"), + "git@github.com:org/repo.git" + ); + assert_eq!( + strip_url_credentials("ssh://git@github.com/org/repo.git"), + "ssh://git@github.com/org/repo.git" + ); + } + + #[test] + fn gather_git_info_no_repo_returns_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(gather_git_info(tmpdir.path()).is_none()); + } + + #[test] + fn gather_git_info_in_repo_returns_branch() { + let tmpdir = TempDir::new().unwrap(); + // Init a real git repo. + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + // Create a commit so rev-parse works. + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + + let info = gather_git_info(tmpdir.path()).unwrap(); + assert!( + info.contains("main") || info.contains("master"), + "should show branch: {info}" + ); + } + + #[test] + fn gather_git_info_works_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + std::fs::write(tmpdir.path().join("hello.txt"), "hi").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + let info = gather_git_info(&nested).unwrap(); + + assert!(info.contains("Branch: main"), "git info was: {info}"); + } + + // --- detect_ci_systems tests --- + + #[test] + fn detect_ci_github_actions() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("ci.yml"), "").unwrap(); + let ci = detect_ci_systems(tmpdir.path()); + assert!(ci.iter().any(|s| s.contains("GitHub Actions"))); + } + + #[test] + fn detect_ci_github_actions_sorts_workflow_files() { + let tmpdir = TempDir::new().unwrap(); + let wf_dir = tmpdir.path().join(".github").join("workflows"); + std::fs::create_dir_all(&wf_dir).unwrap(); + std::fs::write(wf_dir.join("z.yml"), "").unwrap(); + std::fs::write(wf_dir.join("a.yaml"), "").unwrap(); + + let ci = detect_ci_systems(tmpdir.path()); + + assert_eq!(ci[0], "GitHub Actions (a.yaml, z.yml)"); + } + + #[test] + fn detect_ci_none() { + let tmpdir = TempDir::new().unwrap(); + assert!(detect_ci_systems(tmpdir.path()).is_empty()); + } + + // --- detect_build_systems tests --- + + #[test] + fn detect_makefile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Makefile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Makefile".to_string())); + } + + #[test] + fn detect_justfile() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("Justfile"), "").unwrap(); + let build = detect_build_systems(tmpdir.path()); + assert!(build.contains(&"Justfile".to_string())); + } + + #[test] + fn detect_build_systems_sorts_scripts() { + let tmpdir = TempDir::new().unwrap(); + let scripts = tmpdir.path().join("scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write(scripts.join("z.sh"), "").unwrap(); + std::fs::write(scripts.join("a.py"), "").unwrap(); + + let build = detect_build_systems(tmpdir.path()); + + assert!(build.contains(&"scripts/ (a.py, z.sh)".to_string())); + } + + // --- detect_test_frameworks tests --- + + #[test] + fn detect_rust_test_frameworks_from_cargo() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("Cargo.toml"), + "[dev-dependencies]\ntokio-test = \"1\"\nproptest = \"1\"\n", + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"tokio-test".to_string())); + assert!(frameworks.contains(&"proptest".to_string())); + } + + #[test] + fn detect_js_test_frameworks_from_package_json() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write( + tmpdir.path().join("package.json"), + r#"{"devDependencies":{"jest":"^29","vitest":"^1"}}"#, + ) + .unwrap(); + let frameworks = detect_test_frameworks(tmpdir.path()); + assert!(frameworks.contains(&"Jest".to_string())); + assert!(frameworks.contains(&"Vitest".to_string())); + } + + // --- read_existing_agents_md tests --- + + #[test] + fn read_existing_agents_md_present() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "hello world").unwrap(); + let content = read_existing_agents_md(tmpdir.path()); + assert_eq!(content, Some("hello world".to_string())); + } + + #[test] + fn read_existing_agents_md_missing() { + let tmpdir = TempDir::new().unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + #[test] + fn read_existing_agents_md_empty_file_returns_none() { + let tmpdir = TempDir::new().unwrap(); + std::fs::write(tmpdir.path().join("AGENTS.md"), "").unwrap(); + assert!(read_existing_agents_md(tmpdir.path()).is_none()); + } + + // --- build_init_prompt tests --- + + #[test] + fn build_init_prompt_contains_all_sections() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(prompt.contains("Project Context")); + assert!(prompt.contains("A Rust project")); + assert!(prompt.contains("Read key source files")); + assert!(prompt.contains("Build / Test / Lint")); + assert!(prompt.contains("Architecture")); + assert!(prompt.contains("AGENTS.md")); + } + + #[test] + fn build_init_prompt_with_existing_content() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let existing = "# Old AGENTS.md content"; + let prompt = build_init_prompt(ctx, Some(existing), true); + assert!(prompt.contains("Old AGENTS.md content")); + assert!(prompt.contains("Update it in place")); + } + + #[test] + fn build_init_prompt_new_file_no_update_instruction() { + let ctx = "## Project Summary\n\nA Rust project\n"; + let prompt = build_init_prompt(ctx, None, false); + assert!(!prompt.contains("The file already exists")); + } + + // --- js framework detection --- + + #[test] + fn detect_js_frameworks_react() { + let deps = ["react", "react-dom", "vite"]; + let frameworks = detect_js_frameworks(&deps); + assert!(frameworks.contains(&"React".to_string())); + assert!(frameworks.contains(&"Vite".to_string())); + } + + #[test] + fn detect_js_frameworks_none() { + let deps = ["lodash", "axios"]; + assert!(detect_js_frameworks(&deps).is_empty()); + } + + // --- ensure_deepseek_gitignored (preserved tests) --- + #[test] fn ensure_deepseek_gitignored_creates_gitignore() { let tmpdir = TempDir::new().unwrap(); - // Simulate a git repo. std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains(".deepseek/")); // .codewhale/ is ignored at any depth, but the committed @@ -412,9 +1323,7 @@ version = "1.0.0" let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); std::fs::write(tmpdir.path().join(".gitignore"), "target/\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); @@ -424,10 +1333,8 @@ version = "1.0.0" fn ensure_deepseek_gitignored_idempotent() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); assert_eq!(content.matches(".deepseek/").count(), 1); } @@ -435,10 +1342,7 @@ version = "1.0.0" #[test] fn ensure_deepseek_gitignored_skips_non_git_repo() { let tmpdir = TempDir::new().unwrap(); - // No .git directory — not a git repo. - ensure_deepseek_gitignored(tmpdir.path()); - assert!(!tmpdir.path().join(".gitignore").exists()); } @@ -446,16 +1350,11 @@ version = "1.0.0" fn ensure_deepseek_gitignored_handles_no_trailing_newline() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write a file that does NOT end with a newline. std::fs::write(tmpdir.path().join(".gitignore"), "target/").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Must have both entries on separate lines. assert!(content.contains("target/")); assert!(content.contains(".deepseek/")); - // The entries should be on different lines. let lines: Vec<&str> = content.lines().collect(); assert!(lines.len() >= 2); } @@ -464,13 +1363,27 @@ version = "1.0.0" fn ensure_deepseek_gitignored_detects_variant_without_slash() { let tmpdir = TempDir::new().unwrap(); std::fs::create_dir_all(tmpdir.path().join(".git")).unwrap(); - // Write .deepseek without trailing slash. std::fs::write(tmpdir.path().join(".gitignore"), ".deepseek\n").unwrap(); - ensure_deepseek_gitignored(tmpdir.path()); - let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); - // Should NOT add a duplicate entry. assert_eq!(content.matches(".deepseek").count(), 1); } + + #[test] + fn ensure_deepseek_gitignored_updates_repo_root_from_nested_workspace() { + let tmpdir = TempDir::new().unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(tmpdir.path()) + .output() + .unwrap(); + let nested = tmpdir.path().join("nested").join("app"); + std::fs::create_dir_all(&nested).unwrap(); + + ensure_deepseek_gitignored(&nested); + + let content = std::fs::read_to_string(tmpdir.path().join(".gitignore")).unwrap(); + assert!(content.contains(".deepseek/")); + assert!(!nested.join(".gitignore").exists()); + } } From 912d6aed2c058c85617f05ee02ebb89096bf8574 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 20:49:54 -0700 Subject: [PATCH 080/209] fix(tui): #2742 avoid static Ollama model suggestions Harvested from PR #2742 by @reidliu41 Ollama model IDs are local passthrough tags, so /model static completions should not suggest hosted DeepSeek API models or entrench the stale default local tag. Keep the picker on auto/current/saved local models while the existing /models path fetches installed tags from the configured endpoint. Verification: cargo fmt --all -- --check; git diff --check; ./scripts/release/check-versions.sh; cargo test -p codewhale-tui --bin codewhale-tui --locked ollama -- --nocapture; cargo clippy -p codewhale-tui --bin codewhale-tui --locked -- -D warnings. Co-authored-by: reidliu41 <61492567+reidliu41@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ crates/tui/CHANGELOG.md | 5 +++++ crates/tui/src/config.rs | 12 +++++++++--- crates/tui/src/tui/model_picker.rs | 16 ++++++++++++++++ crates/tui/src/tui/widgets/mod.rs | 15 +++++++++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d02fc1c4a..b22b3b729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a PR gate marker guard so reopened unapproved PRs do not get duplicate intake comments, and clarified that PR reopening should happen after allowlist approval is merged. +- Ollama `/model` completions no longer show hosted DeepSeek API model IDs. + The picker preserves the current or saved local Ollama tag, and users can + still fetch installed model IDs through `/models` instead of relying on a + stale static default (#2742). Thanks @reidliu41 for the focused report and + draft fix. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. - Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index d02fc1c4a..b22b3b729 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -78,6 +78,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a PR gate marker guard so reopened unapproved PRs do not get duplicate intake comments, and clarified that PR reopening should happen after allowlist approval is merged. +- Ollama `/model` completions no longer show hosted DeepSeek API model IDs. + The picker preserves the current or saved local Ollama tag, and users can + still fetch installed model IDs through `/models` instead of relying on a + stale static default (#2742). Thanks @reidliu41 for the focused report and + draft fix. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. - Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2f65bc51d..20f752a10 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -788,9 +788,8 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], ApiProvider::Volcengine => vec![DEFAULT_VOLCENGINE_MODEL, DEFAULT_VOLCENGINE_FLASH_MODEL], - ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => { - OFFICIAL_DEEPSEEK_MODELS.to_vec() - } + ApiProvider::Ollama => Vec::new(), + ApiProvider::Openai | ApiProvider::Atlascloud => OFFICIAL_DEEPSEEK_MODELS.to_vec(), } } @@ -7499,6 +7498,13 @@ api_key = "old-openrouter-key" ); } + #[test] + fn model_completion_names_for_ollama_do_not_promote_static_remote_models() { + let models = model_completion_names_for_provider(ApiProvider::Ollama); + + assert!(models.is_empty()); + } + #[test] fn model_completion_names_for_openrouter_include_recent_large_models() { let models = model_completion_names_for_provider(ApiProvider::Openrouter); diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index e2a8f1a0f..211250440 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -777,6 +777,22 @@ mod tests { } } + #[test] + fn picker_for_ollama_preserves_current_local_tag_without_hosted_static_rows() { + let (mut app, _lock) = create_test_app(); + app.api_provider = crate::config::ApiProvider::Ollama; + app.model_ids_passthrough = true; + app.model = "qwen2.5-coder:7b".to_string(); + app.auto_model = false; + + let view = ModelPickerView::new(&app); + let model_ids = view.visible_model_ids(); + + assert_eq!(model_ids, vec!["auto"]); + assert!(view.show_custom_model_row); + assert_eq!(view.resolved_model(), "qwen2.5-coder:7b"); + } + #[test] fn visible_row_window_tracks_selection_in_short_panes() { assert_eq!(visible_row_window(0, 16, 8), (0, 8)); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 55b8cff8d..181a45a05 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -3303,6 +3303,21 @@ mod tests { assert!(!names.contains(&"/model deepseek/deepseek-v4-pro")); } + #[test] + fn slash_completion_hints_model_ollama_has_no_static_remote_models() { + let hints = + slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Ollama); + let names = hints + .iter() + .map(|hint| hint.name.as_str()) + .collect::>(); + + assert!(names.contains(&"/model")); + assert!(!names.contains(&"/model deepseek-v4-pro")); + assert!(!names.contains(&"/model deepseek-v4-flash")); + assert!(!names.contains(&"/model deepseek-coder:1.3b")); + } + #[test] fn selection_style_uses_explicit_selection_text_role() { let line = Line::from(Span::styled( From 8869f6a722cc6f2861c66fefa6e1802a4a55f700 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 20:55:53 -0700 Subject: [PATCH 081/209] fix(mcp): #2744 preserve underscored names in displays Follow up the #2744 MCP routing harvest by reusing the registered-server parser in the runtime API tool listing path and by making approval summaries show the full MCP target route instead of a guessed first underscore segment. This keeps tool-call routing, runtime metadata, and approval copy aligned for servers such as my_db while avoiding an impossible server-only guess in approval cards that do not have the live MCP registry. Refs #2744 Verification: cargo fmt --all -- --check; git diff --check; ./scripts/release/check-versions.sh; cargo test -p codewhale-tui --bin codewhale-tui --locked underscored -- --nocapture; cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool -- --nocapture; cargo clippy -p codewhale-tui --bin codewhale-tui --locked -- -D warnings. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> Co-authored-by: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> --- CHANGELOG.md | 6 ++++ crates/tui/CHANGELOG.md | 6 ++++ crates/tui/src/mcp.rs | 26 +++++++++++++++++- crates/tui/src/runtime_api.rs | 5 +--- crates/tui/src/tui/approval.rs | 50 ++++++++++++++++++++++++++-------- 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b22b3b729..043ed22ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 still fetch installed model IDs through `/models` instead of relying on a stale static default (#2742). Thanks @reidliu41 for the focused report and draft fix. +- MCP runtime API tool listings and approval summaries no longer split + underscored MCP server names at the first `_`. Tool-call routing already used + the longest registered server name; the list endpoint now reuses that parser, + and approval cards show the full MCP target route instead of a guessed server + segment (#2744). Thanks @lioryx, @cyq1017, and @puneetdixit200 for the report + and matching fixes. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. - Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index b22b3b729..043ed22ce 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -83,6 +83,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 still fetch installed model IDs through `/models` instead of relying on a stale static default (#2742). Thanks @reidliu41 for the focused report and draft fix. +- MCP runtime API tool listings and approval summaries no longer split + underscored MCP server names at the first `_`. Tool-call routing already used + the longest registered server name; the list endpoint now reuses that parser, + and approval cards show the full MCP target route instead of a guessed server + segment (#2744). Thanks @lioryx, @cyq1017, and @puneetdixit200 for the report + and matching fixes. - Documented the agent and sub-agent stewardship ethos so future automation preserves human issue intake, careful PR review, and contributor credit. - Moved the TUI Starlark execpolicy parser and PTY support behind non-OHOS diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index ea090af11..31f509383 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -2261,7 +2261,10 @@ impl McpPool { } /// Parse a prefixed name into (server_name, tool_name) - fn parse_prefixed_name<'a>(&self, prefixed_name: &'a str) -> Result<(&'a str, &'a str)> { + pub(crate) fn parse_prefixed_name<'a>( + &self, + prefixed_name: &'a str, + ) -> Result<(&'a str, &'a str)> { let Some(rest) = prefixed_name.strip_prefix("mcp_") else { anyhow::bail!("Invalid MCP tool name: {prefixed_name}"); }; @@ -3151,6 +3154,27 @@ mod tests { assert_eq!(server.env.get("FOO"), Some(&"bar".to_string())); } + #[test] + fn mcp_pool_parse_prefixed_name_preserves_registered_underscored_server() { + let config: McpConfig = serde_json::from_str( + r#"{ + "servers": { + "my": {"command": "node"}, + "my_db": {"command": "node"} + } + }"#, + ) + .unwrap(); + let pool = McpPool::new(config); + + let (server, tool) = pool + .parse_prefixed_name("mcp_my_db_execute_sql") + .expect("registered underscored server should parse"); + + assert_eq!(server, "my_db"); + assert_eq!(tool, "execute_sql"); + } + #[test] fn mcp_server_config_parses_custom_headers() { let json = r#"{ diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 921cc42a2..6265d466e 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1427,10 +1427,7 @@ async fn list_mcp_tools( let mut tools = Vec::new(); for (prefixed_name, tool) in pool.all_tools() { - let Some(rest) = prefixed_name.strip_prefix("mcp_") else { - continue; - }; - let Some((server, name)) = rest.split_once('_') else { + let Ok((server, name)) = pool.parse_prefixed_name(&prefixed_name) else { continue; }; diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 361cb751c..8c2665e3f 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -344,13 +344,12 @@ fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option Option { +fn mcp_target_hint(tool_name: &str) -> Option { let remainder = tool_name.strip_prefix("mcp_")?; - let (server, _) = remainder.split_once('_')?; - if server.is_empty() { + if remainder.is_empty() { None } else { - Some(server.to_string()) + Some(remainder.to_string()) } } @@ -393,16 +392,16 @@ fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) ToolCategory::McpRead => { let mut impacts = vec!["Reads from an MCP server without an obvious local write.".to_string()]; - if let Some(server) = mcp_server_hint(tool_name) { - impacts.push(format!("Server: {server}")); + if let Some(target) = mcp_target_hint(tool_name) { + impacts.push(format!("MCP target: {target}")); } impacts } ToolCategory::McpAction => { let mut impacts = vec!["Calls an MCP server action that may have side effects.".to_string()]; - if let Some(server) = mcp_server_hint(tool_name) { - impacts.push(format!("Server: {server}")); + if let Some(target) = mcp_target_hint(tool_name) { + impacts.push(format!("MCP target: {target}")); } impacts } @@ -475,15 +474,15 @@ fn build_impact_summary_zh_hans( } ToolCategory::McpRead => { let mut impacts = vec!["从 MCP 服务器读取信息,不应产生本地写入。".to_string()]; - if let Some(server) = mcp_server_hint(tool_name) { - impacts.push(format!("服务器:{server}")); + if let Some(target) = mcp_target_hint(tool_name) { + impacts.push(format!("MCP 目标:{target}")); } impacts } ToolCategory::McpAction => { let mut impacts = vec!["调用可能产生副作用的 MCP 服务器操作。".to_string()]; - if let Some(server) = mcp_server_hint(tool_name) { - impacts.push(format!("服务器:{server}")); + if let Some(target) = mcp_target_hint(tool_name) { + impacts.push(format!("MCP 目标:{target}")); } impacts } @@ -1447,6 +1446,33 @@ mod tests { ); } + #[test] + fn mcp_impact_summary_preserves_full_target_for_underscored_names() { + let request = ApprovalRequest::new( + "test-id", + "mcp_my_db_execute_sql", + "Call an MCP tool", + &json!({}), + "tool:mcp_my_db_execute_sql", + ); + + assert!( + request + .impacts + .iter() + .any(|line| line == "MCP target: my_db_execute_sql") + ); + assert!(!request.impacts.iter().any(|line| line == "Server: my")); + + let zh_impacts = request.impacts_for_locale(Locale::ZhHans); + assert!( + zh_impacts + .iter() + .any(|line| line == "MCP 目标:my_db_execute_sql") + ); + assert!(!zh_impacts.iter().any(|line| line == "服务器:my")); + } + #[test] fn test_prominent_details_shell_does_not_truncate_long_command() { let command = format!("printf '{}\\n' > /tmp/x && cat /tmp/x", "x".repeat(300)); From b000096cd0865bbec4fe1eb1cbd6d9ab3fa4f05c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 21:01:49 -0700 Subject: [PATCH 082/209] docs: drop internal v0.9 execution map --- docs/V0_9_0_EXECUTION_MAP.md | 193 ----------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 docs/V0_9_0_EXECUTION_MAP.md diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md deleted file mode 100644 index 672422aed..000000000 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ /dev/null @@ -1,193 +0,0 @@ -# v0.9.0 Execution Map - -Snapshot date: 2026-06-05 - -This map tracks the v0.9.0 integration branch and keeps the open-PR harvest -separate from release publishing. It is a working document: update it whenever a -PR is harvested, superseded, deferred, or closed. - -## Live Counts - -- Actual open issues: 452 -- Open PRs: 47 -- Repo API open issue count: 499, because GitHub includes PRs in that total -- Open issues labeled `v0.9.0`: 119 -- Open `v0.9.0` milestone items: 135 open, 8 closed -- Open issues without a milestone: 108 - -## Execution Order - -1. Stabilization and PR harvest: finish #2721 and #2722 before new feature work. -2. Provider/model/auth correctness: land narrow correctness fixes that match the - current provider architecture. -3. HarmonyOS/MatePad Edge intake: keep #2634 credited while the local harvest - clears the OHOS/Nix dependency chain; full target-build success still needs a - host with the OpenHarmony native SDK loaded. -4. File decomposition Phase 1: split safe, test-covered config/provider and TUI - view surfaces before adding larger workflow UX. -5. WhaleFlow MVP: typed IR, executor skeleton, replay, and pod monitor before - teacher/student promotion loops. -6. Model Lab and HarnessProfile MVP: Hugging Face polish and provider/model - posture before automatic harness creation. -7. Release readiness: keep #2729 current and do not tag or publish without - maintainer approval. - -## Current Branch Harvest - -Branch: `codex/v0.9.0-stewardship` - -The branch contains the previous 22-commit v0.9.0 stack plus these fresh -harvest/stewardship commits: - -| PR | Disposition | Evidence / next step | -| --- | --- | --- | -| #2708 Windows sub-agent completion halves TUI render width | Harvested; original closed on 2026-06-05 after public integration branch. | Cherry-picked as `e933a11d7`; follow-up fix `72653f8ef` invalidates reused fanout-card rows. `cargo test -p codewhale-tui --locked subagent`; `cargo test -p codewhale-tui --locked terminal_size`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Broader Windows resize/IME manual smoke remains in #2721. | -| #2627 Xiaomi MiMo Token Plan mode | Harvested only the auth-header behavior as `5aa68d986`; did not merge the conflicting mode/env changes. | `cargo test -p codewhale-tui --bin codewhale-tui --locked xiaomi_mimo`; `cargo test -p codewhale-secrets --locked xiaomi_mimo`; `cargo test -p codewhale-config --locked xiaomi_mimo`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | -| #2730 canonical CodeWhale settings path | Harvested; original closed on 2026-06-05 after public integration branch. | Fixes #2664 by reading legacy DeepSeek settings fallbacks, migrating them into `~/.codewhale/settings.toml`, and ensuring `/config` displays the canonical CodeWhale path. Harvested as `9e15805f6`; follow-up assertion `fb86737a8` covers the platform-config fallback display. `cargo test -p codewhale-tui --bin codewhale-tui --locked settings_ -- --nocapture` passed. | -| Contributor credit plumbing | Added locally after the co-author audit. | Normalized unpushed harvest author/trailer emails to numeric GitHub noreply identities, added `.github/AUTHOR_MAP`, and wired `scripts/check-coauthor-trailers.py` into CI so future `Harvested from PR #N by @handle` commits require machine-readable credit. | -| #2640 workspace field on UpdateThreadRequest | Harvested; original closed on 2026-06-05 after public integration branch. | Added `workspace` to `PATCH /v1/threads/{id}`, rejects empty paths, rejects workspace changes during active turns, and evicts idle cached engines so the next turn uses the new workspace. `cargo test -p codewhale-tui --bin codewhale-tui --locked update_thread_workspace -- --nocapture` and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @gaord in commit `66c88ddfa`. | -| #2639 POST /v1/sessions endpoint | Harvested; original closed on 2026-06-05 after public integration branch. | Adds `POST /v1/sessions` so runtime clients can save a completed thread as a managed session, preserves title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 while any turn or item is queued/in-progress. `cargo test -p codewhale-tui --bin codewhale-tui --locked session_create -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked session_ -- --nocapture` passed. Credit @gaord in commit `333275162`. | -| #2733 PlanArtifact for Plan mode | Harvested; original closed on 2026-06-05 after public integration branch. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @idling11 in commit `7ac8063b6`; keep #2691 open only for remaining PlanReview product scope. | -| #2741 HarnessPosture data model | Harvested; original closed on 2026-06-05 after public integration branch. | Adds typed `HarnessPostureKind`, compaction/tool/safety enums, `HarnessPosture`, `HarnessProfile`, and `ConfigToml.harness_profiles` as the durable v0.9 config model for #2693. The harvest removes the PR's silent unknown-kind catch-all, rejects unknown posture/profile keys, derives whole-struct equality, and keeps runtime wiring as an explicit follow-up. `cargo test -p codewhale-config --locked harness_posture -- --nocapture`, `cargo test -p codewhale-config --locked harness_profile -- --nocapture`, `cargo test -p codewhale-config --locked config_toml_accepts_harness_profiles -- --nocapture`, and `cargo clippy -p codewhale-config --locked -- -D warnings` passed. Credit @idling11 in commit `586640a43`; keep #2693 open for provider/model selection, prompt/tool/runtime behavior, telemetry, and docs once wiring lands. | -| #2736 sub-agent model inheritance | Harvested; original closed on 2026-06-05 after public integration branch. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin in commit `55024a16d`. | -| #2737 configured `skills_dir` discovery | Harvested; original closed on 2026-06-05 after public integration branch. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin in commit `9719b45cd`. | -| #2738 dense tool-call transcript collapse | Harvested; original already closed, and #2740 follow-up closed as superseded on 2026-06-05. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692 in commit `c76ec4752`. | -| #2740 dense tool-run collapse follow-up | Closed as superseded by the local #2738 harvest on 2026-06-05. | The PR carries the same #2692 product direction but its reviewed head still depended on folded-thinking state before collapse could render and omitted MCP status/name handling. The local #2738 harvest already covers common-case collapse, MCP success/tool-name grouping, expansion/cell-map behavior, and `tool_collapse` modes with focused transcript-collapse tests. Credit @idling11 in changelog/execution-map notes. | -| #2734 sidebar detail popovers | Harvested; original closed on 2026-06-05 after public integration branch. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11 in commit `3cb49233e`; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | -| #2532 pending-input delivery-mode labels plus #2054 queued-edit recovery | Locally re-harvested and extended for #2054. | Pending-input preview rows label steer-pending, rejected-steer, queued-follow-up, and editing-queued-follow-up delivery modes. The accidental ↑ edit path is test-covered while loading, and `Esc` restores the original queued follow-up before cancelling the active turn. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture`, `... queued_draft ...`, and `... accidental_queue_edit_while_loading_is_labeled_and_recoverable ...` passed. Credit @cyq1017; leave #2054 open only if row-level edit/drop/send controls are still required beyond the composer recovery fix. | -| #2029 sub-agent checkpoint continuation | Locally implemented as the live-timeout recovery slice. | Sub-agents now persist `SubAgentCheckpoint` metadata through state, results, projections, and transcript handles. The runner checkpoints local messages before API calls and after model/tool cycles; per-step API timeout marks the child interrupted with `continuable=true`; `agent_eval { continue: true }` resumes only live checkpointed interrupted children. Reload preserves checkpoint metadata, but cold-restart continuation is intentionally not claimed because the child task/input channel is not rehydrated yet. `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture`, `cargo fmt --all -- --check`, `git diff --check`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Credit @qiyuanlicn for the recovery report; keep #2029 open only if cold-restart continuation or broader checkpoint UX remains required. | -| #1786 stale running task recovery | Locally implemented as the durable restart-safety slice. | `TaskManager::load_state` now marks tasks that were persisted as `running` in a prior process as failed with an explicit restart/interrupted error instead of requeueing them. Running tool-call summaries inside those stale tasks are also marked failed. `cargo test -p codewhale-tui --bin codewhale-tui --locked running_tasks_are_not_requeued_after_restart -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked task_manager -- --nocapture` passed. Credit @bevis-wong; keep #1786 open for foreground shell hang root cause and careful LIVE-state watchdog work that does not abort legitimate foreground commands. | -| #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | -| #2636 project-context context-signature cache | Harvested; original closed on 2026-06-05 after public integration branch. | Project context hot-path loads now use a bounded process-local cache keyed by canonical workspace plus content fingerprints for workspace/parent instructions, global AGENTS/WHALE fallbacks, repo constitution candidates, generated-context targets, trust markers, and trust config paths. The wrapper stores under a post-load signature so auto-generated `.codewhale/instructions.md` deletion/regeneration stays correct. `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context -- --nocapture` passed. Credit @HUQIANTAO in commit `e18f072a5`. | -| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `./scripts/release/check-ohos-deps.sh` now guards the OHOS graph against `nix` 0.28/0.29, `portable-pty`, `starlark`, `arboard`, and `keyring`; `cargo check --workspace --all-features --locked` and focused PTY/clipboard tests passed. Full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | -| #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | -| #2581 provider fallback chain design doc | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `docs/rfcs/2574-provider-fallback-chain.md` because the current PR head had no net file changes. Credit @idling11 in commit `5dc1a63cd`; keep issue #2574 open for implementation. | -| #2530 mention depth-cap hint | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack as `a97675824` and `29f57665e`. `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. | -| #2513 restore snapshot listing | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `311eb4002` with explicit `/restore list 101` cap rejection. `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. | -| #2510 custom DuckDuckGo-compatible endpoint | Harvested into a focused review branch; close original after review PR lands. | Adds `[search].base_url`, preferred `CODEWHALE_SEARCH_BASE_URL`, and legacy `DEEPSEEK_SEARCH_BASE_URL` for private DDG-compatible HTML endpoints. Network policy gates the configured host, custom endpoints do not fall back to public Bing, non-DDG provider/base_url combinations and challenge pages return explicit errors, and custom results report the configured host as `source`. Credit @cyq1017 for #2510 and @Artenx for the DDG-style endpoint clarification in #2436. | -| #2512 custom completion sound files | Harvested into a focused review branch; close original after review PR lands. | Adds `completion_sound = "file"` plus `[notifications].sound_file` so Windows users can play a per-app custom WAV through `PlaySoundW(SND_FILENAME | SND_ASYNC | SND_NODEFAULT)`. Non-Windows file mode warns and no-ops, missing paths warn once, and setting a valid path resets the missing-path warning latch so later misconfiguration is visible again. Credit @cyq1017 for #2512 and @LHqweasd for the Windows custom notification request in #2484. | -| #2576 PrefixCacheChange first-freeze event | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack through `29acb87a9d`. `cargo test -p codewhale-tui --locked prefix_cache` passed. | -| #2502 web_run RwLock split | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `60f8e7d62` with panic-safe state write-back, `Arc` cache reads, and serialized cache tests. `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. | -| #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | -| #2528 background completion wait | Harvested through review PR #2765; original closed as harvested. | Widened the focused background-shell completion wait to 30 seconds so slow Windows runners do not leave lightweight completed background commands reported as `Running` before assertions fire. `cargo test -p codewhale-tui --bin codewhale-tui --locked test_background_execution -- --nocapture`, `... test_completed_background_shell_releases_process_handles ...`, and `cargo clippy -p codewhale-tui --bin codewhale-tui --locked -- -D warnings` passed. Credit @cyq1017; refs #2525/#2526. | - -## Stabilization Gate Evidence (#2721) - -This ledger is not closed yet. It records the evidence already attached to the -v0.9 branch so the remaining Windows/manual checks are explicit. - -| Area | Current disposition | Evidence / remaining check | -| --- | --- | --- | -| Windows IME/input recovery (#1835) | Partially fixed, still release-blocking. | Current branch has Windows IME recovery and char-routing tests, but the issue remains open with Windows/WSL reports. Needs a real Windows Terminal IME smoke for focus loss, idle, mode switch, first keystroke, and Esc recovery. | -| Windows width/resize (#2708, #582 class) | Partially fixed on this branch. | #2708 is cherry-picked plus the fanout-card cache invalidation follow-up. `cargo test -p codewhale-tui --bin codewhale-tui --locked terminal_size -- --nocapture` passed. Still needs a real Windows Terminal resize smoke for #582 before #2721 closes. | -| Windows shell descendant hangs (#2498, #1812 class) | Partially fixed and already harvested. | Foreground orphan-pipe regression passed locally with `cargo test -p codewhale-tui --all-features --locked foreground_shell_does_not_block_on_orphaned_subprocess_pipe -- --nocapture`. PR #2498 closed as harvested on 2026-06-05, but #1812 remains open for broader input-poll freeze modes and Windows CI/manual confirmation. | -| Large-repo context startup (#697/#1827 class) | Partially covered. | Project-context pack ordering/budget/noise tests passed, and the auto-generated fallback now has a synthetic 1000-file startup smoke with `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture`. Still needs a real massive-repo/manual startup benchmark before closing #697 or #1827. | -| Sub-agent timeout and trust model (#1806, #719) | Fixed or covered in current branch. | `heartbeat_timeout_secs` clamp/default test passed, and `agent_open_description_explains_fresh_vs_forked_context_and_trust_model` asserts that sub-agent results are self-reports. | -| Sub-agent checkpoint/resume (#2029) | Partially covered. | Live per-step API timeout now preserves a continuable checkpoint and `agent_eval { continue: true }` resumes the parked child; `cargo test -p codewhale-tui --bin codewhale-tui --locked subagent -- --nocapture` passed with checkpoint/projection/persistence/continuation coverage. Cold-restart continuation is not implemented because persisted child tasks are not rehydrated; decide whether #2029 can close as live-timeout recovery or should remain open for restart-resume UX. | -| Live shell/session liveness (#1786) | Partially fixed, still release-blocking. | Durable task restart recovery now fails stale persisted `running` tasks instead of requeueing them, covered by `running_tasks_are_not_requeued_after_restart` and broader `task_manager` tests. Foreground shell hang root cause and LIVE-state watchdog recovery remain open; avoid aborting legitimate foreground `exec_shell` commands while adding stale-card recovery. | -| Queued/live input feedback (#2054) | Common accidental edit path covered. | Queued-message recovery/editing, pending-input delivery-mode labels, explicit editing-queued-follow-up preview state, and Esc restore semantics are covered by `queued_draft`, `pending_input_preview`, and `accidental_queue_edit_while_loading_is_labeled_and_recoverable` focused tests. Keep open only if v0.9 also requires row-level edit/drop/send controls rather than composer-level recovery. | -| Prompt/UI calmness (#1191) | Defer or narrow. | No release-blocking regression evidence yet; keep as polish unless a current user-facing prompt/UI failure is identified. | - -## PR Harvest Queue - -| PR | State | v0.9.0 disposition | -| --- | --- | --- | -| #1865 Pro Plan mode | Conflicting | Likely superseded by HarnessProfile/model-posture lane; review before closing. | -| #1893 TLS certificate verification toggle | Conflicting | Security-sensitive; review separately, not part of first v0.9 harvest. | -| #2045 NSIS installer and classroom checklist | Conflicting | Defer unless release-readiness needs Windows installer work. | -| #2048 live shell output | Mergeable but build-broken/stale | Defer; PR head fails `cargo check -p codewhale-tui --tests --locked`, matches jobs by command prefix, and misses newer `task_shell_start` / `task_shell_wait` cards. Harvest only via a task-id based rewrite. | -| #2113 independent scroll regions | Conflicting | Defer; likely overlaps current transcript/sidebar work. | -| #2239 i18n Phase 1-4b | Conflicting | Defer until localization lane. | -| #2242 typed persistent tool permission rules | Conflicting | Compare with #2721 stabilization and permissions model. | -| #2256 workspace crate consolidation | Conflicting | Do not merge during v0.9 stabilization. | -| #2269 approval details and shell previews | Conflicting / locally harvested | Narrow UI slice landed manually: approval cards now show prominent command/dir/file/path/target rows, preserve #2381 intent summaries, classify live shell companion tools as shell, split common shell chains, and show compact simple `printf > file` previews. Do not merge the broader diff-preview/pager rewrite. Close/comment after branch is public, crediting @tdccccc for #1991/#2269. | -| #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. | -| #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. | -| #2476 fork migration parent links | Closed / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Original closed on 2026-06-05, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. | -| #2479 ProviderKind/ApiProvider trait collapse | Conflicting | Defer until file decomposition Phase 1 reduces config surface. | -| #2482 WhaleFlow orchestration | Draft/conflicting | Inspect for IR ideas; do not merge wholesale. | -| #2486 WhaleFlow cost tracking | Draft/conflicting | Inspect after #2482; harvest telemetry ideas only. | -| #2491 typed ask permissions schema | Conflicting | Prior memory says safe candidate; verify current permissions work first. | -| #2498 Windows shell process trees | Closed / already harvested | Patch-equivalent work is already present on `origin/main` and this branch through the Windows JobObject cleanup commits. Original closed on 2026-06-05, crediting @aboimpinto; leave issue #1812 open because this fixes descendant pipe-handle hangs but not every reported Windows input-poll freeze mode. | -| #2501 in-process LLM response cache | Conflicting | Defer; cache key risks noted in prior review. | -| #2502 web_run RwLock split | Closed / harvested | Manually harvested with panic-safety and shared cached-page reads; original closed on 2026-06-05. | -| #2505 subagent cap accounting | Draft/conflicting | Compare with current subagent cap tests before harvest. | -| #2506 provider path suffix overrides | Draft/conflicting / superseded | The current branch already contains provider-table `path_suffix` support from #2558 with the safer constrained behavior: only `chat/completions` uses the override, while `models` and DeepSeek `beta/*` keep their built-in routing. `cargo test -p codewhale-tui --bin codewhale-tui --locked api_url_with_suffix -- --nocapture` passed. Credit @cyq1017 for the earlier design/review trail; comment/close after branch is public, keeping #1874 tied to the shipped #2558 implementation/docs. | -| #2507 stream chunk timeout config | Draft/conflicting | Defer unless stabilization needs it. | -| #2508 configurable path suffix | Conflicting / superseded | #2089 is already closed. The current implementation covers #1874's third-party gateway need without the broader env/CLI surface from #2508. Docs now show `[providers.openai].path_suffix = "/chat/completions"` and state that model/beta paths are not rewritten. Credit @hongqitai for the follow-up PR and @shuxiangxuebiancheng for the original #1874 report; close/comment after branch is public. | -| #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. | -| #2510 custom DuckDuckGo endpoint | Draft/mergeable / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @Artenx. | -| #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. | -| #2512 custom completion sounds | Draft/conflicting / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @LHqweasd. | -| #2513 restore snapshot listing | Closed / harvested | Manually harvested as `311eb4002` with cap-rejection polish; original closed on 2026-06-05, leave #2494 open. | -| #2517 turn_meta tail relocation | Mergeable | Manually harvested on the v0.9 branch; close/comment after branch is public. | -| #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. | -| #2522 hard compaction preserving system segment | Mergeable | Defer. Review found a dormant hard path that would duplicate/cache summaries into the mutable system prompt if wired through current engine flow, and a simple tail split that can break tool-call pair and pinning invariants. | -| #2526 shell tool availability docs | Draft/conflicting | Likely superseded by tool-surface docs; verify before closing. | -| #2528 background completion wait | Closed / harvested | Harvested through #2765 with a 30-second focused wait for background-shell completion tests. Original closed as harvested, crediting @cyq1017; refs #2525/#2526. | -| #2529 workspace shell opt-in | Draft/conflicting | Review with permissions/sandbox stabilization. | -| #2530 mention depth cap hint | Closed / already present | Already present locally as `a97675824` and `29f57665e`; original closed on 2026-06-05. | -| #2576 PrefixCacheChange events | Closed / already present | Already present locally through `29acb87a9d`; original closed on 2026-06-05. | -| #2578 turn_end observer hook | Conflicting / locally harvested | Narrow Rust/docs slice landed in the hook lifecycle lane: `turn_end` now uses the existing structured observer path, fires after post-turn state updates and before queued follow-up dispatch, and includes status, usage, totals, duration, tool count, and queued-message count. Close/comment after branch is public, crediting @AresNing and #1364 reporter @esinecan. | -| #2579 AppendLog session messages | Conflicting | Defer; large architectural change. | -| #2581 provider fallback chain design doc | Closed / harvested | Manually harvested into `docs/rfcs/2574-provider-fallback-chain.md`; original closed on 2026-06-05, keep #2574 open for implementation. | -| #2623 plan prompt modal scroll support | Mergeable | Already harvested into the 22-commit stack. Comment/close original after integration branch is public. | -| #2627 Xiaomi MiMo Token Plan mode | Conflicting | Partially harvested; leave original open or comment with remaining mode/env scope once branch is public. | -| #2631 estimated_input_tokens cache | Mergeable | Already harvested into the 22-commit stack. | -| #2632 tool-catalog JSON cache | Mergeable | Already harvested into the 22-commit stack. | -| #2633 capacity reverse scans | Mergeable | Already harvested into the 22-commit stack. | -| #2634 HarmonyOS port | Draft / locally harvested | Harvested with credit and extra Nix-chain fixes. Keep the original PR open for now; comment after the integration branch is public and request a real OHOS SDK build confirmation from the contributor before closing. | -| #2635 output rows cache | Mergeable | Already harvested into the 22-commit stack. | -| #2636 project-context cache | Closed / harvested | Harvested after widened invalidation fixes; original closed on 2026-06-05, crediting @HUQIANTAO. | -| #2639 POST /v1/sessions endpoint | Closed / harvested | Harvested with a 409 guard for queued/in-progress turns/items, 404 missing-thread mapping, saved-session metadata preservation, and focused session endpoint tests. Original closed on 2026-06-05, crediting @gaord. | -| #2640 workspace field on UpdateThreadRequest | Closed / harvested | Harvested locally with extra tests and engine-cache invalidation. Original closed on 2026-06-05, crediting @gaord. | -| #2646 release publish hardening | Mergeable | Already harvested into the 22-commit stack. | -| #2687 append-only mode/approval prompt | Draft/mergeable | Defer. Review found compile failures and Agent-mode prompt leakage into Plan sessions via hard-coded prompt refresh. | -| #2708 Windows width fix | Closed / harvested | Cherry-picked and patched locally; original closed on 2026-06-05. Broader Windows resize smoke remains in #2721. | -| #2730 canonical CodeWhale settings path | Closed / harvested | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Original closed on 2026-06-05, crediting @xyuai and issue #2664. | -| #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | -| #2733 PlanArtifact UI | Closed / harvested | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Original closed on 2026-06-05, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | -| #2734 sidebar detail popovers | Closed / harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Original closed on 2026-06-05, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | -| #2736 sub-agent model inheritance | Closed / harvested | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Original closed on 2026-06-05, crediting @h3c-hexin. | -| #2737 configured `skills_dir` discovery | Closed / harvested | Locally harvested with extra configured-before-global precedence tests. Original closed on 2026-06-05, crediting @h3c-hexin. | -| #2738 dense tool-call transcript collapse | Closed / harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Original was already closed; #2740 follow-up is now closed as superseded. | -| #2740 dense tool-run collapse follow-up | Closed / superseded locally | Same #2692 lane as #2738. Reviewed PR head still had the common-case collapse and MCP grouping/name issues; local #2738 harvest already fixed those and added the focused tests. Closed on 2026-06-05, crediting @idling11. | -| #2742 Ollama default model in completions | Mergeable / deferred | Real picker inconsistency (Ollama completion list returned hosted-only `OFFICIAL_DEEPSEEK_MODELS`), but the PR's fix pins the stale `deepseek-coder:1.3b` into another surface. Deferred per maintainer direction: do not entrench the V1-era local default; the live Ollama `/models` path (`client.rs`) already lists actually-installed models. Better fix is dynamic/current-model discovery. Keep #2742 open with a respectful comment; treat "should the Ollama default move off `deepseek-coder:1.3b`" as a separate maintainer decision. Credit @reidliu41. | -| #2746 / #2747 MCP underscore server names | Harvested; originals closed on 2026-06-05 after public integration branch. | Two competing fixes for #2744. Harvested #2747 (@cyq1017) as `parse_prefixed_name` longest-registered-server match with first-underscore fallback, because it adds the overlapping `my`/`my_db` tie-break test; #2746 (@puneetdixit200) is the equivalent narrower fix and is superseded. `cargo test -p codewhale-tui --bin codewhale-tui --locked mcp_pool_call_tool` (3 pass). Both contributors are credited in commit `9e29c221b`; both original PRs were closed with evidence comments. Fixes #2744. | -| #2750 Xiaomi MiMo pricing | Harvested; original closed on 2026-06-05 after public integration branch. | Harvested: `mimo-v2.5-pro`/`xiaomi/mimo-v2.5-pro` reuse DeepSeek V4-Pro rates, `mimo-v2.5`/`xiaomi/mimo-v2.5` reuse V4-Flash rates, via extracted `deepseek_v4_pro_pricing()`/`deepseek_v4_flash_pricing()` helpers. DeepSeek pricing behavior unchanged. `cargo test -p codewhale-tui --bin codewhale-tui --locked pricing` (16 pass). Credit @cyq1017 in commit `9d1396060`. Fixes #2731. | -| #2751 merge workspace MCP config | Draft/mergeable / forwarded | Implements project-level MCP config merging (#2749) with path-normalization tests, but is a 365+/112- refactor across mcp.rs/main.rs/engine.rs/runtime_api.rs. Help-forward to maintainer review for MCP pool init ordering in runtime_api/doctor flows before harvest. Credit @cyq1017. | -| #2755 roll back provider after auth failure | Draft / forwarded | Snapshot+rollback of provider/model on auth failure (#2754). Design is sound and tested, but author opened it as draft noting they could not reproduce the live Moonshot auth failure end-to-end. Help-forward: needs maintainer validation against a real provider auth failure (engine respawn + model restore). Credit @cyq1017. | -| #2756 Xiaomi MiMo Token Plan region docs | Harvested; original closed on 2026-06-05 after public integration branch. | Docs-only; verified accurate against branch `resolve_xiaomi_mimo_base_url` (tp- keys default to `token-plan-sgp`, pay-as-you-go to `api.xiaomimimo.com`, CN requires explicit `base_url`). Conflict on the CONFIGURATION.md provider bullet resolved by keeping the branch `path_suffix` bullet and adopting the PR's accurate base_url wording. Credit @xyuai in commit `960bdc91c`. Fixes #2735. | -| #2757 hydrated deferred-tool render | Harvested; original closed on 2026-06-05 after public integration branch. | Harvested in full (6 files): deferred-tool first-use schema hydration now renders as "tool loaded — retry required" via `ToolStatus::Hydrated` instead of "run done". Local correction: hydrated rows rank with active work (rank 1) not completed successes; kept the contributor's hydration detection (sole emitter always sets `executed=false`, consistent with the engine's own check, so the missing-field default was not changed). `cargo test -p codewhale-tui --bin codewhale-tui --locked hydrat` (6 pass), clippy clean. Credit @mvanhorn in commit `74b326852`. Fixes #2648. | -| #2760 sessions footer resume command | Harvested through review PR #2761, merged into the integration branch on 2026-06-05; original #2760 closed as harvested. | Corrects the `codewhale sessions` footer from `codewhale --resume ` to `codewhale resume `, matching the actual CLI subcommand and fixing the repro from #2758. Added `sessions_footer_points_to_resume_subcommand`; review PR checks (`gate`, GitGuardian) passed and local focused test/clippy/credit audits passed. Credit @sximelon as both reporter and PR author in commit `47577d59e`. Fixes #2758. | -| Local verification sweep stabilizer | Added after the full workspace verification sweep found test-only no-provider TLS panics and prompt byte instability. | Shared TUI Rustls provider helpers now wrap `reqwest` client construction across engine, runtime API, tool, MCP, config, and skill paths; the skill-installer integration include keeps its own local helper. Prompt byte-stability tests pin home and skills env under the shared test-env lock. Evidence: `cargo fmt --all -- --check`, `git diff --check`, `./scripts/release/check-versions.sh`, `cargo clippy --workspace --all-features --locked -- -D warnings`, focused skill/finance/goal/MCP reruns, and `cargo test --workspace --all-features --locked` all passed locally. | - -## Issue Reduction Strategy - -Issue count should drop through evidence-backed consolidation, not bulk closing. - -- Close fixed issues only after the v0.9 integration branch is pushed or merged - and the relevant tests/checks are named in the closure comment. -- Close obsolete release-harvest PRs/issues after verifying no unique commits or - linked reports remain. -- Supersede older OPENCODE, memory, web, VS Code, and cache-maximalism tickets - into the current v0.9 lanes when their acceptance criteria are now covered by - #2667, #2720-#2729, or a narrower current issue. -- Remove or defer `v0.9.0` scope from valid but non-release-critical roadmap - issues instead of closing them. -- Always credit PR authors, issue reporters, and useful reviewers when a - contributor branch is harvested. - -## Immediate Next Actions - -1. Prepare public comments for #2627, #2634, #2687, and already-harvested - performance PRs. -2. Help-forward #2751 (workspace MCP config merge) and #2755 (provider auth - rollback): maintainer review of MCP init ordering and a live provider - auth-failure smoke before harvest. -3. Decide the Ollama default-model question raised on #2742: keep the V1-era - `deepseek-coder:1.3b` local default, or move it to a current small model the - completion picker and `default_model()` both reflect. Do not surface the old - model in additional pick-lists in the meantime. -4. Start file decomposition Phase 1 only after the PR harvest table has no - unknown high-priority provider/prompt/cache branches. From cba5537b8459f1a133f69e343de6a035428263b6 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 21:03:41 -0700 Subject: [PATCH 083/209] fix(config): keep path suffix out of project overrides --- crates/config/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 96a6b95dd..0d42a0f88 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1659,9 +1659,6 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi if source.model.is_some() { target.model = source.model.clone(); } - if source.path_suffix.is_some() { - target.path_suffix = source.path_suffix.clone(); - } } #[must_use] @@ -3767,6 +3764,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" ..ConfigToml::default() }; base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); + base.providers.openrouter.path_suffix = Some("/chat/completions".to_string()); let mut project = ConfigToml { provider: ProviderKind::Openrouter, @@ -3779,6 +3777,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" }; project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); + project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string()); project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); project.providers.moonshot.model = Some("kimi-k2.6".to_string()); @@ -3795,6 +3794,10 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some("user-openrouter-key") ); assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!( + base.providers.openrouter.path_suffix.as_deref(), + Some("/chat/completions") + ); assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); assert_eq!( base.providers.openrouter.model.as_deref(), From e5fe46db4f0500cbea5b76502ac1f88db34d76db Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 21:22:15 -0700 Subject: [PATCH 084/209] feat(tui): expose stream chunk timeout config Harvested from PR #2507 by @cyq1017. Reported by @mserrano11 in #2365. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 2 +- config.example.toml | 1 + crates/tui/CHANGELOG.md | 4 + crates/tui/src/client.rs | 19 +++ crates/tui/src/client/chat.rs | 19 +-- crates/tui/src/commands/config.rs | 206 +++++++++++++++++++++++- crates/tui/src/config.rs | 104 ++++++++++++ crates/tui/src/core/engine.rs | 18 ++- crates/tui/src/core/engine/streaming.rs | 37 ----- crates/tui/src/core/engine/turn_loop.rs | 26 ++- crates/tui/src/core/ops.rs | 3 + crates/tui/src/main.rs | 5 + crates/tui/src/runtime_threads.rs | 3 + crates/tui/src/tui/app.rs | 4 + crates/tui/src/tui/ui.rs | 11 ++ crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/views/mod.rs | 11 ++ docs/CONFIGURATION.md | 1 + 19 files changed, 420 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043ed22ce..28b1f67f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `completion_sound = "file"` with `[notifications].sound_file` so Windows users can play a custom WAV file for turn-completion sounds without changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). ### Changed diff --git a/README.md b/README.md index 873765269..508113f59 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Key environment variables: | `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | -| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | +| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Legacy stream idle timeout env override, default `300`, clamped to `1..=3600`; `[tui].stream_chunk_timeout_secs` takes precedence when configured | | `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | diff --git a/config.example.toml b/config.example.toml index a2129c05e..1f56333b0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -472,6 +472,7 @@ max_subagents = 10 # optional (1-20) alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) +stream_chunk_timeout_secs = 300 # optional SSE idle timeout per chunk (0 = default, 1-3600) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender # Ordered footer chips shown in the TUI status line. Omit the key to use the # built-in default; set [] to hide all configurable chips. You can also edit diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 043ed22ce..28b1f67f4 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `completion_sound = "file"` with `[notifications].sound_file` so Windows users can play a custom WAV file for turn-completion sounds without changing the global Windows sound scheme (#2484, #2512). +- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs` + so slow local or OpenAI-compatible model servers can extend the SSE idle + timeout without mutating process environment. The legacy + `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). ### Changed diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 81745b8c3..74c4713d4 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -158,6 +158,7 @@ pub struct DeepSeekClient { connection_health: Arc>, rate_limiter: Arc>, path_suffix: Option, + pub(super) stream_idle_timeout: Duration, } const CONNECTION_FAILURE_THRESHOLD: u32 = 2; @@ -325,6 +326,7 @@ impl Clone for DeepSeekClient { connection_health: self.connection_health.clone(), rate_limiter: self.rate_limiter.clone(), path_suffix: self.path_suffix.clone(), + stream_idle_timeout: self.stream_idle_timeout, } } } @@ -581,6 +583,7 @@ impl DeepSeekClient { validate_base_url_security(&base_url)?; let retry = config.retry_policy(); let default_model = config.default_model(); + let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs()); let http_headers = config.http_headers(); let path_suffix = config .provider_config_for(api_provider) @@ -615,6 +618,7 @@ impl DeepSeekClient { connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), path_suffix, + stream_idle_timeout, }) } @@ -1683,6 +1687,21 @@ mod tests { assert!(headers.get("x-blank").is_none()); } + #[test] + fn client_stream_idle_timeout_uses_tui_config() { + let client = DeepSeekClient::new(&Config { + api_key: Some("sk-test".to_string()), + tui: Some(crate::config::TuiConfig { + stream_chunk_timeout_secs: Some(777), + ..crate::config::TuiConfig::default() + }), + ..Config::default() + }) + .expect("client"); + + assert_eq!(client.stream_idle_timeout, Duration::from_secs(777)); + } + #[test] fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() { let headers = DeepSeekClient::default_headers_for_provider( diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index a3ecf4b45..6acc77e68 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -16,11 +16,6 @@ use tokio::time::timeout as tokio_timeout; use crate::config::wire_model_for_provider; -/// Default idle timeout for SSE stream reads (300 seconds = 5 minutes). -/// After this period with no data, the stream is considered stalled and -/// yields a recoverable error so the caller can retry. -const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300); - /// Default timeout for the initial streaming response headers. /// /// `doctor` uses a bounded non-streaming request, but normal TUI turns first @@ -48,17 +43,6 @@ fn stream_open_timeout_from_env(value: Option<&str>) -> Duration { Duration::from_secs(secs) } -/// Reads the `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var, falling back to -/// the default 300s. The parsed value is clamped to [1, 3600] seconds. -fn stream_idle_timeout() -> Duration { - let secs = std::env::var("DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_IDLE_TIMEOUT.as_secs()) - .clamp(1, 3600); - Duration::from_secs(secs) -} - use crate::config::ApiProvider; use crate::llm_client::StreamEventBox; use crate::llm_client::sanitize_http_error_body; @@ -283,6 +267,7 @@ impl DeepSeekClient { // gzip-compressor failure when investigating #103. let response_headers = format_stream_headers(response.headers()); let byte_stream = response.bytes_stream(); + let stream_idle_timeout = self.stream_idle_timeout; let stream = async_stream::stream! { use futures_util::StreamExt; @@ -315,7 +300,7 @@ impl DeepSeekClient { let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model); let mut byte_stream = std::pin::pin!(byte_stream); - let idle = stream_idle_timeout(); + let idle = stream_idle_timeout; // Telemetry for #103 stream-decode diagnostics: bytes received // since the start of this stream and last successful event time. diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c7718fb65..9f860ecdf 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -6,7 +6,8 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; use crate::config::{ - ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL, + ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir, expand_path, normalize_model_name_for_provider, }; @@ -152,6 +153,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; Some(config.deepseek_base_url()) } + "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => { Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) @@ -417,6 +419,45 @@ fn persist_root_bool_key( Ok(path) } +fn persist_tui_integer_key( + config_path: Option<&Path>, + key: &str, + value: u64, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let value = i64::try_from(value).context("integer value is too large for TOML")?; + tui_table.insert(key.to_string(), toml::Value::Integer(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + fn persist_provider_base_url_key( config_path: Option<&Path>, provider: ApiProvider, @@ -525,6 +566,14 @@ fn parse_config_bool(value: &str) -> Result { } } +fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { + if raw == 0 { + format!("0 (default {resolved})") + } else { + resolved.to_string() + } +} + /// Resolve the path to `~/.codewhale/config.toml` (or /// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we /// never write to a different file than the one we read. @@ -729,6 +778,55 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", ); } + "stream_chunk_timeout_secs" => { + let raw = match value.trim().parse::() { + Ok(value) => value, + Err(_) => { + return CommandResult::error( + "stream_chunk_timeout_secs must be a whole number", + ); + } + }; + if raw != 0 + && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw) + { + return CommandResult::error(format!( + "stream_chunk_timeout_secs must be 0 or {}..={}", + MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS + )); + } + let resolved = if raw == 0 { + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + } else { + raw + }; + app.stream_chunk_timeout_secs = resolved; + let value_label = stream_chunk_timeout_value_label(raw, resolved); + if persist { + match persist_tui_integer_key( + app.config_path.as_deref(), + "stream_chunk_timeout_secs", + raw, + ) { + Ok(path) => { + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)", + path.display() + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::with_message_and_action( + format!( + "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } _ => {} } @@ -2371,6 +2469,112 @@ mod tests { assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); } + #[test] + fn config_command_stream_chunk_timeout_session_query_uses_live_value() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90")); + assert!(!result.is_error); + assert_eq!(app.stream_chunk_timeout_secs, 90); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(90)) + )); + + let query = config_command(&mut app, Some("stream_chunk_timeout_secs")); + assert_eq!( + query.message.as_deref(), + Some("stream_chunk_timeout_secs = 90") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_save_persists_tui_key() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-stream-timeout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join("custom-config.toml"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)", + config_path.display() + ) + ); + assert!(saved.contains("[tui]")); + assert!(saved.contains("stream_chunk_timeout_secs = 120")); + assert_eq!(app.stream_chunk_timeout_secs, 120); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(120)) + )); + } + + #[test] + fn config_command_stream_chunk_timeout_rejects_invalid_input() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); + assert!(text.is_error); + assert!( + text.message + .unwrap() + .contains("stream_chunk_timeout_secs must be a whole number") + ); + + let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); + assert!(high.is_error); + assert!( + high.message + .unwrap() + .contains("stream_chunk_timeout_secs must be 0 or 1..=3600") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_zero_reports_effective_default() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0")); + + assert!(!result.is_error); + assert_eq!( + app.stream_chunk_timeout_secs, + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + assert_eq!( + result.message.as_deref(), + Some( + "stream_chunk_timeout_secs = 0 (default 300) (session only; affects subsequent turns in this session)" + ) + ); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout( + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + )) + )); + } + #[test] fn config_command_provider_url_token_plan_persists_provider_base_url() { let temp_root = env::temp_dir().join(format!( diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 20f752a10..02c36a2f5 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -39,6 +39,13 @@ pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300; pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30; /// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour). pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600; +/// Default per-SSE-chunk idle timeout, in seconds. +pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; +/// Minimum accepted stream chunk timeout. +pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; +/// Maximum accepted stream chunk timeout. +pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; +pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; @@ -835,6 +842,9 @@ pub struct TuiConfig { /// Timeout for startup terminal mode/probe calls in milliseconds. /// Defaults to 500ms when omitted. pub terminal_probe_timeout_ms: Option, + /// Per-SSE-chunk idle timeout in seconds. Defaults to 300 seconds when + /// omitted. `0` maps to the default; values clamp to `1..=3600`. + pub stream_chunk_timeout_secs: Option, /// Ordered list of footer items the user wants visible. `None` (the field /// missing from `config.toml`) means "use the built-in default order"; an /// empty `Some(vec![])` means "show nothing in the footer". @@ -2784,6 +2794,30 @@ impl Config { configured.max(min_for_api) } + /// Resolved per-SSE-chunk idle timeout in seconds. + /// + /// Reads `[tui].stream_chunk_timeout_secs`, falling back to the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var when the config key is + /// omitted. `None` or `0` resolve to the default 300 seconds; explicit + /// values are clamped to `1..=3600`. + #[must_use] + pub fn stream_chunk_timeout_secs(&self) -> u64 { + let raw = self + .tui + .as_ref() + .and_then(|cfg| cfg.stream_chunk_timeout_secs) + .or_else(|| { + std::env::var(STREAM_CHUNK_TIMEOUT_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS); + if raw == 0 { + return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS; + } + raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) + } + /// Raw sub-agent model override map. Values are validated at spawn time /// so an invalid role/type model fails before any partial agent spawn. #[must_use] @@ -6417,6 +6451,76 @@ mod tests { ); } + #[test] + fn tui_stream_chunk_timeout_defaults_env_and_clamps() { + let _lock = lock_test_env(); + let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV); + unsafe { + env::remove_var(STREAM_CHUNK_TIMEOUT_ENV); + } + + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let zero = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(0), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + zero.stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + let explicit_min = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + explicit_min.stream_chunk_timeout_secs(), + MIN_STREAM_CHUNK_TIMEOUT_SECS + ); + + let high = Config { + tui: Some(TuiConfig { + stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1), + ..TuiConfig::default() + }), + ..Config::default() + }; + assert_eq!( + high.stream_chunk_timeout_secs(), + MAX_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123"); + } + assert_eq!(Config::default().stream_chunk_timeout_secs(), 123); + + unsafe { + env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "0"); + } + assert_eq!( + Config::default().stream_chunk_timeout_secs(), + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + + unsafe { + match previous { + Some(value) => env::set_var(STREAM_CHUNK_TIMEOUT_ENV, value), + None => env::remove_var(STREAM_CHUNK_TIMEOUT_ENV), + } + } + } + #[test] fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> { // `save_api_key` writes to the shared user config file. This diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 5f58d925e..ef92332af 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -351,6 +351,10 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// Per-SSE-chunk idle timeout for streamed model responses. + /// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy + /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`. + pub stream_chunk_timeout: Duration, /// No-progress heartbeat timeout for live sub-agents. Used by the manager /// and parent wait loop to auto-cancel stuck children before they exhaust /// the sub-agent slot pool indefinitely (#2614). @@ -414,6 +418,9 @@ impl Default for EngineConfig { subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + stream_chunk_timeout: Duration::from_secs( + crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, + ), subagent_heartbeat_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, ), @@ -1271,6 +1278,15 @@ impl Engine { ))) .await; } + Op::SetStreamChunkTimeout { timeout_secs } => { + self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs); + let _ = self + .tx_event + .send(Event::status(format!( + "Stream chunk timeout set to {timeout_secs}s" + ))) + .await; + } Op::SyncSession { session_id, messages, @@ -2745,7 +2761,7 @@ use self::streaming::{ ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS, ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta, - should_transparently_retry_stream, stream_chunk_timeout_secs, + should_transparently_retry_stream, }; use self::tool_catalog::{ CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME, diff --git a/crates/tui/src/core/engine/streaming.rs b/crates/tui/src/core/engine/streaming.rs index 0da4d5aea..35adca04f 100644 --- a/crates/tui/src/core/engine/streaming.rs +++ b/crates/tui/src/core/engine/streaming.rs @@ -22,26 +22,6 @@ pub(super) struct ToolUseState { pub(super) input_buffer: String, } -/// Default maximum time to wait for a single stream chunk before assuming a stall. -/// **This is the idle timeout** — it resets on every SSE chunk, so long -/// thinking turns that ARE producing reasoning_content stay alive. Only a -/// genuine `chunk_timeout` window of silence kills the stream. -const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300; -const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1; -const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600; -const STREAM_IDLE_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS"; - -/// Reads the shared stream idle-timeout override used by the SSE client. -pub(super) fn stream_chunk_timeout_secs() -> u64 { - stream_chunk_timeout_secs_from_env(std::env::var(STREAM_IDLE_TIMEOUT_ENV).ok().as_deref()) -} - -fn stream_chunk_timeout_secs_from_env(value: Option<&str>) -> u64 { - value - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS) - .clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS) -} /// Maximum total bytes of text/thinking content before aborting the stream. pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB /// Sanity backstop for total stream wall-clock duration. **Not** a routine @@ -150,20 +130,3 @@ pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> St output } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stream_chunk_timeout_defaults_and_clamps_env_values() { - assert_eq!(stream_chunk_timeout_secs_from_env(None), 300); - assert_eq!( - stream_chunk_timeout_secs_from_env(Some("not-a-number")), - 300 - ); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("0")), 1); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("90")), 90); - assert_eq!(stream_chunk_timeout_secs_from_env(Some("99999")), 3600); - } -} diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa0..bcb3dc3a6 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -469,8 +469,7 @@ impl Engine { // budget restarts with the fresh stream. let mut stream_start = Instant::now(); let mut stream_content_bytes: usize = 0; - let chunk_timeout_secs = stream_chunk_timeout_secs(); - let chunk_timeout = Duration::from_secs(chunk_timeout_secs); + let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config); let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS); // Process stream events @@ -2293,6 +2292,29 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u queued_completions > 0 || running_children > 0 } +fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) { + let secs = config.stream_chunk_timeout.as_secs(); + (secs, Duration::from_secs(secs)) +} + +#[cfg(test)] +mod stream_timeout_tests { + use super::*; + + #[test] + fn stream_chunk_timeout_budget_uses_engine_config() { + let config = EngineConfig { + stream_chunk_timeout: Duration::from_secs(42), + ..EngineConfig::default() + }; + + assert_eq!( + stream_chunk_timeout_budget(&config), + (42, Duration::from_secs(42)) + ); + } +} + fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool { let Some(allowed_tools) = allowed_tools else { return true; diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 4260cf0c8..0889c5b29 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -84,6 +84,9 @@ pub enum Op { /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, + /// Update the SSE idle timeout used for subsequent streamed turns. + SetStreamChunkTimeout { timeout_secs: u64 }, + /// Sync engine session state (used for resume/load) SyncSession { session_id: Option, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 7c9815b7d..d5d2dfae0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5765,6 +5765,7 @@ async fn run_exec_agent( runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()), + stream_chunk_timeout: std::time::Duration::from_secs(config.stream_chunk_timeout_secs()), subagent_heartbeat_timeout: std::time::Duration::from_secs( config.subagent_heartbeat_timeout_secs(), ), @@ -6743,6 +6744,7 @@ mod terminal_mode_tests { alternate_screen: Some("never".to_string()), mouse_capture: None, terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6836,6 +6838,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(false), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6867,6 +6870,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(true), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, @@ -6952,6 +6956,7 @@ mod terminal_mode_tests { alternate_screen: None, mouse_capture: Some(true), terminal_probe_timeout_ms: None, + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, composer_arrows_scroll: None, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 48bf3e44e..64369523d 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2065,6 +2065,9 @@ impl RuntimeThreadManager { subagent_api_timeout: std::time::Duration::from_secs( self.config.subagent_api_timeout_secs(), ), + stream_chunk_timeout: std::time::Duration::from_secs( + self.config.stream_chunk_timeout_secs(), + ), subagent_heartbeat_timeout: std::time::Duration::from_secs( self.config.subagent_heartbeat_timeout_secs(), ), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d2315448d..34b56aaf2 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1407,6 +1407,8 @@ pub struct App { pub max_input_history: usize, pub allow_shell: bool, pub max_subagents: usize, + /// Per-SSE-chunk idle timeout for streamed turns, in seconds. + pub stream_chunk_timeout_secs: u64, /// Cached sub-agent snapshots for UI views. pub subagent_cache: Vec, /// Last known per-agent progress text for running sub-agents. @@ -2136,6 +2138,7 @@ impl App { max_input_history, allow_shell, max_subagents, + stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(), subagent_cache: Vec::new(), agent_progress: HashMap::new(), subagent_card_index: HashMap::new(), @@ -5047,6 +5050,7 @@ pub enum AppAction { model: Option, }, UpdateCompaction(CompactionConfig), + UpdateStreamChunkTimeout(u64), OpenContextInspector, CompactContext, PurgeContext, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1a814bd58..0ec107e77 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -908,6 +908,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { runtime_services: app.runtime_services.clone(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()), + stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs), subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), @@ -5804,6 +5805,11 @@ async fn apply_command_result( AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction, app.mode).await; } + AppAction::UpdateStreamChunkTimeout(timeout_secs) => { + let _ = engine_handle + .send(Op::SetStreamChunkTimeout { timeout_secs }) + .await; + } AppAction::OpenConfigEditor(mode) => match mode { ConfigUiMode::Native => { if app.view_stack.top_kind() != Some(ModalKind::Config) { @@ -7407,6 +7413,11 @@ async fn handle_view_events( apply_model_and_compaction_update(engine_handle, compaction, app.mode) .await; } + AppAction::UpdateStreamChunkTimeout(timeout_secs) => { + let _ = engine_handle + .send(Op::SetStreamChunkTimeout { timeout_secs }) + .await; + } AppAction::OpenConfigView => {} _ => {} } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f45686c1c..b5ba6f09b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1937,6 +1937,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { alternate_screen: None, mouse_capture: None, terminal_probe_timeout_ms: Some(750), + stream_chunk_timeout_secs: None, status_items: None, osc8_links: None, notification_condition: None, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 042357e84..4b2287b74 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -532,6 +532,7 @@ enum ConfigSection { Provider, Model, Permissions, + Network, Display, Composer, Sidebar, @@ -545,6 +546,7 @@ impl ConfigSection { ConfigSection::Provider => "Provider", ConfigSection::Model => "Model", ConfigSection::Permissions => "Permissions", + ConfigSection::Network => "Network", ConfigSection::Display => "Display", ConfigSection::Composer => "Composer", ConfigSection::Sidebar => "Sidebar", @@ -658,6 +660,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Network, + key: "stream_chunk_timeout_secs".to_string(), + value: app.stream_chunk_timeout_secs.to_string(), + editable: true, + scope: ConfigScope::Session, + }, ConfigRow { section: ConfigSection::Display, key: "theme".to_string(), @@ -2371,6 +2380,7 @@ mod tests { ConfigSection::Provider.label(), ConfigSection::Model.label(), ConfigSection::Permissions.label(), + ConfigSection::Network.label(), ConfigSection::Display.label(), ConfigSection::Composer.label(), ConfigSection::Sidebar.label(), @@ -2395,6 +2405,7 @@ mod tests { assert!(keys.contains(&"base_url")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"allow_shell")); + assert!(keys.contains(&"stream_chunk_timeout_secs")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); assert!(keys.contains(&"background_color")); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 42cd3bd48..5b82a66b9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -966,6 +966,7 @@ If you are upgrading from older releases: - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport. - `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely. +- `tui.stream_chunk_timeout_secs` (int, optional, default `300`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs `; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted. - `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes. - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). - `features.*` (optional): feature flag overrides (see below). From 93d08a8f611a697e4f1d3fd79a029b257859b8d4 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Thu, 4 Jun 2026 21:32:25 -0700 Subject: [PATCH 085/209] feat(config): add dormant provider fallback chain Harvested from PR #2777 by @idling11. Reported by @hsdbeebou in #2574. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 4 + crates/config/src/lib.rs | 178 +++++++++++++++++++++++++++++++++++++++ crates/tui/CHANGELOG.md | 4 + 3 files changed, 186 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b1f67f4..3cc71d06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so slow local or OpenAI-compatible model servers can extend the SSE idle timeout without mutating process environment. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. ### Changed diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 0d42a0f88..46ce8e67e 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -464,6 +464,11 @@ pub struct ConfigToml { pub tools: Option, #[serde(default)] pub providers: ProvidersToml, + /// Dormant provider fallback chain (#2574). This is parsed and preserved + /// for future provider-routing work; current runtime resolution still uses + /// the selected primary provider and does not auto-switch routes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallback_providers: Vec, /// Per-domain network policy (#135). When absent, network tools fall back /// to a permissive default that mirrors pre-v0.7.0 behavior. #[serde(default)] @@ -493,6 +498,71 @@ pub struct ConfigToml { pub extras: BTreeMap, } +/// Ordered primary-plus-fallback provider list for future provider routing. +/// +/// The helper is intentionally dormant: constructing or parsing a chain does +/// not change [`ConfigToml::resolve_runtime_options`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderChain { + providers: Vec, + position: usize, +} + +impl ProviderChain { + #[must_use] + pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self { + let mut providers = vec![active]; + for fallback in fallbacks { + if *fallback != active && !providers.contains(fallback) { + providers.push(*fallback); + } + } + Self { + providers, + position: 0, + } + } + + #[must_use] + pub fn providers(&self) -> &[ProviderKind] { + &self.providers + } + + #[must_use] + pub fn position(&self) -> usize { + self.position + } + + #[must_use] + pub fn current(&self) -> ProviderKind { + self.providers[self.position] + } + + #[must_use] + pub fn has_next(&self) -> bool { + self.position + 1 < self.providers.len() + } + + pub fn advance(&mut self) -> Option { + if !self.has_next() { + return None; + } + self.position += 1; + Some(self.current()) + } + + #[must_use] + pub fn is_fallback_active(&self) -> bool { + self.position > 0 + } + + /// Count the current provider plus untried chain entries. + #[must_use] + pub fn remaining(&self) -> usize { + self.providers.len() - self.position + } +} + /// On-disk schema for the `[hook_sinks]` table. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSinksToml { @@ -5227,6 +5297,114 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); } + #[test] + fn provider_chain_initial_current_is_active() { + let chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert_eq!(chain.current(), ProviderKind::NvidiaNim); + assert_eq!(chain.position(), 0); + assert_eq!( + chain.providers(), + &[ + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ProviderKind::Openrouter, + ] + ); + assert!(!chain.is_fallback_active()); + } + + #[test] + fn provider_chain_advance_switches_to_fallback() { + let mut chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert!(chain.has_next()); + assert_eq!(chain.advance(), Some(ProviderKind::Deepseek)); + assert_eq!(chain.current(), ProviderKind::Deepseek); + assert!(chain.is_fallback_active()); + } + + #[test] + fn provider_chain_exhausts_returns_none() { + let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]); + + assert_eq!(chain.advance(), Some(ProviderKind::Openrouter)); + assert!(!chain.has_next()); + assert_eq!(chain.advance(), None); + } + + #[test] + fn provider_chain_skips_duplicates() { + let chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ + ProviderKind::Deepseek, + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ], + ); + + assert_eq!( + chain.providers(), + &[ProviderKind::Deepseek, ProviderKind::NvidiaNim] + ); + } + + #[test] + fn provider_chain_remaining_counts_current_and_untried_entries() { + let mut chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ProviderKind::NvidiaNim, ProviderKind::Openrouter], + ); + + assert_eq!(chain.remaining(), 3); + assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim)); + assert_eq!(chain.remaining(), 2); + } + + #[test] + fn config_toml_parses_fallback_providers() { + let config: ConfigToml = toml::from_str( + r#" +provider = "nvidia-nim" +fallback_providers = ["deepseek", "openrouter"] +"#, + ) + .expect("fallback providers config"); + + assert_eq!(config.provider, ProviderKind::NvidiaNim); + assert_eq!( + config.fallback_providers, + [ProviderKind::Deepseek, ProviderKind::Openrouter] + ); + } + + #[test] + fn empty_fallback_providers_do_not_serialize() { + let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes"); + + assert!(!serialized.contains("fallback_providers")); + } + + #[test] + fn fallback_providers_do_not_change_runtime_resolution() { + let config = ConfigToml { + provider: ProviderKind::NvidiaNim, + fallback_providers: vec![ProviderKind::Deepseek], + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + } + #[test] fn harness_posture_default_is_standard() { let posture = HarnessPosture::default(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 28b1f67f4..3cc71d06a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so slow local or OpenAI-compatible model servers can extend the SSE idle timeout without mutating process environment. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. ### Changed From 02c3579be1192c40a6bc2619cf8b56b47c14c8e4 Mon Sep 17 00:00:00 2001 From: Punkcan Yang Date: Fri, 5 Jun 2026 13:45:48 +0800 Subject: [PATCH 086/209] feat(tui): ghost-text prompt suggestion after each turn After each completed turn, a lightweight API call generates a short follow-up question rendered as dimmed ghost text in the composer. Tab accepts the suggestion; typing dismisses it. - prompt_suggestion.rs: async suggestion generation via API - app.rs: prompt_suggestion display field + suggestion_cell for cross-thread delivery (Arc>> pattern) - widgets/mod.rs: ghost text rendered with TEXT_HINT when input is empty and suggestion exists - ui.rs: suggestion generation on TurnComplete, cleanup on TurnStarted, Tab acceptance in event loop Co-Authored-By: Claude Opus 4.7 --- crates/tui/src/tui/app.rs | 7 ++ crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/prompt_suggestion.rs | 117 ++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 48 ++++++++++ crates/tui/src/tui/widgets/mod.rs | 39 +++++--- 5 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 crates/tui/src/tui/prompt_suggestion.rs diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3eb494f16..5d93886f8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1182,6 +1182,9 @@ pub struct App { pub next_history_revision: u64, pub api_messages: Vec, pub is_loading: bool, + /// Ghost-text follow-up suggestion shown in the composer when empty. + /// Generated asynchronously after each completed turn; cleared on new input. + pub prompt_suggestion: Option, /// Degraded connectivity mode; new user inputs are queued for later retry. pub offline_mode: bool, /// Whether an `EngineEvent::Error` has already been posted for the @@ -1521,6 +1524,8 @@ pub struct App { /// DeepSeek account balance, refreshed once per turn completion. /// Shared cell updated by background fetch tasks; read lock in the UI thread. pub balance_cell: std::sync::Arc>>, + /// Shared cell for async prompt suggestion delivery from background task. + pub prompt_suggestion_cell: std::sync::Arc>>, /// Tracks whether the initial balance fetch has been attempted for this session. pub balance_initiated: bool, /// Timestamp of the last balance fetch, used to debounce rapid requests. @@ -1991,6 +1996,7 @@ impl App { next_history_revision: 1, api_messages: Vec::new(), is_loading: false, + prompt_suggestion: None, offline_mode: false, turn_error_posted: false, status_message: None, @@ -2145,6 +2151,7 @@ impl App { turn_last_activity_at: None, cumulative_turn_duration: std::time::Duration::ZERO, balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), + prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), balance_initiated: false, last_balance_fetch: None, runtime_turn_id: None, diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index af2d8996d..a987a03d4 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -51,6 +51,7 @@ pub mod paste; pub mod paste_burst; pub mod persistence_actor; pub mod plan_prompt; +pub mod prompt_suggestion; pub mod provider_picker; pub mod scrolling; pub mod selection; diff --git a/crates/tui/src/tui/prompt_suggestion.rs b/crates/tui/src/tui/prompt_suggestion.rs new file mode 100644 index 000000000..484d53014 --- /dev/null +++ b/crates/tui/src/tui/prompt_suggestion.rs @@ -0,0 +1,117 @@ +//! Ghost-text follow-up prompt suggestion. +//! +//! After each completed turn, a lightweight API call generates ONE short +//! follow-up question the user might want to ask next. The suggestion is +//! rendered as dimmed ghost text in the composer when the input is empty. + +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; +use serde_json::Value; +use tracing::debug; + +/// Generate a follow-up prompt suggestion based on recent messages. +/// +/// Sends the conversation summary to the API with a system prompt that +/// asks for a single short follow-up question. Returns `None` on failure +/// or empty result — callers treat this as best-effort. +pub async fn generate_suggestion( + api_key: &str, + base_url: &str, + model: &str, + recent_messages: &str, +) -> Option { + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": "\ + You are a helpful assistant. Based on the recent conversation context, generate \ + ONE short follow-up question (under 60 characters) the user might want to ask \ + next. Reply with ONLY the question text, nothing else — no quotes, no explanations, \ + no prefixes." + }, + { + "role": "user", + "content": format!( + "Recent conversation:\n{recent_messages}\n\n\ + Generate ONE short follow-up question the user might ask next:" + ) + } + ], + "max_tokens": 64, + "temperature": 0.3, + "stream": false + }); + + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + debug!(%url, %model, "generating prompt suggestion"); + let response = match client + .post(&url) + .header(AUTHORIZATION, format!("Bearer {api_key}")) + .header(CONTENT_TYPE, "application/json") + .timeout(std::time::Duration::from_secs(10)) + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(_) => return None, + }; + + let value: Value = match response.json().await { + Ok(v) => v, + Err(_) => return None, + }; + + let suggestion = value["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.trim().trim_matches('"').to_string()) + .filter(|s| !s.is_empty() && s.len() <= 200)?; + + debug!(text = %suggestion, "prompt suggestion generated"); + Some(suggestion) +} + +/// Extract the first text line from a single message. +fn message_summary(m: &crate::models::Message) -> Option { + let role = match m.role.as_str() { + "user" => "User", + "assistant" => "Assistant", + _ => return None, + }; + let text = m + .content + .iter() + .filter_map(|block| match block { + crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + let first_line = text.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + return None; + } + let truncated: String = first_line + .chars() + .take(120) + .chain(if first_line.chars().count() > 120 { + Some('…') + } else { + None + }) + .collect(); + Some(format!("{role}: {truncated}")) +} + +/// Build a one-line-per-message summary of recent conversation context. +/// Takes the last N messages, skipping tool-only messages. +pub fn summarize_recent_messages(messages: &[crate::models::Message], limit: usize) -> String { + let start = messages.len().saturating_sub(limit); + messages[start..] + .iter() + .filter_map(message_summary) + .collect::>() + .join("\n") +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fadf..936bd4628 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1265,6 +1265,13 @@ async fn run_event_loop( app.needs_redraw = true; } + // Poll prompt suggestion cell from background generation task. + if let Ok(mut guard) = app.prompt_suggestion_cell.lock() { + if let Some(suggestion) = guard.take() { + app.prompt_suggestion = Some(suggestion); + } + } + // First, poll for engine events (non-blocking) let mut received_engine_event = false; let mut transcript_batch_updated = false; @@ -1618,6 +1625,7 @@ async fn run_event_loop( app.is_loading = true; app.offline_mode = false; app.turn_error_posted = false; + app.prompt_suggestion = None; app.dispatch_started_at = None; current_streaming_text.clear(); app.streaming_state.reset(); @@ -1819,6 +1827,38 @@ async fn run_event_loop( } } + // Generate ghost-text follow-up suggestion asynchronously. + if status == crate::core::events::TurnOutcomeStatus::Completed + && app.api_messages.len() >= 2 + { + let suggestion_cell = app.prompt_suggestion_cell.clone(); + let api_key = config.deepseek_api_key().unwrap_or_default(); + let base_url = config.deepseek_base_url(); + let model = config.default_model(); + let messages: Vec = + app.api_messages.clone(); + if !api_key.is_empty() { + tokio::spawn(async move { + let summary = + crate::tui::prompt_suggestion::summarize_recent_messages( + &messages, 8, + ); + if let Some(suggestion) = + crate::tui::prompt_suggestion::generate_suggestion( + &api_key, + &base_url, + &model, + &summary, + ) + .await + && let Ok(mut guard) = suggestion_cell.lock() + { + *guard = Some(suggestion); + } + }); + } + } + // Generate post-turn receipt for completed turns. // Also push a persistent status toast so users always // see the outcome in the footer (not just the 8-second @@ -3591,6 +3631,14 @@ async fn run_event_loop( if app.is_loading && queue_current_draft_for_next_turn(app) { continue; } + if app.input.is_empty() + && let Some(suggestion) = app.prompt_suggestion.take() + { + app.input = suggestion; + app.cursor_position = app.input.chars().count(); + app.needs_redraw = true; + continue; + } let prior_model = app.model.clone(); let prior_mode = app.mode; app.cycle_mode(); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9bed..7339d15f9 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -659,17 +659,24 @@ impl Renderable for ComposerWidget<'_> { let mut input_lines = Vec::new(); if input_text.is_empty() { - let placeholder = if self.app.is_history_search_active() { - self.app - .tr(crate::localization::MessageId::HistorySearchPlaceholder) + if let Some(ref suggestion) = self.app.prompt_suggestion { + input_lines.push(Line::from(Span::styled( + suggestion.as_str(), + Style::default().fg(palette::TEXT_HINT), + ))); } else { - self.app - .tr(crate::localization::MessageId::ComposerPlaceholder) - }; - input_lines.push(Line::from(Span::styled( - placeholder, - Style::default().fg(palette::TEXT_MUTED).italic(), - ))); + let placeholder = if self.app.is_history_search_active() { + self.app + .tr(crate::localization::MessageId::HistorySearchPlaceholder) + } else { + self.app + .tr(crate::localization::MessageId::ComposerPlaceholder) + }; + input_lines.push(Line::from(Span::styled( + placeholder, + Style::default().fg(palette::TEXT_MUTED).italic(), + ))); + } } else if let Some((sel_start, sel_end)) = self.app.selection_range() { let line_ranges: Vec<(usize, usize)> = wrap_input_lines_for_mouse(&self.app.input, content_width) @@ -704,12 +711,16 @@ impl Renderable for ComposerWidget<'_> { // wrap the single Line at render time, so we must estimate the wrapped // row count ourselves to keep padding accurate on narrow widths. let visual_rows = if input_text.is_empty() { - let placeholder = if self.app.is_history_search_active() { + let placeholder = if let Some(ref suggestion) = self.app.prompt_suggestion { + suggestion.as_str() + } else if self.app.is_history_search_active() { self.app .tr(crate::localization::MessageId::HistorySearchPlaceholder) + .as_ref() } else { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) + .as_ref() }; placeholder_visual_lines_for(placeholder, content_width) } else { @@ -1009,12 +1020,16 @@ impl Renderable for ComposerWidget<'_> { let (visible_lines, cursor_row, cursor_col) = layout_input(input_text, input_cursor, content_width, input_rows_budget); let visual_rows = if input_text.is_empty() { - let placeholder = if self.app.is_history_search_active() { + let placeholder = if let Some(ref suggestion) = self.app.prompt_suggestion { + suggestion.as_str() + } else if self.app.is_history_search_active() { self.app .tr(crate::localization::MessageId::HistorySearchPlaceholder) + .as_ref() } else { self.app .tr(crate::localization::MessageId::ComposerPlaceholder) + .as_ref() }; placeholder_visual_lines_for(placeholder, content_width) } else { From 2e49b146559521e24ce6ab593d5758096f908aef Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 08:02:41 -0700 Subject: [PATCH 087/209] test(config): isolate provider chain runtime resolution --- crates/config/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 46ce8e67e..de8f28e3d 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -5394,6 +5394,8 @@ fallback_providers = ["deepseek", "openrouter"] #[test] fn fallback_providers_do_not_change_runtime_resolution() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); let config = ConfigToml { provider: ProviderKind::NvidiaNim, fallback_providers: vec![ProviderKind::Deepseek], From 5d9f93af4d3f98c9f49b6344ca8b86967b7968ee Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 08:06:35 -0700 Subject: [PATCH 088/209] fix(tui): expose external URL opener on unsupported targets Harvested from PR #2789 by @ci4ic4. Found while packaging CodeWhale 0.8.53 for pkgsrc on NetBSD. Co-authored-by: ci4ic4 <6495973+ci4ic4@users.noreply.github.com> --- .github/AUTHOR_MAP | 3 +++ CHANGELOG.md | 4 ++++ crates/tui/CHANGELOG.md | 4 ++++ crates/tui/src/tui/ui.rs | 1 - 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index 6b55ca302..a3a218cde 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -74,6 +74,9 @@ xrnc@outlook.com = elowen53 <88364845+elowen53@users.noreply.github.com> CrepuscularIRIS = CrepuscularIRIS <126939795+CrepuscularIRIS@users.noreply.github.com> chnjames = chnjames <44110547+chnjames@users.noreply.github.com> ChaceLyee2101 = ChaceLyee2101 <95995339+ChaceLyee2101@users.noreply.github.com> +ci4ic4 = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> +Chavdar Ivanov = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> +ci4ic4@gmail.com = ci4ic4 <6495973+ci4ic4@users.noreply.github.com> AresNing = AresNing <49557311+AresNing@users.noreply.github.com> shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc71d06a..9314e7747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Browser-opening actions now compile on non-desktop targets by delegating the + unsupported-platform error to the shared URL opener instead of hiding the TUI + wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the + NetBSD/pkgsrc packaging report and fix (#2789). - MCP tool routing now preserves server names that contain underscores. `parse_prefixed_name` matches the qualified `mcp__` name against the set of registered server names and prefers the longest match, so tools on diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 3cc71d06a..9314e7747 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -161,6 +161,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Browser-opening actions now compile on non-desktop targets by delegating the + unsupported-platform error to the shared URL opener instead of hiding the TUI + wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the + NetBSD/pkgsrc packaging report and fix (#2789). - MCP tool routing now preserves server names that contain underscores. `parse_prefixed_name` matches the qualified `mcp__` name against the set of registered server names and prefers the longest match, so tools on diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 0ec107e77..239741409 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6093,7 +6093,6 @@ async fn apply_command_result( #[cfg(test)] use std::process::{Command, Stdio}; -#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] fn open_external_url(url: &str) -> Result<()> { crate::utils::open_url(url) } From 0b0d815fab73ebf33d30d3a57b4cd035513d5d72 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:09:58 -0700 Subject: [PATCH 089/209] fix(tui): enrich auth errors with request context Harvested from PR #2792 by @mvanhorn. Reported by @Hmbown in #2665. --- CHANGELOG.md | 4 + crates/tui/CHANGELOG.md | 4 + crates/tui/src/error_taxonomy.rs | 33 ++- crates/tui/src/llm_client/mod.rs | 370 ++++++++++++++++++++++++++++++- 4 files changed, 402 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9314e7747..98deb5d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Authentication failures now include redacted request context such as provider, + base URL authority, model, key source, key type, and key fingerprint, making + stale provider, endpoint, or API-key state diagnosable without exposing the + secret (#2665, #2792). Thanks @mvanhorn for the implementation. - Browser-opening actions now compile on non-desktop targets by delegating the unsupported-platform error to the shared URL opener instead of hiding the TUI wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 9314e7747..98deb5d27 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -161,6 +161,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Authentication failures now include redacted request context such as provider, + base URL authority, model, key source, key type, and key fingerprint, making + stale provider, endpoint, or API-key state diagnosable without exposing the + secret (#2665, #2792). Thanks @mvanhorn for the implementation. - Browser-opening actions now compile on non-desktop targets by delegating the unsupported-platform error to the shared URL opener instead of hiding the TUI wrapper behind a narrower macOS/Linux/Windows cfg. Thanks @ci4ic4 for the diff --git a/crates/tui/src/error_taxonomy.rs b/crates/tui/src/error_taxonomy.rs index 7f14644f2..be032dd30 100644 --- a/crates/tui/src/error_taxonomy.rs +++ b/crates/tui/src/error_taxonomy.rs @@ -234,12 +234,12 @@ impl From for ErrorEnvelope { "llm_timeout", format!("Request timed out after {duration:?}"), ), - LlmError::AuthenticationError(message) => Self::new( + LlmError::AuthenticationError(auth) => Self::new( ErrorCategory::Authentication, ErrorSeverity::Critical, false, "llm_auth_error", - message, + auth.to_user_message(), ), LlmError::AuthorizationError(message) => Self::new( ErrorCategory::Authorization, @@ -566,6 +566,35 @@ mod tests { } } + #[test] + fn llm_auth_error_envelope_renders_context_without_secret() { + let api_key = "tp-secret-token-plan-value"; + let env = ErrorEnvelope::from(LlmError::from_http_response_with_request_context( + 401, + &format!("Invalid API Key: {api_key}"), + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("env"), + Some(api_key), + )); + + assert_eq!(env.category, ErrorCategory::Authentication); + assert_eq!(env.severity, ErrorSeverity::Critical); + assert!(!env.recoverable); + assert!(env.message.contains("provider: Xiaomi MiMo")); + assert!( + env.message + .contains("base URL authority: token-plan-sgp.xiaomimimo.com") + ); + assert!(env.message.contains("model: mimo-v2.5")); + assert!(env.message.contains("key source: env")); + assert!(env.message.contains("key fingerprint: tp-... (len=26)")); + assert!(env.message.contains("key type: Xiaomi MiMo Token Plan key")); + assert!(!env.message.contains(api_key)); + assert!(!env.message.contains("secret-token-plan-value")); + } + #[test] fn authorization_catches_forbidden_and_denied() { for msg in [ diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index 90a652092..de849328a 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -82,6 +82,194 @@ pub trait RetryConfigurable { fn set_retry_config(&mut self, config: RetryConfig); } +// === Authentication diagnostics === + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AuthenticationErrorContext { + pub provider: Option, + pub base_url_authority: Option, + pub model: Option, + pub key_source: Option, + pub key_fingerprint: Option, + pub key_kind: Option, +} + +impl AuthenticationErrorContext { + #[must_use] + pub fn new( + provider: &str, + base_url: &str, + model: &str, + key_source: &str, + api_key: &str, + ) -> Self { + Self::from_parts( + Some(provider), + Some(base_url), + Some(model), + Some(key_source), + Some(api_key), + ) + } + + #[must_use] + pub fn from_parts( + provider: Option<&str>, + base_url: Option<&str>, + model: Option<&str>, + key_source: Option<&str>, + api_key: Option<&str>, + ) -> Self { + let api_key = api_key.and_then(non_empty_trimmed); + Self { + provider: provider.and_then(non_empty_trimmed).map(str::to_string), + base_url_authority: base_url.and_then(base_url_authority), + model: model.and_then(non_empty_trimmed).map(str::to_string), + key_source: key_source.and_then(non_empty_trimmed).map(str::to_string), + key_fingerprint: api_key.map(redacted_key_fingerprint), + key_kind: api_key.map(classify_api_key_prefix).map(str::to_string), + } + } + + fn is_empty(&self) -> bool { + self.provider.is_none() + && self.base_url_authority.is_none() + && self.model.is_none() + && self.key_source.is_none() + && self.key_fingerprint.is_none() + && self.key_kind.is_none() + } + + fn detail_segments(&self) -> Vec { + let mut segments = Vec::new(); + if let Some(provider) = self.provider.as_deref() { + segments.push(format!("provider: {provider}")); + } + if let Some(authority) = self.base_url_authority.as_deref() { + segments.push(format!("base URL authority: {authority}")); + } + if let Some(model) = self.model.as_deref() { + segments.push(format!("model: {model}")); + } + if let Some(source) = self.key_source.as_deref() { + segments.push(format!("key source: {source}")); + } + if let Some(fingerprint) = self.key_fingerprint.as_deref() { + segments.push(format!("key fingerprint: {fingerprint}")); + } + if let Some(kind) = self.key_kind.as_deref() { + segments.push(format!("key type: {kind}")); + } + segments + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticationErrorDetail { + message: String, + context: Option, +} + +impl AuthenticationErrorDetail { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + context: None, + } + } + + #[must_use] + pub fn with_context( + message: impl Into, + context: Option, + ) -> Self { + let context = context.filter(|context| !context.is_empty()); + Self { + message: message.into(), + context, + } + } + + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + #[must_use] + pub fn to_user_message(&self) -> String { + let Some(context) = self.context.as_ref() else { + return self.message.clone(); + }; + let segments = context.detail_segments(); + if segments.is_empty() { + self.message.clone() + } else { + format!("{} ({})", self.message, segments.join(", ")) + } + } +} + +impl From for AuthenticationErrorDetail { + fn from(message: String) -> Self { + Self::new(message) + } +} + +impl From<&str> for AuthenticationErrorDetail { + fn from(message: &str) -> Self { + Self::new(message) + } +} + +#[must_use] +pub fn classify_api_key_prefix(api_key: &str) -> &'static str { + if api_key.starts_with("tp-") { + "Xiaomi MiMo Token Plan key" + } else { + "API key" + } +} + +fn non_empty_trimmed(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { None } else { Some(value) } +} + +fn base_url_authority(base_url: &str) -> Option { + let base_url = non_empty_trimmed(base_url)?; + let without_scheme = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest); + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, authority)| authority); + non_empty_trimmed(authority).map(str::to_string) +} + +fn redacted_key_fingerprint(api_key: &str) -> String { + let api_key = api_key.trim(); + let len = api_key.chars().count(); + match public_key_prefix(api_key) { + Some(prefix) => format!("{prefix}... (len={len})"), + None => format!("unprefixed (len={len})"), + } +} + +fn public_key_prefix(api_key: &str) -> Option<&str> { + ["tp-", "sk-", "hf_", "hf-", "ak-", "rk-"] + .into_iter() + .find(|prefix| api_key.starts_with(prefix)) +} + +fn redact_api_key_from_message(message: &str, api_key: Option<&str>) -> String { + let Some(api_key) = api_key.and_then(non_empty_trimmed) else { + return message.to_string(); + }; + message.replace(api_key, "[redacted API key]") +} + // === LlmError - Classified Error Types === /// Classified LLM errors with retryability information. @@ -107,8 +295,8 @@ pub enum LlmError { /// Request timed out Timeout(Duration), - /// Authentication failed (HTTP 401, 403) - AuthenticationError(String), + /// Authentication failed (HTTP 401, selected HTTP 403) + AuthenticationError(AuthenticationErrorDetail), /// Authorization or provider-side blocking failed (HTTP 403) AuthorizationError(String), @@ -141,7 +329,9 @@ impl std::fmt::Display for LlmError { } LlmError::NetworkError(msg) => write!(f, "Network error: {msg}"), LlmError::Timeout(d) => write!(f, "Request timed out after {d:?}"), - LlmError::AuthenticationError(msg) => write!(f, "Authentication failed: {msg}"), + LlmError::AuthenticationError(auth) => { + write!(f, "Authentication failed: {}", auth.to_user_message()) + } LlmError::AuthorizationError(msg) => write!(f, "Authorization failed: {msg}"), LlmError::InvalidRequest { status, message } => { write!(f, "Invalid request ({status}): {message}") @@ -203,10 +393,10 @@ impl LlmError { message: body.to_string(), retry_after: None, }, - 401 => LlmError::AuthenticationError(body.to_string()), + 401 => Self::authentication_error(body), 403 => { if looks_like_authentication_failure(body) { - LlmError::AuthenticationError(body.to_string()) + Self::authentication_error(body) } else { LlmError::AuthorizationError(body.to_string()) } @@ -262,6 +452,62 @@ impl LlmError { } } + #[must_use] + pub fn authentication_error(message: impl Into) -> Self { + LlmError::AuthenticationError(AuthenticationErrorDetail::new(message)) + } + + #[must_use] + pub fn authentication_error_with_context( + message: impl Into, + context: Option, + ) -> Self { + LlmError::AuthenticationError(AuthenticationErrorDetail::with_context(message, context)) + } + + /// Constructs an `LlmError` from HTTP response data plus request context + /// that is safe to display when authentication fails. + #[must_use] + pub fn from_http_response_with_request_context( + status: u16, + body: &str, + provider: Option<&str>, + base_url: Option<&str>, + model: Option<&str>, + key_source: Option<&str>, + api_key: Option<&str>, + ) -> Self { + let body = redact_api_key_from_message(body, api_key); + let context = + AuthenticationErrorContext::from_parts(provider, base_url, model, key_source, api_key); + Self::from_http_response_with_auth_context(status, &body, Some(context)) + } + + /// Constructs an `LlmError` from HTTP status code and response body, with + /// optional structured details for authentication failures. + /// + /// The `body` passed here must already be safe for user display. Prefer + /// [`Self::from_http_response_with_request_context`] when the raw API key is + /// available so the response body can be redacted before rendering. + #[must_use] + pub fn from_http_response_with_auth_context( + status: u16, + body: &str, + auth_context: Option, + ) -> Self { + match status { + 401 => Self::authentication_error_with_context(body, auth_context), + 403 => { + if looks_like_authentication_failure(body) { + Self::authentication_error_with_context(body, auth_context) + } else { + LlmError::AuthorizationError(body.to_string()) + } + } + _ => Self::from_http_response(status, body), + } + } + /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header. pub fn from_http_response_with_retry_after( status: u16, @@ -898,6 +1144,13 @@ mod tests { ); } + fn auth_user_message(error: LlmError) -> String { + match error { + LlmError::AuthenticationError(auth) => auth.to_user_message(), + other => panic!("expected authentication error, got {other}"), + } + } + #[test] fn test_retry_config_defaults() { let config = RetryConfig::default(); @@ -1014,7 +1267,7 @@ mod tests { assert!(LlmError::Timeout(Duration::from_secs(30)).is_retryable()); // Non-retryable errors - assert!(!LlmError::AuthenticationError("invalid key".to_string()).is_retryable()); + assert!(!LlmError::authentication_error("invalid key").is_retryable()); assert!(!LlmError::AuthorizationError("blocked".to_string()).is_retryable()); assert!( !LlmError::InvalidRequest { @@ -1071,6 +1324,109 @@ mod tests { assert!(matches!(err, LlmError::InvalidRequest { status: 400, .. })); } + #[test] + fn auth_error_with_context_includes_provider_authority_model_and_key_source() { + let err = LlmError::from_http_response_with_request_context( + 401, + "Invalid API Key", + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("env"), + Some("tp-secret-token-plan-value"), + ); + let message = auth_user_message(err); + + assert!(message.contains("Invalid API Key")); + assert!(message.contains("provider: Xiaomi MiMo")); + assert!(message.contains("base URL authority: token-plan-sgp.xiaomimimo.com")); + assert!(message.contains("model: mimo-v2.5")); + assert!(message.contains("key source: env")); + assert!(message.contains("key fingerprint: tp-... (len=26)")); + } + + #[test] + fn auth_error_redacts_full_api_key_from_body_and_context() { + let api_key = "tp-secret-token-plan-value"; + let err = LlmError::from_http_response_with_request_context( + 401, + &format!("Invalid API Key: {api_key}"), + Some("Xiaomi MiMo"), + Some("https://token-plan-sgp.xiaomimimo.com/v1"), + Some("mimo-v2.5"), + Some("config-file"), + Some(api_key), + ); + let message = auth_user_message(err); + + assert!(!message.contains(api_key)); + assert!(!message.contains("secret-token-plan-value")); + assert!(message.contains("[redacted API key]")); + assert!(message.contains("key fingerprint: tp-... (len=26)")); + } + + #[test] + fn auth_error_classifies_xiaomi_token_plan_key_prefix() { + let token_plan = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("tp-secret-token-plan-value"), + ); + let generic = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("sk-other"), + ); + let unprefixed = AuthenticationErrorContext::from_parts( + None, + None, + None, + Some("session"), + Some("plainsecretvalue"), + ); + + assert_eq!( + token_plan.key_kind.as_deref(), + Some("Xiaomi MiMo Token Plan key") + ); + assert_eq!(generic.key_kind.as_deref(), Some("API key")); + assert_eq!(unprefixed.key_kind.as_deref(), Some("API key")); + assert_eq!( + unprefixed.key_fingerprint.as_deref(), + Some("unprefixed (len=16)") + ); + } + + #[test] + fn authorization_403_is_not_reclassified_by_auth_context() { + let err = LlmError::from_http_response_with_request_context( + 403, + "forbidden", + Some("Arcee AI"), + Some("https://api.arcee.ai/v1"), + Some("auto"), + Some("env"), + Some("sk-arcee-secret"), + ); + + assert!(matches!(err, LlmError::AuthorizationError(_))); + } + + #[test] + fn auth_error_without_context_preserves_bare_message() { + let err = LlmError::from_http_response_with_auth_context( + 401, + "Invalid API Key", + Some(AuthenticationErrorContext::default()), + ); + + assert_eq!(auth_user_message(err), "Invalid API Key"); + } + #[test] fn cloudflare_html_error_is_summarized_without_raw_markup() { let body = r#"Access Denied + + +
${escapeHtml(badge)}
+

${escapeHtml(this.state.detail)}

+

${escapeHtml(this.state.baseUrl)}

+ + + + + +`; + } +} +exports.RuntimeStatusView = RuntimeStatusView; +function labelFor(kind) { + switch (kind) { + case "connected": + return "Connected"; + case "auth-required": + return "Token Required"; + case "error": + return "Runtime Error"; + case "offline": + return "Offline"; + } +} +function escapeHtml(value) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function makeNonce() { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let index = 0; index < 32; index += 1) { + nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return nonce; +} +//# sourceMappingURL=status.js.map \ No newline at end of file diff --git a/extensions/vscode/out/status.js.map b/extensions/vscode/out/status.js.map new file mode 100644 index 000000000..5aed8b6e0 --- /dev/null +++ b/extensions/vscode/out/status.js.map @@ -0,0 +1 @@ +{"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAiC;AAGjC,MAAa,iBAAiB;IACrB,MAAM,CAAU,QAAQ,GAAG,yBAAyB,CAAC;IAEpD,IAAI,CAAsB;IAC1B,KAAK,GAAiB;QAC5B,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,uBAAuB;QAChC,MAAM,EAAE,mCAAmC;KAC5C,CAAC;IAEF,kBAAkB,CAAC,IAAwB;QACzC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,CAAC,OAAO,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;QAC/C,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC,OAA6B,EAAE,EAAE;YACjE,IAAI,OAAO,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;gBAChC,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;gBACvC,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;iBAAM,IAAI,OAAO,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;gBAC1C,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;YAChE,CAAC;QACH,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAED,MAAM,CAAC,KAAmB;QACxB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,EAAE,CAAC;IAChB,CAAC;IAEO,MAAM;QACZ,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACf,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACxC,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG;;;;yHAI4F,KAAK;;;;;;;;;;;wBAWtG,UAAU,CAAC,KAAK,CAAC;sBACnB,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;4BACvB,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC;;;;mBAIvC,KAAK;;;;;;;QAOhB,CAAC;IACP,CAAC;;AAlEH,8CAmEC;AAED,SAAS,QAAQ,CAAC,IAA0B;IAC1C,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,WAAW;YACd,OAAO,WAAW,CAAC;QACrB,KAAK,eAAe;YAClB,OAAO,gBAAgB,CAAC;QAC1B,KAAK,OAAO;YACV,OAAO,eAAe,CAAC;QACzB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC;IACrB,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,SAAS,SAAS;IAChB,MAAM,QAAQ,GAAG,gEAAgE,CAAC;IAClF,IAAI,KAAK,GAAG,EAAE,CAAC;IACf,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,EAAE,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC3C,KAAK,IAAI,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"} \ No newline at end of file diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json new file mode 100644 index 000000000..f5aa199b2 --- /dev/null +++ b/extensions/vscode/package-lock.json @@ -0,0 +1,3851 @@ +{ + "name": "codewhale-vscode", + "version": "0.8.53", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codewhale-vscode", + "version": "0.8.53", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.19.27", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.7.0", + "typescript": "^5.9.3" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.12.0.tgz", + "integrity": "sha512-eNf2aqx1C6I0yT1GEu5ukblFrmaBXGfe1bivpmlfqvK7giPZvoXLa404C8EfeHVsy6EIryfQuPRzuW1fPxWlHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.7.0.tgz", + "integrity": "sha512-Jb8Y7pX6KM42SIT7KWP6YbY3+vLbwB5b5m+tpiiOzMU1QeyelQzs9lO8jv1e7/Uj9r7tg7VjPvW4T0KB1jF3UQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.3.tgz", + "integrity": "sha512-YYX4TchEVddVBiybKvKhV9QO/q22jgewP+BVxKG7Uh115voPcviGlypbKERDsqQdAiSTJrwi80gcWFjYKdo8+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.7.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.7.1" + } + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", + "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.2.tgz", + "integrity": "sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json new file mode 100644 index 000000000..74ba45254 --- /dev/null +++ b/extensions/vscode/package.json @@ -0,0 +1,109 @@ +{ + "name": "codewhale-vscode", + "displayName": "CodeWhale", + "description": "Official CodeWhale VS Code integration scaffold for local runtime attach and terminal launch.", + "version": "0.8.53", + "publisher": "codewhale", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Hmbown/CodeWhale.git", + "directory": "extensions/vscode" + }, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onCommand:codewhale.openTerminal", + "onCommand:codewhale.startRuntime", + "onCommand:codewhale.checkRuntime", + "onCommand:codewhale.openRuntimeDocs", + "onView:codewhale.runtimeStatus" + ], + "main": "./out/extension.js", + "files": [ + "out", + "media", + "README.md", + "LICENSE" + ], + "contributes": { + "commands": [ + { + "command": "codewhale.openTerminal", + "title": "CodeWhale: Open Terminal" + }, + { + "command": "codewhale.startRuntime", + "title": "CodeWhale: Start Local Runtime" + }, + { + "command": "codewhale.checkRuntime", + "title": "CodeWhale: Check Runtime" + }, + { + "command": "codewhale.openRuntimeDocs", + "title": "CodeWhale: Open Runtime API Docs" + } + ], + "configuration": { + "title": "CodeWhale", + "properties": { + "codewhale.commandPath": { + "type": "string", + "default": "codewhale", + "description": "Command or absolute path used to launch CodeWhale." + }, + "codewhale.runtimeHost": { + "type": "string", + "default": "127.0.0.1", + "description": "Local host used for CodeWhale runtime attach checks." + }, + "codewhale.runtimePort": { + "type": "number", + "default": 7878, + "minimum": 1, + "maximum": 65535, + "description": "Local port used for CodeWhale runtime attach checks." + }, + "codewhale.runtimeToken": { + "type": "string", + "default": "", + "description": "Optional bearer token for authenticated runtime endpoints." + } + } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "codewhale", + "title": "CodeWhale", + "icon": "media/codewhale.svg" + } + ] + }, + "views": { + "codewhale": [ + { + "type": "webview", + "id": "codewhale.runtimeStatus", + "name": "Runtime" + } + ] + } + }, + "scripts": { + "compile": "tsc -p ./", + "check": "npm run compile", + "package": "vsce package --no-dependencies" + }, + "devDependencies": { + "@types/node": "^20.19.27", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.7.0", + "typescript": "^5.9.3" + } +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts new file mode 100644 index 000000000..749d12822 --- /dev/null +++ b/extensions/vscode/src/extension.ts @@ -0,0 +1,89 @@ +import * as vscode from "vscode"; +import { + checkRuntime, + openCodeWhaleTerminal, + readRuntimeConfig, + runtimeBaseUrl, + startRuntimeTerminal, +} from "./runtime"; +import { RuntimeStatusView } from "./status"; + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("CodeWhale"); + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + const statusView = new RuntimeStatusView(); + + status.command = "codewhale.checkRuntime"; + context.subscriptions.push(output, status); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(RuntimeStatusView.viewType, statusView), + ); + + const updateStatus = (text: string, tooltip: string): void => { + status.text = text; + status.tooltip = tooltip; + status.show(); + }; + + updateStatus("$(terminal) CodeWhale", "Check CodeWhale runtime"); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.openTerminal", () => { + const config = readRuntimeConfig(); + openCodeWhaleTerminal(config); + output.appendLine(`Opened CodeWhale terminal using ${config.commandPath}.`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.startRuntime", () => { + const config = readRuntimeConfig(); + startRuntimeTerminal(config); + const baseUrl = runtimeBaseUrl(config); + updateStatus("$(sync~spin) CodeWhale", `Runtime terminal started for ${baseUrl}`); + output.appendLine(`Started CodeWhale runtime terminal at ${baseUrl}.`); + void vscode.window.showInformationMessage(`CodeWhale runtime starting at ${baseUrl}`); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.checkRuntime", async () => { + const config = readRuntimeConfig(); + updateStatus("$(sync~spin) CodeWhale", "Checking CodeWhale runtime..."); + const state = await checkRuntime(config); + statusView.update(state); + + switch (state.kind) { + case "connected": + updateStatus("$(check) CodeWhale", state.detail); + break; + case "auth-required": + updateStatus("$(lock) CodeWhale", state.detail); + break; + case "offline": + case "error": + updateStatus("$(warning) CodeWhale", state.detail); + break; + } + + output.appendLine(`${new Date().toISOString()} ${state.kind}: ${state.detail}`); + return state; + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("codewhale.openRuntimeDocs", () => { + void vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_API.md", + ), + ); + }), + ); + + void vscode.commands.executeCommand("codewhale.checkRuntime"); +} + +export function deactivate(): void { + // No background process is owned by the extension; runtime starts in a user-visible terminal. +} diff --git a/extensions/vscode/src/runtime.ts b/extensions/vscode/src/runtime.ts new file mode 100644 index 000000000..c333b4b0c --- /dev/null +++ b/extensions/vscode/src/runtime.ts @@ -0,0 +1,153 @@ +import * as http from "node:http"; +import * as vscode from "vscode"; + +export type RuntimeStateKind = "connected" | "offline" | "auth-required" | "error"; + +export interface RuntimeState { + kind: RuntimeStateKind; + baseUrl: string; + detail: string; + version?: string; +} + +export interface RuntimeConfig { + commandPath: string; + host: string; + port: number; + token?: string; +} + +export function readRuntimeConfig(): RuntimeConfig { + const config = vscode.workspace.getConfiguration("codewhale"); + const commandPath = config.get("commandPath", "codewhale").trim() || "codewhale"; + const host = config.get("runtimeHost", "127.0.0.1").trim() || "127.0.0.1"; + const port = config.get("runtimePort", 7878); + const token = config.get("runtimeToken", "").trim(); + return { + commandPath, + host, + port, + token: token.length > 0 ? token : undefined, + }; +} + +export function runtimeBaseUrl(config: RuntimeConfig): string { + return `http://${config.host}:${config.port}`; +} + +export async function checkRuntime(config: RuntimeConfig): Promise { + const baseUrl = runtimeBaseUrl(config); + const health = await requestJson(`${baseUrl}/health`, config.token); + if (health.statusCode === 0) { + return { kind: "offline", baseUrl, detail: "Runtime is not reachable." }; + } + if (health.statusCode === 401) { + return { kind: "auth-required", baseUrl, detail: "Runtime requires a token." }; + } + if (health.statusCode !== 200) { + return { + kind: "error", + baseUrl, + detail: `Health check returned HTTP ${health.statusCode}.`, + }; + } + + const info = await requestJson(`${baseUrl}/v1/runtime/info`, config.token); + if (info.statusCode === 401) { + return { kind: "auth-required", baseUrl, detail: "Runtime info requires a token." }; + } + + const version = readVersion(info.body); + return { + kind: "connected", + baseUrl, + detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.", + version, + }; +} + +export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal { + const terminal = vscode.window.createTerminal("CodeWhale Runtime"); + const args = [ + "serve", + "--http", + "--host", + shellQuote(config.host), + "--port", + String(config.port), + ]; + if (config.token) { + args.push("--auth-token", shellQuote(config.token)); + } + terminal.sendText(`${shellQuote(config.commandPath)} ${args.join(" ")}`); + terminal.show(); + return terminal; +} + +export function openCodeWhaleTerminal(config: RuntimeConfig): vscode.Terminal { + const terminal = vscode.window.createTerminal("CodeWhale"); + terminal.sendText(shellQuote(config.commandPath)); + terminal.show(); + return terminal; +} + +async function requestJson( + url: string, + token: string | undefined, +): Promise<{ statusCode: number; body: unknown }> { + try { + return await new Promise<{ statusCode: number; body: unknown }>((resolve, reject) => { + const request = http.get( + url, + { + timeout: 2500, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => { + resolve({ + statusCode: response.statusCode ?? 0, + body: parseJson(body), + }); + }); + }, + ); + + request.on("timeout", () => { + request.destroy(new Error("Runtime check timed out.")); + }); + request.on("error", reject); + }); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + return { statusCode: 0, body: { error: detail } }; + } +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function readVersion(value: unknown): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const version = (value as { version?: unknown }).version; + return typeof version === "string" ? version : undefined; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/extensions/vscode/src/status.ts b/extensions/vscode/src/status.ts new file mode 100644 index 000000000..03f8f8c22 --- /dev/null +++ b/extensions/vscode/src/status.ts @@ -0,0 +1,101 @@ +import * as vscode from "vscode"; +import type { RuntimeState } from "./runtime"; + +export class RuntimeStatusView implements vscode.WebviewViewProvider { + public static readonly viewType = "codewhale.runtimeStatus"; + + private view?: vscode.WebviewView; + private state: RuntimeState = { + kind: "offline", + baseUrl: "http://127.0.0.1:7878", + detail: "Runtime has not been checked yet.", + }; + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true }; + view.webview.onDidReceiveMessage((message: { command?: string }) => { + if (message.command === "check") { + void vscode.commands.executeCommand("codewhale.checkRuntime"); + } else if (message.command === "start") { + void vscode.commands.executeCommand("codewhale.startRuntime"); + } else if (message.command === "terminal") { + void vscode.commands.executeCommand("codewhale.openTerminal"); + } + }); + this.render(); + } + + update(state: RuntimeState): void { + this.state = state; + this.render(); + } + + private render(): void { + if (!this.view) { + return; + } + + const badge = labelFor(this.state.kind); + const nonce = makeNonce(); + this.view.webview.html = ` + + + + + + + + +
${escapeHtml(badge)}
+

${escapeHtml(this.state.detail)}

+

${escapeHtml(this.state.baseUrl)}

+ + + + + +`; + } +} + +function labelFor(kind: RuntimeState["kind"]): string { + switch (kind) { + case "connected": + return "Connected"; + case "auth-required": + return "Token Required"; + case "error": + return "Runtime Error"; + case "offline": + return "Offline"; + } +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function makeNonce(): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let index = 0; index < 32; index += 1) { + nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return nonce; +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json new file mode 100644 index 000000000..ce31b8d1f --- /dev/null +++ b/extensions/vscode/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From 7d8308a7a2faaba206d0c3214361c785b2be9b6d Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 19:16:21 -0700 Subject: [PATCH 107/209] docs: credit VS Code plugin request trail Adds #1584 and @nasus9527 to the v0.9 VS Code extension credit trail after the Phase 0 extension scaffold landed. --- CHANGELOG.md | 8 ++++---- README.md | 9 +++++---- crates/tui/CHANGELOG.md | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0799c33db..6f6d8dccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,10 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 local runtime attach checks, status bar state, and a CodeWhale runtime status view. This answers the VS Code GUI lane without exposing chat webviews, Agent View, inline edits, or retry/undo runtime endpoints yet (#461, #462, - #480, #2580). Thanks @AiurArtanis for the Agent View prompt, @lbcheng888 for - the earlier scaffold, and @BigBenLabs, @lzx1545642258, @yangdaowan, - @mangdehuang, @VerrPower, @hejia-v, and @ygzhang-cn for the GUI/VS Code - demand and validation trail. + #480, #1584, #2580). Thanks @AiurArtanis for the Agent View prompt, + @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, + @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn + for the GUI/VS Code demand and validation trail. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/README.md b/README.md index 56910ae1f..e2479ed8c 100644 --- a/README.md +++ b/README.md @@ -644,10 +644,11 @@ Current v0.9 track credits: - **[aboimpinto](https://github.com/aboimpinto)** — sidebar command polish and pausable custom-command lifecycle direction harvested into the v0.9 track (#2788, #2732) -- **[lbcheng888](https://github.com/lbcheng888)** and - **[AiurArtanis](https://github.com/AiurArtanis)** — VS Code extension - scaffold direction and Agent View request that shaped the official Phase 0 - extension (#1022, #2580) +- **[lbcheng888](https://github.com/lbcheng888)**, + **[AiurArtanis](https://github.com/AiurArtanis)**, and + **[nasus9527](https://github.com/nasus9527)** — VS Code extension scaffold + direction, Agent View request, and IDE plugin request that shaped the + official Phase 0 extension (#1022, #1584, #2580) - **[HUQIANTAO](https://github.com/HUQIANTAO)** — `web_run` cache-state lock-splitting, turn-metadata prefix-cache stability, and project-context cache work (#2502, #2517, #2636) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 0799c33db..6f6d8dccf 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -43,10 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 local runtime attach checks, status bar state, and a CodeWhale runtime status view. This answers the VS Code GUI lane without exposing chat webviews, Agent View, inline edits, or retry/undo runtime endpoints yet (#461, #462, - #480, #2580). Thanks @AiurArtanis for the Agent View prompt, @lbcheng888 for - the earlier scaffold, and @BigBenLabs, @lzx1545642258, @yangdaowan, - @mangdehuang, @VerrPower, @hejia-v, and @ygzhang-cn for the GUI/VS Code - demand and validation trail. + #480, #1584, #2580). Thanks @AiurArtanis for the Agent View prompt, + @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, + @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn + for the GUI/VS Code demand and validation trail. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting From 54cbcd0d8e88fee41e1777013d2f661c0a66849e Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 19:21:17 -0700 Subject: [PATCH 108/209] chore: map AdityaVG13 for harvested credit Adds the GitHub noreply and source commit email aliases needed for the WhaleFlow harvest co-author trail to pass the contributor-credit gate. --- .github/AUTHOR_MAP | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index 2e9f7ea3b..057b9e1ef 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -87,6 +87,8 @@ shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github shenjack = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> 3695888@qq.com = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com> xyuai = xyuai <281015099+xyuai@users.noreply.github.com> +AdityaVG13 = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com> +adityavgcode@gmail.com = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com> Implementist = Implementist <24910011+Implementist@users.noreply.github.com> implecao = Implementist <24910011+Implementist@users.noreply.github.com> yuyuyu4993@qq.com = Implementist <24910011+Implementist@users.noreply.github.com> From ab299865dd29f80cb213e9065770db61631c585c Mon Sep 17 00:00:00 2001 From: Hunter B Date: Fri, 5 Jun 2026 19:35:08 -0700 Subject: [PATCH 109/209] feat(vscode): add read-only agent view preview --- CHANGELOG.md | 14 +++--- crates/tui/CHANGELOG.md | 14 +++--- extensions/vscode/README.md | 10 ++-- extensions/vscode/out/extension.js | 27 +++++++++++ extensions/vscode/out/extension.js.map | 2 +- extensions/vscode/out/runtime.js | 42 +++++++++++++++++ extensions/vscode/out/runtime.js.map | 2 +- extensions/vscode/out/status.js | 38 +++++++++++++++ extensions/vscode/out/status.js.map | 2 +- extensions/vscode/package.json | 7 ++- extensions/vscode/src/extension.ts | 30 ++++++++++++ extensions/vscode/src/runtime.ts | 65 ++++++++++++++++++++++++++ extensions/vscode/src/status.ts | 43 ++++++++++++++++- 13 files changed, 273 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6d8dccf..b48b7eb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,13 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release-safe. Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking direction. - Added an official VS Code extension Phase 0 scaffold with terminal launch, - local runtime attach checks, status bar state, and a CodeWhale runtime status - view. This answers the VS Code GUI lane without exposing chat webviews, - Agent View, inline edits, or retry/undo runtime endpoints yet (#461, #462, - #480, #1584, #2580). Thanks @AiurArtanis for the Agent View prompt, - @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, - @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn - for the GUI/VS Code demand and validation trail. + local runtime attach checks, status bar state, and a read-only Agent View + preview backed by recent runtime thread summaries. This answers the VS Code + GUI lane without exposing chat webviews, inline edits, or retry/undo runtime + endpoints yet (#461, #462, #480, #1584, #2580). Thanks @AiurArtanis for the + Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs, + @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, + @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6f6d8dccf..b48b7eb50 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -40,13 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release-safe. Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking direction. - Added an official VS Code extension Phase 0 scaffold with terminal launch, - local runtime attach checks, status bar state, and a CodeWhale runtime status - view. This answers the VS Code GUI lane without exposing chat webviews, - Agent View, inline edits, or retry/undo runtime endpoints yet (#461, #462, - #480, #1584, #2580). Thanks @AiurArtanis for the Agent View prompt, - @lbcheng888 for the earlier scaffold, and @BigBenLabs, @lzx1545642258, - @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, @nasus9527, and @ygzhang-cn - for the GUI/VS Code demand and validation trail. + local runtime attach checks, status bar state, and a read-only Agent View + preview backed by recent runtime thread summaries. This answers the VS Code + GUI lane without exposing chat webviews, inline edits, or retry/undo runtime + endpoints yet (#461, #462, #480, #1584, #2580). Thanks @AiurArtanis for the + Agent View prompt, @lbcheng888 for the earlier scaffold, and @BigBenLabs, + @lzx1545642258, @yangdaowan, @mangdehuang, @VerrPower, @hejia-v, + @nasus9527, and @ygzhang-cn for the GUI/VS Code demand and validation trail. - Added `POST /v1/sessions` for runtime clients to save a completed thread as a managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index fcdda1774..fc9450ac5 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -7,11 +7,13 @@ This first slice is intentionally small: - open CodeWhale in an integrated terminal - start `codewhale serve --http` in a visible terminal - check a local runtime through `/health` and `/v1/runtime/info` -- show connection state in the status bar and CodeWhale activity view +- show connection state in the status bar +- show a read-only Agent View with recent runtime thread summaries from + `/v1/threads/summary` -It does not expose the full chat webview, VS Code Agent View integration, -inline edit application, marketplace publish workflow, or retry/undo/snapshot -GUI endpoints yet. +It does not expose the full chat webview, VS Code Agent View chat/editor +integration, inline edit application, marketplace publish workflow, or +retry/undo/snapshot GUI endpoints yet. ## Local Use diff --git a/extensions/vscode/out/extension.js b/extensions/vscode/out/extension.js index 0fd460ecc..4ef48fc0e 100644 --- a/extensions/vscode/out/extension.js +++ b/extensions/vscode/out/extension.js @@ -45,6 +45,12 @@ function activate(context) { status.command = "codewhale.checkRuntime"; context.subscriptions.push(output, status); context.subscriptions.push(vscode.window.registerWebviewViewProvider(status_1.RuntimeStatusView.viewType, statusView)); + const refreshAgentView = async () => { + const config = (0, runtime_1.readRuntimeConfig)(); + const threads = await (0, runtime_1.listThreadSummaries)(config); + statusView.updateThreads(threads, "Showing recent runtime threads."); + output.appendLine(`Loaded ${threads.length} runtime thread summaries.`); + }; const updateStatus = (text, tooltip) => { status.text = text; status.tooltip = tooltip; @@ -72,18 +78,39 @@ function activate(context) { switch (state.kind) { case "connected": updateStatus("$(check) CodeWhale", state.detail); + try { + await refreshAgentView(); + } + catch (error) { + const detail = error instanceof Error ? error.message : String(error); + statusView.updateThreads([], detail); + output.appendLine(`Runtime thread summaries unavailable: ${detail}`); + } break; case "auth-required": updateStatus("$(lock) CodeWhale", state.detail); + statusView.updateThreads([], "Runtime token is required before threads can load."); break; case "offline": case "error": updateStatus("$(warning) CodeWhale", state.detail); + statusView.updateThreads([], "Connect to the runtime to load recent threads."); break; } output.appendLine(`${new Date().toISOString()} ${state.kind}: ${state.detail}`); return state; })); + context.subscriptions.push(vscode.commands.registerCommand("codewhale.refreshAgentView", async () => { + try { + await refreshAgentView(); + } + catch (error) { + const detail = error instanceof Error ? error.message : String(error); + statusView.updateThreads([], detail); + output.appendLine(`Runtime thread summaries unavailable: ${detail}`); + void vscode.window.showWarningMessage(detail); + } + })); context.subscriptions.push(vscode.commands.registerCommand("codewhale.openRuntimeDocs", () => { void vscode.env.openExternal(vscode.Uri.parse("https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_API.md")); })); diff --git a/extensions/vscode/out/extension.js.map b/extensions/vscode/out/extension.js.map index 750701e44..682135bd5 100644 --- a/extensions/vscode/out/extension.js.map +++ b/extensions/vscode/out/extension.js.map @@ -1 +1 @@ -{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,4BA0EC;AAED,gCAEC;AAxFD,+CAAiC;AACjC,uCAMmB;AACnB,qCAA6C;AAE7C,SAAgB,QAAQ,CAAC,OAAgC;IACvD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC9D,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,kBAAkB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACtF,MAAM,UAAU,GAAG,IAAI,0BAAiB,EAAE,CAAC;IAE3C,MAAM,CAAC,OAAO,GAAG,wBAAwB,CAAC;IAC1C,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3C,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,MAAM,CAAC,2BAA2B,CAAC,0BAAiB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAClF,CAAC;IAEF,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,OAAe,EAAQ,EAAE;QAC3D,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;QACnB,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;QACzB,MAAM,CAAC,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;IAEF,YAAY,CAAC,uBAAuB,EAAE,yBAAyB,CAAC,CAAC;IAEjE,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAC7D,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,IAAA,+BAAqB,EAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,UAAU,CAAC,mCAAmC,MAAM,CAAC,WAAW,GAAG,CAAC,CAAC;IAC9E,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAC7D,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,IAAA,8BAAoB,EAAC,MAAM,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAG,IAAA,wBAAc,EAAC,MAAM,CAAC,CAAC;QACvC,YAAY,CAAC,wBAAwB,EAAE,gCAAgC,OAAO,EAAE,CAAC,CAAC;QAClF,MAAM,CAAC,UAAU,CAAC,yCAAyC,OAAO,GAAG,CAAC,CAAC;QACvE,KAAK,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,iCAAiC,OAAO,EAAE,CAAC,CAAC;IACxF,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,YAAY,CAAC,wBAAwB,EAAE,+BAA+B,CAAC,CAAC;QACxE,MAAM,KAAK,GAAG,MAAM,IAAA,sBAAY,EAAC,MAAM,CAAC,CAAC;QACzC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAEzB,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;YACnB,KAAK,WAAW;gBACd,YAAY,CAAC,oBAAoB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBACjD,MAAM;YACR,KAAK,eAAe;gBAClB,YAAY,CAAC,mBAAmB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBAChD,MAAM;YACR,KAAK,SAAS,CAAC;YACf,KAAK,OAAO;gBACV,YAAY,CAAC,sBAAsB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBACnD,MAAM;QACV,CAAC;QAED,MAAM,CAAC,UAAU,CAAC,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAChF,OAAO,KAAK,CAAC;IACf,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,2BAA2B,EAAE,GAAG,EAAE;QAChE,KAAK,MAAM,CAAC,GAAG,CAAC,YAAY,CAC1B,MAAM,CAAC,GAAG,CAAC,KAAK,CACd,mEAAmE,CACpE,CACF,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;IAEF,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;AAChE,CAAC;AAED,SAAgB,UAAU;IACxB,8FAA8F;AAChG,CAAC"} \ No newline at end of file +{"version":3,"file":"extension.js","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAWA,4BAuGC;AAED,gCAEC;AAtHD,+CAAiC;AACjC,uCAOmB;AACnB,qCAA6C;AAE7C,SAAgB,QAAQ,CAAC,OAAgC;IACvD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC9D,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,kBAAkB,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACtF,MAAM,UAAU,GAAG,IAAI,0BAAiB,EAAE,CAAC;IAE3C,MAAM,CAAC,OAAO,GAAG,wBAAwB,CAAC;IAC1C,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3C,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,MAAM,CAAC,2BAA2B,CAAC,0BAAiB,CAAC,QAAQ,EAAE,UAAU,CAAC,CAClF,CAAC;IAEF,MAAM,gBAAgB,GAAG,KAAK,IAAmB,EAAE;QACjD,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,MAAM,OAAO,GAAG,MAAM,IAAA,6BAAmB,EAAC,MAAM,CAAC,CAAC;QAClD,UAAU,CAAC,aAAa,CAAC,OAAO,EAAE,iCAAiC,CAAC,CAAC;QACrE,MAAM,CAAC,UAAU,CAAC,UAAU,OAAO,CAAC,MAAM,4BAA4B,CAAC,CAAC;IAC1E,CAAC,CAAC;IAEF,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,OAAe,EAAQ,EAAE;QAC3D,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;QACnB,MAAM,CAAC,OAAO,GAAG,OAAO,CAAC;QACzB,MAAM,CAAC,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;IAEF,YAAY,CAAC,uBAAuB,EAAE,yBAAyB,CAAC,CAAC;IAEjE,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAC7D,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,IAAA,+BAAqB,EAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,UAAU,CAAC,mCAAmC,MAAM,CAAC,WAAW,GAAG,CAAC,CAAC;IAC9E,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAC7D,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,IAAA,8BAAoB,EAAC,MAAM,CAAC,CAAC;QAC7B,MAAM,OAAO,GAAG,IAAA,wBAAc,EAAC,MAAM,CAAC,CAAC;QACvC,YAAY,CAAC,wBAAwB,EAAE,gCAAgC,OAAO,EAAE,CAAC,CAAC;QAClF,MAAM,CAAC,UAAU,CAAC,yCAAyC,OAAO,GAAG,CAAC,CAAC;QACvE,KAAK,MAAM,CAAC,MAAM,CAAC,sBAAsB,CAAC,iCAAiC,OAAO,EAAE,CAAC,CAAC;IACxF,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,wBAAwB,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,MAAM,GAAG,IAAA,2BAAiB,GAAE,CAAC;QACnC,YAAY,CAAC,wBAAwB,EAAE,+BAA+B,CAAC,CAAC;QACxE,MAAM,KAAK,GAAG,MAAM,IAAA,sBAAY,EAAC,MAAM,CAAC,CAAC;QACzC,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAEzB,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;YACnB,KAAK,WAAW;gBACd,YAAY,CAAC,oBAAoB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBACjD,IAAI,CAAC;oBACH,MAAM,gBAAgB,EAAE,CAAC;gBAC3B,CAAC;gBAAC,OAAO,KAAc,EAAE,CAAC;oBACxB,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;oBACtE,UAAU,CAAC,aAAa,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;oBACrC,MAAM,CAAC,UAAU,CAAC,yCAAyC,MAAM,EAAE,CAAC,CAAC;gBACvE,CAAC;gBACD,MAAM;YACR,KAAK,eAAe;gBAClB,YAAY,CAAC,mBAAmB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBAChD,UAAU,CAAC,aAAa,CAAC,EAAE,EAAE,oDAAoD,CAAC,CAAC;gBACnF,MAAM;YACR,KAAK,SAAS,CAAC;YACf,KAAK,OAAO;gBACV,YAAY,CAAC,sBAAsB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;gBACnD,UAAU,CAAC,aAAa,CAAC,EAAE,EAAE,gDAAgD,CAAC,CAAC;gBAC/E,MAAM;QACV,CAAC;QAED,MAAM,CAAC,UAAU,CAAC,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAChF,OAAO,KAAK,CAAC;IACf,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,4BAA4B,EAAE,KAAK,IAAI,EAAE;QACvE,IAAI,CAAC;YACH,MAAM,gBAAgB,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,KAAc,EAAE,CAAC;YACxB,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACtE,UAAU,CAAC,aAAa,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;YACrC,MAAM,CAAC,UAAU,CAAC,yCAAyC,MAAM,EAAE,CAAC,CAAC;YACrE,KAAK,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAC;QAChD,CAAC;IACH,CAAC,CAAC,CACH,CAAC;IAEF,OAAO,CAAC,aAAa,CAAC,IAAI,CACxB,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,2BAA2B,EAAE,GAAG,EAAE;QAChE,KAAK,MAAM,CAAC,GAAG,CAAC,YAAY,CAC1B,MAAM,CAAC,GAAG,CAAC,KAAK,CACd,mEAAmE,CACpE,CACF,CAAC;IACJ,CAAC,CAAC,CACH,CAAC;IAEF,KAAK,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;AAChE,CAAC;AAED,SAAgB,UAAU;IACxB,8FAA8F;AAChG,CAAC"} \ No newline at end of file diff --git a/extensions/vscode/out/runtime.js b/extensions/vscode/out/runtime.js index fb706ee9f..e69c42963 100644 --- a/extensions/vscode/out/runtime.js +++ b/extensions/vscode/out/runtime.js @@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.readRuntimeConfig = readRuntimeConfig; exports.runtimeBaseUrl = runtimeBaseUrl; exports.checkRuntime = checkRuntime; +exports.listThreadSummaries = listThreadSummaries; exports.startRuntimeTerminal = startRuntimeTerminal; exports.openCodeWhaleTerminal = openCodeWhaleTerminal; const http = __importStar(require("node:http")); @@ -84,6 +85,17 @@ async function checkRuntime(config) { version, }; } +async function listThreadSummaries(config, limit = 8) { + const baseUrl = runtimeBaseUrl(config); + const response = await requestJson(`${baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`, config.token); + if (response.statusCode === 401) { + throw new Error("Thread summaries require the runtime bearer token."); + } + if (response.statusCode !== 200) { + throw new Error(`Thread summary returned HTTP ${response.statusCode}.`); + } + return readThreadSummaries(response.body); +} function startRuntimeTerminal(config) { const terminal = vscode.window.createTerminal("CodeWhale Runtime"); const args = [ @@ -152,6 +164,36 @@ function readVersion(value) { const version = value.version; return typeof version === "string" ? version : undefined; } +function readThreadSummaries(value) { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((item) => { + if (!item || typeof item !== "object") { + return []; + } + const record = item; + const id = readString(record.id); + if (!id) { + return []; + } + return [ + { + id, + title: readString(record.title) ?? "New Thread", + preview: readString(record.preview) ?? "", + model: readString(record.model) ?? "unknown", + mode: readString(record.mode) ?? "agent", + archived: record.archived === true, + updatedAt: readString(record.updated_at) ?? "", + latestTurnStatus: readString(record.latest_turn_status), + }, + ]; + }); +} +function readString(value) { + return typeof value === "string" ? value : undefined; +} function shellQuote(value) { if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) { return value; diff --git a/extensions/vscode/out/runtime.js.map b/extensions/vscode/out/runtime.js.map index 8288d0d73..1a65b5467 100644 --- a/extensions/vscode/out/runtime.js.map +++ b/extensions/vscode/out/runtime.js.map @@ -1 +1 @@ -{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmBA,8CAYC;AAED,wCAEC;AAED,oCA6BC;AAED,oDAgBC;AAED,sDAKC;AA3FD,gDAAkC;AAClC,+CAAiC;AAkBjC,SAAgB,iBAAiB;IAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC;IAC9D,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,IAAI,WAAW,CAAC;IACzF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,IAAI,WAAW,CAAC;IAClF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,IAAI,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAS,cAAc,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5D,OAAO;QACL,WAAW;QACX,IAAI;QACJ,IAAI;QACJ,KAAK,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;KAC5C,CAAC;AACJ,CAAC;AAED,SAAgB,cAAc,CAAC,MAAqB;IAClD,OAAO,UAAU,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;AAChD,CAAC;AAEM,KAAK,UAAU,YAAY,CAAC,MAAqB;IACtD,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,GAAG,OAAO,SAAS,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IACpE,IAAI,MAAM,CAAC,UAAU,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;IAC3E,CAAC;IACD,IAAI,MAAM,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC9B,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;IACjF,CAAC;IACD,IAAI,MAAM,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC9B,OAAO;YACL,IAAI,EAAE,OAAO;YACb,OAAO;YACP,MAAM,EAAE,8BAA8B,MAAM,CAAC,UAAU,GAAG;SAC3D,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,GAAG,OAAO,kBAAkB,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3E,IAAI,IAAI,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,gCAAgC,EAAE,CAAC;IACtF,CAAC;IAED,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvC,OAAO;QACL,IAAI,EAAE,WAAW;QACjB,OAAO;QACP,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,0BAA0B,OAAO,GAAG,CAAC,CAAC,CAAC,iCAAiC;QAC1F,OAAO;KACR,CAAC;AACJ,CAAC;AAED,SAAgB,oBAAoB,CAAC,MAAqB;IACxD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;IACnE,MAAM,IAAI,GAAG;QACX,OAAO;QACP,QAAQ;QACR,QAAQ;QACR,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC;QACvB,QAAQ;QACR,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;KACpB,CAAC;IACF,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACtD,CAAC;IACD,QAAQ,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACzE,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAgB,qBAAqB,CAAC,MAAqB;IACzD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;IAC3D,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC;IAClD,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,WAAW,CACxB,GAAW,EACX,KAAyB;IAEzB,IAAI,CAAC;QACH,OAAO,MAAM,IAAI,OAAO,CAAwC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAClF,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CACtB,GAAG,EACH;gBACE,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS;aAClE,EACD,CAAC,QAAQ,EAAE,EAAE;gBACX,IAAI,IAAI,GAAG,EAAE,CAAC;gBACd,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBAC7B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;oBACpC,IAAI,IAAI,KAAK,CAAC;gBAChB,CAAC,CAAC,CAAC;gBACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;oBACtB,OAAO,CAAC;wBACN,UAAU,EAAE,QAAQ,CAAC,UAAU,IAAI,CAAC;wBACpC,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC;qBACtB,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CACF,CAAC;YAEF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;gBACzB,OAAO,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC,CAAC;YACzD,CAAC,CAAC,CAAC;YACH,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACxB,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,OAAO,EAAE,UAAU,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC;IACpD,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACxC,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAAI,KAA+B,CAAC,OAAO,CAAC;IACzD,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,IAAI,uBAAuB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACxC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC;AAC7C,CAAC"} \ No newline at end of file +{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,8CAYC;AAED,wCAEC;AAED,oCA6BC;AAED,kDAkBC;AAED,oDAgBC;AAED,sDAKC;AA1HD,gDAAkC;AAClC,+CAAiC;AA6BjC,SAAgB,iBAAiB;IAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,WAAW,CAAC,CAAC;IAC9D,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,IAAI,WAAW,CAAC;IACzF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,IAAI,WAAW,CAAC;IAClF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAS,aAAa,EAAE,IAAI,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAS,cAAc,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5D,OAAO;QACL,WAAW;QACX,IAAI;QACJ,IAAI;QACJ,KAAK,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;KAC5C,CAAC;AACJ,CAAC;AAED,SAAgB,cAAc,CAAC,MAAqB;IAClD,OAAO,UAAU,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC;AAChD,CAAC;AAEM,KAAK,UAAU,YAAY,CAAC,MAAqB;IACtD,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,GAAG,OAAO,SAAS,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IACpE,IAAI,MAAM,CAAC,UAAU,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;IAC3E,CAAC;IACD,IAAI,MAAM,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC9B,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,2BAA2B,EAAE,CAAC;IACjF,CAAC;IACD,IAAI,MAAM,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC9B,OAAO;YACL,IAAI,EAAE,OAAO;YACb,OAAO;YACP,MAAM,EAAE,8BAA8B,MAAM,CAAC,UAAU,GAAG;SAC3D,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,GAAG,OAAO,kBAAkB,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3E,IAAI,IAAI,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,gCAAgC,EAAE,CAAC;IACtF,CAAC;IAED,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvC,OAAO;QACL,IAAI,EAAE,WAAW;QACjB,OAAO;QACP,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,0BAA0B,OAAO,GAAG,CAAC,CAAC,CAAC,iCAAiC;QAC1F,OAAO;KACR,CAAC;AACJ,CAAC;AAEM,KAAK,UAAU,mBAAmB,CACvC,MAAqB,EACrB,KAAK,GAAG,CAAC;IAET,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACvC,MAAM,QAAQ,GAAG,MAAM,WAAW,CAChC,GAAG,OAAO,6BAA6B,kBAAkB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAC1E,MAAM,CAAC,KAAK,CACb,CAAC;IAEF,IAAI,QAAQ,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IACD,IAAI,QAAQ,CAAC,UAAU,KAAK,GAAG,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,gCAAgC,QAAQ,CAAC,UAAU,GAAG,CAAC,CAAC;IAC1E,CAAC;IAED,OAAO,mBAAmB,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AAC5C,CAAC;AAED,SAAgB,oBAAoB,CAAC,MAAqB;IACxD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;IACnE,MAAM,IAAI,GAAG;QACX,OAAO;QACP,QAAQ;QACR,QAAQ;QACR,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC;QACvB,QAAQ;QACR,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;KACpB,CAAC;IACF,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACtD,CAAC;IACD,QAAQ,CAAC,QAAQ,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACzE,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAgB,qBAAqB,CAAC,MAAqB;IACzD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC;IAC3D,QAAQ,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC;IAClD,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,WAAW,CACxB,GAAW,EACX,KAAyB;IAEzB,IAAI,CAAC;QACH,OAAO,MAAM,IAAI,OAAO,CAAwC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAClF,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CACtB,GAAG,EACH;gBACE,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS;aAClE,EACD,CAAC,QAAQ,EAAE,EAAE;gBACX,IAAI,IAAI,GAAG,EAAE,CAAC;gBACd,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBAC7B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;oBACpC,IAAI,IAAI,KAAK,CAAC;gBAChB,CAAC,CAAC,CAAC;gBACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;oBACtB,OAAO,CAAC;wBACN,UAAU,EAAE,QAAQ,CAAC,UAAU,IAAI,CAAC;wBACpC,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC;qBACtB,CAAC,CAAC;gBACL,CAAC,CAAC,CAAC;YACL,CAAC,CACF,CAAC;YAEF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;gBACzB,OAAO,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC,CAAC;YACzD,CAAC,CAAC,CAAC;YACH,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC9B,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAc,EAAE,CAAC;QACxB,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,OAAO,EAAE,UAAU,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC;IACpD,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACxC,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAAI,KAA+B,CAAC,OAAO,CAAC;IACzD,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC;AAED,SAAS,mBAAmB,CAAC,KAAc;IACzC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;QAC5B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,MAAM,MAAM,GAAG,IAA+B,CAAC;QAC/C,MAAM,EAAE,GAAG,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACjC,IAAI,CAAC,EAAE,EAAE,CAAC;YACR,OAAO,EAAE,CAAC;QACZ,CAAC;QAED,OAAO;YACL;gBACE,EAAE;gBACF,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,YAAY;gBAC/C,OAAO,EAAE,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE;gBACzC,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,SAAS;gBAC5C,IAAI,EAAE,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,OAAO;gBACxC,QAAQ,EAAE,MAAM,CAAC,QAAQ,KAAK,IAAI;gBAClC,SAAS,EAAE,UAAU,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE;gBAC9C,gBAAgB,EAAE,UAAU,CAAC,MAAM,CAAC,kBAAkB,CAAC;aACxD;SACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,UAAU,CAAC,KAAc;IAChC,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACvD,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,IAAI,uBAAuB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACxC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC;AAC7C,CAAC"} \ No newline at end of file diff --git a/extensions/vscode/out/status.js b/extensions/vscode/out/status.js index 3100eb386..fcf8985de 100644 --- a/extensions/vscode/out/status.js +++ b/extensions/vscode/out/status.js @@ -43,6 +43,8 @@ class RuntimeStatusView { baseUrl: "http://127.0.0.1:7878", detail: "Runtime has not been checked yet.", }; + threads = []; + threadsDetail = "Connect to the runtime to load recent threads."; resolveWebviewView(view) { this.view = view; view.webview.options = { enableScripts: true }; @@ -56,6 +58,9 @@ class RuntimeStatusView { else if (message.command === "terminal") { void vscode.commands.executeCommand("codewhale.openTerminal"); } + else if (message.command === "threads") { + void vscode.commands.executeCommand("codewhale.refreshAgentView"); + } }); this.render(); } @@ -63,12 +68,20 @@ class RuntimeStatusView { this.state = state; this.render(); } + updateThreads(threads, detail) { + this.threads = threads; + this.threadsDetail = detail; + this.render(); + } render() { if (!this.view) { return; } const badge = labelFor(this.state.kind); const nonce = makeNonce(); + const threadsHtml = this.threads.length > 0 + ? this.threads.map((thread) => renderThread(thread)).join("") + : `

${escapeHtml(this.threadsDetail)}

`; this.view.webview.html = ` @@ -79,6 +92,11 @@ class RuntimeStatusView { body { padding: 14px; color: var(--vscode-foreground); font-family: var(--vscode-font-family); } .status { margin-bottom: 12px; font-weight: 600; } .detail { margin: 0 0 14px; color: var(--vscode-descriptionForeground); line-height: 1.45; } + .section-title { margin: 18px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; color: var(--vscode-descriptionForeground); } + .thread { padding: 8px 0; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); } + .thread-title { margin-bottom: 4px; font-weight: 600; overflow-wrap: anywhere; } + .thread-preview { margin-bottom: 5px; color: var(--vscode-descriptionForeground); line-height: 1.35; overflow-wrap: anywhere; } + .thread-meta { color: var(--vscode-descriptionForeground); font-size: 11px; overflow-wrap: anywhere; } code { color: var(--vscode-textLink-foreground); } button { width: 100%; margin: 4px 0; } @@ -88,8 +106,11 @@ class RuntimeStatusView {

${escapeHtml(this.state.detail)}

${escapeHtml(this.state.baseUrl)}

+ +
Agent View
+ ${threadsHtml}