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:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user