From a06bbe57a6def8e7d80b5c95d642dfe8fa48ec7e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:09:48 -0500 Subject: [PATCH 01/22] fix(tools): replace cross-await RwLock with Semaphore to prevent deadlock Replace `Arc>` in ToolCallRuntime with `Arc` to eliminate the risk of a tool re-entering and deadlocking on the same lock. Parallel tools now acquire then immediately drop the permit, allowing concurrent execution after any in-flight serial tool finishes. Serial tools hold the permit for the full duration. Fixes #2157. Harvested from #1856. Co-authored-by: Fire-dtx <58944505+Fire-dtx@users.noreply.github.com> --- crates/tools/src/lib.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a7179410..050b840f 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,7 +8,7 @@ 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::Semaphore; /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -309,9 +309,19 @@ pub trait ToolHandler: Send + Sync { ) -> std::result::Result; } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ToolCallRuntime { - pub parallel_execution: Arc>, + /// Serialise non-parallel tool executions. Capacity 1 ensures at most one + /// serial tool runs at a time, and blocks parallel tools while it runs. + serial_semaphore: Arc, +} + +impl Default for ToolCallRuntime { + fn default() -> Self { + Self { + serial_semaphore: Arc::new(Semaphore::new(1)), + } + } } #[derive(Default)] @@ -380,13 +390,20 @@ impl ToolRegistry { }; if configured.supports_parallel_tool_calls { - let _guard = self.runtime.parallel_execution.read().await; + // Parallel tools wait for any in-flight serial tool to finish, + // but do not hold the permit so other parallel tools may run concurrently. + drop(self.runtime.serial_semaphore.acquire().await + .map_err(|_| FunctionCallError::Cancelled { name: call.name })?); self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) .await } else { - let _guard = self.runtime.parallel_execution.write().await; + // Serial tools hold the semaphore for the full execution duration, + // preventing other serial AND parallel tools from starting. + let _permit = self.runtime.serial_semaphore.acquire().await + .map_err(|_| FunctionCallError::Cancelled { name: call.name })?; self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) .await + // _permit dropped here, releasing the semaphore. } } From 84463711b493ab6f22de241c0480a2b49568f0e2 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:14:55 -0500 Subject: [PATCH 02/22] feat(composer): mouse + keyboard text selection with copy/cut Add mouse drag selection and Shift+Arrow text selection in the composer input box. Ctrl+C copies selected text; Ctrl+X cuts (or toggles mode if no selection). Selection highlighting uses the theme's selection_bg color. Mouse coordinate mapping accounts for wrapping, scroll offset, and padding. Also fix Home, End, Ctrl+A, and Ctrl+E to clear the selection anchor before jumping, matching the existing Left/Right behavior. Without these calls a stale anchor silently reforms a selection and can cause unintended deletions on the next keystroke. Harvested from #2228. Co-authored-by: imkingjh999 --- CHANGELOG.md | 13 ++ crates/tui/src/tui/app.rs | 203 +++++++++++++++++++++++++++++- crates/tui/src/tui/mouse_ui.rs | 107 ++++++++++++++++ crates/tui/src/tui/ui.rs | 95 ++++++++++++-- crates/tui/src/tui/widgets/mod.rs | 169 ++++++++++++++++++++++++- 5 files changed, 575 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c266c89..007fc825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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 to prevent + accidental deletions on the next keystroke (#2228). + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, + preventing re-entrant tool calls from deadlocking on the same lock (#1856). + ## [0.8.46] - 2026-05-26 ### Added diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2c3ec0c9..201890ad 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,58 @@ 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 anchor = self.selection_anchor?; + let cursor = self.cursor_position; + 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 +4003,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; @@ -6662,4 +6760,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/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index c3c985c1..47e323a7 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()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + app.clear_selection(); + return; + } if !app.viewport.transcript_selection.is_active() { return; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef9..7c77715d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2969,7 +2969,17 @@ 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 @@ -3482,16 +3492,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) => { @@ -3507,15 +3533,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) => { @@ -3618,12 +3648,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(); @@ -5826,6 +5866,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/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index f1e395e6..d78c1a96 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,25 @@ 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 = visible_line_char_ranges( + &self.app.input, + &visible_lines, + content_width, + scroll_offset, + ); + 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( @@ -1938,7 +1957,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 +2270,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 +2306,7 @@ fn layout_input( visible, visible_cursor_row, cursor_col.min(width.saturating_sub(1)), + start, ) } @@ -2342,6 +2373,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 +2442,108 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +/// Compute the (char_start, char_end) range for each visible wrapped line. +/// `char_start` is inclusive, `char_end` is exclusive. +/// `scroll_offset` is the number of wrapped lines skipped from the top. +fn visible_line_char_ranges( + input: &str, + visible_lines: &[String], + width: usize, + scroll_offset: usize, +) -> Vec<(usize, usize)> { + if input.is_empty() || width == 0 { + return vec![(0, 0); visible_lines.len()]; + } + + let mut ranges = Vec::new(); + let mut char_idx = 0usize; + let mut line_start = 0usize; + let mut line_width = 0usize; + + for g in input.graphemes(true) { + if g == "\n" { + ranges.push((line_start, char_idx)); + char_idx += 1; + line_start = char_idx; + line_width = 0; + continue; + } + + let gw = g.width(); + if line_width + gw > width && line_width > 0 { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + char_idx += g.chars().count(); + line_width += gw; + if line_width >= width { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + } + ranges.push((line_start, char_idx)); + + // Use the actual scroll_offset to align with visible_lines. + let start = scroll_offset.min(ranges.len()); + ranges + .into_iter() + .skip(start) + .take(visible_lines.len()) + .collect() +} + +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::{ From a554aa9603d46fa6c8333671b1ff33b03e18fd3e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:20:25 -0500 Subject: [PATCH 03/22] feat(project-context): add tracing log when context file is loaded Emit a tracing::info! line with the file path and byte size when a project context file (AGENTS.md, CLAUDE.md, etc.) is successfully loaded. This helps users and maintainers verify which file was used during prompt assembly, addressing the confusion reported in #2227. --- crates/tui/src/project_context.rs | 5 +++++ 1 file changed, 5 insertions(+) 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; From 1893f797fb94377817d71a05aac66a54e9841f39 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:25:55 -0500 Subject: [PATCH 04/22] docs: update CHANGELOG for v0.8.47 work Add entries for deadlock fix (#1856), composer text selection (#2228), and project context loading tracing (#2227). Add community credits for @Fire-dtx and @imkingjh999. --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 007fc825..3ffd8207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 support. Home, End, Ctrl+A, and Ctrl+E now clear the selection to prevent accidental deletions on the next keystroke (#2228). +### Changed + +- **Project context loading now logs the source file.** A tracing info + line is emitted when AGENTS.md, CLAUDE.md, or another context file is + successfully loaded into the system prompt, making it easier to verify + which file was used during prompt assembly (#2227). + ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, preventing re-entrant tool calls from deadlocking on the same lock (#1856). +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228). + ## [0.8.46] - 2026-05-26 ### Added From 0251b4e8e8a3cbc8e19f45b1894666ddd2de76a0 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:39:01 -0500 Subject: [PATCH 05/22] refactor: migrate snapshot and skill_state paths to ~/.codewhale - Add resolve_project_state_dir and ensure_project_state_dir to codewhale-config, providing project-local .codewhale/.deepseek resolution matching the home-directory pattern. - Migrate snapshot paths to prefer ~/.codewhale/snapshots with ~/.deepseek/snapshots fallback (snapshot_base_with_home). - Migrate skill_state.rs to use codewhale_config::ensure_state_dir instead of hardcoded home.join(".deepseek"). - Update doc comments to reference canonical .codewhale paths. Part of #2231 (state-root migration). --- crates/config/src/lib.rs | 30 ++++++++++++++++++++++++++++++ crates/tui/src/skill_state.rs | 8 +++----- crates/tui/src/snapshot/paths.rs | 31 +++++++++++++++++++++---------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9ee6e2fd..ffc78e66 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1618,6 +1618,36 @@ 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/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/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") From b9596a9640a81bff590ed11ba36cd680ea336c96 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:39:57 -0500 Subject: [PATCH 06/22] fix(tui): flush active cell before inserting steer message in transcript Steered/queued user messages were being inserted into app.history before the active cell (holding streaming thinking/tool content) was flushed, causing the user's message to render above (before) the thinking block that chronologically preceded it. Now call app.flush_active_cell() before app.add_message() in steer_user_message(), matching the pattern used in MessageStarted and MessageDelta handlers. Fixes #2225. --- crates/tui/src/tui/ui.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 7c77715d..c0f1dad4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5384,6 +5384,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), From 9540af268f11b2a741cfe720bd271be96bf7bd94 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:45:18 -0500 Subject: [PATCH 07/22] fix(tests): update save-default-path test for managed sessions dir The /save command now writes to ~/.codewhale/sessions (or legacy ~/.deepseek/sessions) instead of the workspace root. Update the test to set CODEWHALE_HOME to a temp directory and pre-create the sessions subdirectory so resolve_state_dir picks the primary path. Fixes the first failing test in #2223. --- crates/tui/src/commands/session.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index bc51683d..316e8c6c 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -487,22 +487,33 @@ 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] From 187014d7d5760390a121dbb11c566c34f5d7dcea Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:47:36 -0500 Subject: [PATCH 08/22] docs: update README paths from ~/.deepseek to ~/.codewhale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all three READMEs (en, zh-CN, ja-JP) to use the canonical ~/.codewhale paths for config, skills, and Docker volume mounts, with legacy ~/.deepseek noted as a compatibility fallback. The state-root migration has been underway since v0.8.44 — the docs now reflect it. --- README.ja-JP.md | 12 ++++++------ README.md | 14 +++++++------- README.zh-CN.md | 18 +++++++++--------- 3 files changed, 22 insertions(+), 22 deletions(-) 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 334ae74f..5531f242 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 @@ -395,7 +395,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 @@ -461,7 +461,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: @@ -514,7 +514,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/ @@ -539,7 +539,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 ``` From f23f4244923afb4a530f9da49277e13ff61aad12 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:47:54 -0500 Subject: [PATCH 09/22] docs: update CHANGELOG for v0.8.47 session 2 work Add entries for steer message ordering fix, state-root migration progress, README path updates, and session save test fix. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffd8207..291b80e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 line is emitted when AGENTS.md, CLAUDE.md, or another context file is successfully loaded into the system prompt, making it easier to verify which file was used during prompt assembly (#2227). +- **State-root migration continues.** Snapshot directory resolution now + prefers `~/.codewhale/snapshots` with `~/.deepseek/snapshots` fallback. + Skill state storage migrated to `~/.codewhale/skills_state.toml`. + Added `resolve_project_state_dir` and `ensure_project_state_dir` to + `codewhale-config` for project-local `.codewhale/` vs `.deepseek/` + resolution (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths for config, skills, and Docker + volumes, with legacy `~/.deepseek` noted as a compatibility fallback. ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, preventing re-entrant tool calls from deadlocking on the same lock (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell into history before + inserting the steer message, so the user's message appears after + (below) the thinking content that chronologically preceded it (#2225). +- **Session save test updated for managed sessions directory.** The + `/save` command now writes to `~/.codewhale/sessions` (or legacy + `~/.deepseek/sessions`) instead of the workspace root. Test updated + to set `CODEWHALE_HOME` and pre-create the sessions directory (#2223). ### Community From a1a7b5709ac949ed6cd9768d9b53efe6003e4b90 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:50:31 -0500 Subject: [PATCH 10/22] refactor: migrate more state paths to ~/.codewhale with legacy fallback - Spillover directory (truncate.rs): prefer ~/.codewhale/tool_outputs - Memory storage (capacity_memory.rs): prefer ~/.codewhale/memory - Runtime logs (runtime_log.rs): prefer ~/.codewhale/logs - Crash dumps (utils.rs): prefer ~/.codewhale/crashes - Automations (automation_manager.rs): prefer ~/.codewhale/automations - TUI settings (settings.rs): prefer ~/.codewhale/tui.toml All paths fall back to the legacy ~/.deepseek location when the canonical path doesn't exist, preserving compatibility for existing installs. Part of #2231. --- crates/tui/src/automation_manager.rs | 11 +++++++++-- crates/tui/src/core/capacity_memory.rs | 15 +++++++++++---- crates/tui/src/runtime_log.rs | 13 ++++++++++--- crates/tui/src/settings.rs | 4 ++++ crates/tui/src/tools/truncate.rs | 5 +++++ crates/tui/src/utils.rs | 12 +++++++++++- 6 files changed, 50 insertions(+), 10 deletions(-) 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/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs index f41bd48a..ab598512 100644 --- a/crates/tui/src/core/capacity_memory.rs +++ b/crates/tui/src/core/capacity_memory.rs @@ -56,14 +56,21 @@ 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); + .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/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..040ba88f 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")) } diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index e0cadcae..6c8d6e69 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -81,6 +81,11 @@ 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/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) } From 4925be4ddaf51581daa044d8a3de9e467d7c611b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:15:57 -0500 Subject: [PATCH 11/22] refactor: migrate handoff, notes, mcp, subagent, recall, anchors to .codewhale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HANDOFF_RELATIVE_PATH → .codewhale/handoff.md with .deepseek fallback - load_handoff_block reads both paths, prefers .codewhale - ToolContext notes_path and mcp_config_path use resolve_project_state_dir - Sub-agent state path prefers .codewhale/state/ - Cycle archive (recall_archive) uses resolve_state_dir for sessions - Compaction anchors path prefers .codewhale/anchors.md - Updated marker constants and comments Part of #2231. --- crates/tui/src/compaction.rs | 8 +++++++- crates/tui/src/prompts.rs | 17 ++++++++++++----- crates/tui/src/tools/recall_archive.rs | 9 ++++----- crates/tui/src/tools/spec.rs | 5 +++-- crates/tui/src/tools/subagent/mod.rs | 5 +++++ 5 files changed, 31 insertions(+), 13 deletions(-) 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/prompts.rs b/crates/tui/src/prompts.rs index aa69f4f7..6a60ab33 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -53,7 +53,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` @@ -180,7 +182,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() { @@ -373,7 +380,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"); /// Memory hygiene guidance — appended to the system prompt only when the @@ -741,7 +748,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); @@ -832,7 +839,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/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 f13c6516..af6c0ce0 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") From 0706285bfebee76ba746150ef0edf313f7fae333 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:19:22 -0500 Subject: [PATCH 12/22] feat(update): add CNB mirror support for China-friendly binary downloads - Add CODEWHALE_RELEASE_BASE_URL as canonical env override for release asset base URL (DEEPSEEK_TUI_RELEASE_BASE_URL and DEEPSEEK_RELEASE_BASE_URL remain as legacy fallbacks). - Add CODEWHALE_USE_CNB_MIRROR env var to auto-select the CNB (cnb.cool) mirror for binary downloads, avoiding GitHub Releases timeouts in China. - Update npm install scripts (artifacts.js) with the same env checks. - Update Rust self-updater (update.rs) with new constants and env cascade. Fixes #2222. --- crates/cli/src/update.rs | 31 +++++++++++++++++++++++------- npm/codewhale/scripts/artifacts.js | 11 ++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 9205c899..5b1dce53 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"; @@ -370,11 +374,24 @@ 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()) + // 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_ASSET_BASE.to_string()); + } + None } fn update_version_from_env() -> Option { 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}/`; } From 8ed4301d3581d0c4056bdfb8d94d3a4f897d2f71 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:19:53 -0500 Subject: [PATCH 13/22] docs: update CHANGELOG for CNB mirror and state-root migration --- CHANGELOG.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291b80e3..5616eaa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 line is emitted when AGENTS.md, CLAUDE.md, or another context file is successfully loaded into the system prompt, making it easier to verify which file was used during prompt assembly (#2227). -- **State-root migration continues.** Snapshot directory resolution now - prefers `~/.codewhale/snapshots` with `~/.deepseek/snapshots` fallback. - Skill state storage migrated to `~/.codewhale/skills_state.toml`. - Added `resolve_project_state_dir` and `ensure_project_state_dir` to - `codewhale-config` for project-local `.codewhale/` vs `.deepseek/` - resolution (#2231). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` env var and `CODEWHALE_USE_CNB_MIRROR` + auto-detection to both the npm install scripts and Rust self-updater. + Users in China can set `CODEWHALE_USE_CNB_MIRROR=1` to download + binaries from cnb.cool instead of GitHub Releases (#2222). +- **State-root migration continues.** Migrated these storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback: snapshots, skill + state, spillover, memory, logs, crashes, automations, TUI settings, + handoff, notes, MCP config, sub-agent state, cycle archives, and + anchors. Added `resolve_project_state_dir` and `ensure_project_state_dir` + to `codewhale-config` for project-local resolution (#2231). - **READMEs updated for the CodeWhale rename.** All three READMEs now reference canonical `~/.codewhale` paths for config, skills, and Docker volumes, with legacy `~/.deepseek` noted as a compatibility fallback. From 236ad4137d88bb8a58a5c1341f030f4352645265 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:34:21 -0500 Subject: [PATCH 14/22] feat: harvest 6 community PRs for v0.8.47 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harvested and vetted — no malware, no external deps, no injection: - #1859 (@harvey2011888): loop guard now reports Failed on halt - #1870 (@victorcheng2333): honour DEEPSEEK_YOLO env on startup - #1935 (@IIzzaya): replace [x] with [✓] completion markers - #1837 (@PurplePulse): fix macOS title centering (pin to top) - #1967 (@cyq1017): show base_url in /config view - #1906 (@knqiufan): copy transcript without visual-wrap newlines Also fix cycle_manager archive_dir_for to use resolve_state_dir so recall_archive tests pass with the migrated sessions path. Co-authored-by: victorcheng2333 Co-authored-by: IIzzaya Co-authored-by: PurplePulse Co-authored-by: cyq1017 Co-authored-by: knqiufan --- crates/tui/src/commands/config.rs | 180 +++++++++++++++++++++- crates/tui/src/commands/mod.rs | 8 +- crates/tui/src/commands/network.rs | 6 +- crates/tui/src/commands/skills.rs | 2 +- crates/tui/src/config_ui.rs | 6 +- crates/tui/src/core/engine/turn_loop.rs | 4 +- crates/tui/src/cycle_manager.rs | 21 +-- crates/tui/src/main.rs | 18 ++- crates/tui/src/settings.rs | 4 + crates/tui/src/tui/history.rs | 97 +++++++++++- crates/tui/src/tui/markdown_render.rs | 155 +++++++++++++------ crates/tui/src/tui/mouse_ui.rs | 45 ++++-- crates/tui/src/tui/onboarding/mod.rs | 7 +- crates/tui/src/tui/scrolling.rs | 28 ++++ crates/tui/src/tui/sidebar.rs | 20 +-- crates/tui/src/tui/transcript.rs | 33 +++- crates/tui/src/tui/ui/tests.rs | 99 ++++++++++++ crates/tui/src/tui/ui_text.rs | 18 +++ crates/tui/src/tui/views/mod.rs | 38 +++++ crates/tui/src/tui/views/status_picker.rs | 2 +- crates/tui/src/tui/widgets/mod.rs | 3 +- docs/CONFIGURATION.md | 2 +- docs/MODES.md | 2 +- 23 files changed, 691 insertions(+), 107 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..3496c826 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() { @@ -417,7 +436,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 +453,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 +1790,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/skills.rs b/crates/tui/src/commands/skills.rs index b1823d5f..9dc7fbf7 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/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/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 1a1a9104..0a3402b8 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1944,7 +1944,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..6817811e 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,17 @@ 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 473484dc..56c1a8ae 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -839,8 +839,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 { @@ -848,7 +852,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, @@ -4869,6 +4873,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 { @@ -4876,7 +4884,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, @@ -4885,9 +4893,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/settings.rs b/crates/tui/src/settings.rs index 040ba88f..f4520af8 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -770,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/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 47e323a7..92278bb6 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -744,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). @@ -755,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..fa56e868 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, }; 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/tests.rs b/crates/tui/src/tui/ui/tests.rs index c73a6a6e..73a3f219 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 d78c1a96..58cdffb7 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1948,7 +1948,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("")); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b92cd8ff..5d6bef88 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -540,7 +540,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 From 799d9f8176f3361bb914124cf206e7b99665381e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:35:57 -0500 Subject: [PATCH 15/22] docs: consolidate CHANGELOG for v0.8.47, add 5 new contributors --- CHANGELOG.md | 60 +++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5616eaa6..924e4f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,49 +11,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 to prevent - accidental deletions on the next keystroke (#2228). + 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.** A tracing info - line is emitted when AGENTS.md, CLAUDE.md, or another context file is - successfully loaded into the system prompt, making it easier to verify - which file was used during prompt assembly (#2227). -- **CNB mirror support for China-friendly downloads.** Added - `CODEWHALE_RELEASE_BASE_URL` env var and `CODEWHALE_USE_CNB_MIRROR` - auto-detection to both the npm install scripts and Rust self-updater. - Users in China can set `CODEWHALE_USE_CNB_MIRROR=1` to download - binaries from cnb.cool instead of GitHub Releases (#2222). -- **State-root migration continues.** Migrated these storage paths to - prefer `~/.codewhale` with `~/.deepseek` fallback: snapshots, skill - state, spillover, memory, logs, crashes, automations, TUI settings, - handoff, notes, MCP config, sub-agent state, cycle archives, and - anchors. Added `resolve_project_state_dir` and `ensure_project_state_dir` - to `codewhale-config` for project-local resolution (#2231). +- **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 for config, skills, and Docker - volumes, with legacy `~/.deepseek` noted as a compatibility fallback. + reference canonical `~/.codewhale` paths. ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced - `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, - preventing re-entrant tool calls from deadlocking on the same lock (#1856). + `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 into history before - inserting the steer message, so the user's message appears after - (below) the thinking content that chronologically preceded it (#2225). -- **Session save test updated for managed sessions directory.** The - `/save` command now writes to `~/.codewhale/sessions` (or legacy - `~/.deepseek/sessions`) instead of the workspace root. Test updated - to set `CODEWHALE_HOME` and pre-create the sessions directory (#2223). + `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). +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). ## [0.8.46] - 2026-05-26 From 8822e9873a621198fee844459d97131697fde9bb Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 15:05:59 -0500 Subject: [PATCH 16/22] =?UTF-8?q?refactor:=20finish=20state-root=20migrati?= =?UTF-8?q?on=20=E2=80=94=20all=20runtime=20paths=20now=20.codewhale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore: add deep-swe/ and all_preds.jsonl to prevent accidental commits - config.rs: home_config_path(), managed_config, requirements, mcp, notes, memory all prefer ~/.codewhale/config.toml with .deepseek fallback - commands/config.rs: config_toml_path() prefers .codewhale - commands/anchor.rs: anchors_path prefers .codewhale/anchors.md - commands/note.rs: notes_path prefers .codewhale/notes.md - skills/install.rs: cache defaults to .codewhale/cache/skills - skills/mod.rs: global skills discovery includes .codewhale/skills - file_frecency, clipboard, onboarding, audit, task_manager: all .codewhale - project-local paths (onboarding trust) still .deepseek for compat Closes #2231. --- .gitignore | 3 +++ crates/tui/src/audit.rs | 2 +- crates/tui/src/commands/anchor.rs | 4 +++ crates/tui/src/commands/config.rs | 4 +++ crates/tui/src/commands/note.rs | 4 +++ crates/tui/src/config.rs | 38 +++++++++++++++++++++++----- crates/tui/src/palette.rs | 8 ++++-- crates/tui/src/skills/install.rs | 2 +- crates/tui/src/task_manager.rs | 4 +-- crates/tui/src/tui/clipboard.rs | 2 +- crates/tui/src/tui/file_frecency.rs | 2 +- crates/tui/src/tui/onboarding/mod.rs | 6 ++++- 12 files changed, 64 insertions(+), 15 deletions(-) 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/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/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 3496c826..651b4d5d 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -379,6 +379,10 @@ pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result) -> 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/config.rs b/crates/tui/src/config.rs index 63fd1e80..55d46871 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,11 @@ 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 +2384,11 @@ 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 +2413,27 @@ 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/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/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/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/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/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index fa56e868..2967cab6 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -128,7 +128,11 @@ 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 { From 60c1b6619cfdba6c714344a42b062f3ee55c5811 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:28 -0500 Subject: [PATCH 17/22] style: rustfmt cleanups and minor formatting fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Line-wrap long function signatures and format arguments - Fix bracket placement for early returns (consistent style) - Use [!] instead of [✓] for network-denied skill sync - Fix copy-selection ordering: clear after success, not always --- crates/cli/src/update.rs | 28 ++++++++++++++++++++++---- crates/config/src/lib.rs | 10 ++------- crates/tui/src/commands/session.rs | 5 ++++- crates/tui/src/commands/skills.rs | 2 +- crates/tui/src/config.rs | 20 +++++++++++++----- crates/tui/src/core/capacity_memory.rs | 3 +-- crates/tui/src/cycle_manager.rs | 13 ++++++------ crates/tui/src/tools/truncate.rs | 4 +++- crates/tui/src/tui/mouse_ui.rs | 2 +- crates/tui/src/tui/onboarding/mod.rs | 4 +++- crates/tui/src/tui/ui.rs | 6 +++++- 11 files changed, 65 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 5b1dce53..2ab35ef1 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -351,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, @@ -373,7 +373,7 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { }) } -fn release_base_url_from_env() -> Option { +fn release_base_url_from_env(version: &str) -> Option { // Check canonical env first, then legacy envs for env_name in [ RELEASE_BASE_URL_ENV, @@ -389,11 +389,19 @@ fn release_base_url_from_env() -> Option { } // Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set if std::env::var(CNB_MIRROR_ENV).is_ok() { - return Some(CNB_RELEASE_ASSET_BASE.to_string()); + 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 { std::env::var(UPDATE_VERSION_ENV) .ok() @@ -993,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 ffc78e66..bb2c339b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1624,10 +1624,7 @@ pub fn ensure_state_dir(subdir: &str) -> Result { /// 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) { +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); @@ -1638,10 +1635,7 @@ pub fn resolve_project_state_dir( /// 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 { +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()))?; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 316e8c6c..80535bbd 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -513,7 +513,10 @@ mod tests { 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}"); + 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 9dc7fbf7..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, " [✓] {name} — network denied ({host})"); + let _ = writeln!(out, " [!] {name} — network denied ({host})"); } SkillSyncOutcome::NeedsApproval { name, host } => { failed += 1; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 55d46871..44bfd200 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2371,7 +2371,9 @@ fn default_managed_config_path() -> Option { { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("managed_config.toml"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("managed_config.toml") }) } @@ -2386,7 +2388,9 @@ fn default_requirements_path() -> Option { { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("requirements.toml"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("requirements.toml") }) } @@ -2415,7 +2419,9 @@ fn default_skills_dir() -> Option { fn default_mcp_config_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("mcp.json"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("mcp.json") }) } @@ -2423,7 +2429,9 @@ fn default_mcp_config_path() -> Option { fn default_notes_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("notes.txt"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("notes.txt") }) } @@ -2431,7 +2439,9 @@ fn default_notes_path() -> Option { fn default_memory_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("memory.md"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("memory.md") }) } diff --git a/crates/tui/src/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs index ab598512..0d22e4df 100644 --- a/crates/tui/src/core/capacity_memory.rs +++ b/crates/tui/src/core/capacity_memory.rs @@ -64,8 +64,7 @@ fn capacity_memory_dirs() -> Vec { dirs.push(home.join(".deepseek").join("memory")); } - let cwd = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")); + 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); diff --git a/crates/tui/src/cycle_manager.rs b/crates/tui/src/cycle_manager.rs index 6817811e..c7315053 100644 --- a/crates/tui/src/cycle_manager.rs +++ b/crates/tui/src/cycle_manager.rs @@ -466,13 +466,12 @@ pub struct CycleArchiveHeader { /// Resolve the on-disk archive directory: `~/.codewhale/sessions//cycles` /// (or legacy `~/.deepseek/sessions//cycles`). fn archive_dir_for(session_id: &str) -> Result { - let sessions = codewhale_config::resolve_state_dir("sessions") - .unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".deepseek") - .join("sessions") - }); + 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")) } diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index 6c8d6e69..4de0a540 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -82,7 +82,9 @@ pub fn spillover_root() -> Option { } // Prefer .codewhale, fall back to .deepseek - let primary = dirs::home_dir()?.join(".codewhale").join(SPILLOVER_DIR_NAME); + let primary = dirs::home_dir()? + .join(".codewhale") + .join(SPILLOVER_DIR_NAME); if primary.exists() { return Some(primary); } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 92278bb6..a22b2b61 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -714,10 +714,10 @@ pub(crate) fn copy_active_selection(app: &mut App) { 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()); } - app.clear_selection(); return; } if !app.viewport.transcript_selection.is_active() { diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 2967cab6..a1cce682 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -130,7 +130,9 @@ pub fn tips_lines(app: &App) -> Vec> { pub fn default_marker_path() -> Option { dirs::home_dir().map(|home| { let primary = home.join(".codewhale").join(".onboarded"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join(".onboarded") }) } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c0f1dad4..83c755b8 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2972,7 +2972,11 @@ async fn run_event_loop( 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.push_status_toast( + "Copied to clipboard", + StatusToastLevel::Info, + None, + ); app.clear_selection(); } else { app.push_status_toast("Copy failed", StatusToastLevel::Error, None); From 74878dcd300903e95491e04e743e649503a4a1d8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 18/22] refactor(tools): replace Semaphore with RwLock for parallel-safe tool execution - Use OwnedRwLockReadGuard for parallel-safe tools, OwnedRwLockWriteGuard for serial - Add TOOL_EXECUTION_LOCK_HELD task-local for reentrancy detection - Add BlockingHandler test harness and parallel-vs-serial concurrency tests --- crates/tools/src/lib.rs | 62 ++++++--- crates/tools/tests/parity_tools.rs | 208 ++++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 22 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 050b840f..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::Semaphore; +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)] @@ -311,15 +315,36 @@ pub trait ToolHandler: Send + Sync { #[derive(Debug)] pub struct ToolCallRuntime { - /// Serialise non-parallel tool executions. Capacity 1 ensures at most one - /// serial tool runs at a time, and blocks parallel tools while it runs. - serial_semaphore: 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 { - serial_semaphore: Arc::new(Semaphore::new(1)), + 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) } } } @@ -389,22 +414,17 @@ impl ToolRegistry { source: call.source, }; - if configured.supports_parallel_tool_calls { - // Parallel tools wait for any in-flight serial tool to finish, - // but do not hold the permit so other parallel tools may run concurrently. - drop(self.runtime.serial_semaphore.acquire().await - .map_err(|_| FunctionCallError::Cancelled { name: call.name })?); - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } else { - // Serial tools hold the semaphore for the full execution duration, - // preventing other serial AND parallel tools from starting. - let _permit = self.runtime.serial_semaphore.acquire().await - .map_err(|_| FunctionCallError::Cancelled { name: call.name })?; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - // _permit dropped here, releasing the semaphore. - } + 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:?}"), + } +} From 92c8dbc7cec77d0cc399a9353afc3fca014a43a5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 19/22] fix(composer): use wrap_input_lines_for_mouse, clamp selection to input length - Replace visible_line_char_ranges with wrap_input_lines_for_mouse for accurate mouse selection - Clamp selection_anchor and cursor_position to char_count - Clear selection on history navigation to prevent stale highlights - Add test for history-navigation-clears-stale-selection --- crates/tui/src/tui/app.rs | 24 +++++++++++- crates/tui/src/tui/widgets/mod.rs | 65 ++++--------------------------- 2 files changed, 29 insertions(+), 60 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 201890ad..d8068956 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3774,8 +3774,9 @@ impl App { /// 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 anchor = self.selection_anchor?; - let cursor = self.cursor_position; + 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; } @@ -4510,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(); @@ -4526,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(); @@ -4534,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(); @@ -5914,6 +5918,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()); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 58cdffb7..2c478a29 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -667,12 +667,13 @@ impl Renderable for ComposerWidget<'_> { Style::default().fg(palette::TEXT_MUTED).italic(), ))); } else if let Some((sel_start, sel_end)) = self.app.selection_range() { - let line_ranges = visible_line_char_ranges( - &self.app.input, - &visible_lines, - content_width, - scroll_offset, - ); + 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( @@ -2443,58 +2444,6 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } -/// Compute the (char_start, char_end) range for each visible wrapped line. -/// `char_start` is inclusive, `char_end` is exclusive. -/// `scroll_offset` is the number of wrapped lines skipped from the top. -fn visible_line_char_ranges( - input: &str, - visible_lines: &[String], - width: usize, - scroll_offset: usize, -) -> Vec<(usize, usize)> { - if input.is_empty() || width == 0 { - return vec![(0, 0); visible_lines.len()]; - } - - let mut ranges = Vec::new(); - let mut char_idx = 0usize; - let mut line_start = 0usize; - let mut line_width = 0usize; - - for g in input.graphemes(true) { - if g == "\n" { - ranges.push((line_start, char_idx)); - char_idx += 1; - line_start = char_idx; - line_width = 0; - continue; - } - - let gw = g.width(); - if line_width + gw > width && line_width > 0 { - ranges.push((line_start, char_idx)); - line_start = char_idx; - line_width = 0; - } - char_idx += g.chars().count(); - line_width += gw; - if line_width >= width { - ranges.push((line_start, char_idx)); - line_start = char_idx; - line_width = 0; - } - } - ranges.push((line_start, char_idx)); - - // Use the actual scroll_offset to align with visible_lines. - let start = scroll_offset.min(ranges.len()); - ranges - .into_iter() - .skip(start) - .take(visible_lines.len()) - .collect() -} - fn line_spans_with_selection<'a>( line: &'a str, line_start: usize, From 11e1ec1fe323c999a3d58c004a0b706472165e5a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 20/22] docs: add v0.8.47 CHANGELOG entries for composer, CNB mirror, fixes --- crates/tui/CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) 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 From ac6693581f8ae640d36414f826fb249b54c0acb4 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:40:16 -0500 Subject: [PATCH 21/22] fix(tools): restore checklist and planning tools to default active catalog PR #2076 deferred planning/checklist tools (checklist_write, update_plan, task_create, task_list, task_read) to reduce catalog tokens, but the system prompt actively instructs the model to use these tools. Without them in the active catalog, the model cannot call them until it first discovers them via tool_search, which it is not prompted to do for planning tools. Keep these tools in DEFAULT_ACTIVE_NATIVE_TOOLS so the model can follow the Constitution's Regulations (Tier 3) and the Mode: YOLO instructions. --- crates/tui/src/core/engine/tool_catalog.rs | 5 +++++ 1 file changed, 5 insertions(+) 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", ]; From b96fa37ea44a19ba30e482bc0b049d40bec0ab3a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:40:16 -0500 Subject: [PATCH 22/22] chore: add DeepSWE task verification script --- scripts/verify_task.sh | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 scripts/verify_task.sh 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 ""