Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1575,9 +1575,9 @@ fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String>
}

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.
Expand Down Expand Up @@ -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() {
Expand Down
105 changes: 104 additions & 1 deletion crates/tui/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions crates/tui/src/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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`.
Expand Down
Loading