From 0e5afe0b01fcd5f10216a93d7c3dc66e9ee14916 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 04:42:53 +0000 Subject: [PATCH 1/5] feat(v0.8.8): linux ARM64 prebuilts + install docs overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggered by a Telegram report from a Chinese user trying to deploy DeepSeek TUI on a HarmonyOS ARM64 thin-and-light: `npm i -g deepseek-tui` exited with `Unsupported architecture: arm64 on platform linux` because v0.8.7 only published x64 Linux artifacts. They worked around it with `cargo install`, but the README never documented that path for ARM users. This PR closes that gap on three layers: - **Release workflow** — add `aarch64-unknown-linux-gnu` to the build matrix using GitHub's `ubuntu-24.04-arm` runner. v0.8.8 will publish `deepseek-linux-arm64` and `deepseek-tui-linux-arm64` alongside the existing x64/macOS/Windows assets, plus add the row to the Release body's manual-download table. - **npm wrapper** — uncomment the linux/arm64 row in `ASSET_MATRIX`, rewrite the `Unsupported architecture/platform` error to print the full `cargo install deepseek-tui-cli deepseek-tui --locked` recipe and link to docs/INSTALL.md, and add `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` so CI matrices that include unsupported platforms can keep running without a binary. - **Docs** — new docs/INSTALL.md covering every supported platform, prebuilt vs. cargo install vs. manual download, cross-compiling x64 -> ARM64 with `cross` or `gcc-aarch64-linux-gnu`, China mirror setup, and a troubleshooting section for the common arm64, MISSING_COMPANION_BINARY, and self-update arch-mapping (#503) errors. README and README.zh-CN now have an explicit Linux ARM64 quickstart pointing at `cargo install` for v0.8.7 today and `npm i -g` for v0.8.8+; the v0.8.7 known-issue block is updated to mention both #503 and the missing arm64 prebuilt. https://claude.ai/code/session_01Fg1FKMtDxVnC4pp6bNBRCS --- .github/workflows/release.yml | 9 + CHANGELOG.md | 27 +++ README.md | 70 +++++-- README.zh-CN.md | 37 +++- docs/INSTALL.md | 259 ++++++++++++++++++++++++++ npm/deepseek-tui/README.md | 10 +- npm/deepseek-tui/scripts/artifacts.js | 34 +++- npm/deepseek-tui/scripts/install.js | 6 + 8 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 docs/INSTALL.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52cd68db..4af9664d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,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 @@ -71,6 +75,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 @@ -154,6 +162,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` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd27a3a..afc797e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **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+. + ## [0.8.7] - 2026-05-03 ### Fixed diff --git a/README.md b/README.md index 5a9c151d..b2d579a8 100644 --- a/README.md +++ b/README.md @@ -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 ``` -
+
Install from source +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). +
--- @@ -153,14 +189,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). diff --git a/README.zh-CN.md b/README.zh-CN.md index 07380d19..289da12e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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)。 + --- ## 其他模型提供方 diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 00000000..5976825d --- /dev/null +++ b/docs/INSTALL.md @@ -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. diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index d6ff56f3..07e2d891 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -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 diff --git a/npm/deepseek-tui/scripts/artifacts.js b/npm/deepseek-tui/scripts/artifacts.js index 8dbc7148..22447bdb 100644 --- a/npm/deepseek-tui/scripts/artifacts.js +++ b/npm/deepseek-tui/scripts/artifacts.js @@ -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; } diff --git a/npm/deepseek-tui/scripts/install.js b/npm/deepseek-tui/scripts/install.js index e1d8ba5a..b1310094 100644 --- a/npm/deepseek-tui/scripts/install.js +++ b/npm/deepseek-tui/scripts/install.js @@ -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); }); } From 2009f037b3ae5ede32f3203473398c16740d0043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 05:21:31 +0000 Subject: [PATCH 2/5] docs(zh-CN): unify cross-reference style in ARM64 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited README.zh-CN.md against sparanoid/chinese-copywriting-guidelines. The whole file already follows the CJK<->Latin spacing rule and uses full-width punctuation correctly — automated scan returns zero violations. The only inconsistency was the new "Linux ARM64" block referencing the source-install section as `[「从源码安装」]`, while every other cross-reference in this README uses the bare-link style. Drop the `「」` brackets so it matches. (Heti / sivan/heti is a runtime CSS library — it can't apply to README rendering on GitHub, but is worth wiring up if we ever publish a docs site.) https://claude.ai/code/session_01Fg1FKMtDxVnC4pp6bNBRCS --- README.zh-CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 289da12e..88b75035 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -48,7 +48,7 @@ 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)。也可以提前配置: From 3013a54c78ae1e04f5ad6fed2f92672be8db90e4 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 02:13:14 -0500 Subject: [PATCH 3/5] feat(tui): emit OSC 8 hyperlinks so URLs are Cmd+click-openable (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modern terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) make a URL clickable when it's wrapped in: \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. ### What's wired - New `crates/tui/src/tui/osc8.rs` module with `wrap_link(target, label)`, `strip_into(s, &mut out)`, and a process-wide `ENABLED` AtomicBool that defaults to `true`. - `markdown_render::render_line_with_links` now wraps recognized URLs (`http(s)://…`) in OSC 8 when the runtime flag is on. Display width is computed from the bare URL — the escapes are zero-width on supporting terminals. - `ui_text::line_to_string` and `line_to_plain` strip OSC 8 wrappers when the span content contains an escape, so selection / clipboard output carries clean URLs and not the raw escape codes. - `[tui] osc8_links: bool` config (default `true`) added to `TuiConfig`, documented in `docs/CONFIGURATION.md`, and surfaced in `config.example.toml`. `run_tui` applies it at startup. ### Tests - 7 unit tests in `osc8::tests` covering wrap, strip-with-ESC-terminator, strip-with-BEL-terminator, plain passthrough, mixed escapes, default state, and round-trip set/unset. - 2 markdown_render tests proving URLs in paragraph blocks emit the OSC 8 wrapper when enabled and emit plain text when disabled. - 2 ui_text tests proving `line_to_plain` strips OSC 8 wrappers from spans and passes plain spans through unchanged. Tests that touch the global ENABLED flag serialize through a static Mutex inside the test module so cargo's parallel runner can't observe a torn read. ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ (1820 + supporting; was 1809) Closes #498 Co-Authored-By: Claude Opus 4.7 (1M context) --- config.example.toml | 1 + crates/tui/src/config.rs | 7 ++ crates/tui/src/main.rs | 2 + crates/tui/src/tui/markdown_render.rs | 56 ++++++++- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/osc8.rs | 165 ++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 9 ++ crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/ui_text.rs | 55 +++++++-- docs/CONFIGURATION.md | 1 + 10 files changed, 284 insertions(+), 14 deletions(-) create mode 100644 crates/tui/src/tui/osc8.rs diff --git a/config.example.toml b/config.example.toml index e850b07d..33c3a76d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -171,6 +171,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 diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 5ea98fdf..6f0e2698 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -336,6 +336,13 @@ pub struct TuiConfig { /// Edited interactively via `/statusline`; persisted to `tui.status_items` /// in `~/.deepseek/config.toml`. pub status_items: Option>, + /// 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, } /// Notification delivery method (mirrors `tui::notifications::Method`). diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 140a9157..a406ff32 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3372,6 +3372,7 @@ mod terminal_mode_tests { mouse_capture: Some(false), terminal_probe_timeout_ms: None, status_items: None, + osc8_links: None, }), ..Config::default() }; @@ -3396,6 +3397,7 @@ mod terminal_mode_tests { mouse_capture: Some(true), terminal_probe_timeout_ms: None, status_items: None, + osc8_links: None, }), ..Config::default() }; diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 4a427df5..0a42d09f 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -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::(); + 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")); + } } diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 150f099d..5b9c689d 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -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; diff --git a/crates/tui/src/tui/osc8.rs b/crates/tui/src/tui/osc8.rs new file mode 100644 index 00000000..57426e52 --- /dev/null +++ b/crates/tui/src/tui/osc8.rs @@ -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); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c3112c75..d59543d4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 || { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0b6687fb..a1cd0dcb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -592,6 +592,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() }; diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index 8be85943..2ea4daf2 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -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::() + 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::() + 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"); + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 48f62f91..24273aa8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -275,6 +275,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). From f2cf3843ec5593f7bea206bc3976ad617f50a070 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 02:30:44 -0500 Subject: [PATCH 4/5] feat(tools): inline unified-diff in edit_file / write_file results (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `edit_file` and `write_file` now capture the file contents before and after the mutation, generate a unified diff with `similar`, and emit it at the head of the `ToolResult` body. The TUI's existing `output_looks_like_diff` detector (history.rs:1335) sees the `@@` header in the first 5 lines and routes the payload through `diff_render::render_diff`, which already renders unified diffs with line numbers and coloured `+`/`-` gutters. The model also benefits — it sees exactly which lines changed instead of just `Replaced N occurrence(s)` or `Wrote N bytes`. Identical content produces an empty diff, in which case the body falls back to `\n(no changes)`. ### What's wired - New `crates/tui/src/tools/diff_format.rs` exposes `make_unified_diff(path, old, new) -> String` using `similar::TextDiff::from_lines(...).unified_diff().context_radius(3)`. - `WriteFileTool::execute` snapshots prior contents (or empty for new files), writes, then emits `\n` where summary is `Wrote N bytes to PATH` for existing files and `Created PATH (N bytes)` for new ones. - `EditFileTool::execute` snapshots, replaces, writes, emits `\nReplaced N occurrence(s) in PATH`. - `similar = "2"` added to `crates/tui/Cargo.toml`. Pure-Rust, no C deps; v2.7.0 in Cargo.lock. ### Tests - 4 unit tests in `diff_format::tests` covering identical inputs, replacement, new-file (against empty), and presence of the `@@` header in the first 5 lines (so the TUI detector trips). - Existing `test_write_file_tool` / `test_edit_file_tool` updated to assert both the summary line and the unified-diff body (`--- a/`, `-old`, `+new`). ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ (1824 + supporting; was 1820) Closes #505 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 7 +++ crates/tui/Cargo.toml | 1 + crates/tui/src/tools/diff_format.rs | 77 +++++++++++++++++++++++++++++ crates/tui/src/tools/file.rs | 67 ++++++++++++++++++++----- crates/tui/src/tools/mod.rs | 1 + 5 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 crates/tui/src/tools/diff_format.rs diff --git a/Cargo.lock b/Cargo.lock index 7c6e8864..bb2d7aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,6 +1269,7 @@ dependencies = [ "sha2 0.10.9", "shellexpand", "shlex", + "similar", "starlark", "tar", "tempfile", @@ -4607,6 +4608,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" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index cd501fe0..0b03fdf8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -39,6 +39,7 @@ indicatif = "0.18.0" 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"] } diff --git a/crates/tui/src/tools/diff_format.rs b/crates/tui/src/tools/diff_format.rs new file mode 100644 index 00000000..bb3549b2 --- /dev/null +++ b/crates/tui/src/tools/diff_format.rs @@ -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:?}" + ); + } +} diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 5a56d59f..1218a26c 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -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"); diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 1394521e..9ea0826a 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -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; From 7547d168a4b2485a5b54a8c79266c818cb9a4fe5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 02:51:17 -0500 Subject: [PATCH 5/5] =?UTF-8?q?feat(memory):=20user-memory=20MVP=20?= =?UTF-8?q?=E2=80=94=20persistent=20notes,=20#=20quick-add,=20/memory,=20r?= =?UTF-8?q?emember=20tool=20(#489=E2=80=93#493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small, opt-in user-memory layer so the model has access to durable preferences and conventions across sessions, and the user can dump quick notes without leaving the TUI. ### What ships - **Hierarchy loader** (#490): on every prompt assembly the engine reads `Config::memory_path()` (defaults to `~/.deepseek/memory.md`, override via `memory_path` in config or `DEEPSEEK_MEMORY_PATH`) and injects the file as a `` block alongside the existing `` block. Goes above the volatile-content boundary so prefix-cache stays warm. Oversize files (>100 KiB) are truncated with a marker. - **`# foo` composer quick-add** (#492): typing a single line that starts with `#` (but not `##` / `#!`) appends a timestamped bullet to the memory file and consumes the input — no turn fires. The composer status line surfaces the path that was written. Multi-`#` prefixes deliberately fall through so users can paste Markdown headings. - **`/memory` slash command** (#491): `/memory` (or `/memory show`) prints the resolved path and contents inline; `/memory path`, `/memory clear`, and `/memory edit` (prints `${VISUAL:-${EDITOR:-vi}} `) cover the rest of the manual-curation surface. - **`remember` tool** (auto-update): model-callable tool that takes a `note` string and appends it as a bullet — the same persistence path as `# foo`. Auto-approved (writes only the user's own memory file). Only registered when memory is enabled, so it doesn't pollute the catalog when the feature is off. - **Opt-in toggle** (#493): default behaviour is off. Enable with `[memory] enabled = true` in `config.toml` or `DEEPSEEK_MEMORY=on` in the environment. ### What's wired - New `crates/tui/src/memory.rs` module (`load`, `as_system_block`, `compose_block`, `append_entry`). - New `crates/tui/src/tools/remember.rs` (`RememberTool` + 3 tests). - New `crates/tui/src/commands/memory.rs` (`memory(app, arg)` handler). - `EngineConfig` gains `memory_enabled: bool` + `memory_path: PathBuf`. - `ToolContext` gains `memory_path: Option`. - `App` exposes `memory_path` + `use_memory` from `AppOptions` (previously destructured-and-dropped); `main.rs` populates `use_memory` from `config.memory_enabled()`. - `system_prompt_for_mode_with_context_and_skills` accepts an optional `user_memory_block` parameter; the engine computes it via `memory::compose_block(...)` and threads it through. - Composer Enter handler intercepts `# foo` only when `config.memory_enabled()` is true; otherwise falls through to existing turn-submission path. - `MemoryConfig` table (`[memory] enabled`) added to `Config`, surfaced in `config.example.toml`, plumbed through `merge_config`. ### Tests - 8 unit tests in `memory::tests` covering `load` (missing / whitespace / real), `as_system_block` (xml shape, empty input, oversize truncation), and `append_entry` (creation, repeated append, empty-after-strip rejection). - 3 unit tests in `tools::remember::tests` covering disabled-state error, successful append, and missing-`note`-field validation. ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ (1821 + supporting; was 1809 on main) Closes #490 #491 #492 #493 Refines #489 (EPIC parent — phase-1 MVP delivered; phase-2 items 494–497 stay on the v0.9.0 board) Co-Authored-By: Claude Opus 4.7 (1M context) --- config.example.toml | 11 ++ crates/tui/src/commands/memory.rs | 62 +++++++ crates/tui/src/commands/mod.rs | 2 + crates/tui/src/config.rs | 43 +++++ crates/tui/src/core/engine.rs | 22 +++ crates/tui/src/core/engine/tool_setup.rs | 7 + crates/tui/src/main.rs | 5 +- crates/tui/src/memory.rs | 197 +++++++++++++++++++++++ crates/tui/src/prompts.rs | 12 +- crates/tui/src/runtime_threads.rs | 2 + crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 10 ++ crates/tui/src/tools/remember.rs | 138 ++++++++++++++++ crates/tui/src/tools/spec.rs | 8 + crates/tui/src/tui/app.rs | 14 +- crates/tui/src/tui/ui.rs | 58 +++++++ 16 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 crates/tui/src/commands/memory.rs create mode 100644 crates/tui/src/memory.rs create mode 100644 crates/tui/src/tools/remember.rs diff --git a/config.example.toml b/config.example.toml index e850b07d..cc4b0ba7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -56,6 +56,17 @@ notes_path = "~/.deepseek/notes.txt" memory_path = "~/.deepseek/memory.md" +# ───────────────────────────────────────────────────────────────────────────────── +# User memory (#489) — opt-in. When enabled, the TUI reads memory_path on +# startup and injects its contents into the system prompt as a +# 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" diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/memory.rs new file mode 100644 index 00000000..def6b19c --- /dev/null +++ b/crates/tui/src/commands/memory.rs @@ -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]" + )), + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index cddfe68f..4dcfe81a 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -12,6 +12,7 @@ mod goal; mod init; mod jobs; mod mcp; +mod memory; mod note; mod provider; mod queue; @@ -465,6 +466,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), diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 5ea98fdf..23de5a51 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -400,6 +400,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 `` block, and intercept + /// `# foo` typed in the composer to append to that file. Default `false`. + #[serde(default)] + pub enabled: Option, +} + impl SnapshotsConfig { #[must_use] pub fn max_age(&self) -> std::time::Duration { @@ -723,6 +737,12 @@ pub struct Config { #[serde(default)] pub snapshots: Option, + /// 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, + /// Post-edit LSP diagnostics injection (#136). When absent, the engine /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] @@ -1263,6 +1283,18 @@ impl Config { .unwrap_or_else(|| PathBuf::from("./memory.md")) } + /// 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 { @@ -1627,6 +1659,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")); } @@ -1904,6 +1946,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), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ba4b3c19..a45f04be 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -127,6 +127,13 @@ pub struct EngineConfig { pub runtime_services: RuntimeToolServices, /// Per-role/type sub-agent model overrides already resolved from config. pub subagent_model_overrides: HashMap, + /// Whether the user-memory feature is enabled (#489). When `true` the + /// engine reads `memory_path` on each prompt assembly and prepends a + /// `` 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 { @@ -153,6 +160,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"), } } } @@ -338,11 +347,14 @@ 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), + user_memory_block.as_deref(), ); session.system_prompt = append_working_set_summary(Some(system_prompt), working_set_summary.as_deref()); @@ -1226,6 +1238,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()); } @@ -1597,11 +1616,14 @@ 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), + user_memory_block.as_deref(), ); let stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 220d4706..d3f44195 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -47,6 +47,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 } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 140a9157..67fc74b0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -35,6 +35,7 @@ mod logging; mod lsp; mod mcp; mod mcp_server; +mod memory; mod models; mod network_policy; mod palette; @@ -3006,7 +3007,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 @@ -3166,6 +3167,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); diff --git a/crates/tui/src/memory.rs b/crates/tui/src/memory.rs new file mode 100644 index 00000000..f2158254 --- /dev/null +++ b/crates/tui/src/memory.rs @@ -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 `` block, and prepend it to the system +//! prompt alongside the existing `` 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 +/// `` 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 { + let content = fs::read_to_string(path).ok()?; + if content.trim().is_empty() { + return None; + } + Some(content) +} + +/// Wrap memory content in a `` 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 { + 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!( + "\n{payload}\n" + )) +} + +/// Compose the `` 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 { + 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("")); + assert!(block.contains("note 1")); + assert!(block.ends_with("")); + } + + #[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); + } +} diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index d091f176..c68e8dc1 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -173,7 +173,7 @@ 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) + system_prompt_for_mode_with_context_and_skills(mode, workspace, working_set_summary, None, None) } /// Get the system prompt for a specific mode with project and skills context. @@ -198,6 +198,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( workspace: &Path, working_set_summary: Option<&str>, skills_dir: Option<&Path>, + user_memory_block: Option<&str>, ) -> SystemPrompt { let mode_prompt = compose_mode_prompt(mode); @@ -217,6 +218,15 @@ pub fn system_prompt_for_mode_with_context_and_skills( ) }; + // 2.5. 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. if let Some(skills_block) = skills_dir.and_then(crate::skills::render_available_skills_context) { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 6bc2f258..c4e7800c 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1563,6 +1563,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); diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 1394521e..4882a22d 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -17,6 +17,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; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index e1812567..f2bdb7f1 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -504,6 +504,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 diff --git a/crates/tui/src/tools/remember.rs b/crates/tui/src/tools/remember.rs new file mode 100644 index 00000000..05b6ff5d --- /dev/null +++ b/crates/tui/src/tools/remember.rs @@ -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 { + 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 { + 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}"); + } +} diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 2209d277..87bb59ed 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -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, + /// 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, } 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, } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 084480c2..4ab4219f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -563,6 +563,14 @@ pub struct App { pub config_profile: Option, 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, @@ -931,10 +939,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, @@ -1064,6 +1072,8 @@ impl App { config_profile, mcp_config_path, skills_dir, + memory_path, + use_memory, use_alt_screen, use_mouse_capture, use_bracketed_paste, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c3112c75..9b29f8a7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -403,6 +403,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(), @@ -439,6 +484,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(), } } @@ -2194,6 +2241,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,