Merge main into feat/v0.8.8-tui-polish + gemini-code-assist feedback

Resolves the post-#514/#517/#518 conflicts:

- CHANGELOG.md: kept both polish-stack and Linux ARM64 entries under
  [Unreleased]; reordered so the ARM64/install-message Changed/Docs
  sections precede the Releases footer.
- config.example.toml: kept both the `instructions = [...]` example
  and the `[memory]` opt-in stanza in sequence.
- crates/tui/src/config.rs: kept both `instructions_paths()` (#454)
  and `memory_enabled()` (#489) on the Config impl.
- crates/tui/src/prompts.rs: extended
  `system_prompt_for_mode_with_context_and_skills` to take BOTH
  `instructions: Option<&[PathBuf]>` and `user_memory_block:
  Option<&str>`. Section 2.5a renders instructions; 2.5b renders the
  memory block — both above the skills block so KV prefix caching
  still wins.
- crates/tui/src/core/engine.rs: thread both args through the two
  call sites.
- crates/tui/src/prompts.rs: update the `system_prompt_for_mode_with_context`
  forwarder and the test caller to pass `None` for the new arg.
- .gitignore: ignore `.claude/*.local.md` and `*.local.json` so
  local ralph / Claude-Code notes can't leak into commits.

Folds in two valid suggestions from the gemini-code-assist review on #519:

- `client.rs`: collapse the duplicated `LlmError → label` match and the
  `human_retry_reason` body into a single
  `retry_reason_label_and_human(err) -> (&'static str, String)` helper.
- `widgets/footer.rs::retry_banner_spans`: merge the two separate
  `match &props.retry` blocks into one that returns both `(label, color)`.

Behavior is unchanged; refactor is a pure DRY win.
This commit is contained in:
Hunter Bown
2026-05-03 08:29:59 -05:00
37 changed files with 1482 additions and 73 deletions
+9
View File
@@ -60,6 +60,10 @@ jobs:
target: x86_64-unknown-linux-gnu
binary: deepseek
artifact_name: deepseek-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary: deepseek
artifact_name: deepseek-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
binary: deepseek
@@ -77,6 +81,10 @@ jobs:
target: x86_64-unknown-linux-gnu
binary: deepseek-tui
artifact_name: deepseek-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
binary: deepseek-tui
artifact_name: deepseek-tui-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
binary: deepseek-tui
@@ -160,6 +168,7 @@ jobs:
| Platform | Dispatcher | TUI runtime |
|---|---|---|
| Linux x64 | `deepseek-linux-x64` | `deepseek-tui-linux-x64` |
| Linux ARM64 | `deepseek-linux-arm64` | `deepseek-tui-linux-arm64` |
| macOS x64 | `deepseek-macos-x64` | `deepseek-tui-macos-x64` |
| macOS ARM | `deepseek-macos-arm64` | `deepseek-tui-macos-arm64` |
| Windows x64 | `deepseek-windows-x64.exe` | `deepseek-tui-windows-x64.exe` |
+4
View File
@@ -74,3 +74,7 @@ apps/
.claude/worktrees/
.worktrees/
.ace-tool/
# Local-only Claude / ralph notes
.claude/*.local.md
.claude/*.local.json
+26
View File
@@ -363,6 +363,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
session so the chat-area Rect stays stable; the menu still
renders only the entries that actually match.
- **Linux ARM64 prebuilt binaries** — the release workflow now publishes
`deepseek-linux-arm64` and `deepseek-tui-linux-arm64` (built natively on
GitHub's `ubuntu-24.04-arm` runner). The npm wrapper picks them up
automatically on `arm64` Linux hosts, so HarmonyOS thin-and-light,
openEuler/Kylin, Asahi Linux, Raspberry Pi, AWS Graviton, etc. now work
with a plain `npm i -g deepseek-tui`.
### Changed
- **npm `postinstall` failure messages** — when no prebuilt is available for
the host's `os.platform() / os.arch()` combo, the wrapper now prints the
full `cargo install` fallback recipe and a link to
[`docs/INSTALL.md`](docs/INSTALL.md) instead of just the bare error.
- **`DEEPSEEK_TUI_OPTIONAL_INSTALL=1`** — new env knob that downgrades a
postinstall failure to a warning + `exit 0`, so CI matrices that include
unsupported platforms don't fail the whole `npm install`.
### Docs
- New [`docs/INSTALL.md`](docs/INSTALL.md) — every supported platform,
prebuilt vs. `cargo install` vs. manual download, cross-compiling x64 → ARM64
Linux with `cross` or `gcc-aarch64-linux-gnu`, and a troubleshooting section
covering the common `Unsupported architecture`, `MISSING_COMPANION_BINARY`,
and self-update mismatch errors.
- README and `README.zh-CN.md` now have an explicit **Linux ARM64** quickstart
pointing ARM64 users at `cargo install deepseek-tui-cli deepseek-tui --locked`
for v0.8.7 and at `npm i -g deepseek-tui` for v0.8.8+.
### Releases
- npm wrapper publish remains manual (npm 2FA OTP requirement).
- GitHub release automation depends on `RELEASE_TAG_PAT` secret —
Generated
+7
View File
@@ -1255,6 +1255,7 @@ dependencies = [
"sha2 0.10.9",
"shellexpand",
"shlex",
"similar",
"starlark",
"tar",
"tempfile",
@@ -4574,6 +4575,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "1.0.1"
+58 -12
View File
@@ -69,6 +69,29 @@ npm install -g deepseek-tui
deepseek
```
Prebuilt binaries are published for **Linux x64**, **Linux ARM64** (v0.8.8+),
**macOS x64**, **macOS ARM64**, and **Windows x64**. For everything else —
musl, riscv64, FreeBSD, etc. — see [Build from source](#install-from-source)
below or the full [docs/INSTALL.md](docs/INSTALL.md) walkthrough.
### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC)
`npm i -g deepseek-tui` works on glibc-based ARM64 Linux from **v0.8.8**
onward. If you're stuck on v0.8.7 or earlier (where you'll see
`Unsupported architecture: arm64`), upgrade or use `cargo install`:
```bash
# requires Rust 1.85+ (https://rustup.rs)
cargo install deepseek-tui-cli --locked # provides `deepseek`
cargo install deepseek-tui --locked # provides `deepseek-tui`
```
You can also download `deepseek-linux-arm64` and `deepseek-tui-linux-arm64`
directly from the [Releases page](https://github.com/Hmbown/DeepSeek-TUI/releases)
and drop both side by side into a directory on your `PATH`. Cross-compiling
from x64 to ARM64 is documented in
[docs/INSTALL.md](docs/INSTALL.md#cross-compiling-from-x64-to-arm64-linux).
### China / mirror-friendly install
If GitHub or npm downloads are slow from mainland China, install the Rust
@@ -83,12 +106,12 @@ replace-with = "tuna"
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
```
Then install the canonical `deepseek` dispatcher and (optionally) the
companion TUI binary:
Then install the canonical `deepseek` dispatcher and the companion TUI binary
(both are required — the dispatcher delegates to the TUI runtime):
```bash
cargo install deepseek-tui-cli --locked # provides `deepseek`
cargo install deepseek-tui --locked # provides `deepseek-tui` (optional)
cargo install deepseek-tui --locked # provides `deepseek-tui`
deepseek --version
```
@@ -129,15 +152,28 @@ deepseek --provider fireworks --model deepseek-v4-pro
SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash
```
<details>
<details id="install-from-source">
<summary>Install from source</summary>
Works on any Tier-1 Rust target — including Linux musl/riscv64, FreeBSD, and
ARM64 distros that pre-date our prebuilt binaries.
```bash
# Linux build deps (Debian/Ubuntu/openEuler/Kylin):
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# # RHEL family: sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo install --path crates/tui --locked # requires Rust 1.85+
cargo install --path crates/cli --locked # requires Rust 1.85+; provides `deepseek`
cargo install --path crates/tui --locked # provides `deepseek-tui`
```
Both binaries are required — the `deepseek` dispatcher delegates to
`deepseek-tui` at runtime. Cross-compilation, mirror, and platform-specific
notes live in [docs/INSTALL.md](docs/INSTALL.md).
</details>
---
@@ -219,14 +255,24 @@ assistant message bodies, which made it impossible to copy text out of
system notes, thinking blocks, or tool output. v0.8.7 drops that gate so
the rendered transcript block is selectable end-to-end again.
> **Known issue in v0.8.7:** `deepseek update` fails with `no asset found
> for platform …` because the platform-string mapping in the self-updater
> uses `aarch64`/`x86_64` instead of the release artifact's `arm64`/`x64`
> ([#503](https://github.com/Hmbown/DeepSeek-TUI/issues/503)). Until this
> is fixed in v0.8.8, update via:
> **Known issues in v0.8.7 (fixed in v0.8.8):**
> - `deepseek update` fails with `no asset found for platform …` because the
> platform-string mapping in the self-updater uses `aarch64`/`x86_64`
> instead of the release artifact's `arm64`/`x64`
> ([#503](https://github.com/Hmbown/DeepSeek-TUI/issues/503)).
> - `npm i -g deepseek-tui` exits with `Unsupported architecture: arm64 on
> platform linux` on ARM64 Linux because v0.8.7 didn't publish a
> `deepseek-linux-arm64` asset.
>
> Until v0.8.8 ships, install via:
> ```bash
> npm i -g deepseek-tui # or
> cargo install deepseek-tui-cli --locked
> # x64 Linux / macOS / Windows
> npm i -g deepseek-tui
>
> # ARM64 Linux (HarmonyOS, openEuler, Asahi, Raspberry Pi, Graviton, …) —
> # build from source with Cargo (Rust 1.85+):
> cargo install deepseek-tui-cli --locked # provides `deepseek`
> cargo install deepseek-tui --locked # provides `deepseek-tui`
> ```
Full changelog: [CHANGELOG.md](CHANGELOG.md).
+36 -1
View File
@@ -46,6 +46,11 @@ npm install -g deepseek-tui
deepseek
```
预构建二进制覆盖 **Linux x64**、**Linux ARM64**v0.8.8 起)、**macOS x64**、
**macOS ARM64**、**Windows x64**。其他平台(musl、riscv64、FreeBSD 等)请见
下方的 [从源码安装](#从源码安装) 章节,或参考完整的
[docs/INSTALL.md](docs/INSTALL.md)。
首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。也可以提前配置:
```bash
@@ -57,6 +62,23 @@ export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
deepseek
```
### Linux ARM64HarmonyOS 轻薄本、openEuler、Kylin、树莓派、Graviton 等)
**v0.8.8** 起,`npm i -g deepseek-tui` 直接支持 glibc 系的 ARM64 Linux。
如果你停留在 v0.8.7 或更早版本,会看到 `Unsupported architecture: arm64`
错误。升级到最新版即可,或直接用 `cargo install`
```bash
# 需要 Rust 1.85+https://rustup.rs
cargo install deepseek-tui-cli --locked # 提供 `deepseek`
cargo install deepseek-tui --locked # 提供 `deepseek-tui`
```
也可以从 [Releases 页面](https://github.com/Hmbown/DeepSeek-TUI/releases) 下载
`deepseek-linux-arm64``deepseek-tui-linux-arm64`,放到同一个 `PATH` 目录里。
从 x64 主机交叉编译到 ARM64 的步骤见
[docs/INSTALL.md](docs/INSTALL.md#cross-compiling-from-x64-to-arm64-linux)。
### 中国大陆 / 镜像友好安装
如果在中国大陆访问 GitHub 或 npm 下载较慢,可以通过 Cargo 注册表镜像安装 Rust crate
@@ -88,13 +110,26 @@ deepseek doctor --json
### 从源码安装
适用于任何 Tier-1 Rust 目标,包括 musl、riscv64、FreeBSD,以及早于
v0.8.8、还没有官方预编译包的 ARM64 发行版。
```bash
# Linux 构建依赖(Debian/Ubuntu/openEuler/Kylin):
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# # RHEL 系:sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo install --path crates/tui --locked # 需要 Rust 1.85+
cargo install --path crates/cli --locked # 需要 Rust 1.85+;提供 `deepseek`
cargo install --path crates/tui --locked # 提供 `deepseek-tui`
deepseek --version
```
两个二进制都需要安装:`deepseek` 是入口调度器,运行时会调用 `deepseek-tui`
跨平台编译、镜像、平台特定故障排查见 [docs/INSTALL.md](docs/INSTALL.md)。
---
## 其他模型提供方
+12
View File
@@ -69,6 +69,17 @@ memory_path = "~/.deepseek/memory.md"
# inside the project array if you want both. An explicit empty array
# (`instructions = []`) clears the user list for the current repo.
# ─────────────────────────────────────────────────────────────────────────────────
# User memory (#489) — opt-in. When enabled, the TUI reads memory_path on
# startup and injects its contents into the system prompt as a
# <user_memory> block, intercepts `# foo` typed in the composer to append
# the line as a timestamped bullet, and registers a `remember` tool the
# model can call to add durable notes itself.
# ─────────────────────────────────────────────────────────────────────────────────
[memory]
# enabled = true # turn the feature on (default: false)
# Override the env-var equivalent: `DEEPSEEK_MEMORY=on`
# Parsed but currently unused (reserved for future versions):
# tools_file = "./tools.json"
@@ -184,6 +195,7 @@ max_subagents = 10 # optional (1-20)
alternate_screen = "auto" # auto | always | never
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
# ─────────────────────────────────────────────────────────────────────────────────
# Feature Flags
+1
View File
@@ -38,6 +38,7 @@ futures-util = "0.3.31"
ratatui = "0.29"
regex = "1.11"
reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "native-tls", "http2"] }
similar = "2"
rustyline = "15.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.149", features = ["preserve_order"] }
+15 -19
View File
@@ -636,23 +636,17 @@ impl DeepSeekClient {
}
},
Some(Box::new(|err, attempt, delay| {
let reason = match err {
LlmError::RateLimited { .. } => "rate_limited",
LlmError::ServerError { .. } => "server_error",
LlmError::NetworkError(_) => "network_error",
LlmError::Timeout(_) => "timeout",
_ => "other",
};
let (reason_label, human_reason) = retry_reason_label_and_human(err);
logging::warn(format!(
"HTTP retry reason={} attempt={} delay={:.2}s",
reason,
reason_label,
attempt + 1,
delay.as_secs_f64(),
));
// Light up the foreground retry banner (#499). `attempt`
// here is 0-indexed for the *failed* attempt; surface the
// 1-indexed *upcoming* attempt the user is waiting on.
crate::retry_status::start(attempt + 1, delay, human_retry_reason(err, reason));
crate::retry_status::start(attempt + 1, delay, human_reason);
})),
)
.await;
@@ -681,22 +675,24 @@ impl DeepSeekClient {
}
}
/// Translate the structured `LlmError` into a short human reason string
/// for the retry banner. Falls back to the categorical label so even
/// unknown variants render something useful.
fn human_retry_reason(err: &LlmError, fallback: &'static str) -> String {
/// Translate the structured `LlmError` into both a categorical label
/// (for structured logs / metrics) and a short human reason string
/// (for the retry banner). Returning both from one match avoids the
/// double-classification we had before.
fn retry_reason_label_and_human(err: &LlmError) -> (&'static str, String) {
match err {
LlmError::RateLimited { retry_after, .. } => {
if let Some(after) = retry_after {
let human = if let Some(after) = retry_after {
format!("rate limited (Retry-After {}s)", after.as_secs())
} else {
"rate limited".to_string()
}
};
("rate_limited", human)
}
LlmError::ServerError { status, .. } => format!("upstream {status}"),
LlmError::NetworkError(_) => "network error".to_string(),
LlmError::Timeout(_) => "timeout".to_string(),
_ => fallback.to_string(),
LlmError::ServerError { status, .. } => ("server_error", format!("upstream {status}")),
LlmError::NetworkError(_) => ("network_error", "network error".to_string()),
LlmError::Timeout(_) => ("timeout", "timeout".to_string()),
_ => ("other", "other".to_string()),
}
}
+62
View File
@@ -0,0 +1,62 @@
//! `/memory` slash command — inspect and edit the user memory file.
//!
//! When the user-memory feature is opted-in (`[memory] enabled = true` in
//! config or `DEEPSEEK_MEMORY=on` in the environment), `/memory` shows
//! the current memory file path and contents inline. Subcommands let the
//! user clear or open the file:
//!
//! - `/memory` — show path + content
//! - `/memory show` — alias for the no-arg form
//! - `/memory clear` — replace the file contents with an empty marker
//! - `/memory path` — show only the resolved path
//!
//! Editor integration (`/memory edit`) is intentionally minimal: the
//! command prints a copy-pasteable shell line to open the file in the
//! user's `$VISUAL` / `$EDITOR`, since the in-process external editor
//! plumbing requires terminal teardown that the slash-command handler
//! doesn't have access to.
use std::fs;
use super::CommandResult;
use crate::tui::app::App;
pub fn memory(app: &mut App, arg: Option<&str>) -> CommandResult {
if !app.use_memory {
return CommandResult::error(
"user memory is disabled. Enable with `[memory] enabled = true` in `~/.deepseek/config.toml` or `DEEPSEEK_MEMORY=on` in your environment, then restart the TUI.",
);
}
let path = app.memory_path.clone();
let sub = arg.unwrap_or("show").trim();
match sub {
"" | "show" => {
let body = match fs::read_to_string(&path) {
Ok(text) if text.trim().is_empty() => format!(
"{}\n(empty — add via `# foo` from the composer or have the model use the `remember` tool)",
path.display()
),
Ok(text) => format!("{}\n\n{}", path.display(), text.trim_end()),
Err(_) => format!(
"{}\n(file does not exist yet — add via `# foo` from the composer to create it)",
path.display()
),
};
CommandResult::message(body)
}
"path" => CommandResult::message(path.display().to_string()),
"clear" => match fs::write(&path, "") {
Ok(()) => CommandResult::message(format!("memory cleared: {}", path.display())),
Err(err) => CommandResult::error(format!("failed to clear {}: {err}", path.display())),
},
"edit" => CommandResult::message(format!(
"to edit your memory file, run:\n\n ${{VISUAL:-${{EDITOR:-vi}}}} {}",
path.display()
)),
_ => CommandResult::error(format!(
"unknown subcommand `{sub}`. usage: /memory [show|path|clear|edit]"
)),
}
}
+2
View File
@@ -13,6 +13,7 @@ mod hooks;
mod init;
mod jobs;
mod mcp;
mod memory;
mod note;
mod provider;
mod queue;
@@ -481,6 +482,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"links" | "dashboard" | "api" => core::deepseek_links(app),
"home" | "stats" | "overview" => core::home_dashboard(app),
"note" => note::note(app, arg),
"memory" => memory::memory(app, arg),
"attach" | "image" | "media" => attachment::attach(app, arg),
"task" | "tasks" => task::task(app, arg),
"jobs" | "job" => jobs::jobs(app, arg),
+50
View File
@@ -336,6 +336,13 @@ pub struct TuiConfig {
/// Edited interactively via `/statusline`; persisted to `tui.status_items`
/// in `~/.deepseek/config.toml`.
pub status_items: Option<Vec<StatusItem>>,
/// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so
/// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,
/// WezTerm, Alacritty, recent gnome-terminal/konsole) make them
/// Cmd+click-openable. Terminals without OSC 8 support render the plain
/// label and ignore the escape. Defaults to `true`; set `false` for
/// terminals that misrender the sequence.
pub osc8_links: Option<bool>,
}
/// Notification delivery method (mirrors `tui::notifications::Method`).
@@ -400,6 +407,20 @@ impl Default for SnapshotsConfig {
}
}
/// User-level memory configuration (#489).
///
/// Default is opt-in: when this table is absent or `enabled = false`, the
/// memory file is neither read nor written, and `# foo` quick-adds in the
/// composer fall through to the normal turn-submission path.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MemoryConfig {
/// When `true`, load the user memory file at `Config::memory_path()`
/// into the system prompt as a `<user_memory>` block, and intercept
/// `# foo` typed in the composer to append to that file. Default `false`.
#[serde(default)]
pub enabled: Option<bool>,
}
impl SnapshotsConfig {
#[must_use]
pub fn max_age(&self) -> std::time::Duration {
@@ -732,6 +753,12 @@ pub struct Config {
#[serde(default)]
pub snapshots: Option<SnapshotsConfig>,
/// User-level memory file (#489). Default behaviour is **opt-in**:
/// loading + injection happens only when `[memory] enabled = true` or
/// `DEEPSEEK_MEMORY=on` is set.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
/// applies the defaults documented in [`LspConfigToml`].
#[serde(default)]
@@ -1289,6 +1316,18 @@ impl Config {
.collect()
}
/// Whether the user-memory feature is enabled. The default is **off**
/// to preserve zero-overhead behavior for users who haven't opted in.
/// Flips to `true` when `[memory] enabled = true` in `config.toml` or
/// `DEEPSEEK_MEMORY=on` is set in the environment.
#[must_use]
pub fn memory_enabled(&self) -> bool {
self.memory
.as_ref()
.and_then(|m| m.enabled)
.unwrap_or(false)
}
/// Return whether shell execution is allowed.
#[must_use]
pub fn allow_shell(&self) -> bool {
@@ -1653,6 +1692,16 @@ fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = std::env::var("DEEPSEEK_MEMORY_PATH") {
config.memory_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MEMORY") {
let on = matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "on" | "true" | "yes" | "y" | "enabled"
);
config
.memory
.get_or_insert_with(MemoryConfig::default)
.enabled = Some(on);
}
if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") {
config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
@@ -1934,6 +1983,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
network: override_cfg.network.or(base.network),
skills: override_cfg.skills.or(base.skills),
snapshots: override_cfg.snapshots.or(base.snapshots),
memory: override_cfg.memory.or(base.memory),
lsp: override_cfg.lsp.or(base.lsp),
context: ContextConfig {
enabled: override_cfg.context.enabled.or(base.context.enabled),
+22
View File
@@ -132,6 +132,13 @@ pub struct EngineConfig {
pub runtime_services: RuntimeToolServices,
/// Per-role/type sub-agent model overrides already resolved from config.
pub subagent_model_overrides: HashMap<String, String>,
/// Whether the user-memory feature is enabled (#489). When `true` the
/// engine reads `memory_path` on each prompt assembly and prepends a
/// `<user_memory>` block to the system prompt.
pub memory_enabled: bool,
/// Path to the user memory file (#489). Always populated; only
/// consulted when `memory_enabled` is `true`.
pub memory_path: PathBuf,
}
impl Default for EngineConfig {
@@ -159,6 +166,8 @@ impl Default for EngineConfig {
lsp_config: None,
runtime_services: RuntimeToolServices::default(),
subagent_model_overrides: HashMap::new(),
memory_enabled: false,
memory_path: PathBuf::from("./memory.md"),
}
}
}
@@ -344,12 +353,15 @@ impl Engine {
// Set up system prompt with project context (default to agent mode)
let working_set_summary = session.working_set.summary_block(&config.workspace);
let user_memory_block =
crate::memory::compose_block(config.memory_enabled, &config.memory_path);
let system_prompt = prompts::system_prompt_for_mode_with_context_and_skills(
AppMode::Agent,
&config.workspace,
None,
Some(&config.skills_dir),
Some(&config.instructions),
user_memory_block.as_deref(),
);
session.system_prompt =
append_working_set_summary(Some(system_prompt), working_set_summary.as_deref());
@@ -1239,6 +1251,13 @@ impl Engine {
.with_cancel_token(self.cancel_token.clone())
.with_trusted_external_paths(trusted.paths().to_vec());
// Hand the user-memory path to tools so the model-callable
// `remember` tool can append entries (#489). `None` when the
// feature is disabled — tools short-circuit on that.
if self.config.memory_enabled {
ctx.memory_path = Some(self.config.memory_path.clone());
}
if let Some(decider) = self.config.network_policy.as_ref() {
ctx = ctx.with_network_policy(decider.clone());
}
@@ -1610,12 +1629,15 @@ impl Engine {
.session
.working_set
.summary_block(&self.config.workspace);
let user_memory_block =
crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path);
let base = prompts::system_prompt_for_mode_with_context_and_skills(
mode,
&self.config.workspace,
None,
Some(&self.config.skills_dir),
Some(&self.config.instructions),
user_memory_block.as_deref(),
);
let stable_prompt =
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
+7
View File
@@ -48,6 +48,13 @@ impl Engine {
builder = builder.with_shell_tools();
}
// Register the `remember` tool only when the user has opted in to
// user-memory (#489). Without that opt-in the tool would always
// fail; surfacing it would just waste catalog slots.
if self.config.memory_enabled {
builder = builder.with_remember_tool();
}
builder
}
}
+6 -1
View File
@@ -36,6 +36,7 @@ mod logging;
mod lsp;
mod mcp;
mod mcp_server;
mod memory;
mod models;
mod network_policy;
mod palette;
@@ -3465,7 +3466,7 @@ async fn run_interactive(
memory_path: config.memory_path(),
notes_path: config.notes_path(),
mcp_config_path: config.mcp_config_path(),
use_memory: false,
use_memory: config.memory_enabled(),
start_in_agent_mode: cli.yolo,
skip_onboarding: cli.skip_onboarding,
yolo: cli.yolo, // YOLO mode auto-approves all tool executions
@@ -3627,6 +3628,8 @@ async fn run_exec_agent(
lsp_config,
runtime_services: crate::tools::spec::RuntimeToolServices::default(),
subagent_model_overrides: config.subagent_model_overrides(),
memory_enabled: config.memory_enabled(),
memory_path: config.memory_path(),
};
let engine_handle = spawn_engine(engine_config, config);
@@ -3833,6 +3836,7 @@ mod terminal_mode_tests {
mouse_capture: Some(false),
terminal_probe_timeout_ms: None,
status_items: None,
osc8_links: None,
}),
..Config::default()
};
@@ -3857,6 +3861,7 @@ mod terminal_mode_tests {
mouse_capture: Some(true),
terminal_probe_timeout_ms: None,
status_items: None,
osc8_links: None,
}),
..Config::default()
};
+197
View File
@@ -0,0 +1,197 @@
//! User-level memory file.
//!
//! v0.8.8 ships an MVP that lets the user keep a persistent personal
//! note file the model sees on every turn:
//!
//! - **Load** `~/.deepseek/memory.md` (path is configurable via
//! `memory_path` in `config.toml` and `DEEPSEEK_MEMORY_PATH` env),
//! wrap it in a `<user_memory>` block, and prepend it to the system
//! prompt alongside the existing `<project_instructions>` block.
//! - **`# foo`** typed in the composer appends `foo` to the memory
//! file as a timestamped bullet — fast capture without leaving the TUI.
//! - **`/memory`** opens the memory file in `$VISUAL` / `$EDITOR`.
//! - **`remember` tool** lets the model itself append a bullet when it
//! notices a durable preference or convention worth keeping across
//! sessions.
//!
//! Default behavior is **opt-in**: load + use the memory file only when
//! `[memory] enabled = true` in `config.toml` or `DEEPSEEK_MEMORY=on`.
//! That keeps existing users on zero-overhead behavior and makes the
//! feature explicit.
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use chrono::Utc;
/// Maximum size of the user memory file. Larger files are loaded but the
/// `<user_memory>` block carries a "(truncated)" marker so the user knows
/// the model only saw a slice. Mirrors `project_context::MAX_CONTEXT_SIZE`.
const MAX_MEMORY_SIZE: usize = 100 * 1024;
/// Read the user memory file at `path`, returning `None` when the file
/// doesn't exist or is empty after trimming.
#[must_use]
pub fn load(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
if content.trim().is_empty() {
return None;
}
Some(content)
}
/// Wrap memory content in a `<user_memory>` block ready to prepend to the
/// system prompt. The `source` value is rendered verbatim into a
/// `source="…"` attribute — pass the path so the model can see where the
/// memory came from. Returns `None` for empty content.
#[must_use]
pub fn as_system_block(content: &str, source: &Path) -> Option<String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
let display = source.display();
let payload = if content.len() > MAX_MEMORY_SIZE {
let mut head = content[..MAX_MEMORY_SIZE].to_string();
head.push_str("\n…(truncated, raise [memory].max_size or trim memory.md)");
head
} else {
trimmed.to_string()
};
Some(format!(
"<user_memory source=\"{display}\">\n{payload}\n</user_memory>"
))
}
/// Compose the `<user_memory>` block for the system prompt, honouring the
/// opt-in toggle. Returns `None` when the feature is disabled or the file
/// is missing / empty so the caller doesn't have to check both conditions.
///
/// Callers that hold a `&Config` should pass `config.memory_enabled()` and
/// `config.memory_path()` directly. The split keeps this module
/// `Config`-free so it can be reused from sub-agent / engine boundaries
/// where the high-level `Config` isn't available.
#[must_use]
pub fn compose_block(enabled: bool, path: &Path) -> Option<String> {
if !enabled {
return None;
}
let content = load(path)?;
as_system_block(&content, path)
}
/// Append `entry` to the memory file at `path`, creating it (and its
/// parent directory) if needed. The entry is timestamped so the user can
/// later see when each note was added. The leading `#` from a `# foo`
/// quick-add is stripped so the file stays as readable Markdown.
pub fn append_entry(path: &Path, entry: &str) -> io::Result<()> {
let trimmed = entry.trim_start_matches('#').trim();
if trimmed.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"memory entry is empty after stripping `#` prefix",
));
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let timestamp = Utc::now().format("%Y-%m-%d %H:%M UTC");
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "- ({timestamp}) {trimmed}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_returns_none_for_missing_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("never-existed.md");
assert!(load(&path).is_none());
}
#[test]
fn load_returns_none_for_whitespace_only_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
fs::write(&path, " \n \n").unwrap();
assert!(load(&path).is_none());
}
#[test]
fn load_returns_content_for_real_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
fs::write(&path, "remember the milk").unwrap();
assert_eq!(load(&path).as_deref(), Some("remember the milk"));
}
#[test]
fn as_system_block_produces_xml_wrapper() {
let block = as_system_block("note 1", Path::new("/tmp/m.md")).unwrap();
assert!(block.contains("<user_memory source=\"/tmp/m.md\">"));
assert!(block.contains("note 1"));
assert!(block.ends_with("</user_memory>"));
}
#[test]
fn as_system_block_returns_none_for_empty_content() {
assert!(as_system_block(" ", Path::new("/tmp/m.md")).is_none());
}
#[test]
fn as_system_block_truncates_oversize_input() {
let big = "x".repeat(MAX_MEMORY_SIZE + 100);
let block = as_system_block(&big, Path::new("/tmp/m.md")).unwrap();
assert!(block.contains("(truncated"));
}
#[test]
fn append_entry_creates_file_and_writes_one_bullet() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
append_entry(&path, "# remember the milk").unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("remember the milk"), "{body}");
assert!(
body.starts_with("- ("),
"should start with bullet + date: {body}"
);
assert!(body.trim_end().ends_with("remember the milk"));
}
#[test]
fn append_entry_appends_subsequent_lines() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
append_entry(&path, "# first").unwrap();
append_entry(&path, "second").unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("first"));
assert!(body.contains("second"));
// Two bullets means two lines of `- (date) entry`.
assert_eq!(body.matches("- (").count(), 2);
}
#[test]
fn append_entry_rejects_empty_after_strip() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
let err = append_entry(&path, "###").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
}
+20 -2
View File
@@ -226,7 +226,14 @@ pub fn system_prompt_for_mode_with_context(
workspace: &Path,
working_set_summary: Option<&str>,
) -> SystemPrompt {
system_prompt_for_mode_with_context_and_skills(mode, workspace, working_set_summary, None, None)
system_prompt_for_mode_with_context_and_skills(
mode,
workspace,
working_set_summary,
None,
None,
None,
)
}
/// Get the system prompt for a specific mode with project and skills context.
@@ -252,6 +259,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
working_set_summary: Option<&str>,
skills_dir: Option<&Path>,
instructions: Option<&[PathBuf]>,
user_memory_block: Option<&str>,
) -> SystemPrompt {
let mode_prompt = compose_mode_prompt(mode);
@@ -271,7 +279,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
)
};
// 2.5. Configured `instructions = [...]` files (#454). Loaded
// 2.5a. Configured `instructions = [...]` files (#454). Loaded
// and concatenated in declared order. Lives above the skills
// block so it's part of the workspace-static layer that the KV
// prefix cache can hit, and so per-project overrides apply
@@ -282,6 +290,15 @@ pub fn system_prompt_for_mode_with_context_and_skills(
full_prompt = format!("{full_prompt}\n\n{block}");
}
// 2.5b. User memory block (#489). Goes above skills/context-management
// because it's session-stable: the memory file changes when the user
// edits it via `/memory` or `# foo` quick-add, but not turn-over-turn.
if let Some(memory_block) = user_memory_block
&& !memory_block.trim().is_empty()
{
full_prompt = format!("{full_prompt}\n\n{memory_block}");
}
// 3. Skills block. #432: walks every candidate workspace
// skills directory (`.agents/skills`, `skills`,
// `.opencode/skills`, `.claude/skills`) plus the global
@@ -786,6 +803,7 @@ mod tests {
None,
None,
Some(std::slice::from_ref(&extra)),
None,
) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
+2
View File
@@ -1564,6 +1564,8 @@ impl RuntimeThreadManager {
shell_manager: None,
},
subagent_model_overrides: self.config.subagent_model_overrides(),
memory_enabled: self.config.memory_enabled(),
memory_path: self.config.memory_path(),
};
let engine = spawn_engine(engine_cfg, &self.config);
+77
View File
@@ -0,0 +1,77 @@
//! Build unified-diff strings for tool results.
//!
//! `edit_file` and `write_file` capture the file contents before and after
//! the mutation and emit a unified diff at the head of their `ToolResult`
//! output. The TUI's `output_looks_like_diff` detector then routes the
//! payload through `diff_render::render_diff`, which renders it with line
//! numbers and coloured `+`/`-` gutters (#505).
//!
//! The diff is also a strict UX upgrade for the model — it sees exactly
//! which lines changed instead of a one-line summary.
use similar::TextDiff;
/// Build a unified diff between `old` and `new` keyed at `path`.
///
/// Returns an empty string when the inputs are byte-identical so callers
/// can skip the "no changes" header. The output uses git-style `--- a/...`
/// / `+++ b/...` headers and three lines of context — matching the format
/// the TUI's `diff_render::render_diff` already understands.
#[must_use]
pub fn make_unified_diff(path: &str, old: &str, new: &str) -> String {
if old == new {
return String::new();
}
let a = format!("a/{path}");
let b = format!("b/{path}");
let diff = TextDiff::from_lines(old, new);
diff.unified_diff()
.context_radius(3)
.header(&a, &b)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_inputs_emit_empty_diff() {
let s = "hello\nworld\n";
assert!(make_unified_diff("foo.txt", s, s).is_empty());
}
#[test]
fn replacement_emits_minus_plus_pair() {
let old = "alpha\nbeta\ngamma\n";
let new = "alpha\nBETA\ngamma\n";
let diff = make_unified_diff("foo.txt", old, new);
assert!(diff.contains("--- a/foo.txt"), "{diff}");
assert!(diff.contains("+++ b/foo.txt"), "{diff}");
assert!(diff.contains("-beta"), "{diff}");
assert!(diff.contains("+BETA"), "{diff}");
}
#[test]
fn new_file_renders_against_empty_old() {
let new = "first line\nsecond line\n";
let diff = make_unified_diff("new.txt", "", new);
assert!(diff.contains("--- a/new.txt"), "{diff}");
assert!(diff.contains("+++ b/new.txt"), "{diff}");
assert!(diff.contains("+first line"), "{diff}");
assert!(diff.contains("+second line"), "{diff}");
}
#[test]
fn diff_contains_hunk_header_so_tui_renders_it() {
// The TUI detector scans the first 5 lines for `@@`. Make sure the
// unified diff puts a hunk header within that window so the
// diff-aware renderer kicks in (#505).
let diff = make_unified_diff("foo.txt", "a\n", "b\n");
let head: Vec<&str> = diff.lines().take(5).collect();
assert!(
head.iter().any(|line| line.starts_with("@@")),
"expected hunk header in first 5 lines; got {head:?}"
);
}
}
+56 -11
View File
@@ -3,6 +3,7 @@
//! These tools provide safe file system operations within the workspace,
//! with path validation to prevent escaping the workspace boundary.
use super::diff_format::make_unified_diff;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
@@ -228,6 +229,15 @@ impl ToolSpec for WriteFileTool {
let file_path = context.resolve_path(path_str)?;
// Snapshot the existing contents (if any) before we overwrite — used
// to render an inline diff in the tool result.
let existed_before = file_path.exists();
let prior_contents = if existed_before {
fs::read_to_string(&file_path).unwrap_or_default()
} else {
String::new()
};
// Create parent directories if needed
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
@@ -243,11 +253,20 @@ impl ToolSpec for WriteFileTool {
ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
})?;
Ok(ToolResult::success(format!(
"Wrote {} bytes to {}",
file_content.len(),
file_path.display()
)))
let display = file_path.display().to_string();
let diff = make_unified_diff(&display, &prior_contents, file_content);
let summary = if existed_before {
format!("Wrote {} bytes to {}", file_content.len(), display)
} else {
format!("Created {} ({} bytes)", display, file_content.len())
};
let body = if diff.is_empty() {
format!("{summary}\n(no changes)")
} else {
format!("{diff}\n{summary}")
};
Ok(ToolResult::success(body))
}
}
@@ -324,11 +343,16 @@ impl ToolSpec for EditFileTool {
ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
})?;
Ok(ToolResult::success(format!(
"Replaced {} occurrence(s) in {}",
count,
file_path.display()
)))
let display = file_path.display().to_string();
let diff = make_unified_diff(&display, &contents, &updated);
let summary = format!("Replaced {count} occurrence(s) in {display}");
let body = if diff.is_empty() {
format!("{summary}\n(no textual changes)")
} else {
format!("{diff}\n{summary}")
};
Ok(ToolResult::success(body))
}
}
@@ -527,7 +551,15 @@ mod tests {
.expect("execute");
assert!(result.success);
assert!(result.content.contains("Wrote"));
// New file → "Created …" summary; the unified diff above the summary
// primes the TUI's diff-aware renderer (#505).
assert!(result.content.contains("Created"), "{}", result.content);
assert!(result.content.contains("--- a/"), "{}", result.content);
assert!(
result.content.contains("+test content"),
"{}",
result.content
);
// Verify file was written
let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read");
@@ -575,6 +607,19 @@ mod tests {
assert!(result.success);
assert!(result.content.contains("2 occurrence(s)"));
// Inline diff (#505) — the unified diff lands above the summary
// line so the TUI's diff-aware renderer kicks in.
assert!(result.content.contains("--- a/"), "{}", result.content);
assert!(
result.content.contains("-hello world hello"),
"{}",
result.content
);
assert!(
result.content.contains("+hi world hi"),
"{}",
result.content
);
// Verify edit was applied
let edited = fs::read_to_string(&test_file).expect("read");
+2
View File
@@ -4,6 +4,7 @@ pub mod apply_patch;
pub mod approval_cache;
pub mod automation;
pub mod diagnostics;
pub mod diff_format;
pub mod file;
pub mod file_search;
pub mod finance;
@@ -17,6 +18,7 @@ pub mod plan;
pub mod project;
pub mod recall_archive;
pub mod registry;
pub mod remember;
pub mod revert_turn;
pub mod review;
pub mod rlm;
+10
View File
@@ -514,6 +514,16 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(NoteTool))
}
/// Include the `remember` tool — model-callable bullet-add into the
/// user memory file (#489). Only register when the user has opted
/// in to the memory feature; without that, the tool would surface
/// in the model's catalog but always fail with "memory disabled".
#[must_use]
pub fn with_remember_tool(self) -> Self {
use super::remember::RememberTool;
self.with_tool(Arc::new(RememberTool))
}
/// Include MCP tools from a connected pool as first-class registry
/// citizens. Each MCP tool is wrapped in a lightweight adapter that
/// implements `ToolSpec`, so the unified `ToolRegistryBuilder` flow
+138
View File
@@ -0,0 +1,138 @@
//! `remember` tool — model-callable bullet-add into the user memory file.
//!
//! Lets the model itself notice a durable preference, convention, or fact
//! worth keeping across sessions and write it to the user's `memory.md`.
//! The tool is auto-approved and side-effecting only on the user-owned
//! memory file (`~/.deepseek/memory.md` by default), so it doesn't get
//! gated behind the same approval flow as shell or arbitrary file writes.
//!
//! Only registered when `[memory] enabled = true` (or
//! `DEEPSEEK_MEMORY=on`). When disabled, the tool isn't surfaced to the
//! model at all, so prompts that mention `remember` simply fall through.
use async_trait::async_trait;
use serde_json::{Value, json};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
};
/// Tool that appends one bullet to the user memory file.
pub struct RememberTool;
#[async_trait]
impl ToolSpec for RememberTool {
fn name(&self) -> &'static str {
"remember"
}
fn description(&self) -> &'static str {
"Append a durable note to the user memory file so it surfaces in \
future sessions. Use this when the user states a preference, a \
convention they want enforced, or a fact about themselves or \
their workflow that you should not have to relearn next time. \
Keep notes terse (one sentence). Don't store secrets, transient \
tasks, or reasoning scratch those belong in a checklist or in \
the conversation."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "The single-sentence durable note to remember."
}
},
"required": ["note"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::WritesFiles]
}
fn approval_requirement(&self) -> ApprovalRequirement {
// Memory writes are scoped to the user's own memory file; gating
// them behind the standard shell/write approval would defeat the
// point of automatic memory.
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let note = required_str(&input, "note")?;
let path = context.memory_path.as_ref().ok_or_else(|| {
ToolError::execution_failed(
"user memory is disabled — set `[memory] enabled = true` in config.toml or \
`DEEPSEEK_MEMORY=on` in the environment to enable",
)
})?;
crate::memory::append_entry(path, note).map_err(|err| {
ToolError::execution_failed(format!("failed to append to {}: {err}", path.display()))
})?;
Ok(ToolResult::success(format!(
"remembered: {}",
note.trim_start_matches('#').trim()
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::tempdir;
fn ctx_with_memory(path: PathBuf) -> ToolContext {
let mut ctx = ToolContext::new(path.parent().unwrap_or_else(|| std::path::Path::new(".")));
ctx.memory_path = Some(path);
ctx
}
#[tokio::test]
async fn returns_error_when_memory_disabled() {
let tmp = tempdir().unwrap();
let mut ctx = ToolContext::new(tmp.path());
ctx.memory_path = None; // explicitly disabled
let tool = RememberTool;
let err = tool
.execute(json!({"note": "use 4 spaces for indentation"}), &ctx)
.await
.unwrap_err();
assert!(err.to_string().contains("memory is disabled"), "{err}");
}
#[tokio::test]
async fn appends_bullet_to_memory_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
let ctx = ctx_with_memory(path.clone());
let tool = RememberTool;
let result = tool
.execute(json!({"note": "use 4 spaces for indentation"}), &ctx)
.await
.expect("ok");
assert!(result.success);
assert!(result.content.contains("4 spaces"));
let body = std::fs::read_to_string(&path).expect("read");
assert!(body.contains("4 spaces"));
assert!(body.starts_with("- ("), "{body}");
}
#[tokio::test]
async fn rejects_missing_note_field() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
let ctx = ctx_with_memory(path);
let tool = RememberTool;
let err = tool.execute(json!({}), &ctx).await.unwrap_err();
assert!(err.to_string().to_lowercase().contains("note"), "{err}");
}
}
+8
View File
@@ -100,6 +100,11 @@ pub struct ToolContext {
/// Cancellation token for the active engine turn. Tools that may wait on
/// external work should observe this so UI cancel can interrupt them.
pub cancel_token: Option<CancellationToken>,
/// Path to the user memory file. `None` when the user-memory feature
/// (#489) is disabled — tools that read or write the file should
/// short-circuit on `None` rather than fall back to a workspace-local
/// default.
pub memory_path: Option<PathBuf>,
}
impl ToolContext {
@@ -125,6 +130,7 @@ impl ToolContext {
network_policy: None,
runtime: RuntimeToolServices::default(),
cancel_token: None,
memory_path: None,
}
}
@@ -153,6 +159,7 @@ impl ToolContext {
network_policy: None,
runtime: RuntimeToolServices::default(),
cancel_token: None,
memory_path: None,
}
}
@@ -181,6 +188,7 @@ impl ToolContext {
network_policy: None,
runtime: RuntimeToolServices::default(),
cancel_token: None,
memory_path: None,
}
}
+12 -2
View File
@@ -568,6 +568,14 @@ pub struct App {
pub config_profile: Option<String>,
pub mcp_config_path: PathBuf,
pub skills_dir: PathBuf,
/// Path to the user-memory file (#489). Always populated; only
/// consulted when `use_memory` is `true`.
pub memory_path: PathBuf,
/// Whether the user-memory feature is enabled (#489). Mirrors
/// `Config::memory_enabled()` at app boot. Used by the `# foo`
/// composer interception, the `/memory` slash command, and tool
/// registration for `remember`.
pub use_memory: bool,
pub use_alt_screen: bool,
pub use_mouse_capture: bool,
pub use_bracketed_paste: bool,
@@ -946,10 +954,10 @@ impl App {
use_bracketed_paste,
max_subagents,
skills_dir: global_skills_dir,
memory_path: _,
memory_path,
notes_path: _,
mcp_config_path,
use_memory: _,
use_memory,
start_in_agent_mode,
skip_onboarding,
yolo,
@@ -1091,6 +1099,8 @@ impl App {
config_profile,
mcp_config_path: mcp_config_path.clone(),
skills_dir,
memory_path,
use_memory,
use_alt_screen,
use_mouse_capture,
use_bracketed_paste,
+50 -6
View File
@@ -32,6 +32,7 @@ use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::palette;
use crate::tui::osc8;
// Thread-local counter incremented every time `parse` runs. Used by tests to
// prove that width-only changes hit the cached-AST path and skip parsing.
@@ -324,11 +325,8 @@ fn render_line_with_links(
let mut current_width = 0usize;
for word in line.split_whitespace() {
let style = if looks_like_link(word) {
link_style
} else {
base_style
};
let is_link = looks_like_link(word);
let style = if is_link { link_style } else { base_style };
let word_width = word.width();
let additional = if current_width == 0 {
word_width
@@ -347,7 +345,16 @@ fn render_line_with_links(
current_width += 1;
}
current_spans.push(Span::styled(word.to_string(), style));
// For URLs, wrap the visible text in OSC 8 escapes when the runtime
// flag allows it. Display width is computed from the bare URL — the
// escapes are zero-width on supporting terminals and ignored on the
// rest. The clipboard / selection path strips OSC 8 before yanking.
let content = if is_link && osc8::enabled() {
osc8::wrap_link(word, word)
} else {
word.to_string()
};
current_spans.push(Span::styled(content, style));
current_width += word_width;
}
@@ -512,4 +519,41 @@ mod tests {
.collect();
assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]);
}
/// Render with the OSC 8 flag pinned to `enabled`, then restore the prior
/// value. We serialize through a static mutex because `osc8::ENABLED` is
/// process-wide state and other tests touching it would race otherwise.
fn render_with_osc8(enabled: bool, source: &str) -> String {
use std::sync::Mutex;
static OSC8_GUARD: Mutex<()> = Mutex::new(());
let _guard = OSC8_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = osc8::enabled();
osc8::set_enabled(enabled);
let lines = render_markdown(source, 80, Style::default());
let joined = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<String>();
osc8::set_enabled(prior);
joined
}
#[test]
fn http_links_get_osc_8_wrapped_when_enabled() {
let joined = render_with_osc8(true, "see https://example.com for details");
assert!(
joined.contains("\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\"),
"expected OSC 8 wrapper around URL; got {joined:?}"
);
}
#[test]
fn osc_8_disabled_emits_plain_url() {
let joined = render_with_osc8(false, "see https://example.com for details");
assert!(
!joined.contains("\x1b]8;;"),
"expected no OSC 8 wrapper when disabled; got {joined:?}"
);
assert!(joined.contains("https://example.com"));
}
}
+1
View File
@@ -25,6 +25,7 @@ mod mcp_routing;
pub mod model_picker;
pub mod notifications;
pub mod onboarding;
pub mod osc8;
pub mod pager;
pub mod paste;
pub mod paste_burst;
+165
View File
@@ -0,0 +1,165 @@
//! OSC 8 hyperlink emission and stripping.
//!
//! Modern terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm,
//! Alacritty, recent gnome-terminal/konsole) make a substring clickable when
//! it is wrapped in:
//!
//! ```text
//! \x1b]8;;TARGET\x1b\\LABEL\x1b]8;;\x1b\\
//! ```
//!
//! Terminals that don't understand the sequence simply render the visible
//! `LABEL` and ignore the escape. So emitting OSC 8 is a strict UX upgrade for
//! supporting terminals and a no-op for the rest.
//!
//! The TUI emits these inside `Span::content` strings so the existing
//! ratatui pipeline carries them through. The tradeoff is that the clipboard
//! / selection extraction path must strip the codes before handing text to the
//! user — that's what [`strip_into`] is for.
use std::sync::atomic::{AtomicBool, Ordering};
const OSC8_PREFIX: &str = "\x1b]8;;";
const OSC8_TERMINATOR: &str = "\x1b\\";
/// Process-wide enable flag. `true` by default. Set once at app init from
/// `[ui] osc8_links` (when present) and read by the renderer.
static ENABLED: AtomicBool = AtomicBool::new(true);
/// Set the process-wide OSC 8 enable flag. Intended to be called once at
/// startup; subsequent calls take effect immediately.
pub fn set_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
/// Whether OSC 8 hyperlink emission is currently enabled.
#[must_use]
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Wrap `label` so it links to `target` in OSC 8-aware terminals. The returned
/// string contains the full `\x1b]8;;TARGET\x1b\LABEL\x1b]8;;\x1b\` payload.
///
/// Does **not** check [`enabled()`]; callers wanting the runtime gate should
/// branch on it before calling this. That keeps the helper test-friendly.
#[must_use]
pub fn wrap_link(target: &str, label: &str) -> String {
let mut out = String::with_capacity(target.len() + label.len() + 12);
out.push_str(OSC8_PREFIX);
out.push_str(target);
out.push_str(OSC8_TERMINATOR);
out.push_str(label);
out.push_str(OSC8_PREFIX);
out.push_str(OSC8_TERMINATOR);
out
}
/// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
/// label text. Other escapes (color, style) pass through untouched. The
/// implementation handles both the standard `ESC \` and the lone `BEL`
/// terminators that some emitters use.
pub fn strip_into(s: &str, out: &mut String) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
// Look for the OSC 8 prefix `ESC ] 8 ;`
if i + 4 <= bytes.len()
&& bytes[i] == 0x1b
&& bytes[i + 1] == b']'
&& bytes[i + 2] == b'8'
&& bytes[i + 3] == b';'
{
// Skip until the string terminator (ESC \) or BEL.
let mut j = i + 4;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Serialize tests that read or write the `ENABLED` flag so they don't
/// race each other under cargo's default parallel test runner.
static FLAG_GUARD: Mutex<()> = Mutex::new(());
fn strip(s: &str) -> String {
let mut out = String::with_capacity(s.len());
strip_into(s, &mut out);
out
}
#[test]
fn wrap_link_shape_is_osc_8_compliant() {
let wrapped = wrap_link("https://example.com", "click me");
assert_eq!(
wrapped,
"\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
);
}
#[test]
fn strip_removes_wrapper_keeps_label() {
let wrapped = wrap_link("https://example.com", "click me");
assert_eq!(strip(&wrapped), "click me");
}
#[test]
fn strip_handles_bel_terminator() {
let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
assert_eq!(strip(wrapped), "click me");
}
#[test]
fn strip_passes_through_text_with_no_escapes() {
let plain = "no escapes here";
assert_eq!(strip(plain), plain);
}
#[test]
fn strip_preserves_non_osc_8_escapes() {
// Color escape stays in place; only OSC 8 wrappers are removed.
let mixed = format!(
"\x1b[31mred\x1b[0m {wrapped}",
wrapped = wrap_link("https://example.com", "click")
);
assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
}
#[test]
fn enabled_is_true_by_default_when_untouched() {
// Hold the flag guard so we observe the initial state, not a value
// mid-flight from `set_enabled_round_trips`. The flag *defaults* to
// true at static init and tests in this module are the only writers.
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
assert!(enabled());
}
#[test]
fn set_enabled_round_trips() {
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = enabled();
set_enabled(false);
assert!(!enabled());
set_enabled(true);
assert!(enabled());
set_enabled(prior);
}
}
+67
View File
@@ -148,6 +148,15 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
let use_mouse_capture = options.use_mouse_capture;
let use_bracketed_paste = options.use_bracketed_paste;
// Apply OSC 8 hyperlink toggle from config; default `true`.
crate::tui::osc8::set_enabled(
config
.tui
.as_ref()
.and_then(|tui| tui.osc8_links)
.unwrap_or(true),
);
// Terminal probe with timeout to prevent hanging on unresponsive terminals
let probe_timeout = terminal_probe_timeout(config);
let enable_raw = tokio::task::spawn_blocking(move || {
@@ -428,6 +437,51 @@ fn terminal_probe_timeout(config: &Config) -> Duration {
Duration::from_millis(timeout_ms)
}
/// Recognise composer input that is a `# foo` memory quick-add (#492).
///
/// Returns `true` for inputs that:
/// - start with `#`,
/// - have at least one non-whitespace character after the leading `#`,
/// - are a single line (no embedded `\n`), and
/// - are not a shebang (`#!`) or Markdown heading (`## …`, `### …`).
///
/// Multi-`#` prefixes are deliberately rejected so users can paste
/// Markdown headings into the composer without triggering the quick-add.
#[must_use]
fn is_memory_quick_add(input: &str) -> bool {
let trimmed = input.trim_start();
if !trimmed.starts_with('#') {
return false;
}
if trimmed.starts_with("##") || trimmed.starts_with("#!") {
return false;
}
if input.contains('\n') {
return false;
}
// Require something after the `#`.
!trimmed.trim_start_matches('#').trim().is_empty()
}
/// Persist a `# foo` quick-add to the memory file and surface a status
/// note to the user. Errors land in the same status channel so a missing
/// memory directory becomes visible without crashing the composer.
fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) {
let path = config.memory_path();
match crate::memory::append_entry(&path, input) {
Ok(()) => {
app.status_message = Some(format!("memory: appended to {}", path.display()));
}
Err(err) => {
app.status_message = Some(format!(
"memory: failed to write {}: {}",
path.display(),
err
));
}
}
}
fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
EngineConfig {
model: app.model.clone(),
@@ -465,6 +519,8 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
.map(crate::config::LspConfigToml::into_runtime),
runtime_services: app.runtime_services.clone(),
subagent_model_overrides: config.subagent_model_overrides(),
memory_enabled: config.memory_enabled(),
memory_path: config.memory_path(),
}
}
@@ -2220,6 +2276,17 @@ async fn run_event_loop(
if handle_plan_choice(app, &engine_handle, &input).await? {
continue;
}
// `# foo` quick-add (#492) — when memory is enabled,
// a single line starting with `#` (but not `##` /
// `#!` shebangs / Markdown headings the user might
// be pasting in) is intercepted: the text is
// appended to the user memory file and the input
// is consumed without firing a turn. Disabled
// behaviour falls through to normal turn submit.
if config.memory_enabled() && is_memory_quick_add(&input) {
handle_memory_quick_add(app, &input, config);
continue;
}
if input.starts_with('/') {
if execute_command_input(
terminal,
+1
View File
@@ -596,6 +596,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() {
mouse_capture: None,
terminal_probe_timeout_ms: Some(750),
status_items: None,
osc8_links: None,
}),
..Config::default()
};
+47 -8
View File
@@ -4,6 +4,7 @@ use ratatui::text::Line;
use unicode_width::UnicodeWidthChar;
use crate::tui::history::HistoryCell;
use crate::tui::osc8;
pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
cell.transcript_lines(width)
@@ -14,17 +15,27 @@ pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
}
fn line_to_string(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
let mut out = String::new();
for span in line.spans {
if span.content.contains('\x1b') {
osc8::strip_into(&span.content, &mut out);
} else {
out.push_str(&span.content);
}
}
out
}
pub(super) fn line_to_plain(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
let mut out = String::new();
for span in &line.spans {
if span.content.contains('\x1b') {
osc8::strip_into(&span.content, &mut out);
} else {
out.push_str(span.content.as_ref());
}
}
out
}
pub(super) fn text_display_width(text: &str) -> usize {
@@ -60,3 +71,31 @@ fn char_display_width(ch: char) -> usize {
UnicodeWidthChar::width(ch).unwrap_or(0).max(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn line_to_plain_strips_osc_8_wrapper() {
// A span carrying an OSC 8-wrapped URL must not leak the escape into
// selection / clipboard output. The visible label survives.
let wrapped = format!(
"\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
"https://example.com", "https://example.com"
);
let line = Line::from(vec![
Span::raw("see "),
Span::raw(wrapped),
Span::raw(" for details"),
]);
assert_eq!(line_to_plain(&line), "see https://example.com for details");
}
#[test]
fn line_to_plain_passes_through_plain_spans() {
let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
assert_eq!(line_to_plain(&line), "plain text");
}
}
+6 -7
View File
@@ -522,23 +522,22 @@ fn spans_text(spans: &[Span<'_>]) -> String {
/// reports an active retry or a final failure. Returns `None` when idle
/// so callers fall back to the regular status line / toast.
fn retry_banner_spans(max_width: usize, props: &FooterProps) -> Option<Vec<Span<'static>>> {
let label = match &props.retry {
let (label, color) = match &props.retry {
crate::retry_status::RetryState::Active(banner) => {
let secs = props.retry.seconds_remaining().unwrap_or(0);
// Round to 1s — we redraw each frame anyway so the
// countdown ticks visually without us having to schedule
// anything extra.
format!("⟳ retry {} in {secs}s — {}", banner.attempt, banner.reason)
(
format!("⟳ retry {} in {secs}s — {}", banner.attempt, banner.reason),
crate::palette::STATUS_WARNING,
)
}
crate::retry_status::RetryState::Failed { reason, .. } => {
format!("× failed: {reason}")
(format!("× failed: {reason}"), crate::palette::STATUS_ERROR)
}
crate::retry_status::RetryState::Idle => return None,
};
let color = match &props.retry {
crate::retry_status::RetryState::Failed { .. } => crate::palette::STATUS_ERROR,
_ => crate::palette::STATUS_WARNING,
};
let truncated = truncate_to_width(&label, max_width);
Some(vec![Span::styled(truncated, Style::default().fg(color))])
}
+1
View File
@@ -364,6 +364,7 @@ If you are upgrading from older releases:
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode. Set `never` or run with `--no-alt-screen` when you want real terminal scrollback.
- `tui.mouse_capture` (bool, optional, default `true` when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. TUI-owned drag selection copies only user/assistant transcript text. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
- `features.*` (optional): feature flag overrides (see below).
+259
View File
@@ -0,0 +1,259 @@
# Installing DeepSeek TUI
This page covers every supported install path and the most common
"it didn't install" failures, including **Linux ARM64** and other less
common platforms.
If you just want the short version, see the
[main README](../README.md#quickstart) or
[简体中文 README](../README.zh-CN.md#快速开始).
---
## 1. Supported platforms
`deepseek-tui` ships prebuilt binaries for these
platform/architecture combinations from v0.8.8 onward:
| Platform | Architecture | npm install | `cargo install` | GitHub release asset |
| ------------ | ------------ | :---------: | :-------------: | ----------------------------------------------------- |
| Linux | x64 (x86_64) | ✅ | ✅ | `deepseek-linux-x64`, `deepseek-tui-linux-x64` |
| Linux | arm64 | ✅ | ✅ | `deepseek-linux-arm64`, `deepseek-tui-linux-arm64` |
| macOS | x64 | ✅ | ✅ | `deepseek-macos-x64`, `deepseek-tui-macos-x64` |
| macOS | arm64 (M-series) | ✅ | ✅ | `deepseek-macos-arm64`, `deepseek-tui-macos-arm64` |
| Windows | x64 | ✅ | ✅ | `deepseek-windows-x64.exe`, `deepseek-tui-windows-x64.exe` |
| Other Linux (musl, riscv64, …) | — | ❌¹ | ✅² | build from source |
| FreeBSD / OpenBSD | — | ❌ | ✅² | build from source |
¹ The npm package will exit with a clear error and point you here.
² Provided your toolchain can compile a recent Rust workspace; see
[Build from source](#5-build-from-source) below.
> **Linux ARM64 note (v0.8.7 and earlier).** v0.8.7 and earlier do **not**
> publish a Linux ARM64 prebuilt; users on HarmonyOS thin-and-light, Asahi
> Linux, Raspberry Pi, AWS Graviton, etc. saw `Unsupported architecture: arm64`
> from `npm i -g deepseek-tui`. v0.8.8 publishes both `deepseek-linux-arm64`
> and `deepseek-tui-linux-arm64`, so a plain `npm i -g deepseek-tui` works
> on any glibc-based ARM64 Linux. If you're stuck on v0.8.7, jump to
> [Build from source](#5-build-from-source) — `cargo install` works fine.
---
## 2. Install via npm (recommended)
```bash
npm install -g deepseek-tui
deepseek
```
`postinstall` downloads the right pair of binaries from the matching GitHub
release, verifies a SHA-256 manifest, and exposes both `deepseek` and
`deepseek-tui` on your `PATH`.
Useful environment variables:
| Variable | Purpose |
| ----------------------------------- | -------------------------------------------------------------------------------------- |
| `DEEPSEEK_TUI_VERSION` | Pin which release the wrapper downloads (defaults to `deepseekBinaryVersion`) |
| `DEEPSEEK_TUI_GITHUB_REPO` | Point the downloader at a fork (`owner/repo`) |
| `DEEPSEEK_TUI_RELEASE_BASE_URL` | Override the download root (e.g. an internal mirror or release-asset proxy) |
| `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` | Re-download even if a cached binary marker matches |
| `DEEPSEEK_TUI_DISABLE_INSTALL=1` | Skip the `postinstall` download entirely (CI smoke, vendored binaries) |
| `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` | Don't fail `npm install` on download/extract errors — useful in CI matrices |
---
## 3. Install via Cargo (any Tier-1 Rust target)
If GitHub releases are slow, blocked, or you're on an unsupported architecture,
install from crates.io directly. Both crates are required — the dispatcher
delegates to the TUI runtime at runtime.
```bash
# Requires Rust 1.85+ (https://rustup.rs)
cargo install deepseek-tui-cli --locked # provides `deepseek`
cargo install deepseek-tui --locked # provides `deepseek-tui`
deepseek --version
```
### China / mirror-friendly Cargo registry
```toml
# ~/.cargo/config.toml
[source.crates-io]
replace-with = "tuna"
[source.tuna]
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
```
`rsproxy`, Tencent COS, and Aliyun OSS mirrors work the same way; pick whichever
is fastest from your network.
---
## 4. Manual download from GitHub Releases
Grab the matching pair of binaries for your platform from the
[Releases page](https://github.com/Hmbown/DeepSeek-TUI/releases) and drop them
side by side into a directory on your `PATH` (e.g. `~/.local/bin`):
```bash
# Linux ARM64 example
mkdir -p ~/.local/bin
curl -L -o ~/.local/bin/deepseek \
https://github.com/Hmbown/DeepSeek-TUI/releases/latest/download/deepseek-linux-arm64
curl -L -o ~/.local/bin/deepseek-tui \
https://github.com/Hmbown/DeepSeek-TUI/releases/latest/download/deepseek-tui-linux-arm64
chmod +x ~/.local/bin/deepseek ~/.local/bin/deepseek-tui
deepseek --version
```
Verify integrity against the per-release SHA-256 manifest:
```bash
curl -L -o /tmp/deepseek-artifacts-sha256.txt \
https://github.com/Hmbown/DeepSeek-TUI/releases/latest/download/deepseek-artifacts-sha256.txt
( cd ~/.local/bin && sha256sum -c /tmp/deepseek-artifacts-sha256.txt --ignore-missing )
```
(Use `shasum -a 256 -c` instead of `sha256sum` on macOS.)
---
## 5. Build from source
This is the catch-all for any platform we don't ship — including musl, riscv64,
LoongArch, FreeBSD, and pre-2024 ARM64 distros.
### Prerequisites
- **Rust** 1.85 or later — install with [rustup](https://rustup.rs).
- **Linux build-time deps** (Debian/Ubuntu/openEuler/Kylin):
```bash
sudo apt-get install -y build-essential pkg-config libdbus-1-dev
# openEuler / RHEL family:
# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
```
- A working `cmake` is **not** required.
### Build and install
```bash
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo install --path crates/cli --locked # provides `deepseek`
cargo install --path crates/tui --locked # provides `deepseek-tui`
deepseek --version
```
Both binaries land in `~/.cargo/bin/` by default; make sure that directory is
on your `PATH`.
### Cross-compiling from x64 to ARM64 Linux
If you want to build an ARM64 Linux binary on an x64 Linux host (e.g. for a
HarmonyOS / openEuler ARM64 thin-and-light), use
[`cross`](https://github.com/cross-rs/cross), which wraps the official Rust
cross-targets in a Docker container:
```bash
# Once
rustup target add aarch64-unknown-linux-gnu
cargo install cross --locked
# Per build
cross build --release --target aarch64-unknown-linux-gnu -p deepseek-tui-cli
cross build --release --target aarch64-unknown-linux-gnu -p deepseek-tui
```
The resulting binaries land in
`target/aarch64-unknown-linux-gnu/release/deepseek` and
`target/aarch64-unknown-linux-gnu/release/deepseek-tui`. Copy the matched pair
to the ARM64 host (e.g. via `scp`) and `chmod +x` them.
If you don't have Docker available, install the cross-linker directly and let
Cargo do the work:
```bash
sudo apt-get install -y gcc-aarch64-linux-gnu
rustup target add aarch64-unknown-linux-gnu
cat >> ~/.cargo/config.toml <<'EOF'
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
EOF
cargo build --release --target aarch64-unknown-linux-gnu -p deepseek-tui-cli
cargo build --release --target aarch64-unknown-linux-gnu -p deepseek-tui
```
The same recipe works for `aarch64-unknown-linux-musl` if your distro is
musl-based.
---
## 6. Troubleshooting
### `Unsupported architecture: arm64 on platform linux`
You're on a release earlier than v0.8.8 that doesn't publish Linux ARM64
binaries. Either upgrade (`npm i -g deepseek-tui@latest`) or use
`cargo install` per [Section 3](#3-install-via-cargo-any-tier-1-rust-target).
### `MISSING_COMPANION_BINARY` at runtime
The dispatcher (`deepseek`) requires the TUI runtime (`deepseek-tui`) to be on
the same `PATH`. If you installed only one crate via `cargo install`, install
both:
```bash
cargo install deepseek-tui-cli --locked
cargo install deepseek-tui --locked
```
### `deepseek update` reports `no asset found for platform deepseek-linux-aarch64`
This is [#503](https://github.com/Hmbown/DeepSeek-TUI/issues/503) in v0.8.7 —
the self-updater used Rust's `aarch64`/`x86_64` arch names instead of the
release artifact's `arm64`/`x64`. Workaround until v0.8.8:
```bash
npm i -g deepseek-tui@latest
# or
cargo install deepseek-tui-cli --locked
```
### npm download is slow or times out from mainland China
Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to a mirrored release-asset directory
(rsproxy, TUNA, Tencent COS, Aliyun OSS), or skip npm entirely and use the
Cargo mirror setup in [Section 3](#3-install-via-cargo-any-tier-1-rust-target).
### Debian/Ubuntu: `error: linker 'cc' not found` while building
Install the C toolchain:
```bash
sudo apt-get install -y build-essential pkg-config libdbus-1-dev
```
### Wrapper installs but `deepseek` isn't found
`npm i -g` installs into `$(npm prefix -g)/bin`; make sure that directory is on
your shell's `PATH`. With nvm: `nvm use --lts && hash -r`.
---
## 7. Verifying your install
```bash
deepseek --version
deepseek doctor # checks API key, provider, runtime, and PATH integrity
deepseek doctor --json
```
`doctor` exits non-zero if it finds a problem and prints structured remediation
hints. Paste the JSON output into a GitHub issue if you need help.
+9 -1
View File
@@ -52,11 +52,18 @@ is `https://integrate.api.nvidia.com/v1`. With `--provider nvidia-nim`,
## Supported platforms
Prebuilt binaries for the GitHub release are downloaded automatically:
- Linux x64
- Linux arm64 (v0.8.8+)
- macOS x64 / arm64
- Windows x64
Other platform/architecture combinations are not supported and will fail during install.
Other platform/architecture combinations (musl, riscv64, FreeBSD, …) aren't
shipped as prebuilts. The `postinstall` will exit with a clear error pointing
you at `cargo install deepseek-tui-cli deepseek-tui --locked` and the full
[docs/INSTALL.md](https://github.com/Hmbown/DeepSeek-TUI/blob/main/docs/INSTALL.md)
build-from-source guide.
## Configuration
@@ -65,6 +72,7 @@ Other platform/architecture combinations are not supported and will fail during
- Set `DEEPSEEK_TUI_GITHUB_REPO` or `DEEPSEEK_GITHUB_REPO` to override the source repo (defaults to `Hmbown/DeepSeek-TUI`).
- Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present.
- Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download.
- Set `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` to make the `postinstall` step warn and exit `0` on download/extract errors instead of failing `npm install` (useful in CI matrices).
## Release integrity
+31 -3
View File
@@ -6,7 +6,7 @@ const CHECKSUM_MANIFEST = "deepseek-artifacts-sha256.txt";
const ASSET_MATRIX = {
linux: {
x64: ["deepseek-linux-x64", "deepseek-tui-linux-x64"],
// arm64: ["deepseek-linux-arm64", "deepseek-tui-linux-arm64"], // Uncomment when binaries are available
arm64: ["deepseek-linux-arm64", "deepseek-tui-linux-arm64"],
},
darwin: {
x64: ["deepseek-macos-x64", "deepseek-tui-macos-x64"],
@@ -23,12 +23,19 @@ function detectBinaryNames() {
const defaults = ASSET_MATRIX[platform];
if (!defaults) {
const supported = Object.keys(ASSET_MATRIX).map(p => `'${p}'`).join(', ');
throw new Error(`Unsupported platform: ${platform}. Supported platforms: ${supported}`);
throw new Error(
`Unsupported platform: ${platform}. Supported platforms: ${supported}.\n\n` +
unsupportedBuildHint(),
);
}
const pair = defaults[arch];
if (!pair) {
const supported = Object.keys(defaults).map(a => `'${a}'`).join(', ');
throw new Error(`Unsupported architecture: ${arch} on platform ${platform}. Supported architectures: ${supported}`);
throw new Error(
`Unsupported architecture: ${arch} on platform ${platform}. ` +
`Supported architectures: ${supported}.\n\n` +
unsupportedBuildHint(),
);
}
return {
platform,
@@ -38,6 +45,27 @@ function detectBinaryNames() {
};
}
function unsupportedBuildHint() {
return [
"No prebuilt binary is available for this platform/architecture combo.",
"You can still run DeepSeek TUI by building from source with Cargo:",
"",
" # Requires Rust 1.85+ (https://rustup.rs)",
" cargo install deepseek-tui-cli --locked # provides `deepseek`",
" cargo install deepseek-tui --locked # provides `deepseek-tui`",
"",
"Or build from a checkout:",
"",
" git clone https://github.com/Hmbown/DeepSeek-TUI.git",
" cd DeepSeek-TUI",
" cargo install --path crates/cli --locked",
" cargo install --path crates/tui --locked",
"",
"See https://github.com/Hmbown/DeepSeek-TUI/blob/main/docs/INSTALL.md",
"for cross-compilation, mirror, and Linux ARM64 specifics.",
].join("\n");
}
function executableName(base, platform) {
return platform === "win32" ? `${base}.exe` : base;
}
+6
View File
@@ -210,6 +210,12 @@ module.exports = {
if (require.main === module) {
run().catch((error) => {
console.error("deepseek-tui install failed:", error.message);
if (process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1") {
console.error(
"DEEPSEEK_TUI_OPTIONAL_INSTALL=1 set; continuing without a usable binary.",
);
process.exit(0);
}
process.exit(1);
});
}