v0.8.5: config test fixes + default_model session-apply bugfix (#381)

* feat: add config UI support for TUI and web modes

- Introduced a new `config_ui.rs` module to handle configuration UI for TUI and web.
- Updated `TuiOptions` and `App` structures to include `config_path` and `config_profile`.
- Implemented functions to build and apply configuration documents.
- Added tests to ensure the new configuration UI behaves as expected.
- Integrated web configuration session handling into the event loop.
- Updated various modules to accommodate the new configuration options and UI.

* refactor(tui): remove local path reference for schemaui dependency

Remove the local file system path reference for schemaui in favor of
using the published crate from the registry. This change updates the
Cargo.toml to use only the version specification and adds the source
and checksum information to Cargo.lock.

* fix: add AGENTS.md guide and improve config error handling

- Add comprehensive AGENTS.md file with project instructions for AI
  assistants, including build commands, dependencies, and GitHub
  operations guidance
- Introduce is_error field to CommandResult struct for better error
  tracking
- Refactor config application logic to properly handle errors using
  the new is_error flag
- Add test utilities for WebConfigSession to support testing
- Optimize web config event polling by extracting drain logic into
  separate function
- Add unit tests for session-only config application and engine sync
  requirements

* fix(security): add SSRF protection to fetch_url (#261)

Block private, link-local, and cloud metadata IPs in fetch_url HTTP requests. Co-authored-by: JasonOA888

* test(portability): inject paths instead of mutating HOME (Windows fix)

CI's `Test (windows-latest)` job failed because both my new tests
(composer_history and the spawn_supervised crash-dump test) mutated
HOME to redirect `dirs::home_dir()`. That works on macOS / Linux but
not on Windows, where dirs::home_dir() reads USERPROFILE / queries
SHGetKnownFolderPath rather than HOME.

Fix: refactor both modules to expose path-injecting helpers so tests
never need to touch the env var:

- composer_history: split load_history / append_history into thin
  wrappers around load_history_from(&Path) / append_history_to(&Path).
  Tests use the *_to / *_from form with a tempdir path.
- utils::write_panic_dump: same pattern — write_panic_dump_to(&Path)
  takes the crash dir directly. The spawn_supervised end-to-end test
  splits into two: one verifies panic-doesn't-propagate (no on-disk
  side effect needed), one verifies write_panic_dump_to writes the
  expected log format.

Production callers continue to use the env-driven default (`HOME`/
`USERPROFILE` via `dirs::home_dir()`) so no behavior change. Tests
work identically on every platform now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tui): clear chat area each frame so stale cells don't bleed into sidebar

ChatWidget's render path was `Paragraph::new(lines).render(content_area, buf)`
with no Block and no Clear — ratatui's Paragraph only writes cells that
contain text, leaving any cell the current frame's paragraph doesn't
touch holding the *previous* frame's contents. With wide tool output
(`gh pr list`, `git log`) emitting ISO-8601 timestamps like
`2026-05-02T07:29:24Z`, then a subsequent shorter-paragraph frame, the
old timestamp tails (`:24Z`, `7:29:24Z`, etc.) persisted on the right
edge of the chat area, visually colliding with the section headers in
the sidebar (`Plan` rendering as `:24Zan`, `Agents` as `:24Zents`).

Fix: render `Clear` over the full content_area before drawing the
Paragraph. Cheap (one buffer-fill per frame) and guarantees stale cells
can never persist into the next frame's render.

Reported in v0.8.5 testing right after install. The other v0.8.5
bordered widgets (composer, sidebar sections, footer) already render
into a Block with a solid background style, so they were never
affected — only the chat area used a bare Paragraph.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(theme): vendor + theme schemaui to deepseek navy palette (config UI)

The schemaui-0.12.0 crate the contributor brought in via #365 ships
hardcoded Color::Gray / Color::DarkGray / Color::White / Color::Yellow
references across its rendering components. Visually it clashed with
the rest of deepseek-tui — the editor area read as gray-on-black on a
TUI that's otherwise navy ink + sky accents. Two ship-day options
weren't acceptable: defaulting back to the legacy modal lost the new
editor's UX, and living with gray was off-brand.

This commit forks schemaui at 0.12.0 into vendor/schemaui-0.12.0 and
themes the rendering layer to match deepseek-tui's palette. The patch
is wired in via a workspace-level [patch.crates-io] override so the
deepseek-tui Cargo.toml continues to depend on `schemaui = "0.12.0"`
and would automatically resolve back to crates.io if we ever drop the
override (e.g. once upstream lands a ColorTheme API).

Changes inside the vendored fork:

- New `src/deepseek_palette.rs` with the brand RGB values:
  SURFACE_INK / SURFACE_RAISED for backgrounds, BORDER_DIM /
  BORDER_ACTIVE for chrome, TEXT_PRIMARY / TEXT_MUTED / TEXT_DIM,
  ACCENT_SKY / ACCENT_BLUE / ACCENT_PURPLE, and STATUS_OK / WARN /
  ERROR. Values mirror crates/tui/src/palette.rs in the workspace.
- `src/lib.rs` exposes the palette module under `cfg(feature = "tui")`.
- `src/tui/view/frame.rs::draw` paints a navy backdrop across the
  full frame area before any child widget renders, so any cell that
  doesn't get explicitly written reads as ink instead of the terminal
  default.
- `tabstrip.rs`, `overlay.rs`, `popup.rs`, `body.rs`, `sections.rs`,
  `footer.rs`, `help.rs`, `fields.rs`: every Color::Gray / DarkGray /
  White / Yellow / Cyan / Blue / Magenta / Red / Green / LightBlue
  swapped out for a deepseek_palette token, plus explicit `bg(...)`
  fills on the top-level Block styles and Paragraph wrappers.
- `Cargo.toml` adds an empty `[workspace]` so the vendored crate
  builds standalone (its dev-deps don't drift into ours).

Workspace-level changes:

- `Cargo.toml` adds `[patch.crates-io] schemaui = { path =
  "vendor/schemaui-0.12.0" }`. Production deepseek-tui builds pick up
  the themed fork transparently.
- `.gitignore` excludes `vendor/.../web/ui/node_modules/` (15 MB of
  npm artefacts the Rust build doesn't need) and the vendored
  Cargo.lock (regenerated locally per build).

Verification:
- cargo build --workspace --all-features: clean
- cargo clippy --workspace --all-targets --all-features --locked: clean
- cargo test --workspace: 1777 passed, 0 failed
- /config inside `deepseek` now opens a navy-themed editor matching
  the rest of the TUI; tabs, body panel, footer, popup, and help
  overlay all read on brand.

Future work tracked separately: upstream a `with_theme(ColorTheme)`
builder API to schemaui so we can drop the fork. Until then, sync the
fork against new schemaui releases when we want their fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "feat(theme): vendor + theme schemaui to deepseek navy palette"

This reverts ed597ccc — vendoring 28,913 lines of schemaui to recolor
a config editor was the wrong tradeoff. Maintenance cost for a
cosmetic match wasn't worth it, and the recolor wasn't even fully
working (terminal-default bg kept bleeding through Style::default()
calls in the form fields).

The simpler path: keep the schemaui-driven editor available as
`/config tui` for users who want the form-style UX, but make bare
`/config` open the legacy native modal that already matches the
deepseek-tui navy chrome by inheritance. No fork, no vendored copy,
no ongoing sync burden.

Changes:
- `git rm -r vendor/schemaui-0.12.0/` (28,913 lines gone)
- Drop `[patch.crates-io]` from workspace Cargo.toml — schemaui
  resolves back to crates.io v0.12.0 unmodified.
- Drop the corresponding `.gitignore` exclusions (no more vendor dir
  to filter).
- `config_ui::parse_mode` default mode flipped from `Tui` to `Native`.
  Bare `/config` → legacy navy modal. Explicit `/config tui` → the
  contributor's schemaui editor (still available, gray-on-default
  chrome, but opt-in). `/config web` and `/config <key>` /
  `/config <key> <value>` unchanged.
- Help text updated to list `[native|tui|web]` in that order.

Verified: cargo build / clippy --workspace --all-features --locked
with -D warnings: clean.

The contributor's work (#365) ships and gets credit; users discover
the alternate editor via the help text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tui): paint chat area with explicit navy ink instead of Clear

The Clear-instead-of-fill in 0ae2cead reset cells to the terminal's
default background, which read as a brown-gray on most user setups
even though the rest of the TUI chrome is navy. Replace the Clear
with an explicit Block fill at palette::DEEPSEEK_INK, and pass the
same bg through to the Paragraph itself so streamed text cells
inherit ink rather than bouncing back to terminal default.

Net effect: the chat area visually unifies with the sidebar /
composer / footer instead of showing as a contrasting brown-gray
panel in the middle of an otherwise navy frame.

Stale-cell guarantee from #372-followup is preserved — the Block
fills every cell in the area on each frame, so wide tool output
(`gh pr list` ISO timestamps, etc.) still can't bleed past the
current frame's actual text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(config): update tests for Native default + fix default_model override in session-only apply

- Update test_show_config_defaults_to_native and
  execute_config_opens_config_view_action to expect
  OpenConfigView (Native) instead of OpenConfigEditor(Tui),
  matching the parse_mode default change from ce98f054.

- Fix apply_document bug where default_model was processed
  in the main key-value loop after model, causing
  set_config_value('default_model') to overwrite the
  runtime model. default_model is now only applied when
  persist=true, preventing session-only edits from being
  silently reverted.

* style: cargo fmt

* chore: remove end-of-night report (session artifact)

---------

Co-authored-by: unic <yuniqueunic@gmail.com>
Co-authored-by: Jason <jason@aveoresearchlabs.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: YuniqueUnic <YuniqueUnic@users.noreply.github.com>
This commit is contained in:
Hunter Bown
2026-05-02 16:25:03 -05:00
committed by GitHub
parent 43ccbe4a30
commit 2d61513a9e
36 changed files with 4399 additions and 279 deletions
+1
View File
@@ -73,3 +73,4 @@ apps/
.claude/scheduled_tasks.lock
.claude/worktrees/
.worktrees/
.ace-tool/
Generated
+625 -41
View File
File diff suppressed because it is too large Load Diff
-109
View File
@@ -1,109 +0,0 @@
# End-of-Night Report — v0.8.5 Backlog Sprint
**Date:** Overnight session
**Branch:** feat/v0.8.5 (HEAD a8be33b3)
**Baseline:** Clean git status, clippy passes, 1755/1756 tests pass (1 pre-existing env-dependent config failure)
---
## Summary
Three stacked incremental features landed tonight. Each was scoped as a self-contained commit so they can be cherry-picked or reverted independently.
---
## Completed
### #361 — `ApiProvider::DeepseekCN` for China Endpoint ✅
**Commit:** `e5f56dee`
- Added `ApiProvider::DeepseekCN` variant to the core enum
- Default base URL: `https://api.deepseeki.com`
- Auto-detect: if `base_url` contains `api.deepseeki.com`, treat as DeepseekCN
- Locale auto-suggest: if no provider is configured and system locale is `zh-*`, default to DeepseekCN at startup
- All match arms updated across config.rs, client.rs, provider_picker.rs, main.rs, ui.rs, and command_palette.rs
- Provider picker now shows 7 entries (DeepseekCN inserted after Deepseek)
- Provider picker test updated for the new entry (up → up → enter now targets Deepseek instead of up → enter)
### #355 — Atomic File Writes for ~/.deepseek/ ✅
**Commit:** `5bd63c77`
- Added `write_atomic(path, contents)` helper in `utils.rs` using `NamedTempFile` + `fsync` + `persist` (atomic rename)
- Added `open_append(path)` and `flush_and_sync(writer)` for append-only logs
- Converted all non-append write sites:
- `session_manager.rs`: `save_session`, `save_checkpoint`, `save_offline_queue_state`
- `workspace_trust.rs`: `write_trust_file_at`
- `task_manager.rs`: `write_json_atomic` → delegates to `write_atomic`
- `runtime_threads.rs`: `write_json_atomic` → delegates to `write_atomic`, `append_event` now calls `sync_all`
- `mcp.rs`: `save_config`, `init_config`, `save_legacy`
- `audit.rs`: buffered append with `flush_and_sync` after each event
- `main.rs`: `save_mcp_config``write_atomic`
- Added 4 unit tests covering writing, replacing, temp-file cleanup, and append
### #346 — Panic Safety Foundations ✅ (partial)
**Commit:** `a8be33b3`
- Added `spawn_supervised(name, location, future)` to `utils.rs`:
- Wraps future in `AssertUnwindSafe` + `catch_unwind` (via `futures_util::FutureExt`)
- On panic: logs via `tracing::error!`, writes crash dump to `~/.deepseek/crashes/<timestamp>-<task>.log`
- Returns `JoinHandle<()>` — panic is caught internally so parent stays alive
- Added `write_panic_dump()` helper for crash dump writing
- Added process-level panic hook in `main.rs` that writes crash dump before invoking original hook
- Converted `persistence_actor::spawn_persistence_actor` as the first `spawn_supervised` caller
**Remaining:** ~34 `tokio::spawn` sites still unconverted (low risk — tokio isolates panicked tasks from the process; this gap is just crash dump coverage + structured logging).
---
## Not Completed
### Phase 2 Issues (all untouched)
| Issue | Scope | Reason deferred |
|-------|-------|----------------|
| #338 | `/config <key> <value>` wiring | Not started — well-scoped, could be done next |
| #342 | Paste in provider picker | Not started — needs UI event routing |
| #343 | `/logout` stale key | Not started — needs client rebuild |
| #345 | Submit-disposition UX | Not started — larger UX change |
| #286/#352 | NVIDIA NIM / China endpoint CI | Not started — integration-test scope |
---
## Key Decisions & Design Notes
### #361 — DeepseekCN shares API key slot with Deepseek
Both variants use the same `DEEPSEEK_API_KEY` env var and keyring slot (`deepseek`). The distinction is purely the base URL (`api.deepseek.com` vs `api.deepseeki.com`). The config stores a `[providers.deepseek_cn]` block for provider-scoped overrides but the credential is shared.
### #355 — Task artifact writes excluded from atomic conversion
`task_manager.rs:1346` writes task artifacts to `~/.deepseek/<data_dir>/artifacts/<task_id>/`. These are secondary outputs — losing one to a crash is inconvenient but not dangerous. Left as bare `fs::write` to avoid unnecessary `NamedTempFile` churn.
### #346 — Only 1 of ~15 production `tokio::spawn` sites converted
The `spawn_supervised` wrapper exists and is proved by `persistence_actor`. Converting every spawn site is mechanically safe but requires per-site analysis (some spawns need `JoinHandle<T>` for `.await` on the result). The remaining 14 production sites are straightforward fire-and-forget patterns that don't need return values.
---
## Pre-existing Test Failures
Two config tests fail in CI due to environment-dependent `dirs::home_dir()` behavior:
- `config::tests::test_load_falls_back_to_home_config_when_env_path_missing`
- `config::tests::test_load_uses_tilde_expanded_deepseek_config_path`
These are sandbox issues where `HOME` env resolution differs from `dirs::home_dir()`. Not caused by these changes.
---
## Coverage Summary
| Metric | Value |
|--------|-------|
| New commits | 3 |
| Issues fully addressed | 2 (#355, #361) |
| Issues partially addressed | 1 (#346) |
| Files changed | ~18 |
| Lines added | ~360 |
| New tests | 4 (atomic writes) |
| Clippy | Clean |
| Test suite | 1755/1756 pass (1 pre-existing env failure) |
+10 -1
View File
@@ -7,6 +7,13 @@ repository.workspace = true
description = "Terminal UI for DeepSeek"
default-run = "deepseek-tui"
[features]
default = ["tui", "json", "toml"]
tui = ["dep:schemaui", "schemaui/tui", "json", "toml"]
web = ["dep:schemaui", "schemaui/web", "json", "toml"]
json = ["schemaui/json"]
toml = ["schemaui/toml"]
[[bin]]
name = "deepseek-tui"
path = "src/main.rs"
@@ -16,6 +23,7 @@ anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.8.5" }
deepseek-tools = { path = "../tools", version = "0.8.5" }
schemaui = { version = "0.12.0", default-features = false, optional = true }
async-stream = "0.3.6"
async-trait = "0.1"
base64 = "0.22.1"
@@ -33,7 +41,8 @@ regex = "1.11"
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
rustyline = "15.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_json = { version = "1.0.149", features = ["preserve_order"] }
schemars = { version = "1.2.1", features = ["derive", "preserve_order"] }
shellexpand = "3"
toml = "0.9.7"
tokio = { version = "1.49.0", features = ["full"] }
+2
View File
@@ -72,6 +72,8 @@ mod tests {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
+49 -9
View File
@@ -4,30 +4,59 @@ use std::path::{Path, PathBuf};
use super::CommandResult;
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::localization::resolve_locale;
use crate::settings::Settings;
use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus};
use crate::tui::approval::ApprovalMode;
/// Open the interactive config editor modal, or handle `/config <key> <value>`.
pub fn show_config(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenConfigView)
/// Open the interactive config editor.
///
/// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action),
/// preserving the v0.8.4 behaviour. `/config tui` opens the new
/// schemaui-driven TUI editor; `/config web` launches the web editor (only
/// available in builds compiled with the `web` feature).
pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
let mode = match parse_mode(arg) {
Ok(mode) => mode,
Err(err) => return CommandResult::error(err),
};
if mode == ConfigUiMode::Web && !cfg!(feature = "web") {
return CommandResult::error(
"This build does not include the web config UI. Rebuild with the `web` feature.",
);
}
let action = match mode {
ConfigUiMode::Native => AppAction::OpenConfigView,
ConfigUiMode::Tui | ConfigUiMode::Web => AppAction::OpenConfigEditor(mode),
};
CommandResult::action(action)
}
/// Dispatch `/config` with optional args.
///
/// - `/config` (no args) — opens the interactive editor modal.
/// - `/config` (no args) — opens the schemaui-driven TUI editor.
/// - `/config tui` / `/config web` / `/config native` — open a specific
/// editor mode (web requires the `web` build feature).
/// - `/config <key>` — shows the current value of a setting.
/// - `/config <key> <value>` — sets a runtime value (session only, no --save).
pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
if raw.is_empty() {
return show_config(app);
return show_config(app, None);
}
let parts: Vec<&str> = raw.splitn(2, ' ').collect();
if parts.len() == 1 {
// Single arg: editor-mode shortcut OR show-value request.
let token = parts[0];
if matches!(
token.to_ascii_lowercase().as_str(),
"tui" | "web" | "native"
) {
return show_config(app, Some(token));
}
// `/config <key>` — show current value
show_single_setting(app, parts[0])
show_single_setting(app, token)
} else {
// `/config <key> <value>` — set value
set_config_value(app, parts[0], parts[1], false)
@@ -159,7 +188,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu
Ok(path)
}
fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;
@@ -370,6 +399,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
CommandResult {
message: Some(message),
action,
is_error: false,
}
}
@@ -648,6 +678,8 @@ mod tests {
let options = TuiOptions {
model: "test-model".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
@@ -689,10 +721,18 @@ mod tests {
}
#[test]
fn test_show_config_opens_config_editor() {
fn test_show_config_defaults_to_native() {
let mut app = create_test_app();
app.total_tokens = 1234;
let result = show_config(&mut app);
let result = show_config(&mut app, None);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
#[test]
fn test_show_config_native_opens_legacy_editor() {
let mut app = create_test_app();
let result = show_config(&mut app, Some("native"));
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
+2
View File
@@ -266,6 +266,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -151,6 +151,8 @@ mod tests {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -267,6 +267,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -163,6 +163,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -72,6 +72,8 @@ mod tests {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
+2
View File
@@ -78,6 +78,8 @@ mod tests {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
+16
View File
@@ -30,6 +30,8 @@ pub struct CommandResult {
pub message: Option<String>,
/// Optional action for the app to take
pub action: Option<AppAction>,
/// Whether the command failed.
pub is_error: bool,
}
impl CommandResult {
@@ -38,6 +40,7 @@ impl CommandResult {
Self {
message: None,
action: None,
is_error: false,
}
}
@@ -46,6 +49,7 @@ impl CommandResult {
Self {
message: Some(msg.into()),
action: None,
is_error: false,
}
}
@@ -54,6 +58,7 @@ impl CommandResult {
Self {
message: None,
action: Some(action),
is_error: false,
}
}
@@ -63,6 +68,7 @@ impl CommandResult {
Self {
message: Some(msg.into()),
action: Some(action),
is_error: false,
}
}
@@ -71,6 +77,7 @@ impl CommandResult {
Self {
message: Some(format!("Error: {}", msg.into())),
action: None,
is_error: true,
}
}
}
@@ -501,6 +508,11 @@ pub fn persist_status_items(
config::persist_status_items(items)
}
/// Persist a root-level string key in `config.toml`.
pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<std::path::PathBuf> {
config::persist_root_string_key(key, value)
}
/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from
/// Zhang et al. (arXiv:2512.24601).
///
@@ -665,6 +677,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
@@ -792,6 +806,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: workspace.clone(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -60,6 +60,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -72,6 +72,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -139,6 +139,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -112,6 +112,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace,
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -73,6 +73,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -223,6 +223,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -365,6 +365,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -52,6 +52,8 @@ mod tests {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
+62 -73
View File
@@ -12,7 +12,7 @@
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Hard cap on persisted history. Keeps the file small (typical entries
/// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
@@ -21,7 +21,7 @@ pub const MAX_HISTORY_ENTRIES: usize = 1000;
const HISTORY_FILE_NAME: &str = "composer_history.txt";
fn history_path() -> Option<PathBuf> {
fn default_history_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join(HISTORY_FILE_NAME))
}
@@ -29,10 +29,14 @@ fn history_path() -> Option<PathBuf> {
/// file doesn't exist or can't be parsed — this is best-effort.
#[must_use]
pub fn load_history() -> Vec<String> {
let Some(path) = history_path() else {
let Some(path) = default_history_path() else {
return Vec::new();
};
let Ok(file) = fs::File::open(&path) else {
load_history_from(&path)
}
fn load_history_from(path: &Path) -> Vec<String> {
let Ok(file) = fs::File::open(path) else {
return Vec::new();
};
BufReader::new(file)
@@ -49,13 +53,17 @@ pub fn load_history() -> Vec<String> {
/// Best-effort — failures are logged via `tracing` but not propagated
/// because composer history is a UX nicety, not a correctness concern.
pub fn append_history(entry: &str) {
let Some(path) = default_history_path() else {
return;
};
append_history_to(&path, entry);
}
fn append_history_to(path: &Path, entry: &str) {
let trimmed = entry.trim();
if trimmed.is_empty() || trimmed.starts_with('/') {
return;
}
let Some(path) = history_path() else {
return;
};
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
@@ -68,7 +76,7 @@ pub fn append_history(entry: &str) {
// Read existing entries, append the new one, prune from the front
// until under the cap, then atomically rewrite.
let mut entries = load_history();
let mut entries = load_history_from(path);
if entries.last().map(String::as_str) == Some(trimmed) {
// De-dupe consecutive duplicates — repeated submission of the
// same prompt shouldn't bloat the file.
@@ -81,7 +89,7 @@ pub fn append_history(entry: &str) {
}
let payload = entries.join("\n") + "\n";
if let Err(err) = crate::utils::write_atomic(&path, payload.as_bytes()) {
if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
tracing::warn!(
"Failed to persist composer history at {}: {err}",
path.display()
@@ -93,94 +101,75 @@ pub fn append_history(entry: &str) {
mod tests {
use super::*;
fn with_temp_home<R>(f: impl FnOnce() -> R) -> R {
// Use the crate-wide test env mutex so we don't race with other
// tests (config, restore, etc.) that also mutate HOME.
let _guard = crate::test_support::lock_test_env();
/// Tests use the path-injecting `*_from` / `*_to` helpers so they
/// don't have to mutate `HOME` (which is not honored by
/// `dirs::home_dir()` on Windows — it reads `USERPROFILE` /
/// `SHGetKnownFolderPath` instead). This makes the suite portable
/// across all three CI runners without per-platform env juggling.
fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var_os("HOME");
// SAFETY: env mutation is serialized by the lock above.
unsafe { std::env::set_var("HOME", tmp.path()) };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prev {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
match result {
Ok(r) => r,
Err(p) => std::panic::resume_unwind(p),
}
let path = tmp.path().join(HISTORY_FILE_NAME);
(tmp, path)
}
#[test]
fn append_and_load_round_trip() {
with_temp_home(|| {
append_history("first");
append_history("second");
append_history("third");
let history = load_history();
assert_eq!(history, vec!["first", "second", "third"]);
});
let (_tmp, path) = temp_history_path();
append_history_to(&path, "first");
append_history_to(&path, "second");
append_history_to(&path, "third");
assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
}
#[test]
fn slash_commands_skipped() {
with_temp_home(|| {
append_history("/help");
append_history("real prompt");
append_history("/cost");
let history = load_history();
assert_eq!(history, vec!["real prompt"]);
});
let (_tmp, path) = temp_history_path();
append_history_to(&path, "/help");
append_history_to(&path, "real prompt");
append_history_to(&path, "/cost");
assert_eq!(load_history_from(&path), vec!["real prompt"]);
}
#[test]
fn empty_and_whitespace_skipped() {
with_temp_home(|| {
append_history("");
append_history(" ");
append_history("\n\t");
append_history("real");
let history = load_history();
assert_eq!(history, vec!["real"]);
});
let (_tmp, path) = temp_history_path();
append_history_to(&path, "");
append_history_to(&path, " ");
append_history_to(&path, "\n\t");
append_history_to(&path, "real");
assert_eq!(load_history_from(&path), vec!["real"]);
}
#[test]
fn consecutive_duplicates_deduped() {
with_temp_home(|| {
append_history("same");
append_history("same");
append_history("same");
append_history("different");
append_history("same");
let history = load_history();
assert_eq!(history, vec!["same", "different", "same"]);
});
let (_tmp, path) = temp_history_path();
append_history_to(&path, "same");
append_history_to(&path, "same");
append_history_to(&path, "same");
append_history_to(&path, "different");
append_history_to(&path, "same");
assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
}
#[test]
fn pruned_to_cap_at_append_time() {
with_temp_home(|| {
for i in 0..(MAX_HISTORY_ENTRIES + 50) {
append_history(&format!("entry {i}"));
}
let history = load_history();
assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
// Newest entries survive; oldest 50 were pruned.
assert_eq!(history.first().map(String::as_str), Some("entry 50"));
assert_eq!(
history.last().map(String::as_str),
Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
);
});
let (_tmp, path) = temp_history_path();
for i in 0..(MAX_HISTORY_ENTRIES + 50) {
append_history_to(&path, &format!("entry {i}"));
}
let history = load_history_from(&path);
assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
// Newest entries survive; oldest 50 were pruned.
assert_eq!(history.first().map(String::as_str), Some("entry 50"));
assert_eq!(
history.last().map(String::as_str),
Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
);
}
#[test]
fn missing_file_loads_empty() {
with_temp_home(|| {
let history = load_history();
assert!(history.is_empty());
});
let (_tmp, path) = temp_history_path();
assert!(load_history_from(&path).is_empty());
}
}
+940
View File
@@ -0,0 +1,940 @@
#[cfg(feature = "web")]
use std::net::SocketAddr;
#[cfg(feature = "web")]
use std::process::Command;
#[cfg(feature = "web")]
use std::time::Duration;
use anyhow::{Context, Result, bail};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::commands;
use crate::config::{Config, StatusItem, normalize_model_name};
use crate::localization::{normalize_configured_locale, resolve_locale};
use crate::settings::Settings;
use crate::tui::app::{
App, AppMode, ComposerDensity, ReasoningEffort, SidebarFocus, TranscriptSpacing,
};
use crate::tui::approval::ApprovalMode;
#[cfg(feature = "web")]
use schemaui::web::session::{ServeOptions, WebSessionBuilder, bind_session};
#[cfg(feature = "tui")]
use schemaui::{FrontendOptions, SchemaUI, UiOptions};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigUiMode {
Native,
Tui,
Web,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct ConfigUiDocument {
pub runtime: RuntimeSection,
pub settings: SettingsSection,
pub config: ConfigSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RuntimeSection {
#[schemars(title = "Current model")]
pub model: String,
pub approval_mode: ApprovalModeValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct SettingsSection {
pub auto_compact: bool,
pub calm_mode: bool,
pub low_motion: bool,
pub fancy_animations: bool,
pub paste_burst_detection: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
pub locale: UiLocale,
pub composer_density: ComposerDensityValue,
pub composer_border: bool,
pub transcript_spacing: TranscriptSpacingValue,
pub default_mode: DefaultModeValue,
#[schemars(range(min = 10, max = 50))]
pub sidebar_width: u16,
pub sidebar_focus: SidebarFocusValue,
#[schemars(range(min = 0))]
pub max_history: usize,
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct ConfigSection {
pub mcp_config_path: String,
pub reasoning_effort: ReasoningEffortValue,
#[schemars(title = "Status line items")]
pub status_items: Vec<StatusItemValue>,
}
#[derive(Debug, Clone)]
pub struct ConfigUiApplyOutcome {
pub changed: bool,
pub final_message: String,
pub requires_engine_sync: bool,
}
#[cfg(feature = "web")]
#[derive(Debug)]
pub struct WebConfigSession {
#[allow(dead_code)]
task: tokio::task::JoinHandle<()>,
pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
pub addr: SocketAddr,
}
#[cfg(not(feature = "web"))]
#[derive(Debug)]
pub struct WebConfigSession {
#[allow(dead_code)]
pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
}
#[cfg(test)]
impl WebConfigSession {
pub(crate) fn for_test(
receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
) -> Self {
#[cfg(feature = "web")]
{
Self {
task: tokio::spawn(async {}),
receiver,
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
}
}
#[cfg(not(feature = "web"))]
{
Self { receiver }
}
}
}
#[cfg_attr(not(feature = "web"), allow(dead_code))]
#[derive(Debug, Clone)]
pub enum WebConfigSessionEvent {
Draft(ConfigUiDocument),
Committed(ConfigUiDocument),
Failed(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalModeValue {
Auto,
Suggest,
Never,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub enum UiLocale {
#[serde(rename = "auto")]
#[schemars(rename = "auto")]
Auto,
#[serde(rename = "en")]
#[schemars(rename = "en")]
En,
#[serde(rename = "ja")]
#[schemars(rename = "ja")]
Ja,
#[serde(rename = "zh-Hans")]
#[schemars(rename = "zh-Hans")]
ZhHans,
#[serde(rename = "pt-BR")]
#[schemars(rename = "pt-BR")]
PtBr,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ComposerDensityValue {
Compact,
Comfortable,
Spacious,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptSpacingValue {
Compact,
Comfortable,
Spacious,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DefaultModeValue {
Agent,
Plan,
Yolo,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SidebarFocusValue {
Auto,
Plan,
Todos,
Tasks,
Agents,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffortValue {
Off,
Low,
Medium,
High,
Max,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StatusItemValue {
Mode,
Model,
Cost,
Status,
Coherence,
Agents,
ReasoningReplay,
Cache,
ContextPercent,
GitBranch,
LastToolElapsed,
RateLimit,
}
pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> {
let raw = arg.unwrap_or("").trim();
// Bare `/config` opens the legacy native modal — it matches the rest
// of the deepseek-tui navy chrome out of the box. Power users can
// opt into the schemaui-driven editor with `/config tui`, or the
// browser surface with `/config web` (web feature only).
if raw.is_empty() || raw.eq_ignore_ascii_case("native") {
return Ok(ConfigUiMode::Native);
}
if raw.eq_ignore_ascii_case("tui") {
return Ok(ConfigUiMode::Tui);
}
if raw.eq_ignore_ascii_case("web") {
return Ok(ConfigUiMode::Web);
}
Err("Usage: /config [native|tui|web]".to_string())
}
pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
let settings = Settings::load().unwrap_or_default();
let reasoning_effort = config
.reasoning_effort()
.map(ReasoningEffortValue::from_setting)
.unwrap_or_else(|| app.reasoning_effort.into());
let default_model = settings.default_model.clone();
let status_items = app.status_items.iter().copied().map(Into::into).collect();
Ok(ConfigUiDocument {
runtime: RuntimeSection {
model: app.model.clone(),
approval_mode: app.approval_mode.into(),
},
settings: SettingsSection {
auto_compact: settings.auto_compact,
calm_mode: settings.calm_mode,
low_motion: settings.low_motion,
fancy_animations: settings.fancy_animations,
paste_burst_detection: settings.paste_burst_detection,
show_thinking: settings.show_thinking,
show_tool_details: settings.show_tool_details,
locale: UiLocale::from_setting(&settings.locale)?,
composer_density: settings.composer_density.as_str().into(),
composer_border: settings.composer_border,
transcript_spacing: settings.transcript_spacing.as_str().into(),
default_mode: settings.default_mode.as_str().into(),
sidebar_width: settings.sidebar_width_percent,
sidebar_focus: settings.sidebar_focus.as_str().into(),
max_history: settings.max_input_history,
default_model,
},
config: ConfigSection {
mcp_config_path: app.mcp_config_path.display().to_string(),
reasoning_effort,
status_items,
},
})
}
pub fn build_schema() -> Value {
let mut schema = serde_json::to_value(schema_for!(ConfigUiDocument)).expect("config ui schema");
schema["title"] = Value::String("DeepSeek TUI Config".to_string());
schema["description"] =
Value::String("Edit runtime and persisted TUI configuration.".to_string());
schema
}
#[cfg(feature = "tui")]
pub fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> {
let document = build_document(app, config)?;
let value = SchemaUI::new(serde_json::to_value(document.clone())?)
.with_schema(build_schema())
.with_title("DeepSeek TUI Config")
.with_description("Edit persisted settings and live runtime knobs.")
.run(FrontendOptions::Tui(
UiOptions::default()
.with_confirm_exit(true)
.with_bool_labels("On", "Off")
.with_integer_step(1)
.with_integer_fast_step(5)
.with_help(true),
))?;
parse_document(value)
}
#[cfg(feature = "web")]
pub async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSession> {
let initial = serde_json::to_value(build_document(app, config)?)?;
let session = WebSessionBuilder::new(build_schema())
.with_initial_data(initial)
.with_title("DeepSeek TUI Config")
.with_description("Save updates the browser draft. Exit commits changes back to the TUI.")
.build()?;
let bound = bind_session(session, ServeOptions::default()).await?;
let addr = bound.local_addr();
let url = format!("http://{addr}");
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let app_snapshot = build_document(app, config)?;
let task = tokio::spawn(async move {
let poll_tx = tx.clone();
let poll_url = format!("{url}/api/session");
let poll_task = tokio::spawn(async move {
let client = reqwest::Client::new();
let mut last: Option<ConfigUiDocument> = Some(app_snapshot);
loop {
tokio::time::sleep(Duration::from_millis(750)).await;
let response = match client.get(&poll_url).send().await {
Ok(response) => response,
Err(err) => {
let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
"config web poll failed: {err}"
)));
break;
}
};
if !response.status().is_success() {
continue;
}
let body: Value = match response.json().await {
Ok(body) => body,
Err(err) => {
let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
"config web decode failed: {err}"
)));
break;
}
};
let Some(data) = body.get("data") else {
continue;
};
let doc = match parse_document(data.clone()) {
Ok(doc) => doc,
Err(_) => continue,
};
if last.as_ref() == Some(&doc) {
continue;
}
let _ = poll_tx.send(WebConfigSessionEvent::Draft(doc.clone()));
last = Some(doc);
}
});
let result = bound.run().await;
poll_task.abort();
match result {
Ok(value) => match parse_document(value) {
Ok(doc) => {
let _ = tx.send(WebConfigSessionEvent::Committed(doc));
}
Err(err) => {
let _ = tx.send(WebConfigSessionEvent::Failed(format!(
"config web result decode failed: {err}"
)));
}
},
Err(err) => {
let _ = tx.send(WebConfigSessionEvent::Failed(format!(
"config web session failed: {err}"
)));
}
}
});
Ok(WebConfigSession {
task,
receiver: rx,
addr,
})
}
pub fn apply_document(
doc: ConfigUiDocument,
app: &mut App,
config: &mut Config,
persist: bool,
) -> Result<ConfigUiApplyOutcome> {
validate_document(&doc)?;
let mut notes = Vec::new();
let previous_compaction = app.compaction_config();
let previous_reasoning_effort = app.reasoning_effort;
for (key, value) in [
("model", doc.runtime.model.as_str()),
("approval_mode", doc.runtime.approval_mode.as_setting()),
("auto_compact", bool_str(doc.settings.auto_compact)),
("calm_mode", bool_str(doc.settings.calm_mode)),
("low_motion", bool_str(doc.settings.low_motion)),
("fancy_animations", bool_str(doc.settings.fancy_animations)),
(
"paste_burst_detection",
bool_str(doc.settings.paste_burst_detection),
),
("show_thinking", bool_str(doc.settings.show_thinking)),
(
"show_tool_details",
bool_str(doc.settings.show_tool_details),
),
("locale", doc.settings.locale.as_setting()),
(
"composer_density",
doc.settings.composer_density.as_setting(),
),
("composer_border", bool_str(doc.settings.composer_border)),
(
"transcript_spacing",
doc.settings.transcript_spacing.as_setting(),
),
("default_mode", doc.settings.default_mode.as_setting()),
("sidebar_width", &doc.settings.sidebar_width.to_string()),
("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
("max_history", &doc.settings.max_history.to_string()),
("mcp_config_path", doc.config.mcp_config_path.as_str()),
] {
let result = commands::set_config_value(app, key, value, persist);
if result.is_error {
bail!(
"{}",
result
.message
.unwrap_or_else(|| "config update failed".to_string())
);
}
if let Some(message) = result.message {
notes.push(message);
}
}
// default_model is only applied when persisting (it controls the model
// for future sessions). Processing it in the main loop would overwrite
// the runtime model the user just chose when persist=false (#346-fix).
if persist {
let default_model_val = doc.settings.default_model.as_deref().unwrap_or("default");
let result = commands::set_config_value(app, "default_model", default_model_val, true);
if result.is_error {
bail!(
"{}",
result
.message
.unwrap_or_else(|| "default_model update failed".to_string())
);
}
if let Some(message) = result.message {
notes.push(message);
}
}
apply_reasoning_effort(app, config, doc.config.reasoning_effort, persist)?;
let requires_engine_sync = app.compaction_config() != previous_compaction
|| app.reasoning_effort != previous_reasoning_effort;
let new_status_items = parse_status_items(&doc.config.status_items);
if app.status_items != new_status_items {
app.status_items = new_status_items.clone();
app.needs_redraw = true;
if persist {
let path = commands::persist_status_items(&new_status_items)?;
notes.push(format!("status_items saved to {}", path.display()));
} else {
notes.push("status_items updated for this session".to_string());
}
}
if persist {
reload_runtime_config(app, config)?;
notes.extend(config_reload_notes(app, config));
}
let changed = !notes.is_empty();
let final_message = if notes.is_empty() {
if persist {
"Config unchanged".to_string()
} else {
"Runtime config unchanged".to_string()
}
} else {
notes.last().cloned().unwrap_or_default()
};
Ok(ConfigUiApplyOutcome {
changed,
final_message,
requires_engine_sync,
})
}
pub fn parse_document(value: Value) -> Result<ConfigUiDocument> {
serde_json::from_value(value).context("failed to decode config ui document")
}
#[cfg(feature = "web")]
pub fn open_browser(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
let mut command = {
let mut command = Command::new("open");
command.arg(url);
command
};
#[cfg(target_os = "linux")]
let mut command = {
let mut command = Command::new("xdg-open");
command.arg(url);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("cmd");
command.args(["/C", "start", "", url]);
command
};
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return Err(anyhow::anyhow!(
"browser opening is unsupported on this platform"
));
let status = command
.status()
.context("failed to launch browser command")?;
if !status.success() {
bail!("browser command exited with status {status}");
}
Ok(())
}
fn validate_document(doc: &ConfigUiDocument) -> Result<()> {
if normalize_model_name(&doc.runtime.model).is_none() {
bail!("invalid model '{}'", doc.runtime.model);
}
if doc.config.mcp_config_path.trim().is_empty() {
bail!("mcp_config_path cannot be empty");
}
Ok(())
}
fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> {
let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?;
*config = reloaded.clone();
app.api_provider = reloaded.api_provider();
app.reasoning_effort = ReasoningEffort::from_setting(
reloaded
.reasoning_effort()
.unwrap_or_else(|| app.reasoning_effort.as_setting()),
);
app.update_model_compaction_budget();
app.mcp_config_path = reloaded.mcp_config_path();
app.skills_dir = reloaded.skills_dir();
app.ui_locale = resolve_locale(&Settings::load().unwrap_or_default().locale);
Ok(())
}
fn config_reload_notes(app: &App, config: &Config) -> Vec<String> {
let mut notes = Vec::new();
notes.push("Config saved and reloaded".to_string());
if app.mcp_restart_required {
notes.push(format!(
"MCP tool pool still requires restart after {}",
config.mcp_config_path().display()
));
}
notes
}
fn apply_reasoning_effort(
app: &mut App,
config: &mut Config,
value: ReasoningEffortValue,
persist: bool,
) -> Result<()> {
let effort: ReasoningEffort = value.into();
app.reasoning_effort = effort;
app.update_model_compaction_budget();
if persist {
commands::persist_root_string_key("reasoning_effort", effort.as_setting())?;
}
config.reasoning_effort = Some(effort.as_setting().to_string());
Ok(())
}
fn parse_status_items(items: &[StatusItemValue]) -> Vec<StatusItem> {
items.iter().copied().map(Into::into).collect()
}
impl ApprovalModeValue {
fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Suggest => "suggest",
Self::Never => "never",
}
}
}
impl UiLocale {
fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::En => "en",
Self::Ja => "ja",
Self::ZhHans => "zh-Hans",
Self::PtBr => "pt-BR",
}
}
fn from_setting(value: &str) -> Result<Self> {
match normalize_configured_locale(value) {
Some("auto") => Ok(Self::Auto),
Some("en") => Ok(Self::En),
Some("ja") => Ok(Self::Ja),
Some("zh-Hans") => Ok(Self::ZhHans),
Some("pt-BR") => Ok(Self::PtBr),
Some(other) => bail!("unsupported locale '{other}'"),
None => bail!("invalid locale '{value}'"),
}
}
}
impl ComposerDensityValue {
fn as_setting(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Comfortable => "comfortable",
Self::Spacious => "spacious",
}
}
}
impl TranscriptSpacingValue {
fn as_setting(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Comfortable => "comfortable",
Self::Spacious => "spacious",
}
}
}
impl DefaultModeValue {
fn as_setting(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Plan => "plan",
Self::Yolo => "yolo",
}
}
}
impl SidebarFocusValue {
fn as_setting(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Plan => "plan",
Self::Todos => "todos",
Self::Tasks => "tasks",
Self::Agents => "agents",
}
}
}
impl From<ApprovalMode> for ApprovalModeValue {
fn from(value: ApprovalMode) -> Self {
match value {
ApprovalMode::Auto => Self::Auto,
ApprovalMode::Suggest => Self::Suggest,
ApprovalMode::Never => Self::Never,
}
}
}
impl From<ReasoningEffort> for ReasoningEffortValue {
fn from(value: ReasoningEffort) -> Self {
match value {
ReasoningEffort::Off => Self::Off,
ReasoningEffort::Low => Self::Low,
ReasoningEffort::Medium => Self::Medium,
ReasoningEffort::High => Self::High,
ReasoningEffort::Max => Self::Max,
}
}
}
impl ReasoningEffortValue {
fn from_setting(value: &str) -> Self {
match ReasoningEffort::from_setting(value) {
ReasoningEffort::Off => Self::Off,
ReasoningEffort::Low => Self::Low,
ReasoningEffort::Medium => Self::Medium,
ReasoningEffort::High => Self::High,
ReasoningEffort::Max => Self::Max,
}
}
}
impl From<ReasoningEffortValue> for ReasoningEffort {
fn from(value: ReasoningEffortValue) -> Self {
match value {
ReasoningEffortValue::Off => Self::Off,
ReasoningEffortValue::Low => Self::Low,
ReasoningEffortValue::Medium => Self::Medium,
ReasoningEffortValue::High => Self::High,
ReasoningEffortValue::Max => Self::Max,
}
}
}
impl From<&str> for ComposerDensityValue {
fn from(value: &str) -> Self {
match ComposerDensity::from_setting(value) {
ComposerDensity::Compact => Self::Compact,
ComposerDensity::Comfortable => Self::Comfortable,
ComposerDensity::Spacious => Self::Spacious,
}
}
}
impl From<&str> for TranscriptSpacingValue {
fn from(value: &str) -> Self {
match TranscriptSpacing::from_setting(value) {
TranscriptSpacing::Compact => Self::Compact,
TranscriptSpacing::Comfortable => Self::Comfortable,
TranscriptSpacing::Spacious => Self::Spacious,
}
}
}
impl From<&str> for DefaultModeValue {
fn from(value: &str) -> Self {
match AppMode::from_setting(value) {
AppMode::Agent => Self::Agent,
AppMode::Plan => Self::Plan,
AppMode::Yolo => Self::Yolo,
}
}
}
impl From<&str> for SidebarFocusValue {
fn from(value: &str) -> Self {
match SidebarFocus::from_setting(value) {
SidebarFocus::Auto => Self::Auto,
SidebarFocus::Plan => Self::Plan,
SidebarFocus::Todos => Self::Todos,
SidebarFocus::Tasks => Self::Tasks,
SidebarFocus::Agents => Self::Agents,
}
}
}
impl From<StatusItem> for StatusItemValue {
fn from(value: StatusItem) -> Self {
match value {
StatusItem::Mode => Self::Mode,
StatusItem::Model => Self::Model,
StatusItem::Cost => Self::Cost,
StatusItem::Status => Self::Status,
StatusItem::Coherence => Self::Coherence,
StatusItem::Agents => Self::Agents,
StatusItem::ReasoningReplay => Self::ReasoningReplay,
StatusItem::Cache => Self::Cache,
StatusItem::ContextPercent => Self::ContextPercent,
StatusItem::GitBranch => Self::GitBranch,
StatusItem::LastToolElapsed => Self::LastToolElapsed,
StatusItem::RateLimit => Self::RateLimit,
}
}
}
impl From<StatusItemValue> for StatusItem {
fn from(value: StatusItemValue) -> Self {
match value {
StatusItemValue::Mode => Self::Mode,
StatusItemValue::Model => Self::Model,
StatusItemValue::Cost => Self::Cost,
StatusItemValue::Status => Self::Status,
StatusItemValue::Coherence => Self::Coherence,
StatusItemValue::Agents => Self::Agents,
StatusItemValue::ReasoningReplay => Self::ReasoningReplay,
StatusItemValue::Cache => Self::Cache,
StatusItemValue::ContextPercent => Self::ContextPercent,
StatusItemValue::GitBranch => Self::GitBranch,
StatusItemValue::LastToolElapsed => Self::LastToolElapsed,
StatusItemValue::RateLimit => Self::RateLimit,
}
}
}
fn bool_str(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::{App, TuiOptions};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn build_document_reflects_app_state() {
let app = app();
let config = Config::default();
let doc = build_document(&app, &config).expect("document");
assert_eq!(doc.runtime.model, app.model);
assert_eq!(doc.runtime.approval_mode, ApprovalModeValue::Suggest);
assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Max);
}
#[test]
fn schema_contains_typed_enums() {
let schema = build_schema();
let approval_mode = &schema["$defs"]["ApprovalModeValue"]["enum"];
assert_eq!(
approval_mode,
&serde_json::json!(["auto", "suggest", "never"])
);
let locale = &schema["$defs"]["UiLocale"]["enum"];
assert_eq!(
locale,
&serde_json::json!(["auto", "en", "ja", "zh-Hans", "pt-BR"])
);
}
#[test]
fn parse_document_roundtrip() {
let _lock = lock_test_env();
let app = app();
let config = Config::default();
let doc = build_document(&app, &config).expect("document");
let value = serde_json::to_value(doc.clone()).expect("json");
let parsed = parse_document(value).expect("parsed");
assert_eq!(parsed, doc);
}
#[test]
fn session_only_apply_keeps_runtime_overrides_and_skips_reload() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let temp_root = std::env::temp_dir().join(format!(
"deepseek-config-ui-session-only-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::write(
&config_path,
r#"
model = "deepseek-v4-pro"
reasoning_effort = "max"
mcp_config_path = "disk-mcp.json"
"#,
)
.expect("seed config");
let mut app = app();
app.config_path = Some(config_path.clone());
app.model = "deepseek-v4-pro".to_string();
app.mcp_config_path = PathBuf::from("disk-mcp.json");
app.reasoning_effort = ReasoningEffort::Max;
let mut config = Config::load(Some(config_path), None).expect("load config");
let mut doc = build_document(&app, &config).expect("document");
doc.runtime.model = "deepseek-v4-flash".to_string();
doc.config.reasoning_effort = ReasoningEffortValue::Low;
doc.config.mcp_config_path = "session-mcp.json".to_string();
let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
assert!(outcome.changed);
assert!(outcome.requires_engine_sync);
assert_eq!(app.model, "deepseek-v4-flash");
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.mcp_config_path, PathBuf::from("session-mcp.json"));
assert_eq!(
config.reasoning_effort.as_deref(),
Some(ReasoningEffort::Low.as_setting())
);
assert_eq!(
config.mcp_config_path.as_deref(),
Some("disk-mcp.json"),
"session-only apply must not reload persisted config back into runtime state"
);
}
#[test]
fn status_item_only_apply_does_not_require_engine_sync() {
let _lock = lock_test_env();
let mut app = app();
let mut config = Config::default();
let mut doc = build_document(&app, &config).expect("document");
doc.config.status_items = vec![StatusItemValue::Cost, StatusItemValue::Model];
let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
assert!(outcome.changed);
assert!(!outcome.requires_engine_sync);
}
}
+3
View File
@@ -20,6 +20,7 @@ mod commands;
mod compaction;
mod composer_history;
mod config;
mod config_ui;
mod core;
mod cycle_manager;
mod deepseek_theme;
@@ -2887,6 +2888,8 @@ async fn run_interactive(
tui::TuiOptions {
model,
workspace,
config_path: cli.config.clone(),
config_profile: cli.profile.clone(),
allow_shell: cli.yolo || config.allow_shell(),
use_alt_screen,
use_mouse_capture,
+161 -8
View File
@@ -142,12 +142,15 @@ impl ToolSpec for FetchUrlTool {
));
}
// Extract host once for reuse across network policy + SSRF checks.
let url_host = host_from_url(&url);
// Per-domain network policy gate (#135). If no policy is attached
// (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0.
if let Some(decider) = context.network_policy.as_ref()
&& let Some(host) = host_from_url(&url)
&& let Some(ref host) = url_host
{
match decider.evaluate(&host, "fetch_url") {
match decider.evaluate(host, "fetch_url") {
Decision::Allow => {}
Decision::Deny => {
return Err(ToolError::permission_denied(format!(
@@ -163,19 +166,64 @@ impl ToolSpec for FetchUrlTool {
}
}
// SSRF protection: resolve hostname and reject private/link-local/loopback IPs.
// Prevents LLM-prompted requests to cloud metadata (169.254.169.254),
// localhost services, and internal networks.
// Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding
// TOCTOU window — reqwest will use the pinned IP instead of re-resolving.
let mut dns_pinning = None; // (hostname, validated_ip)
if let Some(host) = &url_host {
if host == "localhost" || host == "localhost.localdomain" {
return Err(ToolError::permission_denied(
"requests to localhost are not allowed",
));
}
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if is_restricted_ip(&ip) {
return Err(ToolError::permission_denied(format!(
"IP {ip} is a restricted address (private/loopback/link-local)"
)));
}
} else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await {
let mut first_valid: Option<std::net::IpAddr> = None;
for addr in addrs {
if is_restricted_ip(&addr.ip()) {
return Err(ToolError::permission_denied(format!(
"resolved IP {} is a restricted address (private/loopback/link-local)",
addr.ip()
)));
}
if first_valid.is_none() {
first_valid = Some(addr.ip());
}
}
if let Some(validated_ip) = first_valid {
dns_pinning = Some((host.clone(), validated_ip));
}
}
// If DNS resolution fails, let the HTTP request proceed and fail naturally.
}
let format = Format::parse(input.get("format").and_then(Value::as_str))?;
let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);
let timeout_ms =
optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);
let client = reqwest::Client::builder()
let mut client_builder = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
.build()
.map_err(|e| {
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
})?;
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));
// Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will
// connect to the validated IP directly instead of re-resolving.
if let Some((hostname, validated_ip)) = dns_pinning {
client_builder =
client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));
}
let client = client_builder.build().map_err(|e| {
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
})?;
let resp = client
.get(&url)
@@ -244,6 +292,46 @@ impl ToolSpec for FetchUrlTool {
}
}
/// Check if an IP address is loopback, private, link-local, cloud-metadata,
/// multicast, or reserved — all addresses that should not be reachable via
/// an LLM-initiated fetch_url request (SSRF prevention).
fn is_restricted_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_unspecified()
// 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)
|| matches!(v4.octets(), [100, 64..=127, ..])
// 169.254.169.254 — cloud metadata (AWS/GCP/Azure)
|| *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))
// 198.18.0.0/15 — IETF benchmark testing
|| matches!(v4.octets(), [198, 18..=19, ..])
// 240.0.0.0/4 — reserved (former Class E)
|| v4.octets()[0] >= 240
}
std::net::IpAddr::V6(v6) => {
// IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4
// to prevent bypass via ::ffff:127.0.0.1 etc.
if v6.is_unspecified()
|| matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])
{
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_restricted_ip(&std::net::IpAddr::V4(v4));
}
v6.is_loopback()
|| v6.is_multicast()
|| matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7
|| matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10
}
}
}
/// Strip `<script>` / `<style>` blocks, drop remaining tags, and collapse
/// whitespace. Good enough for "let the model read this page" — not a full
/// HTML-to-Markdown converter.
@@ -335,6 +423,71 @@ mod tests {
assert!(res.is_err());
}
#[test]
fn rejects_private_localhost_literal() {
assert!(is_restricted_ip(&"127.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::1".parse().unwrap()));
}
#[test]
fn rejects_private_rfc1918() {
assert!(is_restricted_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"192.168.1.1".parse().unwrap()));
}
#[test]
fn rejects_cloud_metadata() {
assert!(is_restricted_ip(&"169.254.169.254".parse().unwrap()));
}
#[test]
fn rejects_link_local() {
assert!(is_restricted_ip(&"169.254.1.1".parse().unwrap()));
}
#[test]
fn rejects_cgnat() {
assert!(is_restricted_ip(&"100.64.0.1".parse().unwrap()));
assert!(!is_restricted_ip(&"100.63.0.1".parse().unwrap()));
assert!(!is_restricted_ip(&"100.128.0.1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_restricted_ip(&"fc00::1".parse().unwrap()));
assert!(is_restricted_ip(&"fd12:3456::1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_mapped_ipv6() {
// ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass
assert!(is_restricted_ip(&"::ffff:127.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:10.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:169.254.169.254".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:192.168.1.1".parse().unwrap()));
// :: (unspecified)
assert!(is_restricted_ip(&"::".parse().unwrap()));
}
#[test]
fn allows_public_ips() {
assert!(!is_restricted_ip(&"8.8.8.8".parse().unwrap()));
assert!(!is_restricted_ip(&"1.1.1.1".parse().unwrap()));
assert!(!is_restricted_ip(&"93.184.216.34".parse().unwrap()));
assert!(!is_restricted_ip(&"2606:4700::1".parse().unwrap()));
}
#[tokio::test]
async fn rejects_localhost_hostname() {
let tool = FetchUrlTool;
let res = tool
.execute(json!({"url": "http://localhost:8080/admin"}), &ctx())
.await;
let err = res.unwrap_err();
assert!(format!("{err}").contains("localhost"));
}
#[tokio::test]
async fn network_policy_denies_blocked_host() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
+12
View File
@@ -10,6 +10,7 @@ use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{ApiProvider, Config, has_api_key, save_api_key};
use crate::config_ui::ConfigUiMode;
use crate::core::coherence::CoherenceState;
use crate::cycle_manager::{CycleBriefing, CycleConfig};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
@@ -375,6 +376,8 @@ impl AppMode {
pub struct TuiOptions {
pub model: String,
pub workspace: PathBuf,
pub config_path: Option<PathBuf>,
pub config_profile: Option<String>,
pub allow_shell: bool,
/// Use the alternate screen buffer (fullscreen TUI).
pub use_alt_screen: bool,
@@ -472,6 +475,8 @@ pub struct App {
/// Cycled via Shift+Tab; initialized from config at startup.
pub reasoning_effort: ReasoningEffort,
pub workspace: PathBuf,
pub config_path: Option<PathBuf>,
pub config_profile: Option<String>,
pub mcp_config_path: PathBuf,
pub skills_dir: PathBuf,
pub use_alt_screen: bool,
@@ -839,6 +844,8 @@ impl App {
let TuiOptions {
model,
workspace,
config_path,
config_profile,
allow_shell,
use_alt_screen,
use_mouse_capture,
@@ -967,6 +974,8 @@ impl App {
ReasoningEffort::from_setting(s)
}),
workspace,
config_path,
config_profile,
mcp_config_path,
skills_dir,
use_alt_screen,
@@ -2768,6 +2777,7 @@ pub enum AppAction {
model: String,
workspace: PathBuf,
},
OpenConfigEditor(ConfigUiMode),
OpenConfigView,
/// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max).
OpenModelPicker,
@@ -2877,6 +2887,8 @@ mod tests {
TuiOptions {
model: "test-model".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: yolo,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -309,6 +309,8 @@ mod tests {
TuiOptions {
model: "unknown-model".to_string(),
workspace: PathBuf::from("/tmp/project"),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -348,6 +348,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -128,6 +128,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+206 -8
View File
@@ -33,6 +33,7 @@ use crate::client::DeepSeekClient;
use crate::commands;
use crate::compaction::estimate_input_tokens_conservative;
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
use crate::core::coherence::CoherenceState;
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::Event as EngineEvent;
@@ -118,6 +119,7 @@ const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
const UI_IDLE_POLL_MS: u64 = 48;
const UI_ACTIVE_POLL_MS: u64 = 24;
const WEB_CONFIG_POLL_MS: u64 = 16;
// Forced repaint cadence while a turn is live (model loading, compacting,
// sub-agents running). Drives the footer water-spout animation as well as
// the per-tool spinner pulse — keep this fast enough that the spout reads as
@@ -418,8 +420,13 @@ async fn run_event_loop(
// `tui::frame_rate_limiter` for the rationale; ports the small piece of
// codex's frame coalescing that maps cleanly onto our poll-based loop.
let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default();
let mut web_config_session: Option<WebConfigSession> = None;
loop {
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
web_config_session = None;
}
if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
let tasks = task_manager.list_tasks(Some(10)).await;
app.task_panel = tasks.into_iter().map(task_summary_to_panel_entry).collect();
@@ -1134,7 +1141,17 @@ async fn run_event_loop(
if !events.is_empty() {
app.needs_redraw = true;
}
if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? {
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
}
@@ -1199,6 +1216,9 @@ async fn run_event_loop(
if let Some(until_draw) = draw_wait {
poll_timeout = poll_timeout.min(until_draw);
}
if web_config_session.is_some() {
poll_timeout = poll_timeout.min(Duration::from_millis(WEB_CONFIG_POLL_MS));
}
// While the quit-confirmation prompt is armed, ensure we wake up to
// expire it on time even if no input event arrives.
if let Some(deadline) = app.quit_armed_until {
@@ -1278,8 +1298,16 @@ async fn run_event_loop(
&& let Event::Mouse(mouse) = evt
{
let events = handle_mouse_event(app, mouse);
if handle_view_events(app, config, &task_manager, &mut engine_handle, events)
.await?
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
@@ -1483,8 +1511,16 @@ async fn run_event_loop(
if !app.view_stack.is_empty() {
let events = app.view_stack.handle_key(key);
if handle_view_events(app, config, &task_manager, &mut engine_handle, events)
.await?
if handle_view_events(
terminal,
app,
config,
&task_manager,
&mut engine_handle,
&mut web_config_session,
events,
)
.await?
{
return Ok(());
}
@@ -1942,10 +1978,12 @@ async fn run_event_loop(
}
if input.starts_with('/') {
if execute_command_input(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
&mut web_config_session,
&input,
)
.await?
@@ -2587,6 +2625,77 @@ async fn apply_model_and_compaction_update(
.await;
}
async fn drain_web_config_events(
web_config_session: &mut Option<WebConfigSession>,
app: &mut App,
config: &mut Config,
engine_handle: &EngineHandle,
) -> bool {
let Some(session) = web_config_session.as_mut() else {
return true;
};
let mut keep_session = true;
while let Ok(event) = session.receiver.try_recv() {
match event {
WebConfigSessionEvent::Draft(doc) => {
match config_ui::apply_document(doc, app, config, false) {
Ok(outcome) if outcome.changed => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.status_message = Some(format!(
"Web config draft applied: {}",
outcome.final_message
));
}
Ok(_) => {}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Web config draft apply failed: {err}"),
});
}
}
}
WebConfigSessionEvent::Committed(doc) => {
keep_session = false;
match config_ui::apply_document(doc, app, config, true) {
Ok(outcome) => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.add_message(HistoryCell::System {
content: outcome.final_message.clone(),
});
app.status_message = Some(outcome.final_message);
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Web config commit failed: {err}"),
});
}
}
}
WebConfigSessionEvent::Failed(err) => {
keep_session = false;
app.add_message(HistoryCell::System {
content: format!("Web config session failed: {err}"),
});
}
}
}
keep_session
}
/// Apply the choice made in the `/model` picker (#39): mutate App state so
/// the next turn uses the new model/effort, persist the selection to
/// `~/.deepseek/settings.toml` so it survives a restart, push the change to
@@ -3010,10 +3119,14 @@ fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
}
async fn apply_command_result(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
#[cfg_attr(not(feature = "web"), allow(unused_variables))] web_config_session: &mut Option<
WebConfigSession,
>,
result: commands::CommandResult,
) -> Result<bool> {
if let Some(msg) = result.message {
@@ -3106,6 +3219,70 @@ async fn apply_command_result(
AppAction::UpdateCompaction(compaction) => {
apply_model_and_compaction_update(engine_handle, compaction).await;
}
AppAction::OpenConfigEditor(mode) => match mode {
ConfigUiMode::Native => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
}
}
ConfigUiMode::Tui => {
pause_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
let editor_result = config_ui::run_tui_editor(app, config)
.and_then(|doc| config_ui::apply_document(doc, app, config, true));
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
match editor_result {
Ok(outcome) => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
)
.await;
}
app.add_message(HistoryCell::System {
content: outcome.final_message.clone(),
});
app.status_message = Some(outcome.final_message);
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Config UI failed: {err}"),
});
}
}
}
ConfigUiMode::Web => {
#[cfg(feature = "web")]
{
let session = config_ui::start_web_editor(app, config).await?;
let url = format!("http://{}", session.addr);
let open_err = config_ui::open_browser(&url).err();
if let Some(err) = open_err {
app.add_message(HistoryCell::System {
content: format!("Failed to open browser automatically: {err}"),
});
}
app.status_message = Some(format!("web ui listen on: {url}"));
*web_config_session = Some(session);
}
#[cfg(not(feature = "web"))]
{
app.add_message(HistoryCell::System {
content: "This build does not include the web config UI.".to_string(),
});
}
}
},
AppAction::OpenConfigView => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
@@ -3381,10 +3558,12 @@ fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobActio
}
async fn execute_command_input(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
web_config_session: &mut Option<WebConfigSession>,
input: &str,
) -> Result<bool> {
let result = commands::execute(input, app);
@@ -3404,7 +3583,16 @@ async fn execute_command_input(
providers.sglang.api_key = None;
}
}
apply_command_result(app, engine_handle, task_manager, config, result).await
apply_command_result(
terminal,
app,
engine_handle,
task_manager,
config,
web_config_session,
result,
)
.await
}
async fn steer_user_message(
@@ -3889,18 +4077,28 @@ fn toggle_live_transcript_overlay(app: &mut App) {
}
async fn handle_view_events(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
config: &mut Config,
task_manager: &SharedTaskManager,
engine_handle: &mut EngineHandle,
web_config_session: &mut Option<WebConfigSession>,
events: Vec<ViewEvent>,
) -> Result<bool> {
for event in events {
match event {
ViewEvent::CommandPaletteSelected { action } => match action {
crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
if execute_command_input(app, engine_handle, task_manager, config, &command)
.await?
if execute_command_input(
terminal,
app,
engine_handle,
task_manager,
config,
&mut *web_config_session,
&command,
)
.await?
{
return Ok(true);
}
+37
View File
@@ -1,5 +1,7 @@
use super::*;
use crate::config::Config;
use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};
use crate::core::engine::mock_engine_handle;
use crate::tui::file_mention::{
apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,
try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,
@@ -374,6 +376,8 @@ fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
@@ -392,6 +396,39 @@ fn create_test_app() -> App {
App::new(options, &Config::default())
}
#[tokio::test]
async fn drain_web_config_events_applies_draft_without_closing_session() {
let mut app = create_test_app();
let mut config = Config::default();
let engine = mock_engine_handle();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let doc = config_ui::build_document(&app, &config).expect("document");
tx.send(WebConfigSessionEvent::Draft(doc))
.expect("send draft");
let mut session = Some(WebConfigSession::for_test(rx));
let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;
assert!(keep);
assert!(session.is_some());
}
#[tokio::test]
async fn drain_web_config_events_closes_session_after_commit() {
let mut app = create_test_app();
let mut config = Config::default();
let engine = mock_engine_handle();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let doc = config_ui::build_document(&app, &config).expect("document");
tx.send(WebConfigSessionEvent::Committed(doc))
.expect("send commit");
let mut session = Some(WebConfigSession::for_test(rx));
let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;
assert!(!keep);
}
#[test]
fn backtrack_prefill_rehydrates_attachment_rows() {
let mut app = create_test_app();
+2
View File
@@ -1694,6 +1694,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+2
View File
@@ -526,6 +526,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-flash".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+16 -1
View File
@@ -210,7 +210,20 @@ impl ChatWidget {
impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(self.lines.clone());
// Repaint the full chat area with the deepseek-ink background each
// frame. Ratatui's `Paragraph` only writes cells that contain text,
// so cells the current frame's paragraph doesn't touch would
// otherwise hold the *previous* frame's contents (the `:24Z`
// timestamp-tail bleed-through reported in v0.8.5 testing). Using
// `Clear` reset cells to terminal default, which read as a brown-
// gray on most user setups; an explicit ink fill keeps the chat
// area on-brand.
Block::default()
.style(Style::default().bg(palette::DEEPSEEK_INK))
.render(self.content_area, buf);
let paragraph =
Paragraph::new(self.lines.clone()).style(Style::default().bg(palette::DEEPSEEK_INK));
paragraph.render(self.content_area, buf);
if let Some(scrollbar) = self.scrollbar {
@@ -1635,6 +1648,8 @@ mod tests {
let options = TuiOptions {
model: "deepseek-v4-flash".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
+33 -29
View File
@@ -250,12 +250,21 @@ fn write_panic_dump(
location: &std::panic::Location<'_>,
message: &str,
) -> std::io::Result<()> {
use chrono::Utc;
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");
std::fs::create_dir_all(&crash_dir)?;
write_panic_dump_to(&crash_dir, name, location, message)
}
fn write_panic_dump_to(
crash_dir: &Path,
name: &str,
location: &std::panic::Location<'_>,
message: &str,
) -> std::io::Result<()> {
use chrono::Utc;
std::fs::create_dir_all(crash_dir)?;
let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let filename = format!("{timestamp}-{name}.log");
let path = crash_dir.join(&filename);
@@ -529,19 +538,14 @@ mod spawn_supervised_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// A spawned task that panics produces a crash dump in
/// `~/.deepseek/crashes/` and the panic does not propagate to the
/// parent task — `spawn_supervised` catches it.
/// A spawned task that panics does not propagate the panic to the
/// parent task — `spawn_supervised` catches it. Verified in isolation
/// from the on-disk crash-dump path so the test is portable across
/// macOS / Linux / Windows (where `dirs::home_dir()` reads
/// `USERPROFILE`, not `HOME`, so env-mutation tricks don't redirect
/// the dump on Windows).
#[tokio::test]
async fn panicking_task_writes_crash_dump_and_does_not_kill_parent() {
// Redirect HOME so we don't pollute the real ~/.deepseek/crashes/.
let tmp = tempfile::tempdir().expect("tempdir");
let prev_home = std::env::var_os("HOME");
// SAFETY: tests in this crate run with single-threaded env mutation
// by harness convention; we restore on exit.
unsafe { std::env::set_var("HOME", tmp.path()) };
// Spawn a task that immediately panics.
async fn panicking_task_does_not_propagate_to_parent() {
let parent_alive = Arc::new(AtomicBool::new(false));
let parent_alive_clone = parent_alive.clone();
@@ -550,21 +554,11 @@ mod spawn_supervised_tests {
std::panic::Location::caller(),
async move {
parent_alive_clone.store(true, Ordering::SeqCst);
panic!("deliberate panic for crash-dump test");
panic!("deliberate panic for catch-unwind test");
},
);
// The handle resolves to () because spawn_supervised swallows the
// panic. Awaiting must not return Err — the caller must not see
// the panic.
let result = handle.await;
// Restore HOME before any assertions can panic.
match prev_home {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert!(
result.is_ok(),
"spawn_supervised must convert panic to a normal completion"
@@ -573,9 +567,19 @@ mod spawn_supervised_tests {
parent_alive.load(Ordering::SeqCst),
"fixture task must have run before panicking"
);
}
/// `write_panic_dump_to` writes a properly-formatted crash log into
/// the supplied directory. Tested separately from `spawn_supervised`
/// because env-mutation redirection of `dirs::home_dir()` doesn't
/// work on Windows.
#[test]
fn write_panic_dump_writes_named_log() {
let tmp = tempfile::tempdir().expect("tempdir");
let crash_dir = tmp.path().join("crashes");
let location = std::panic::Location::caller();
write_panic_dump_to(&crash_dir, "panic-fixture", location, "boom").expect("write dump");
// A crash dump file must exist under <HOME>/.deepseek/crashes/.
let crash_dir = tmp.path().join(".deepseek").join("crashes");
let entries: Vec<_> = std::fs::read_dir(&crash_dir)
.expect("crashes dir exists")
.flatten()
@@ -583,11 +587,11 @@ mod spawn_supervised_tests {
assert_eq!(entries.len(), 1, "exactly one crash dump expected");
let dump = std::fs::read_to_string(entries[0].path()).expect("read dump");
assert!(
dump.contains("panic-test-fixture"),
dump.contains("panic-fixture"),
"dump must include the task name; got: {dump}"
);
assert!(
dump.contains("deliberate panic for crash-dump test"),
dump.contains("boom"),
"dump must include the panic message; got: {dump}"
);
}
+2188
View File
File diff suppressed because it is too large Load Diff