diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 5de583bfe..cde3054d5 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.44] - 2026-05-24 + +### Added + +- **`codew` convenience alias.** `codew` is a short-form command that silently + forwards to `codewhale`. Six fewer keystrokes, same binary. Ships with the + Rust `codewhale-cli` crate and the npm `codewhale` package (#2013). +- **Session picker inline rename.** Press `r` in the session picker (Ctrl+R) + to rename the selected session inline. Type the new title, Enter to confirm, + Esc to cancel (#1600). + +### Changed + +- **App state migrates to `~/.codewhale/`.** New installs write product-owned + state (config, sessions, tasks, skills, logs, etc.) under `~/.codewhale/`. + `~/.deepseek/` continues to work as a compatibility fallback — no data loss, + no forced migration. `CODEWHALE_HOME` and `CODEWHALE_CONFIG_PATH` env vars + are now supported alongside existing `DEEPSEEK_*` vars (#2011). +- **Project config overlay prefers `.codewhale/config.toml`** before + `.deepseek/config.toml`. Both are read; the CodeWhale root takes precedence. +- **Doctor reports active state root** and whether legacy `~/.deepseek/` + state is also present. + +### Fixed + +- **`/save` no longer creates repo-local `session_*.json`.** Default saves + now go to the managed sessions directory instead of the current workspace. + Explicit `/save path/to/file.json` exports still work as before (#2010). +- **Boot-time session prune** caps managed sessions at 50 on every startup, + preventing unbounded growth of `~/.codewhale/sessions/`. +- **Checkpoint path resolution** no longer hardcodes `~/.deepseek/` — uses + the resolved session directory instead. +- **Work sidebar now refreshes immediately** after `checklist_write`, + `checklist_update`, and `update_plan` tool calls, matching the existing + `todo_write` behavior instead of relying on the 2.5s periodic poll (#1787). + ## [0.8.43] - 2026-05-24 ### Fixed diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 851dfabe3..5ed1c4810 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1575,9 +1575,9 @@ fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec } fn deepseek_home_dir() -> PathBuf { - codewhale_config::codewhale_home() - .unwrap_or_else(|_| dirs::home_dir() - .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))) + codewhale_config::codewhale_home().unwrap_or_else(|_| { + dirs::home_dir().map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale")) + }) } /// Resolve the default tools directory. Mirrors `default_skills_dir` shape. @@ -2076,10 +2076,10 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt // State root (v0.8.44) println!(); println!("{}", "State Root:".bold()); - let code_home = codewhale_config::codewhale_home() - .unwrap_or_else(|_| PathBuf::from("~/.codewhale")); - let legacy_home = codewhale_config::legacy_deepseek_home() - .unwrap_or_else(|_| PathBuf::from("~/.deepseek")); + let code_home = + codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale")); + let legacy_home = + codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek")); let active_root = if code_home.exists() { &code_home } else if legacy_home.exists() { diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index e7be32db9..518f177ef 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1084,7 +1084,12 @@ fn find_sse_event_separator(buffer: &str) -> Option<(usize, usize)> { fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> { let value = line.strip_prefix(field)?; - Some(value.strip_prefix(' ').unwrap_or(value)) + Some( + value + .strip_prefix(' ') + .unwrap_or(value) + .trim_end_matches('\r'), + ) } #[async_trait::async_trait] @@ -3377,6 +3382,18 @@ mod tests { assert!(value.get("result").is_some()); } + #[test] + fn sse_field_value_strips_trailing_cr() { + assert_eq!( + sse_field_value("event: endpoint\r", "event:"), + Some("endpoint") + ); + assert_eq!( + sse_field_value("data: /messages\r", "data:"), + Some("/messages") + ); + } + #[test] fn find_sse_event_separator_accepts_lf_and_crlf() { assert_eq!( @@ -3876,6 +3893,92 @@ mod tests { server.abort(); } + #[tokio::test] + async fn sse_connect_accepts_mixed_crlf_lines_with_lf_separator() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering as AtomicOrdering}, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let post_seen = Arc::new(AtomicBool::new(false)); + let server_post_seen = Arc::clone(&post_seen); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let server_cancel = cancel_token.clone(); + + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let post_seen = Arc::clone(&server_post_seen); + let server_cancel = server_cancel.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut buf = [0; 1024]; + loop { + let n = socket.read(&mut buf).await.unwrap(); + if n == 0 { + return; + } + request.extend_from_slice(&buf[..n]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&request); + if request.starts_with("GET /sse ") { + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n", + ) + .await + .unwrap(); + // CRLF for fields but LF-only event separator. + socket + .write_all(b"event: endpoint\r\ndata: /messages\r\n\n") + .await + .unwrap(); + server_cancel.cancelled().await; + } else if request.starts_with("POST /messages ") { + post_seen.store(true, AtomicOrdering::SeqCst); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + }); + } + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/sse"); + let mut transport = + SseTransport::connect(client, url, cancel_token.clone(), Duration::from_secs(2)) + .await + .unwrap(); + + transport + .send(json_frame(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize" + }))) + .await + .unwrap(); + + assert!( + post_seen.load(AtomicOrdering::SeqCst), + "first SSE send should POST to endpoint stripped of separator CR" + ); + + cancel_token.cancel(); + server.abort(); + } + #[test] fn session_id_starts_none() { let transport = StreamableHttpTransport::new( diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 0ae53e4d3..c5a691930 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -617,9 +617,8 @@ fn is_git_metadata_entry(path: &Path) -> bool { /// v0.8.44: prefers `~/.codewhale/sessions`, falls back to /// `~/.deepseek/sessions` for existing installs. pub fn default_sessions_dir() -> std::io::Result { - codewhale_config::resolve_state_dir("sessions").map_err(|e| { - std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()) - }) + codewhale_config::resolve_state_dir("sessions") + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string())) } /// Prune snapshots older than `max_age` for `workspace`.