From c000bd7e606f66ddd4a16145efc8a34918e611bb Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 2 Jun 2026 20:30:31 -0700 Subject: [PATCH] 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 --- HANDOFF_v0.8.51.md | 140 +++++++++++++++++++++++++++++ crates/tui/src/commands/core.rs | 107 ++++++++++++++++++++-- crates/tui/src/prompts/base.md | 2 +- crates/tui/src/prompts/base.txt | 2 +- crates/tui/src/schema_migration.rs | 1 + crates/tui/src/settings.rs | 28 +++++- crates/tui/src/tui/diff_render.rs | 130 ++++++++++++++++++++------- 7 files changed, 366 insertions(+), 44 deletions(-) create mode 100644 HANDOFF_v0.8.51.md diff --git a/HANDOFF_v0.8.51.md b/HANDOFF_v0.8.51.md new file mode 100644 index 00000000..175485e4 --- /dev/null +++ b/HANDOFF_v0.8.51.md @@ -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. diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 21e5b38d..c8f32bdd 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -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 { + 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, + _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; diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 2a576f9e..fa441e07 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -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. diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index c347cafb..7346f127 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -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. diff --git a/crates/tui/src/schema_migration.rs b/crates/tui/src/schema_migration.rs index 2ea3fba9..9b886ad6 100644 --- a/crates/tui/src/schema_migration.rs +++ b/crates/tui/src/schema_migration.rs @@ -205,6 +205,7 @@ pub fn backup_before_migrate(path: &Path, domain: &str) -> PathBuf { /// 3. Bump `CURRENT_VERSION` to match. /// 4. Wire `Migration::migrate(...)` into the load function in /// the owning module. +#[allow(dead_code)] pub mod registry { use super::{MigrationFn, SchemaMigration}; diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 42fcfe13..e00c349b 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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 diff --git a/crates/tui/src/tui/diff_render.rs b/crates/tui/src/tui/diff_render.rs index ac8cb7bc..cbcf30cc 100644 --- a/crates/tui/src/tui/diff_render.rs +++ b/crates/tui/src/tui/diff_render.rs @@ -341,43 +341,61 @@ fn wrap_text(text: &str, width: usize) -> Vec { 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::(); + 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 { + 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::>(); + + 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);