diff --git a/.gitignore b/.gitignore index 0668130d..50c41e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ apps/ # Maintainer-local SWE-bench scratch (instance workspaces, venvs, predictions, # Docker harness logs). Never published. .swebench/ +deep-swe/ +all_preds.jsonl # Agent handoffs and version-specific setup plans are working-state notes, not # public docs. Keep durable setup guidance in docs/runbooks instead. @@ -111,3 +113,4 @@ docs/*_PLAN.md # direnv .envrc .direnv +scripts/run_deep_swe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c266c89..924e4f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). + +### Changed + +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths. + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). + +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). + ## [0.8.46] - 2026-05-26 ### Added diff --git a/README.ja-JP.md b/README.ja-JP.md index 813cefea..667aafb5 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -33,7 +33,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -118,12 +118,12 @@ codewhale --model auto ビルド済みバイナリペアとプラットフォームアーカイブは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 -初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.deepseek/config.toml` に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 +初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 事前に設定することもできます: ```bash -codewhale auth set --provider deepseek # ~/.deepseek/config.toml に保存 +codewhale auth set --provider deepseek # ~/.codewhale/config.toml に保存 export DEEPSEEK_API_KEY="YOUR_KEY" # 環境変数による代替方法。非対話シェルでは ~/.zshenv を使用 codewhale @@ -308,7 +308,7 @@ codewhale update # バイナリ更新の確認 ## 設定 -ユーザー設定: `~/.deepseek/config.toml`。プロジェクトオーバーレイ: `/.deepseek/config.toml`(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 +ユーザー設定: `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)。プロジェクトオーバーレイ: `/.codewhale/config.toml`(旧 `/.deepseek/config.toml`)(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 主な環境変数: @@ -359,10 +359,10 @@ UI のロケールはモデルの言語とは別です。`settings.toml` で `lo ## 自分のスキルを公開する -codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.deepseek/skills` からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: +codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.codewhale/skills`(旧 `~/.deepseek/skills` も互換性維持)からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` diff --git a/README.md b/README.md index 098e5067..3213ee15 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -176,12 +176,12 @@ codewhale --model auto 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). -On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.deepseek/config.toml` so it works from any directory without OS credential prompts. +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. You can also set it ahead of time: ```bash -codewhale auth set --provider deepseek # saves to ~/.deepseek/config.toml +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 @@ -400,7 +400,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -466,7 +466,7 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). ## Configuration -User config: `~/.deepseek/config.toml`. Project overlay: `/.deepseek/config.toml` (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. +User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. Key environment variables: @@ -519,7 +519,7 @@ Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` ## Publishing Your Own Skill -codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: +codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: ```text ~/.agents/skills/my-skill/ @@ -544,7 +544,7 @@ First launch also installs bundled system skills for common workflows: `skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`, `skill-installer`, `mcp-builder`, `documents`, `presentations`, `spreadsheets`, `pdf`, and `feishu`. These live under -`~/.deepseek/skills` and are versioned so new bundles are added on upgrade +`~/.codewhale/skills` (or legacy `~/.deepseek/skills`) and are versioned so new bundles are added on upgrade without recreating skills the user deliberately deleted. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 314b5955..f079cc80 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -136,12 +136,12 @@ codewhale --model auto 预构建二进制对和平台压缩包覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 -首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.deepseek/config.toml`,在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 +首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.codewhale/config.toml`(同时兼容旧版 `~/.deepseek/config.toml`),在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 也可以提前配置: ```bash -codewhale auth set --provider deepseek # 保存到 ~/.deepseek/config.toml +codewhale auth set --provider deepseek # 保存到 ~/.codewhale/config.toml codewhale auth status # 显示当前活跃的凭证来源 export DEEPSEEK_API_KEY="YOUR_KEY" # 环境变量方式;需要在非交互式 shell 中使用请放入 ~/.zshenv @@ -331,7 +331,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -389,7 +389,7 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 ## 配置 -用户配置:`~/.deepseek/config.toml`。项目覆盖:`/.deepseek/config.toml`(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 +用户配置:`~/.codewhale/config.toml`(兼容旧版 `~/.deepseek/config.toml`)。项目覆盖:`/.codewhale/config.toml`(兼容 `/.deepseek/config.toml`)(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 常用环境变量: @@ -431,10 +431,10 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 可选语言:`auto` | `en` | `ja` | `zh-Hans` | `pt-BR`。 -也可以在 `~/.deepseek/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: +也可以在 `~/.codewhale/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: ```toml -# ~/.deepseek/config.toml +# ~/.codewhale/config.toml [tui] locale = "zh-Hans" ``` @@ -463,10 +463,10 @@ LANG=zh_CN.UTF-8 codewhale run ## 创建和安装技能 -codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.deepseek/skills` 发现技能。每个技能是一个包含 `SKILL.md` 的目录: +codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.codewhale/skills`(兼容旧版 `~/.deepseek/skills`)发现技能。每个技能是一个包含 `SKILL.md` 的目录: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 9205c899..2ab35ef1 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -15,8 +15,12 @@ const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; const RELEASES_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100"; const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; -const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; -const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL"; +const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; +const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR"; +/// Base URL for CNB binary release asset downloads (China-friendly mirror). +const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases"; const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; @@ -347,8 +351,8 @@ fn update_http_client() -> Result { /// Fetch the latest release metadata from GitHub. fn fetch_latest_release(channel: ReleaseChannel) -> Result { - if let Some(base_url) = release_base_url_from_env() { - let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + if let Some(base_url) = release_base_url_from_env(&version) { return Ok(FetchedRelease { release: release_from_mirror_base_url( &base_url, @@ -369,12 +373,33 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { }) } -fn release_base_url_from_env() -> Option { - std::env::var(RELEASE_BASE_URL_ENV) - .ok() - .or_else(|| std::env::var(LEGACY_RELEASE_BASE_URL_ENV).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) +fn release_base_url_from_env(version: &str) -> Option { + // Check canonical env first, then legacy envs + for env_name in [ + RELEASE_BASE_URL_ENV, + LEGACY_RELEASE_BASE_URL_ENV, + DEEPSEEK_RELEASE_BASE_URL_ENV, + ] { + if let Ok(value) = std::env::var(env_name) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + // Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set + if std::env::var(CNB_MIRROR_ENV).is_ok() { + return Some(cnb_release_base_url(version)); + } + None +} + +fn cnb_release_base_url(version: &str) -> String { + format!( + "{}/v{}", + CNB_RELEASE_ASSET_BASE.trim_end_matches('/'), + version.trim_start_matches('v') + ) } fn update_version_from_env() -> Option { @@ -976,6 +1001,18 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ); } + #[test] + fn cnb_release_base_url_includes_tag_directory() { + assert_eq!( + cnb_release_base_url("0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + assert_eq!( + cnb_release_base_url("v0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + } + #[test] fn stable_update_is_needed_only_when_latest_is_newer() { assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap()); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9ee6e2fd..bb2c339b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1618,6 +1618,30 @@ pub fn ensure_state_dir(subdir: &str) -> Result { Ok(dir) } +/// Resolve a project-local state subdirectory, preferring `.codewhale/` +/// when it exists, falling back to `.deepseek/` for legacy projects. +/// +/// Returns `(true, path)` when the primary `.codewhale/` path is used, +/// `(false, path)` for the legacy fallback. The boolean helps callers +/// emit a deprecation notice on legacy paths. +pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) { + let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir); + if primary.exists() { + return (true, primary); + } + let legacy = workspace.join(LEGACY_APP_DIR).join(subdir); + (false, legacy) +} + +/// Ensure a project-local state subdirectory exists under `.codewhale/`, +/// creating it if necessary. Returns the directory path. +pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result { + let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir); + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}/", dir.display()))?; + Ok(dir) +} + pub fn resolve_config_path(explicit: Option) -> Result { let path = if let Some(path) = explicit { path diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a7179410..b0ffc55b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,7 +8,11 @@ use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::RwLock; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; + +tokio::task_local! { + static TOOL_EXECUTION_LOCK_HELD: (); +} /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -309,9 +313,40 @@ pub trait ToolHandler: Send + Sync { ) -> std::result::Result; } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ToolCallRuntime { - pub parallel_execution: Arc>, + /// Preserve read/write tool execution semantics: parallel-safe tools may + /// overlap, while serial tools run exclusively. + execution_lock: Arc>, +} + +impl Default for ToolCallRuntime { + fn default() -> Self { + Self { + execution_lock: Arc::new(RwLock::new(())), + } + } +} + +#[derive(Debug)] +enum ToolExecutionGuard { + Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>), + Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>), + Reentrant, +} + +impl ToolCallRuntime { + async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard { + if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() { + return ToolExecutionGuard::Reentrant; + } + + if supports_parallel { + ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await) + } else { + ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await) + } + } } #[derive(Default)] @@ -379,15 +414,17 @@ impl ToolRegistry { source: call.source, }; - if configured.supports_parallel_tool_calls { - let _guard = self.runtime.parallel_execution.read().await; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } else { - let _guard = self.runtime.parallel_execution.write().await; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } + let _guard = self + .runtime + .acquire(configured.supports_parallel_tool_calls) + .await; + + TOOL_EXECUTION_LOCK_HELD + .scope( + (), + self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation), + ) + .await } async fn execute_with_timeout( diff --git a/crates/tools/tests/parity_tools.rs b/crates/tools/tests/parity_tools.rs index fb08753b..ef525ba4 100644 --- a/crates/tools/tests/parity_tools.rs +++ b/crates/tools/tests/parity_tools.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; @@ -6,6 +7,7 @@ use codewhale_tools::{ ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec, }; use serde_json::json; +use tokio::sync::Notify; struct EchoHandler; @@ -33,6 +35,64 @@ impl ToolHandler for EchoHandler { } } +struct BlockingHandler { + started: Arc, + release: Arc, +} + +#[async_trait] +impl ToolHandler for BlockingHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> std::result::Result { + self.started.notify_waiters(); + self.release.notified().await; + Ok(ToolOutput::Function { + body: Some(json!({ + "tool": invocation.tool_name, + "call_id": invocation.call_id + })), + success: true, + }) + } +} + +struct ReentrantHandler { + registry: Arc>>, +} + +#[async_trait] +impl ToolHandler for ReentrantHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> std::result::Result { + let registry = self.registry.get().expect("registry initialized").clone(); + registry + .dispatch( + ToolCall { + name: "inner".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("inner-call".to_string()), + }, + true, + ) + .await + } +} + #[tokio::test] async fn dispatches_function_tool_with_parallel_flag() { let mut registry = ToolRegistry::default(); @@ -68,3 +128,149 @@ async fn dispatches_function_tool_with_parallel_flag() { other => panic!("unexpected output: {other:?}"), } } + +#[tokio::test] +async fn serial_tool_waits_for_running_parallel_tool() { + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "slow_read".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: true, + timeout_ms: Some(1000), + }, + Arc::new(BlockingHandler { + started: started.clone(), + release: release.clone(), + }), + ) + .expect("register slow read"); + registry + .register( + ToolSpec { + name: "serial".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register serial"); + + let registry = Arc::new(registry); + let started_wait = started.notified(); + let parallel_registry = registry.clone(); + let parallel = tokio::spawn(async move { + parallel_registry + .dispatch( + ToolCall { + name: "slow_read".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("parallel-call".to_string()), + }, + true, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), started_wait) + .await + .expect("parallel tool started"); + + let serial_registry = registry.clone(); + let mut serial = tokio::spawn(async move { + serial_registry + .dispatch( + ToolCall { + name: "serial".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("serial-call".to_string()), + }, + true, + ) + .await + }); + + tokio::select! { + _ = &mut serial => panic!("serial tool overlapped a running parallel tool"), + () = tokio::time::sleep(Duration::from_millis(50)) => {} + } + + release.notify_waiters(); + serial + .await + .expect("serial task panicked") + .expect("serial ran"); + parallel + .await + .expect("parallel task panicked") + .expect("parallel ran"); +} + +#[tokio::test] +async fn serial_tool_can_reenter_registry_without_deadlock() { + let registry_cell = Arc::new(OnceLock::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "outer".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(ReentrantHandler { + registry: registry_cell.clone(), + }), + ) + .expect("register outer"); + registry + .register( + ToolSpec { + name: "inner".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register inner"); + + let registry = Arc::new(registry); + assert!(registry_cell.set(registry.clone()).is_ok()); + + let output = tokio::time::timeout( + Duration::from_secs(1), + registry.dispatch( + ToolCall { + name: "outer".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("outer-call".to_string()), + }, + true, + ), + ) + .await + .expect("outer dispatch timed out") + .expect("outer dispatch failed"); + + match output { + ToolOutput::Function { success, .. } => assert!(success), + other => panic!("unexpected output: {other:?}"), + } +} diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6c266c89..924e4f2f 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). + +### Changed + +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths. + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). + +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). + ## [0.8.46] - 2026-05-26 ### Added diff --git a/crates/tui/src/audit.rs b/crates/tui/src/audit.rs index 60b49c63..2638131d 100644 --- a/crates/tui/src/audit.rs +++ b/crates/tui/src/audit.rs @@ -41,5 +41,5 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> { fn default_audit_path() -> anyhow::Result { let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?; - Ok(home.join(".deepseek").join("audit.log")) + Ok(home.join(".codewhale").join("audit.log")) } diff --git a/crates/tui/src/automation_manager.rs b/crates/tui/src/automation_manager.rs index c98dc7e8..79bc8765 100644 --- a/crates/tui/src/automation_manager.rs +++ b/crates/tui/src/automation_manager.rs @@ -795,8 +795,15 @@ pub fn default_automations_dir() -> PathBuf { } } dirs::home_dir() - .map(|home| home.join(".deepseek").join("automations")) - .unwrap_or_else(|| PathBuf::from(".deepseek").join("automations")) + .map(|home| { + let primary = home.join(".codewhale").join("automations"); + if primary.exists() { + primary + } else { + home.join(".deepseek").join("automations") + } + }) + .unwrap_or_else(|| PathBuf::from(".codewhale").join("automations")) } pub type SharedAutomationManager = Arc>; diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/anchor.rs index fb15fb33..7ba66d7a 100644 --- a/crates/tui/src/commands/anchor.rs +++ b/crates/tui/src/commands/anchor.rs @@ -47,6 +47,10 @@ pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { } fn anchors_path(app: &App) -> std::path::PathBuf { + let primary = app.workspace.join(".codewhale").join("anchors.md"); + if primary.exists() { + return primary; + } app.workspace.join(".deepseek").join("anchors.md") } diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..651b4d5d 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -5,7 +5,9 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; -use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name_for_provider}; +use crate::config::{ + COMMON_DEEPSEEK_MODELS, Config, clear_api_key, expand_path, normalize_model_name_for_provider, +}; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; use crate::localization::resolve_locale; @@ -122,6 +124,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } } "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), + "base_url" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(config) => config, + Err(err) => { + return CommandResult::error(format!("Failed to load config: {err}")); + } + }; + Some(config.deepseek_base_url()) + } "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()) @@ -284,7 +296,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu use anyhow::Context; use std::fs; - let path = config_toml_path()?; + let path = config_toml_path(None)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config directory {}", parent.display()))?; @@ -320,11 +332,15 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu Ok(path) } -pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { +pub fn persist_root_string_key( + config_path: Option<&Path>, + key: &str, + value: &str, +) -> anyhow::Result { use anyhow::Context; use std::fs; - let path = config_toml_path()?; + 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()))?; @@ -351,8 +367,11 @@ pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result anyhow::Result { +pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { use anyhow::Context; + if let Some(path) = config_path { + return Ok(expand_path(path.to_string_lossy().as_ref())); + } if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = env.trim(); if !trimmed.is_empty() { @@ -360,6 +379,10 @@ pub(super) fn config_toml_path() -> anyhow::Result { } } let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?; + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return Ok(primary); + } Ok(home.join(".deepseek").join("config.toml")) } @@ -417,7 +440,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.mcp_config_path = PathBuf::from(expand_tilde(value)); app.mcp_restart_required = true; let message = if persist { - match persist_root_string_key("mcp_config_path", value) { + match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value) + { Ok(path) => format!( "mcp_config_path = {} (saved to {}; restart required for MCP tool pool)", app.mcp_config_path.display(), @@ -433,6 +457,26 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }; return CommandResult::message(message); } + "base_url" => { + let value = value.trim(); + if value.is_empty() { + return CommandResult::error("base_url cannot be empty"); + } + if persist { + match persist_root_string_key(app.config_path.as_deref(), "base_url", value) { + Ok(path) => { + return CommandResult::message(format!( + "base_url = {value} (saved to {})", + path.display() + )); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::error(format!( + "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." + )); + } _ => {} } @@ -1750,6 +1794,134 @@ mod tests { assert!(saved.contains("cost_currency = \"cny\"")); } + #[test] + fn config_command_base_url_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command( + &mut app, + Some("base_url https://example.internal.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved_path = config_toml_path(None).unwrap(); + let saved = fs::read_to_string(&saved_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.internal.local/v1 (saved to {})", + saved_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.internal.local/v1\"")); + } + + #[test] + fn config_command_base_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url https://example.internal.local/v1")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("base_url must be saved with --save"), + "got {msg}" + ); + } + + #[test] + fn config_command_base_url_reads_current_value_from_config() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-show-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "base_url = \"https://api.from-config.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-config.local/v1"); + } + + #[test] + fn config_command_base_url_reads_current_value_from_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-app-config-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + "base_url = \"https://api.from-app-path.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-app-path.local/v1"); + } + + #[test] + fn config_command_base_url_save_persists_to_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + 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("base_url https://example.session.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.session.local/v1 (saved to {})", + config_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); + } + #[test] fn theme_command_accepts_grayscale_arg() { let nanos = SystemTime::now() diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index b1e9f3dd..f21df395 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -702,8 +702,12 @@ pub fn persist_status_items( } /// Persist a root-level string key in `config.toml`. -pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { - config::persist_root_string_key(key, value) +pub fn persist_root_string_key( + config_path: Option<&std::path::Path>, + key: &str, + value: &str, +) -> anyhow::Result { + config::persist_root_string_key(config_path, key, value) } pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs index 563ded91..dbe0e7af 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/network.rs @@ -70,7 +70,7 @@ enum NetworkEdit { } fn list_policy() -> anyhow::Result { - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let doc = load_config_doc(&path)?; let network = doc.get("network").and_then(Value::as_table); let default = network @@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result { } fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result { - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; @@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result { _ => bail!("Usage: /network default "), }; - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; network.insert("default".to_string(), Value::String(normalized.to_string())); diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/note.rs index 8aa1267f..6efe4413 100644 --- a/crates/tui/src/commands/note.rs +++ b/crates/tui/src/commands/note.rs @@ -39,6 +39,10 @@ pub fn note(app: &mut App, content: Option<&str>) -> CommandResult { } fn notes_path(app: &App) -> PathBuf { + let primary = app.workspace.join(".codewhale").join("notes.md"); + if primary.exists() { + return primary; + } app.workspace.join(".deepseek").join("notes.md") } diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 6bd50285..a54c4403 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -490,22 +490,36 @@ mod tests { } #[test] - fn test_save_with_default_path_uses_workspace() { + fn test_save_with_default_path_uses_managed_sessions_dir() { let tmpdir = TempDir::new().unwrap(); + // Set CODEWHALE_HOME so the managed sessions directory lands inside the + // temp dir rather than the real user home. Pre-create the directory so + // resolve_state_dir picks it up instead of falling back to legacy. + let home = tmpdir.path().join("home"); + let sessions_dir = home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + // SAFETY: test-only, single-threaded via cargo test + unsafe { std::env::set_var("CODEWHALE_HOME", home.to_str().unwrap()) }; let mut app = create_test_app_with_tmpdir(&tmpdir); let result = save(&mut app, None); assert!(result.message.is_some()); let msg = result.message.unwrap(); - // Should create file in workspace with timestamp name // Give it a moment to ensure file is written std::thread::sleep(std::time::Duration::from_millis(10)); - let entries: Vec<_> = std::fs::read_dir(tmpdir.path()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) - .collect(); - // Test passes if file was created or if save returned success message - assert!(!entries.is_empty() || msg.contains("Session saved")); + let entries: Vec<_> = if sessions_dir.exists() { + std::fs::read_dir(&sessions_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) + .collect() + } else { + Vec::new() + }; + // Session should be saved to the managed dir, not the workspace root. + assert!( + !entries.is_empty(), + "expected session file in {sessions_dir:?}, got none; msg: {msg}" + ); } #[test] diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index b1823d5f..a8a4997f 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -441,7 +441,7 @@ fn sync_skills(app: &mut App) -> CommandResult { } SkillSyncOutcome::Denied { name, host } => { failed += 1; - let _ = writeln!(out, " [x] {name} — network denied ({host})"); + let _ = writeln!(out, " [!] {name} — network denied ({host})"); } SkillSyncOutcome::NeedsApproval { name, host } => { failed += 1; diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 460eb9e0..4048524d 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -1032,7 +1032,13 @@ fn read_workspace_anchors(workspace: Option<&Path>) -> Vec { return Vec::new(); }; - let anchors_path = ws.join(".deepseek").join("anchors.md"); + // Prefer .codewhale, fall back to .deepseek + let primary = ws.join(".codewhale").join("anchors.md"); + let anchors_path = if primary.exists() { + primary + } else { + ws.join(".deepseek").join("anchors.md") + }; let Ok(content) = std::fs::read_to_string(anchors_path) else { return Vec::new(); }; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index d93faa06..282d2023 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2200,7 +2200,13 @@ pub(crate) fn effective_home_dir() -> Option { } fn home_config_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("config.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("config.toml") + }) } #[must_use] @@ -2363,7 +2369,13 @@ fn default_managed_config_path() -> Option { } #[cfg(not(unix))] { - effective_home_dir().map(|home| home.join(".deepseek").join("managed_config.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("managed_config.toml"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("managed_config.toml") + }) } } @@ -2374,7 +2386,13 @@ fn default_requirements_path() -> Option { } #[cfg(not(unix))] { - effective_home_dir().map(|home| home.join(".deepseek").join("requirements.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("requirements.toml"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("requirements.toml") + }) } } @@ -2399,15 +2417,33 @@ fn default_skills_dir() -> Option { } fn default_mcp_config_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("mcp.json")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("mcp.json"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("mcp.json") + }) } fn default_notes_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("notes.txt")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("notes.txt"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("notes.txt") + }) } fn default_memory_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("memory.md")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("memory.md"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("memory.md") + }) } // === Environment Overrides === diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 7f3b5801..9cf8ecd2 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -687,7 +687,11 @@ fn apply_reasoning_effort( app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); if persist { - commands::persist_root_string_key("reasoning_effort", effort.as_setting())?; + commands::persist_root_string_key( + app.config_path.as_deref(), + "reasoning_effort", + effort.as_setting(), + )?; } config.reasoning_effort = Some(effort.as_setting().to_string()); Ok(()) diff --git a/crates/tui/src/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs index f41bd48a..0d22e4df 100644 --- a/crates/tui/src/core/capacity_memory.rs +++ b/crates/tui/src/core/capacity_memory.rs @@ -56,14 +56,20 @@ fn capacity_memory_dirs() -> Vec { let mut dirs = Vec::new(); if let Some(home) = dirs::home_dir() { + // Prefer .codewhale, fall back to .deepseek + let primary = home.join(".codewhale").join("memory"); + if primary.exists() { + dirs.push(primary); + } dirs.push(home.join(".deepseek").join("memory")); } - let cwd = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".deepseek") - .join("memory"); - dirs.push(cwd); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let primary_cwd = cwd.join(".codewhale").join("memory"); + if primary_cwd.exists() { + dirs.push(primary_cwd); + } + dirs.push(cwd.join(".deepseek").join("memory")); dirs.dedup(); dirs diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index c699a0a8..65b194ce 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -32,6 +32,7 @@ pub(super) fn is_tool_search_tool(name: &str) -> bool { pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "agent_open", "apply_patch", + "checklist_write", "edit_file", "exec_shell", "fetch_url", @@ -42,6 +43,10 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "list_dir", "read_file", "run_tests", + "task_create", + "task_list", + "task_read", + "update_plan", "web_search", "write_file", ]; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c0003910..f9df1795 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1960,7 +1960,9 @@ impl Engine { if let Some(message) = loop_guard_halt { crate::logging::warn(message.clone()); - let _ = self.tx_event.send(Event::status(message)).await; + let _ = self.tx_event.send(Event::status(message.clone())).await; + // 设置 turn_error 以确保最终返回 TurnOutcomeStatus::Failed 而非 Completed + turn_error = Some(message); break; } diff --git a/crates/tui/src/cycle_manager.rs b/crates/tui/src/cycle_manager.rs index cfbe2a17..c7315053 100644 --- a/crates/tui/src/cycle_manager.rs +++ b/crates/tui/src/cycle_manager.rs @@ -284,7 +284,7 @@ impl StructuredState { let marker = match item.status { crate::tools::todo::TodoStatus::Pending => "[ ]", crate::tools::todo::TodoStatus::InProgress => "[~]", - crate::tools::todo::TodoStatus::Completed => "[x]", + crate::tools::todo::TodoStatus::Completed => "[✓]", }; out.push_str(&format!("- {marker} {}\n", item.content)); } @@ -299,7 +299,7 @@ impl StructuredState { let marker = match item.status { crate::tools::plan::StepStatus::Pending => "[ ]", crate::tools::plan::StepStatus::InProgress => "[~]", - crate::tools::plan::StepStatus::Completed => "[x]", + crate::tools::plan::StepStatus::Completed => "[✓]", }; out.push_str(&format!("- {marker} {}\n", item.step)); } @@ -463,14 +463,16 @@ pub struct CycleArchiveHeader { pub message_count: usize, } -/// Resolve the on-disk archive directory: `~/.deepseek/sessions//cycles`. +/// Resolve the on-disk archive directory: `~/.codewhale/sessions//cycles` +/// (or legacy `~/.deepseek/sessions//cycles`). fn archive_dir_for(session_id: &str) -> Result { - let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?; - Ok(home - .join(".deepseek") - .join("sessions") - .join(session_id) - .join("cycles")) + let sessions = codewhale_config::resolve_state_dir("sessions").unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".deepseek") + .join("sessions") + }); + Ok(sessions.join(session_id).join("cycles")) } /// Archive a cycle's messages to JSONL on disk and return the path written. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f2ab6af6..99579eac 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -842,8 +842,12 @@ async fn main() -> Result<()> { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; + // The `deepseek` launcher forwards `--yolo` to this binary via + // the DEEPSEEK_YOLO env var (which the config loader folds into + // `config.yolo`), not as a CLI flag. Honour either source. + let yolo = cli.yolo || config.yolo.unwrap_or(false); let needs_engine = args.auto - || cli.yolo + || yolo || resume_session_id.is_some() || args.output_format == ExecOutputFormat::StreamJson; if needs_engine { @@ -851,7 +855,7 @@ async fn main() -> Result<()> { || config.max_subagents(), |value| value.clamp(1, MAX_SUBAGENTS), ); - let auto_mode = args.auto || cli.yolo; + let auto_mode = args.auto || yolo; run_exec_agent( &config, &model, @@ -4872,6 +4876,10 @@ async fn run_interactive( let _ = manager.cleanup_old_sessions(); } + // The `deepseek` launcher forwards `--yolo` to this binary via the + // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either. + let yolo = cli.yolo || config.yolo.unwrap_or(false); + tui::run_tui( config, tui::TuiOptions { @@ -4879,7 +4887,7 @@ async fn run_interactive( workspace, config_path: cli.config.clone(), config_profile: cli.profile.clone(), - allow_shell: cli.yolo || config.allow_shell(), + allow_shell: yolo || config.allow_shell(), use_alt_screen, use_mouse_capture, use_bracketed_paste, @@ -4888,9 +4896,9 @@ async fn run_interactive( notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), use_memory: config.memory_enabled(), - start_in_agent_mode: cli.yolo, + start_in_agent_mode: yolo, skip_onboarding: cli.skip_onboarding, - yolo: cli.yolo, // YOLO mode auto-approves all tool executions + yolo, // YOLO mode auto-approves all tool executions resume_session_id, initial_input, max_subagents, diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index a521c610..b3a5a367 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -8,7 +8,7 @@ use std::process::Command; pub const WHALE_BG_RGB: (u8, u8, u8) = (10, 17, 32); // #0A1120 Deep Navy pub const WHALE_PANEL_RGB: (u8, u8, u8) = (22, 34, 56); // #162238 pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (36, 52, 78); // #24344E -pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (48, 68, 100); // #304464 +pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (40, 56, 84); // #283854 — darker to avoid bright pop on deep navy pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray @@ -244,7 +244,11 @@ pub const TEXT_ACCENT: Color = Color::Rgb( WHALE_ACCENT_SECONDARY_RGB.1, WHALE_ACCENT_SECONDARY_RGB.2, ); -pub const SELECTION_TEXT: Color = Color::White; +pub const SELECTION_TEXT: Color = Color::Rgb( + WHALE_TEXT_BODY_RGB.0, + WHALE_TEXT_BODY_RGB.1, + WHALE_TEXT_BODY_RGB.2, +); // Ivory — softer than pure white pub const TEXT_SOFT: Color = Color::Rgb( WHALE_TEXT_SOFT_RGB.0, WHALE_TEXT_SOFT_RGB.1, diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 7ff922d4..39a8ad29 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -384,6 +384,11 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { if file_path.exists() && file_path.is_file() { match load_context_file(&file_path) { Ok(content) => { + tracing::info!( + "Loaded project context from {} ({} bytes)", + file_path.display(), + content.len() + ); ctx.instructions = Some(content); ctx.source_path = Some(file_path); break; diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index a12b08ba..c43f2e84 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -58,7 +58,9 @@ impl Default for PromptSessionContext<'_> { /// A previous session writes it on exit / `/compact`; the next session reads /// it back on startup and prepends it to the system prompt so a fresh agent /// doesn't have to re-discover open blockers from scratch. -pub const HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; +pub const HANDOFF_RELATIVE_PATH: &str = ".codewhale/handoff.md"; +/// Legacy handoff path for reading from existing installs. +const LEGACY_HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; /// Per-file size cap for `instructions = [...]` entries (#454). Mirrors /// the existing project-context cap in `project_context::load_context_file` @@ -204,7 +206,12 @@ fn render_instructions_block(paths: &[PathBuf]) -> Option { /// system-prompt block. Returns `None` when the file is absent or empty so /// callers can keep the default-uncluttered prompt for fresh workspaces. fn load_handoff_block(workspace: &Path) -> Option { - let path = workspace.join(HANDOFF_RELATIVE_PATH); + let primary = workspace.join(HANDOFF_RELATIVE_PATH); + let path = if primary.exists() { + primary + } else { + workspace.join(LEGACY_HANDOFF_RELATIVE_PATH) + }; let raw = std::fs::read_to_string(&path).ok()?; let trimmed = raw.trim(); if trimmed.is_empty() { @@ -397,7 +404,7 @@ pub const SUGGEST_APPROVAL: &str = include_str!("prompts/approvals/suggest.md"); pub const NEVER_APPROVAL: &str = include_str!("prompts/approvals/never.md"); /// Compaction relay template — written into the system prompt so the -/// model knows the format to use when writing `.deepseek/handoff.md`. +/// model knows the format to use when writing `.codewhale/handoff.md`. pub const COMPACT_TEMPLATE: &str = include_str!("prompts/compact.md"); /// Goal continuation audit template — injected by the engine when a runtime @@ -774,7 +781,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( } // 5. Compaction relay template — so the model knows the format to use - // when writing `.deepseek/handoff.md` on exit / `/compact`. + // when writing `.codewhale/handoff.md` on exit / `/compact`. full_prompt.push_str("\n\n"); full_prompt.push_str(COMPACT_TEMPLATE); @@ -874,7 +881,7 @@ mod tests { /// Discriminator unique to the injected relay block (not present in the /// agent prompt's own discussion of the convention). - const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.deepseek/handoff.md`"; + const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.codewhale/handoff.md`"; fn contains_cjk(text: &str) -> bool { text.chars().any(|ch| { diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 7fa0e8ca..48373d4b 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -157,17 +157,24 @@ pub fn init() -> Result { } fn log_directory() -> Option { + let resolve = |base: PathBuf| -> Option { + let primary = base.join(".codewhale").join("logs"); + if primary.exists() { + return Some(primary); + } + Some(base.join(".deepseek").join("logs")) + }; if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) && !home.as_os_str().is_empty() { - return Some(home.join(".deepseek").join("logs")); + return resolve(home); } if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from) && !userprofile.as_os_str().is_empty() { - return Some(userprofile.join(".deepseek").join("logs")); + return resolve(userprofile); } - dirs::home_dir().map(|h| h.join(".deepseek").join("logs")) + dirs::home_dir().and_then(|h| resolve(h)) } fn log_file_name(date: &str, pid: u32) -> String { diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 1a92ef2f..f4520af8 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -109,6 +109,10 @@ impl TuiPrefs { let home = dirs::home_dir() .context("Failed to resolve home directory: cannot determine tui.toml path.")?; + let primary = home.join(".codewhale").join("tui.toml"); + if primary.exists() { + return Ok(primary); + } Ok(home.join(".deepseek").join("tui.toml")) } @@ -766,6 +770,10 @@ impl Settings { ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), + ( + "base_url", + "HTTP base URL for DeepSeek-compatible endpoints.", + ), ( "locale", "UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419", diff --git a/crates/tui/src/skill_state.rs b/crates/tui/src/skill_state.rs index 4816fa8e..245b51f3 100644 --- a/crates/tui/src/skill_state.rs +++ b/crates/tui/src/skill_state.rs @@ -5,7 +5,7 @@ //! filesystem-discovered `SkillRegistry`: the registry tells us which skills //! exist on disk, and this store tells API clients which ones are marked active. //! -//! Storage shape (TOML at `~/.deepseek/skills_state.toml`): +//! Storage shape (TOML at `~/.codewhale/skills_state.toml`, legacy `~/.deepseek/skills_state.toml`): //! //! ```toml //! disabled = ["skill-name-1", "skill-name-2"] @@ -104,10 +104,8 @@ impl SkillStateStore { } fn default_state_path() -> Result { - let home = dirs::home_dir().context("could not resolve $HOME for ~/.deepseek")?; - let dir = home.join(".deepseek"); - fs::create_dir_all(&dir) - .with_context(|| format!("create deepseek state dir at {}", dir.display()))?; + let dir = codewhale_config::ensure_state_dir(".") + .context("could not resolve or create CodeWhale state directory")?; Ok(dir.join(STATE_FILE_NAME)) } diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index aa4550be..787b6c4a 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -52,7 +52,7 @@ use crate::network_policy::{Decision, NetworkPolicy, host_from_url}; pub fn default_cache_skills_dir() -> PathBuf { dirs::home_dir().map_or_else( || PathBuf::from("/tmp/codewhale/cache/skills"), - |p| p.join(".deepseek").join("cache").join("skills"), + |p| p.join(".codewhale").join("cache").join("skills"), ) } diff --git a/crates/tui/src/snapshot/paths.rs b/crates/tui/src/snapshot/paths.rs index 90d70091..d1ac8c78 100644 --- a/crates/tui/src/snapshot/paths.rs +++ b/crates/tui/src/snapshot/paths.rs @@ -1,18 +1,20 @@ //! Path resolution for the per-workspace snapshot side-repos. //! -//! Snapshots live in `~/.deepseek/snapshots///`. -//! The two-level hash split lets us snapshot multiple worktrees of the same -//! project independently — `git worktree list` users won't get cross-talk -//! between feature branches. +//! Snapshots live under the resolved state directory +//! (`~/.codewhale/snapshots` or legacy `~/.deepseek/snapshots`) with +//! a two-level hash split so we can snapshot multiple worktrees of the +//! same project independently — `git worktree list` users won't get +//! cross-talk between feature branches. use std::io; use std::path::{Path, PathBuf}; /// Compute the snapshot directory for a given workspace path. /// -/// Returns `~/.deepseek/snapshots///`. The -/// caller is responsible for creating it on disk; we purposefully don't -/// touch the filesystem here so this is cheap to call repeatedly. +/// Returns `$STATE_DIR/snapshots///` where +/// `$STATE_DIR` is resolved via `codewhale_config::resolve_state_dir`. +/// The caller is responsible for creating it on disk; we purposefully +/// don't touch the filesystem here so this is cheap to call repeatedly. /// /// The `project_hash` is derived from the canonicalized workspace path /// after stripping any `.worktrees/` suffix — multiple worktrees @@ -24,7 +26,7 @@ pub fn snapshot_dir_for(workspace: &Path) -> PathBuf { } /// Same as [`snapshot_dir_for`] but with an injectable home directory. -/// Used by tests so we never touch the user's real `~/.deepseek/`. +/// Used by tests so they never touch the user's real state directory. pub fn snapshot_dir_with_home(workspace: &Path, home: Option) -> PathBuf { let home = home.unwrap_or_else(|| PathBuf::from(".")); let canonical = workspace @@ -33,12 +35,21 @@ pub fn snapshot_dir_with_home(workspace: &Path, home: Option) -> PathBu let project_root = strip_worktree_suffix(&canonical); let project_hash = stable_hex(&project_root); let worktree_hash = stable_hex(&canonical); - home.join(".deepseek") - .join("snapshots") + snapshot_base_with_home(Some(home)) .join(project_hash) .join(worktree_hash) } +fn snapshot_base_with_home(home: Option) -> PathBuf { + let home = home.unwrap_or_else(|| PathBuf::from(".")); + // Prefer .codewhale, fall back to .deepseek + let primary = home.join(".codewhale").join("snapshots"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("snapshots") +} + /// Resolve the `.git` directory inside the snapshot dir. pub fn snapshot_git_dir(workspace: &Path) -> PathBuf { snapshot_dir_for(workspace).join(".git") diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index b0d9e39e..8f927023 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -1648,9 +1648,9 @@ pub fn default_tasks_dir() -> PathBuf { return PathBuf::from(path); } if let Some(home) = dirs::home_dir() { - return home.join(".deepseek").join("tasks"); + return home.join(".codewhale").join("tasks"); } - PathBuf::from(".deepseek").join("tasks") + PathBuf::from(".codewhale").join("tasks") } /// Wait for a task to reach a terminal status (tests and API helpers). diff --git a/crates/tui/src/tools/recall_archive.rs b/crates/tui/src/tools/recall_archive.rs index 380d11ad..6ec0b1a6 100644 --- a/crates/tui/src/tools/recall_archive.rs +++ b/crates/tui/src/tools/recall_archive.rs @@ -162,11 +162,10 @@ fn archive_root(session_id: &str) -> Result { "Could not resolve home directory for cycle archive root", ) })?; - Ok(home - .join(".deepseek") - .join("sessions") - .join(session_id) - .join("cycles")) + // Use resolved sessions dir (prefers ~/.codewhale/sessions) + let sessions = codewhale_config::resolve_state_dir("sessions") + .unwrap_or_else(|_| home.join(".deepseek").join("sessions")); + Ok(sessions.join(session_id).join("cycles")) } /// Enumerate all archive files for a session, sorted by cycle number ascending. diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 3e16a81d..8f2e186e 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -183,8 +183,9 @@ impl ToolContext { pub fn new(workspace: impl Into) -> Self { let workspace = workspace.into(); let shell_manager = new_shared_shell_manager(workspace.clone()); - let notes_path = workspace.join(".deepseek").join("notes.md"); - let mcp_config_path = workspace.join(".deepseek").join("mcp.json"); + // Prefer .codewhale, fall back to .deepseek for project-local state + let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md").1; + let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json").1; Self { workspace, shell_manager, diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index d50972ec..357aeec4 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1845,6 +1845,11 @@ async fn subagent_session_projection( } fn default_state_path(workspace: &Path) -> PathBuf { + // Prefer .codewhale, fall back to .deepseek for project-local state + let primary = workspace.join(".codewhale").join("state"); + if primary.exists() { + return primary.join(SUBAGENT_STATE_FILE); + } workspace .join(".deepseek") .join("state") diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index e0cadcae..4de0a540 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -81,6 +81,13 @@ pub fn spillover_root() -> Option { return Some(root); } + // Prefer .codewhale, fall back to .deepseek + let primary = dirs::home_dir()? + .join(".codewhale") + .join(SPILLOVER_DIR_NAME); + if primary.exists() { + return Some(primary); + } Some(dirs::home_dir()?.join(".deepseek").join(SPILLOVER_DIR_NAME)) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 449e267e..3c49df0e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -368,7 +368,7 @@ pub(crate) struct InputHistoryDraft { cursor: usize, } -fn char_count(text: &str) -> usize { +pub(crate) fn char_count(text: &str) -> usize { text.chars().count() } @@ -902,6 +902,10 @@ pub struct ComposerState { /// user presses `d` in Normal mode; cleared on the next key (either `d` /// to complete `dd`, or any other key to cancel). pub vim_pending_d: bool, + /// When set, the cursor is the active end of a text selection and + /// `selection_anchor` is the fixed end. Both are char-indexed. + /// `None` means no selection is active. + pub selection_anchor: Option, } impl Default for ComposerState { @@ -926,6 +930,7 @@ impl Default for ComposerState { vim_enabled: false, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, } } } @@ -940,11 +945,21 @@ pub struct ViewportState { pub selection_autoscroll: Option, pub transcript_scrollbar_dragging: bool, pub last_transcript_area: Option, + pub last_composer_area: Option, pub last_transcript_top: usize, pub last_transcript_visible: usize, pub last_transcript_total: usize, pub last_transcript_padding_top: usize, pub jump_to_latest_button_area: Option, + /// Inner content rect of the composer (excluding border/padding), + /// stored at render time for mouse coordinate mapping. + pub last_composer_content: Option, + /// Number of rendered text lines scrolled off the top of the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_scroll_offset: usize, + /// Vertical padding above the first text line in the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_top_padding: usize, } impl Default for ViewportState { @@ -958,11 +973,15 @@ impl Default for ViewportState { selection_autoscroll: None, transcript_scrollbar_dragging: false, last_transcript_area: None, + last_composer_area: None, last_transcript_top: 0, last_transcript_visible: 0, last_transcript_total: 0, last_transcript_padding_top: 0, jump_to_latest_button_area: None, + last_composer_content: None, + last_composer_scroll_offset: 0, + last_composer_top_padding: 0, } } } @@ -1809,6 +1828,7 @@ impl App { vim_enabled: composer_vim_enabled, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, }, viewport: ViewportState::default(), goal: GoalState::default(), @@ -3124,6 +3144,7 @@ impl App { if text.is_empty() { return; } + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3383,6 +3404,7 @@ impl App { pub fn insert_char(&mut self, c: char) { self.clear_input_history_navigation(); + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3409,6 +3431,9 @@ impl App { pub fn delete_char(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3426,6 +3451,9 @@ impl App { pub fn delete_char_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.input.is_empty() { return; @@ -3444,6 +3472,9 @@ impl App { /// Delete the word before the cursor. pub fn delete_word_backward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3485,6 +3516,9 @@ impl App { /// Delete from the cursor to the start of the line. pub fn delete_to_start_of_line(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3510,6 +3544,9 @@ impl App { /// Delete the word after the cursor. pub fn delete_word_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; let cursor_byte = byte_index_at_char(&self.input, self.cursor_position); if cursor_byte >= self.input.len() { @@ -3554,6 +3591,13 @@ impl App { /// Returns `true` when bytes were moved into the kill buffer. pub fn kill_to_end_of_line(&mut self) -> bool { self.clear_input_history_navigation(); + if let Some((start, end)) = self.selection_range() { + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.kill_buffer = self.input[sb..eb].to_string(); + self.delete_selection(); + return true; + } let total_chars = char_count(&self.input); let cursor = self.cursor_position.min(total_chars); let start_byte = byte_index_at_char(&self.input, cursor); @@ -3599,6 +3643,7 @@ impl App { if self.kill_buffer.is_empty() { return false; } + self.delete_selection(); self.clear_input_history_navigation(); let text = self.kill_buffer.clone(); let cursor = self.cursor_position.min(char_count(&self.input)); @@ -3724,6 +3769,59 @@ impl App { self.needs_redraw = true; } + // === Selection helpers === + + /// Return the (start, end) of the active selection, or `None`. + /// `start` is inclusive, `end` is exclusive; both are char indices. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let total = char_count(&self.input); + let anchor = self.selection_anchor?.min(total); + let cursor = self.cursor_position.min(total); + if anchor == cursor { + return None; + } + Some(if anchor < cursor { + (anchor, cursor) + } else { + (cursor, anchor) + }) + } + + /// Return the selected text, or empty string if no selection. + pub fn selected_text(&self) -> String { + self.selection_range() + .map(|(s, e)| { + let sb = byte_index_at_char(&self.input, s); + let eb = byte_index_at_char(&self.input, e); + self.input[sb..eb].to_string() + }) + .unwrap_or_default() + } + + /// Delete the selected text, place cursor at the start of the deleted range. + /// Returns true if a selection was deleted. + pub fn delete_selection(&mut self) -> bool { + let Some((start, end)) = self.selection_range() else { + return false; + }; + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.input.replace_range(sb..eb, ""); + self.cursor_position = start; + self.selection_anchor = None; + self.clear_input_history_navigation(); + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.needs_redraw = true; + true + } + + /// Clear the selection without moving the cursor. + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } + // === Vim composer mode helpers === /// Move the cursor to the start of the current logical line (vim `0`). @@ -3906,6 +4004,7 @@ impl App { self.clear_input_history_navigation(); self.input.clear(); self.cursor_position = 0; + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_selected = 0; self.slash_menu_hidden = false; @@ -4412,6 +4511,7 @@ impl App { self.history_index = Some(new_index); self.input = self.input_history[new_index].clone(); self.cursor_position = char_count(&self.input); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -4428,6 +4528,7 @@ impl App { self.history_index = Some(i + 1); self.input = self.input_history[i + 1].clone(); self.cursor_position = char_count(&self.input); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -4436,6 +4537,7 @@ impl App { if let Some(draft) = self.history_navigation_draft.take() { self.input = draft.input; self.cursor_position = draft.cursor.min(char_count(&self.input)); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -5820,6 +5922,22 @@ mod tests { assert!(app.history_index.is_none()); } + #[test] + fn input_history_navigation_clears_stale_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input_history.push("previous input".to_string()); + app.input = "hello world".to_string(); + app.cursor_position = "hello ".chars().count(); + app.selection_anchor = Some(app.input.chars().count()); + + app.history_up(); + assert_eq!(app.input, "previous input"); + assert!(app.selection_anchor.is_none()); + + app.insert_char('x'); + assert_eq!(app.input, "previous inputx"); + } + #[test] fn input_history_restores_empty_draft_at_end_of_navigation() { let mut app = App::new(test_options(false), &Config::default()); @@ -6666,4 +6784,107 @@ mod tests { assert_eq!(app.input, "café 你好"); assert_eq!(app.cursor_position, 7); } + + #[test] + fn selection_range_returns_none_when_no_anchor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = None; + assert!(app.selection_range().is_none()); + } + + #[test] + fn selection_range_returns_ordered_range() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_normalizes_order() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 2; + app.selection_anchor = Some(5); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_returns_none_when_anchor_equals_cursor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = Some(3); + assert!(app.selection_range().is_none()); + } + + #[test] + fn delete_selection_removes_selected_text() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert!(app.delete_selection()); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn insert_char_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_char('X'); + assert_eq!(app.input, "heX world"); + assert_eq!(app.cursor_position, 3); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_char_removes_selection_instead_of_single_char() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.delete_char(); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn selected_text_returns_correct_substring() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selected_text(), "llo"); + } + + #[test] + fn insert_str_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_str("yo"); + assert_eq!(app.input, "heyo world"); + assert_eq!(app.cursor_position, 4); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_selection_noop_when_no_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = None; + assert!(!app.delete_selection()); + assert_eq!(app.input, "hello"); + assert_eq!(app.cursor_position, 3); + } } diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index dffadfac..bbefcac8 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -279,7 +279,7 @@ fn osc52_sequence(text: &str, in_tmux: bool) -> Result { /// `/clipboard-images/` if the home dir is unavailable. pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf { if let Some(home) = dirs::home_dir() { - return home.join(".deepseek").join("clipboard-images"); + return home.join(".codewhale").join("clipboard-images"); } workspace.join("clipboard-images") } diff --git a/crates/tui/src/tui/file_frecency.rs b/crates/tui/src/tui/file_frecency.rs index 5129d695..10b83852 100644 --- a/crates/tui/src/tui/file_frecency.rs +++ b/crates/tui/src/tui/file_frecency.rs @@ -55,7 +55,7 @@ fn store() -> &'static Mutex { } fn default_path() -> Option { - dirs::home_dir().map(|h| h.join(".deepseek").join("file-frecency.jsonl")) + dirs::home_dir().map(|h| h.join(".codewhale").join("file-frecency.jsonl")) } fn now_secs() -> u64 { diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 90a07b9e..f2ce686e 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -15,6 +15,7 @@ use crate::tools::review::ReviewOutput; use crate::tui::app::TranscriptSpacing; use crate::tui::diff_render; use crate::tui::markdown_render; +use crate::tui::ui_text::CopyLineSeparator; // === Constants === @@ -158,6 +159,12 @@ pub struct TranscriptRenderOptions { pub spacing: TranscriptSpacing, } +pub(crate) struct RenderedTranscriptLine { + pub line: Line<'static>, + pub copy_prefix_width: usize, + pub copy_separator_after: CopyLineSeparator, +} + impl Default for TranscriptRenderOptions { fn default() -> Self { Self { @@ -296,6 +303,39 @@ impl HistoryCell { } } + pub(crate) fn lines_with_copy_metadata( + &self, + width: u16, + options: TranscriptRenderOptions, + ) -> Vec { + match self { + HistoryCell::User { content } => render_message_with_copy_metadata( + USER_GLYPH, + user_label_style(), + user_body_style(), + content, + width, + ), + HistoryCell::Assistant { content, streaming } => render_message_with_copy_metadata( + ASSISTANT_GLYPH, + assistant_label_style_for(*streaming, options.low_motion), + message_body_style(), + content, + width, + ), + HistoryCell::System { content } if !is_cycle_boundary(content) => { + render_message_with_copy_metadata( + "Note", + system_label_style(), + system_body_style(), + content, + width, + ) + } + _ => hard_break_copy_lines(self.lines_with_options(width, options)), + } + } + /// Render the cell in transcript mode: full content, no caps, no /// "Alt+V for details" affordances. /// @@ -2193,6 +2233,19 @@ fn render_message( content: &str, width: u16, ) -> Vec> { + render_message_with_copy_metadata(prefix, label_style, body_style, content, width) + .into_iter() + .map(|rendered| rendered.line) + .collect() +} + +fn render_message_with_copy_metadata( + prefix: &str, + label_style: Style, + body_style: Style, + content: &str, + width: u16, +) -> Vec { let prefix_width = UnicodeWidthStr::width(prefix); let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX); let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1)); @@ -2200,7 +2253,7 @@ fn render_message( let rendered = markdown_render::render_markdown_tagged(content, content_width as u16, body_style); for (idx, rendered_line) in rendered.into_iter().enumerate() { - if idx == 0 { + let line = if idx == 0 { let mut spans = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled( @@ -2210,7 +2263,7 @@ fn render_message( spans.push(Span::raw(" ")); } spans.extend(rendered_line.line.spans); - lines.push(Line::from(spans)); + Line::from(spans) } else { let indent = if prefix.is_empty() { String::new() @@ -2225,15 +2278,49 @@ fn render_message( let rail_style = Style::default().fg(palette::TEXT_DIM); let mut spans = vec![Span::styled(indent, rail_style)]; spans.extend(rendered_line.line.spans); - lines.push(Line::from(spans)); - } + Line::from(spans) + }; + lines.push(RenderedTranscriptLine { + line, + copy_prefix_width: rendered_line.copy_prefix_width + + history_copy_prefix_width(prefix, prefix_width, rendered_line.is_code, idx), + copy_separator_after: rendered_line.copy_separator_after, + }); } if lines.is_empty() { - lines.push(Line::from("")); + lines.push(RenderedTranscriptLine { + line: Line::from(""), + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }); } lines } +fn history_copy_prefix_width( + prefix: &str, + prefix_width: usize, + is_code: bool, + line_index: usize, +) -> usize { + if line_index > 0 && is_code && !prefix.is_empty() { + prefix_width + 1 + } else { + 0 + } +} + +fn hard_break_copy_lines(lines: Vec>) -> Vec { + lines + .into_iter() + .map(|line| RenderedTranscriptLine { + line, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }) + .collect() +} + /// Render a plain-text user message: split on newlines, word-wrap each line, /// preserve leading whitespace. No markdown interpretation (headings, lists, /// code blocks, etc. are rendered as literal text). diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index e9c92e3a..0d645510 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -33,6 +33,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; use crate::tui::osc8; +use crate::tui::ui_text::CopyLineSeparator; // Thread-local counter incremented every time `parse` runs. Used by tests to // prove that width-only changes hit the cached-AST path and skip parsing. @@ -101,6 +102,8 @@ pub struct ParsedMarkdown { pub struct RenderedMarkdownLine { pub line: Line<'static>, pub is_code: bool, + pub copy_prefix_width: usize, + pub copy_separator_after: CopyLineSeparator, } /// Parse markdown source into a width-independent block AST. @@ -227,6 +230,8 @@ pub fn render_parsed_tagged( .map(|line| RenderedMarkdownLine { line, is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }), ); continue; @@ -246,6 +251,8 @@ pub fn render_parsed_tagged( Style::default().fg(palette::TEXT_DIM), )), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::HorizontalRule => { @@ -255,18 +262,19 @@ pub fn render_parsed_tagged( Style::default().fg(palette::TEXT_DIM), )), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::ListItem { bullet, text } => { let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY); - out.extend( - render_list_line(bullet, text, width, bullet_style, base_style) - .into_iter() - .map(|line| RenderedMarkdownLine { - line, - is_code: false, - }), - ); + out.extend(render_list_line_tagged( + bullet, + text, + width, + bullet_style, + base_style, + )); } Block::Code { line } => { let code_style = Style::default() @@ -280,19 +288,16 @@ pub fn render_parsed_tagged( let link_style = Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::UNDERLINED); - out.extend( - render_line_with_links(text, width, base_style, link_style) - .into_iter() - .map(|line| RenderedMarkdownLine { - line, - is_code: false, - }), - ); + out.extend(render_line_with_links_tagged( + text, width, base_style, link_style, + )); } Block::Blank => { out.push(RenderedMarkdownLine { line: Line::from(""), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::TableRow(_) | Block::TableSeparator => unreachable!(), @@ -304,6 +309,8 @@ pub fn render_parsed_tagged( out.push(RenderedMarkdownLine { line: Line::from(""), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } @@ -484,6 +491,7 @@ fn render_wrapped_line_tagged( }; let mut out = Vec::new(); + let last_index = wrapped.len().saturating_sub(1); for (idx, chunk) in wrapped.into_iter().enumerate() { let line = if idx == 0 { Line::from(vec![Span::raw(prefix), Span::styled(chunk, style)]) @@ -493,47 +501,87 @@ fn render_wrapped_line_tagged( Span::styled(chunk, style), ]) }; - out.push(RenderedMarkdownLine { line, is_code }); + let copy_separator_after = if idx == last_index { + CopyLineSeparator::Newline + } else if is_code { + CopyLineSeparator::None + } else { + CopyLineSeparator::Space + }; + out.push(RenderedMarkdownLine { + line, + is_code, + copy_prefix_width: if indent_code { prefix_width } else { 0 }, + copy_separator_after, + }); } out } -fn render_list_line( +fn render_list_line_tagged( bullet: &str, text: &str, width: usize, bullet_style: Style, text_style: Style, -) -> Vec> { +) -> Vec { let bullet_prefix = format!("{bullet} "); let bullet_width = bullet_prefix.width(); let available = width.saturating_sub(bullet_width).max(1); - let wrapped = render_line_with_links(text, available, text_style, link_style()); + let wrapped = render_line_with_links_tagged(text, available, text_style, link_style()); let mut out = Vec::new(); - for (idx, line) in wrapped.into_iter().enumerate() { + for (idx, rendered) in wrapped.into_iter().enumerate() { if idx == 0 { let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)]; - spans.extend(line.spans); - out.push(Line::from(spans)); + spans.extend(rendered.line.spans); + out.push(RenderedMarkdownLine { + line: Line::from(spans), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: rendered.copy_separator_after, + }); } else { let mut spans = vec![Span::raw(" ".repeat(bullet_width))]; - spans.extend(line.spans); - out.push(Line::from(spans)); + spans.extend(rendered.line.spans); + out.push(RenderedMarkdownLine { + line: Line::from(spans), + is_code: false, + copy_prefix_width: bullet_width, + copy_separator_after: rendered.copy_separator_after, + }); } } out } +#[cfg(test)] fn render_line_with_links( line: &str, width: usize, base_style: Style, link_style: Style, ) -> Vec> { + render_line_with_links_tagged(line, width, base_style, link_style) + .into_iter() + .map(|rendered| rendered.line) + .collect() +} + +fn render_line_with_links_tagged( + line: &str, + width: usize, + base_style: Style, + link_style: Style, +) -> Vec { if line.trim().is_empty() { - return vec![Line::from("")]; + return vec![RenderedMarkdownLine { + line: Line::from(""), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }]; } // Flatten inline tokens into (word, style) pairs preserving inter-token spaces. @@ -558,8 +606,8 @@ fn render_line_with_links( } } - let mut lines = Vec::new(); - let mut current_spans: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut current_spans: Vec> = Vec::new(); let mut current_width = 0usize; for word in words { @@ -581,12 +629,7 @@ fn render_line_with_links( if ww > width && width > 0 { // Flush the in-progress line first. if !current_spans.is_empty() { - if let Some(last) = current_spans.last() - && last.content.as_ref() == " " - { - current_spans.pop(); - } - lines.push(Line::from(std::mem::take(&mut current_spans))); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space); current_width = 0; } // Char-break the word into width-sized chunks. Each full chunk @@ -597,7 +640,12 @@ fn render_line_with_links( for ch in word.text.chars() { let cw = ch.width().unwrap_or(1); if chunk_w + cw > width && chunk_w > 0 { - lines.push(Line::from(vec![word.span_for(std::mem::take(&mut chunk))])); + lines.push(RenderedMarkdownLine { + line: Line::from(vec![word.span_for(std::mem::take(&mut chunk))]), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::None, + }); chunk_w = 0; } chunk.push(ch); @@ -612,13 +660,7 @@ fn render_line_with_links( // Wrap before this word if it doesn't fit. if current_width > 0 && current_width + ww > width { // Trim trailing space span before breaking. - if let Some(last) = current_spans.last() - && last.content.as_ref() == " " - { - current_spans.pop(); - } - lines.push(Line::from(current_spans)); - current_spans = Vec::new(); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space); current_width = 0; } current_spans.push(word.into_span()); @@ -626,14 +668,39 @@ fn render_line_with_links( } if !current_spans.is_empty() { - lines.push(Line::from(current_spans)); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Newline); + } else if let Some(last) = lines.last_mut() { + last.copy_separator_after = CopyLineSeparator::Newline; } if lines.is_empty() { - lines.push(Line::from("")); + lines.push(RenderedMarkdownLine { + line: Line::from(""), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }); } lines } +fn push_inline_line( + lines: &mut Vec, + spans: &mut Vec>, + copy_separator_after: CopyLineSeparator, +) { + if let Some(last) = spans.last() + && last.content.as_ref() == " " + { + spans.pop(); + } + lines.push(RenderedMarkdownLine { + line: Line::from(std::mem::take(spans)), + is_code: false, + copy_prefix_width: 0, + copy_separator_after, + }); +} + #[derive(Clone)] struct InlineToken { text: String, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index c3c985c1..a22b2b61 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::command_palette::{ @@ -37,6 +39,91 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +/// Map a mouse (column, row) within the composer area to a char index +/// in the composer input string. Uses the inner content rect (border-aware) +/// for coordinate mapping, and accounts for vertical padding and scroll offset. +fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option { + let rel_col = col.saturating_sub(inner.x) as usize; + let rel_row = row.saturating_sub(inner.y) as usize; + + if app.input.is_empty() { + return Some(0); + } + + let width = inner.width.max(1) as usize; + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width); + + // Subtract the vertical top-padding (centering of short inputs). + let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding); + + // Add the scroll offset (lines scrolled out of view). + let absolute_row = text_row + app.viewport.last_composer_scroll_offset; + + if absolute_row >= wrapped.len() { + return Some(app.input.chars().count()); + } + + let (line_start, line_text) = &wrapped[absolute_row]; + + let mut char_offset = 0usize; + let mut col_used = 0usize; + for g in line_text.graphemes(true) { + let gw = g.width(); + if col_used + gw > rel_col { + break; + } + col_used += gw; + char_offset += g.chars().count(); + } + Some(line_start + char_offset) +} + +/// Handle mouse events within the composer area. +/// Returns true if the event was consumed. +pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { + // Use outer area for hit-testing (includes border). + let Some(area) = app.viewport.last_composer_area else { + return false; + }; + if mouse.column < area.x + || mouse.column >= area.x + area.width + || mouse.row < area.y + || mouse.row >= area.y + area.height + { + return false; + } + // Use inner content rect for coordinate-to-char mapping (border-aware). + let inner = app.viewport.last_composer_content.unwrap_or(area); + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + app.cursor_position = pos; + app.selection_anchor = None; + app.needs_redraw = true; + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.cursor_position = pos; + app.needs_redraw = true; + } + true + } + MouseEventKind::Up(MouseButton::Left) => { + if app.selection_anchor == Some(app.cursor_position) { + app.selection_anchor = None; + } + true + } + _ => false, + } +} + pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { @@ -52,6 +139,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { // Update last mouse position for tooltip rendering. @@ -585,6 +677,10 @@ pub(crate) fn selection_point_from_position( } pub(crate) fn selection_has_content(app: &App) -> bool { + // Composer selection takes priority (same as Cmd+C handler above). + if !app.selected_text().is_empty() { + return true; + } selection_to_text(app).is_some_and(|text| !text.is_empty()) } @@ -613,6 +709,17 @@ pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { } pub(crate) fn copy_active_selection(app: &mut App) { + // Composer selection takes priority. + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.status_message = Some("Selection copied".to_string()); + app.clear_selection(); + } else { + app.status_message = Some("Copy failed".to_string()); + } + return; + } if !app.viewport.transcript_selection.is_active() { return; } @@ -637,9 +744,14 @@ pub(crate) fn selection_to_text(app: &App) -> Option { let end_index = end.line_index.min(lines.len().saturating_sub(1)); let start_index = start.line_index.min(end_index); - let mut selected_lines = Vec::new(); + let line_meta = app.viewport.transcript_cache.line_meta(); + let mut selected = String::new(); + let mut separator_before = None; #[allow(clippy::needless_range_loop)] for line_index in start_index..=end_index { + if let Some(separator) = separator_before { + selected.push_str(separator); + } // Rail-prefix decorations are stored as cache metadata rather than // detected from glyphs, so new decoration types are covered without // changes to the copy path (#1163). @@ -648,30 +760,50 @@ pub(crate) fn selection_to_text(app: &App) -> Option { // slice off the rail prefix so subsequent column offsets operate // on content-only text. let full_text = line_to_plain(&lines[line_index]); - let line_text = if rail_width > 0 { + let line_after_rail = if rail_width > 0 { slice_text(&full_text, rail_width, text_display_width(&full_text)) } else { full_text }; + let line_after_rail_width = text_display_width(&line_after_rail); + let copy_prefix_width = line_meta + .get(line_index) + .map(|meta| meta.copy_prefix_width()) + .unwrap_or(0) + .min(line_after_rail_width); + let line_text = if copy_prefix_width > 0 { + slice_text(&line_after_rail, copy_prefix_width, line_after_rail_width) + } else { + line_after_rail + }; let line_width = text_display_width(&line_text); + let visual_prefix_width = rail_width.saturating_add(copy_prefix_width); // Selection coordinates are recorded in rendered-column space, which - // includes the visual rail prefix. Add rail_width back so the column - // window maps correctly into the rail-stripped text. + // includes visual prefixes. Add them back so the column window maps + // correctly into copy-only text. let (raw_col_start, raw_col_end) = if start_index == end_index { (start.column, end.column) } else if line_index == start_index { - (start.column, line_width.saturating_add(rail_width)) + (start.column, line_width.saturating_add(visual_prefix_width)) } else if line_index == end_index { (0, end.column) } else { - (0, line_width.saturating_add(rail_width)) + (0, line_width.saturating_add(visual_prefix_width)) }; - let col_start = raw_col_start.saturating_sub(rail_width).min(line_width); - let col_end = raw_col_end.saturating_sub(rail_width).min(line_width); + let col_start = raw_col_start + .saturating_sub(visual_prefix_width) + .min(line_width); + let col_end = raw_col_end + .saturating_sub(visual_prefix_width) + .min(line_width); let slice = slice_text(&line_text, col_start, col_end); - selected_lines.push(slice); + selected.push_str(&slice); + separator_before = line_meta + .get(line_index) + .map(|meta| meta.copy_separator_after().as_str()) + .or(Some("\n")); } - Some(selected_lines.join("\n")) + Some(selected) } diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 4c7741d5..a1cce682 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -22,11 +22,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK)); f.render_widget(block, area); + const TOP_MARGIN: u16 = 2; let content_width = 76.min(area.width.saturating_sub(4)); - let content_height = 20.min(area.height.saturating_sub(4)); + let content_height = 20.min(area.height.saturating_sub(TOP_MARGIN + 2)); let content_area = Rect { - x: (area.width - content_width) / 2, - y: (area.height - content_height) / 2, + x: (area.width.saturating_sub(content_width)) / 2, + y: TOP_MARGIN, width: content_width, height: content_height, }; @@ -127,7 +128,13 @@ pub fn tips_lines(app: &App) -> Vec> { } pub fn default_marker_path() -> Option { - dirs::home_dir().map(|home| home.join(".deepseek").join(".onboarded")) + dirs::home_dir().map(|home| { + let primary = home.join(".codewhale").join(".onboarded"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join(".onboarded") + }) } pub fn is_onboarded() -> bool { diff --git a/crates/tui/src/tui/scrolling.rs b/crates/tui/src/tui/scrolling.rs index 6bc51781..1e976b64 100644 --- a/crates/tui/src/tui/scrolling.rs +++ b/crates/tui/src/tui/scrolling.rs @@ -17,6 +17,8 @@ use std::time::{Duration, Instant}; +use crate::tui::ui_text::CopyLineSeparator; + const TRACKPAD_EVENT_WINDOW: Duration = Duration::from_millis(35); const WHEEL_LINES_PER_TICK: i32 = 3; const TRACKPAD_BASE_LINES_PER_TICK: i32 = 1; @@ -36,6 +38,8 @@ pub enum TranscriptLineMeta { CellLine { cell_index: usize, line_in_cell: usize, + copy_prefix_width: usize, + copy_separator_after: CopyLineSeparator, }, Spacer, } @@ -48,10 +52,32 @@ impl TranscriptLineMeta { TranscriptLineMeta::CellLine { cell_index, line_in_cell, + .. } => Some((cell_index, line_in_cell)), TranscriptLineMeta::Spacer => None, } } + + #[must_use] + pub fn copy_separator_after(&self) -> CopyLineSeparator { + match *self { + TranscriptLineMeta::CellLine { + copy_separator_after, + .. + } => copy_separator_after, + TranscriptLineMeta::Spacer => CopyLineSeparator::Newline, + } + } + + #[must_use] + pub fn copy_prefix_width(&self) -> usize { + match *self { + TranscriptLineMeta::CellLine { + copy_prefix_width, .. + } => copy_prefix_width, + TranscriptLineMeta::Spacer => 0, + } + } } // === Transcript Scroll State === @@ -271,6 +297,8 @@ mod tests { TranscriptLineMeta::CellLine { cell_index, line_in_cell, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index ec3d5bad..2ebd58dd 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -442,7 +442,7 @@ fn push_work_checklist_lines( let (prefix, color) = match item.status { TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), - TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS), + TodoStatus::Completed => ("[✓]", palette::STATUS_SUCCESS), }; let text = format!("{prefix} #{} {}", item.id, item.content); lines.push(Line::from(Span::styled( @@ -533,7 +533,7 @@ fn push_work_strategy_lines( let (prefix, color) = match step.status { StepStatus::Pending => ("[ ]", theme.plan_pending_color), StepStatus::InProgress => ("[~]", theme.plan_in_progress_color), - StepStatus::Completed => ("[x]", theme.plan_completed_color), + StepStatus::Completed => ("[✓]", theme.plan_completed_color), }; let mut text = format!("{prefix} {}", step.text); if !step.elapsed.is_empty() { @@ -1361,7 +1361,7 @@ fn first_nonempty_line(text: &str) -> &str { fn tool_status_marker(status: ToolStatus) -> (&'static str, ratatui::style::Color) { match status { ToolStatus::Running => ("[~]", palette::STATUS_WARNING), - ToolStatus::Success => ("[x]", palette::STATUS_SUCCESS), + ToolStatus::Success => ("[✓]", palette::STATUS_SUCCESS), ToolStatus::Failed => ("[!]", palette::STATUS_ERROR), } } @@ -1656,7 +1656,7 @@ pub fn subagent_panel_lines( fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) { match status { "running" => ("[~]", palette::STATUS_WARNING), - "done" => ("[x]", palette::STATUS_SUCCESS), + "done" => ("[✓]", palette::STATUS_SUCCESS), "failed" => ("[!]", palette::STATUS_ERROR), "canceled" | "interrupted" => ("[-]", palette::TEXT_MUTED), _ => ("[ ]", palette::TEXT_MUTED), @@ -2152,7 +2152,7 @@ mod tests { "recent section missing: {text:?}" ); assert!( - text.iter().any(|line| line.contains("[x] read_file")), + text.iter().any(|line| line.contains("[✓] read_file")), "recent read_file row missing: {text:?}" ); } @@ -2181,7 +2181,7 @@ mod tests { let text = lines_to_text(&task_panel_lines(&app, 64, 8)); assert!( - !text.iter().any(|line| line.contains("[x] read_file")), + !text.iter().any(|line| line.contains("[✓] read_file")), "expired completed active row should leave the sidebar: {text:?}" ); } @@ -2219,7 +2219,7 @@ mod tests { let text = lines_to_text(&task_panel_lines(&app, 64, 8)); assert!( - text.iter().any(|line| line.contains("[x] read_file")), + text.iter().any(|line| line.contains("[✓] read_file")), "fresh completed active row should linger briefly: {text:?}" ); } @@ -2372,7 +2372,7 @@ mod tests { .expect("failed grep row should stay visible"); let read_group_index = text .iter() - .position(|line| line.contains("[x] read_file x3")) + .position(|line| line.contains("[✓] read_file x3")) .expect("repeated read_file rows should collapse"); assert!( @@ -2381,7 +2381,7 @@ mod tests { ); assert_eq!( text.iter() - .filter(|line| line.contains("[x] read_file")) + .filter(|line| line.contains("[✓] read_file")) .count(), 1, "read_file should render once after grouping: {text:?}" @@ -2481,7 +2481,7 @@ mod tests { assert!( text.iter() - .any(|line| line.contains("[x] cargo check 1.2s")), + .any(|line| line.contains("[✓] cargo check 1.2s")), "status marker and duration should stay in the row label: {text:?}" ); assert!( diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 9616a9c7..53319c96 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -26,6 +26,7 @@ use ratatui::{ use crate::tui::app::TranscriptSpacing; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::scrolling::TranscriptLineMeta; +use crate::tui::ui_text::CopyLineSeparator; /// Per-cell cached render output. Reused across `ensure` calls when the /// upstream cell's revision counter hasn't changed. @@ -45,6 +46,12 @@ struct CachedCell { /// Rendered lines for this cell (without trailing inter-cell spacers), /// shared via `Arc` so cache enumeration is O(N) not O(N*lines). lines: Arc>>, + /// Copy separators aligned with `lines`. These preserve source hard + /// newlines while allowing copy to remove visual soft-wrap breaks. + copy_separators: Arc>, + /// Display-column widths of visual prefixes that should be omitted from + /// clipboard text, aligned with `lines`. + copy_prefix_widths: Arc>, /// Whether this cell's rendered output was empty (e.g. Thinking hidden). /// Cached so we can skip empty cells without re-rendering. is_empty: bool, @@ -183,11 +190,21 @@ impl TranscriptViewCache { } else { width }; - let rendered = cell.lines_with_options(render_width, options); - let is_empty = rendered.is_empty(); + let rendered = cell.lines_with_copy_metadata(render_width, options); + let mut lines = Vec::with_capacity(rendered.len()); + let mut copy_separators = Vec::with_capacity(rendered.len()); + let mut copy_prefix_widths = Vec::with_capacity(rendered.len()); + for rendered_line in rendered { + lines.push(rendered_line.line); + copy_prefix_widths.push(rendered_line.copy_prefix_width); + copy_separators.push(rendered_line.copy_separator_after); + } + let is_empty = lines.is_empty(); new_per_cell.push(CachedCell { revision: current_rev, - lines: Arc::new(rendered), + lines: Arc::new(lines), + copy_separators: Arc::new(copy_separators), + copy_prefix_widths: Arc::new(copy_prefix_widths), is_empty, is_stream_continuation: cell.is_stream_continuation(), is_conversational: cell.is_conversational(), @@ -280,6 +297,16 @@ impl TranscriptViewCache { self.line_meta.push(TranscriptLineMeta::CellLine { cell_index, line_in_cell, + copy_prefix_width: cached + .copy_prefix_widths + .get(line_in_cell) + .copied() + .unwrap_or(0), + copy_separator_after: cached + .copy_separators + .get(line_in_cell) + .copied() + .unwrap_or(CopyLineSeparator::Newline), }); } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 17be9a0e..fb89de61 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2975,7 +2975,21 @@ async fn run_event_loop( KeyCode::Char('c') | KeyCode::Char('C') if key_shortcuts::is_copy_shortcut(&key) => { - copy_active_selection(app); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast( + "Copied to clipboard", + StatusToastLevel::Info, + None, + ); + app.clear_selection(); + } else { + app.push_status_toast("Copy failed", StatusToastLevel::Error, None); + } + } else { + copy_active_selection(app); + } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Four behaviors layered on Ctrl+C in priority order — see @@ -3488,16 +3502,32 @@ async fn run_event_loop( app.delete_char_forward(); } KeyCode::Delete => {} + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_left(); + } KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_backward(); } KeyCode::Left => { + app.clear_selection(); app.move_cursor_left(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_right(); + } KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_forward(); } KeyCode::Right => { + app.clear_selection(); app.move_cursor_right(); } KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3513,15 +3543,19 @@ async fn run_event_loop( KeyCode::Home | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_start(); } KeyCode::Home => { + app.clear_selection(); app.move_cursor_line_start(); } KeyCode::End => { + app.clear_selection(); app.move_cursor_line_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_end(); } KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3624,12 +3658,22 @@ async fn run_event_loop( } } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Plan, - }; - app.set_mode(new_mode); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); + app.delete_selection(); + } else { + app.push_status_toast("Cut failed", StatusToastLevel::Error, None); + } + } else { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + AppMode::Agent => AppMode::Yolo, + AppMode::Yolo => AppMode::Plan, + }; + app.set_mode(new_mode); + } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); @@ -5352,6 +5396,11 @@ async fn steer_user_message( engine_handle.steer(content.clone()).await?; app.last_submitted_prompt = Some(message.display.clone()); + // Flush any streaming thinking/tool content into history before + // inserting the steer message, so the steer appears after (below) + // the content that chronologically preceded it. + app.flush_active_cell(); + // Mirror steer input in local transcript/session state. app.add_message(HistoryCell::User { content: format!("+ {}", message.display), @@ -5834,6 +5883,47 @@ fn render(f: &mut Frame, app: &mut App) { composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) }; + app.viewport.last_composer_area = Some(chunks[3]); + { + let area = chunks[3]; + let has_panel = app.composer_border && area.height >= 3 && area.width >= 12; + let inner = if has_panel { + ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .inner(area) + } else { + area + }; + app.viewport.last_composer_content = Some(inner); + + // Compute scroll offset and top padding for mouse coordinate mapping. + let input_text = app.composer_display_input(); + let input_cursor = app.composer_display_cursor(); + let content_width = usize::from(inner.width.max(1)); + let menu_lines = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ) + .active_menu_reserved_rows(); + let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines); + let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll( + input_text, + input_cursor, + content_width, + budget, + ); + let visible_lines = if input_text.is_empty() { + 1 + } else { + // Count wrapped lines (approximation matching the render path). + crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len() + }; + let top_padding = budget.saturating_sub(visible_lines.clamp(1, budget)); + app.viewport.last_composer_scroll_offset = scroll_offset; + app.viewport.last_composer_top_padding = top_padding; + } if let Some(cursor_pos) = cursor_pos { f.set_cursor_position(cursor_pos); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c17f3320..4f0baa5b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -294,6 +294,21 @@ fn word_cursor_modifier_accepts_control_and_alt() { assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT)); } +fn select_full_transcript(app: &mut App) { + app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint { + line_index: 0, + column: 0, + }); + app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint { + line_index: app + .viewport + .transcript_cache + .total_lines() + .saturating_sub(1), + column: 80, + }); +} + #[test] fn selection_point_from_position_ignores_top_padding() { let area = Rect { @@ -375,6 +390,90 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() { assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam")); } +#[test] +fn selection_to_text_removes_visual_wrap_breaks_from_paragraphs() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "alpha beta gamma delta epsilon".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert!( + !selected.contains('\n'), + "soft-wrapped paragraph copied with visual newlines: {selected:?}" + ); + assert!(selected.contains("alpha beta gamma delta epsilon")); +} + +#[test] +fn selection_to_text_preserves_wrapped_long_words() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "abcdefghijklmnop".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 10, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "abcdefghijklmnop"); +} + +#[test] +fn selection_to_text_strips_code_block_visual_wrap_prefixes() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "```\nlet example = abcdefghijklmnop;\n```".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "let example = abcdefghijklmnop;"); +} + +#[test] +fn selection_to_text_strips_list_continuation_prefixes() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "- alpha beta gamma delta epsilon".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "- alpha beta gamma delta epsilon"); +} + #[test] fn selection_to_text_copies_rendered_transcript_block() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index daafddb5..6c01743d 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -6,6 +6,24 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::tui::history::HistoryCell; use crate::tui::osc8; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CopyLineSeparator { + None, + Space, + Newline, +} + +impl CopyLineSeparator { + #[must_use] + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::None => "", + Self::Space => " ", + Self::Newline => "\n", + } + } +} + pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index beb044be..68ce1ac7 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect}; use std::cell::{Cell, RefCell}; use std::fmt; +use crate::config::Config; use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::settings::Settings; @@ -614,6 +615,15 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Model, + key: "base_url".to_string(), + value: Config::load(app.config_path.clone(), app.config_profile.as_deref()) + .map(|config| config.deepseek_base_url()) + .unwrap_or_else(|_| "(unavailable)".to_string()), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Permissions, key: "approval_mode".to_string(), @@ -2013,6 +2023,7 @@ mod tests { KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{buffer::Buffer, layout::Rect}; + use std::fs; use std::path::PathBuf; fn create_test_app() -> App { @@ -2175,6 +2186,7 @@ mod tests { .collect::>(); assert!(keys.contains(&"model")); assert!(keys.contains(&"reasoning_effort")); + assert!(keys.contains(&"base_url")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); @@ -2193,6 +2205,32 @@ mod tests { assert!(view.rows.iter().all(|row| row.editable)); } + #[test] + fn config_view_base_url_reflects_app_config_path() { + let temp_root = std::env::temp_dir().join(format!( + "deepseek-tui-base-url-view-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("config.toml"); + fs::write( + &config_path, + "base_url = \"https://ui-config-view.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let view = ConfigView::new_for_app(&app); + + let row = view + .rows + .iter() + .find(|row| row.key == "base_url") + .expect("base_url row missing"); + assert_eq!(row.value, "https://ui-config-view.local/v1"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app(); diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 2cbf576e..17b6173a 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -204,7 +204,7 @@ impl ModalView for StatusPickerView { for (idx, item) in self.rows.iter().enumerate() { let checked = *self.selected.get(idx).unwrap_or(&false); let is_cursor = idx == self.cursor; - let mark = if checked { "[x]" } else { "[ ]" }; + let mark = if checked { "[✓]" } else { "[ ]" }; let row_style = if is_cursor { Style::default() diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index f1e395e6..2c478a29 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -474,7 +474,7 @@ impl<'a> ComposerWidget<'a> { /// backend's per-cell write cost makes the layout jitter visible /// even though the work is tiny on Unix terminals. See user /// feedback in v0.8.8 polish thread. - fn active_menu_reserved_rows(&self) -> usize { + pub fn active_menu_reserved_rows(&self) -> usize { let actual = self.active_menu_row_count(); if actual == 0 { return 0; @@ -535,8 +535,8 @@ impl Renderable for ComposerWidget<'_> { let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines_for_budget); let content_width = usize::from(inner_area.width.max(1)); - let (visible_lines, _cursor_row, _cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + let (visible_lines, _cursor_row, _cursor_col, scroll_offset) = + layout_input_with_scroll(input_text, input_cursor, content_width, input_rows_budget); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { let border_color = if input_text.trim().is_empty() { @@ -666,6 +666,26 @@ impl Renderable for ComposerWidget<'_> { 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) + .into_iter() + .skip(scroll_offset) + .take(visible_lines.len()) + .map(|(start, text)| (start, start + text.chars().count())) + .collect(); + for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) + { + let spans = line_spans_with_selection( + line_text, + *line_start, + *line_end, + sel_start, + sel_end, + self.app.ui_theme.selection_bg, + ); + input_lines.push(Line::from(spans)); + } } else { for line in &visible_lines { input_lines.push(Line::from(Span::styled( @@ -1929,7 +1949,8 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { )), ]; - let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3); + // Keep the welcome block near the top of the chat pane (header is separate). + let top_padding = 2usize; let mut lines = Vec::new(); for _ in 0..top_padding { lines.push(Line::from("")); @@ -1938,7 +1959,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { lines } -fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { +pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { usize::from(inner_height).saturating_sub(extra_lines).max(1) } @@ -2251,6 +2272,17 @@ fn layout_input( width: usize, max_height: usize, ) -> (Vec, usize, usize) { + let (visible, visible_cursor_row, visible_cursor_col, _) = + layout_input_with_scroll(input, cursor, width, max_height); + (visible, visible_cursor_row, visible_cursor_col) +} + +pub fn layout_input_with_scroll( + input: &str, + cursor: usize, + width: usize, + max_height: usize, +) -> (Vec, usize, usize, usize) { let mut lines = wrap_input_lines(input, width); if lines.is_empty() { lines.push(String::new()); @@ -2276,6 +2308,7 @@ fn layout_input( visible, visible_cursor_row, cursor_col.min(width.saturating_sub(1)), + start, ) } @@ -2342,6 +2375,34 @@ fn wrap_input_lines(input: &str, width: usize) -> Vec { lines } +/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs +/// matching the wrapping produced by `wrap_input_lines`. +pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> { + if input.is_empty() || width == 0 { + return vec![(0, String::new())]; + } + + let mut result = Vec::new(); + let mut char_idx = 0usize; + + for raw_line in input.split('\n') { + if raw_line.is_empty() { + result.push((char_idx, String::new())); + char_idx += 1; // the '\n' + continue; + } + let wrapped = wrap_text(raw_line, width); + for wrapped_line in &wrapped { + let line_char_len: usize = wrapped_line.chars().count(); + result.push((char_idx, wrapped_line.clone())); + char_idx += line_char_len; + } + char_idx += 1; // the '\n' + } + + result +} + fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { return vec![text.to_string()]; @@ -2383,6 +2444,56 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +fn line_spans_with_selection<'a>( + line: &'a str, + line_start: usize, + line_end: usize, + sel_start: usize, + sel_end: usize, + highlight_bg: Color, +) -> Vec> { + let normal_style = Style::default().fg(palette::TEXT_PRIMARY); + let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg); + + // No overlap between this line and the selection + if line_end <= sel_start || line_start >= sel_end { + return vec![Span::styled(line, normal_style)]; + } + + let local_sel_start = sel_start.saturating_sub(line_start); + let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + + // Build a Vec of byte offsets for each char boundary, plus one past the end. + let mut byte_offsets: Vec = line.char_indices().map(|(i, _)| i).collect(); + byte_offsets.push(line.len()); + + let b0 = byte_offsets + .get(local_sel_start) + .copied() + .unwrap_or(line.len()); + let b1 = byte_offsets + .get(local_sel_end) + .copied() + .unwrap_or(line.len()); + + let mut spans = Vec::with_capacity(3); + + // Text before selection + if b0 > 0 { + spans.push(Span::styled(&line[..b0], normal_style)); + } + // Selected text + if b1 > b0 { + spans.push(Span::styled(&line[b0..b1], sel_style)); + } + // Text after selection + if b1 < line.len() { + spans.push(Span::styled(&line[b1..], normal_style)); + } + + spans +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index a260e1d5..15c23199 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -259,7 +259,17 @@ fn write_panic_dump( let home = dirs::home_dir().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") })?; - let crash_dir = home.join(".deepseek").join("crashes"); + // Prefer .codewhale, fall back to .deepseek + let crash_dir = home.join(".codewhale").join("crashes"); + if !crash_dir.exists() { + // Try legacy path for reading, but prefer new for writing + let _ = std::fs::create_dir_all(&crash_dir); + } + let crash_dir = if crash_dir.exists() { + crash_dir + } else { + home.join(".deepseek").join("crashes") + }; write_panic_dump_to(&crash_dir, name, location, message) } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6bfe6860..f0b75de0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -546,7 +546,7 @@ If you are upgrading from older releases: `false`. When `true`, the notification body includes the elapsed duration and the turn's cost in the configured display currency. - `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 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 because the terminal, not the TUI, owns the selection. +- `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.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`). diff --git a/docs/MODES.md b/docs/MODES.md index 3da4f5b4..250721db 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -102,7 +102,7 @@ Run `codewhale --help` for the canonical list. Common flags: - `-r, --resume `: resume a saved session - `-c, --continue`: resume the most recent session in this workspace - `--max-subagents `: clamp to `1..=20` -- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection. +- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `--profile `: select config profile - `--config `: config file path - `-v, --verbose`: verbose logging diff --git a/npm/codewhale/scripts/artifacts.js b/npm/codewhale/scripts/artifacts.js index 27117b0c..10645404 100644 --- a/npm/codewhale/scripts/artifacts.js +++ b/npm/codewhale/scripts/artifacts.js @@ -78,12 +78,21 @@ function executableName(base, platform) { } function releaseBaseUrl(version, repo = "Hmbown/CodeWhale") { + // CODEWHALE_RELEASE_BASE_URL is the canonical override. + // DEEPSEEK_TUI_RELEASE_BASE_URL / DEEPSEEK_RELEASE_BASE_URL are legacy aliases. const override = - process.env.DEEPSEEK_TUI_RELEASE_BASE_URL || process.env.DEEPSEEK_RELEASE_BASE_URL; + process.env.CODEWHALE_RELEASE_BASE_URL || + process.env.DEEPSEEK_TUI_RELEASE_BASE_URL || + process.env.DEEPSEEK_RELEASE_BASE_URL; if (override) { const trimmed = String(override).trim(); return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; } + // When CODEWHALE_USE_CNB_MIRROR is set, use the CNB (China-friendly) + // mirror that already builds and publishes binary release assets. + if (process.env.CODEWHALE_USE_CNB_MIRROR) { + return `https://cnb.cool/Hmbown/CodeWhale/-/releases/v${version}/`; + } return `https://github.com/${repo}/releases/download/v${version}/`; } diff --git a/scripts/verify_task.sh b/scripts/verify_task.sh new file mode 100644 index 00000000..97689ebf --- /dev/null +++ b/scripts/verify_task.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# verify_task.sh +# Runs the DeepSWE verifier inside the task's Docker container. +# Expects model.patch at /tmp/deep-swe-verify//model.patch +TASK_ID="$1" +IMAGE="$2" +TASKS_DIR="/Volumes/VIXinSSD/whalebro/codewhale/deep-swe/tasks" +WORK_DIR="/tmp/deep-swe-verify/$TASK_ID" + +mkdir -p "$WORK_DIR" +RESULT_FILE="$WORK_DIR/result.txt" + +echo "[$TASK_ID] Pulling image..." +docker pull "$IMAGE" 2>&1 | tail -1 + +echo "[$TASK_ID] Running verifier..." +docker run --rm \ + --platform linux/amd64 \ + -v "$WORK_DIR/model.patch:/model.patch:ro" \ + -v "$TASKS_DIR/$TASK_ID/tests/test.patch:/tests/test.patch:ro" \ + -v "$TASKS_DIR/$TASK_ID/tests/test.sh:/verify.sh:ro" \ + "$IMAGE" \ + bash -c ' + set -e + mkdir -p /logs/verifier /logs/artifacts + cd /app + git apply --whitespace=nowarn /model.patch 2>/dev/null || { echo "PATCH_FAILED"; exit 2; } + bash /verify.sh > /logs/verifier/output.txt 2>&1 + EC=$? + if [ -f /logs/verifier/reward.txt ]; then + REWARD=$(cat /logs/verifier/reward.txt) + echo "REWARD=$REWARD" + else + # Extract from output + if grep -q "New tests exit code: 0" /logs/verifier/output.txt && \ + grep -q "Baseline exit code: 0" /logs/verifier/output.txt; then + echo "REWARD=1" + else + echo "REWARD=0" + fi + fi + echo "---OUTPUT_TAIL---" + tail -30 /logs/verifier/output.txt + ' > "$RESULT_FILE" 2>&1 + +echo "[$TASK_ID] Done. Result:" +cat "$RESULT_FILE" | grep -E 'REWARD|FAILED|PATCH_FAILED|passed' +echo ""