harvest(v0.8.51): diff-render whitespace fix + schema dead_code + model persistence + prompt updates

- fix(diff-render): preserve leading whitespace in patch content lines
  Credit: @zlh124 (PR #2591), with extra-space bug fixed.
- fix(tui): allow unused schema migration registry
  Credit: @reidliu41 (PR #2601).
- feat(tui): persist per-provider model selection from /model command
- docs(prompts): prefer gh --json CLI for GitHub triage in agent instructions
This commit is contained in:
Hunter B
2026-06-02 20:30:31 -07:00
parent f886f28acf
commit c000bd7e60
7 changed files with 366 additions and 44 deletions
+140
View File
@@ -0,0 +1,140 @@
# v0.8.51 Release Handoff — 2026-06-02
## Workspace
```
/Volumes/VIXinSSD/codewhale
Branch: codex/v0.8.51-arcee-provider (12 commits ahead of origin/main)
```
## What's Already Landed (committed, 12 commits on branch)
| Commit | What |
|--------|------|
| `e54a0a500` | feat(provider): add direct Arcee support |
| `99da87ca1` | fix(cli): wire arcee provider auth |
| `8eca75763` | test(tui): cover arcee provider picker entry |
| `06612495f` | chore(release): prep v0.8.51 — version bump, CHANGELOG |
| `fd69f4c80` | fix(tui): strip DEC private mode CSI fragments (#2592) |
| `5249723e1` | fix(engine): recover from turn panics (#2583, #1269) |
| `478bae451` | fix(tui): find deeply nested files via @/Ctrl+P (#2488) |
| `e95f759cd` | fix(tui): command-palette scroll visibility (#2590) |
| `cccc5ed55` | fix(shell): .NET/NuGet + Windows env (#1857) |
| `7aa73fad5` | fix(config): warn on misplaced shell/sandbox keys (#2589) |
| `a7d482067` | fix(clippy): clear -D warnings (#2599) |
| `79d78878b` | test(mcp): deterministic SSE reconnect (#2597) |
| `f886f28ac` | test(tui): update walk-depth test for new default depth |
**Claude branch `origin/claude/busy-mayer-b66rA` is identical to our HEAD** — same commit `f886f28ac`. Nothing to merge; already up to date.
## What's Applied in Working Tree (NOT committed)
4 files modified (88 insertions, 25 deletions):
| File | Change | Credits |
|------|--------|---------|
| `crates/tui/src/tui/diff_render.rs` | `wrap_text` preserves leading whitespace; fixes extra-space bug in PR #2591; adds 2 regression tests | @zlh124 (PR #2591, fix version from working tree) |
| `crates/tui/src/schema_migration.rs` | `#[allow(dead_code)]` on `pub mod registry` | @reidliu41 (PR #2601) |
| `crates/tui/src/prompts/base.md` | Tool desc: prefer `gh --json` CLI for GitHub triage | — |
| `crates/tui/src/prompts/base.txt` | Same prompt update for text variant | — |
## Ready to Apply (patch saved, NOT yet applied)
The model persistence patch is at `/tmp/model_persist.patch` (240 lines, 2 files):
- `crates/tui/src/commands/core.rs`: `/model` command remembers per-provider model selection + persist warning
- `crates/tui/src/settings.rs`: new `set_provider_model_selection()` and `persist_provider_model_selection()` methods
**Apply it:**
```bash
cd /Volumes/VIXinSSD/codewhale
git apply /tmp/model_persist.patch
```
This is small, self-contained, and directly improves UX for the new Arcee provider (model choice remembered across restarts). No dependency on the deferred image-attachment work.
## Deferred (in stash, do NOT apply for v0.8.51)
The stash (`stash@{0}`) contains:
- **Image attachment** (#2584/#2587): `ContentBlock::ImageUrl`, multimodal chat requests, base64 encoding, + exhaustive match arms across 15 files. Deferred by Hmbown — changes the request shape, needs multimodal endpoint testing.
- **GitHub structured route** (`fetch_url.rs`): new feature — routes GitHub issue/PR URLs through `gh` CLI. Too broad for v0.8.51.
- **Config custom model changes** (`commands/config.rs`): `normalize_custom_model_id` etc. Need review.
To view: `git stash show -p stash@{0}`
## What Remains
```bash
cd /Volumes/VIXinSSD/codewhale
# 1. Apply the model persistence patch
git apply /tmp/model_persist.patch
# 2. Commit all working-tree changes as one harvest commit
git add -A
git commit -m "harvest(v0.8.51): diff-render whitespace fix + schema dead_code + model persistence + prompt updates
- fix(diff-render): preserve leading whitespace in patch content lines
Credit: @zlh124 (PR #2591), with extra-space bug fixed.
- fix(tui): allow unused schema migration registry
Credit: @reidliu41 (PR #2601).
- feat(tui): persist per-provider model selection from /model command
- docs(prompts): prefer gh --json CLI for GitHub triage in agent instructions"
# 3. Run release gates
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test -p codewhale-tui -- --test-threads=4
# 4. If gates pass, rebuild
cargo install --path crates/tui --locked --force
codewhale --version
codewhale-tui --version
# 5. Final checks
git diff origin/main --stat
gh issue view 2600 --repo Hmbown/CodeWhale # re-read release checklist
```
## Release Checklist Status (issue #2600)
- [x] Arcee provider landed + tested
- [x] Cycle/checkpoint-restart system removed
- [x] Auto-compaction percentage/model-aware
- [x] Provider/gateway HTTP error sanitization
- [x] TUI fixes (blue dot, sidebar scroll, tooltip)
- [x] CHANGELOG + version bump
- [x] Clippy clean (#2599)
- [x] MCP SSE test deterministic (#2597)
- [ ] Full `cargo test --workspace` green — codewhale-tui validated; 1 environment-only Landlock test may fail on macOS
- [ ] `npm test` in `npm/codewhale`
- [ ] Harvest commits applied + re-gated
- [ ] Merge branch to `main`
- [ ] Tag `v0.8.51`, push tag
- [ ] Publish GitHub release + `npm publish`
## Contributors to Credit
| Contributor | Contribution | PR/Issue |
|-------------|-------------|----------|
| @zlh124 (jayzhu) | diff-render whitespace preservation | #2591 |
| @reidliu41 (Reid) | schema migration dead_code allow | #2601 |
| @xyuai | Image attachment root cause + initial PR | #2587, #2584 |
| @IcedOranges | Image attachment bug report | #2584 |
| @idling11 (Hanmiao Li) | Sidebar resize feature request | #2602 |
| @gordonlu (Gordon) | Engine death recovery | #2585 |
| @cyq1017 | File picker depth fix draft | #2593 |
## Risks / Notes
1. **Working tree was stashed** — the image attachment feature and GitHub structured route are deferred for v0.8.52+. The model persistence patch is the only remaining piece worth landing.
2. **DeepSeek naming**: The branch and committed code use "CodeWhale" naming throughout. Do not imply DeepSeek is deprecated.
3. **The `origin/claude/busy-mayer-b66rA` branch is identical to HEAD** — the Claude Code session in #2600 claimed "+8 commits" but those are the same commits already on this branch. Verify with `git rev-parse HEAD origin/claude/busy-mayer-b66rA`.
4. **Landlock test**: `sandbox::tests::test_parity_linux_landlock_available` will fail on macOS (no Landlock LSM). This is environment-only, not a regression. On CI Linux runners it passes.
5. **Cross-platform artifacts**: The release workflow builds macOS + Windows + NSIS installer on tag push. Not buildable locally on macOS alone.
---
Generated by deepseek-v4-pro in CodeWhale v0.8.51 pre-release triage.
Next session: read this file, apply `/tmp/model_persist.patch`, run gates, commit, and prepare the merge.
+100 -7
View File
@@ -134,10 +134,18 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
}
app.provider_models
.insert(app.api_provider.as_str().to_string(), "auto".to_string());
let persist_warning =
provider_model_selection_persist_warning(app.api_provider, "auto");
let mut message = tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", "auto");
if let Some(warning) = persist_warning {
message.push_str(&warning);
}
return CommandResult::with_message_and_action(
tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", "auto"),
message,
AppAction::UpdateCompaction(app.compaction_config()),
);
}
@@ -181,10 +189,17 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
}
app.provider_models
.insert(app.api_provider.as_str().to_string(), model_id.clone());
let persist_warning = provider_model_selection_persist_warning(app.api_provider, &model_id);
let mut message = tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", &model_id);
if let Some(warning) = persist_warning {
message.push_str(&warning);
}
CommandResult::with_message_and_action(
tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", &model_id),
message,
AppAction::UpdateCompaction(app.compaction_config()),
)
} else {
@@ -192,6 +207,12 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
}
}
fn provider_model_selection_persist_warning(provider: ApiProvider, model: &str) -> Option<String> {
crate::settings::Settings::persist_provider_model_selection(provider, model)
.err()
.map(|err| format!(" (not persisted: {err})"))
}
fn saved_provider_model_match(app: &App, name: &str) -> Option<(ApiProvider, String)> {
let requested = normalize_custom_model_id(name)?;
let mut saved = app
@@ -454,9 +475,49 @@ mod tests {
use crate::models::Message;
use crate::tui::app::{App, AppMode, TuiOptions, TurnCacheRecord};
use crate::tui::history::HistoryCell;
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Instant;
use tempfile::tempdir;
use tempfile::{TempDir, tempdir};
struct SettingsPathGuard {
_tmp: TempDir,
previous: Option<OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl SettingsPathGuard {
fn new() -> Self {
let lock = crate::test_support::lock_test_env();
let tmp = TempDir::new().expect("settings tempdir");
let config_path = tmp.path().join(".deepseek").join("config.toml");
std::fs::create_dir_all(config_path.parent().expect("config parent"))
.expect("config dir");
let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH");
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
}
Self {
_tmp: tmp,
previous,
_lock: lock,
}
}
}
impl Drop for SettingsPathGuard {
fn drop(&mut self) {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
if let Some(previous) = self.previous.take() {
std::env::set_var("DEEPSEEK_CONFIG_PATH", previous);
} else {
std::env::remove_var("DEEPSEEK_CONFIG_PATH");
}
}
}
}
fn create_test_app() -> App {
let options = TuiOptions {
@@ -718,6 +779,7 @@ mod tests {
#[test]
fn test_model_change_updates_state() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
let old_model = app.model.clone();
let result = model(&mut app, Some("deepseek-v4-flash"));
@@ -734,8 +796,34 @@ mod tests {
assert_eq!(app.session.last_completion_tokens, None);
}
#[test]
fn model_command_persists_active_provider_model() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
let result = model(&mut app, Some("deepseek-v4-flash"));
assert!(result.message.is_some());
assert_eq!(
app.provider_models.get("deepseek").map(String::as_str),
Some("deepseek-v4-flash")
);
let settings = crate::settings::Settings::load().expect("load settings");
assert_eq!(settings.default_provider.as_deref(), Some("deepseek"));
assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(
settings
.provider_models
.as_ref()
.and_then(|models| models.get("deepseek"))
.map(String::as_str),
Some("deepseek-v4-flash")
);
}
#[test]
fn model_switch_clears_turn_cache_history() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
// Keep the assertion independent of the developer's saved default model.
app.auto_model = false;
@@ -757,6 +845,7 @@ mod tests {
#[test]
fn model_reset_same_model_keeps_turn_cache_history() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
app.auto_model = false;
app.model = "deepseek-v4-pro".to_string();
@@ -777,6 +866,7 @@ mod tests {
#[test]
fn test_model_auto_enables_auto_thinking() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
app.reasoning_effort = ReasoningEffort::Off;
@@ -792,6 +882,7 @@ mod tests {
#[test]
fn test_model_change_accepts_future_deepseek_model() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
let result = model(&mut app, Some("deepseek-v4"));
assert!(result.message.is_some());
@@ -806,6 +897,7 @@ mod tests {
#[test]
fn test_model_change_accepts_custom_id_for_openai_compatible_provider() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
app.api_provider = crate::config::ApiProvider::Openai;
app.model_ids_passthrough = true;
@@ -823,6 +915,7 @@ mod tests {
#[test]
fn test_model_change_accepts_custom_id_for_custom_base_url() {
let _settings = SettingsPathGuard::new();
let mut app = create_test_app();
app.model_ids_passthrough = true;
+1 -1
View File
@@ -245,7 +245,7 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise
- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `todo_*` aliases (legacy compatibility), `note` (persistent memory).
- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs.
- **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. If foreground `exec_shell` times out, the process was killed; rerun long work with `task_shell_start` or `exec_shell` using `background: true`, then poll/wait.
- **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools.
- **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; for GitHub issue/PR/release triage, prefer the native `gh ... --json` CLI through shell because it is authenticated, structured, and reproducible; `github_issue_context` / `github_pr_context` are read-only fallbacks when the CLI route is unavailable; `github_comment` / `github_close_issue` require approval + evidence; `automation_*` scheduling tools.
- **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse).
- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`.
- **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Open fresh sessions by default; pass `fork_context: true` only when the child needs the current parent context and prefix-cache continuity.
+1 -1
View File
@@ -40,7 +40,7 @@ Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`
- **Planning / tracking**: `checklist_write` (primary Work progress under the active task/thread), `checklist_add` / `checklist_update` / `checklist_list`, `update_plan` (optional high-level strategy metadata for complex initiatives), `task_create` / `task_list` / `task_read` / `task_cancel` (durable work objects), `todo_*` aliases (legacy compatibility), `note` (persistent memory).
- **File I/O**: `read_file` (PDFs auto-extracted), `list_dir`, `write_file`, `edit_file`, `apply_patch`, `retrieve_tool_result` for prior spilled large tool outputs.
- **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`.
- **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools.
- **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; for GitHub issue/PR/release triage, prefer the native `gh ... --json` CLI through shell because it is authenticated, structured, and reproducible; `github_issue_context` / `github_pr_context` are read-only fallbacks when the CLI route is unavailable; `github_comment` / `github_close_issue` require approval + evidence; `automation_*` scheduling tools.
- **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse).
- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`.
- **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Fresh sessions are the default; use `fork_context: true` when multiple perspectives need the current parent context and byte-identical prefill/prompt prefix for DeepSeek prefix-cache reuse. Use `tool_agent` for experimental Fin fast-lane execution: simple tool-bound OCR/search/fetch/probe work on Flash V4 with thinking off.
+1
View File
@@ -205,6 +205,7 @@ pub fn backup_before_migrate(path: &Path, domain: &str) -> PathBuf {
/// 3. Bump `CURRENT_VERSION` to match.
/// 4. Wire `<Domain>Migration::migrate(...)` into the load function in
/// the owning module.
#[allow(dead_code)]
pub mod registry {
use super::{MigrationFn, SchemaMigration};
+27 -1
View File
@@ -10,7 +10,7 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::{expand_path, normalize_model_name};
use crate::config::{ApiProvider, expand_path, normalize_model_name};
use crate::localization::normalize_configured_locale;
use crate::palette::{normalize_hex_rgb_color, normalize_theme_name};
@@ -943,6 +943,32 @@ impl Settings {
.insert(provider.to_string(), model.to_string());
}
/// Persist the active provider/model tuple that runtime selection UI and
/// slash commands should restore on the next startup.
pub fn set_provider_model_selection(
&mut self,
provider: ApiProvider,
model: &str,
) -> Result<()> {
let model = model.trim();
if model.is_empty() {
anyhow::bail!("model cannot be empty");
}
self.default_provider = Some(provider.as_str().to_string());
self.set_model_for_provider(provider.as_str(), model);
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
self.set("default_model", model)?;
}
Ok(())
}
/// Load, update, and save the runtime provider/model selection.
pub fn persist_provider_model_selection(provider: ApiProvider, model: &str) -> Result<()> {
let mut settings = Self::load()?;
settings.set_provider_model_selection(provider, model)?;
settings.save()
}
/// Resolved boolean for whether the renderer should wrap each frame in
/// DEC mode 2026 synchronized output. `auto` and `on` enable; `off`
/// disables. The `auto` → `off` flip for known-bad terminals happens
+96 -34
View File
@@ -341,43 +341,61 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = word.width();
if word_width > width {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
continue;
}
let additional = if current.is_empty() {
word_width
} else {
word_width + 1
};
if current_width + additional > width && !current.is_empty() {
lines.push(current);
current = word.to_string();
current_width = word_width;
} else {
if !current.is_empty() {
current.push(' ');
current_width += 1;
}
current.push_str(word);
current_width += word_width;
}
let lead = text
.chars()
.take_while(|ch| ch.is_whitespace())
.collect::<String>();
let trimmed = text.trim_start();
if trimmed.is_empty() {
return vec![text.to_string()];
}
if current.is_empty() {
lines.push(String::new());
} else {
let mut lines = Vec::new();
let lead_width = lead.width();
let mut current = lead.clone();
let mut current_width = lead_width;
let mut has_word = false;
for word in trimmed.split_whitespace() {
let word_width = word.width();
if word_width > width {
if has_word {
lines.push(std::mem::take(&mut current));
current = lead.clone();
current_width = lead_width;
}
push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
has_word = current_width > lead_width;
continue;
}
let additional = if has_word { word_width + 1 } else { word_width };
if current_width + additional > width && has_word {
lines.push(current);
current = lead.clone();
current_width = lead_width;
has_word = false;
}
if has_word {
current.push(' ');
current_width += 1;
}
if current_width + word_width > width && !has_word && lead_width > 0 {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
if current_width == 0 && lead_width > 0 && word_width + lead_width <= width {
current = lead.clone();
current_width = lead_width;
}
current.push_str(word);
current_width += word_width;
has_word = true;
}
if has_word || !current.is_empty() {
lines.push(current);
} else {
lines.push(String::new());
}
lines
@@ -412,6 +430,10 @@ mod tests {
.collect()
}
fn diff_content_text(line: &Line<'static>) -> Option<String> {
line.spans.get(1).map(|span| span.content.to_string())
}
#[test]
fn summarizes_multi_file_diff() {
let diff = "\
@@ -467,6 +489,46 @@ diff --git a/src/a.rs b/src/a.rs
);
}
#[test]
fn wrap_text_preserves_leading_whitespace_without_extra_space() {
assert_eq!(wrap_text(" let y = 2;", 80), vec![" let y = 2;"]);
assert_eq!(
wrap_text(" println!(\"hello\");", 80),
vec![" println!(\"hello\");"]
);
}
#[test]
fn render_diff_preserves_leading_whitespace_exactly() {
let diff = "\
diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,2 +1,3 @@
fn main() {
+ let y = 2;
+ println!(\"{y}\");
}
";
let rendered = render_diff(diff, 80);
let content = rendered
.iter()
.filter_map(diff_content_text)
.collect::<Vec<_>>();
assert!(
content.iter().any(|line| line == " let y = 2;"),
"added line should keep exact 4-space indent: {content:?}"
);
assert!(
content
.iter()
.any(|line| line == " println!(\"{y}\");"),
"added line should keep exact 8-space indent: {content:?}"
);
}
#[test]
fn wrap_text_breaks_overlong_cjk_runs() {
let text = "这是一个非常长的中文字符串".repeat(10);