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:
@@ -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` |
|
||||
|
||||
@@ -74,3 +74,7 @@ apps/
|
||||
.claude/worktrees/
|
||||
.worktrees/
|
||||
.ace-tool/
|
||||
|
||||
# Local-only Claude / ralph notes
|
||||
.claude/*.local.md
|
||||
.claude/*.local.json
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 ARM64(HarmonyOS 轻薄本、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)。
|
||||
|
||||
---
|
||||
|
||||
## 其他模型提供方
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user