Compare commits

...
342 Commits
Author SHA1 Message Date
Hunter Bown c2b2c284f6 release: v0.7.5 — token-basis fixes, shell timeout recovery, context/cache policy
Issues #202, #203, #204, #205:

- Cycle/seam triggers use active request input size + response
  headroom reserve, not lifetime cumulative API usage.
- V4 hard-cycle headroom calibrated around fixed TURN_MAX_OUTPUT_TOKENS
  plus CONTEXT_HEADROOM_TOKENS safety buffer.
- /tokens, /cost, footer/header labels, and docs now separate
  active context, turn telemetry, cumulative usage, cache hit/miss,
  context percent, and cost.
- Foreground exec_shell timeout output tells the model the process
  was killed and suggests task_shell_start or background exec_shell
  plus poll/wait.
- Added regression tests for active-token basis, V4 headroom,
  seam trigger basis, footer label behavior, and shell timeout
  recovery metadata.
- Preserved #200/#201 policy: V4 default is append-only,
  prefix-cache preserving; replacement compaction, Flash seams,
  and capacity intervention remain opt-in.
2026-04-29 10:13:27 -05:00
Hunter Bown 0578eb701e Add shell jobs and MCP manager to the TUI 2026-04-29 09:38:04 -05:00
Hunter Bown 41e8f2b5b2 Disable default compaction and opt in context seams 2026-04-29 09:12:20 -05:00
Hunter Bown 00c92e1c2a Implement v0.7.4 long-running agent tools 2026-04-29 00:50:43 -05:00
Hunter Bown 6d8ab4c2b8 fix: close v0.7.2 issue cleanup 2026-04-28 23:09:19 -05:00
Hunter Bown 0f8c363012 fix: clean v0.7.2 release prep 2026-04-28 21:54:43 -05:00
Hunter Bown 35db361a87 v0.7.2: clippy clean, cost counter wiring, layered context fixup
#167: Fix all 7 clippy warnings — annotated SeamMetadata dead fields,
removed unused should_cycle calls, collapsed nested ifs, fixed
useless_format and nonminimal_bool.

#168: Wire TokenUsage mailbox drain to subagent_cost accumulator.
handle_subagent_mailbox now intercepts TokenUsage before routing to
cards, computes cost via calculate_turn_cost, and increments
app.subagent_cost in real time. Footer reflects live sub-agent spend.

Restored ArchivedContext variant to HistoryCell (corrupted by prior
apply_patch). Version bump to 0.7.2.

Refs: #166, #167, #168
2026-04-28 21:46:25 -05:00
Hunter Bown 12b1ae42c4 feat: SeamManager module + context config for v0.7.2 layered context management
Adds the core SeamManager struct (#159) that uses V4 Flash to produce
append-only <archived_context> XML blocks at 192K/384K/576K thresholds.
No messages are deleted — soft seams are navigational summaries that
preserve the V4 prefix cache.

- seam_manager.rs: Flash-driven soft seam production, recompaction,
  and cycle briefing replacement
- config.rs: [context] table with L1/L2/L3/cycle thresholds,
  verbatim window, seam model, and per-model overrides
- compaction.rs: pub exports for plan_compaction, KEEP_RECENT_MESSAGES,
  and CompactionPlan fields so SeamManager can reuse pinning heuristics
- cycle_manager.rs: pub CYCLE_HANDOFF_TEMPLATE for Flash briefing use
- main.rs: mod seam_manager registration

All 1,570 tests pass. Engine wiring follows in a subsequent commit.
2026-04-28 20:15:22 -05:00
Hunter Bown 64d1698bde Release 0.7.1 (#156) 2026-04-28 18:38:44 -05:00
Hunter Bown 096aa91823 Rank file picker by working set relevance (#155) 2026-04-28 18:26:07 -05:00
Hunter Bown 0e96928f35 Add compact context inspector metadata (#154) 2026-04-28 18:14:29 -05:00
Hunter Bown 6396bffcd4 Make tool details selected-card aware (#153) 2026-04-28 17:58:45 -05:00
Hunter Bown 78b272e56b Group active tool cards and live status (#152) 2026-04-28 17:46:55 -05:00
Hunter Bown d7b033d59e Polish tool cards and context previews (#151) 2026-04-28 17:36:00 -05:00
Hunter Bown 97846cd63a release: include secrets crate in publish order 2026-04-28 16:39:22 -05:00
Hunter Bown 2c8a480fc0 ci: install dbus dev package for parity 2026-04-28 16:30:55 -05:00
Hunter Bown fb4c8150f3 ci: install dbus dev package for keyring builds 2026-04-28 16:11:52 -05:00
Hunter Bown a02907b89d chore(release): v0.7.0 2026-04-28 16:08:33 -05:00
Hunter Bown 30ec07481f Merge branch 'feat/v070-crate-split' (#67 shared tool primitives) 2026-04-28 01:23:50 -05:00
Hunter Bown 49d2be9e5c refactor(tools): share tool result primitives from crate 2026-04-28 01:23:21 -05:00
Hunter Bown 281b712acc Merge branch 'feat/v070-engine-modular' (#74 engine modularization) 2026-04-28 01:12:35 -05:00
Hunter Bown 4fb8372c1c refactor(engine): split turn loop and capacity flow 2026-04-28 01:12:25 -05:00
Hunter Bown 27527699db Merge branch 'feat/v070-lsp' (#136 LSP diagnostics)
# Conflicts:
#	config.example.toml
#	crates/config/src/lib.rs
#	crates/tui/src/config.rs
#	crates/tui/src/core/engine.rs
#	crates/tui/src/main.rs
#	crates/tui/src/runtime_threads.rs
#	crates/tui/src/tui/ui.rs
2026-04-28 01:03:36 -05:00
Hunter Bown 41daab3ca0 Merge branch 'feat/v070-snapshots' (#137 side-git snapshots)
# Conflicts:
#	crates/config/src/lib.rs
#	crates/tui/src/config.rs
2026-04-28 00:58:16 -05:00
Hunter Bown 626bfbc65e Merge branch 'feat/v070-backtrack' (#133 Esc-Esc backtrack) 2026-04-28 00:50:21 -05:00
Hunter Bown cfbf081eb8 Merge branch 'feat/v070-skill-install' (#140 skill install command) 2026-04-28 00:46:46 -05:00
Hunter Bown 3bc54b0bc0 fix(snapshot): harden side-git restore wiring 2026-04-28 00:46:24 -05:00
Hunter BownandClaude Opus 4.7 0781b7c203 feat(session): #137 prune stale workspace snapshots at session boot
`run_interactive` now calls `session_manager::prune_workspace_snapshots_at_boot`
right after the system-skills installer, dropping any snapshot in the
side-git repo older than 7 days (default; configurable via the new
`[snapshots]` section in `config.example.toml`). The helper is
non-fatal: a missing `git` binary, read-only home, or absent snapshot
dir all log a single WARN (or DEBUG for the count of pruned commits)
and return, so the TUI keeps starting even when retention can't run.

Also document the snapshot subsystem in `config.example.toml` —
disk-footprint expectations, where the side repo lives, and how
`/restore` / `revert_turn` consume it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:31:57 -05:00
Hunter BownandClaude Opus 4.7 fb12e331ab feat(snapshot): #137 add /restore command and revert_turn tool
Two user-facing entry points to the snapshot side-repo:

- `/restore [N]` (slash command) — `/restore` with no arg lists the
  10 most recent snapshots so the user can see what's available.
  `/restore N` restores the N-th most recent snapshot. Outside YOLO
  or `/trust on`, the command refuses to mutate files and tells the
  user how to opt in (no in-flow modal-confirm path inside slash
  commands today; trust mode is the explicit gate).

- `revert_turn` (agent-callable tool) — `turn_offset` (default 1)
  counts in `pre-turn:*` snapshots, so the model can say "undo my
  last edit" without having to enumerate the history. Approval-gated
  (`ApprovalRequirement::Required`) since it mutates the workspace,
  and registered through `with_full_agent_surface` so children
  inherit it just like every other agent-mode tool.

Tests for both surfaces use the process-wide env mutex
(`crate::test_support::lock_test_env`) plus an RAII `HOME` guard so
tempdir-based snapshot resolution stays inside the per-test sandbox
even when the runner threads multiple tests in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:31:47 -05:00
Hunter BownandClaude Opus 4.7 8ff4f66b95 feat(core): #137 add pre/post-turn snapshot hooks
Wire `pre_turn_snapshot` and `post_turn_snapshot` helpers into
`core::turn`, then call them from `Engine::handle_send_message` —
pre-turn fires right after `turn_counter` is incremented, post-turn
fires right after `Event::TurnComplete` is emitted.

Both hooks are dispatched via `tokio::task::spawn_blocking` so the
agent loop never waits on the side-git commit, and helper failures are
swallowed at WARN log level so a busted disk or missing `git` binary
can never derail a turn (per the snapshot module's documented
non-fatal contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:31:31 -05:00
Hunter BownandClaude Opus 4.7 3dc116b9fc feat(snapshot): #137 add workspace snapshot side-repo module
Introduce `crate::snapshot` — a per-workspace side-git repo that lives
under `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git` and
captures the workspace into commits via `git add -A` + `git commit
--allow-empty`. The user's own `.git` is never touched: every git
invocation passes both `--git-dir` (side repo) and `--work-tree`
(workspace) together, which is the load-bearing safety invariant.

Module layout:
- `paths.rs` — resolves the side-repo dir; strips `.worktrees/<name>`
  so worktrees of the same checkout share a project_hash but get
  distinct worktree_hashes.
- `repo.rs` — `SnapshotRepo::open_or_init / snapshot / restore / list /
  prune_older_than`. Shells out to system `git` (avoids `git2` LGPL
  surface). Honors workspace `.gitignore` automatically.
- `prune.rs` — boot-time helper used by session_manager (next commit).
  Default retention is 7 days.

Tests (real `git` invocations on tempdirs, env-mutating tests serialised
through the existing `crate::test_support::lock_test_env` mutex) cover:
snapshot creates a commit in the side repo only, restore reverts files,
list respects limit, prune drops aged commits, gitignore is honored,
and re-init is idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:31:21 -05:00
Hunter BownandClaude Opus 4.7 03dcc74833 docs(skills): #140 document /skill install + [skills] config section
- README: add a "Publishing your own skill" section explaining the
  `github:owner/repo` install path, the multi-skill `skills/<name>/`
  layout, and how to submit to the curated registry.
- config.example.toml: document `[skills] registry_url` /
  `max_install_size_bytes` next to the existing `[network]` section so
  users see the network-gate dependency in context.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:30:10 -05:00
Hunter BownandClaude Opus 4.7 de9a3b8b74 feat(skills): #140 wire /skill install/update/uninstall/trust + [skills] config
Slash-command surface for the community-skill installer:

- `/skill install <github:owner/repo|https://...|<registry-name>>` parses
  the spec via `InstallSource::parse`, calls `install_with_registry`, and
  surfaces `NeedsApproval`/`NetworkDenied` with actionable messages
  pointing at `[network]` config (we deliberately don't dispatch a modal
  from the sync slash-command path; the underlying installer returns the
  outcome so a future approval wiring can reuse it).
- `/skill update <name>` re-fetches and prints "no upstream change" when
  the checksum matches.
- `/skill uninstall <name>` and `/skill trust <name>` both refuse to
  touch system skills (no `.installed-from` marker).
- `/skills --remote` (or `/skills remote`) fetches the curated registry
  through the same network gate and prints `name — description (source)`.

Internals:
- Sub-command dispatch happens in `run_skill` before activation lookup,
  so a user can't accidentally activate a skill literally named
  `install`. Async install/update/uninstall plumbed through
  `tokio::task::block_in_place` + `Handle::current().block_on`, matching
  the existing pattern in `commands/cycle.rs`.
- `installer_settings` loads `Config` on demand — `App` doesn't carry a
  `Config` reference, and the cost of a single TOML parse is negligible
  next to the network round-trip the install will make.

Config:
- New `[skills]` section in both `crates/tui/src/config.rs::Config` and
  the workspace `crates/config/src/lib.rs::ConfigToml` with
  `registry_url` (default: bundled raw GitHub index) and
  `max_install_size_bytes` (default: 5 MiB).
- `merge_config` propagates the new field, default impls cover the
  unset case.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:30:05 -05:00
Hunter BownandClaude Opus 4.7 ad78466ba0 feat(skills): #140 community-skill installer module
Add `crates/tui/src/skills/install.rs` — async installer that pulls
user-authored skills from GitHub repos, raw tarball URLs, or a curated
`index.json` registry. The whole pipeline is gated by the per-domain
`NetworkPolicy` (#135), validated against path-traversal / size / symlink
attacks before any bytes hit the destination, and atomic-renamed into place
so a half-installed skill cannot survive a failure mid-extract.

Public surface:
- `InstallSource::{GitHubRepo,DirectUrl,Registry}` with `parse(spec)`.
- `install` / `install_with_registry` returning
  `InstallOutcome::{Installed,NeedsApproval,NetworkDenied}`.
- `update` / `update_with_registry` returning
  `UpdateResult::{NoChange,Updated,NeedsApproval,NetworkDenied}` — uses a
  SHA-256 over the downloaded tarball to short-circuit no-op fetches.
- `uninstall` / `trust` — both refuse to touch directories without an
  `.installed-from` marker, so the bundled `skill-creator` system skill is
  protected.
- `fetch_registry` — typed loader for the curated `index.json`.

Validation hard rules (each covered by an integration test):
- `..` segments and absolute paths in tar entries are rejected.
- Symlinks / hardlinks in tar entries are rejected outright.
- Uncompressed total size is bounded by `max_size` (default 5 MiB).
- SKILL.md must exist at the archive root or under `skills/<name>/`.
- Frontmatter must carry both `name` and `description`.
- `install` with an existing destination requires `update = true`.
- `update` re-fetches and only replaces the on-disk install when the
  checksum changes; no-change paths skip the rename entirely.

Adds `tar`, `flate2`, and `sha2` to `crates/tui/Cargo.toml` and propagates
the resulting lockfile drift to `Cargo.lock`.

Tests: 11 colocated unit tests in `install.rs` + 11 integration tests in
`crates/tui/tests/skill_install.rs` driving a `tiny_http`-based server so
the network gate, download cap, validation pipeline, and atomic rename
all run end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:29:48 -05:00
Hunter BownandClaude Opus 4.7 05a1032e00 feat(lsp): #136 post-edit diagnostics injection
Inject LSP diagnostics as a synthetic user message after every successful
file edit (`edit_file`, `apply_patch`, `write_file`) so the agent sees
compile breaks before its next reasoning step. Largest agent-quality
lever in v0.7.0.

Pieces:
- `crates/tui/src/lsp/`: thin JSON-RPC stdio client (no `tower-lsp`),
  per-language registry, diagnostics renderer producing the
  `<diagnostics file="…">` block format. `LspManager` owns lazily
  spawned per-language transports keyed by `Language`.
- `core/engine.rs`: hook on the success branch of the tool-result loop
  derives the edited file path(s) per tool, queries the LspManager
  with a 5 s timeout, and collects rendered blocks into
  `pending_lsp_blocks`. The queue is flushed as a `text` content
  block on the next request iteration so the model sees the
  diagnostics before it streams its next turn.
- `[lsp]` config schema (`enabled`, `poll_after_edit_ms`,
  `max_diagnostics_per_file`, `include_warnings`, optional
  `servers` override) with built-in defaults for rust-analyzer,
  gopls, pyright, typescript-language-server, and clangd.
- Failure modes are non-blocking by design: a missing LSP binary
  logs a one-time warning and skips the hook; a crashed server or
  poll timeout simply drops that turn's diagnostics. The agent's
  work is never blocked.

Tests: 24 unit tests cover language detection, registry overrides,
filter/sort/truncate behavior, and the rendered block format. Three
engine-level tokio tests exercise the full path through a fake
transport (no real LSP server is ever spawned in CI).

Acceptance criteria (per #136):
- Edit introducing a type error -> next request body contains
  `<diagnostics file="…">` block at the right line/col.
- `[lsp] enabled = false` -> no diagnostics injected.
- Snapshot test exercises full path with mock transport.
- LSP binary not on PATH -> one-time warning, agent proceeds.
- 5 s timeout, errors-only by default.
- Transports spawn lazily on first edit per language.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:29:28 -05:00
Hunter BownandClaude Opus 4.7 18b797b593 feat(tui): #133 wire Esc-Esc backtrack into UI and live transcript
Connects the new BacktrackState to the live UI:

- App: holds a `backtrack: BacktrackState` and a new
  `truncate_history_to(new_len)` helper that keeps `tool_cells`,
  `tool_details_by_cell`, and the sub-agent card index consistent.
- live_transcript: gains a `Mode::BacktrackPreview { selected_idx }`
  that highlights the Nth-from-tail HistoryCell::User with a `▶` marker
  and reverse-video styling. Cache stays valid across mode flips —
  decoration is applied post-wrap. Left/Right/Enter/Esc emit new
  `ViewEvent::Backtrack{Step,Confirm,Cancel}` events.
- ui.rs: routes Esc through `BacktrackState::handle_esc` only when
  no popup is open and not streaming, opens the preview overlay on
  the second Esc, and on confirm trims `app.history` /
  `app.api_messages` and refills the composer with the dropped user
  input. Streaming and existing popup paths preserve their original
  Esc behaviour.
- keybindings: documents the `Esc Esc` chord in the help catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:26:21 -05:00
Hunter BownandClaude Opus 4.7 e3dc49c526 feat(tui): #133 add Esc-Esc backtrack state machine module
Introduces `tui::backtrack::BacktrackState` — a small Inactive/Primed/
Selecting state machine for the two-step Esc chord. The module owns
nothing beyond its phase enum; transcript snapshots, popup detection,
and fork side-effects all stay in the UI layer so the state machine is
trivially unit-testable.

`handle_esc(total_user_messages)` returns one of `None | Prime |
Cancel | OpenOverlay`, `step(Direction)` walks the selection in
`Selecting`, and `confirm()` yields the depth-from-tail and resets to
`Inactive`. 15 unit tests cover every transition including bounds
clamping, empty-transcript short-circuit, and defensive Esc routing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:26:09 -05:00
Hunter BownandClaude Opus 4.7 26b79312f9 feat(runtime): #133 add fork_at_user_message for backtrack rewind
Adds `RuntimeThreadManager::fork_at_user_message(id, depth_from_tail)` —
a sibling of the existing `fork_thread` that drops every turn from the
Nth-from-tail user message onward and returns the dropped user input so
the caller can pre-populate the composer.

The existing `fork_thread` is left untouched. The new helper mirrors its
copy loop but stops short of the cutoff turn, emitting a
`thread.forked` event with backtrack provenance fields. Includes unit
tests covering depth=0, depth=1, out-of-range error, and source-thread
non-mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:26:00 -05:00
Hunter Bown 87ac2e9bdc Merge branch 'feat/v070-network' (#135 egress network policy + audit)
- crates/tui/src/network_policy.rs (NEW, ~300 LOC) — NetworkPolicy/NetworkPolicyDecider/NetworkAuditor
- crates/tui/src/tools/{fetch_url,web_search,spec}.rs + mcp.rs — gate before egress
- crates/tui/src/core/engine.rs + runtime_threads.rs + tui/ui.rs — surface NetworkDenied
- crates/config/src/lib.rs + tui/src/config.rs + config.example.toml — [network] schema
- Subdomain-prefix matching with deny-wins precedence
- Audit format: <RFC3339> network <host> <tool> <decision>

# Conflicts:
#	crates/tui/src/config.rs
2026-04-28 00:07:42 -05:00
Hunter Bown a054789f79 Merge branch 'feat/v070-osc9' (#132 OSC 9 desktop notification on long-turn completion)
- crates/tui/src/tui/notifications.rs (NEW) — Method enum {Auto/Osc9/Bel/Off}, notify_done()
- crates/tui/src/tui/mod.rs + ui.rs — register module + hook EngineEvent::TurnComplete
- crates/tui/src/config.rs — NotificationsConfig (method/threshold_secs/include_summary)
- config.example.toml — [notifications] section
- 9 unit tests including tmux DCS passthrough wrapping
2026-04-28 00:06:26 -05:00
Hunter Bown ff21d2268d Merge branch 'feat/v070-arity' (#131 bash-command arity for safer auto_allow)
- crates/tui/src/command_safety.rs — port opencode arity dictionary (160+ entries) + classify_command()
- crates/tui/src/tools/approval_cache.rs — cache key uses canonical prefix
- config.example.toml — auto_allow prefix-semantics docblock
- 41 colocated unit tests
2026-04-28 00:06:21 -05:00
Hunter Bown 7b6993fecb Merge branch 'feat/v070-altv' (#138 Alt+V chord for tool-details pager)
- crates/tui/src/tui/ui.rs — new Alt+V/Alt+Shift+V arm next to Alt+A/Y/P family, no empty-input gate
- crates/tui/src/tui/history.rs — 5 hint strings + 4 test assertions updated to "Alt+V for details"
- crates/tui/src/tui/keybindings.rs — entry under Submission so ? overlay lists it
- Bare 'v' handler unchanged (legacy muscle memory)
2026-04-28 00:06:16 -05:00
Hunter Bown d3dd8a590f Merge branch 'feat/v070-keyring' (#134 OS keyring credential store)
- crates/secrets/ (NEW crate) — KeyringStore trait + Default/InMemory/File backends
- crates/config/src/lib.rs — api_key resolution via Secrets::auto_detect (CLI → keyring → env → config-file)
- crates/cli/src/main.rs — auth set/get/clear/migrate/list subcommands
- crates/tui/src/config.rs + main.rs — wire keyring resolver, doctor reports backend
- Lockfile updated for keyring 3.6 (apple/windows/linux native features)
- One-time deprecation warning when api_key is read from config.toml
2026-04-28 00:06:09 -05:00
Hunter Bown f36e891117 Merge branch 'feat/v070-skill-creator' (#139 bundled skill-creator system skill)
- crates/tui/assets/skills/skill-creator/SKILL.md (NEW) — MIT-attributed port from codex
- crates/tui/src/skills.rs → skills/mod.rs + skills/system.rs (NEW) — install_system_skills() with version-marker idempotence
- crates/tui/src/main.rs — startup auto-install hook (non-fatal on error)
- crates/tui/src/commands/skills.rs — /skill new alias for /skill skill-creator
- 7 unit tests covering fresh install, idempotence, user-deleted-dir respect, version-bump reinstall
2026-04-28 00:06:03 -05:00
Hunter Bown 3522040855 Merge branch 'feat/v070-mock-llm' (#69 mock LLM client + integration tests)
- crates/tui/src/llm_client.rs → llm_client/mod.rs (directory module)
- crates/tui/src/llm_client/mock.rs (NEW) — MockLlmClient at trait boundary
- crates/tui/tests/integration_mock_llm.rs (NEW) — 7 trait-level scenarios
- crates/tui/src/eval.rs + main.rs — `--record` fixture flag
- 4 engine-level tests #[ignore]'d pending engine LlmClient trait-object refactor
2026-04-28 00:05:55 -05:00
Hunter BownandClaude Opus 4.7 9db841fc62 test(tui): #69 integration tests for mock LLM client + record fixtures
Adds `integration_mock_llm.rs` covering the LlmClient trait surface:

- streaming turn loop (text deltas + finish reason)
- reasoning-content replay across tool-call rounds (V4 §5.1.1, the
  HTTP 400 path that broke v0.4.9-v0.5.1)
- tool-call round-trip with chunked input JSON
- multiple tool calls in one turn preserve event ordering
- compaction-style non-streaming `create_message`
- sub-agent style independent parent/child mocks
- capacity-gate observation of a captured request

Four full-engine tests are `#[ignore]`-marked as BLOCKED on the engine
refactor from concrete `Option<DeepSeekClient>` to `Arc<dyn LlmClient>`.
Once that wiring lands the ignored tests light up with no mock changes.

Adds:
- `tests/support/llm_client.rs` mirrors the trait so the mock can be
  brought into the integration test via `#[path]` without dragging in
  the rest of the binary's module tree
- `tests/fixtures/.gitkeep` so the `eval --record` output directory
  rides the repo
- `tests/README.md` documents both the trait-level mocking strategy
  and the `--record` fixture flow
- `record_flag_writes_one_jsonl_line_per_step` in `eval_harness.rs`
  exercises the new `--record` flag end-to-end

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:03:18 -05:00
Hunter BownandClaude Opus 4.7 0626bc80c0 feat(test): #69 mock LLM client + eval --record fixture flag
Adds a queue-driven `MockLlmClient` that implements the `LlmClient` trait
by replaying canned per-turn `StreamEvent` vectors and capturing every
outgoing `MessageRequest`. The mock lives at the trait boundary so it
stays decoupled from the concrete reqwest plumbing inside `DeepSeekClient`,
and surfaces builders (`canned::*`) for the common event shapes (text
delta, thinking delta, tool_use start, input JSON delta, message delta).

Wires a new `--record <DIR>` flag into `deepseek eval` that appends one
JSON Lines fixture line per step to `<DIR>/<scenario>.jsonl`. The format
is documented at the top of `eval.rs` and is the storage shape the mock
will replay from.

`crates/tui/src/llm_client.rs` becomes `crates/tui/src/llm_client/mod.rs`
to host the new submodule cleanly. The trait shape is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:03:00 -05:00
Hunter Bown f82f162e7f feat(network): #135 gate fetch_url, web_search, MCP HTTP via policy
Threads the optional `NetworkPolicyDecider` from `EngineConfig` through to
`ToolContext.network_policy` and `McpPool::with_network_policy`. Each gate
point follows the same pattern: extract the host, call `decider.evaluate`,
then `Allow` proceeds, `Deny` returns a structured permission-denied error,
and `Prompt` falls through to the same denial with a hint pointing to
`/network allow <host>` (full modal flow lands in a follow-up).

* `fetch_url` — gates on the parsed URL host.
* `web_search` — gates DuckDuckGo (`html.duckduckgo.com`) and the Bing
  fallback (`www.bing.com`) independently so a deny on one engine doesn't
  silently let the other through.
* MCP — only the HTTP/SSE transport is gated; STDIO MCP servers are
  unaffected. `McpConnection::connect_with_policy` replaces the old
  `connect` (no external callers existed).

The session cache short-circuits `evaluate` once a host is approved, so
the existing `approve_session` hook is enough to wire the prompt-once
flow when the approval modal lands.

`NetworkPolicyDecider::with_default_audit` materializes the auditor at
`~/.deepseek/audit.log` when the config has `audit = true`.

Includes one tool-level test asserting `fetch_url` denies a blocked host
through the policy gate.
2026-04-28 00:02:56 -05:00
Hunter Bown abbb86cdd2 feat(network): #135 add [network] config schema for policy
Adds the `[network]` table to both the workspace config crate (`ConfigToml`)
and the live tui config (`Config`), plus a documented example block in
`config.example.toml`. Schema:

```toml
[network]
default = "prompt"      # allow | deny | prompt
allow = ["api.deepseek.com", "github.com"]
deny = []
audit = true
```

`NetworkPolicyToml::into_runtime()` builds a runtime `NetworkPolicy` so the
engine can construct a `NetworkPolicyDecider` without reaching across crate
boundaries. Defaults preserve pre-v0.7.0 behavior: when the section is
absent, no policy is enforced.
2026-04-28 00:02:34 -05:00
Hunter Bown 45727a09f7 feat(network): #135 add per-domain network policy module
Introduces `network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider,
NetworkAuditor, NetworkSessionCache, NetworkDenied}` for gating outbound
network calls.

Deny-wins precedence: a host listed in both `allow` and `deny` is denied.
Subdomain wildcard via leading-dot entries (`.example.com` matches
`api.example.com` but not the apex). Audit log writes one plaintext line
per terminal decision to `~/.deepseek/audit.log` in the format
`<RFC3339> network <host> <tool> <Allow|Deny|Prompt-Approved|Prompt-Denied>`.

Approve-once-for-session caching is implemented in `NetworkSessionCache`;
`approve_persistent` mutates the policy's allow list so callers can write
back to config later.

19 unit tests cover deny-wins precedence, subdomain matching, audit
logging, session-cache short-circuit, and `NetworkDenied` shape.
2026-04-28 00:02:27 -05:00
Hunter BownandClaude Opus 4.7 30d7650bae feat(cli): #134 add deepseek auth keyring subcommands and surface backend in doctor
Adds first-class keyring management on the dispatcher CLI and wires
the TUI to read its DeepSeek key through the same Secrets façade.

Subcommands:
* `auth set --provider <name>`   writes to the OS keyring; prompts
  on stdin without echo, never prints the key, never touches
  `config.toml`. Supports `--api-key` and `--api-key-stdin`.
* `auth get --provider <name>`   reports `set` / `not set` plus the
  resolving layer (keyring / env / config-file). Never prints the
  value.
* `auth clear --provider <name>` deletes from keyring and from any
  legacy plaintext slot in `config.toml` for parity.
* `auth list`                    table of all known providers and
  whether each layer holds a key. Non-revealing.
* `auth migrate [--dry-run]`     reads `api_key` (root + per-provider
  blocks) from `config.toml`, writes them to the keyring, then
  strips the entries from disk. Idempotent.
* `auth status`                  expanded to also report the active
  keyring backend and per-provider keyring state.

`doctor` now prints `keyring backend: ...` plus per-provider
`keyring=yes/no, env=yes/no` lines and points users at
`deepseek auth set` when no key resolves.

`Config::deepseek_api_key()` in the TUI is rewritten to consult
`Secrets::auto_detect()` first (keyring -> env), then fall back to
the existing TOML slots with a deprecation warning. Error messages
now lead with `deepseek auth set --provider <name>`.

5 new unit tests cover argument parsing for the new subcommands and
end-to-end auth set/clear/migrate behaviour against an
`InMemoryKeyringStore`, verifying that no plaintext key ever lands
in `config.toml`.

Verified manually on macOS:
  $ deepseek auth set --provider deepseek --api-key-stdin
  $ security find-generic-password -s deepseek -a deepseek
  # entry present
  $ deepseek auth migrate
  # api_key lines stripped from ~/.deepseek/config.toml

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:01:43 -05:00
Hunter BownandClaude Opus 4.7 a5cc9d5852 feat(config): #134 resolve api_key through OS keyring with env fallback
Routes `ConfigToml::resolve_runtime_options` through the new
`deepseek_secrets::Secrets` façade so API keys are read from the OS
keyring before any environment variable, with the existing
plaintext-config layer kept as a deprecated last resort. The
precedence is now:

  CLI flag -> keyring -> env -> config-file

Reads of an `api_key` value from `~/.deepseek/config.toml` now emit
a one-time `tracing::warn!` directing users to
`deepseek auth set` / `deepseek auth migrate`.

`resolve_runtime_options_with_secrets` is exposed for tests and
process-level injection (the `cfg(test)` default uses an in-memory
store so unit tests never touch the real OS keychain). The
nvidia-nim provider keeps its `DEEPSEEK_API_KEY` env fallback for
back-compat. New tests cover keyring > env > config-file precedence
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:01:23 -05:00
Hunter BownandClaude Opus 4.7 f3ada0be88 chore(secrets): #134 scaffold deepseek-secrets crate
Adds the `deepseek-secrets` crate with the OS keyring backend,
in-memory store for tests, and a JSON-on-disk fallback for
headless environments. The Secrets façade collapses keyring -> env
into a single resolver; callers layer on CLI flags above and TOML
config below to preserve the keyring -> env -> config-file precedence.

* `KeyringStore` trait + `DefaultKeyringStore` (keyring 3.6 with
  per-platform native features).
* `InMemoryKeyringStore` for unit tests.
* `FileKeyringStore` writes ~/.deepseek/secrets/secrets.json with
  mode 0600 on unix; rejects world-readable files at read time.
* `Secrets::auto_detect` probes the OS keyring and falls back to
  the file store on headless Linux.
* 9 unit tests covering round-trips, precedence, and 0600 perms.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 00:01:10 -05:00
Hunter BownandClaude Sonnet 4.6 6432d47c53 feat: #132 emit OSC 9 / BEL desktop notification on long turn completion
Adds crates/tui/src/tui/notifications.rs with Method enum (Auto/Osc9/Bel/Off),
notify_done / notify_done_to helpers, tmux DCS passthrough, and 9 unit tests.
Wires the hook at the TurnComplete event in tui/ui.rs so turns >= threshold_secs
(default 30 s) emit an escape to stdout; method auto-detects iTerm.app/Ghostty/
WezTerm for OSC 9 and falls back to BEL. Config exposed under [notifications] in
config.toml and documented in config.example.toml.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-27 23:52:58 -05:00
Hunter BownandClaude Opus 4.7 c31fbe9de2 feat(skills): #139 bundle skill-creator and auto-install on first launch
- Add crates/tui/assets/skills/skill-creator/SKILL.md (ported from
  OpenAI codex repo, MIT-licensed; all Codex→deepseek / ~/.codex→~/.deepseek
  replacements applied; MIT attribution comment at top).
- Convert crates/tui/src/skills.rs → skills/mod.rs + new skills/system.rs.
  install_system_skills() writes the bundled SKILL.md on fresh install or
  version bump; respects user-deleted directories; idempotent by design.
  Version marker at ~/.deepseek/skills/.system-installed-version.
- Wire install_system_skills() into run_interactive() (main.rs) before the
  TUI mounts; errors are non-fatal (logged as warnings).
- Add /skill new as an alias for /skill skill-creator in commands/skills.rs.
- 7 unit tests covering fresh install, idempotence, user-deleted dir, version
  bump, uninstall (all pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 23:52:58 -05:00
Hunter BownandClaude Sonnet 4.6 ce3d1a73b9 feat(approval): #131 port opencode arity dict for prefix-based auto_allow matching
Add COMMAND_ARITY dictionary (160+ entries: git, npm, yarn, pnpm, cargo,
docker, kubectl, go, pip, gh, rustup, deno, bun) and classify_command()
so auto_allow = ["git status"] matches git status -s / --porcelain but
not git push.  Wire classify_command() into approval_cache command_prefix
key construction.  50 colocated unit tests including an explicit
auto_allow semantics test.  Document prefix semantics in config.example.toml.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-27 23:49:56 -05:00
Hunter BownandClaude Sonnet 4.6 eab853e92b feat(tui): #138 add Alt+V chord for tool-details pager
Add Alt+V keybinding that opens the tool-details pager regardless of
composer state, fixing the broken "press v for details" hint. Update
all 5 transcript hint strings to show "Alt+V for details". Bare v with
empty composer is preserved for legacy muscle memory.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-27 23:46:49 -05:00
Hunter BownandClaude Opus 4.7 693fbca4ea fix(test): #101 nested-path test uses file_name() for Windows portability
Windows preserves the user-typed `/` when Path::join() ingests a multi-
component string with forward slashes, producing a mixed-separator path
in the rendered <file> block (e.g. `C:\...\.tmpKxj0Pk\nested/deep/file.md`).
The test compared full paths via display(), which mismatched.

Switch to a basename comparison per CLAUDE.md's portability rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 23:00:56 -05:00
Hunter BownandClaude Opus 4.7 65f5daa9f9 chore(release): v0.6.7
Bumps workspace + npm wrapper to 0.6.7. Auto-tag.yml will create the
v0.6.7 tag, which fires release.yml to build the binary matrix and draft
the GitHub Release.

v0.6.7 highlights (all 12 open issues for this release landed):

- #130 sub-agent mailbox abstraction with monotonic seq + watch-based
  backpressure; close-as-cancel propagates through nested children
- #128 in-transcript DelegateCard + FanoutCard consume the mailbox stream;
  sidebar demoted to a navigator
- #129 approval modal Codex-style takeover with benign / destructive
  variant routing — destructive ops require explicit second-key confirm
- #103 stream-error diagnostics + transparent retry on early decode
  failure; HTTP/2 keepalive defaults; DEEPSEEK_FORCE_HTTP1 escape hatch
- #101 @-file mention BLOCKER fix — two-pass workspace→cwd→fuzzy
  resolution; .deepseekignore honored; Workspace::resolve extracted
- #52 OpenRouter + Novita as first-class providers; /provider picker
  modal with inline API-key prompt; OPENROUTER_API_KEY / NOVITA_API_KEY
- #93 help overlay (?) with searchable command + keybinding catalog
- #95 /statusline picker for configurable footer items, persisted to
  config.toml under tui.status_items
- #94 live transcript overlay (Ctrl+T) with sticky-bottom auto-scroll
  and (cell, width, revision) wrap cache
- #85 pending input preview widget — three semantic buckets (pending
  steers / rejected steers / queued follow-ups); Alt+Up edits last queued
- #66 error taxonomy wired through engine + capacity controller + audit
  log + TUI severity rendering; ad-hoc string matching gone
- #68 sub-agent prompts tightened — explicit
  SUMMARY/EVIDENCE/CHANGES/RISKS/BLOCKERS contract, mode-specific
  guidance, tool-calling conventions in one canonical file

1358 deepseek-tui binary tests passing (1217 baseline → +141), all
parity gates green, clippy -D warnings clean, rustdoc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 22:46:38 -05:00
Hunter Bown 1184662875 Merge branch 'feat/v067-transcript-overlay' (#94 live transcript overlay Ctrl+T) 2026-04-27 22:44:33 -05:00
Hunter Bown 900825c411 Merge branch 'feat/v067-pending-input' (#85 pending input preview widget) 2026-04-27 22:44:29 -05:00
Hunter Bown 07be2f655e Merge branch 'feat/v067-error-taxonomy' (#66 wire error taxonomy) 2026-04-27 22:44:25 -05:00
Hunter BownandClaude Opus 4.7 3c4eb1ed55 feat(errors): #66 wire error taxonomy through engine + capacity + audit
Removed dead_code allow from error_taxonomy.rs. Event::error now carries
an ErrorEnvelope with category + severity instead of (String, bool); all
~13 engine callsites migrated through a small helper API (ErrorEnvelope::transient
/ ::fatal / ::fatal_auth / ::context_overflow / ::network / ::tool / ::classify).

Capacity controller branches on ErrorCategory instead of substring matches.
TUI renders severity with distinct palette tokens via a new HistoryCell::Error
variant. Audit log carries category + severity fields so downstream tooling
can categorize.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 22:43:08 -05:00
Hunter BownandClaude Opus 4.7 f88f810b1a feat(tui): #94 live transcript overlay (Ctrl+T) with sticky-bottom auto-scroll
Pager view gains a sticky_to_bottom mode: scroll-up pauses auto-tail,
scrolling back to bottom resumes it. Wrapped lines cached by
(cell_id, width, revision); revisions bumped on live-cell mutation so
resize doesn't reflow the world.

Ctrl+T toggles; Esc returns. Engine continues streaming while overlay open.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 22:36:54 -05:00
Hunter BownandClaude Opus 4.7 5f96990739 feat(tui): #85 pending input preview — three-bucket queued message UI
Port of codex-main's PendingInputPreview pattern. Three semantic buckets
render in the composer area: pending steers (Esc submits), rejected
steers (re-fires at end of turn), queued follow-ups (Alt+Up edits last).
Empty state renders zero rows.

Engine populates the new App.pending_steers / rejected_steers fields
through the steer-submission path; existing queued_messages plumbing
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 22:29:17 -05:00
Hunter Bown f6c9bf13db Merge branch 'feat/v067-statusline' (#95 /statusline picker)
# Conflicts:
#	crates/tui/src/tui/app.rs
#	crates/tui/src/tui/ui.rs
#	crates/tui/src/tui/views/mod.rs
2026-04-27 22:18:53 -05:00
Hunter Bown 5e7dbcd32b Merge branch 'feat/v067-help' (#93 help overlay) 2026-04-27 22:17:30 -05:00
Hunter Bown 48c30473da Merge branch 'feat/v067-providers' (#52 OpenRouter + Novita providers) 2026-04-27 22:17:27 -05:00
Hunter Bown 363f064fce Merge branch 'fix/v067-file-mention' (#101 @-file mention BLOCKER fix) 2026-04-27 22:17:22 -05:00
Hunter Bown 0f7252198d Merge branch 'fix/v067-stream-retry' (#103 stream-error retry + diagnostics) 2026-04-27 22:17:19 -05:00
Hunter Bown a3d0134173 Merge branch 'feat/v067-approval' (#129 approval modal Codex-style takeover) 2026-04-27 22:17:16 -05:00
Hunter Bown 7819fcc18b Merge branch 'feat/v067-cards' (#128 sub-agent in-transcript cards) 2026-04-27 22:17:11 -05:00
Hunter Bown 176a2ba4f4 Merge branch 'feat/v067-mailbox' (#130 sub-agent mailbox port) 2026-04-27 22:17:00 -05:00
Hunter Bown 34cba09e22 Merge branch 'feat/v067-prompts' (#68 sub-agent prompts tightening) 2026-04-27 22:16:57 -05:00
Hunter BownandClaude Opus 4.7 63cb06637b feat(tui): #128 in-transcript DelegateCard + FanoutCard
Cards consume the #130 mailbox stream and render live in the transcript:
- DelegateCard: last-3-actions tree for active agent_spawn
- FanoutCard: dot-grid + aggregate stats for agent_swarm / rlm fanout
Sidebar demoted to a navigator (count + role); detail lives in the card.

Engine wires SubAgentRuntime::with_mailbox so the primitive actually flows.
Cards re-bind on session resume via runtime_threads agent_ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 22:15:26 -05:00
Hunter Bown d6a7a0e84a feat(tui): #95 /statusline picker for configurable footer items
StatusItem enum covers all current + new (rate-limit, ctx %, git branch,
last-tool elapsed) items. /statusline opens a multi-select picker with
live preview; selections persist to config.toml under `tui.status_items`.

Default selection mirrors today's footer so upgraders see no change.
2026-04-27 22:00:44 -05:00
Hunter BownandClaude Opus 4.7 f118db8201 feat(providers): #52 OpenRouter + Novita as first-class providers
ProviderKind gains Openrouter + Novita variants; ModelRegistry registers
deepseek/deepseek-v4-{pro,flash} against both. /provider opens a picker
modal with inline API-key prompt for un-configured providers. Env
fallbacks: OPENROUTER_API_KEY, NOVITA_API_KEY.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:58:51 -05:00
Hunter BownandClaude Opus 4.7 9d4c1c1966 feat(tui): #129 approval modal Codex-style takeover
Reflowed approval.rs to a full-screen modal with two stakes-based variants:
benign (single-key approve / always) and destructive (explicit confirm).
Variant routing classifies from tool kind + command-safety so destructive
ops never get a muscle-memory accept.

Existing approval tests still pass; new tests cover variant routing + keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:57:16 -05:00
Hunter BownandClaude Opus 4.7 36320c5bea fix(client): #103 stream-error diagnostics + transparent retry on early decode failure
Phase 1: log full reqwest error chain + headers + bytes-received at decode site
Phase 2: HTTP/2 keepalive settings + tcp keepalive on the reqwest builder
Phase 3: engine transparently retries when stream errors before any content;
         surface error on mid-stream failure (no double-bill); stream_errors
         threshold relaxed 3 -> 5 with the new keepalive
Phase 4: unit tests for the four classes of stream failure

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:57:13 -05:00
Hunter Bown b759e3f74c feat(tui): #93 help overlay — ? opens searchable command + keybinding reference
New HelpView modal lists all slash commands with descriptions and all
keybindings, with a live substring filter. Bound to `?` when focus is
outside the composer; Esc / `?` toggles.

Slash commands pull from the existing slash_menu registry; keybindings
pull from a new KeybindingCatalog single-source-of-truth so docs can't
drift from the wired handlers.
2026-04-27 21:54:25 -05:00
Hunter BownandClaude Opus 4.7 fd13dffd60 fix(tui): #101 @-file mention resolves CWD before workspace fallback
Two-pass resolution in file_mention::resolve_mention_path -- try
workspace.join first, then std::env::current_dir().join, then a basename
fuzzy fallback. Extracted the shared resolver into working_set.rs so the
future Ctrl+P fuzzy picker (#97) uses the same logic.

Tests cover the workspace/CWD divergence repro that was masquerading as a
"@ doesn't work" report.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:52:25 -05:00
Hunter Bown 8d8c1ad2d4 feat(prompts): #68 tighten sub-agent output format + stop conditions
Each sub-agent type now has an explicit SUMMARY / EVIDENCE / CHANGES /
RISKS / BLOCKERS output contract, mode-specific guidance (explorer /
planner / reviewer / general), and tool-calling conventions that prefer
the typed tool surface over exec_shell shellouts.

The output format is defined once and referenced from each per-type
prompt, so future tweaks live in one place.
2026-04-27 21:50:18 -05:00
Hunter BownandClaude Opus 4.7 32750cb52d feat(subagent): #130 mailbox abstraction with seq + backpressure
Internal upgrade — public tool surface (agent_spawn, agent_swarm, …) unchanged.
The mailbox primitive replaces ad-hoc mpsc plumbing in the runtime so:
- progress events have monotonic ordering
- subscribers get watch-based backpressure
- close-as-cancel propagates through nested children

Pairs with #128 (in-transcript cards consume the mailbox stream).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:50:14 -05:00
Hunter BownandClaude Opus 4.7 4ac7219d77 fix(test): cycle archive tests are Windows-portable
The Windows CI test for archive_cycle_writes_jsonl_with_header_and_messages
was failing because:

1. The path-suffix assertion used "/1.jsonl" as a literal — Windows uses
   backslashes, so the assertion never matched. Replace with file_name()
   comparison which is platform-agnostic.
2. set_var("HOME", ...) doesn't redirect dirs::home_dir() on Windows;
   that function reads USERPROFILE on Windows. Set both env vars so the
   test redirects portably.

Same HomeGuard pattern in tools/recall_archive.rs tests was passing on
Windows by luck (each test uses a unique session_id, so writes to the
real ~/.deepseek/sessions/ didn't clash) but was polluting the user's
home directory in CI. Mirror the dual-env-var fix there too.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:25:33 -05:00
Hunter Bown 0db918f9e8 Merge branch 'feat/v066-ui-redesign'
#121 — UI redesign (4 of 6 sub-areas + foundation):
- Color-depth + brightness palette helpers
- Speaker glyphs (▎ / ●) with 2s pulse
- Reasoning treatment (dashed rail, italic, warm tint)
- Tool-card verb glyphs + family vocabulary module
- Footer crest waves + cost-on-left + 150ms cadence

Deferred (separate follow-up issues): sub-agent in-transcript cards,
approval modal Codex-style takeover.
2026-04-27 21:10:50 -05:00
Hunter Bown 95d6937d34 Merge branch 'feat/v066-steer-queue'
#122 — Esc-to-steer + queue visibility. SubmitDisposition state machine,
pending_steers/rejected_steers buckets, partial-output [interrupted] save.
2026-04-27 21:10:41 -05:00
Hunter Bown f95be44bc8 Merge branch 'feat/v066-cycle-restart'
#124 + #127 — checkpoint-restart context cycles + recall_archive tool.
2026-04-27 21:10:37 -05:00
Hunter BownandClaude Opus 4.7 2b2bddcf7e style: cargo fmt + clippy fixes for v0.6.6 UI redesign
Run `cargo fmt --all` after the four redesign sub-areas land to settle
attribute placement (`#[allow(dead_code)]` lives after doc comments,
not between them — interleaving was splitting docs from items).
Inline the trailing `let dom = …; dom` in `nearest_ansi16` to satisfy
clippy::let_and_return.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:08:29 -05:00
Hunter BownandClaude Opus 4.7 aeba004c7b feat(tui): tool-card verb glyphs + family vocabulary module
Sub-area #3 of the v0.6.6 UI redesign (issue #121).

Introduces `crates/tui/src/tui/widgets/tool_card.rs` — a small,
self-contained vocabulary module that owns:

- `ToolFamily` enum (Read / Patch / Run / Find / Delegate / Fanout /
  Think / Generic) and the verb-glyph + label per family
  (▷ read, ◆ patch, ▶ run, ⌕ find, ◐ delegate, ⋮⋮ fanout, … think)
- `tool_family_for_title` — maps the legacy header titles
  (`"Shell"`, `"Patch"`, `"Workspace"`, `"Search"`, `"Diff"`, `"Image"`)
  to a family, so existing call sites pick up the new glyph without
  re-architecture
- `tool_family_for_name` — maps actual tool names
  (`agent_spawn`, `apply_patch`, etc.) for `GenericToolCell`, which
  shares the catch-all `"Tool"` title across every model-facing tool
- `CardRail` + `rail_glyph` — the `╭ │ ╰` rail vocabulary, declared
  here so any future per-card refactor has the matching glyphs

Wires the verb glyph + label into `render_tool_header` and adds a
`render_tool_header_with_family` overload so `GenericToolCell` can
route by tool name rather than the generic title. The header now reads
`<spinner> <verb-glyph> <verb> <state>` instead of
`<spinner> <Title-Case-Word> <state>`.

Existing parity tests for ExecCell / PlanUpdate are updated to assert
against the new header structure (verb + glyph) — the colour wiring is
unchanged. New tests pin the verb-glyph format end-to-end:
`agent_spawn` → `◐ delegate`, exec → `▶ run`.

Spinner cadence (TOOL_STATUS_SYMBOL_MS = 720 ms) is unchanged — the
spec already matched.

Deferred to a follow-up: full per-card rail (`╭ │ ╰`) refactor that
threads `CardRail` through every cell render path. The vocabulary is
in place; the layout pass is the next bite.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 21:04:23 -05:00
Hunter BownandClaude Opus 4.7 7c3a01c7b8 feat(footer): crest waves + cost on left + 150 ms tick cadence
Sub-area #5 of the v0.6.6 UI redesign (issue #121).

Spout strip:
- Replace box-drawing arc cups (`╭───╮`) with paired crests (`⌒‿`) over
  the existing water-surface (`─`). Crests read as gentle ripples instead
  of hard architectural arches — calmer eye-feel.
- Two crests at independent cadences: A advances every 4 ticks (~600 ms),
  B every 6 ticks (~900 ms). Phase jitter every 17 ticks (~2.5 s) keeps
  the pattern from settling into a strict beat.
- Frame counter cadence in `ui.rs` retimed from 80 ms to 150 ms so the
  4×6×17 tick math lands on the spec'd timings.

Footer left cluster consolidates "what costs you what":
- Cost chip moves from the right-hand parade to the left, between model
  and status: `mode · model · cost · status`.
- Priority drop is now status → cost → truncate model → mode-only. Cost
  outranks status because it's steady info; status is a transient signal.
- Right cluster shrinks to coherence / agents / replay / cache.

Tests: existing strip determinism / position-advances tests are retuned
for the new tick math (12-tick window covers both crests). New tests pin
the cost slot's order on wide widths and confirm cost survives status
drop in the priority cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 20:59:07 -05:00
Hunter Bown 764aed65ed feat(tui): #122 Esc-to-steer + queue visibility
While a turn is running, Esc with non-empty composer input now steers:
the typed text is captured, the in-flight HTTP request is cancelled,
and on TurnComplete::Interrupted every accumulated steer is merged
into a single fresh user message that re-enters the engine. Empty-input
Esc still cancels exactly as before.

State machine. SubmitDisposition::{Immediate, Queue, Steer} replaces
the implicit if/else in submit_or_steer_message; truth table preserves
the offline_mode + busy fallback path. App.pending_steers /
rejected_steers / submit_pending_steers_after_interrupt back the new
flow and the queue-visibility widget.

Partial save. Deliberate divergence from openai/codex which discards
on abort: V4 thinking is expensive, so the streaming Assistant cell is
tagged '[interrupted] …' (or '[interrupted]' when nothing streamed yet)
and its spinner is flipped off. The TurnComplete handler also calls the
helper so Ctrl+C / network failures get the same treatment, idempotent
with the optimistic call in the Esc handler.

Queue visibility. PendingInputPreview already supported all three
buckets; build_pending_input_preview now populates pending_steers and
rejected_steers alongside queued_messages. rejected_steers stays empty
under today's engine paths (no rejection signal yet) but renders if/when
populated.

Recovery. If TurnComplete arrives with Failed instead of Interrupted
while pending_steers is non-empty, the steers are demoted to the
visible queue so they're not silently lost.

Tests. 13 new app-level units cover the disposition truth table,
push/drain semantics, double-Esc idempotency, and the partial-save
helper. 11 new ui-level units cover Esc-action routing, slash-menu
priority, whitespace-only input handling, merge_pending_steers (empty
/ single / multi / skill-instruction), and the three-bucket preview.

Closes #122.
2026-04-27 20:59:00 -05:00
Hunter BownandClaude Opus 4.7 2b0e73a4cf feat(tui): reasoning cells get dashed rail, italic body, warm tint
Sub-area #2 of the v0.6.6 UI redesign (issue #121).

Reasoning is the only deliberately-warm element in the redesigned
transcript. The treatment makes that visible:

- Header opener becomes `…` (slow exhale) instead of the spinner glyph
- Body left rail switches from solid `▏` to dashed `╎` so it visibly
  differs from message body and tool output rails
- Body text carries an italic modifier
- Body lines tint with `palette::reasoning_surface_tint(depth)` — 12%
  blend of SURFACE_REASONING (#362C1A) over DEEPSEEK_INK. ANSI-16
  terminals get no bg (the named-color mapping would distort the warm)
- A trailing `▎` cursor in ACCENT_REASONING_LIVE follows the most recent
  body line during streaming, suppressed under low_motion

Wires up palette helpers from the prior commit: `ColorDepth::detect`,
`reasoning_surface_tint`, `blend`. SURFACE_REASONING is no longer
dead-coded. The unused `thinking_symbol` helper is removed since the new
header doesn't spin.

Tests: dashed rail and italic body land on every body line; streaming
cursor appears only when motion is allowed; collapsed-summary affordance
keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 20:52:59 -05:00
Hunter BownandClaude Opus 4.7 10bc2480db feat(core): #124 + #127 — checkpoint-restart cycles + recall_archive
Replaces lossy summarization compaction with a checkpoint-restart
architecture (#124). At 110K cumulative tokens (per V4's 128K retrieval
elbow) the engine runs a briefing turn, archives the cycle to JSONL at
~/.deepseek/sessions/<id>/cycles/<n>.jsonl, then resets the in-memory
buffer to a fresh context: original system prompt + structured state
(plan/todos/working-set/sub-agents) + the model-curated <carry_forward>
briefing (~3K token cap, hard-bounded).

The compaction summarizer is now off by default. Per-model thresholds in
[cycle.per_model] let operators tune deepseek-v4-pro vs -flash separately.
Phase guard in should_advance_cycle blocks mid-tool/stream/approval boundaries;
engine only invokes at clean turn-completed events. Sub-agents are not
awaited — their handles are captured in the structured-state block so the
new cycle sees them still running.

Adds the recall_archive tool (#127) — BM25 over message text in archived
cycles, top-N hits with cycle/index/excerpt. Always-loaded across modes
via should_default_defer_tool so the agent doesn't need ToolSearch to
discover it. Children inherit it via with_full_agent_surface.

UI surfaces:
- /cycles, /cycle <n>, /recall <query> slash commands
- Sidebar shows cycle counter once a boundary fires
- CycleAdvanced engine event carries the full briefing so the UI can
  populate app.cycle_briefings for /cycle <n>
- runtime_threads schema bumped to v2 (cycle.advanced events appear in
  the durable timeline; load rejects future versions)

Tests: 21 cycle_manager + 13 recall_archive + 4 commands::cycle.
All 1168 workspace tests pass. Three parity gates pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 20:52:29 -05:00
Hunter BownandClaude Opus 4.7 5b7ff8cb69 feat(tui): replace literal speaker labels with calm glyphs
Sub-area #1 of the v0.6.6 UI redesign (issue #121).

User cells now lead with `▎` (solid bar, no animation — input is finished).
Assistant cells lead with `●`. While streaming, the bullet pulses on a
2-second cycle between 30%..100% brightness via `palette::pulse_brightness`;
once a turn completes the bullet sits at full DEEPSEEK_SKY so finished
history reads as solid.

Honors `low_motion`: the pulse is suppressed and the glyph holds full
brightness regardless of streaming state. Pager / clipboard exports
(`transcript_lines`) also skip the pulse so screenshots are stable.

Existing pager titles in `ui.rs` (`history_cell_to_text`) keep the literal
"You" / "Assistant" wording — those drive the modal title bar and read
better as words than as glyphs.

Tests: glyphs replace the literal labels in both User and Assistant cells;
streaming pulse demonstrably dips below source brightness; idle and
low_motion both pin to source.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 20:50:24 -05:00
Hunter BownandClaude Opus 4.7 9358b92af5 feat(palette): color-depth fallback + brightness pulse helpers
Adds the foundation for the v0.6.6 UI redesign (issue #121):

- ColorDepth enum with detect() reading COLORTERM/TERM
- adapt_color / adapt_bg gates for ANSI-16 terminals (drops bg tints
  rather than coarse-mapping them, which would distort the palette)
- blend(fg, bg, alpha) for alpha compositing on RGB
- reasoning_surface_tint() — 12% blend over DEEPSEEK_INK (None on ANSI-16)
- pulse_brightness(color, now_ms) — 30%..100% sine swing on a 2s cycle
- nearest_ansi16() helper for legacy-terminal foreground mapping

Helpers carry #[allow(dead_code)] until the per-area redesign sub-tasks
wire them in (speaker pulse, reasoning treatment, footer cost cluster).
Tests cover envelope bounds and brand-color routing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 20:47:27 -05:00
Hunter Bown e075ecd0fe chore: add #[allow(dead_code)] for v0.6.6 additions not yet wired
- ClientError, StreamError, and their impl blocks in error_taxonomy.rs
- ApprovalCache in approval_cache.rs (pending #66 follow-up wiring)
- Legacy prompt constants in prompts.rs (backward compat)
- with_mcp_tools and McpToolAdapter in registry.rs (pending MCP migration)
- Fix rlm_query → rlm in when_not_to_use_sections_present test
2026-04-27 19:57:53 -05:00
Hunter Bown 8be032b6bd style: cargo fmt after parallel stream merges 2026-04-27 19:43:58 -05:00
Hunter Bown f10d5d1829 Merge branch 'feat/tool-polish' 2026-04-27 19:41:44 -05:00
Hunter Bown 7a06915b0b feat(tools): approval cache + error taxonomy + defer_loading + command safety trim
- Add fingerprint-based ApprovalCache with call-specific keys (patch hash,
  shell prefix, URL host) instead of tool-name keys. Session-keyed.
- Add ClientError/StreamError enums in error_taxonomy.rs with Retry-After
  header support. Wire ErrorEnvelope into Event::Error.
- Add defer_loading() default method to ToolSpec trait. McpToolAdapter
  returns true for non-discovery MCP tools.
- Add with_mcp_tools() on ToolRegistryBuilder for unified pipeline.
- Trim DANGEROUS_PATTERNS in command_safety.rs from 25→5 entries.
  Only rm -rf and fork bomb remain; chaining/substitution downgraded
  to RequiresApproval. Matches Codex's restraint.
- ApprovalRequired events now carry approval_key for UI caching.

TODO_BACKEND.md §1, §5
2026-04-27 19:40:49 -05:00
Hunter Bown a32148dac9 Merge branch 'feat/prompts-restructure' 2026-04-27 19:34:27 -05:00
Hunter Bown a345a956aa feat(perf): wire frame-rate limiter + adaptive chunking low-motion mode
- Wire 120 FPS FrameRateLimiter into run_event_loop via
  time_until_next_draw + mark_emitted
- Add low_motion support: 30 FPS cap via LOW_MOTION_MIN_FRAME_INTERVAL
- Add AdaptiveChunkingPolicy::set_low_motion() to force Smooth mode
- Add StreamingState::set_low_motion() to propagate to all block policies
- Tool spinner already freezes on first frame when low_motion is set

TODO_BACKEND.md §3, TODO_FIXES.md #4
2026-04-27 19:33:52 -05:00
Hunter Bown 6ef2421d61 feat(prompts): restructure into composable personality overlays
Split monolithic agent.txt into:
- base.md: core identity, toolbox, subagent.done protocol
- personalities/calm.md + playful.md: voice overlays
- modes/agent.md, plan.md, yolo.md: mode deltas
- approvals/auto.md, suggest.md, never.md: approval-policy deltas
- compact.md: 9-line compaction handoff template

Add compose_prompt() in prompts.rs: base → personality → mode → approval.
Add Personality enum with from_settings(). Preserve legacy .txt constants for
backward compatibility.

TODO_BACKEND.md §6
2026-04-27 19:33:52 -05:00
Hunter Bown d4b9ccfdb3 feat(subagent): full registry inheritance + auto-approve + depth cap + cwd (#99)
v0.6.6 — sub-agents inherit the parent's full tool registry,
auto-approve, respect a depth cap, and propagate cancellation.
Adds optional cwd to agent_spawn for parallel-worktree dispatch.
Schema-ready for roles (full library lands in 0.6.7).

Changes:
- New ToolRegistryBuilder::with_full_agent_surface(...) shared by parent and child
- SubAgentToolRegistry::new refactored to use shared builder; per-type
  allowlist becomes advisory
- SubAgentRuntime gains auto_approve, spawn_depth, max_spawn_depth, cancel_token
- Depth check at spawn entry; cancellation cascade via CancellationToken::child_token()
- <deepseek:subagent.done> sentinel emitted on child completion
- cwd: Option<PathBuf> on agent_spawn with workspace-boundary validation
- Stream wall-clock cap bumped to 30 min (was 300s)
- max_spawn_depth configurable via EngineConfig (default 3)
- Version bump to 0.6.6

Closes #99.
2026-04-27 19:16:22 -05:00
Hunter BownandClaude Opus 4.7 2787cdc7b9 refactor(tools): drop parallel_fanout — rlm is the only RLM tool
Two near-duplicate top-level tools made the surface confusing. With
parallel_fanout (formerly rlm_query) removed, there's exactly one RLM
shape: load a long input as `context` in a Python REPL via `rlm`, and
let the sub-agent fan out from inside the REPL via `llm_query_batched`
where it has `context` in scope to chunk against.

For non-RLM parallel work the dispatcher already runs multiple tool
calls per turn concurrently — no separate fan-out tool needed. The
GenericToolCell.prompts rendering hook stays (one-row-per-child for any
future fan-out tool), but no tool currently populates it.

Also drops two stray test artifacts (rlm_catalog.md, rlm_test_doc.md)
the model wrote to repo root during a previous live test session.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 02:14:10 -05:00
Hunter BownandClaude Opus 4.7 9dd0d12cea refactor(tools): rename rlm_process → rlm, rlm_query → parallel_fanout
Two top-level tools shared the rlm_ prefix but did completely different
things — rlm_query was a flat parallel-completion fan-out wearing an
RLM-shaped name, and rlm_process was the actual recursive language model.
The overlap was the source of the "our rlm query is completely wrong"
confusion.

  rlm_process  → rlm              # single, honest name for the recursive tool
  rlm_query    → parallel_fanout  # honest name for the flat fanout

Internal renames follow:
  Op::RlmQuery       → Op::Rlm
  AppAction::RlmQuery → AppAction::Rlm
  handle_rlm_query    → handle_rlm
  RlmProcessTool      → RlmTool
  RlmQueryTool        → ParallelFanoutTool
  RlmChildClient      → FanoutChildClient
  with_rlm_process_tool → with_rlm_tool
  with_rlm_query_tool   → with_parallel_fanout_tool

The REPL helpers `rlm_query` / `rlm_query_batched` / `llm_query` /
`llm_query_batched` keep their names — those are correctly named (they
ARE recursive within the REPL) and the model knows them from the system
prompt and metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 02:10:17 -05:00
Hunter BownandClaude Opus 4.7 2865c9a766 refactor(rlm): drop HTTP sidecar — long-lived Python REPL over stdin/stdout
The RLM tool used to spawn a fresh `python3 -c "..."` per round and route
sub-LLM calls through a localhost axum sidecar; state persisted only via a
JSON file (lossy: imports and non-JSON values were lost). The model could
also short-circuit by replying with prose and the loop would ship the
prose as if it came from the REPL.

This commit replaces that with one long-lived `python3 -u` subprocess per
turn driven by a stdin/stdout RPC protocol with UUID-prefixed sentinels.
No more HTTP server, no more port allocation, no more JSON state file —
variables, imports, and any other Python state persist naturally across
rounds. The `RlmBridge` (`crates/tui/src/rlm/bridge.rs`) services
`llm_query` / `llm_query_batched` / `rlm_query` / `rlm_query_batched`
calls inline, recursing into `run_rlm_turn_inner` for sub-RLMs.

The system prompt is tightened: the only legal turn shape is one
` ```repl ` block; calling `FINAL(...)` from prose without ever invoking a
sub-LLM is rejected with a strict reminder. The `DirectAnswer` termination
is gone, replaced by `NoCode` which only surfaces after multiple consecutive
empty rounds. `rlm_process` now returns a per-round trace (code summary,
sub-LLM call count, elapsed) so callers can verify the model actually
engaged with `context` rather than guessing from the preview.

Net: -313 lines. 17 new REPL runtime tests cover variable persistence,
import persistence, RPC round-trips, FINAL capture, and error recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 01:44:53 -05:00
Hunter BownandClaude Opus 4.7 5cec1534be feat(rlm): align with reference impl + add rlm_process tool; bump 0.6.5
The previous /rlm slash command flow had a UI rendering gap (the answer
never made it back to the model's view) and required the user to invoke
it manually. Pivoting to a tool-call surface and aligning the in-REPL
helpers with the canonical reference (alexzhang13/rlm) by the paper
authors so the same prompts and decomposition patterns transfer.

New tool: rlm_process
- crates/tui/src/tools/rlm_process.rs
- Inputs: task (small, shown to root LLM each iter as root_prompt) +
  exactly one of file_path (workspace-relative, preferred) or content
  (inline, capped at 200k chars). Optional child_model and max_depth.
- Loaded across Plan/Agent/YOLO; never deferred via ToolSearch.
- Returns the final answer string + metadata (iterations, duration,
  tokens, termination).

REPL surface aligned with reference (alexzhang13/rlm):
- Variable name `context` (was PROMPT)
- Code fence ```repl (was ```python; python/py kept as fallback)
- Helpers: llm_query, llm_query_batched (NEW), rlm_query (was sub_rlm),
  rlm_query_batched (NEW), SHOW_VARS (NEW), FINAL, FINAL_VAR,
  repl_get/repl_set
- Top-level JSON-serializable user variables auto-persist across rounds
  (no repl_set ceremony required)
- FINAL(...) / FINAL_VAR(...) parseable from the model's raw response
  text (parse_text_final), in addition to the in-REPL sentinel path.
  Code-fenced occurrences are correctly ignored to prevent false hits.

Sidecar (axum, 127.0.0.1:0):
- Added POST /llm_batch and POST /rlm_batch endpoints (parallel fanout,
  cap 16 prompts per batch). Mirrors the reference's batched semantics.

Other:
- System prompt rewritten with reference's strategy patterns
  (PREVIEW → CHUNK+map-reduce via llm_query_batched → RECURSIVE
  decomposition via rlm_query → programmatic compute + LLM interp).
- Strict termination loop unchanged: must emit ```repl or text-level
  FINAL each round; one fence-less round → reminder, two → DirectAnswer.
- /rlm slash command remains for manual debug; description points the
  model toward rlm_process for the in-agent flow.

Versions: workspace 0.6.4 → 0.6.5; npm wrapper 0.6.4 → 0.6.5.

Gates green: cargo fmt, cargo clippy --all-targets --all-features
--locked -D warnings, cargo test --workspace --all-features --locked
(all pass), parity_protocol/parity_state/snapshot, RUSTDOCFLAGS=
-Dwarnings cargo doc --workspace --no-deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 01:17:09 -05:00
Hunter BownandClaude Opus 4.7 950a66c24a chore(rlm): drop "Algorithm 1" from user-facing status strings
Keep the paper reference in code/doc comments where it actually helps a
future reader; the live status line just needs to say what's happening,
not cite the citation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 00:51:17 -05:00
Hunter BownandClaude Opus 4.7 bd938a559c fix(rlm): wire real recursive substrate; bump 0.6.4
The v0.6.3 RLM loop had Algorithm 1's outer shape but the substrate was
non-functional: `llm_query()` was a Python stub that returned a hardcoded
string and `child_model` was bound with an underscore prefix and silently
dropped. The recursive sub-LLM call advertised by /rlm never fired.

This commit wires the substrate end-to-end per Zhang/Kraska/Khattab
(arXiv:2512.24601, Algorithm 1):

- New axum HTTP sidecar (`rlm/sidecar.rs`) bound to 127.0.0.1:0 for the
  duration of one RLM turn. Python's `llm_query()` and `sub_rlm()` are
  real `urllib.request` POSTs; Rust services them via the existing
  DeepSeek client. Token usage from sidecar-served calls folds into the
  parent `RlmTurnResult.usage`.
- `child_model` is plumbed through `Op::RlmQuery` → `AppAction::RlmQuery`
  → `run_rlm_turn` → sidecar handlers; default remains `deepseek-v4-flash`.
- New `sub_rlm(prompt)` Python helper runs a full Algorithm-1 turn at
  depth-1 (paper's `sub_RLM`). Default `max_depth = 2` from `/rlm`. The
  recursive opaque-future cycle is broken by returning a concrete
  `Pin<Box<dyn Future + Send>>` from `run_rlm_turn_inner`.
- Strict termination: the loop ends only via `FINAL(value)` (or the
  iteration cap). One fence-less round is tolerated with a reminder
  appended; two consecutive ones surface the model text as a
  `RlmTermination::DirectAnswer` exit. New `RlmTermination` enum lets
  callers tell `Final | DirectAnswer | Exhausted | Error` apart.
- Richer `Metadata(state)`: includes paper-required access patterns
  (`repl_get` / slicing / `splitlines` / `repl_set` / `llm_query` /
  `sub_rlm` / `FINAL`) and a live list of variable keys currently in
  the REPL state file.
- Unicode-safe `truncate_text` (was mixing bytes with chars), per-turn
  state-file cleanup, `ROOM_TEMPERATURE` typo → `ROOT_TEMPERATURE`.
- New end-to-end test `sidecar_url_is_exported_to_python_env` stands up
  a stand-in axum server, runs `print(llm_query('hello'))` in the real
  PythonRuntime, and asserts the reply round-trips. Catches future
  regressions in sidecar URL passthrough.

Versions: workspace 0.6.3 → 0.6.4 in Cargo.toml; npm wrapper 0.6.3 → 0.6.4
in npm/deepseek-tui/package.json.

Gates: cargo fmt, cargo clippy --all-targets --all-features --locked
-D warnings, cargo test --workspace --all-features --locked (1088
passed), parity_protocol/parity_state/snapshot, RUSTDOCFLAGS=-Dwarnings
cargo doc --workspace --no-deps — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 00:36:59 -05:00
Hunter Bown e8c3e7d1bf docs: fix rustdoc release warnings 2026-04-26 23:43:31 -05:00
Hunter Bown bb3c460358 chore: satisfy v0.6.3 release gates 2026-04-26 23:39:03 -05:00
Hunter Bown 42c684367f feat(rlm): implement true RLM loop per Algorithm 1 (Zhang et al., arXiv:2512.24601)
Adds the true Recursive Language Model (RLM) inference paradigm:

- rlm/mod.rs — module root with public API
- rlm/prompt.rs — RLM system prompt teaching the model to write code
- rlm/turn.rs — Algorithm 1 implementation:
  - P stored as REPL variable (NEVER in LLM context window)
  - Metadata-only context sent to root LLM (constant-size)
  - LLM generates Python code, not free text
  - Code executed in PythonRuntime with llm_query() for recursion
  - FINAL() detection ends the loop
- Op::RlmQuery variant in ops.rs
- /rlm command in the command system
- AppAction::RlmQuery handler in ui.rs
- PythonRuntime::with_state_path made public for RLM integration
- 18 new unit tests for code extraction, metadata building, truncation

Key differences from previous 'RLM-inspired' approach:
✅ P is external (REPL variable), not in LLM context
✅ Only metadata(state) in LLM context (constant-size)
✅ LLM generates code, not free text + tool calls
✅ sub-LLM recursion via llm_query() inside REPL code
✅ FINAL() mechanism for programmatic termination
2026-04-26 23:34:17 -05:00
Hunter Bown ac8a882be5 chore: clean v0.6.3 repl build warnings 2026-04-26 23:12:57 -05:00
Hunter Bown 4e46fd06f6 feat(repl): wire PythonRuntime into engine turn loop (Phase 2)
After the assistant message is persisted, when tool_uses is empty,
check for inline ```repl blocks and execute them via PythonRuntime:

- Extract REPL blocks from assistant text
- Spawn PythonRuntime and execute each block sequentially
- If a round returns FINAL: replace the assistant message text with
  the final value and break the turn
- If no FINAL: append truncated stdout/stderr as user feedback and
  continue the turn loop for iterative refinement
- Emit status events so the user sees 'REPL round N: ...' in the UI

All 26 REPL tests + RLM tests pass. Release build verified.

Refs: paper-spec RLM (Zhang et al., arXiv:2512.24601) §2
2026-04-26 18:54:46 -05:00
Hunter Bown 2fcc637d4f Merge pull request #118 from Hmbown/fix/issue-115-context-percent
fix(tui): context-usage % no longer drops after multi-round turns (#115)
2026-04-26 18:01:14 -05:00
Hunter Bown b7c5cb4112 test(ui): update auto-compact test for #115 estimate-first behavior 2026-04-26 17:55:56 -05:00
Hunter Bown 60f5f39584 Merge pull request #109 from Hmbown/feat/issue-85-pending-input-preview
feat(tui): pending-input preview widget (#85 Phase 1)
2026-04-26 17:54:35 -05:00
Hunter Bown 6fc4680b91 Merge pull request #117 from Hmbown/feat/issue-92-clipboard-image
feat(tui): clipboard image paste (#92)
2026-04-26 17:52:56 -05:00
Hunter Bown 28e8d70ffc chore(release): bump workspace + npm wrapper to 0.6.3 2026-04-26 17:52:29 -05:00
Hunter Bown 8d33b92f82 style(tests): rustfmt collapses short let-binding pairs 2026-04-26 17:51:23 -05:00
Hunter Bown e31d6db3ee style(pending_input_preview): single-line fmt for short literal arrays 2026-04-26 17:50:53 -05:00
Hunter BownandClaude Opus 4.7 2378fbc26f feat(tui): pending-input preview widget (#85 Phase 1)
Port of codex-rs's `bottom_pane/pending_input_preview.rs` for the queued /
pending steer / rejected steer surface. Phase 1 ships the widget + 7 unit
tests in isolation so reviewers can evaluate the rendering decisions
without also reviewing the composer-area integration. Phase 2 wires it
into `ui.rs` and threads the `pending_steers` / `rejected_steers` fields
onto `App`.

The widget renders three semantic buckets when any are non-empty:

  • Messages to be submitted after next tool call (press Esc to send now)
    ↳ <pending steer>
    ↳ <pending steer>

  • Messages to be submitted at end of turn
    ↳ <rejected steer>

  • Queued follow-up inputs
    ↳ <queued message>
        Alt+↑ edit last queued message

Items truncate to 3 visible rows with a `…` overflow indicator. Long
URL-like tokens emit on their own row instead of fanning out into junk
ellipsis rows (regression test included). Empty state renders zero rows
so the composer doesn't gain wasted height when nothing is queued.

Refs #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:50:16 -05:00
Hunter Bown 32a977e58e Merge pull request #116 from Hmbown/feat/issue-97-fuzzy-picker
feat(tui): Ctrl+P fuzzy file picker (#97)
2026-04-26 17:49:41 -05:00
Hunter Bown 6ba43e749a Merge pull request #114 from Hmbown/fix/issue-103-stream-retry
fix(engine): transparent retry on stream death with no content (#103 Phase 3)
2026-04-26 17:49:38 -05:00
Hunter Bown 904ec869a6 Merge pull request #112 from Hmbown/feat/issue-96-pager-search
feat(pager): match highlighting + counter no-clip + Esc clears (#96)
2026-04-26 17:49:33 -05:00
Hunter BownandClaude Opus 4.7 a326ef2891 fix(tui): context-usage % no longer drops after multi-round turns (#115)
User reported: "the context % at the top is pretty inconsistent — like I
just had a message where it was 31% then I sent another message and it
went to 9%? not sure how that works......"

Root cause: `context_usage_snapshot` preferred `app.last_prompt_tokens`
(reported, from `Event::TurnComplete.usage`) over the estimate computed
from `app.api_messages`. The engine populates that usage via
`turn.add_usage`, which SUMS `input_tokens` across every round in a turn:

  ```
  pub fn add_usage(&mut self, usage: &Usage) {
      self.usage.input_tokens += usage.input_tokens;
      ...
  }
  ```

So a multi-round tool-call turn reports a value much larger than the
actual context window state (e.g., 200k from round 1 + 210k from round 2
= 410k displayed as 31% of 1M), then the next single-round turn drops
back to a single round's input_tokens (e.g., 90k displayed as 9%).

Fix: prefer the estimate, which is computed from the current
`api_messages` and is monotonic wrt conversation growth. Reported tokens
fall back only when no estimate is available (e.g., immediately after a
session restore). Also clamp `used` to the model's context window so the
ratio never exceeds 100%.

`is_reported_context_inflated` is no longer in the primary path; kept
behind `#[allow(dead_code)]` because existing tests still exercise it
and a future heuristic may want to distinguish "obviously inflated
reported tokens" from healthy reports.

Regression test
`context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn`
exercises the exact 31% → 9% scenario the user hit.

Fixes #115.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:48:57 -05:00
Hunter BownandClaude Opus 4.7 5b0af72c3d feat(tui): clipboard image paste (#92)
Save clipboard images as PNG under ~/.deepseek/clipboard-images/
instead of PPM in the workspace, and surface dimensions + size in the
composer's [Attached image: WxH PNG (NkB) at <path>] token plus the
post-paste status hint. DeepSeek V4 does not currently accept inline
image input on its Chat Completions endpoint, so we materialize the
bytes to disk and let the model reach them via the existing file
tools rather than base64-embedding them in the request.

Adds the `image` crate (PNG-only feature; already pulled in
transitively via arboard, so no compile-time delta) plus unit tests
covering PNG header round-trip and label formatting.

Fixes #92

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:47:34 -05:00
Hunter BownandClaude Opus 4.7 07dc8f2037 feat(tui): Ctrl+P fuzzy file picker (#97)
Adds a modal overlay (`FilePickerView`) bound to Ctrl+P from the composer
when no other modal is open and the engine is not streaming.

* Single-pass `WalkBuilder` walk at construction (depth 6, hidden=true,
  follow_links=false, .gitignore honored) caches workspace-relative paths
  so per-keystroke filtering is fully in-memory.
* Custom subsequence scorer with start/boundary bonuses, consecutive-run
  reward, and gap penalty. ~70 lines, no new crate dependency.
* Up/Down + PgUp/PgDn navigate; Backspace and Ctrl+U edit the query;
  Enter emits `ViewEvent::FilePickerSelected` which the UI handler
  inserts at the composer cursor as `@<path>` (with surrounding spaces
  so the existing `@`-mention parser picks it up); Esc closes without
  modifying the composer.
* Ten unit tests cover the scorer (subsequence / boundary / case /
  empty-query edge cases) and the view (typing narrows, backspace
  widens, Enter emits, Esc closes, `.ignore` is honored).

Fixes #97

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:45:14 -05:00
Hunter Bown 6a13ce8c29 style(pager): single-line fmt for highlight Style chain 2026-04-26 17:40:42 -05:00
Hunter Bown e5954160fb Merge pull request #111 from Hmbown/feat/issue-89-key-hint-rendering
feat(tui): terminal-aware keybinding rendering (#89)
2026-04-26 17:39:57 -05:00
Hunter Bown 54aaba1876 Merge pull request #110 from Hmbown/feat/issue-90-quit-confirmation
feat(tui): two-tap Ctrl+C quit confirmation with 2s countdown
2026-04-26 17:39:54 -05:00
Hunter Bown 633379be0e Merge pull request #108 from Hmbown/feat/issue-91-external-editor
feat(tui): external editor support (Ctrl+E)
2026-04-26 17:39:52 -05:00
Hunter Bown 9dd4f65af2 Merge pull request #107 from Hmbown/feat/issue-88-footer-collapse
feat(tui): priority-ordered footer hint dropping for narrow terminals
2026-04-26 17:39:49 -05:00
Hunter BownandClaude Opus 4.7 44f3b2cae5 fix(engine): transparent retry on stream death with no content (#103 Phase 3)
When the chunked-transfer connection to DeepSeek dies mid-stream — the
"Stream read error: error decoding response body" symptom — the engine
previously surfaced the error to the user and ended the turn as Failed,
even when no useful content had been received. The user's only recourse
was to manually re-send the same message.

Phase 3 closes that loop. After the inner stream-consumption loop ends,
detect "stream died with nothing actionable":

  - stream_errors > 0 (the stream errored at some point)
  - tool_uses.is_empty() (no tool call landed)
  - current_text_visible is empty/whitespace
  - current_thinking is empty/whitespace
  - !pending_message_complete

If all hold AND stream_retry_attempts < MAX_STREAM_RETRIES (3), silently
re-issue the SAME outer-loop iteration: rebuilds the request from
self.session.messages, calls create_message_stream again, and starts a
fresh inner loop. Surface a "Connection interrupted; retrying (N/3)"
status to the user so they know something's happening, but don't trip
the engine-level Error event so we don't double-display the failure as
a History cell.

Healthy rounds (stream_errors == 0) reset the retry budget so a single
proxy hiccup doesn't poison subsequent rounds in the same turn.

Crucially: if we got partial output (any tool call, any visible text,
or any thinking), we DON'T retry. Re-running the request would
double-bill the user; ship the partial state to the rest of the turn
pipeline (existing tool execution, content_blocks finalization) and
let the agent loop continue.

Combined with #103 Phase 1+2 (TCP/HTTP2 keepalives + diagnostic logging
in client.rs), this should turn the user-visible "Turn failed: Stream
read error" into either a fully-recovered turn OR a clearly-labeled
3-attempts-exhausted failure.

Refs #103.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:37:48 -05:00
Hunter BownandClaude Opus 4.7 30ae78ee19 feat(pager): match highlighting + status counter no-clip + Esc clears matches (#96)
Polish pass on the existing pager search loop. The infrastructure was
already there (`/` opens search, type query, Enter commits, `n`/`N` cycle,
`match X/Y (n/N)` status row gets pushed) but had three rough edges that
made it less than the codex pager-overlay parity #96 asks for:

1. **Status row clipped on small popup heights.** `visible_height` was
   `popup_area.height - 2` (borders only). With `Padding::uniform(1)` on
   the block we actually have 4 rows of overhead, not 2 — so the status
   row got pushed past the viewport on shorter pagers and the user never
   saw match-count feedback. Subtract 4, then reserve another row for
   the status when matches exist.

2. **Matched lines weren't visually distinguished.** Searching jumped the
   scroll to the match but the line itself rendered the same as
   surrounding rows. Now the current match row gets a Yellow/Black
   bold background; other matches get a DarkGray/Yellow background.
   Per-substring highlighting (preserving the original spans' styling)
   is deferred — the all-row highlight is enough to navigate and avoids
   the substring-styling-vs-pre-styled-spans interaction that needs its
   own design pass.

3. **Esc in the search prompt left stale matches behind.** Pressing `/`
   then Esc to bail now ALSO clears `search_input` / `search_matches` /
   `search_index`, returning the pager to a clean un-highlighted view.
   Codex parity. To resume from where the user left off they re-`/` and
   re-type.

4 new tests (`search_finds_matches_and_renders_match_counter`,
`esc_in_search_mode_clears_matches`, `n_and_capital_n_cycle_matches_with_wrap`,
`matched_lines_get_highlight_background`). 22/22 pager tests pass.

Fixes #96.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:35:05 -05:00
Hunter BownandClaude Opus 4.7 83a1060460 feat(tui): terminal-aware keybinding rendering
Add `KeyBinding` widget at `crates/tui/src/tui/widgets/key_hint.rs` that
renders chord shortcuts in the host platform's notation: `⌥+X` on macOS
and `alt+X` on Linux/Windows. Constructors `plain`, `alt`, `shift`,
`ctrl`, `ctrl_alt` plus `KeyBinding::new` for arbitrary modifier sets.
Both a `Display` impl and `From<KeyBinding> for Span<'static>` are
provided so call sites can use the type in plain `format!` strings as
well as in ratatui line/span builders.

The Help view now renders its keybinding hints through `KeyBinding`
instead of hardcoded `Alt+`/`Shift+`/`Ctrl+` strings, so macOS users see
`⌥+↑` etc. matching every other Mac app.

Windows AltGr handling: `is_altgr()` flags `Ctrl+Alt` (the way
European-layout AltGr keypresses arrive on Windows) and
`has_ctrl_or_alt()` exposes a "real modifier" predicate that returns
`false` for AltGr. The composer's plain-char detector in `ui.rs` now
uses `has_ctrl_or_alt`, so AltGr-typed glyphs (`@`, `\`, `|`, …) on
European keyboards reach the input buffer instead of being swallowed as
modified shortcuts. Distinguishing left-vs-right Alt keys is not
portable across crossterm backends; `Ctrl+Alt+<char>` chords are
unbindable on Windows as a result, and that limitation is documented in
the module comment.

Tests: 10 unit tests cover plain/alt/shift/ctrl/ctrl_alt rendering,
modifier order, function keys, lowercase normalization, the `Span`
conversion, `is_press` semantics, and AltGr platform branching.

Fixes #89

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:33:56 -05:00
Hunter Bown ec3470fd07 feat(tui): two-tap Ctrl+C quit confirmation with 2s countdown
Add a codex-rs-style "Press Ctrl+C again to quit" prompt so a single
stray Ctrl+C in idle state no longer kills the session.

State is held on `App::quit_armed_until: Option<Instant>` and threaded
through three small helpers (`arm_quit`, `quit_is_armed`, `disarm_quit`,
`tick_quit_armed`). The redraw loop calls `tick_quit_armed` each pass
and caps `event::poll` at the deadline so the prompt expires on time
even when no input arrives.

Behavior:
- First Ctrl+C in idle state: arm a 2s window; footer shows
  "Press Ctrl+C again to quit" (warning color) overriding any active
  status toast.
- Second Ctrl+C inside the window: clean shutdown via `Op::Shutdown`.
- Ctrl+C 3 seconds later: re-arms instead of quitting.
- Ctrl+C while a turn is in flight (`is_loading`): unchanged — still
  cancels the turn, and explicitly disarms the quit prompt.

Tests cover the timer lifecycle: default-disarmed, arm-sets-2s-window,
disarm-clears-and-redraws, tick-no-op-within-window, tick-clears-after-
expiry, and re-arm-after-expiry-starts-fresh-window.

Fixes #90
2026-04-26 17:32:24 -05:00
Hunter Bown 697fb5de4d feat(tui): external editor support (Ctrl+E)
Bind Ctrl+E in the composer to suspend the TUI, spawn $VISUAL/$EDITOR
(fallback `vi`) on a temp file pre-populated with the composer's current
contents, then read the file back into the composer on save.

- New `crates/tui/src/tui/external_editor.rs`:
  - `spawn_editor_for_input` toggles raw mode + alt-screen + mouse +
    bracketed-paste before/after the edit and forces a `terminal.clear()`
    on return so a SIGWINCH during the edit doesn't leave a stale viewport.
  - `run_editor_raw` is the testable core: writes seed -> spawns editor ->
    reads back -> returns `Edited(new) | Unchanged | Cancelled`. Uses
    `tempfile::NamedTempFile` so the temp file is unlinked on every path
    (success, non-zero exit, missing binary, IO error).
- `tui/ui.rs`: split the existing `End | Ctrl+e` cursor-end keybinding
  so `End` still moves the cursor to the line end, and `Ctrl+E` now
  spawns the external editor. Status-line feedback: "Edited in <editor>",
  "Editor closed (no changes)", or "Editor cancelled".
- 7 new unit tests cover the resolver precedence (VISUAL > EDITOR > vi),
  the no-op / failure / missing-binary / edited paths, and an explicit
  temp-file-cleanup assertion via a captured editor argv.

Fixes #91
2026-04-26 17:29:05 -05:00
Hunter Bown bf2a1765fa feat(tui): priority-ordered footer hint dropping for narrow terminals
Rework `FooterWidget::status_line_spans` so the footer never wraps
mid-hint at any width. Hints now drop in priority order:

1. mode label (always visible; truncated only as a last resort)
2. model name (always visible alongside mode; truncated mid-word only
   after status has already been dropped)
3. status label ("working", "draft", "refreshing context", ...) — drops
   first when space is tight

Previously the model name would ellipsize the moment a long status
label crowded the line, even at 60–80 columns. The new tier system
keeps mode + model intact down to ~25 cols and only falls back to
mode-only on extreme narrow widths.

Includes snapshot-style tests at widths 40, 60, 80, 100, 120 covering
the full / drop-status / truncate-model / mode-only tiers.

Fixes #88
2026-04-26 17:28:36 -05:00
Hunter Bown ec98a64711 Merge pull request #106 from Hmbown/fix/issue-103-stream-resume
fix(client): TCP/HTTP2 keepalives + stream-error diagnostics (#103 Phase 1+2)
2026-04-26 17:24:05 -05:00
Hunter BownandClaude Opus 4.7 bbdfb26f3c fix(client): TCP/HTTP2 keepalives + stream-error diagnostics (#103 Phase 1+2)
Two fixes for the persistent "Stream read error: error decoding response
body" we saw mid-turn during long V4-pro thinking sessions.

1) HTTP transport tuning (`crates/tui/src/client.rs`):
   - Drop the blanket 300s request timeout. Long V4 thinking turns
     legitimately exceed the wall-clock window; per-chunk and per-stream
     guards in `engine.rs` already bound how long we wait without progress.
   - Add `tcp_keepalive(30s)` so dead-peer detection happens at the TCP
     layer instead of waiting for the application to notice.
   - Add `http2_keep_alive_interval(15s)` + `http2_keep_alive_timeout(20s)`
     so HTTP/2 connections to DeepSeek's edge don't go silent and get
     killed by an upstream proxy mid-thinking.

2) Stream-error diagnostics (`crates/tui/src/client/chat.rs`):
   - Walk reqwest's `std::error::Error::source()` chain when a chunk read
     errors, so the underlying hyper / h2 / io error is logged. Without
     this the outer "error decoding response body" message tells us
     nothing about WHY the stream died.
   - Track elapsed wall time, bytes received so far, and ms since the
     last successful event; log them alongside the error chain. Lets us
     tell HTTP/2 RST_STREAM mid-idle from chunk-decode-failure on a
     short stream from gzip-corruption mid-burst.

Phase 3 (transparent retry with `prefix` continuation) is intentionally
NOT in this PR. The retry-flag plumbing on MessageRequest + chat.rs prefix
wire format + engine.rs retry loop is a meaningful surface that deserves
its own review pass; this PR ships the diagnostic-and-resilience floor so
we can land the harder retry work knowing the underlying network state is
better.

Refs #103.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:19:42 -05:00
Hunter Bown 97c89745fa Merge pull request #105 from Hmbown/fix/issue-101-path-routing
fix: @-mention path routing (#101)
2026-04-26 17:16:55 -05:00
Hunter Bown 98ba62915a Merge pull request #104 from Hmbown/fix/issue-100-rlm-query
fix(rlm_query): Phase 1+2 - Add diagnostic logs and fallback for empty responses
2026-04-26 17:16:52 -05:00
Hunter BownandClaude Opus 4.7 6beb2099e4 style(working_set): collapse nested if-let in expand_mention_home
Parity CI's `cargo clippy ... -D warnings` rejects the nested `if let`
pattern in `expand_mention_home` under the new clippy::collapsible_if
lint (rust-clippy 1.95). Use the chained `if let ... && let ...` form
the lint suggests. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:12:38 -05:00
Hunter BownandClaude Opus 4.7 adb60ba6b0 perf+fix(file_mention): address Gemini and Devin code-review findings
Five issues from the PR #105 review pass:

1. Lazy file-tree index for fuzzy_resolve (HIGH, Gemini)
   The previous `fuzzy_resolve` walked the workspace up to depth 6 on every
   miss. A user typing several non-existent paths in one message could
   trigger multiple disk-intensive walks. Replace with a `OnceLock`-backed
   basename → paths index, built once on first miss and reused thereafter.

2. Cache cwd at construction (MEDIUM, Gemini)
   `std::env::current_dir()` was a syscall on every `resolve` call (up to
   8 mentions per message). Capture once in `Workspace::new()` and store
   in the struct.

3. Include directories in fuzzy match (MEDIUM, Gemini)
   The mention system supports directory listings, but fuzzy_resolve was
   restricted to `is_file()`. Allow directories too.

4. Drop `Path::canonicalize` from the mention loop (MEDIUM, Gemini)
   `Workspace::resolve` already returns absolute paths when the workspace
   root is absolute (always true in TUI use). Removed the per-mention
   `canonicalize` syscall on the message-send hot path. The rare
   symlink-aliasing dedup miss is an acceptable cost.

5. Gate <missing-file> blocks through dedup set (Devin)
   The `Err` arm in `local_context_from_file_mentions` pushed a
   `<missing-file>` block before reaching the `seen.insert` check, so the
   same non-existent mention typed twice produced duplicate blocks and
   wasted prompt tokens. Restructured the loop so all blocks (existing
   AND missing) flow through the dedup gate.

Bonus: replaced the test that was mutating the global cwd (race-prone
across tests) with one that constructs `Workspace::with_cwd` explicitly.
Added a second test exercising the lazy-index + directory-match paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 17:08:33 -05:00
Hunter BownandClaude Opus 4.7 d7b202d996 style(rlm_query): cargo fmt fixes for parity CI
CI's `cargo fmt --all -- --check` rejected two long expressions in
`crates/tui/src/tools/rlm_query.rs` (the per-child structured-log match
arm, and the `Ok(Ok(response))` extract_text invocation). Reformatted to
match rustfmt's preferred multi-line block style. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 16:58:14 -05:00
Hunter Bown 08706f77d6 fix(file_mention): two-pass resolver for @-mentions (#101)
- Moved resolution logic to  in .
- Two-pass resolution: workspace, then cwd, then fuzzy fallback.
-  now uses cwd if different from workspace.
2026-04-26 16:52:43 -05:00
Hunter Bown 96c979f160 fix(rlm_query): Phase 1+2 - Add diagnostic logs and fallback for empty responses 2026-04-26 16:48:00 -05:00
Hunter Bown c1259eff68 merge: don't enter offline mode on recoverable engine errors (closes #86) 2026-04-26 16:30:21 -05:00
Hunter BownandClaude Opus 4.7 a8fe5298a2 fix(tui): don't enter offline mode on recoverable engine errors
The TUI's `EngineEvent::Error` handler in `ui.rs` matched on `{ message, .. }`
and unconditionally set `app.offline_mode = true`. This meant any transient
stream-disconnect (e.g., the chunked-transfer connection getting closed during
a long V4 thinking turn with no SSE keepalive) flipped the session offline,
queued the user's next message, and forced them to recover manually — even
though the engine had already classified the error as recoverable.

The engine has been emitting `Event::error(message, recoverable)` with the
correct boolean since the error-taxonomy work in #66. Stream stalls
(engine.rs:2286), max-duration aborts (:2322), max-bytes aborts (:2334), and
upstream stream errors (:2344) all set `recoverable = true`. Hard failures
like sub-agent spawn failures (:1202) and post-recovery context overflows
(:1378, :1559) set `recoverable = false`. The UI just wasn't reading it.

Pull the body out into a `pub(crate) fn apply_engine_error_to_app` helper so
the branch logic is unit-testable from `ui/tests.rs`, then split:

- `recoverable = true`  → status: "Connection interrupted: …"; stay online.
- `recoverable = false` → status: "Engine error; queued messages stay
                          pending: …"; flip into offline mode.

Add two regression tests covering both branches.

Fixes #86

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 16:30:18 -05:00
Hunter BownandClaude Opus 4.7 1f00ac6311 chore(release): drop auto npm publish, document manual flow, trim research PDF
- Remove the `publish-npm` job from `release.yml`. It has been failing on
  every release with `npm error code EOTP` because the configured `NPM_TOKEN`
  doesn't bypass 2FA. Manual publish from a developer machine is the actual
  ship path; codify that.
- Update `docs/RELEASE_RUNBOOK.md` "npm Wrapper Release" to describe the
  manual flow (`npm publish --access public` + OTP) and explain why the auto
  path is gone, with a recovery note for future Trusted-Publishing migration.
- Refresh stale cross-reference comment in `publish-npm.yml` (the workflow
  remains as inert plumbing for an eventual Trusted Publishing setup).
- Stop tracking `docs/DeepSeek_V4.pdf` (4.4 MB). It was never referenced
  outside test fixture filenames; the tests synthesize their own fake PDF.
  Add to `.gitignore` so a local copy can sit there without nagging.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 16:26:10 -05:00
Hunter Bown 9758e26349 fix(docs): escape <no body> in mcp doc-comment so rustdoc accepts it
CI Documentation job was failing with `error: unclosed HTML tag 'no'` at
crates/tui/src/mcp.rs:71. Rustdoc parses unescaped `<no body>` as an HTML
tag and `RUSTDOCFLAGS=-Dwarnings` (used in CI) promotes that to a hard
error. Reword the comment to refer to the literal string instead.
2026-04-26 15:56:01 -05:00
Hunter Bown fa99fb5124 fix(engine): 256K output budget + capacity controller off by default
User feedback after v0.6.2 dogfooding: "we'd be better off simplifying and
removing guardrails." Two changes that meaningfully shrink the surface:

1. TURN_MAX_OUTPUT_TOKENS: 32_768 → 262_144 (256K).
   V4 thinking models can produce tens of thousands of reasoning tokens
   on hard prompts before the visible reply, and DeepSeek V4 has a 1M
   context window. 32K was tight for that workload (showed up as the
   model "stopping mid-response" once reasoning exhausted the budget).
   256K is generous enough that the per-turn ceiling effectively never
   bites in normal use.

2. CapacityControllerConfig::enabled: true → false.
   The controller's main intervention, `TargetedContextRefresh`, runs
   `compact_messages_safe` which rewrites the live conversation —
   visually identical to the agent "restarting" mid-turn. The failure
   mode it protects against (context overflow) is rare in practice and
   self-correcting (the model surfaces a clear error). Power users on
   V4 do not need the guardrail; users who do can re-enable it via
   `capacity.enabled = true` in `~/.deepseek/config.toml`.

Tests:
- context_budget_reserves_output_and_headroom: switched fixture model
  to deepseek-v4-pro (1M context) so the 256K reservation doesn't
  saturate the budget to zero.
- cooldown_blocks_repeated_action: explicitly enables the controller
  (the cooldown logic short-circuits when disabled).

cargo clippy --workspace -- -D warnings clean; full test suite green
(990 + adjacent crate tests).
2026-04-26 15:51:58 -05:00
Hunter Bown 6ab2fcc21f fix(tui): rustfmt parity + working-strip stays visible all turn
Two fixes folded into one commit (the parity failure was blocking the
v0.6.2 npm publish, the strip fix is the dogfooding follow-up):

1. cargo fmt --all: subagent/mod.rs (long timeout wrapper) was over the
   line-length budget when committed earlier; rustfmt rewraps it. CI
   parity (`cargo fmt -- --check`) was failing the release pipeline.

2. footer working-strip stays visible for the entire turn: previously
   the strip only animated while `is_loading || is_compacting ||
   running_agents > 0`. Between LLM rounds inside a single turn (tool
   execution, reasoning replay, capacity refresh) `is_loading` flickers
   off — and so the user saw the strip vanish for seconds at a time
   even though the agent was clearly still working. Widen the gate to
   ALSO include `runtime_turn_status == Some("in_progress")`, which
   only clears when `EngineEvent::TurnComplete` fires — so the strip
   now stays lit for the whole turn duration.
2026-04-26 15:45:13 -05:00
Hunter Bown 0d92eb847b fix(engine): raise output-token + step ceilings (mid-response cutoff)
User repro: V4 thinking on hard prompts (~107s of thinking) randomly
"stops mid-response", more often when starting in Agent mode and
switching to YOLO. Two ceilings were too tight:

1. TURN_MAX_OUTPUT_TOKENS: 4096 → 32768
   `reasoning_content` from V4 thinking can easily exceed 4K tokens on
   hard problems. Once the per-turn output budget exhausts, the API
   closes the SSE stream with `finish_reason: "length"` and the visible
   reply ends up empty — surfaced as the assistant "stopping randomly".
   32K leaves comfortable headroom for thinking + the visible reply on
   every realistic turn while staying well below DeepSeek V4's
   1M-context output ceiling.

2. max_steps: 100 → u32::MAX (effectively unlimited)
   100 was hitting the ceiling on long multi-step plans (wide refactors,
   sub-agent orchestration) and presenting as the agent "giving up
   mid-task". V4's 1M context window means there's no good reason to
   cap steps administratively. Users can still interrupt with Ctrl+C /
   Esc; a turn naturally ends when the model stops emitting tool calls.

All 54 turn tests pass; full workspace clippy + fmt remain clean.
2026-04-26 15:38:59 -05:00
Hunter Bown d98cc58028 fix(tui): sidebar padding + capacity controller tuning
Two tuning fixes for issues observed in v0.6.2 dogfooding:

#63 follow-up — sidebar panels still empty in compact terminals:
  `section_padding: Padding::uniform(1)` ate two rows of every sidebar
  panel (one above content, one below). At the 25% layout split, in
  terminals around 12-15 rows tall, Plan/Todos/Tasks each get only
  3 rows total — borders take 2, vertical padding takes 2, leaving
  -1 (saturated to 0) rows for the actual content. Even "No todos" /
  "No active plan" got eaten. Switched to horizontal-only padding so
  the inner row survives.

Capacity-controller tuning (user feedback: "refreshing context is overtuned"):
  `apply_targeted_context_refresh` runs `compact_messages_safe` which
  rewrites the conversation history — visually identical to the agent
  "restarting" mid-session. The previous defaults
  (low_risk_max=0.34, refresh_cooldown_turns=2, min_turns=2)
  fired this every couple of turns once p_fail crept above 0.34.
  Bumped:
    - low_risk_max:               0.34 → 0.50
    - refresh_cooldown_turns:     2 → 6
    - min_turns_before_guardrail: 2 → 4
  Still well below the medium-risk ceiling (0.62), so genuine drift
  still triggers; routine noise no longer does.

All 14 capacity tests + workspace clippy + fmt remain clean.
2026-04-26 15:27:22 -05:00
Hunter Bown aa8d0dc73a merge: per-cell transcript line cache + revisions (closes #78)
Resolves conflicts with the #65 resize fix that landed first. Both branches
converged on the same resize-coalescing + display-width truncation fix;
took the perf branch's more detailed inline comments and combined the
transcript bench from #78 with the existing #65 resize regression tests.

Issue #78 baseline (release, 5000-cell synthetic transcript):
  pure scroll, off=0    3549µs → 21µs   (~150x)
  pure scroll, off=2000 3303µs → 19µs   (~170x)
  streaming append      11.6ms → 3.4ms  (~3.4x)
2026-04-26 14:53:04 -05:00
Hunter Bown eee5081ef7 merge: clean resize redraw + display-width truncation (closes #65) 2026-04-26 14:49:42 -05:00
Hunter BownandClaude Opus 4.7 ab70c40beb perf(tui): cache wrapped transcript lines per-cell (closes #78)
Scrolling far back through a long transcript stalled the entire UI: every
keypress paid the cost of re-wrapping every history cell from index 0 on
every frame. Two bugs combined to defeat the existing per-cell cache:

1. **Uniform cache keys** — `widgets/mod.rs` synthesized
   `cell_revisions = vec![app.history_version; len]`, so a single mutation
   anywhere bumped every cell's revision and busted the entire cache.
2. **Vec-deep-clone on cache hit** — `CachedCell.lines: Vec<Line>` deep-cloned
   on every `prev.clone()` inside `ensure`, so even a fully-cached frame paid
   O(total_lines) per render.

Fix mirrors Codex's chatwidget pattern: track per-cell revisions in
`App.history_revisions`, bump only the cell whose content actually
changed, and store cached lines behind `Arc<Vec<Line>>` so a cache-hit
clone is O(1). The cache reuse path is unchanged; what changed is the
keying.

Touchpoints:
* `App::history_revisions` + `next_history_revision` counter, kept in
  lockstep with `history` via `add_message` / `extend_history` /
  `push_history_cell` / `clear_history` / `pop_history` /
  `bump_history_cell` helpers.
* `cell_at_virtual_index_mut` and the `append_streaming_text` path now
  bump only the targeted cell's revision instead of fanning the global
  `history_version` across the whole transcript.
* `TranscriptViewCache::ensure_split` accepts cell shards directly so the
  caller no longer concatenates history + active-cell entries into a
  fresh `Vec<HistoryCell>` every frame.
* `mark_history_updated` resyncs `history_revisions.len()` to
  `history.len()`, preserving correctness for direct callers that bulk
  mutate via `clear`/`extend`.

Bench (release, 5000-cell synthetic transcript, 100×30 area):

| scenario             | before  | after  |
|----------------------|--------:|-------:|
| pure scroll, off=0   | 3549 µs |  23 µs |
| pure scroll, off=100 | 3338 µs |  23 µs |
| pure scroll, off=500 | 3306 µs |  20 µs |
| pure scroll, off=2k  | 3303 µs |  20 µs |
| streaming, off=0     | 11.6 ms | 3.4 ms |
| streaming, off=2k    | 11.6 ms | 3.3 ms |

Pure-scroll renders are now ~150× faster and constant-time vs scroll
offset; streaming cost is ~3.5× lower (the remaining cost is the
per-frame flatten which always rebuilds the line buffer when the cell
count changes — orthogonal follow-up).

Bench is `#[ignore]`'d:
`cargo test -p deepseek-tui --release bench_transcript_scroll -- --ignored --nocapture`

All existing transcript and scroll tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:47:17 -05:00
Hunter BownandClaude Opus 4.7 033fef6cb2 fix(tui): force clean redraw on resize / bound sidebar labels (closes #65)
After v0.6.1's light-theme removal exposed it more visibly, rapid resizes
left stale glyphs in the right column (sidebar fragments, mid-character
title truncation, duplicated transcript timestamps). Three small fixes:

- Coalesce queued `Event::Resize` events, run a single `terminal.clear()`,
  and immediately draw the new frame instead of waiting for the next event
  loop iteration. Previously the cleared screen could sit blank between
  the resize handler's `continue` and the next draw, so any other event
  arriving in that window would be processed before the repaint.
- `truncate_line_to_width` for budgets `<= 3` was counting codepoints
  instead of display widths, overrunning the cell budget for any
  double-width grapheme. Fix by accumulating display widths consistently.
- Add a `tracing::debug!` log to the resize handler so users hitting this
  in the wild can confirm whether crossterm is delivering the event.

Adds two regression tests in `tui/widgets` (resize cycle + cache
invalidation on width change) and one in `tui/ui` (truncate semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:42:42 -05:00
Hunter Bown 6d06595b76 merge: animated working strip (closes #61) 2026-04-26 14:22:37 -05:00
Hunter Bown 70ce26e196 merge: rlm_query parallelism verification + per-child UI (closes #60) 2026-04-26 14:22:32 -05:00
Hunter Bown 2b7800885e merge: 'deepseek metrics' CLI (closes #70) 2026-04-26 14:22:27 -05:00
Hunter BownandClaude Opus 4.7 49673d2ea3 feat(rlm_query): verify parallel fan-out + per-child prompt rendering (closes #60)
Introduce `RlmChildClient` — a dyn-compatible `#[async_trait]` wrapper around
the single create_message operation — so tests can inject a `MockRlmClient`
without a live API key. This replaces the direct `Arc<DeepSeekClient>` field
with `Arc<dyn RlmChildClient>`, wired transparently via `RlmQueryTool::new`.

Concurrency regression test (`rlm_parallel_fanout_overlaps_not_serialized`):
fires N=4 children each sleeping 50 ms through `join_all`. Asserts total
elapsed < 4×50 ms (serial bound) and that all start timestamps cluster within
<50 ms of each other. First run: total_elapsed=54 ms, start_spread=141 µs —
fan-out was already correct; no serialization fix needed.

UI wiring tests (`rlm_query_tool_cell_wired_with_prompts_on_start` etc.) verify
that `handle_tool_call_started` with `rlm_query` populates `GenericToolCell.prompts`
from the `prompts` (array) and `prompt` (singular) input shapes, and that
non-fan-out tools leave `prompts: None`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:21:43 -05:00
Hunter BownandClaude Opus 4.7 9804c92c21 feat(cli): add 'deepseek metrics' command (closes #70)
Implement `deepseek metrics` as a dispatcher-handled subcommand (no TUI
binary roundtrip) that reads ~/.deepseek/audit.log, session JSON files,
and tasks runtime JSONL event streams, then prints a human-readable
usage rollup aggregated by tool name, compaction events, sub-agent
spawns, and capacity-controller interventions.

Flags: --json (machine-readable) and --since DURATION (e.g. 7d, 24h,
30m, now-2h, 2h30m). Empty/missing audit log exits 0 with an empty
rollup; malformed lines are skipped silently via tracing::trace!.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:17:58 -05:00
Hunter BownandClaude Opus 4.7 7d0450f541 feat(tui): animated water-spout working strip in the footer (closes #61)
Replace the single-spout bounce animation with two independent `╭───╮`
arcs sweeping at different speeds across a calm `─` water surface. Add
`footer_working_label` to pulse `working` → `working...` at 400 ms
cadence while a turn is live. The dot-pulse fires even in low-motion
mode; the arc strip is gated behind `!app.low_motion`. Frame math is
purely deterministic so the test suite can pin specific frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:17:17 -05:00
Hunter BownandClaude Opus 4.7 9bc8eee927 feat(tui): animated water-spout working strip in the footer (closes #61)
Replace the single-spout bounce animation with two independent `╭───╮`
arcs sweeping at different speeds across a calm `─` water surface. Add
`footer_working_label` to pulse `working` → `working...` at 400 ms
cadence while a turn is live. The dot-pulse fires even in low-motion
mode; the arc strip is gated behind `!app.low_motion`. Frame math is
purely deterministic so the test suite can pin specific frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 14:17:02 -05:00
Hunter Bown ebc70176ad feat(tui): bracketed-paste config toggle + capacity hot-path bench
Closes #77, refs #75.

#77 — bracketed paste was unconditionally enabled at terminal init. Add a
\`bracketed_paste\` field to Settings (default true) and propagate it through
TuiOptions → App → run_tui / pause_terminal / resume_terminal so users on
the rare terminal that mishandles \`\e[?2004h\` can disable it via
\`/set bracketed_paste off\` or \`bracketed_paste = false\` in
\`~/.config/deepseek/settings.toml\`. Modern terminals continue to work as
before. All TuiOptions construction sites updated in one pass.

#75 — added an ignored-test microbench for \`compute_profile\` in
\`crates/tui/src/core/capacity.rs\`. Run with:
  cargo test -p deepseek-tui --release bench_compute_profile -- --ignored --nocapture

Baseline (release, M1):
  window=  16  per-call=  48ns
  window=  64  per-call= 126ns
  window= 256  per-call= 385ns
  window=1024  per-call=1438ns

Sub-µs at typical window sizes — no optimization shipped, bench locks in
the regression contract. No new dev-deps (uses std::time::Instant +
black_box, gated as #[ignore]).
2026-04-26 14:10:50 -05:00
Hunter Bown 432082e956 docs(agents): document deepseek as the canonical CLI binary
The user-facing entry point for every flow is the `deepseek` dispatcher
(crates/cli), not `deepseek-tui`. Future agent sessions and example
commands should default to `deepseek` / `cargo run --bin deepseek`.
Mirror the same directive in the local CLAUDE.md (gitignored).
2026-04-26 14:01:30 -05:00
Hunter Bown ac1332565c release: v0.6.2
Highlights:
- fix(client): SSE idle-timeout so a stalled stream surfaces a clear error
  instead of hanging the active cell (#76)
- fix(tui): sidebar Agents panel reads live engine progress, not just the
  cached snapshot — matches the footer chip in real time (#63)
- fix(tui): generic tool result preview preserves newlines for diff stats
  / file lists / todo snapshots (#80)
- fix(tui): slash-menu scroll viewport now exercises center-tracking past
  the first 6 entries (#64)
- feat(mcp): connect-failure errors include URL, status, body excerpt,
  transport — credentials masked (#71)
- feat(tools): mark alias tools (spawn_agent, close_agent, send_input,
  delegate_to_agent) with _deprecation metadata; removal slated 0.8.0 (#72)
- feat(capacity): V4 model priors (deepseek-v4-pro/flash) + key
  normalization, plus DEEPSEEK_CAPACITY_PRIOR_V4_* env overrides (#73)
- feat(tools): explain parallel fan-out caps in agent_spawn vs rlm_query
  descriptions and error messages — cost-class table in TOOL_SURFACE.md (#81)
- chore(errors): partial wiring of the error taxonomy — classify_error_message
  helper used in capacity controller, audit log fields pending (#66)
- chore(providers): scaffold OpenRouter and Novita variants end-to-end
  (env keys, default base URLs, model normalization). Modal /provider
  picker UI still pending (#52)

Build hygiene:
- cargo fmt clean, cargo clippy --workspace -- -D warnings clean
- cargo test --workspace passes (979+ tests across crates)
- pre-existing dead-code warnings gated per-item with TODO refs to #61/#66
EOF
)
2026-04-26 13:56:40 -05:00
Hunter Bown 3375fc7285 merge: explain parallel fan-out caps (fixes #81 — was PR #82) 2026-04-26 13:55:21 -05:00
Hunter Bown 1107b723b1 chore: simplify pass + clippy clean for v0.6.2
Cleanup pass after the issue fixes (#64, #71, #80, #63):

Simplifications:
- sidebar.rs: extract `push_agent_row` closure to remove the duplicated
  two-line agent rendering (cached + progress-only paths used the same
  shape with different summary text).
- engine.rs: replace `error_categories.iter().any(|c| c == X)` with
  `.contains(&X)` (clippy::manual_contains).
- widgets/mod.rs: replace `for idx in menu_top..menu_bottom` index loop
  with `.iter().enumerate().take(menu_bottom).skip(menu_top)`
  (clippy::needless_range_loop).

Build hygiene (CI runs `cargo clippy ... -- -D warnings`):
- error_taxonomy.rs: per-item `#[allow(dead_code)]` on `ErrorSeverity`,
  `ErrorEnvelope`, and `ErrorEnvelope::new` with TODO notes referencing
  #66. Keeps deepseek's removal of the file-wide allow but stops the
  scaffold from breaking the build until #66 follows up.
- app.rs: per-field `#[allow(dead_code)]` on `fancy_animations` (pending
  #61 footer animation consumer).
- config/lib.rs: complete the OpenRouter/Novita variant scaffolding so
  `match ProviderKind { ... }` is exhaustive — add api_key/base_url env
  loading (`OPENROUTER_API_KEY`, `NOVITA_API_KEY`, optional `*_BASE_URL`
  overrides), wire `api_key_for` / `base_url_for` arms with the documented
  defaults, and extend `normalize_model_for_provider` so generic V4 model
  names map to each provider's catalog ID. Full /provider picker UI still
  pending #52.

Verified: cargo fmt clean, cargo clippy --workspace --all-targets
--all-features --locked -- -D warnings clean, full test suite passes
(979 + adjacent crate tests).
2026-04-26 13:54:54 -05:00
Hunter Bown 124011a862 fix(tui): sidebar Agents panel reads live progress, not just cache (closes #63)
Repro: spawn 5 sub-agents. The footer chip correctly shows "5 agents" because
running_agent_count() unions app.agent_progress (live engine events) with
app.subagent_cache (settled snapshot from Op::ListSubAgents). The sidebar's
Agents panel only read app.subagent_cache and so showed "No agents" while
the footer said 5 — same data flow bug the user screenshotted in #63.

Mirror the footer's union here:

- Live progress-only IDs (in agent_progress, not yet in subagent_cache) get a
  one-line "starting" row with the latest progress message — surfaces the
  freshest signal first.
- Cached entries get the full status row (steps taken, role, objective).
- Header shows "{live_running} running / {total}" with both counts unified.

The Agents panel now stays in sync with the footer chip and never lies
about whether agents are in flight. Todos panel was already wired correctly
to app.todos (the SharedTodoList lock); only the agents path was racing.

Refs #63
2026-04-26 13:48:28 -05:00
Hunter Bown f342d6508e fix(tui): preserve newlines in generic tool result preview (closes #80)
Before, GenericToolCell rendered its `output` through `render_compact_kv`, which
treated the entire string as one logical line and let the wrapper handle
overflow. Multi-line output (git diff --stat, todo snapshots, file lists)
ended up squashed into a single hard-wrapped blob — the screenshot in the
issue showed "Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs"
all on one row.

Switch the result rendering to `render_tool_output_mode` (already used by
ExecCell) which:

- splits on `\n` first, then wraps each line independently;
- caps live view at TOOL_OUTPUT_LINE_LIMIT (= 6) rows with a "+N more lines;
  press v for details" affordance;
- emits the full body in transcript view.

Threaded `RenderMode` through `ToolCell::Generic(...)` dispatch and renamed
`GenericToolCell::lines_with_motion` → `lines_with_mode(mode)` (sole caller).

Tests:
- `generic_tool_cell_preserves_multi_line_output_in_transcript` asserts each
  diff-stat file lands on its own row.
- `generic_tool_cell_caps_multi_line_output_in_live_with_affordance` pins the
  live cap + affordance + transcript-includes-everything contract.

Fixes #80
2026-04-26 13:44:51 -05:00
Hunter Bown ec92e535e8 feat(mcp): surface URL, status, body excerpt, transport on connect failure
Before: a failed MCP server connection just said "Failed to connect to SSE: 401" or
"Failed to spawn MCP server 'foo'" — devs had to enable RUST_LOG=debug to see
what actually went wrong.

Now:
- SSE failures show "MCP SSE rejected (transport=http url=... status=401):
  <body excerpt up to 200 bytes>", with userinfo + bearer tokens + api_key
  query params masked.
- stdio spawn failures show "MCP stdio spawn failed (transport=stdio
  server=foo cmd="..." args=[...] env_keys=[...])" — env values stay private,
  only keys leak.

Helpers `mask_url_secrets`, `redact_body_preview`, `bounded_body_excerpt` are
covered by 4 unit tests.

Fixes #71
2026-04-26 13:40:07 -05:00
Hunter Bown 86f59cd2c2 merge: slash-menu scroll viewport fix (fixes #64) 2026-04-26 13:37:30 -05:00
Hunter Bown 320325e419 fix(tui): bump SLASH_MENU_LIMIT to 128 so the scroll viewport works
The composer's render path already paginates with center-tracking, but the
source list was hard-capped at 6 entries — so pressing Down arrow past
index 5 had no entries to land on. Repro: with ~37 slash commands, hitting
Down repeatedly stuck at the last visible row.

Bumping the source cap to 128 lets the existing viewport scroll logic
exercise the full filtered command list. No render-path change needed.

Fixes #64
2026-04-26 13:37:29 -05:00
Hunter Bown feb3cf1e0c feat: explain parallel fan-out caps in tool descriptions and error messages (fixes #81) 2026-04-26 13:16:12 -05:00
Hunter Bown 38069700cc chore: wip capacity canonical state + tool alias deprecation 2026-04-26 13:11:57 -05:00
Hunter Bown 2adbe398ba merge: tool alias deprecation metadata (fixes #72) 2026-04-26 12:55:17 -05:00
Hunter Bown 4f18809d74 merge: V4 capacity priors (fixes #73) 2026-04-26 12:53:31 -05:00
Hunter BownandClaude Opus 4.7 c58d10ded1 feat(tools): mark alias tools with deprecation metadata
Add `wrap_with_deprecation_notice` helper in the subagent module that
merges a `_deprecation` block into a ToolResult's metadata. Applied
exclusively on alias invocations:

- `spawn_agent` → use `agent_spawn` (removed in v0.8.0)
- `delegate_to_agent` → use `agent_spawn` (removed in v0.8.0)
- `close_agent` → use `agent_cancel` (removed in v0.8.0)
- `send_input` → use `agent_send_input` (removed in v0.8.0)

Canonical names are unaffected. Each alias invocation also emits a
`tracing::warn` so the deprecation appears in audit logs. Documents
the deprecation schedule in `docs/TOOL_SURFACE.md`. Four unit tests
verify the notice shape and that canonical tools stay clean.

Refs #72

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 12:32:26 -05:00
Hunter BownandClaude Opus 4.7 cf9fdef9d8 fix(capacity): add V4 model priors and key normalization
Add deepseek_v4_pro (3.5) and deepseek_v4_flash (4.2) priors to
CapacityControllerConfig::default() so V4 models are no longer silently
mapped to the generic 3.8 fallback.

Extend normalize_model_prior_key to match v4-pro, v4_pro, v4-flash,
v4_flash, and deepseek-ai/-prefixed NIM identifiers before the V3/
reasoner branches to prevent cross-matches. V3 and reasoner fallbacks
are unchanged.

Add deepseek_v4_pro_prior / deepseek_v4_flash_prior fields to
CapacityConfig (config.toml) and DEEPSEEK_CAPACITY_PRIOR_V4_PRO /
DEEPSEEK_CAPACITY_PRIOR_V4_FLASH env-var overrides, matching the
existing V3 pattern.

Refs #73

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 12:28:21 -05:00
Hunter BownandClaude Opus 4.7 e9970fcad3 ci: switch npm publish to NPM_TOKEN + add auto-tag workflow
The OIDC Trusted Publisher path for npm has 404'd on PUT for v0.5.1,
v0.5.2, and v0.6.1, even with valid OIDC tokens. Switch publish-npm and
publish-npm-manual to a classic NPM_TOKEN automation token (set the
NPM_TOKEN repo secret to a granular access token scoped to deepseek-tui
with publish permission) so future releases ship reliably.

Also add .github/workflows/auto-tag.yml: when the workspace version on
main changes, push the matching v$VERSION tag automatically so release.yml
fires without a manual tag push. Requires a RELEASE_TAG_PAT secret to
trigger downstream workflows (GITHUB_TOKEN tag pushes don't trigger
on: push: tags by design).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 12:22:15 -05:00
Hunter Bown e1ac84ae44 release: v0.6.1 — pricing update, remove light theme + theme setting
- V4 cache-hit input prices cut to 1/10th per DeepSeek pricing update:
  Pro promo 0.03625→0.003625, Pro base 0.145→0.0145, Flash 0.028→0.0028
- Remove the 'light' theme variant (Variant::Light, Theme::light(), test)
- Remove the theme setting entirely — hardcode UI_THEME to whale/dark,
  drop the theme field from Settings, ConfigView, and config command
- Bump workspace version 0.6.0 → 0.6.1 (Cargo.toml, npm pkg, CHANGELOG)
- De-cringe the README: drop emojis, marketing fluff, unverified claims
2026-04-26 11:56:41 -05:00
Hunter Bown c5a584d5c3 refactor(client): extract chat + responses into folder module (P1.1)
Split client.rs into client/mod.rs (public API + helpers), client/chat.rs
(chat-completions streaming), and client/responses.rs (responses API
helpers).  Internal helpers promoted to pub(super) for intra-module
visibility; the public DeepSeekClient API is unchanged.

While here, redesign all five system prompts around decomposition-first
philosophy inspired by the mismanaged-geniuses hypothesis (Zhang et al.,
2026).  The model is now instructed to todo_write / update_plan before
acting, fan out sub-agents for parallel work, and keep the sidebar
populated so the user always sees what's happening.  Mode prompts updated:
- agent.txt: 'Before requesting approval, lay out work with todo_write'
- plan.txt: 'Use update_plan for strategy, todo_write for tactics'
- yolo.txt:  'Even with auto-approval, create a todo_write first'
- normal.txt: same pattern for legacy compatibility

Update CHANGELOG [Unreleased] and README modes section accordingly.
2026-04-26 11:39:44 -05:00
Hunter BownandClaude Opus 4.7 1a100fe96c refactor(core): carve approval + dispatch helpers out of engine.rs (P1.3)
Splits `core/engine.rs` (4670 → 4314 lines) into a small folder module:

- `engine/approval.rs` (~125 lines) — `ApprovalDecision`,
  `UserInputDecision`, `ApprovalResult`, plus the two handshake
  methods `Engine::await_tool_approval` and `Engine::await_user_input`.
- `engine/dispatch.rs` (~300 lines) — tool-input parsing
  (`final_tool_input`, `parse_tool_input`, fenced/JSON segment
  helpers), `multi_tool_use.parallel` payload parser, dispatch policy
  predicates (`should_parallelize_tool_batch`,
  `should_force_update_plan_first`, `should_stop_after_plan_tool`,
  the read-only MCP tool helpers), and the
  `ToolExecutionPlan`/`ToolExecOutcome`/`ParallelToolResult*`/
  `ToolExecGuard` types the batch driver passes around.

The public engine surface (`EngineConfig`, `EngineHandle`,
`spawn_engine`, `MockEngineHandle`, `mock_engine_handle`,
`compact_tool_result_for_context`, `TOOL_CALL_*_MARKERS`,
`FAKE_WRAPPER_NOTICE`) stays in `engine.rs` — every external user
imports unchanged.

Not split this round: the 1268-line `handle_deepseek_turn` method.
Carving its inline parallel/sequential dispatch and approval handshake
arms requires extracting two new methods from a borrow-heavy turn
loop; flagged in the v0.6.0 audit doc as future work.

Workspace tests: 1011/1011 still green. No clippy regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:57:27 -05:00
Hunter BownandClaude Opus 4.7 25cfe11736 refactor(tui): extract slash-menu helpers into tui/slash_menu.rs (P1.2)
Lifts `visible_slash_menu_entries`, `apply_slash_menu_selection`, and
`try_autocomplete_slash_command` from `tui/ui.rs` into a sibling
module. Drops the now-unused `slash_completion_hints` import from
`ui.rs` (the new module imports it directly).

Kept separate from `tui::file_mention` per the audit doc — the two
popups have distinct trigger characters, ranking, and post-selection
behaviour even though they share UI scaffolding.

`ui.rs`: ~5070 → ~4990 lines.
Workspace tests: 1011/1011 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:47:44 -05:00
Hunter BownandClaude Opus 4.7 56308bb5d7 refactor(tui): extract paste-burst handlers into tui/paste.rs (P1.2)
Lifts `handle_paste_burst_key`, `handle_paste_burst_decision`,
`apply_paste_burst_retro_capture`, and the local `in_command_context`
helper out of `tui/ui.rs` into a sibling module. The state machine
(`PasteBurst`) and its tests stay in `paste_burst.rs`; only the keymap-
side wiring moves. Drops the now-unused `CharDecision` import from
`ui.rs`.

Workspace tests: 1011/1011 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:45:35 -05:00
Hunter BownandClaude Opus 4.7 4138053dd8 refactor(tui): extract sidebar rendering into tui/sidebar.rs (P1.2)
Moves the four sidebar panels (Plan, Todos, Tasks, Agents) plus the
shared `render_sidebar_section` wrapper out of `tui/ui.rs` into a new
sibling module. `truncate_line_to_width` becomes `pub(crate)` so the
new module can reuse it. Drops six imports from `ui.rs` that the
sidebar took with it.

`ui.rs`: 5450 → ~5070 lines.
Workspace tests: 1011/1011 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:43:43 -05:00
Hunter BownandClaude Opus 4.7 5d51143194 feat(tui): group thinking + tool calls in one active cell (P2.3)
Routes Thinking content through `active_cell` so a turn that emits
Thinking → Tool → Tool renders as one logical "Working…" block until
the next assistant prose chunk flushes the group into history.

- `ActiveCell::push_thinking` parallels `push_tool` for non-tool entries.
- `mark_in_progress_as_interrupted` now also stops streaming Thinking
  spinners on cancellation, matching tool cell behaviour.
- New `streaming_thinking_active_entry` field on `App` tracks the
  in-flight thinking entry index so deltas can mutate it in place.
- `flush_active_cell` finalizes any unclosed thinking spinner before
  draining the group into history (defensive guard).
- Removed the dead `StreamingCellKind::Thinking` variant and tightened
  `append_streaming_text` to Assistant only.

Tests cover: push_thinking, group ordering, drain order, interrupt-
clears-spinner, the full Thinking → Tool → Tool → flush flow,
defensive flush of an unclosed thinking block, and a second thinking
block appending inside the same active cell.

Workspace tests: 1004/1004 → 1011/1011.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:40:05 -05:00
Hunter BownandClaude Opus 4.7 ca7ca9f75f docs: drop stale handoff/migration/parity docs
Removed:

- `.claude/next-agent-prompt.md` (111 lines) — v0.4.6-era session prompt
  describing slices A/B/C that have all shipped. Successive sessions use
  fresh prompts (e.g. .deepseek/v0.6.0-overnight-review.md); this one is
  pure history.

- `docs/archive/workspace_migration_status.md` (92 lines) — explicitly
  archived (April 11), describes a one-time migration that's complete.
  Removed enclosing `docs/archive/` directory too (was the only file).
  CHANGELOG entry from v0.4.x still narrates the archival as history.

- `docs/parity_release_and_ci.md` (38 lines) — duplicates what
  `.github/workflows/parity.yml` and CONTRIBUTING.md already say
  authoritatively. Single source of truth wins.

- `AI_HANDOFF.md` + `todo.md` (untracked, no commit needed) — `todo.md`
  was a 7-line pointer to AI_HANDOFF.md, which itself was an April 11
  snapshot listing "remaining work" that's mostly delivered. CLAUDE.md
  is the live developer guide now.

1004/1004 tests still green; no doc/code references broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:27:58 -05:00
Hunter BownandClaude Opus 4.7 d6bfcda474 chore: drop unreferenced assets/hero.png
Not referenced from README.md, docs/, npm/, or any Cargo metadata.
README uses assets/screenshot.png. Reduces repo size by 226 KB.

Also cleaned up working-directory cruft (untracked, no commit needed):
apps/ (empty), python/ (empty after egg-info removed),
counterpoint.copilot.db, firebase-debug.log, excalidraw.log, .DS_Store.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:23:08 -05:00
Hunter BownandClaude Opus 4.7 f3df5e515e docs(changelog): roll up Phase 2/4 polish — agents chip, mention popup, P2.4 tests, subagent split, parse-counter de-flake
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:16:30 -05:00
Hunter BownandClaude Opus 4.7 a4f4f5040f style: cargo fmt --all (post-Phase-2/4 cleanup)
Auto-format pass after the tool-call rendering work, footer chip,
mention popup, subagent split, and parse-counter de-flake.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:15:11 -05:00
Hunter BownandClaude Opus 4.7 a02898545d refactor(tools): split subagent.rs into folder module — start with tests (P1.1)
Promote `tools/subagent.rs` (4206 lines) to a folder module:

  tools/subagent/
    mod.rs    — runtime types, manager, tool implementations (~3577 lines)
    tests.rs  — extracted test module (~631 lines)

This is the safe first step. The audit doc proposed a 4-way split
(mod / spec / executor / tests). I tried the 3-way (mod / tools / tests)
and the runtime <-> tool-impl coupling produces unresolved-symbol errors
because shared helpers (`SubAgentTask`, `run_subagent_task`,
`build_allowed_tools`, `normalize_role_alias`, `parse_spawn_request`,
the agent prompt constants) are referenced from both layers. Doing that
split right needs a small API design pass to decide which helpers
graduate to the manager API and which stay tool-private — out of scope
for a structural reorg. Pulled the test module out as the cleanest
no-API-change win and left a path open for the bigger split later.

Public API unchanged — `pub mod subagent;` still exports the same items
because `mod.rs` is a drop-in replacement for `subagent.rs`.

954 → 954 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:13:35 -05:00
Hunter BownandClaude Opus 4.7 2185b8c3c6 feat(tui): wire up @-mention popup end-to-end (P2.1)
The audit doc claimed the wiring was "in place" but only the App state
fields existed (`mention_menu_selected`, `mention_menu_hidden`) — no
helpers, no widget rendering, no key handling. Building it out fully so
the popup actually shows when the user types `@` in the composer and
Up/Down/Enter/Tab/Esc behave the way the slash menu does.

What's new:

1. `file_mention::visible_mention_menu_entries(app, limit)` — the entries
   source. Returns `Vec<String>` from the workspace walk, gated on the
   `mention_menu_hidden` flag and on the cursor being inside an `@token`.

2. `file_mention::apply_mention_menu_selection(app, entries)` — splices
   the selected entry into the input via the existing `replace_file_mention`,
   resets `mention_menu_hidden`, surfaces a status confirmation.

3. `ComposerWidget::new(app, max_height, slash_entries, mention_entries)`
   — second menu slot. The widget renders whichever slice is non-empty,
   addressed by the matching selected index. Mention entries get an `@`
   prefix so the popup row reads like the actual mention being composed.
   Mention takes precedence (positional check is stricter than slash's
   "starts-with-/").

4. ui.rs key handler:
   - Up/Down navigate `mention_menu_selected` when the popup is open.
   - Enter applies `apply_mention_menu_selection` instead of submitting.
   - Tab applies the selection (then falls through to the existing slash /
     command-completion / file-mention chain).
   - Esc hides the popup until the next input edit (`insert_str` already
     resets `mention_menu_hidden`, so typing re-opens it).

6 new tests in `ui/tests.rs`:
- mention_popup_is_empty_when_cursor_is_not_in_a_mention
- mention_popup_lists_workspace_matches_for_cursor_partial
- mention_popup_respects_hidden_flag
- apply_mention_menu_selection_splices_selected_entry
- apply_mention_menu_selection_is_noop_outside_a_mention
- apply_mention_menu_selection_with_no_entries_is_noop

Also fixes a stray duplicate `#[cfg(...)]` and an unused-doc-comment
warning that landed when the parse-counter went thread-local — back to
baseline 7 clippy warnings.

948 → 954 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:09:04 -05:00
Hunter BownandClaude Opus 4.7 06355e3aea test(tui): pin auto-scroll churn contract for P2.4 regression coverage
Audit pass found the auto-scroll paths are already gated correctly:

- `mark_history_updated` only bumps history_version + needs_redraw — does
  NOT scroll.
- All tool-cell handlers (`handle_tool_call_started`,
  `handle_tool_call_complete`, `push_active_tool_cell`,
  `register_tool_cell`) call `mark_history_updated` only — none of them
  call `scroll_to_bottom`.
- `add_message` and `flush_active_cell` gate their auto-scroll on
  `user_scrolled_during_stream`.
- The per-stream lock clears at TurnComplete (ui.rs ~557) and when the
  user scrolls back to the live tail (widgets/mod.rs ~126).
- Explicit user actions (vim G, End, session resume, message submit) call
  `scroll_to_bottom` directly — that's correct.

5 new regression tests in ui/tests.rs lock the contract so a future
contributor adding `app.scroll_to_bottom()` to a tool-cell handler hits a
red CI immediately:

- add_message_does_not_scroll_when_user_scrolled_away
- add_message_pins_to_tail_when_user_was_following
- tool_call_started_does_not_scroll_when_user_scrolled_away
- tool_call_complete_does_not_scroll_when_user_scrolled_away
- mark_history_updated_does_not_call_scroll_to_bottom

948 → 948 (no changes; tests were already passing — they just weren't
written yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:00:43 -05:00
Hunter BownandClaude Opus 4.7 75de26c7a1 test(tui): de-flake parse-invocation counter via thread-local
`parse_invocations_increment` and `render_parsed_does_not_call_parse` both
read the global PARSE_INVOCATIONS atomic. They were racing whenever any
other test in the suite called `parse()` in parallel — the global counter
would tick once for each unrelated call and the assertion (== 2 / == 0)
would mismatch.

Switching to `thread_local!<Cell<u64>>` gives each test thread its own
counter, so concurrent callers from other tests can't pollute the result.
Tested across 8 sequential full-suite runs: 8/8 green (was ~40% green).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 00:00:32 -05:00
Hunter BownandClaude Opus 4.7 9467d26db7 feat(tui): surface in-flight sub-agents in the footer status strip (P2.5)
FooterProps gains an `agents` chip slot, populated by `footer_agents_chip`
which mirrors the rest of the footer chips: empty `Vec<Span>` when
`running_agent_count == 0` (chip hides), "1 agent" / "N agents" otherwise,
DeepSeek-sky color matching the model badge.

The widget's `auxiliary_spans` includes it in the same drop-from-end
fit-to-width chain as the existing chips, so on narrow terminals the cost
chip drops first as before.

The "0 running" wording the audit doc called out wasn't actually in
FooterProps — that wording is in the agent sidebar (ui.rs ~2960) and was
already fixed there to swap to "N done" once nothing is in flight. So the
P2.5 work here is the additive footer surface, not a wording fix.

4 new tests in widgets/footer.rs:
- footer_agents_chip_is_empty_when_no_agents_running
- footer_agents_chip_uses_singular_for_one
- footer_agents_chip_uses_plural_for_many
- footer_agents_chip_renders_into_widget

939 → 943 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 23:54:03 -05:00
Hunter BownandClaude Opus 4.7 93efb09038 fix(tui): tool-call rendering — defer ToolCallStarted, progressive labels, elapsed badge
The engine used to fire `Event::ToolCallStarted` from `ContentBlockStart::ToolUse`
with `input: json!({})` — before any `Delta::InputJsonDelta` had streamed in.
The UI's `handle_tool_call_started` baked the placeholder into the cell at
creation time and never refreshed, so users saw `<command>` and `<file>`
literals while the args finished streaming.

Fix relocates the emission to `ContentBlockStop` (where the input is finalized
already) and routes it through a new `final_tool_input(state)` helper that
prefers the parsed buffer over a stale empty initial input. Three regression
tests in `engine/tests.rs` pin the contract.

Also bundled (same theme — make in-flight tool cells read right):

- Progressive labels via `exploring_label`: "Read foo.rs" → "Reading foo.rs",
  "List X" → "Listing X", "Search pattern" → "Searching for `pattern`",
  "List files" → "Listing files". 5 tests in `ui/tests.rs`.
- `running_status_label_with_elapsed` in `history.rs`: from 3 s onward the
  status segment becomes `running (Ns)` and ticks every second, driven by
  the existing CX#3 status-animation tick. Below 3 s no badge — quick
  reads/greps stay quiet. Wired through `render_tool_header`. 2 tests.
- Spinner cadence sped up: `TOOL_STATUS_SYMBOL_MS` 1800 → 720 ms per glyph,
  so the 4-glyph "heartbeat" is ~2.88 s instead of ~7.2 s.

929 → 939 tests, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 23:50:32 -05:00
Hunter BownandClaude Opus 4.7 42fe888d35 Merge CX#7: one active cell mutated in place
Replaces "tool start pushes new cell" with a single ActiveCell that
collects parallel/serial tool entries at the transcript tail and
flushes as a contiguous block on first assistant text or turn complete.
Stops the bounce when many tools fire concurrently.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 23:14:07 -05:00
Hunter BownandClaude Opus 4.7 63d7391ff8 CX#7: one active cell, mutated in place
Codex pattern — instead of appending a new ToolCall history cell for each
parallel tool invocation, keep one Exploring/Searching/Reading active cell at
the tail of the transcript and mutate its contents in place as new tool calls
fire. Drops cell churn and keeps the visual anchor stable while multiple tools
stream concurrently.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 23:13:57 -05:00
Hunter Bown 585dd2f7d0 CX#8: two surfaces — display_lines vs transcript_lines
- HistoryCell::Thinking — live shows first ~4 lines + Ctrl+O affordance;
  transcript_lines() returns full content with all paragraphs.
- ExecCell — live caps with head/tail + omission marker; transcript
  emits all wrapped lines without truncation.
- Tool/Patch/Mcp/Review cells — live caps + affordance; transcript
  uncapped.
- User/Assistant/System/Plan/Diff/etc — display == transcript.
- Pager (Ctrl+O / Ctrl+T) flows through transcript_lines via
  history_cell_to_text — opening the pager on a thinking or capped tool
  cell shows the full body.

Updated affordance assertion to match the post-CX#9 wording
(press Ctrl+O for full text).

911/911 tests pass; clippy -D warnings clean; fmt clean.
2026-04-25 22:44:42 -05:00
Hunter Bown 8f05f272d3 CX#5 + CX#11: line-buffer newline gate + pure-render footer
CX#5 (newline-boundary streaming gate):
- New crates/tui/src/tui/streaming/line_buffer.rs — LineBuffer holds
  text after the last \n until the next \n arrives, so partial code
  fences never become visible state.
- Wired into BlockState in streaming/mod.rs. Assistant text gates;
  thinking deltas bypass (reasoning stays live).
- 9 unit tests including the partial-fence regression case.

CX#11 (pure-render footer):
- New crates/tui/src/tui/widgets/footer.rs — FooterProps / FooterToast
  / FooterWidget. Pure render of pre-computed props.
- ui.rs::render_footer rewritten to build props once and delegate to
  FooterWidget. Visual output identical; existing 10 footer tests
  pass unchanged. 5 new from_app tests for the props builder.

908/908 tests pass; cargo clippy --workspace -D warnings clean;
cargo fmt clean.
2026-04-25 22:43:03 -05:00
Hunter Bown 1ad0c886b8 CX#4: two-gear streaming chunker (Smooth ↔ CatchUp)
Splits crates/tui/src/tui/streaming.rs into a streaming/ module:
- streaming/mod.rs — StreamingState with per-block BlockState
- streaming/chunking.rs — policy state machine, 7 tests
- streaming/commit_tick.rs — StreamChunker queue + run_commit_tick

Thresholds match codex parity: ENTER_QUEUE_DEPTH=8, ENTER_OLDEST_AGE=120ms,
EXIT_QUEUE_DEPTH=2, EXIT_OLDEST_AGE=40ms, EXIT_HOLD=250ms.

894/894 tests pass; clippy -D warnings clean; fmt clean.
2026-04-25 22:28:00 -05:00
Hunter Bown d111680a3b v0.6: codex-pattern wave 1 — frame rate, scroll model, revision cache, vim pager, cap output, kill-buffer
Merges in 6 of 13 codex-pattern items as a wave-1 checkpoint:

- CX#3 frame-rate limiter (~120 FPS coalesce)
- CX#9 LIVE_TOOL_OUTPUT_MAX_LINES + Ctrl+O affordance
- CX#13 kill-buffer Ctrl+K / Ctrl+Y composer
- CX#12 vim pager keys (j/k/g/G/Ctrl+D/Ctrl+U/Ctrl+F/Ctrl+B/Space)
- CX#2 flat line-offset scroll model (replaces TranscriptScroll cell-anchor enum)
- CX#10 per-cell revision counter for partial cache rebuilds

883/883 tests pass (was 853). cargo clippy --workspace -D warnings clean. cargo fmt clean.
2026-04-25 22:25:30 -05:00
Hunter Bown 5f223adea6 v0.6.0: native rlm_query tool + scroll fix + cleanup
Adds a structured rlm_query tool for parallel/batched LLM fan-out.
The model calls it with one prompt or up to 16 concurrent prompts;
children dispatch via tokio::join_all against the existing DeepSeek
client. Default child model is deepseek-v4-flash; override per-call
via the model field. Available in Plan / Agent / YOLO. Cost folds
into the session's running total automatically.

Fixes scroll-stuck regression (#56): TranscriptScroll::resolve_top
and scrolled_by now use a three-level fallback chain (same line →
same cell line 0 → nearest cell at-or-before) instead of teleporting
to ToBottom when an anchor cell vanishes.

Loosens command-safety chains (#57): cargo build && cargo test and
similar chains of known-safe commands now escalate to RequiresApproval
instead of being hard-blocked as Dangerous. Chains containing unknown
commands still block.

Suppresses the GettingCrowded footer chip — context-percent header
already covers conversation pressure.

Refactors:
- Extracts file_mention parsing/completion/expansion (~450 LOC) from
  the 5,500-line ui.rs into crates/tui/src/tui/file_mention.rs.
- Deletes truly unused helpers (write_bytes, timestamped_filename,
  extension_from_url, output_path, has_project_doc, primary_doc_path).

Tests: 853 pass. cargo clippy --workspace -D warnings clean.
cargo fmt --all -- --check clean.

Closes #46 #47 #48 #49 #50 #53 #54 #55 #56 #57 #58.
2026-04-25 21:48:17 -05:00
Hunter BownandClaude Opus 4.7 027d6d19b6 docs(rlm): land Hetun design + helper layer + Sakana research methodology
Captures the full RLM-fundamental story across the design doc, MODES.md,
and the Hetun prompt. Tracking issues are now #46–#55 (helper layer
filed as #53, Hetun as #54, vendoring as #55).

What this nails down:

- **Hetun mode** is added at the END of the Tab cycle (Plan → Agent →
  YOLO → Hetun → Plan), not as a Plan replacement. Default landing mode
  is unchanged so people don't accidentally start there. Plan stays as
  it is.

- **Mission-level approval, not block-level.** Hetun runs a research
  phase, presents one mission card, and only executes after explicit
  user approval. Inside the execution turn the repl block runs straight
  through with no per-block prompts — that's the whole point of the
  mode.

- **The user's configured model is left alone on enter/exit.** Pro/max
  users stay on Pro/max. The flash-as-coordinator behaviour is internal
  to the runtime (ZIGRLM_RLM_CMD always points to flash regardless of
  mode). No global model swap.

- **No /hetun slash command.** Tab cycles into the mode; /plan keeps
  switching to Plan as today.

- **The helper layer (#53) is fundamental, not aleph-derived.** A
  curated ~20-function ctx-helper module + AST-validated Python sandbox
  baked into the repl runtime so a single block can load → slice → fan
  out flash queries → aggregate without crossing tool boundaries.
  Inspired by aleph's pattern but our own native primitive — not a port.

- **Hetun research methodology adopts Sakana's Fugu patterns.** The
  research phase is recursive novelty sampling + hierarchical narrative
  tree synthesis + multi-detector cross-verification (flash for
  breadth, Pro for depth) + hypothesis-verification loop. Not "fan out
  8 fixed queries". This is what makes "Plan + Recursive Agents"
  meaningful versus a flash-coordinator wrapper.

- **No version-number framing anywhere.** The plan ships as one cohesive
  RLM landing across #46/#48/#49/#50/#53/#54/#55 — order is dependency,
  not release schedule. We keep shipping.

- **Auto-compaction stays automatic.** Removed a manual /compact nag
  from the Hetun prompt; the existing coherence + capacity system
  already handles this.

Files:
  docs/rlm-design.md          new — full design doc with Hetun details
  docs/research-react-vs-rlm.md  new — supporting research treatment
  docs/MODES.md               4-mode cycle, Hetun added at end, Plan kept
  crates/tui/src/prompts/hetun.txt  prompt teaching the recursive-novelty
                              + hierarchical-synthesis + verification-loop
                              rhythm, mission-card structure, two-step gate
  .gitignore                  ignore .claude/scheduled_tasks.lock runtime

Closes nothing yet — implementation lands across the tracking issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:37:25 -05:00
Hunter BownandClaude Opus 4.7 229b1993ac ci: mirror release-workflow strict gates so failed publishes can't slip through
The Release / parity job runs with `--locked` and clippy `-D warnings`.
Main CI ran without either flag, so commits could pass main CI but fail
the release pipeline at the parity stage — which has been silently
blocking every npm publish since v0.4.6 (latest npm = 0.4.8 even though
git tags reach v0.5.2). Most failures were either fmt drift caused by
new stable rustc / rustfmt revisions or lockfile drift the workspace CI
never noticed.

Aligns the Lint job's clippy step with `--locked -- -D warnings` and
the Test job with `--locked` + an explicit `git diff --exit-code --
Cargo.lock` lockfile drift guard. From here on, anything that would
fail Release / parity also fails main CI on the same push, so we
never push a tag we know will fail the publish.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:07:28 -05:00
Hunter BownandClaude Opus 4.7 686ef94719 fix: doc-link warning in model_picker module header
Removed the `[`Settings`]` intra-doc link that referenced a type not
in scope at the module-doc level — RUSTDOCFLAGS=-Dwarnings rejects it.
Replaced with backtick code formatting; the rest of the doc is
unchanged.

Caught by the Documentation CI job on v0.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:03:37 -05:00
Hunter BownandClaude Opus 4.7 ba6bc351da release: v0.5.2 — /model picker
Single-feature release that lands #39 (the /model two-pane picker)
on top of the v0.5.1 quality-of-life batch. Bumps workspace +
npm wrapper + Cargo.lock in lockstep; check-versions.sh verifies.

Closes #39.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:00:19 -05:00
Hunter BownandClaude Opus 4.7 ebdda09c29 feat(#39): /model opens Pro/Flash + Off/High/Max picker
`/model` with no argument now opens a two-pane modal: model on the left
(deepseek-v4-pro flagship vs deepseek-v4-flash fast-and-cheap, with the
current id appearing as a "current (custom)" row when it isn't one of
the listed defaults), and thinking effort on the right (Off, High,
Max). Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter
applies both, Esc cancels.

Effort exposes only the three rows DeepSeek behaviorally distinguishes
per the Thinking Mode docs — `low`/`medium` are mapped server-side to
`high`, and `xhigh` to `max`, so listing them as separate choices would
mislead. The legacy variants stay valid in `~/.deepseek/settings.toml`
for back-compat (the existing `cycle_next` already only visits
Off→High→Max), the picker just doesn't surface them.

Apply path:
 * mutates app.model and app.reasoning_effort
 * resets last_*_tokens / cache / replay-token gauges so the next-turn
   footer numbers reflect the new model rather than stale ones
 * persists `default_model` and `reasoning_effort` to settings via the
   existing Settings::set/save flow so the choice survives restart
 * forwards Op::SetModel + Op::SetCompaction to the engine so the
   running session picks up the new compaction budget
 * surfaces a one-line summary describing what changed
 * if persistence fails, the in-memory change still applies and a
   "(not persisted: ...)" suffix is appended to the status line

`/model <id>` keeps working unchanged for power users; only the
no-argument branch was redirected to the new modal.

Files:
 * tui/model_picker.rs — new ModelPickerView struct + ModalView impl,
   plus eight unit tests (initial state, low/medium normalisation,
   custom model preservation, arrow navigation, focus toggle, Enter
   emits ModelPickerApplied with the right values, Esc closes silently,
   and a guard that the picker exposes exactly off/high/max).
 * tui/views/mod.rs — adds ModalKind::ModelPicker and
   ViewEvent::ModelPickerApplied carrying both new and previous
   model+effort so the handler can describe the diff.
 * tui/app.rs — adds AppAction::OpenModelPicker.
 * commands/core.rs — `/model` no-arg branch now returns
   AppAction::OpenModelPicker; `/model <id>` shortcut is unchanged.
 * tui/ui.rs — pushes ModelPickerView on the action and adds
   apply_model_picker_choice() that handles persistence + engine sync
   when ViewEvent::ModelPickerApplied fires.
 * tui/mod.rs — registers the new submodule.

Closes #39 (against v0.5.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:50:49 -05:00
Hunter BownandClaude Opus 4.7 f42f94207c release: v0.5.1 — telemetry, completion, and trust quality-of-life
Bumps workspace version to 0.5.1 and finalises the changelog with the
issues that landed since v0.5.0:
 * #25, #27, #31, #33, #34 (already on main)
 * #28 @file Tab-completion
 * #29 per-workspace trust list with /trust slash command
 * #30 reasoning-replay token chip in the footer
 * #36 regression tests for sidebar gutter bleed

scripts/release/check-versions.sh is green: workspace=0.5.1, npm=0.5.1,
Cargo.lock in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:40:06 -05:00
Hunter BownandClaude Opus 4.7 d79178a926 feat(#28,#30): @file Tab-completion + reasoning replay footer chip
Two related TUI affordances bundled because they share ui.rs and the
ui/tests.rs file.

#30 — Reasoning-content replay telemetry, end-to-end:
 * models.rs — Usage gains reasoning_replay_tokens: Option<u32>.
 * client.rs — sanitize_thinking_mode_messages now returns the
   approximate replay-token count (~4 chars/token); the streaming
   pipeline overlays it onto the parsed MessageDelta usage so the
   server-reported and client-estimated numbers reach the engine
   together.
 * app.rs — App stores last_reasoning_replay_tokens.
 * ui.rs — TurnComplete handler copies the value into the App; new
   footer_reasoning_replay_spans renders an `rsn N.Nk` chip in the
   footer next to the cache hit-rate, warning-coloured when replay
   tokens exceed 50% of the input budget.
 * ui/tests.rs — covers chip-on, chip-hidden-when-zero, and the
   sanitizer's None-on-non-thinking-model path.

#28 — Tab-complete @file mentions against the workspace:
 * ui.rs — adds partial_file_mention_at_cursor (with a guard against
   `[email protected]`-style false positives) and
   try_autocomplete_file_mention. Walks the workspace via the
   existing ignore::WalkBuilder, ranks prefix matches above
   substring matches, applies the unique match outright, extends to
   the longest common prefix when multiple match, and surfaces
   ambiguous candidates via the status line. Wired into the existing
   Tab handler after the slash-command branch.
 * ui/tests.rs — covers cursor-inside-mention extraction, email
   guard, prefix vs substring ranking, single-match application,
   common-prefix extension, no-match status, and the
   no-mention-no-op path.

The mention-expansion path that ships file contents to the model is
unchanged — this is purely a discovery aid for typing the path.
Inline-contents and a fuzzy popup picker are queued for v0.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:59 -05:00
Hunter BownandClaude Opus 4.7 bcf6ba9a8e feat(#29): per-workspace trust list with /trust slash command
Adds a persistent allowlist of external paths the agent may read/write
from outside the current workspace, scoped to the workspace it was
granted in. The list lives in ~/.deepseek/workspace-trust.json with
schema {"workspaces": {"<ws>": ["<trusted>", ...]}}; canonical paths on
both sides keep symlink-aliased macOS tempdirs sane.

Surface area:
 * crates/tui/src/workspace_trust.rs — new module: load_for / add /
   remove plus *_at variants for tests that need an explicit file path
   rather than HOME mutation.
 * tools/spec.rs — ToolContext gains trusted_external_paths and
   resolve_path consults it before returning PathEscape, both for the
   existing-path branch and the to-be-created (parent-canonical) branch.
 * core/engine.rs — build_tool_context loads the trust snapshot on every
   tool dispatch so /trust mutations apply on the next call.
 * commands/config.rs — /trust now takes subcommands (add, remove,
   list, on, off, status) instead of being a single all-or-nothing
   toggle. Tilde expansion handled in-line.
 * commands/mod.rs — registry entry updated with the new usage string
   and a dispatcher that forwards args.
 * tools/diagnostics.rs — adds trusted_external_paths to the JSON
   output so the agent and the user can see the list at a glance.

The interactive "Allow once / Always allow / Deny" prompt that the
issue describes is deferred — for v0.5.1 the workflow is "grant
ahead with /trust add". A future change will add a hook in
ToolContext::resolve_path that surfaces an ApprovalRequest when an
escape path is hit, so the slash-command remains the durable
mechanism while the prompt becomes the discovery one.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:37 -05:00
Hunter BownandClaude Opus 4.7 7a85f182e2 test(#36): regression tests for sidebar gutter bleed
Adds two snapshot tests against ChatWidget rendering to lock in that long
single-line tool results never write any cells outside chat_area at the
widths reported in the bug (80, 120, 165, 200 cols), and that the
scrollbar coexists with content along the right edge instead of
overdrawing the penultimate column. The acceptance criterion in the
issue specifically requires this regression coverage; the tests pass
against current code, so existing rendering is the baseline being
guarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:19 -05:00
Hunter Bown f7fe5e09a5 fix(#37): make NIM a peer provider in config.example.toml + setup status
A user couldn't find an `NVIDIA_API_KEY` block in `~/.deepseek/config.toml`
because the example file only mentioned NIM as commented-out alternates
to the top-level keys. Two fixes:

- `config.example.toml` now has explicit `[providers.deepseek]` and
  `[providers.nvidia_nim]` sections (placed after all top-level keys so
  the TOML still parses cleanly), each documenting `api_key` /
  `base_url` / `model` plus the env vars that override them. Both
  providers can be stored at once and toggled via `/provider` or
  `--provider` without re-entering keys.
- `setup --status` "missing api_key" message is now provider-aware: on
  `nvidia-nim` it points at `NVIDIA_API_KEY` + `[providers.nvidia_nim]`
  + `deepseek auth set --provider nvidia-nim`, instead of the
  DeepSeek-only hint.

Audit verified: the v0.5.0 multi-turn replay fix path
(`should_replay_reasoning_content` → `requires_reasoning_content` in
`crates/tui/src/client.rs:1796`) keys off the model name (matches
`deepseek-v4`), not the provider, so NIM-hosted V4 models get the
replay automatically. No NIM-specific 400-class regression there.

Closes #37 (docs/UX); the live multi-turn-against-NIM verification
remains a manual smoke step listed in the issue (no NIM creds in CI).
2026-04-25 13:52:44 -05:00
Hunter Bown 24b8945010 feat(#32): basic session-handoff convention via .deepseek/handoff.md
Minimum-viable version of the handoff artifact described in #32:

- New `HANDOFF_RELATIVE_PATH = ".deepseek/handoff.md"` convention.
- `system_prompt_for_mode_with_context` now reads that path on every
  prompt rebuild and prepends a `## Previous Session Handoff` block to
  the system prompt when the file is non-empty. A fresh agent gets the
  prior session's blockers/decisions/files-touched in turn-1 context
  with zero discovery cost.
- Agent prompt updated to make the convention explicit: "if the block
  appears, read it first; before exit/`/compact`, write or update it
  via `write_file`."
- `.deepseek/` is already gitignored, so the handoff travels with the
  workspace but doesn't pollute commits unless the user opts in.

Tests cover: present-and-non-empty (block injected with file content),
missing file (no block), empty/whitespace-only file (no block). A
unique marker in the injected block (`"left a handoff at .deepseek/..."`)
discriminates the actual block from the agent prompt's own description
of the convention.

Out of scope for v0.5.1: a `/handoff` slash command, a startup banner
toast, automatic write on exit, and the diff-against-HEAD-on-resume
mechanism. The agent can already write the file via `write_file` when
the user types `write a session handoff`.

Closes #32.
2026-04-25 13:48:22 -05:00
Hunter Bown 82e4a564aa refactor(#35): tighten agent prompt tool descriptions, drop alias dupes
Tool-surface audit pass:

- FILE OPERATIONS rewritten so each line states the niche, not just the
  verb. read_file mentions PDF auto-extraction + `pages` slicing.
- New SEARCH section consolidates grep_files / file_search / web_search /
  fetch_url so the model sees them next to each other and picks the
  right one. fetch_url (#33) added; previously absent from the prompt.
- request_user_input pulled out of FILE OPERATIONS into its own USER
  section — it never belonged there.
- SUB-AGENTS list shrinks by 3: drops `spawn_agent` (use `agent_spawn`),
  `close_agent` (use `agent_cancel`), and the `agent_assign /
  assign_agent` dual-name. The underlying dispatchers still resolve those
  names, so existing sessions don't break — they just no longer
  pollute the model's tool list.

Adds `docs/TOOL_SURFACE.md` with the rationale, the v0.5.1 final
surface, and the dropped aliases. Calls out that grep_files is pure-Rust
(no rg/grep shell-out, so the "fall back to grep" AC from #35 is
vacuously satisfied — the tool has no shell dependency to fall back from).

Closes #35.
2026-04-25 13:44:43 -05:00
Hunter Bown 07ae792068 fix(#38): show provider chip in header when not on default DeepSeek
The reasoning-effort tier (`max` chip + whale icon) and the live/context
indicators were the only signals on the right of the header. Switching
to nvidia-nim left the right-hand side identical to a DeepSeek session,
so it wasn't obvious at a glance that requests were going to a different
backend.

Now: when `app.api_provider != Deepseek`, the header surfaces a bold
`NIM` chip on the right, leftmost in the chip cluster (so it survives
the narrow-width fallback variants in `right_spans`). Default-DeepSeek
sessions are unchanged — `provider_label = None` short-circuits the
chip.

Closes #38.
2026-04-25 13:41:52 -05:00
Hunter Bown ba40ae4aac feat(#34): auto-extract text from PDFs in read_file
`read_file` now detects PDFs by extension or `%PDF-` magic bytes and
shells out to `pdftotext -layout` (poppler) to return plain text
directly to the model. New optional `pages` arg accepts `N` or `N-M`
slices so big papers can be read in pieces without burning context.

When `pdftotext` isn't on `$PATH`, the tool returns a structured
`{type: "binary_unavailable", kind: "pdf", reason, hint}` payload with
install hints (`brew install poppler` / `apt install poppler-utils`)
instead of crashing or returning UTF-8 garbage from a binary file.

Tests cover extension detection (case-insensitive), magic-byte sniffing
on extension-less files, the negative case for plain text, the pages
arg parser (single, range, whitespace, invalid forms), and the
binary_unavailable branch when `pdftotext` is absent.

.docx / .epub / .html stripping deferred — same dispatch can take more
extractors later.

Closes #34.
2026-04-25 13:36:30 -05:00
Hunter Bown 7f2c382343 feat(#33): add fetch_url tool for direct HTTP GET
Complements `web_search` for cases where the URL is already known —
GitHub repos, blog posts, spec pages — and a search-engine round trip is
overkill or actively unhelpful (which #25 had been making worse).

Surface:
- `fetch_url(url, format?, max_bytes?, timeout_ms?)`
- `format`: `markdown` (default), `text`, `raw`
- HTTPS preferred, http:// allowed; non-http schemes rejected up front
- Follows up to 5 redirects; 1 MB default cap (10 MB hard ceiling); 15 s
  default timeout (60 s ceiling)
- HTML responses are stripped to readable text via the same regex
  pattern used by `web_search` (script/style strip → tag strip → entity
  decode → whitespace collapse)
- 4xx / 5xx responses still return the body (with `success: false`) so
  the caller can read JSON error envelopes

Capabilities: `ReadOnly + Network`. Approval: `Auto` (matches
`web_search`). Registered in `with_web_tools` so it's available wherever
`web_search` is.

Tests cover: format parsing aliases, scheme rejection, missing/empty
url validation, html-to-text stripping. The over-the-wire cases
(redirect chains, oversized truncation) are exercised by integration
tests once the test suite is wired to a local mock HTTP server —
deferring that since the unit tests already lock in the input
validation and HTML processing.

Closes #33.
2026-04-25 13:33:22 -05:00
Hunter Bown 017ac97d0d feat(#30): debug-log reasoning_content replay size per request
The thinking-mode sanitizer now sums the byte size of every replayed
`reasoning_content` field in the outgoing chat-completions body and
emits an `info`-level log line:

  Reasoning-content replay: 7 assistant message(s), ~3.2K input tokens (12,884 chars) being re-sent in this request

This is visible under `RUST_LOG=deepseek_tui=info` (or higher). It's the
first step toward the footer/status-line indicator described in #30 —
the model's input-side reasoning replay is now observable per turn,
even before it gets a dedicated UI surface.

Tests cover both branches: bodies that already have reasoning_content
(count is summed across all assistant turns) and bodies where the
sanitizer had to inject the `(reasoning omitted)` placeholder (the
placeholder bytes are included in the count since they ship over the
wire).

Footer integration deferred — that needs a new event from client → engine
→ TUI to surface the count alongside `cache N%` / `$X.XX`. Part of #30
remains open.
2026-04-25 13:28:44 -05:00
Hunter Bown 0a394e1587 fix(#31): catch version drift in CI, not at release time
Adds scripts/release/check-versions.sh and a `versions` CI job that runs
on every push/PR. Verifies:
- no per-crate Cargo.toml carries a literal version (must inherit the
  workspace version)
- npm/deepseek-tui/package.json matches the workspace version
- Cargo.lock is in sync with the manifests

Closes #31.
2026-04-25 13:25:55 -05:00
Hunter Bown fafb76063d ci: fix unused import + cargo fmt drift from #27
The #27 per-mode context budget commit (1be18e69) replaced calls to
compaction_threshold_for_model with compaction_threshold_for_model_and_effort
but left the old name in the import list, which fails under -Dwarnings on
Build, Test, and the npm wrapper smoke job. Also re-runs cargo fmt over
the four files the lint job flagged.
2026-04-25 13:21:16 -05:00
Hunter Bown 9e7cfc951a docs: README v0.5.0 callout + CHANGELOG entry, drop cargo install
- README: drop the `cargo install deepseek-tui` / `deepseek-tui-cli`
  block and the crates.io badge — those packages have been lagging the
  workspace version for several releases. Source install (`cargo install
  --path crates/tui`) remains documented for hackers.
- README: add a "What's new in v0.5.0" section pointing at the
  thinking-mode tool-call 400 fix and the #25 web.run cleanup.
- CHANGELOG: add the [0.5.0] - 2026-04-25 entry covering the per-message
  reasoning_content rule, the wire-payload sanitizer, and #25.
2026-04-25 13:15:35 -05:00
Hunter Bown 1be18e691b feat(#27): per-mode soft context budget for V4 compaction trigger
Add compaction_threshold_for_model_and_effort() with mode-aware soft
caps based on DeepSeek V4 paper Figure 9 recall-quality data:

  Plan / off   ->  64K (paper eval: 8K-128K)
  Agent / high -> 192K (paper eval: 128K)
  YOLO / max   -> 384K (paper eval: 384K-512K)

Previously, the 80%-of-window rule gave 800K for V4's 1M window,
which is well past the point where MRCR MMR collapses (0.49 at 1M).

Non-V4 models keep the legacy 80% rule. None/unknown effort defaults
to agent-tier (192K).
2026-04-25 12:58:35 -05:00
Hunter Bown ccc9554ef4 fix(#25): strip phantom web.run references from prompts and web_search tool
Replace all web.run mentions with web_search in prompt files (base,
agent, yolo, plan, normal) and update web_search.rs description.
Model was trying web.run which doesn't exist, wasting turns on
validation errors. Also remove [cite:ref_id] citation format which
required web.run's ref_id system.

Partial fix for #25 — web_search reliability improvements (real
search provider) still needed.
2026-04-25 12:51:12 -05:00
Hunter Bown 97f245d5ac docs: add TUI screenshot to README 2026-04-25 12:48:42 -05:00
Hunter Bown 19f8d83d3b release: v0.5.0 — fix multi-turn tool call 400 error (missing reasoning_content on assistant messages with tool_calls) 2026-04-25 12:27:53 -05:00
Hunter Bown 67b232b063 Release v0.4.9: thinking-mode reasoning_content fix + README refresh
### Fixed
- DeepSeek thinking-mode tool-call rounds now always replay reasoning_content
  in all subsequent requests (including across new user turns), matching the
  documented API contract that assistant tool-call messages must retain their
  reasoning content forever. Previously, reasoning_content was cleared after
  the current user turn completed, which could cause HTTP 400 errors.
- Missing reasoning_content on a tool-call assistant message now substitutes
  a safe placeholder ("(reasoning omitted)") instead of dropping the tool
  calls and their matching tool results, preventing orphaned conversation
  chains and API 400 rejections.
- Session checkpoint now persists a Thinking-block placeholder for tool-call
  turns that produced no streamed reasoning text, keeping on-disk sessions
  structurally correct for subsequent requests.
- Token estimation for compaction now counts thinking tokens across ALL
  tool-call rounds (not just the current user turn), aligning with the
  updated reasoning_content replay rule.

### Changed
- Internal crate dependency pins bumped 0.4.5 → 0.4.9 to match workspace.
- npm wrapper version and deepseekBinaryVersion bumped to 0.4.9.
- README fully rewritten: clearer feature highlights, V4 model focus,
  keyboard shortcut table, improved docs index, and more engaging layout.
- CHANGELOG entry for 0.4.9 with comparison URLs.
2026-04-25 12:00:08 -05:00
Hunter Bown 41c54f08aa release: deepseek-tui 0.4.8 2026-04-25 10:48:00 -05:00
Hunter Bown 56ea63e2c6 Merge pull request #20 from Hmbown/codex/v4-pro-discount-pricing
[codex] Track DeepSeek V4 Pro discount pricing
2026-04-25 10:44:22 -05:00
Hunter Bown 94834b2eb4 fix: track DeepSeek V4 Pro discount pricing 2026-04-25 10:34:02 -05:00
Hunter Bown dc8f8496d6 Merge pull request #19 from Hmbown/codex/release-0.4.7
release: deepseek-tui 0.4.7 + Devin .env empty-key fix
2026-04-25 08:04:10 -05:00
Hunter Bown 5c1086fe9e release: deepseek-tui 0.4.7
Bump workspace version 0.4.6 -> 0.4.7 and ship the bug fix flagged by Devin
on PR #18: an uncommented `DEEPSEEK_API_KEY=` line in .env.example caused
`cp .env.example .env` to load an empty key, which `apply_env_overrides`
then propagated into the config and `Config::validate()` rejected on
startup with "api_key cannot be empty string".

- .env.example: comment out the empty `DEEPSEEK_API_KEY=` placeholder.
- crates/tui/src/config.rs: skip empty `DEEPSEEK_API_KEY` env values in
  `apply_env_overrides`, matching the facade's empty-string filter.
- Add `apply_env_overrides_ignores_empty_api_key` regression test.
- Bump Cargo.toml workspace version, npm wrapper version + binary version,
  and Cargo.lock.
2026-04-25 07:59:01 -05:00
Hunter Bown dff592bc6d Merge pull request #18 from Hmbown/codex/deepseek-tui-app-working
Verify app smoke and add NIM env support
2026-04-25 07:46:41 -05:00
Hunter Bown 29141bc89b Add NIM env support and .env.example template 2026-04-25 07:21:43 -05:00
Hunter Bown fe4f261a5d release: deepseek tui 0.4.6
Bundles new work since v0.4.4: NIM provider support, file mention
attachments, provider switch UX, setup --status/--clean/--tools/--plugins,
doctor --json, and protocol-recovery hardening that strips fake
tool-call wrappers from streaming model output.
2026-04-25 02:01:04 -05:00
Hunter Bown 7f0444d26b fix: re-add DEFAULT_TEXT_MODEL import dropped by merge
Local main's unpushed commits had removed DEFAULT_TEXT_MODEL from the
crate::config import in main.rs, but the merged branch's new code at
two call sites still uses it. Textual three-way merge took the local
import line and the branch's call sites, producing a build break.
Re-add the symbol to the import.
2026-04-25 01:57:17 -05:00
Hunter Bown 298f5c6c51 Merge branch 'claude/improve-deepseek-v4-harness-NxBpS' into main 2026-04-25 01:55:44 -05:00
Hunter Bown 853a39138c feat: setup status/clean/dirs and protocol-recovery hardening
Adds a compact `setup --status` view, a `setup --clean` for regenerable
session checkpoints, and `--tools`/`--plugins` scaffolding for
~/.deepseek/{tools,plugins} so the extension model has a documented home
that doctor can count. `doctor --json` lands as a CI-safe alternative to
the human-readable doctor (skips the live API probe).

Also locks down the engine's hostility to fake tool-call wrappers:
filter_tool_call_delta and the marker constants are now testable, the
streaming loop emits one compact status notice per turn when it strips
a wrapper, and a new protocol_recovery integration test asserts that
the legacy text parser never turns <function_calls> into a real tool
call. Adds 23 unit tests + 14 integration tests covering both slices.
2026-04-25 06:26:07 +00:00
Hunter Bown 6ad3727fa0 Add provider switch and file mention attachments 2026-04-24 23:09:48 -05:00
Hunter Bown 16f62f7abf Fix reasoning replay and context accounting for NIM 2026-04-24 18:42:18 -05:00
Hunter Bown d0dc26ce25 Add NVIDIA NIM provider support for DeepSeek 2026-04-24 18:29:19 -05:00
Hunter Bown 7d8c40893f chore: map github web committer 2026-04-24 16:29:01 -05:00
Hunter Bown 411153c549 chore: normalize contributor identities 2026-04-24 16:27:54 -05:00
Hunter Bown e06071c177 docs: remove stale readme screenshot 2026-04-24 16:23:50 -05:00
Hunter Bown f377b0f9d2 ci: add npm publish retry path 2026-04-24 16:15:48 -05:00
Hunter Bown f3df8f5f26 ci: publish npm with trusted publishing 2026-04-24 16:13:59 -05:00
Hunter Bown 8323bedfb7 fix: restore default tui mouse scrolling 2026-04-24 11:41:31 -05:00
Hunter Bown 6c14e2e1f4 release: deepseek tui 0.4.4 2026-04-24 11:02:07 -05:00
Hunter Bown c4f9078712 release: deepseek tui 0.4.3 2026-04-24 10:48:35 -05:00
Hunter Bown d713032b9f ci: allow token-backed npm publish 2026-04-24 00:21:35 -05:00
Hunter Bown d89b33330f fix: preserve DeepSeek V4 reasoning tool turns 2026-04-24 00:01:10 -05:00
Hunter Bown ffa75f07e5 fix: relax v4 compaction and transcript scroll 2026-04-23 23:31:18 -05:00
Hunter Bown 35595f8edc fix: normalize legacy DeepSeek aliases to V4 flash 2026-04-23 23:08:44 -05:00
Hunter Bown b7bd02d814 feat: DeepSeek V4 support with reasoning-effort control (0.4.0)
Adds first-class DeepSeek V4 Pro and Flash support, updates the default model to deepseek-v4-pro, aligns legacy aliases with the current V4 1M context behavior, and fixes thinking-mode request handling.

Key fixes:
- Send DeepSeek's raw Chat Completions `thinking` parameter at the top level instead of SDK-only `extra_body`.
- Preserve assistant `reasoning_content` for all prior thinking-mode tool-call turns so subsequent requests satisfy DeepSeek V4's replay requirement.
- Fix npm wrapper concurrent first-run downloads by using per-process temporary download paths.
- Add `.mailmap` so historical bot-attributed commits aggregate under Hunter Bown where mailmap is honored.

Verified with the full local Rust gate, live DeepSeek V4 smoke, npm wrapper temp-install smoke, and green PR CI across Linux, macOS, and Windows.
2026-04-23 22:53:20 -05:00
Hunter Bown dc8e94d705 docs: update documentation and cleanup for v0.3.33 2026-04-22 22:36:45 -05:00
Hunter Bown f4dbf828c9 Footer polish: remove FOOTER_HINT, simplify footer rendering
- Remove FOOTER_HINT color constant from palette
- Drop footer clock label and related synchronization logic
- Simplify footer status line layout and narrow-terminal handling
- Update tests to align with simplified footer logic
- Remove empty state placeholder text for cleaner UI
- Bump version to 0.3.33
2026-04-11 20:20:18 -05:00
Hunter Bown cbcf35c1bd release: prepare v0.3.32 — finance tool, header redesign, expanded tests
Add Yahoo Finance quote tool with chart fallback, redesign header widget
with proportional truncation and context bar, refactor footer status strip,
expand test suite to 680+ tests, and fix blocking issues (usize underflow
in header, tempdir leak in finance tests, per-call HTTP client creation).
2026-04-11 18:58:56 -05:00
Hunter Bown 3db7f05c60 ci: fix npm smoke runner and bump wrapper to 0.3.32 2026-03-12 21:48:58 -05:00
Hunter Bown 5c82f08c58 docs: tighten README onboarding and usage copy 2026-03-12 21:41:08 -05:00
Hunter Bown ea3cc3d0d2 fix: address PR #4 follow-ups (#5)
* fix: address PR #4 follow-ups

Honor low_motion in the default tool transcript path and align composer cursor padding with the rendered placeholder. Add focused regression tests for both behaviors.


* lint: remove redundant imports in empty_state test, reuse create_test_app

The test had inner `use` statements for Config, App, TuiOptions, and
PathBuf that duplicated the module-level test imports. It also manually
constructed App instead of calling the existing create_test_app() helper.

* fix: replace useless format!("{text}") with text.to_string() in details_affordance_line

* test: pin composer_density in cursor test to avoid sensitivity to loaded settings

Settings::load() may return a non-default composer_density on some CI
environments. Explicitly set ComposerDensity::Comfortable so the
expected cursor position is deterministic across all platforms.

* fix: make tool low_motion test robust against coarse Windows timers

Use a 2× cycle offset so the animated frame index is 2 (maximally
distant from 0), giving 1800 ms of headroom before the animation could
wrap back to index 0. The previous 1× offset left only ~15 ms of
margin, causing flaky failures on Windows where Instant resolution is
approximately 15.6 ms.

* fix: correct headroom comment in tool animation test (3600ms, not 1800ms)

* fix: resolve lint, parity, and Windows test failures

- Fix rustfmt line-length issue in history.rs tool animation test
- Settings::path() now respects DEEPSEEK_CONFIG_PATH for Windows test compat
- doctor_check_mcp_server recognizes Unix-style absolute paths on Windows
- Use checked_sub for Instant arithmetic in web_run tests to prevent
  underflow on freshly-booted Windows CI runners


* fix: expand ~ in DEEPSEEK_CONFIG_PATH when resolving settings path

---------
2026-03-12 21:29:14 -05:00
Hunter Bown b172b8d306 feat: remove Normal mode and consolidate to Agent (#4)
Keep legacy /normal and settings fallback behavior mapped to Agent, align docs around the three visible modes, and include the current TUI and onboarding refinements in this worktree.
2026-03-12 11:32:25 -05:00
Hunter Bown 14bb2bfd98 release: prepare for v0.3.31
- Update CHANGELOG with workspace refactor and clippy fix
- Fix collapsible_if lint in web browsing session manager
2026-03-11 20:11:32 -05:00
Hunter Bown fc4884b0c8 release: prepare for v0.3.31
- Update CHANGELOG with workspace refactor and clippy fix
- Fix collapsible_if lint in web browsing session manager
2026-03-11 20:10:19 -05:00
Hunter Bown 7b91169017 refactor: move source files into workspace crates
- Move src/* into crates/tui/src/ to create a proper workspace structure
- Add .claude/ and .trimtab/ directories for Trimtab closed-loop workflow
- Add DEPENDENCY_GRAPH.md and update documentation
- Update Cargo.toml files to reflect new crate dependencies
- Update CI workflows and npm package scripts
- All tests pass, release build works
2026-03-11 20:00:38 -05:00
Hunter Bown cc0ac49822 fix(ci): target deepseek-tui binary for eval, clean up README
- Fix CI eval step to use `cargo run -p deepseek-tui` instead of bare
  `cargo run` which fails with multiple binaries
- Add --workspace flag to clippy, test, and doc CI steps
- Rewrite README: lead with install command, tighten verbiage,
  consolidate quickstart, drop verbose architecture section
2026-03-03 10:00:59 -06:00
Hunter Bown f2881e7e3d feat: add npm package and update install docs
- Add npm/deepseek-tui package that downloads prebuilt binaries from
  GitHub releases (supports macOS, Linux, Windows)
- Published as [email protected] on npmjs.com
- Update README to feature npm as primary install method
- Add npm badge
2026-03-03 09:57:56 -06:00
Hunter Bown f269147286 fix(release): rename cli crate to deepseek-tui-cli 2026-03-02 18:59:10 -06:00
Hunter Bown ad76ee39ba chore(release): fix workspace dependency versions for publish 2026-03-02 18:07:44 -06:00
Hunter Bown 351b309c7d release: 0.3.28 2026-03-02 18:05:26 -06:00
Hunter Bown 37186c3d95 Workspace migration: split into modular crates, parity CI, release updates
- Convert root to Cargo workspace with crates/ layout
- Add deepseek-* crates mirroring Codex architecture
- Add parity CI workflow with snapshot/protocol/state tests
- Update release workflow to build both deepseek and deepseek-tui binaries
- Bump version to 0.3.28
2026-03-02 17:52:46 -06:00
Hunter Bown 6021fc56c8 release: 0.3.27 add git_history and validate_data tools 2026-03-02 09:21:15 -06:00
Hunter Bown 8bdf7005b8 fix(rustdoc): resolve bare URL and invalid HTML tag warnings 2026-03-01 20:04:48 -06:00
Hunter Bown 698ec1099c release: 0.3.26 code quality sweep 2026-03-01 20:02:01 -06:00
Hunter Bown d80d31aafb Rename project 2026-02-26 14:30:43 -06:00
Hunter Bown 5e52b59005 fix: resolve clippy failures in swarm and csv workers 2026-02-26 14:29:21 -06:00
Hunter Bown 70beb250e7 fix: remove stray blank lines in tui ui 2026-02-26 14:25:18 -06:00
Hunter Bown 9b87804057 Add YOLO mode sandbox policy, interactive shell policy support, and clean up subagent UI logging
- engine: apply elevated sandbox policy (network + workspace write) in YOLO mode
- shell: pass policy override to interactive execution
- ui: remove duplicate subagent list logging (already logged individually)
- ui: remove unused format_subagent_list and format_subagent_status functions
- prompts: add yolo.txt prompt file
2026-02-26 14:22:13 -06:00
Hunter Bown b3e765cc70 Update README with latest features (sub‑agent orchestration, parallel tool execution, runtime API, task queue, etc.) 2026-02-26 14:01:32 -06:00
Hunter Bown c69a73b644 fix: deepseek tui streaming/ux/config reliability sweep 2026-02-25 11:24:12 -06:00
Hunter Bown 7397fb0076 fix: UTF-8 safe truncation, deduplicate url_encode, add Display impls, fix pricing display, simplify model matching, fix changelog links, add PartialEq for Tool (#3) 2026-02-24 22:36:47 -06:00
Hunter Bown a94f29b47b chore: remove frontend CI job and obsolete pnpm workspace files 2026-02-24 10:40:25 -06:00
Hunter Bown 7a6be37e2d fix: preload deferred tools by default in yolo mode 2026-02-24 10:37:17 -06:00
Hunter Bown 8d904129b5 Release 0.3.23 2026-02-24 10:31:54 -06:00
Hunter Bown 64e29c1b73 chore: remove deprecated workspace references 2026-02-19 17:36:03 -06:00
Hunter Bown b88ce88a42 Improve TUI coherence, small-screen layout, and contrast guardrails 2026-02-19 10:09:41 -06:00
Hunter Bown 6130617b8d docs: simplify README 2026-02-19 07:58:24 -06:00
Hunter Bown b04f2860fe docs: remove standalone app section from README 2026-02-19 07:55:46 -06:00
Hunter Bown 07531903e7 docs: add hero screenshot and model support line 2026-02-19 07:52:51 -06:00
Hunter Bown 9b647edf92 docs: tighten README, add v0.3.21 changelog entry 2026-02-19 07:47:29 -06:00
Hunter Bown 8869541fbe fix: address clippy lint warnings 2026-02-19 00:06:41 -06:00
Hunter Bown 304644e448 chore: format tui sources 2026-02-19 00:05:11 -06:00
Hunter Bown d55a9c1231 Complete TUI phase 2/3 UX and help polish 2026-02-19 00:00:24 -06:00
Hunter Bown f3fe107b8e test: cover session resume-thread happy path 2026-02-18 11:15:37 -06:00
Hunter Bown 8b5f1bc83f fix: harden runtime app pre-release issues 2026-02-18 11:12:40 -06:00
Hunter Bown cfcdce3d03 feat: runtime and UX polish
P1 features:
- System prompt injection on session resume (ThreadRecord gains system_prompt
  field, ensure_engine_loaded passes it to Op::SyncSession)
- Session resume bridge: GET/POST /v1/sessions/{id}, seed_thread_from_messages
- Task detail panel with deep links (?task=<id>), timeline, tool calls
- Tauri desktop build fix (CI=true in tauri:build script)

P2 features:
- Task detail auto-refresh polling for running/queued tasks (3s interval)
- Session delete: DELETE /v1/sessions/{id} endpoint + palette delete button
- Transcript full-text search input with combined role filter + search
- Per-item copy-to-clipboard, collapse/expand for long outputs, filter chips

Polish:
- Typography scale CSS variables, skeleton loading utilities
- Panel slide-in animation for task detail, fade-up for list items
- Toast auto-dismiss (4s success, 6s error) with dismiss buttons
- Focus trap in command palette, backdrop click to close
- Escape key closes task detail panel
- ARIA improvements: role=alert on error toasts, role=listbox on palette
- Responsive breakpoints for tablet (task detail stacking, palette width)
- prefers-reduced-motion respected throughout

All validation checks pass:
- cargo test (517 tests), cargo check
- pnpm typecheck, lint (0 errors), test (36 tests)
- pnpm web:build, desktop:build
2026-02-18 10:58:13 -06:00
Hunter Bown cd41e381e7 chore: release v0.3.21 2026-02-18 08:59:27 -06:00
Hunter Bown d0a3d1d7aa fix: isolate test env and use mock engines in runtime_api tests
- Config tests: add DEEPSEEK_API_KEY to EnvGuard so apply_env_overrides()
  doesn't leak the real key into test assertions. Refactor Drop impl
  with a shared restore_var helper.
- Runtime API tests: convert thread_endpoints, events_endpoint, and
  stream_endpoint tests from real engine (which calls DeepSeek API and
  times out) to mock engines via install_test_engine. The lifecycle
  test mock handles both SendMessage and CompactContext ops.
2026-02-18 08:58:21 -06:00
Hunter Bown 605591d064 Feature: parallel tool execution, UI/widget enhancements, onboarding improvements, engine upgrades
- Add parallel batch execution for read-only tools
- Enhance UI widgets, sidebar focus, and onboarding flows
- Upgrade engine with error escalation, tool search, and risk-based replanning
- Extend client with server tool usage and container support
- Update settings and compaction logic
- Improve MCP resource handling and subagent coordination
- Update README with new tool count
2026-02-18 08:17:15 -06:00
Hunter Bown 6fedee021e Improve Plan mode approval UX and release v0.3.20 2026-02-17 17:10:47 -06:00
Hunter Bown d2b64b260e Stabilize tilde expansion test under Windows home env 2026-02-17 16:33:45 -06:00
Hunter Bown c81287cdfd Fix clippy manual char comparison lint 2026-02-17 16:28:16 -06:00
Hunter Bown 91721d8a17 Fix context usage display and config path/session overrides 2026-02-17 16:24:57 -06:00
Hunter Bown 1a04659a95 Add capacity memory controller and smoother TUI streaming 2026-02-17 16:09:07 -06:00
Hunter Bown 87884a1e84 Improve model handling, context recovery, and stabilize config tests 2026-02-16 17:57:22 -06:00
Hunter Bown ccefab8d44 docs: clean up README, remove roadmap.md
- Rewrite README to be concise and user-focused
- Replace verbose tool inventory with capability summary
- Move Runtime API details to docs link
- Trim troubleshooting, config, and keyboard shortcuts
- Remove roadmap.md
2026-02-16 12:24:01 -06:00
Hunter Bown 5f90e1685e fix: resolve CI failures — fmt + remove unused function
- Run cargo fmt to fix trailing whitespace and formatting
- Remove unused `get_compaction_instruction()` that triggered -D warnings
2026-02-16 12:22:14 -06:00
Hunter Bown b556559fb4 chore: v0.3.18 — expand docs, remove PARITY.md, clean up gitignore
- Remove PARITY.md (no longer needed)
- Expand README tools section with full tool inventory and troubleshooting entries
- Add known-issue notes to AGENTS.md (finance tool, token tracking, web.run naming)
- Update .gitignore with additional temp file patterns
- Bump version to 0.3.18
2026-02-16 12:19:50 -06:00
Hunter Bown 19a9724008 fix: reload engine after onboarding API key save 2026-02-16 11:43:14 -06:00
Hunter Bown 4212ba9f26 fix: restore API key config resolution and clean runtime artifacts 2026-02-16 11:37:14 -06:00
Hunter Bown 6c05c3b3ac feat: agent-controlled context compaction (Claude Code style)
- Add agent-facing instructions about /compact command in system prompt
- Enhance compaction summary with workflow context (files, tools, tasks)
- Add 'What to Do Next' guidance after compaction
- Change /compact to trigger immediate compaction (not toggle)
- Extract workflow context from tool usage and messages
- Lower auto-compaction threshold to 85% for earlier triggering

The agent now knows it can use /compact when context gets long,
similar to Claude Code's approach.
2026-02-16 10:58:11 -06:00
Hunter Bown ab2c708ca7 feat: runtime API, task manager, and extensive improvements (v0.3.16)
Major Features:
- Runtime API for external integrations and turn management
- Task manager with persistence and recovery
- Shell output streaming and improved tool execution
- Error taxonomy and audit logging
- Command palette and UI enhancements

Documentation:
- Runtime API documentation
- Operations runbook
- Architecture updates

Fixes:
- Auto-compaction threshold and triggering logic
- Doctor command API key validation
- Clippy and formatting compliance
2026-02-16 10:51:39 -06:00
Hunter Bown fa3905041d fix: auto-compaction not triggering and doctor API key check
- Fix doctor command to properly validate DEEPSEEK_API_KEY (check non-empty)
- Lower auto-compaction threshold from 95% to 85% to trigger earlier
- Fix should_compact() to always trigger when over token threshold
  (even with few unpinned messages)

This fixes the issue where auto-compaction wasn't working and users
would hit context length errors.
2026-02-16 10:50:25 -06:00
Hunter Bown ab87765c26 fix: use is_multiple_of() to satisfy clippy 1.93 2026-02-08 12:50:44 -06:00
Hunter Bown b20937ba55 fix: remove extra blank line to pass cargo fmt check 2026-02-08 12:48:16 -06:00
Hunter Bown 6c55d80dea feat: world-class TUI improvements + deepseek-v3.2 default (v0.3.15)
- Real SSE streaming (token-by-token display via Chat Completions API)
- Token cost tracking with real DeepSeek pricing in header
- Context window usage percentage display
- Atomic session writes (crash safety via temp+rename)
- Responses API recovery (periodic probe instead of permanent fallback)
- Tool parse failure user-visible warnings
- Default model changed to deepseek-v3.2 everywhere
- V3.2 reasoning/thinking support
- V3.2 pricing and context window entries
2026-02-08 12:46:51 -06:00
Hunter Bown 4e516a7c1f Simplify UI and improve footer display (v0.3.14) (#2)
* Polish UI, fix bugs, and rewrite README for clarity

- Fix welcome banner generating a fake session ID (random UUID that
  didn't match the actual session); now shows tips and omits the
  misleading session line
- Fix footer redundancy: mode and model were shown in both the header
  and footer; footer now shows session ID, token count, and help hint
- Fix STATUS_SUCCESS and STATUS_WARNING being the same color
  (both DEEPSEEK_SKY), making success/warning states indistinguishable;
  success is now green, warning is amber
- Remove unprofessional thinking tagline ("You're absolutely right!
  ... maybe."); replaced with professional labels
- Fix README mode cycle documentation (said Normal->Plan->Agent->YOLO
  but code actually cycles Plan->Agent->YOLO->Plan)
- Fix README tool miscategorization: web.run, web_search,
  request_user_input, and multi_tool_use.parallel were listed under
  "File Operations" instead of their own categories
- Rewrite README with numbered getting-started steps, keyboard
  shortcuts table, cleaner tool categories, environment variable table,
  and compact troubleshooting table
- Remove unused imports (chrono::Local, uuid::Uuid, Color, Modifier)
  and dead code (mode_color, mode_badge_style)

https://claude.ai/code/session_011XiGZRhainhyvZ8BKKrfzj

* Update src/tui/app.rs
* Update README.md
2026-02-05 13:29:38 -06:00
Hunter Bown 702f9c353c Release v0.3.14
- Add image_query support to web.run (DuckDuckGo image search)
- Encode tool-call function names for Chat Completions history rebuild
- Allow safe MCP meta tools in multi_tool_use.parallel
- Update prompts for stronger citation placement and quote-limit guidance
2026-02-05 09:28:39 -06:00
Hunter Bown 4ded9e2a4f Restore transcript scrollbar 2026-02-03 19:05:44 -06:00
Hunter Bown 9f47d78a03 Release v0.3.12 2026-02-03 18:52:55 -06:00
Hunter Bown 67b371ca95 Encode tool names for DeepSeek API 2026-02-03 18:52:00 -06:00
Hunter Bown 858649d458 Fix tool name mapping for DeepSeek API 2026-02-03 18:45:49 -06:00
Hunter Bown 433d7cb6e9 Release v0.3.10 2026-02-03 18:31:53 -06:00
Hunter Bown e0bccecd5c Remove RLM/Duo modes and restore footer scroll 2026-02-03 18:29:36 -06:00
Hunter Bown 02ecc51e9c Fix clippy warnings 2026-02-03 17:42:48 -06:00
Hunter Bown 72d1c43d50 Apply rustfmt 2026-02-03 17:38:52 -06:00
Hunter Bown 9e3aafeabb Release 0.3.7 2026-02-03 17:36:17 -06:00
Hunter Bown 325aaefc00 Update tool parity and skills docs 2026-02-03 17:34:55 -06:00
Hunter Bown 62d2877b3b Release v0.3.6: UI polish - welcome banner, context progress bar, remove custom scrollbar 2026-02-02 12:05:48 -06:00
Hunter Bown 3f8d04178c feat: v0.3.5 — smart context offloading, full MCP protocol, Duo mode, project_map tool
- Intelligent context offloading: large tool results (>15k chars) auto-moved to RLM memory
- Persistent history context: compacted messages offloaded to RLM history variable
- Full MCP protocol: SSE transport, Resources (resources/list, resources/read), Prompts (prompts/list, prompts/get)
- mcp_read_resource and mcp_get_prompt virtual tools exposed to the model
- Dialectical Duo mode with Player/Coach TUI rendering
- Dynamic system prompt refresh at each turn for up-to-date RLM/Duo/working-set context
- project_map tool for automatic codebase structure discovery
- delegate_to_agent alias for streamlined sub-agent delegation
- Default theme changed to Whale with updated color palette
- Fix MCP test compilation for updated McpServerConfig struct shape
- Fix clippy warnings (strip_prefix, inspect_err, flatten, is_some_and)
2026-01-30 06:02:43 -06:00
Hunter Bown 18ce9f96f1 chore: update Cargo.lock for v0.3.4 2026-01-29 12:50:53 -06:00
Hunter Bown 5466cd7a19 fix: compaction tool-call pairing, safety net scanning, and double-dispatch race
- Set is_loading immediately in dispatch_user_message to prevent
  a race condition where the user could dispatch two messages before
  the TurnStarted event arrived
- Raise fixpoint loop cap in enforce_tool_call_pairs from 10 to
  messages.len() so long conversations converge; add warn logging
  on non-convergence
- Scan non-contiguous tool results in build_chat_messages safety net
  and drain orphaned results that aren't immediately adjacent
- Log specific missing tool IDs when stripping orphaned tool_calls
- Add tests for partial tool results and long-chain convergence
- Bump version to 0.3.4
2026-01-29 12:50:31 -06:00
Hunter Bown 3c3c3f49ce feat: polish TUI with Kimi-style footer, streaming thinking blocks, and cleanup
- Redesign footer: live clock, lowercase mode badges, color-coded context %
- Stream thinking/reasoning blocks in real-time with sidebar style and cursor
- Replace ThinkingSummary with richer Thinking variant in history cells
- Remove dead code (unused footer helpers, context bar, copy hint)
- Bump version to 0.3.3
2026-01-29 11:27:38 -06:00
Hunter Bown a7c5ef9374 Fix formatting 2026-01-28 10:05:25 -06:00
Hunter Bown 55148906f2 Fix clippy warnings
- Remove unnecessary to_string() in client.rs
- Derive Default for SubAgentType instead of manual impl
- Remove redundant let binding in header.rs
2026-01-28 10:01:50 -06:00
Hunter Bown df45aece8f Apply cargo fmt formatting fixes 2026-01-28 09:59:35 -06:00
Hunter Bown 32443ae91b Fix CI: Remove redundant toolchain input from dtolnay/rust-toolchain@stable
The dtolnay/rust-toolchain action already specifies the toolchain version
in the action reference (@stable). Adding toolchain: stable as an input
was causing 'toolchain is a required input' errors.

Fixed in:
- ci.yml
- release.yml
- crates-publish.yml
2026-01-28 09:58:28 -06:00
Hunter Bown 4ae8c8a3fa Fix CI toolchain inputs and normalize working set paths 2026-01-28 09:56:34 -06:00
360 changed files with 131111 additions and 28930 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# DeepSeek TUI environment
# Shell-exported variables override values in this file.
# Copy this file to `.env`, then uncomment only the values you want to use.
# DeepSeek API (default provider)
# Get an API key from DeepSeek, then keep it local in `.env`.
# DEEPSEEK_API_KEY=
# Global endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# China endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
# to the current V4 flash model in the TUI.
# DEEPSEEK_MODEL=deepseek-v4-pro
# DEEPSEEK_MODEL=deepseek-v4-flash
# NVIDIA NIM-hosted DeepSeek V4
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
# DEEPSEEK_PROVIDER=nvidia-nim
# NVIDIA_API_KEY=
# NVIDIA_NIM_API_KEY=
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
# DEEPSEEK_LOG_LEVEL=debug
# RUST_LOG=deepseek_tui=debug
# Agent safety defaults
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
# inside the workspace unless a sandbox elevation path is explicitly used.
# DEEPSEEK_APPROVAL_POLICY=on-request
# DEEPSEEK_SANDBOX_MODE=workspace-write
# DEEPSEEK_ALLOW_SHELL=true
# Optional extension paths
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
+85
View File
@@ -0,0 +1,85 @@
name: Auto-tag on version bump
# When the workspace version on `main` advances past the latest existing
# `vX.Y.Z` tag, push the matching tag automatically. The push then triggers
# `release.yml`, which runs parity, builds binaries, drafts the GitHub
# Release, and publishes the npm wrapper.
#
# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger
# downstream `on: push: tags` workflows (GitHub Actions safety rule). For
# this auto-tag flow to actually fire `release.yml`, store a PAT (or
# fine-grained token) with `contents: write` on this repo as the
# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml`
# does NOT run automatically — you'd have to push the tag again manually
# (`git push origin v$VERSION` from a developer machine) to trigger release.
on:
push:
branches: [main]
paths:
- 'Cargo.toml'
- 'npm/deepseek-tui/package.json'
workflow_dispatch:
permissions:
contents: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Prefer PAT so the resulting tag push triggers release.yml.
# Falls back to GITHUB_TOKEN, which will tag but NOT trigger.
token: ${{ secrets.RELEASE_TAG_PAT || github.token }}
- name: Read workspace version
id: ver
run: |
v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
if [ -z "$v" ]; then
echo "::error::Could not parse workspace version from Cargo.toml" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "Workspace version: $v"
- name: Check whether tag already exists
id: check
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists; nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} does not exist; will create."
fi
- name: Verify version consistency
if: steps.check.outputs.exists == 'false'
run: ./scripts/release/check-versions.sh
- name: Create and push tag
if: steps.check.outputs.exists == 'false'
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "${TAG}"
git push origin "${TAG}"
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
- name: Warn if PAT missing
if: steps.check.outputs.exists == 'false' && env.HAS_PAT != 'true'
env:
HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }}
run: |
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Manually re-push the tag from a developer machine, or run 'gh workflow run release.yml --ref ${{ steps.ver.outputs.tag }}'."
+86 -5
View File
@@ -11,6 +11,21 @@ env:
RUSTFLAGS: -Dwarnings
jobs:
versions:
name: Version drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Check version drift
run: ./scripts/release/check-versions.sh
lint:
name: Lint
runs-on: ubuntu-latest
@@ -19,11 +34,18 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features
# Mirror the release-workflow `parity` gate exactly. Anything that
# would fail there must fail here so we never push a `v*` tag that
# the npm publish pipeline can't ship. The Release job runs with
# `--locked` + `-D warnings`; we do the same.
- name: Clippy (release-strict)
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
test:
name: Test
@@ -34,11 +56,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --all-features
run: cargo test --workspace --all-features --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
- name: Run Offline Eval Harness
run: cargo run --all-features -- eval
run: cargo run -p deepseek-tui --all-features -- eval
build:
name: Build
@@ -49,10 +76,61 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --release
npm-wrapper-smoke:
name: npm wrapper smoke
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: Swatinem/rust-cache@v2
- name: Build wrapper binaries
run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
- name: Prepare local release assets
run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets"
- name: Start local release server
run: |
python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 &
sleep 1
- name: Pack npm wrapper
working-directory: npm/deepseek-tui
env:
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
run: npm pack
- name: Install packed wrapper
env:
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
run: |
mkdir -p "$RUNNER_TEMP/npm-smoke"
cd "$RUNNER_TEMP/npm-smoke"
npm init -y
npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz
- name: Smoke wrapper entrypoints
env:
DEEPSEEK_TUI_FORCE_DOWNLOAD: "1"
DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/
run: |
cd "$RUNNER_TEMP/npm-smoke"
npx --no-install deepseek --help
npx --no-install deepseek-tui --help
# Check documentation builds without warnings
docs:
name: Documentation
@@ -60,8 +138,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Build docs
run: cargo doc --no-deps
run: cargo doc --workspace --no-deps
env:
RUSTDOCFLAGS: -Dwarnings
+10 -10
View File
@@ -7,24 +7,24 @@ on:
jobs:
publish:
name: Publish to crates.io
name: Publish workspace crates to crates.io
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Verify version matches tag
if: github.event_name == 'release'
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version')
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
run: ./scripts/release/verify-workspace-version.sh "${GITHUB_REF#refs/tags/v}"
- name: Publish to crates.io
run: cargo publish
- name: Preflight workspace crate publishes in workspace order
run: ./scripts/release/publish-crates.sh dry-run
- name: Publish crates.io packages in workspace order
run: ./scripts/release/publish-crates.sh publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+62
View File
@@ -0,0 +1,62 @@
name: parity
on:
pull_request:
push:
branches:
- main
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
jobs:
parity:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
run: cargo check --workspace --all-targets --locked
- name: Clippy
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Unit and parity tests
run: cargo test --workspace --all-features --locked
- name: TUI snapshot parity
run: cargo test -p deepseek-tui-core --test snapshot --locked
- name: Protocol schema sanity
run: cargo test -p deepseek-protocol --test parity_protocol --locked
- name: State persistence sanity
run: cargo test -p deepseek-state --test parity_state --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
+42
View File
@@ -0,0 +1,42 @@
name: Publish npm
on:
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish, without the leading v'
required: true
type: string
env:
CARGO_TERM_COLOR: always
jobs:
publish:
runs-on: ubuntu-latest
# `release.yml` no longer publishes to npm — see CLAUDE.md "Releases" for
# the manual flow we actually use. This workflow remains as inert plumbing
# that only works if npm Trusted Publishing is configured for it; trigger
# via `gh workflow run publish-npm.yml -f version=X.Y.Z` if you've set
# that up on the npm side.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Verify package version
working-directory: npm/deepseek-tui
run: |
actual="$(node -p "require('./package.json').version")"
expected="${{ inputs.version }}"
if [ "${actual}" != "${expected}" ]; then
echo "package.json version ${actual} does not match requested ${expected}" >&2
exit 1
fi
- name: Publish wrapper to npm
working-directory: npm/deepseek-tui
run: npm publish --access public
+80 -1
View File
@@ -3,12 +3,53 @@ name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish to npm, without the leading v'
required: true
type: string
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
jobs:
parity:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
run: cargo check --workspace --all-targets --locked
- name: Clippy
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Workspace tests
run: cargo test --workspace --all-features --locked
- name: TUI snapshot parity
run: cargo test -p deepseek-tui-core --test snapshot --locked
- name: Protocol schema parity
run: cargo test -p deepseek-protocol --test parity_protocol --locked
- name: State persistence parity
run: cargo test -p deepseek-state --test parity_state --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
build:
needs: parity
strategy:
matrix:
include:
# --- deepseek (cli) ---
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: deepseek
@@ -25,13 +66,33 @@ jobs:
target: x86_64-pc-windows-msvc
binary: deepseek.exe
artifact_name: deepseek-windows-x64.exe
# --- deepseek-tui (TUI) ---
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: deepseek-tui
artifact_name: deepseek-tui-linux-x64
- os: macos-latest
target: x86_64-apple-darwin
binary: deepseek-tui
artifact_name: deepseek-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
binary: deepseek-tui
artifact_name: deepseek-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: deepseek-tui.exe
artifact_name: deepseek-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- run: cargo build --release --target ${{ matrix.target }}
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config
- run: cargo build --release --locked --target ${{ matrix.target }}
- name: Rename binary
shell: bash
run: |
@@ -51,7 +112,25 @@ jobs:
path: artifacts
- name: List artifacts
run: find artifacts -type f
- name: Generate checksum manifest
shell: bash
run: |
mkdir -p artifacts/checksums
manifest="artifacts/checksums/deepseek-artifacts-sha256.txt"
: > "${manifest}"
while IFS= read -r -d '' file; do
hash="$(sha256sum "${file}" | awk '{print $1}')"
base="$(basename "${file}")"
printf '%s %s\n' "${hash}" "${base}" >> "${manifest}"
done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z)
cat "${manifest}"
- uses: softprops/action-gh-release@v1
with:
files: artifacts/*/*
prerelease: false
# npm publish is intentionally not automated. The npm account requires 2FA OTP
# on every publish, and a granular automation token that bypasses 2FA has not
# been provisioned. Release the npm wrapper manually from a developer machine
# after the GitHub Release has been created — see CLAUDE.md "Releases" for the
# exact commands.
+26 -7
View File
@@ -11,6 +11,8 @@
# Development
.env
.env.*
!.env.example
node_modules/
.vscode/
.idea/
*.swp
@@ -33,24 +35,41 @@ dist/
# Logs
*.log
firebase-debug.log
# Generated
outputs/
# Rust
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
firebase-debug.log
tmp/
# Reference papers / large research blobs (keep locally if needed, don't ship)
docs/DeepSeek_V4.pdf
docs/*.pdf
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
# Local dev scripts and temp files
*.sh
!scripts/**
test.txt
TODO*.md
todo*.md
CLAUDE.md
NEXT_SESSION.md
AI_HANDOFF.md
result.json
count_deps.py
project_overhaul_prompt.md
.codex/
docs/rlm-paper.txt
.context/
# Local runtime state
.deepseek/
**/session_*.json
*.db
# Companion app (tracked separately)
apps/
# Claude Code runtime artifacts
.claude/scheduled_tasks.lock
.claude/worktrees/
.worktrees/
+16
View File
@@ -0,0 +1,16 @@
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> GitHub <[email protected]>
Hunter Bown <[email protected]> Claude <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
Hunter Bown <[email protected]> Copilot <[email protected]>
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
Hunter Bown <[email protected]> qwencoder <[email protected]>
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
+28 -16
View File
@@ -5,29 +5,41 @@ This file provides context for AI assistants working on this project.
## Project Type: Rust
### Commands
- Build: `cargo build`
- Test: `cargo test`
- Run: `cargo run`
- Check: `cargo check`
- Format: `cargo fmt`
- Lint: `cargo clippy`
- Build: `cargo build` (default-members include the `deepseek` dispatcher)
- Test: `cargo test --workspace --all-features`
- Lint: `cargo clippy --workspace --all-targets --all-features`
- Format: `cargo fmt --all`
- Run (canonical): `deepseek` — use the **`deepseek` binary**, not `deepseek-tui`. The dispatcher delegates to the TUI for interactive use and is the supported entry point for every flow (`deepseek`, `deepseek -p "..."`, `deepseek doctor`, `deepseek mcp …`, etc.).
- Run from source: `cargo run --bin deepseek` (or `cargo run -p deepseek-tui-cli`).
- Local dev shorthand: after `cargo build --release`, run `./target/release/deepseek`.
### Project: deepseek-cli
### Build Dependencies
- **Rust** 1.85+ (for the workspace)
### Documentation
See README.md for project overview.
See README.md for project overview, docs/ARCHITECTURE.md for internals.
### Version Control
This project uses Git. See .gitignore for excluded files.
## DeepSeek-Specific Notes
- **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction.
- **Reasoning Models**: `deepseek-v4-pro` and `deepseek-v4-flash` are the documented V4 model IDs. Legacy `deepseek-chat` and `deepseek-reasoner` are compatibility aliases for `deepseek-v4-flash`.
- **Large Context Window**: DeepSeek V4 models have 1M-token context windows. Use search tools to navigate efficiently.
- **API**: OpenAI-compatible Chat Completions (`/chat/completions`) is the documented DeepSeek API path. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`); `/v1` is accepted for OpenAI SDK compatibility, and `/beta` is only needed for beta features such as strict tool mode, chat prefix completion, and FIM completion.
- **Thinking + Tool Calls**: In V4 thinking mode, assistant messages that contain tool calls must replay their `reasoning_content` in all subsequent requests or the API returns HTTP 400.
## Guidelines
## GitHub Operations
- Follow existing code style and patterns
- Write tests for new functionality
- Keep changes focused and atomic
- Document public APIs
Use the **`gh` CLI** (`/opt/homebrew/bin/gh`) for all GitHub operations — issues, PRs, branches, labels. It's already authenticated as `Hmbown` (token scopes: `gist`, `read:org`, `repo`, `workflow`). Examples:
- List open issues: `gh issue list --state open --limit 20`
- View an issue: `gh issue view <number>`
- Create an issue branch: `gh issue develop <number> --branch-name feat/issue-<number>-<slug>`
- Create a PR: `gh pr create --base feat/v0.6.2 --title "..." --body "..."`
- Check PR status: `gh pr view <number>`
Prefer `gh` over `fetch_url` or `web_search` for GitHub data — it's faster, authenticated, and avoids rate limits.
## Important Notes
<!-- Add project-specific notes here -->
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
- **Modes**: Three modes — Plan (read-only investigation), Agent (tool use with approval), YOLO (auto-approved). See `docs/MODES.md` for details. All three modes can call the `rlm_query` tool for parallel/batched LLM fan-out (`crates/tui/src/tools/rlm_query.rs`).
+514 -11
View File
@@ -7,6 +7,488 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.7.1] - 2026-04-28
### Added
- Grouped active tool-call cards with compact rails and a live working-status row while tools run. (#142, #149)
- Selected-card-aware Alt+V details so the visible or selected tool card opens the matching detail payload. (#143)
- Compact terminal-native session context inspector with persisted `@path` and `/attach` reference metadata for resumed transcripts. (#146, #150)
### Changed
- Polished tool cards, diff summaries, and pending context previews for denser terminal-native scanning. (#141, #144, #145, #148)
- Ranked Ctrl+P file-picker results with working-set relevance from modified files, recent `@file` mentions, and recent tool paths while keeping fuzzy filtering in memory. (#147)
## [0.7.0] - 2026-04-28
### Added
- OS keyring-backed auth storage with `deepseek auth` subcommands, migration from plaintext config, provider-aware key resolution, and doctor visibility. (#134)
- Egress network policy with allow/deny/prompt decisions, deny-wins matching, audit logging, and enforcement hooks for network-capable tools. (#135)
- LSP diagnostics auto-injection after edits so compile feedback can be reinjected into the next agent turn. (#136)
- Side-git workspace snapshots, `/restore`, and `revert_turn` so agent edits can be rolled back without moving the user's repository HEAD. (#137)
- Esc-Esc backtrack over prior user turns, desktop turn-complete notifications, Alt+V tool-details access, safer command-prefix auto-allow matching, bundled `skill-creator`, and `/skill install` management for community skills. (#131, #132, #133, #138, #139, #140)
### Changed
- Split more engine/tool primitives into focused modules and workspace crates, including shared tool result primitives and extracted turn/capacity flow. (#67, #74)
### Tests
- Added mock LLM and skill-install integration coverage for streaming turns, reasoning replay, tool-call loops, network policy, and skill validation. (#69, #140)
## [0.6.5] - 2026-04-27
### Added
- **`rlm_process` tool — recursive language model as a tool call.** The previous `/rlm` slash command had a UI rendering gap (the answer never made it back to the model's view) and required the user to remember to invoke it manually. `rlm_process` exposes the full RLM loop as a structured tool the model itself can choose, the same way it reaches for `agent_spawn` or `rlm_query`. Inputs: `task` (small instruction, shown to the root LLM each iteration) plus exactly one of `file_path` (workspace-relative, preferred — keeps the long input out of the model's context entirely) or `content` (inline, capped at 200k chars). Optional `child_model` (default `deepseek-v4-flash`) and `max_depth` (default 1, paper experiments). Returns the synthesized answer with metadata (iterations, duration, tokens, termination reason). Loaded across Plan / Agent / YOLO; never deferred via ToolSearch. (`crates/tui/src/tools/rlm_process.rs`)
- **Reference-aligned REPL surface.** Aligned the in-REPL Python helpers with the canonical reference RLM (alexzhang13/rlm). The sub-agent now sees `context` (the full input, not `PROMPT`), `llm_query`, `llm_query_batched`, `rlm_query` (was `sub_rlm`), `rlm_query_batched`, `SHOW_VARS()`, `FINAL(...)`, `FINAL_VAR(...)`, plus `repl_get`/`repl_set`. Same prompt patterns and decomposition strategies from the paper now apply verbatim. (`crates/tui/src/repl/runtime.rs`)
- **Concurrent fanout from inside the REPL.** `llm_query_batched(prompts, model=None)` runs up to 16 child completions in parallel via a new `POST /llm_batch` sidecar endpoint — much faster than serial `[llm_query(p) for p in prompts]`. `rlm_query_batched(prompts)` does the same for recursive RLM sub-calls via `POST /rlm_batch`. (`crates/tui/src/rlm/sidecar.rs`)
- **`SHOW_VARS()`** — returns `{name: type-name}` for every user variable in the REPL. Lets the model inspect what it has accumulated across rounds before deciding whether to call `FINAL_VAR(name)`.
- **Auto-persistence of REPL variables across rounds.** Any top-level JSON-serializable variable the sub-agent creates in a `repl` block now persists to the next round automatically — no `repl_set` ceremony needed unless you want explicit control. Matches the in-process reference REPL semantics.
### Changed
- **Code fence is `repl`, not `python`.** Matches the reference RLM language identifier so the same prompts and few-shot examples work here. Backward-compat fallback to `python` / `py` retained for older model behaviors.
- **`FINAL` / `FINAL_VAR` parseable from raw response text.** The reference RLM lets the model write `FINAL(value)` on its own line outside any code block to terminate the loop. Added `parse_text_final()` so that path works alongside the existing in-REPL Python sentinel mechanism. Code-fenced occurrences of `FINAL(...)` are correctly ignored to avoid false positives.
- **Strict termination loop.** The sub-agent must emit a ```repl block (or text-level FINAL) to make progress. One fence-less round triggers a reminder; two consecutive trigger a `RlmTermination::DirectAnswer` exit so we don't loop forever.
- **`rlm_process` separates `task` (root_prompt) from `file_path`/`content` (context).** The `task` rides along as `root_prompt` and is shown to the root LLM each iteration; the big input lives only in the REPL as `context`. Mirrors the reference's `completion(prompt, root_prompt=...)` API.
- **System prompt rewritten** with the reference's strategy patterns (PREVIEW → CHUNK + map-reduce via `llm_query_batched` → RECURSIVE decomposition via `rlm_query` → programmatic computation + LLM interpretation).
- The `/rlm` slash command stays for manual experimentation but is no longer the recommended path; the description in `commands/mod.rs` now points the model toward `rlm_process` for the in-agent flow.
### Reference
- Zhang, Kraska, Khattab. "Recursive Language Models." arXiv:2512.24601.
- alexzhang13/rlm — reference implementation by the paper authors. Variable names, helper surface, and code-fence convention align with that repo so prompts and patterns transfer.
### Fixed
- **`/rlm` actually recurses now (Algorithm 1 substrate, paper-faithful).** The v0.6.3 RLM loop had the right *shape* but its recursive substrate was non-functional: `llm_query()` was a Python stub that returned a hardcoded string, and `child_model` was bound with an underscore prefix and silently dropped. The loop ran but the sub-LLM never fired. v0.6.4 fixes this end-to-end:
- **HTTP sidecar.** Each RLM turn spins up a localhost-only axum server on a kernel-assigned port for the duration of the turn. Python's `llm_query()` and `sub_rlm()` are real `urllib.request.urlopen` POSTs; Rust services them via the existing DeepSeek client and returns the completion text. No long-lived python process, no FIFOs, no two-pass replay — Python blocks on HTTP, Rust answers it. (`crates/tui/src/rlm/sidecar.rs`)
- **`child_model` is plumbed through.** `Op::RlmQuery` and `AppAction::RlmQuery` carry the configured child model (default `deepseek-v4-flash`) all the way to the sidecar, where every `llm_query()` call uses it. Token usage is folded into `RlmTurnResult.usage` so cost tracking works.
- **`sub_rlm()` is exposed as a paper-faithful recursive RLM call.** The Python REPL gets a real `sub_rlm(prompt)` function that runs another full Algorithm-1 turn at depth-1 inside the same process (different sidecar route, decremented recursion budget). Default `max_depth = 2` from the `/rlm` command — the model can recurse twice before the budget hits zero. The recursive opaque-future cycle (`run_rlm_turn_inner` → `start_sidecar` → `sub_rlm_handler` → `run_rlm_turn_inner`) is broken by returning a concrete `Pin<Box<dyn Future + Send>>` from `run_rlm_turn_inner`.
- **Strict termination.** The loop only ends via `FINAL(value)` (or the iteration cap). The previous "no fence = direct answer, end loop" early-exit deviated from the paper and could short-circuit on iteration 1 with a chatty model that never saw `PROMPT`. The new behavior tolerates one fence-less round (with a reminder appended), then falls back to a `RlmTermination::DirectAnswer` exit. `RlmTurnResult` now carries a `termination: RlmTermination` enum (`Final | DirectAnswer | Exhausted | Error`) so callers can tell what happened.
- **Richer `Metadata(state)`.** The metadata message the root LLM sees now includes paper-required *access patterns* (`repl_get`, slicing, `splitlines`, `repl_set`, `llm_query`, `sub_rlm`, `FINAL`) and a live list of variable keys currently in the REPL state file — so the model can see what it's accumulated across rounds without us shipping the values themselves.
- **Unicode-safe truncation.** `truncate_text` now counts Unicode codepoints (was mixing `text.len()` bytes with `chars().take(n)`), so multi-byte previews can no longer mis-count. Per-turn temp state files are cleaned up on completion. `ROOM_TEMPERATURE` typo → `ROOT_TEMPERATURE`.
- **End-to-end smoke test.** `rlm::turn::tests::sidecar_url_is_exported_to_python_env` stands up a stand-in axum server that always replies `{"text":"pong-from-sidecar"}`, runs `print(llm_query('hello'))` in the real `PythonRuntime`, and asserts the reply round-trips. This catches future regressions in the sidecar URL passthrough.
### Reference
- Zhang, Kraska, Khattab. "Recursive Language Models." arXiv:2512.24601 (Algorithm 1).
### Added
- **Sub-agents surface in the footer status strip.** When N > 0 sub-agents are in flight, the footer grows a "1 agent" / "N agents" chip in DeepSeek-sky color matching the model badge. Hides entirely at zero. (`footer_agents_chip` in `widgets/footer.rs`)
- **`@`-mention popup is fully wired in the composer.** Previously only the App state fields existed (`mention_menu_selected`, `mention_menu_hidden`). The popup now renders below the input mirror-style with the slash menu, with `@`-prefixed entries; Up/Down navigates, Enter / Tab apply the selection, Esc hides until the next input edit. Mention takes precedence over slash because the positional check is stricter. (`visible_mention_menu_entries` + `apply_mention_menu_selection` in `file_mention.rs`)
### Fixed
- **Tool-call cells no longer flash `<command>` / `<file>` placeholders.** The engine used to emit `ToolCallStarted` from `ContentBlockStart` with `input: {}` — before any `InputJsonDelta` had streamed in — which baked the placeholder into the cell at creation time. The emission is now deferred to `ContentBlockStop` and routed through `final_tool_input`, so the cell is created with the parsed args already in hand. (engine.rs `final_tool_input`; engine/tests.rs `final_tool_input_*`)
- **`parse_invocation_count` flake.** Two `markdown_render` tests both read the global PARSE_INVOCATIONS atomic and raced when other tests called `parse()` in parallel. Switched the counter to `thread_local!<Cell<u64>>`, so each test thread sees only its own invocations. Tested 8 sequential full-suite runs: 8/8 green (was ~40% green).
### Changed
- **System prompts redesigned with decomposition-first philosophy.** All four prompt tiers (base, agent, plan, yolo) now teach the model to decompose tasks before acting — `todo_write` first for granular task tracking, `update_plan` for high-level strategy, and sub-agents for parallelizable work. Inspired by the "mismanaged geniuses hypothesis" (Zhang et al., 2026): frontier LMs are already capable enough; the bottleneck is how we scaffold their self-management. The prompts now make work visible through the sidebar (Plan / Todos / Tasks / Agents) instead of letting the model work invisibly.
- **Tool labels use progressive verbs.** "Read foo.rs" → "Reading foo.rs", "List X" → "Listing X", "Search pattern" → "Searching for `pattern`", "List files" → "Listing files". Past-tense labels read wrong while a tool is still in flight; the new forms match what the user actually sees.
- **Long-running tools grow an elapsed badge.** From 3 s onward the `running` status segment becomes `running (3s)`, `running (4s)`, … so the user can tell a tool isn't stuck. The status-animation tick (360 ms) drives the redraw; below 3 s the badge stays hidden so quick reads/greps don't churn. (history.rs `running_status_label_with_elapsed`)
- **Spinner pulse is twice as fast** — `TOOL_STATUS_SYMBOL_MS` 1800 ms → 720 ms per glyph (full 4-glyph heartbeat in ~2.88 s instead of ~7.2 s).
- **`tools/subagent.rs` is now a folder module.** Tests live in `tools/subagent/tests.rs`; runtime + manager + tool implementations stay in `tools/subagent/mod.rs`. Public API unchanged. The runtime / tool-impl split was deferred — `SubAgentTask`, `run_subagent_task`, `build_allowed_tools`, the agent prompt constants, and `normalize_role_alias` are referenced from both layers and need a small API design pass before they cleanly separate.
### Test hygiene
- **5 regression tests pin auto-scroll churn contract.** `mark_history_updated` does not scroll; tool-cell handlers only `mark_history_updated`; `add_message` and `flush_active_cell` gate on `user_scrolled_during_stream`; the per-stream lock clears at TurnComplete and when the user returns to the live tail. (P2.4)
## [0.6.1] - 2026-04-26
### Changed
- **V4 cache-hit input prices cut to 1/10th per DeepSeek's pricing update.** Pro promo 0.03625→0.003625, Pro base 0.145→0.0145, Flash 0.028→0.0028 per 1M tokens. Cache-miss and output rates unchanged.
- **Removed the "light" theme option.** It was never tested, looked bad, and the dark/whale palettes are the supported targets. Theme validation now accepts only `default`, `dark`, and `whale`.
- **System prompts redesigned with decomposition-first philosophy.** All five prompt tiers teach the model to `todo_write` before acting, `update_plan` for strategy, and sub-agents for parallel work. Inspired by the mismanaged-geniuses hypothesis (Zhang et al., 2026).
## [0.6.0] - 2026-04-25
### Added
- **`rlm_query` tool — recursive language models as a first-class structured tool.** Inspired by [Alex Zhang's RLM work](https://github.com/alexzhang13/rlm) and Sakana AI's published novelty-search research, but trimmed to what an agent loop actually needs. The model calls `rlm_query` with one prompt or up to 16 concurrent prompts; children run on `deepseek-v4-flash` by default and can be promoted to Pro per-call. Children dispatch concurrently via `tokio::join_all` against the existing DeepSeek client — no external runtime, no fenced-block DSL, no Python sandbox. Returns plain text for one prompt, indexed `[0] ...\n\n---\n\n[1] ...` blocks for many. Available in Plan / Agent / YOLO. Cost is folded into the session's running total automatically.
### Changed
- **Scroll position survives content rewrites (#56).** `TranscriptScroll::resolve_top` and `scrolled_by` no longer teleport to bottom when the anchor cell vanishes. Three-level fallback chain: same line → same cell, line 0 → nearest surviving cell at-or-before. Previously, any rewrite of the assistant message (e.g. tool-result replacement) silently dropped the user back to the live tail mid-scroll.
- **Looser command-safety chains (#57).** `cargo build && cargo test`, `git fetch && git rebase`, and similar chains of known-safe commands now escalate to `RequiresApproval` instead of being hard-blocked as `Dangerous`. Chains containing unknown commands still block.
- **`GettingCrowded` no longer surfaces a footer chip.** The context-percent header already covers conversation pressure; the chip now only fires for active engine interventions (`refreshing context`, `verifying`, `resetting plan`).
## [0.5.2] - 2026-04-25
### Added
- **`/model` opens a Pro/Flash + thinking-effort picker (#39).** Typing `/model` with no argument now pops a two-pane modal: model on the left (`deepseek-v4-pro` flagship, `deepseek-v4-flash` fast/cheap, plus a "current (custom)" row when the active id isn't one of the listed defaults), and thinking effort on the right. Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter applies both selections, Esc cancels. The effort pane intentionally exposes only **Off / High / Max** because [DeepSeek's Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model) state `low`/`medium` are mapped to `high` server-side and `xhigh` is mapped to `max` — the legacy variants stay valid in `~/.deepseek/settings.toml` for back-compat, the picker just doesn't surface them. Apply path persists `default_model` and `reasoning_effort` to settings, forwards `Op::SetModel` + `Op::SetCompaction` to the running engine so the next turn picks up the change without a restart, and resets the per-turn token gauges (cache, replay) so the footer numbers reflect the new model. `/model <id>` keeps working unchanged for power users.
## [0.5.1] - 2026-04-25
### Added
- **`fetch_url` tool** for direct HTTP GET on a known URL — complements `web_search` for cases where the link is already known. Supports `format` (`markdown` / `text` / `raw`), `max_bytes` (default 1 MB, hard cap 10 MB), `timeout_ms` (default 15 s, max 60 s), redirect following, and structured `{url, status, content_type, content, truncated}` responses. 4xx/5xx bodies are returned (with `success: false`) so the caller can read JSON error envelopes. (#33)
- **PDF support in `read_file`.** PDFs are auto-detected by extension or `%PDF-` magic bytes and extracted via `pdftotext -layout` (poppler) when available. New optional `pages` arg (`"5"` or `"1-10"`) reads page slices. Without `pdftotext`, returns a structured `{type: "binary_unavailable", kind: "pdf", reason, hint}` with install commands for macOS/Debian. (#34)
- **Reasoning-content replay telemetry, end-to-end (#30).** The chat-completions sanitizer now estimates replayed `reasoning_content` tokens (~4 chars/token), threads the value through the streaming `Usage` payload, stores it on the App, and renders an `rsn N.Nk` chip in the footer next to the cache hit-rate. The chip turns warning-coloured when replay tokens exceed 50% of the input budget, so users on long thinking-mode loops can see at a glance how much of their context window is going to V4's "Interleaved Thinking" replay (paper §5.1.1). Logged at `RUST_LOG=deepseek_tui=info` for tail-friendly diagnosis.
- **`@file` Tab-completion (#28).** Typing `@<partial>` and pressing Tab now resolves the mention against the workspace using the existing `ignore::WalkBuilder`. A unique match is spliced into the input; multiple matches with a longer common prefix extend the partial; remaining ambiguity is surfaced via the status line. The mention-expansion path that ships file contents to the model is unchanged — this is purely a discovery aid for typing the path. Inline-contents and a fuzzy popup picker are queued for v0.5.2.
- **Per-workspace external trust list (#29).** `~/.deepseek/workspace-trust.json` now records, for each workspace, the absolute paths the user has opted into reading/writing from outside that workspace. The new `/trust` slash command supports `add <path>`, `remove <path>`, `list`, `on`, `off`, and a status read with no args; the engine consults the list when constructing every `ToolContext` so changes apply on the next tool call without restart. `/diagnostics` surfaces the list. The interactive "Allow once / Always allow / Deny" approval prompt is deferred — for now grant access ahead of the turn with `/trust add <path>`.
### Fixed
- **TUI sidebar gutter bleed regression test (#36).** Snapshot tests now lock in that long single-line tool results — including a `todo_write` echo of a multi-kilobyte JSON payload — never write any cells outside `chat_area` at the widths reported in the bug (80, 120, 165, 200 cols). A second test verifies the scrollbar coexists with content along the right edge instead of overdrawing the penultimate column.
- **Version drift caught in CI.** New `versions` job in `.github/workflows/ci.yml` runs `scripts/release/check-versions.sh` on every push/PR, verifying every per-crate `Cargo.toml` inherits the workspace version, the npm wrapper matches the workspace version, and `Cargo.lock` is in sync. The release runbook now lists `check-versions.sh` as the first preflight step. (#31)
- **Per-mode soft context budget for V4 compaction trigger** (#27).
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool surface (#25).
- **Unused import + `cargo fmt` drift** that landed with `feat(#27)` and broke Build / Test / npm wrapper smoke under `-Dwarnings`.
## [0.5.0] - 2026-04-25
### Fixed
- Multi-turn tool calls on thinking-mode models no longer return HTTP 400. Every assistant message in the conversation now carries `reasoning_content` when thinking is enabled — not just tool-call rounds — matching DeepSeek's actual API validation, which rejects any assistant message missing the field even though the docs describe non-tool-call reasoning as "ignored".
- Added a final-pass wire-payload sanitizer in the chat-completions client that forces a non-empty `reasoning_content` placeholder onto any assistant message still missing one at request time. This is the last line of defense after engine-side and build-side substitution, so sessions restored from older checkpoints, sub-agents that append messages directly, and cached prefix mismatches all produce a valid request.
- On a `reasoning_content`-related 400, the client now logs the offending message indices to make future regressions diagnosable.
- Stripped phantom `web.run` references from prompts and the `web_search` tool surface ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
### Changed
- Header/UI widget refactor in the TUI (`crates/tui/src/tui/ui.rs`, `widgets/header.rs`) — internal cleanup, no user-visible behavior change.
## [0.4.9] - 2026-04-27
### Fixed
- DeepSeek thinking-mode tool-call rounds now always replay `reasoning_content` in all subsequent requests (including across new user turns), matching DeepSeek's documented API contract that assistant messages with tool calls must retain their reasoning content forever.
- Missing `reasoning_content` on a tool-call assistant message now substitutes a safe placeholder (`"(reasoning omitted)"`) instead of dropping the tool calls and their matching tool results, preventing orphaned conversation chains and API 400 errors.
- Session checkpoint now persists a Thinking-block placeholder for tool-call turns that produced no streamed reasoning text, keeping on-disk sessions structurally correct so subsequent requests avoid HTTP 400 rejections.
- Token estimation for compaction now counts thinking tokens across all tool-call rounds (not just the current user turn), aligning with the updated reasoning_content replay rule.
## [0.4.8] - 2026-04-25
### Fixed
- DeepSeek V4 Pro cost estimates now use DeepSeek's current limited-time 75% discount until 2026-05-05 15:59 UTC, then automatically fall back to the base Pro rates.
## [0.4.5] - 2026-04-24
### Fixed
- Alternate-screen TUI sessions now capture mouse input by default so wheel scrolling moves the transcript instead of exposing terminal scrollback from before the TUI started. Use `--no-mouse-capture` or `tui.mouse_capture = false` when terminal-native drag selection is preferred.
## [0.4.2] - 2026-04-24
### Fixed
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
## [0.4.1] - 2026-04-24
### Fixed
- DeepSeek V4 tool-result context now preserves large file reads and command outputs instead of compacting noisy tools to a 900-character snippet after 2k characters.
- Capacity guardrail refresh no longer performs destructive summary compaction unless the normal model-aware compaction thresholds are actually crossed.
- V4 compaction summaries retain larger tool-result excerpts and summary input when compaction is genuinely needed.
- The transcript now follows the bottom again when sending a new message, shows an in-app scrollbar when internally scrolled, and leaves mouse capture off in `--no-alt-screen` mode so terminal-native scrolling can work.
## [0.4.0] - 2026-04-23
### Added
- **DeepSeek V4 support**: `deepseek-v4-pro` (flagship) and `deepseek-v4-flash` (fast/cheap) are now first-class model IDs with 1M context windows.
- **Reasoning-effort tier**: new `reasoning_effort` config field (`off | low | medium | high | max`) mapped to DeepSeek's `reasoning_effort` + `thinking` request fields. Defaults to `max`.
- **Shift+Tab cycles reasoning-effort** through the three behaviorally distinct tiers (`off → high → max`). The current tier is shown as a ⚡ chip in the header.
- Per-model pricing table: `deepseek-v4-pro` priced at $0.145/$1.74/$3.48 per 1M tokens (cache-hit/miss/output); `deepseek-v4-flash` and legacy aliases at $0.028/$0.14/$0.28.
### Changed
- **Default model flipped to `deepseek-v4-pro`** (from `deepseek-reasoner`).
- `deepseek-chat` / `deepseek-reasoner` remain as silent aliases of `deepseek-v4-flash` for API compatibility; priced identically.
- **Context compaction**: 1M-context V4 models now compact at 800k input tokens or 2,000 messages, so short/tool-heavy sessions do not compact as if they were 128k-context runs.
- Cycling modes is now Tab-only; Shift+Tab is repurposed for reasoning-effort (reverse-mode cycle was low-value with only three modes).
- Updated help/hint strings, validator error messages, and the model picker to reference V4 IDs.
### Fixed
- `requires_reasoning_content` now recognizes `deepseek-v4*` so thinking streams render correctly on V4 models.
- DeepSeek V4 thinking-mode tool calls now preserve prior assistant `reasoning_content` whenever a tool call is replayed, matching DeepSeek's multi-turn contract and avoiding HTTP 400 rejections on later turns.
- Raw Chat Completions requests now send DeepSeek's top-level `thinking` parameter instead of the OpenAI SDK-only `extra_body` wrapper.
- Config, env, and UI model selection now normalize legacy DeepSeek aliases to `deepseek-v4-flash` instead of preserving old model labels.
- npm wrapper first-run downloads now use process-unique temp files so concurrent `deepseek` / `deepseek-tui` invocations do not race on `*.download` files.
## [0.3.33] - 2026-04-11
### Changed
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
- Palette cleanup: removed `FOOTER_HINT` color constant
### Removed
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
### Fixed
- Test updates to align with simplified footer logic
- Empty state placeholder text removed for cleaner UI
## [0.3.32] - 2026-04-11
### Added
- Finance tool: Yahoo Finance v8 quote endpoint with chart fallback, supporting stocks, ETFs, indices, forex, and crypto lookups.
- Header widget redesign: proportional truncation, context-usage bar with gradient fill, streaming indicator, and graceful narrow-terminal degradation.
- Expanded test coverage: 680+ tests including footer state, context spans, plan prompt lifecycle, workspace context refresh, header rendering, and finance tool integration tests with wiremock.
- Workspace context refresh with configurable TTL and deferred initial fetch.
- Config command additions for runtime settings management.
### Changed
- Redesigned footer status strip with mode/model/status layout, context bar, and narrow-terminal fallback.
- Plan prompt now uses numeric selection (1-4) instead of keyword input; old aliases are sent as regular messages.
- Archived outdated docs (`workspace_migration_status.md` -> `docs/archive/`).
- Trimmed AGENTS.md boilerplate and updated task counts.
- Clarified release-surface documentation: crates.io publication may lag the workspace/npm wrapper.
### Fixed
- Header `metadata_spans` now uses `saturating_sub` to prevent underflow on narrow terminals.
- Finance tool reuses a single HTTP client instead of rebuilding per request.
- Finance tool tests no longer leak temp directories.
## [0.3.31] - 2026-03-08
### Added
- Replaced the finance tool backend with Yahoo Finance v8 + CoinGecko fallback for reliable real-time market data (stocks, ETFs, indices, forex, crypto).
- Added compaction UX: status strip shows animated COMPACTING indicator during context summarization, footer reflects compaction state, and CompactionCompleted events now include message count statistics.
- Added send flash: brief tinted background highlight on the last user message after sending.
- Added braille typing indicator with smooth 10-frame animation cycle.
### Changed
- Redesigned the footer status strip with mode/model/token/cost layout, quadrant separators, and a context-usage bar.
- Added Unicode prefix indicators (▸ You, ◆ Answer, ● System) to chat history cells for visual distinction.
- Improved thinking token delineation with labeled delimiters in transcript rendering.
- Refactored source code into workspace crates for better modularity and dependency management.
### Fixed
- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions.
- Fixed clippy lint (collapsible_if) in web browsing session management.
## [0.3.30] - 2026-03-06
### Added
- Added a release-ready local npm smoke path that builds binaries, serves release assets locally, packs the wrapper, installs the tarball, and checks both entrypoints before publish.
- Added an opt-in full-matrix local release-asset fixture so `npm run release:check` can be exercised before GitHub release assets exist.
### Changed
- Bumped the Rust workspace crates and npm wrapper to `0.3.30`.
- Pointed the npm wrapper's default `deepseekBinaryVersion` at `0.3.30` for the next coordinated Rust + npm release.
- Updated the crates dry-run helper to work from a dirty workspace and to preflight dependent workspace crates without requiring unpublished versions to already exist on crates.io.
## [0.3.29] - 2026-03-03
### Added
- Added npm publish-time release asset verification for the `deepseek-tui` package to fail fast when expected GitHub binaries are missing.
- Added checksum manifests to GitHub release assets and checksum verification in the npm installer.
- Added `npm pack` install-and-smoke CI coverage for the `deepseek-tui` wrapper package.
- Added an end-to-end release runbook covering crates.io, GitHub Releases, and npm publication.
### Changed
- Updated npm package documentation for clearer install modes, environment overrides, and release integrity behavior.
- Improved installer support-matrix error messaging for unsupported platform/architecture combinations.
- Decoupled npm package version from default binary artifact version via `deepseekBinaryVersion`, enabling packaging-only npm releases.
- Moved the `deepseek-tui` binary target inside `crates/tui` so `cargo publish --dry-run -p deepseek-tui` works from the workspace package layout.
- Replaced the root-level crates publish workflow with an ordered workspace publish flow.
- Reworked first-run onboarding and README copy around primary workflows instead of shortcut memorization.
- Relaxed onboarding API-key format heuristics so unusual keys warn instead of blocking setup.
## [0.3.28] - 2026-03-02
### Added
- Converted the project to a modular Cargo workspace using a `crates/` layout.
- Added new crate boundaries mirroring a deepseek architecture (`agent`, `config`, `core`, `execpolicy`, `hooks`, `mcp`, `protocol`, `state`, `tools`, `tui-core`, `tui`, and `app-server`).
### Changed
- Added parity CI coverage with protocol/state/snapshot checks.
- Updated release workflow to build both `deepseek` and `deepseek-tui` binaries.
## [0.3.26] - 2026-03-02
### Fixed
- Resolved SSE stream corruption caused by byte/string position mismatch in streaming parse flow.
- Hardened base URL validation to reject non-HTTP/HTTPS schemes.
- Prevented multi-byte UTF-8 truncation panics in common-prefix and runtime thread summary paths.
- Corrected context usage alert thresholds by separating warning and critical trigger levels.
### Changed
- Removed non-code utility tools from the runtime tool registry (`calculator`, `weather`, `sports`, `finance`, `time`) and related wiring.
- Consolidated duplicate URL encoding helpers by delegating to shared `crate::utils::url_encode`.
- Replaced broad crate-level lint suppressions with targeted `#[allow(...)]` annotations where justified.
- Cleaned up dead APIs, unused struct fields, unused builder helpers, and non-integrated modules.
- Addressed clippy findings across the codebase (collapsible conditionals, defaults, indexing helpers, and API signature cleanup).
## [0.3.24] - 2026-02-25
### Fixed
- Preserve reasoning-only assistant turns for DeepSeek reasoning models (`deepseek-reasoner`, R-series markers) when rebuilding chat history.
- Align SSE tool streaming indices so each tool block start/delta/stop uses the same block index.
- Prevent transcript auto-scroll-to-bottom when a non-empty transcript selection is active.
- Allow session picker search mode to accept the current selection with a single `Enter` press.
- Preserve tool output whitespace/indentation while still wrapping long unbroken tokens.
- Make transcript selection copy/highlighting display-width aware (wide chars and tabs).
- Gate execpolicy behavior on the `exec_policy` feature flag across CLI/tool execution paths.
- Run doctor API connectivity checks using the effective loaded config/profile (instead of reloading defaults).
- Parse DeepSeek model context-window suffix hints such as `-32k` and `-256k`.
- Update README config docs with key environment overrides and a direct link to full configuration docs.
## [0.3.23] - 2026-02-24
### Changed
- Updated project copy to describe the app as a terminal-native TUI/CLI for DeepSeek models (not pinned to a specific model generation).
### Fixed
- Model selection and config validation now accept any valid `deepseek-*` model ID (including future releases), while still normalizing common aliases like `deepseek-v3.2` and `deepseek-r1`.
- Tool-call recovery now auto-loads deferred tools when the model requests them directly, instead of failing with manual `tool_search_*` instructions.
- YOLO mode now preloads tools by default (including deferred MCP tools), so model tool calls can run immediately without discovery indirection.
- Unknown tool-call failures now include discovery guidance and nearest tool-name suggestions instead of generic availability errors.
- Slash-command errors now suggest the closest known command (for example `/modle` -> `/model`) instead of only returning a generic unknown-command message.
## [0.3.22] - 2026-02-19
### Added
- Interactive `/config` editing modal for runtime settings updates.
### Changed
- Retired user-facing `/set` command path (no longer reachable/discoverable).
- Replaced `/deepseek` command behavior with `/links` (aliases: `dashboard`, `api`).
### Fixed
- Legacy `/set` and `/deepseek` inputs now return migration guidance instead of generic unknown-command errors.
## [0.3.21] - 2026-02-19
### Added
- Parallel tool execution in `multi_tool_use.parallel` for independent task workflows.
- Session resume-thread coverage in tests.
### Changed
- Desktop and web parity polish across the TUI and runtime surfaces.
- Onboarding and approval UX refinement from prior phase 3 iteration.
### Fixed
- Runtime pre-release startup issues and config-path edge cases.
- Clippy lint regressions introduced by the last parity pass.
### Security/Hardening
- General pre-release hardening for runtime app behavior.
## [0.3.17] - 2026-02-16
### Fixed
- Config loading now expands `~` in `DEEPSEEK_CONFIG_PATH` and `--config` paths.
- When `DEEPSEEK_CONFIG_PATH` points to a missing file, config loading now falls back to `~/.deepseek/config.toml` if it exists.
### Changed
- Removed committed transient runtime artifacts (`session_*.json`, `.deepseek/trusted`) and added ignore rules to prevent re-commit.
## [0.3.16] - 2026-02-15
### Added
- `deepseek models` CLI command to fetch and list models from the configured `/v1/models` endpoint (with `--json` output mode).
- `/models` slash command to fetch and display live model IDs in the TUI.
- Slash-command autocomplete hints in the composer plus `Tab` completion for `/` commands.
- Command palette modal (`Ctrl+K`) for quick insertion of slash commands and skills.
- Persistent right sidebar in wide terminals showing live plan/todo/sub-agent state.
- Expandable tool payload views (`v` in transcript, `v` in approval modal) for full params/output inspection.
- Runtime HTTP/SSE API (`deepseek serve --http`) with durable thread/turn/item lifecycle, interrupt/steer, and replayable event timeline.
- Background task queue (`/task add|list|show|cancel` and `POST /v1/tasks`) with persistent storage, bounded worker pool, and timeline/artifact tracking.
### Changed
- Centralized the default text model (`DEFAULT_TEXT_MODEL`) and shared common model list to reduce drift across runtime/config paths.
- `/model` now clarifies that any valid DeepSeek model ID is accepted (including future releases), while still showing common model IDs.
### Fixed
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
- Aligned docs/config examples with the then-current runtime default model.
## [0.3.14] - 2026-02-05
### Added
- `web.run` now supports `image_query` (DuckDuckGo image search)
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
### Fixed
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
### Changed
- Prompts: stronger `web.run` citation placement and quote-limit guidance
## [0.3.13] - 2026-02-04
### Fixed
- Restore an in-app scrollbar for the transcript view
## [0.3.12] - 2026-02-04
### Fixed
- Map dotted tool names to API-safe identifiers for DeepSeek tool calls
- Encode any invalid tool names for API tool lists while preserving internal names
## [0.3.11] - 2026-02-04
### Fixed
- Fix tool name mapping for DeepSeek API
## [0.3.10] - 2026-02-04
### Fixed
- Always enable mouse wheel scrolling in the TUI (even without alt screen)
## [0.3.9] - 2026-02-04
### Removed
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
- Duo mode tools and prompts pending a citable research spec
### Fixed
- Footer context usage bar remains visible while status toasts are shown
### Changed
- Updated prompts and docs to reflect the simplified mode/tool surface
## [0.3.8] - 2026-02-03
### Fixed
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
## [0.3.7] - 2026-02-03
### Added
- Tooling parity updates: `weather`, `finance`, `sports`, `time`, `calculator`, `request_user_input`, `multi_tool_use.parallel`, `web.run`
- Shell streaming helpers: `exec_shell_wait` and `exec_shell_interact`
- Sub-agent controls: `send_input` and `wait` (with aliases)
- MCP resource helpers: `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` alias
### Changed
- Skills directory selection now prefers workspace `.agents/skills`, then `./skills`, then global
- Docs and prompts updated to reflect new tool surface and parity notes
## [0.3.6] - 2026-02-02
### Added
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
### Changed
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
## [0.3.5] - 2026-01-30
### Added
- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window
- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall
- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`)
- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model
- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells)
- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context
- `project_map` tool for automatic codebase structure discovery
- `delegate_to_agent` alias for streamlined sub-agent delegation
### Changed
- Default theme changed to 'Whale' with updated color palette
- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes
- MCP `McpServerConfig.command` is now `Option<String>` to support URL-only (SSE) servers
### Fixed
- MCP test compilation errors for updated `McpServerConfig` struct shape
## [0.3.4] - 2026-01-29
### Changed
- Updated Cargo.lock dependencies
### Fixed
- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence
- Safety net scanning to drop orphan tool results in the request builder
- Double-dispatch race in parallel tool execution
## [0.3.3] - 2026-01-28
### Added
- TUI polish: Kimi-style footer with mode/model/token display
- Streaming thinking blocks with dedicated rendering
- Loading animation improvements
## [0.3.2] - 2026-01-28
### Fixed
@@ -134,7 +616,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2026-01-12
### Added
- Initial alpha release of DeepSeek CLI
- Initial alpha release of DeepSeek TUI
- Interactive TUI chat interface
- DeepSeek API integration (OpenAI-compatible Responses API)
- Tool execution (shell, file ops)
@@ -145,18 +627,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...HEAD
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.6.1...HEAD
[0.6.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.9...v0.6.0
[0.4.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.8...v0.4.9
[0.4.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...v0.4.8
[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23
[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22
[0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21
[0.3.17]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.16...v0.3.17
[0.3.16]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...v0.3.16
[0.3.14]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.13...v0.3.14
[0.3.13]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.12...v0.3.13
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.1
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1
[0.1.9]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.0...v0.1.5
[0.1.0]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.1.0
[0.0.1]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.1
[0.1.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.0...v0.1.5
[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0
+33 -31
View File
@@ -1,6 +1,6 @@
# Contributing to DeepSeek CLI
# Contributing to DeepSeek TUI
Thank you for your interest in contributing to DeepSeek CLI! This document provides guidelines and instructions for contributing.
Thank you for your interest in contributing to DeepSeek TUI! This document provides guidelines and instructions for contributing.
## Getting Started
@@ -14,8 +14,8 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
1. Fork and clone the repository:
```bash
git clone https://github.com/YOUR_USERNAME/DeepSeek-CLI.git
cd DeepSeek-CLI
git clone https://github.com/YOUR_USERNAME/DeepSeek-TUI.git
cd DeepSeek-TUI
```
2. Build the project:
@@ -45,8 +45,11 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi
### Testing
- Write tests for new functionality
- Ensure all existing tests pass: `cargo test`
- For integration tests, use the `tests/` directory
- Ensure all existing tests pass: `cargo test --workspace --all-features`
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
modules), and add integration tests under the owning crate's `tests/`
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
repository root `tests/` directory is not used
### Commit Messages
@@ -59,35 +62,34 @@ Use clear, descriptive commit messages following conventional commits:
- `test:` Adding or updating tests
- `chore:` Maintenance tasks
Example: `feat: add --doctor command for system diagnostics`
Example: `feat: add doctor subcommand for system diagnostics`
## Project Structure
DeepSeek TUI is a Cargo workspace. The live runtime and the majority of TUI,
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
crates provide shared abstractions that are being extracted incrementally.
```
src/
├── main.rs # Entry point and CLI definition
├── config.rs # Configuration management
├── client.rs # HTTP client for DeepSeek API
├── llm_client.rs # LLM abstraction layer
├── models.rs # Data structures
├── mcp.rs # Model Context Protocol support
├── hooks.rs # Hook system for extensibility
├── skills.rs # Skills/plugin system
├── core/ # Core engine components
│ ├── engine.rs # Main agent loop
│ ├── session.rs # Session management
│ └── ...
├── tools/ # Built-in tools
│ ├── shell.rs # Shell execution
│ ├── file.rs # File operations
│ └── ...
├── tui/ # Terminal UI
│ ├── app.rs # Application state
│ ├── ui.rs # Rendering logic
│ └── ...
└── sandbox/ # Sandbox execution (macOS)
crates/
├── tui/ deepseek-tui binary (interactive TUI + runtime API)
├── cli/ deepseek binary (dispatcher facade)
├── app-server/ HTTP/SSE + JSON-RPC transport
├── core/ Agent loop / session / turn management
├── protocol/ Request/response framing
├── config/ Config loading, profiles, env precedence
├── state/ SQLite thread/session persistence
├── tools/ Typed tool specs and lifecycle
├── mcp/ MCP client + stdio server
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
├── execpolicy/ Approval/sandbox policy engine
├── agent/ Model/provider registry
└── tui-core/ Event-driven TUI state machine scaffold
```
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
these crates and [DEPENDENCY_GRAPH.md](DEPENDENCY_GRAPH.md) for build ordering.
## Submitting Changes
1. Create a feature branch from `main`:
@@ -121,7 +123,7 @@ When reporting issues, please include:
- Operating system and version
- Rust version (`rustc --version`)
- DeepSeek CLI version (`deepseek --version`)
- DeepSeek TUI version (`deepseek --version`)
- Steps to reproduce the issue
- Expected vs actual behavior
- Relevant error messages or logs
@@ -132,7 +134,7 @@ Be respectful and inclusive. We welcome contributors of all backgrounds and expe
## License
By contributing to DeepSeek CLI, you agree that your contributions will be licensed under the MIT License.
By contributing to DeepSeek TUI, you agree that your contributions will be licensed under the MIT License.
## Questions?
Generated
+1760 -8
View File
File diff suppressed because it is too large. Load diff
+32 -56
View File
@@ -1,68 +1,44 @@
[package]
name = "deepseek-tui"
version = "0.3.2"
[workspace]
members = [
"crates/agent",
"crates/app-server",
"crates/cli",
"crates/config",
"crates/core",
"crates/execpolicy",
"crates/hooks",
"crates/mcp",
"crates/protocol",
"crates/secrets",
"crates/state",
"crates/tools",
"crates/tui",
"crates/tui-core",
]
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.7.5"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
repository = "https://github.com/Hmbown/DeepSeek-TUI"
keywords = ["deepseek", "cli", "ai", "agent", "llm"]
categories = ["command-line-utilities"]
[[bin]]
name = "deepseek"
path = "src/main.rs"
[dependencies]
[workspace.dependencies]
anyhow = "1.0.100"
arboard = "3.4"
async-stream = "0.3.6"
async-trait = "0.1"
bytes = "1.11.0"
base64 = "0.22.1"
async-trait = "0.1.89"
axum = { version = "0.8.4", features = ["json"] }
chrono = { version = "0.4.43", features = ["serde"] }
clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
colored = "3.0.0"
crossterm = "0.28"
dotenvy = "0.15.7"
dirs = "6.0.0"
futures-util = "0.3.31"
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"] }
rustyline = "15.0.0"
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] }
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
shellexpand = "3"
toml = "0.9.7"
tokio = { version = "1.49.0", features = ["full"] }
tokio-util = { version = "0.7.16", features = ["io"] }
unicode-width = "0.2"
unicode-segmentation = "1.12"
uuid = { version = "1.11", features = ["v4"] }
tokio-stream = "0.1"
chrono = { version = "0.4", features = ["serde"] }
tempfile = "3.16"
thiserror = "2.0"
tokio = { version = "1.49.0", features = ["full"] }
toml = "0.9.7"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
wait-timeout = "0.2"
multimap = "0.10.0"
shlex = "1.3.0"
starlark = "0.13.0"
tiny_http = "0.12"
zeroize = "1.8.2"
ignore = "0.4"
[dev-dependencies]
wiremock = "0.6"
pretty_assertions = "1.4"
# Platform-specific dependencies
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.60", features = ["Win32_Foundation"] }
uuid = { version = "1.11", features = ["v4"] }
+65
View File
@@ -0,0 +1,65 @@
# Dependency Graph
## Crate Dependencies (from Cargo.toml)
```
deepseek-tui (binary: `deepseek-tui`)
(no workspace deps — monolith source under crates/tui/src/)
deepseek-tui-cli (binary: `deepseek`)
<- deepseek-agent
<- deepseek-app-server
<- deepseek-config
<- deepseek-execpolicy
<- deepseek-mcp
<- deepseek-state
deepseek-app-server
<- deepseek-agent
<- deepseek-config
<- deepseek-core
<- deepseek-execpolicy
<- deepseek-hooks
<- deepseek-mcp
<- deepseek-protocol
<- deepseek-state
<- deepseek-tools
deepseek-core (agent loop)
<- deepseek-agent
<- deepseek-config
<- deepseek-execpolicy
<- deepseek-hooks
<- deepseek-mcp
<- deepseek-protocol
<- deepseek-state
<- deepseek-tools
deepseek-tools <- deepseek-protocol
deepseek-mcp <- deepseek-protocol
deepseek-hooks <- deepseek-protocol
deepseek-execpolicy <- deepseek-protocol
deepseek-agent <- deepseek-config
deepseek-config (leaf — no internal deps)
deepseek-protocol (leaf — no internal deps)
deepseek-state (leaf — no internal deps)
deepseek-tui-core (leaf — no internal deps)
```
Note: `deepseek-tui` has zero workspace deps because it still compiles the
monolith source tree (`crates/tui/src/main.rs`). The crate split is
structural — source migration into individual workspace crates is
incremental.
## Build Order (bottom-up)
```
Layer 0 (leaves): deepseek-protocol, deepseek-config, deepseek-state, deepseek-tui-core
Layer 1: deepseek-tools, deepseek-mcp, deepseek-hooks, deepseek-execpolicy
Layer 2: deepseek-agent
Layer 3: deepseek-core
Layer 4: deepseek-app-server, deepseek-tui
Layer 5: deepseek-tui-cli
```
-191
View File
@@ -1,191 +0,0 @@
# Parity Spec: Codex vs Claude Code
This document defines "parity" as measurable behavior in this repository.
It is intended to be short, testable, and easy to run during reviews.
## Scope
Parity is evaluated on:
- Instruction following (including `AGENTS.md` and task constraints)
- Rust/Cargo workflow discipline
- Change quality and scope control
- Safety and repo hygiene
- Clear, audit-friendly reporting
Unless a task says otherwise, parity targets the default Rust workflow:
1) search with `rg` 2) edit minimally 3) validate with Cargo commands.
## Parity Behaviors (Measurable)
An agent is considered at parity when it reliably exhibits the following
behaviors on eval tasks.
### 1) Instruction and Scope Compliance
Required behaviors:
- Respects path constraints (for example: "do not edit `src/*`")
- Does not revert or disturb unrelated user changes
- Avoids destructive git commands (for example: `git reset --hard`)
- Stops and reports if unexpected repo changes appear mid-task
Suggested metrics:
- `scope_violations = 0` (no edits outside allowed paths)
- `destructive_git_cmds = 0`
- `unrelated_reverts = 0`
### 2) Rust/Cargo Workflow Discipline
Required behaviors:
- Uses Cargo as the source of truth for validation
- Chooses appropriate checks for the task size/scope
- Reports validation outcomes clearly (pass/fail + command)
Suggested metrics (binary unless noted):
- `cargo_check_pass`
- `cargo_test_pass` (required for most parity gates)
- `cargo_fmt_check_pass` (when formatting could be affected)
- `cargo_clippy_pass` (recommended for non-trivial code edits)
- `validation_reported = 1` (commands + outcomes are stated)
### 3) Change Quality and Minimality
Required behaviors:
- Keeps edits focused and atomic
- Preserves existing style and patterns
- Updates documentation when public behavior changes
Suggested metrics:
- `task_acceptance_pass = 1` (task-specific checks succeed)
- `files_touched_within_expectation = 1`
- `style_regressions = 0` (via `fmt`/`clippy`/review)
### 4) Reporting Quality
Required behaviors:
- States what changed, where, and why
- Provides clickable file references
- Separates results from speculation
Suggested metrics:
- `changed_files_listed = 1`
- `key_paths_cited = 1`
- `claims_match_repo_state = 1`
## Parity Metrics and Gates
Use these gates for pass/fail decisions.
### Hard Gates (must pass)
- No scope violations
- No destructive git commands
- `cargo test` exits 0
- Task-specific acceptance checks pass
### Soft Gates (should pass; track as %)
- `cargo check` exits 0
- `cargo fmt --check` exits 0
- `cargo clippy --all-targets --all-features` exits 0
- Edits are minimal and well-scoped
- Reporting is complete and auditable
A simple parity score can be computed as:
- Fail immediately on any hard-gate violation
- Otherwise: `score = soft_gates_passed / soft_gates_total`
Target: `score >= 0.8` over a representative eval set.
## Evaluation Rubric (Short)
Score each dimension 0-2. Parity requires both conditions:
- No hard-gate violations
- Total score >= 7/8
Dimensions:
- Correctness: solution satisfies the task and acceptance checks
- Scope/Safety: constraints honored; no risky repo operations
- Rust Workflow: appropriate Cargo validation is used and reported
- Communication: changes and evidence are clear and well-referenced
Suggested anchors:
- 2 = consistently strong, no notable gaps
- 1 = acceptable but with minor gaps or ambiguity
- 0 = missing, incorrect, or risky
## Rust/Cargo Eval Task Categories
Use a small mix from each category to assess parity.
### A. Cargo Validation Loops
- Fix a failing test, then run `cargo test`
- Resolve a compiler error, validate with `cargo check`
- Address a lint warning, validate with `cargo clippy`
### B. Tests and Behavior Lock-In
- Add unit tests for a small module
- Add an integration test under `tests/`
- Convert a bug report into a regression test + fix
### C. Dependencies and Features
- Add a small crate and wire it into `Cargo.toml`
- Gate behavior behind a feature flag
- Make code compile cleanly with `--all-features`
### D. CLI and Config Surface
- Adjust a Clap flag/help string and update docs
- Add/modify a config field and update documentation
- Ensure `--help` output remains accurate
### E. Repo-Safe Documentation Tasks
- Update `README.md` or `docs/*` without touching `src/*`
- Add a short spec doc (like this one) and validate with tests
- Reconcile docs with current Cargo commands and project norms
## Milestone Checklist
Track parity progress in small, observable steps.
### M1: Safety + Docs Parity
- [ ] No scope violations on doc-only tasks
- [ ] No destructive git commands across evals
- [ ] `cargo test` is run and reported
### M2: Core Rust Workflow Parity
- [ ] `cargo check`/`test` used appropriately by default
- [ ] Formatting and linting considered when relevant
- [ ] Changes remain minimal and consistent with repo patterns
### M3: Feature and Regression Parity
- [ ] Bugs are captured with tests before or with fixes
- [ ] `--all-features` and integration tests are handled cleanly
- [ ] Public behavior changes include doc updates
### M4: Review-Ready Parity
- [ ] Reports include commands, outcomes, and key file refs
- [ ] Soft-gate score >= 0.8 across the eval set
- [ ] Maintainers can reproduce validation steps quickly
+232 -266
View File
@@ -1,288 +1,254 @@
# DeepSeek CLI 🤖
# DeepSeek TUI
Your AI-powered terminal companion for DeepSeek models
[![CI](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/deepseek-tui)](https://crates.io/crates/deepseek-tui)
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
**Not affiliated with DeepSeek Inc.**
## ✨ Features
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
- **Shell execution**: Run commands with timeout support, background execution with task management
- **Task management**: Todo lists, implementation plans, persistent notes
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
- **Web search**: Integrated web search with DuckDuckGo
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
- **Session management** – resume, fork, and search past conversations
- **Skills system** – reusable workflows stored as `SKILL.md` directories
- **Model Context Protocol (MCP)** – integrate external tool servers
- **Sandboxed execution** (macOS) for safe shell commands
- **Git integration** – code review, patch application, diff analysis
- **Cross‑platform** – works on macOS, Linux, and Windows
## 🚀 Quick Start
1. **Get an API key** from [https://platform.deepseek.com](https://platform.deepseek.com)
2. **Install and run**:
> **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.**
```bash
# Install via Cargo
cargo install deepseek-tui --locked
npm i -g deepseek-tui
```
# Set your API key
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
[![CI](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/deepseek-tui)](https://www.npmjs.com/package/deepseek-tui)
# Bootstrap MCP + skills templates (recommended)
deepseek setup
![DeepSeek TUI screenshot](assets/screenshot.png)
# Start chatting
---
## What is it?
DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives DeepSeek's frontier models direct access to your workspace — reading and editing files, running shell commands, searching the web, managing git, and orchestrating sub-agents — all through a fast, keyboard-driven TUI.
**Built for DeepSeek V4** (`deepseek-v4-pro` / `deepseek-v4-flash`) with 1M-token context windows and native thinking-mode (chain-of-thought) streaming. See the model's reasoning unfold in real time as it works through your tasks.
### Key Features
- **Native RLM** (`rlm_query` tool) — fans out 1–16 cheap `deepseek-v4-flash` children in parallel against the existing DeepSeek client for batched analysis, decomposition, or parallel reasoning
- **Thinking-mode streaming** — shows DeepSeek's chain-of-thought as it reasons about your code
- **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers
- **1M-token context** — automatic intelligent compaction when context fills up
- **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved). Decomposition-first system prompts teach the model to `checklist_write`, `update_plan`, and spawn sub-agents before acting
- **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
- **Session save/resume** — checkpoint and resume long sessions
- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git`
- **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
- **MCP protocol** — connect to Model Context Protocol servers for extended tooling
- **Live cost tracking** — per-turn and session-level token usage and cost estimates
- **Dark theme** — DeepSeek-blue palette
---
## Quickstart
```bash
npm install -g deepseek-tui
deepseek
```
3. Press `F1` or type `/help` for the in‑app command list.
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
## 📦 Installation
### From crates.io
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
```bash
cargo install deepseek-tui --locked
# via CLI
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
# via env var
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
deepseek
```
### Build from source
### Using NVIDIA NIM
```bash
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
deepseek --provider nvidia-nim
# or per-process:
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
```
### Other DeepSeek V4 providers
```bash
deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
deepseek --provider fireworks --model deepseek-v4-pro
# SGLang is self-hosted; auth is optional for localhost deployments.
SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash
```
<details>
<summary>Install from source</summary>
```bash
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo build --release
./target/release/deepseek --help
cargo install --path crates/tui --bin deepseek-tui --locked # requires Rust 1.85+
cargo install --path crates/cli --bin deepseek --locked
```
### Direct download
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
## ⚙️ Configuration
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
```toml
# ~/.deepseek/config.toml
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
default_text_model = "deepseek-reasoner" # optional
allow_shell = false # optional
max_subagents = 3 # optional (1‑20)
```
Useful environment variables:
- `DEEPSEEK_API_KEY` (overrides `api_key`)
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
- `DEEPSEEK_CONFIG_PATH` (override config path)
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
only create an MCP template, run `deepseek mcp init`.
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
## 🎮 Modes
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
| Mode | Description | Approval Behavior |
|------|-------------|-------------------|
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
## 🛠️ Tools
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
### Tool Categories
#### File Operations
- **`list_dir`** – List directory contents with file/directory metadata
- **`read_file`** – Read UTF‑8 files from the workspace
- **`write_file`** – Create or overwrite files
- **`edit_file`** – Search and replace text in files
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
- **`grep_files`** – Search files by regex pattern with context lines
- **`web_search`** – Search the web and return concise results
#### Shell Execution
- **`exec_shell`** – Run shell commands with timeout support
- **Background execution** – Run commands in background with task ID return
#### Task Management
- **`todo_write`** – Create and update todo lists with status tracking
- **`update_plan`** – Manage structured implementation plans
- **`note`** – Append persistent notes across sessions
#### Sub‑Agents
- **`agent_spawn`** – Create background sub‑agents for focused tasks
- **`agent_swarm`** – Launch a dependency‑aware swarm of sub‑agents
- **`agent_result`** – Retrieve results from sub‑agents
- **`agent_list`** – List all active and completed agents
- **`agent_cancel`** – Cancel running sub‑agents
### System Behavior
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
## 🧠 RLM (Reasoning & Large‑scale Memory)
RLM mode is designed for "too big for context" tasks: large files, whole‑doc sweeps, and big pasted blocks.
- Auto‑switch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of sub‑queries.
## 👥 Duo Mode
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
Duo mode implements the player‑coach autocoding paradigm for iterative development with built‑in validation:
- **Player**: implements requirements (builder role)
- **Coach**: validates implementation against requirements (critic role)
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
Workflow: `init → player → coach → advance → (repeat until approved)`
## 📚 Examples
### Interactive chat
```bash
deepseek
```
### One‑shot prompt (non‑interactive)
```bash
deepseek -p "Write a haiku about Rust"
```
### Agentic execution with tool access
```bash
deepseek exec --auto "Fix lint errors in the current directory"
```
### Resume latest session
```bash
deepseek --continue
```
### Work on a specific project
```bash
deepseek --workspace /path/to/project
```
### Review staged git changes
```bash
deepseek review --staged
```
### Apply a patch file
```bash
deepseek apply patch.diff
```
### List saved sessions
```bash
deepseek sessions --limit 50
```
### Generate shell completions
```bash
deepseek completions zsh > _deepseek
deepseek completions bash > deepseek.bash
deepseek completions fish > deepseek.fish
```
## 🔧 Troubleshooting
### No API key
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
### Config not found
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
### Wrong region / base URL
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
### Session issues
Run `deepseek sessions` and try `deepseek --resume latest`.
### Skills missing
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
which skills directory is selected.
### MCP tools missing
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
now checks the MCP path resolved from your config/env overrides.
### Sandbox errors (macOS)
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
`/usr/bin/sandbox-exec` exists. For other platforms, sandboxing is limited.
## 📖 Documentation
- `docs/CONFIGURATION.md` – Complete configuration reference
- `docs/MCP.md` – Model Context Protocol guide
- `docs/ARCHITECTURE.md` – Project architecture
- `docs/RLM.md` – RLM mode deep‑dive
- `docs/MODES.md` – Mode comparison and usage
- `CONTRIBUTING.md` – How to contribute to the project
## 🧪 Development
```bash
cargo build
cargo test
cargo fmt
cargo clippy
```
See `CONTRIBUTING.md` for detailed guidelines.
## 📄 License
MIT
</details>
---
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
## What's new in v0.6.0
### 🌊 `rlm_query` — recursive language models as a first-class tool
The model now has direct access to a native recursive-LLM primitive. Inspired by [Alex Zhang's RLM work](https://github.com/alexzhang13/rlm) and Sakana AI's published research on novelty search, but trimmed to what an agent loop actually needs: one tool, structured args, no DSL.
```jsonc
// Single child:
rlm_query({ "prompt": "Summarise this 4k-line log: ..." })
// 8 parallel children, indexed result:
rlm_query({
"prompts": [
"Review src/foo.rs for race conditions: ...",
"Review src/foo.rs for input validation: ...",
"Review src/foo.rs for error-handling gaps: ...",
"..."
]
})
// Promote one call to Pro:
rlm_query({ "prompt": "Hard reasoning here", "model": "deepseek-v4-pro" })
```
Children run concurrently against the existing DeepSeek client via `tokio` — no external binary, no Python sandbox, no fenced-block DSL. Returns a single string for one prompt or `[i] ...` indexed blocks for many. Available in Plan / Agent / YOLO. The cost is folded into the session's running total automatically.
### Other changes
- **Scroll position survives content rewrites** — anchor fallback now clamps to the nearest surviving cell instead of teleporting to the bottom (#56)
- **Looser command-safety chains** — `cargo build && cargo test` is no longer blocked outright; chains of known-safe commands escalate to RequiresApproval instead of Dangerous (#57)
- **Multi-turn tool calls no longer 400 on thinking-mode models** — `reasoning_content` is replayed across user-message boundaries with a safe placeholder when the round produced none
Full history: [CHANGELOG.md](CHANGELOG.md).
---
## Models & Pricing
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|---|---|---|---|---|
| `deepseek-v4-pro` | 1M | $0.003625 / 1M* | $0.435 / 1M* | $0.87 / 1M* |
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
Legacy aliases `deepseek-chat` and `deepseek-reasoner` silently map to `deepseek-v4-flash`.
**NVIDIA NIM** hosted variants (`deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash`) use your NVIDIA account terms — no DeepSeek platform billing.
*\*DeepSeek lists the Pro rates above as a limited-time 75% discount valid until 2026-05-05 15:59 UTC; the TUI cost estimator falls back to base Pro rates after that timestamp.*
---
## Usage
```bash
deepseek # interactive TUI
deepseek "explain this function" # one-shot prompt
deepseek --model deepseek-v4-flash "summarize" # model override
deepseek --yolo # YOLO mode (auto-approve tools)
deepseek login --api-key "..." # save API key
deepseek doctor # check setup & connectivity
deepseek models # list live API models
deepseek sessions # list saved sessions
deepseek resume --last # resume latest session
deepseek serve --http # HTTP/SSE API server
```
### Keyboard shortcuts
| Key | Action |
|---|---|
| `Tab` | Cycle mode: Plan → Agent → YOLO |
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
| `F1` | Help |
| `Esc` | Back / dismiss |
| `Ctrl+K` | Command palette |
| `@path` | Attach file/directory context in composer |
| `/attach <path>` | Attach image/video media references |
---
## Modes
| Mode | Behavior |
|---|---|
| **Plan** 🔍 | Read-only investigation — model explores and proposes a decomposition plan (`update_plan` + `checklist_write`) before making changes |
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates; model outlines work via `checklist_write` before requesting writes |
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace; model still creates `checklist_write`/`update_plan` to keep work visible and trackable |
---
## Configuration
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for every option.
Key environment overrides:
| Variable | Purpose |
|---|---|
| `DEEPSEEK_API_KEY` | API key |
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_MODEL` | Default model |
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default), `nvidia-nim`, `fireworks`, or `sglang` |
| `DEEPSEEK_PROFILE` | Config profile name |
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
| `FIREWORKS_API_KEY` | Fireworks AI API key |
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
| `SGLANG_API_KEY` | Optional SGLang bearer token |
Quick diagnostics:
```bash
deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env)
deepseek-tui doctor --json # machine-readable doctor output for CI
deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories
```
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
---
## Publishing your own skill
DeepSeek-TUI can install community skills directly from a GitHub repo, with no
backend service in the loop:
1. Create a public GitHub repo with a `SKILL.md` at the root containing the
usual `---` frontmatter (`name`, `description`).
2. Multi-skill bundles use `skills/<name>/SKILL.md` instead — the installer
picks the first match and names the install after the frontmatter `name`.
3. Push to `main` (or `master`); the installer fetches
`archive/refs/heads/main.tar.gz` and falls back to `master.tar.gz`.
4. Users install via `/skill install github:<owner>/<repo>` — installs are
gated by the `[network]` policy, validated for path traversal and size, and
placed under `~/.deepseek/skills/<name>/`.
5. Submit a PR to the curated `index.json` (default registry) to make the skill
installable by name (`/skill install <name>`) instead of the GitHub spec.
## Documentation
| Doc | Topic |
|---|---|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome!
*Not affiliated with DeepSeek Inc.*
## License
[MIT](LICENSE)
Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

+249 -27
View File
@@ -1,5 +1,5 @@
# ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ DeepSeek CLI Configuration ║
# ║ DeepSeek TUI Configuration ║
# ║ ║
# ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║
# ╚══════════════════════════════════════════════════════════════════════════════╝
@@ -7,20 +7,45 @@
# See `docs/CONFIGURATION.md` for how config is loaded (profiles, env overrides, etc.).
# ─────────────────────────────────────────────────────────────────────────────────
# API Keys
# Active provider + DeepSeek defaults
# ─────────────────────────────────────────────────────────────────────────────────
# Choose which provider to use by default. Per-provider credentials live in the
# `[providers.*]` sections near the bottom of
# this file — keeping both stored at once means `/provider deepseek` and
# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`) toggle without having to
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
# defaults when `[providers.deepseek]` is absent (backward compatibility).
provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
# ─────────────────────────────────────────────────────────────────────────────────
# Base URLs
# ─────────────────────────────────────────────────────────────────────────────────
base_url = "https://api.deepseek.com"
# base_url = "https://api.deepseeki.com" # China users
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
# ─────────────────────────────────────────────────────────────────────────────────
# Default Models
# ─────────────────────────────────────────────────────────────────────────────────
default_text_model = "deepseek-reasoner" # also: deepseek-chat, deepseek-r1, deepseek-v3, deepseek-v3.2
# DeepSeek V4 family:
# deepseek-v4-pro — flagship reasoning model on DeepSeek Platform
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
default_text_model = "deepseek-v4-pro"
# ─────────────────────────────────────────────────────────────────────────────────
# Thinking Mode (DeepSeek V4 reasoning effort)
# ─────────────────────────────────────────────────────────────────────────────────
# "off" — disables chain-of-thought (thinking.type = disabled)
# "low" — compat-maps to "high" server-side
# "medium" — compat-maps to "high" server-side
# "high" — reasoning_effort = high (DeepSeek default)
# "max" — reasoning_effort = max (deepest reasoning)
#
# Shift+Tab in the TUI cycles between off / high / max. The header shows the
# current tier as a ⚡ chip.
reasoning_effort = "max"
# ─────────────────────────────────────────────────────────────────────────────────
# Paths
@@ -29,21 +54,118 @@ skills_dir = "~/.deepseek/skills"
mcp_config_path = "~/.deepseek/mcp.json"
notes_path = "~/.deepseek/notes.txt"
memory_path = "~/.deepseek/memory.md"
# Parsed but currently unused (reserved for future versions):
# tools_file = "./tools.json"
# memory_path = "~/.deepseek/memory.md"
# ─────────────────────────────────────────────────────────────────────────────────
# Security
# ─────────────────────────────────────────────────────────────────────────────────
allow_shell = false
allow_shell = true
approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
# auto_allow entries match by command prefix, not raw string.
# See command_safety.rs for the prefix dictionary.
#
# Examples:
# auto_allow = ["git status"] # auto-approves: git status, git status -s, git status --porcelain
# # does NOT auto-approve: git push, git checkout
# auto_allow = ["cargo check", "npm run"]
#
# auto_allow = []
max_subagents = 5 # optional (1-20)
# Optional managed policy paths (defaults to /etc/deepseek/*.toml on unix):
# managed_config_path = "/etc/deepseek/managed_config.toml"
# requirements_path = "/etc/deepseek/requirements.toml"
# ─────────────────────────────────────────────────────────────────────────────────
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
# ─────────────────────────────────────────────────────────────────────────────────
# Providers can be stored at once; `provider = "..."` (top of file) or
# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without
# having to re-enter keys. Env vars override anything set here:
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
# DeepSeek Platform (https://platform.deepseek.com)
[providers.deepseek]
# api_key = "YOUR_DEEPSEEK_API_KEY"
# base_url = "https://api.deepseek.com"
# model = "deepseek-v4-pro"
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
[providers.nvidia_nim]
# api_key = "YOUR_NVIDIA_API_KEY"
# base_url = "https://integrate.api.nvidia.com/v1"
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
# Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai)
[providers.fireworks]
# api_key = "YOUR_FIREWORKS_API_KEY"
# base_url = "https://api.fireworks.ai/inference/v1"
# model = "accounts/fireworks/models/deepseek-v4-pro"
# Self-hosted SGLang OpenAI-compatible server
[providers.sglang]
# api_key = "OPTIONAL_SGLANG_TOKEN"
# base_url = "http://localhost:30000/v1"
# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash
# ─────────────────────────────────────────────────────────────────────────────────
# Network Policy (#135)
# ─────────────────────────────────────────────────────────────────────────────────
# Per-domain allow/deny rules for outbound network calls made by the TUI's
# tools (`fetch_url`, `web_search`) and the MCP HTTP transport. Stdio MCP
# servers and direct LLM API calls are unaffected.
#
# Precedence: deny wins. A host listed in both `allow` and `deny` is denied.
#
# Host-matching rules:
# - Exact match: `api.deepseek.com` matches only `api.deepseek.com`.
# - Subdomain wildcard: an entry starting with `.` (e.g. `.example.com`)
# matches `api.example.com` and `a.b.example.com` but not the apex
# `example.com`. To cover both, list both. `*.example.com` is also accepted.
#
# Defaults are intentionally conservative: when this section is absent, no
# policy is enforced (mirrors pre-v0.7.0 behavior). To opt in:
#
# [network]
# default = "prompt" # allow | deny | prompt
# allow = ["api.deepseek.com", "github.com", ".githubusercontent.com"]
# deny = []
# audit = true # one line per call to ~/.deepseek/audit.log
# ─────────────────────────────────────────────────────────────────────────────────
# Skills (#140)
# ─────────────────────────────────────────────────────────────────────────────────
# Settings for the `/skill install <spec>` community-skill installer.
# * registry_url — curated index.json that resolves bare names to
# `github:owner/repo` specs. Override to point at
# a private fork or internal mirror.
# * max_install_size_bytes — per-skill uncompressed size cap. Tarballs that
# exceed this limit are rejected during validation.
# Default: 5 MiB.
#
# `/skill install` is gated by `[network]`. Make sure `github.com` and
# `raw.githubusercontent.com` are reachable (default `prompt` is fine — you'll
# be asked once and can persist) before running it.
#
# [skills]
# registry_url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json"
# max_install_size_bytes = 5_242_880
# ─────────────────────────────────────────────────────────────────────────────────
# TUI
# ─────────────────────────────────────────────────────────────────────────────────
[tui]
alternate_screen = "auto" # auto | always | never
mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy
# ─────────────────────────────────────────────────────────────────────────────────
# Feature Flags
@@ -51,11 +173,9 @@ alternate_screen = "auto" # auto | always | never
[features]
shell_tool = true
subagents = true
web_search = true
web_search = true # enables canonical web.run plus the compatibility web_search alias
apply_patch = true
mcp = true
rlm = true
duo = true
exec_policy = true
# ─────────────────────────────────────────────────────────────────────────────────
@@ -69,24 +189,46 @@ max_delay = 60.0
exponential_base = 2.0
# ─────────────────────────────────────────────────────────────────────────────────
# Context Compaction (PLANNED - not yet implemented)
# Context Compaction
# ─────────────────────────────────────────────────────────────────────────────────
# [compaction]
# enabled = false # Enable auto-compaction
# token_threshold = 50000 # Trigger compaction above this token estimate
# message_threshold = 50 # Or above this message count
# model = "deepseek-chat" # Model to use for summarization
# cache_summary = true # Cache the summary block
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
# There is no config-file `[compaction]` table yet; detailed thresholds are
# chosen by the TUI from the active model/context budget.
# Append-only Flash seams are experimental and opt-in while the v0.7.5
# context/cache audit validates prefix-cache behavior.
[context]
enabled = false
verbatim_window_turns = 16
# Thresholds are based on the active request input estimate, not lifetime
# summed API usage.
l1_threshold = 192000
l2_threshold = 384000
l3_threshold = 576000
# Hard cycle also reserves the normal 262144-token output budget plus 1024
# safety tokens against the model window.
cycle_threshold = 768000
seam_model = "deepseek-v4-flash"
# ─────────────────────────────────────────────────────────────────────────────────
# RLM Sandbox Configuration (PLANNED - not yet implemented)
# Capacity Controller (runtime pressure guardrails)
# ─────────────────────────────────────────────────────────────────────────────────
# [rlm]
# max_context_chars = 10000000 # Max characters for context (10MB)
# max_search_results = 100 # Max search results
# default_chunk_size = 2000 # Default chunk size
# default_overlap = 200 # Default chunk overlap
# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions
[capacity]
enabled = false
low_risk_max = 0.50
medium_risk_max = 0.62
severe_min_slack = -0.25
severe_violation_ratio = 0.40
refresh_cooldown_turns = 6
replan_cooldown_turns = 5
max_replay_per_turn = 1
min_turns_before_guardrail = 4
profile_window = 8
deepseek_v3_2_chat_prior = 3.9
deepseek_v3_2_reasoner_prior = 4.1
deepseek_v4_pro_prior = 3.5
deepseek_v4_flash_prior = 4.2
fallback_default_prior = 3.8
# ─────────────────────────────────────────────────────────────────────────────────
# Profile Example (for multiple environments)
@@ -100,6 +242,80 @@ base_url = "https://api.deepseek.com"
api_key = "DEV_DEEPSEEK_API_KEY"
allow_shell = true
[profiles.nvidia-nim]
provider = "nvidia-nim"
api_key = "YOUR_NVIDIA_API_KEY"
base_url = "https://integrate.api.nvidia.com/v1"
default_text_model = "deepseek-ai/deepseek-v4-pro"
# ─────────────────────────────────────────────────────────────────────────────────
# Desktop Notifications (OSC 9 / BEL on long agent-turn completion)
# ─────────────────────────────────────────────────────────────────────────────────
# Emits an escape sequence to the terminal when a turn finishes and took longer
# than `threshold_secs`. Useful when you tab away from the TUI and want an alert.
#
# method = "auto" # auto | osc9 | bel | off
# auto: OSC 9 for iTerm.app / Ghostty / WezTerm, BEL otherwise.
# osc9: \x1b]9;<msg>\x07 (iTerm2-style; shows macOS notification)
# bel: plain \x07 beep
# off: disable entirely
# threshold_secs = 30 # only notify when the turn took >= this many seconds
# include_summary = false # include elapsed time + cost in the notification body
[notifications]
# method = "auto"
# threshold_secs = 30
# include_summary = false
# ─────────────────────────────────────────────────────────────────────────────────
# Workspace Snapshots (#137)
# ─────────────────────────────────────────────────────────────────────────────────
# Each turn the TUI takes a `pre-turn:<seq>` and `post-turn:<seq>` snapshot of
# your workspace into a side-git repo at:
#
# ~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git
#
# Your own `.git` is never touched — `--git-dir` and `--work-tree` are always
# set together when shelling out to git. Use `/restore N` (slash command) or
# the `revert_turn` tool to roll the working tree back. Conversation history
# is unaffected.
#
# Disk footprint: ~1-2 GB worst case for a 100 MB workspace × 12 turns/day,
# typically far less thanks to git's content-addressed storage. The session
# boot prunes anything older than `max_age_days` (default 7).
#
# [snapshots]
# enabled = true # Snapshot workspace pre/post each turn for /restore
# max_age_days = 7 # Older snapshots pruned at session start
# ─────────────────────────────────────────────────────────────────────────────────
# LSP Diagnostics (post-edit) (#136)
# ─────────────────────────────────────────────────────────────────────────────────
# After every successful file edit (`edit_file`, `apply_patch`, `write_file`),
# the engine asks an LSP server for diagnostics on the file and injects them
# as a synthetic system message before the next API call. This lets the agent
# see compile breaks immediately without round-tripping through the user.
#
# Enabled by default. Failure modes are non-blocking: a missing LSP binary,
# a crashed server, or a timeout simply skips the post-edit hook for that
# turn — the agent's work is never blocked.
#
# Built-in language → server defaults:
# rust → rust-analyzer
# go → gopls serve
# python → pyright-langserver --stdio
# typescript → typescript-language-server --stdio
# c, cpp → clangd
#
# Override the defaults via the `servers` table below.
[lsp]
# enabled = true
# poll_after_edit_ms = 5000
# max_diagnostics_per_file = 20
# include_warnings = false
# [lsp.servers]
# rust = ["rust-analyzer"]
# go = ["gopls", "serve"]
# ─────────────────────────────────────────────────────────────────────────────────
# Hooks (optional)
# ─────────────────────────────────────────────────────────────────────────────────
@@ -112,4 +328,10 @@ allow_shell = true
#
# [[hooks.hooks]]
# event = "session_start"
# command = "echo 'DeepSeek CLI session started'"
# command = "echo 'DeepSeek TUI session started'"
# ─────────────────────────────────────────────────────────────────────────────────
# Requirements (admin constraints) example file
# ─────────────────────────────────────────────────────────────────────────────────
# allowed_approval_policies = ["on-request", "untrusted", "never"]
# allowed_sandbox_modes = ["read-only", "workspace-write"]
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "deepseek-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.7.5" }
serde.workspace = true
+307
View File
@@ -0,0 +1,307 @@
use std::collections::HashMap;
use deepseek_config::ProviderKind;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub provider: ProviderKind,
pub aliases: Vec<String>,
pub supports_tools: bool,
pub supports_reasoning: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResolution {
pub requested: Option<String>,
pub resolved: ModelInfo,
pub used_fallback: bool,
pub fallback_chain: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ModelRegistry {
models: Vec<ModelInfo>,
alias_map: HashMap<String, usize>,
}
impl Default for ModelRegistry {
fn default() -> Self {
let models = vec![
ModelInfo {
id: "deepseek-v4-pro".to_string(),
provider: ProviderKind::Deepseek,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-v4-flash".to_string(),
provider: ProviderKind::Deepseek,
aliases: vec![
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"deepseek-r1".to_string(),
"deepseek-v3".to_string(),
"deepseek-v3.2".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-pro".to_string(),
provider: ProviderKind::NvidiaNim,
aliases: vec![
"deepseek-v4-pro".to_string(),
"nvidia-deepseek-v4-pro".to_string(),
"nim-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-flash".to_string(),
provider: ProviderKind::NvidiaNim,
aliases: vec![
"deepseek-v4-flash".to_string(),
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"nvidia-deepseek-v4-flash".to_string(),
"nim-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gpt-4.1".to_string(),
provider: ProviderKind::Openai,
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gpt-4.1-mini".to_string(),
provider: ProviderKind::Openai,
aliases: vec!["gpt-4o-mini".to_string()],
supports_tools: true,
supports_reasoning: false,
},
ModelInfo {
id: "deepseek/deepseek-v4-pro".to_string(),
provider: ProviderKind::Openrouter,
aliases: vec![
"deepseek-v4-pro".to_string(),
"openrouter-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek/deepseek-v4-flash".to_string(),
provider: ProviderKind::Openrouter,
aliases: vec![
"deepseek-v4-flash".to_string(),
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"openrouter-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek/deepseek-v4-pro".to_string(),
provider: ProviderKind::Novita,
aliases: vec![
"deepseek-v4-pro".to_string(),
"novita-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek/deepseek-v4-flash".to_string(),
provider: ProviderKind::Novita,
aliases: vec![
"deepseek-v4-flash".to_string(),
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"novita-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
];
Self::new(models)
}
}
impl ModelRegistry {
#[must_use]
pub fn new(models: Vec<ModelInfo>) -> Self {
let mut alias_map = HashMap::new();
for (idx, model) in models.iter().enumerate() {
alias_map.entry(normalize(&model.id)).or_insert(idx);
for alias in &model.aliases {
alias_map.entry(normalize(alias)).or_insert(idx);
}
}
Self { models, alias_map }
}
#[must_use]
pub fn list(&self) -> Vec<ModelInfo> {
self.models.clone()
}
#[must_use]
pub fn resolve(
&self,
requested: Option<&str>,
provider_hint: Option<ProviderKind>,
) -> ModelResolution {
let mut fallback_chain = Vec::new();
if let Some(name) = requested {
fallback_chain.push(format!("requested:{name}"));
if let Some(provider) = provider_hint
&& let Some(model) = self
.models
.iter()
.find(|m| m.provider == provider && model_matches(m, name))
.cloned()
{
return ModelResolution {
requested: Some(name.to_string()),
resolved: model,
used_fallback: false,
fallback_chain,
};
}
if let Some(idx) = self.alias_map.get(&normalize(name)) {
return ModelResolution {
requested: Some(name.to_string()),
resolved: self.models[*idx].clone(),
used_fallback: false,
fallback_chain,
};
}
}
let provider = provider_hint.unwrap_or(ProviderKind::Deepseek);
fallback_chain.push(format!("provider_default:{}", provider.as_str()));
if let Some(model) = self.models.iter().find(|m| m.provider == provider).cloned() {
return ModelResolution {
requested: requested.map(ToOwned::to_owned),
resolved: model,
used_fallback: true,
fallback_chain,
};
}
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
id: "deepseek-v4-pro".to_string(),
provider: ProviderKind::Deepseek,
aliases: Vec::new(),
supports_tools: true,
supports_reasoning: true,
});
fallback_chain.push("global_default:deepseek-v4-pro".to_string());
ModelResolution {
requested: requested.map(ToOwned::to_owned),
resolved: final_fallback,
used_fallback: true,
fallback_chain,
}
}
}
fn normalize(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn model_matches(model: &ModelInfo, requested: &str) -> bool {
let requested = normalize(requested);
normalize(&model.id) == requested
|| model
.aliases
.iter()
.any(|alias| normalize(alias) == requested)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
}
#[test]
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
}
#[test]
fn nvidia_nim_default_uses_catalog_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
}
#[test]
fn openrouter_default_uses_namespaced_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::Openrouter));
assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
}
#[test]
fn novita_default_uses_namespaced_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::Novita));
assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_openrouter_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Openrouter));
assert_eq!(resolved.resolved.provider, ProviderKind::Openrouter);
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_novita_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Novita));
assert_eq!(resolved.resolved.provider, ProviderKind::Novita);
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-flash");
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "deepseek-app-server"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Codex-style app-server transport for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.7.5" }
deepseek-config = { path = "../config", version = "0.7.5" }
deepseek-core = { path = "../core", version = "0.7.5" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
deepseek-hooks = { path = "../hooks", version = "0.7.5" }
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
deepseek-state = { path = "../state", version = "0.7.5" }
deepseek-tools = { path = "../tools", version = "0.7.5" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tower-http.workspace = true
tracing.workspace = true
+783
View File
@@ -0,0 +1,783 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use deepseek_agent::ModelRegistry;
use deepseek_config::{CliRuntimeOverrides, ConfigStore};
use deepseek_core::Runtime;
use deepseek_execpolicy::ExecPolicyEngine;
use deepseek_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink};
use deepseek_mcp::McpManager;
use deepseek_protocol::{
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse,
};
use deepseek_state::StateStore;
use deepseek_tools::{ToolCall, ToolRegistry};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, RwLock};
use tower_http::cors::CorsLayer;
#[derive(Debug, Clone)]
pub struct AppServerOptions {
pub listen: SocketAddr,
pub config_path: Option<PathBuf>,
}
#[derive(Clone)]
struct AppState {
config_path: Option<PathBuf>,
config: Arc<RwLock<deepseek_config::ConfigToml>>,
runtime: Arc<Mutex<Runtime>>,
registry: ModelRegistry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ToolCallRequest {
call: ToolCall,
#[serde(default)]
cwd: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug)]
struct JsonRpcError {
code: i64,
message: String,
data: Option<Value>,
}
#[derive(Debug)]
struct StdioDispatchResult {
result: Value,
should_exit: bool,
}
#[derive(Debug, Deserialize)]
struct ConfigGetParams {
key: String,
}
#[derive(Debug, Deserialize)]
struct ConfigSetParams {
key: String,
value: String,
}
#[derive(Debug, Deserialize)]
struct ThreadIdParams {
thread_id: String,
}
#[derive(Debug, Deserialize)]
struct ThreadMessageParams {
thread_id: String,
input: String,
}
pub async fn run(options: AppServerOptions) -> Result<()> {
let state = build_state(options.config_path.clone())?;
let app = Router::new()
.route("/healthz", get(healthz))
.route("/thread", post(thread_handler))
.route("/app", post(app_handler))
.route("/prompt", post(prompt_handler))
.route("/tool", post(tool_handler))
.route("/jobs", get(jobs_handler))
.route("/mcp/startup", post(mcp_startup_handler))
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(options.listen).await?;
axum::serve(listener, app).await?;
Ok(())
}
pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
let state = build_state(config_path)?;
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let mut reader = BufReader::new(stdin).lines();
let mut writer = tokio::io::BufWriter::new(stdout);
while let Some(line) = reader.next_line().await? {
if line.trim().is_empty() {
continue;
}
let request: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(value) => value,
Err(err) => {
let response = jsonrpc_error(
None,
JsonRpcError::parse_error(format!("invalid json: {err}")),
);
writer.write_all(response.to_string().as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
continue;
}
};
if request
.jsonrpc
.as_deref()
.is_some_and(|version| version != "2.0")
{
let response = jsonrpc_error(
request.id,
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
);
writer.write_all(response.to_string().as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
continue;
}
let response = match dispatch_stdio_request(&state, &request.method, request.params).await {
Ok(dispatch) => {
let encoded = jsonrpc_result(request.id, dispatch.result);
writer.write_all(encoded.to_string().as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
if dispatch.should_exit {
break;
}
continue;
}
Err(err) => jsonrpc_error(request.id, err),
};
writer.write_all(response.to_string().as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Ok(())
}
async fn healthz() -> Json<Value> {
Json(json!({
"status": "ok",
"protocol": "v2",
"service": "deepseek-app-server"
}))
}
async fn thread_handler(
State(state): State<AppState>,
Json(req): Json<ThreadRequest>,
) -> Json<ThreadResponse> {
let mut runtime = state.runtime.lock().await;
match runtime.handle_thread(req).await {
Ok(res) => Json(res),
Err(err) => Json(ThreadResponse {
thread_id: "error".to_string(),
status: format!("error:{err}"),
thread: None,
threads: Vec::new(),
model: None,
model_provider: None,
cwd: None,
approval_policy: None,
sandbox: None,
events: Vec::new(),
data: json!({}),
}),
}
}
async fn prompt_handler(
State(state): State<AppState>,
Json(req): Json<PromptRequest>,
) -> Json<PromptResponse> {
let mut runtime = state.runtime.lock().await;
let overrides = CliRuntimeOverrides::default();
match runtime.handle_prompt(req, &overrides).await {
Ok(res) => Json(res),
Err(err) => Json(PromptResponse {
output: err.to_string(),
model: "unknown".to_string(),
events: Vec::new(),
}),
}
}
async fn tool_handler(
State(state): State<AppState>,
Json(req): Json<ToolCallRequest>,
) -> Json<Value> {
let runtime = state.runtime.lock().await;
let cwd = req
.cwd
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
match runtime
.invoke_tool(
req.call,
deepseek_execpolicy::AskForApproval::OnRequest,
&cwd,
)
.await
{
Ok(value) => Json(value),
Err(err) => Json(json!({ "ok": false, "error": err.to_string() })),
}
}
async fn jobs_handler(State(state): State<AppState>) -> Json<AppResponse> {
let runtime = state.runtime.lock().await;
Json(runtime.app_status())
}
async fn mcp_startup_handler(State(state): State<AppState>) -> Json<Value> {
let runtime = state.runtime.lock().await;
let summary = runtime.mcp_startup().await;
Json(json!({
"ok": true,
"summary": summary
}))
}
async fn app_handler(
State(state): State<AppState>,
Json(req): Json<AppRequest>,
) -> Json<AppResponse> {
Json(process_app_request(&state, req).await)
}
fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
let store = ConfigStore::load(config_path.clone())?;
let config = store.config.clone();
let registry = ModelRegistry::default();
let state_db_path = config_path
.as_ref()
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
let state_store = StateStore::open(state_db_path)?;
let mut hooks = HookDispatcher::default();
hooks.add_sink(Arc::new(StdoutHookSink));
let hook_log_path = config_path
.as_ref()
.and_then(|p| p.parent().map(|parent| parent.join("events.jsonl")))
.unwrap_or_else(|| PathBuf::from(".deepseek/events.jsonl"));
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
let runtime = Runtime::new(
config.clone(),
registry.clone(),
state_store,
Arc::new(ToolRegistry::default()),
Arc::new(McpManager::default()),
ExecPolicyEngine::new(Vec::new(), Vec::new()),
hooks,
);
Ok(AppState {
config_path,
config: Arc::new(RwLock::new(config)),
runtime: Arc::new(Mutex::new(runtime)),
registry,
})
}
fn params_or_object(params: Value) -> Value {
if params.is_null() { json!({}) } else { params }
}
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
}
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"result": result
})
}
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"error": {
"code": err.code,
"message": err.message,
"data": err.data
}
})
}
impl JsonRpcError {
fn parse_error(message: impl Into<String>) -> Self {
Self {
code: -32700,
message: message.into(),
data: None,
}
}
fn invalid_request(message: impl Into<String>) -> Self {
Self {
code: -32600,
message: message.into(),
data: None,
}
}
fn method_not_found(method: &str) -> Self {
Self {
code: -32601,
message: format!("unsupported method: {method}"),
data: None,
}
}
fn invalid_params(message: impl Into<String>) -> Self {
Self {
code: -32602,
message: message.into(),
data: None,
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
code: -32603,
message: message.into(),
data: None,
}
}
}
async fn handle_thread_request(
state: &AppState,
req: ThreadRequest,
) -> std::result::Result<ThreadResponse, JsonRpcError> {
let mut runtime = state.runtime.lock().await;
runtime
.handle_thread(req)
.await
.map_err(|err| JsonRpcError::internal(err.to_string()))
}
async fn handle_prompt_request(
state: &AppState,
req: PromptRequest,
) -> std::result::Result<PromptResponse, JsonRpcError> {
let mut runtime = state.runtime.lock().await;
runtime
.handle_prompt(req, &CliRuntimeOverrides::default())
.await
.map_err(|err| JsonRpcError::internal(err.to_string()))
}
async fn dispatch_stdio_request(
state: &AppState,
method: &str,
params: Value,
) -> std::result::Result<StdioDispatchResult, JsonRpcError> {
let outcome = match method {
"healthz" | "app/healthz" => StdioDispatchResult {
result: json!({
"status": "ok",
"service": "deepseek-app-server",
"transport": "stdio"
}),
should_exit: false,
},
"capabilities" => StdioDispatchResult {
result: json!({
"transport": "stdio",
"families": ["thread/*", "app/*", "prompt/*"],
"methods": [
"healthz",
"thread/capabilities",
"thread/request",
"thread/create",
"thread/start",
"thread/resume",
"thread/fork",
"thread/list",
"thread/read",
"thread/set_name",
"thread/archive",
"thread/unarchive",
"thread/message",
"app/capabilities",
"app/request",
"app/config/get",
"app/config/set",
"app/config/unset",
"app/config/list",
"app/models",
"app/thread_loaded_list",
"prompt/capabilities",
"prompt/request",
"prompt/run",
"shutdown"
]
}),
should_exit: false,
},
"thread/capabilities" => StdioDispatchResult {
result: json!({
"methods": [
"thread/request",
"thread/create",
"thread/start",
"thread/resume",
"thread/fork",
"thread/list",
"thread/read",
"thread/set_name",
"thread/archive",
"thread/unarchive",
"thread/message"
]
}),
should_exit: false,
},
"thread/request" => {
let request: ThreadRequest = parse_params(params)?;
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/create" => {
#[derive(Debug, Deserialize)]
struct CreateParams {
#[serde(default)]
metadata: Value,
}
let parsed: CreateParams = parse_params(params_or_object(params))?;
let response = handle_thread_request(
state,
ThreadRequest::Create {
metadata: parsed.metadata,
},
)
.await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/start" => {
let request = ThreadRequest::Start(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/resume" => {
let request = ThreadRequest::Resume(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/fork" => {
let request = ThreadRequest::Fork(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/list" => {
let request = ThreadRequest::List(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/read" => {
let request = ThreadRequest::Read(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/set_name" | "thread/set-name" => {
let request = ThreadRequest::SetName(parse_params(params_or_object(params))?);
let response = handle_thread_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/archive" => {
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
let response = handle_thread_request(
state,
ThreadRequest::Archive {
thread_id: parsed.thread_id,
},
)
.await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/unarchive" => {
let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
let response = handle_thread_request(
state,
ThreadRequest::Unarchive {
thread_id: parsed.thread_id,
},
)
.await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"thread/message" => {
let parsed: ThreadMessageParams = parse_params(params_or_object(params))?;
let response = handle_thread_request(
state,
ThreadRequest::Message {
thread_id: parsed.thread_id,
input: parsed.input,
},
)
.await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/capabilities" => {
let response = process_app_request(state, AppRequest::Capabilities).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/request" => {
let request: AppRequest = parse_params(params)?;
let response = process_app_request(state, request).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/config/get" => {
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
let response =
process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/config/set" => {
let parsed: ConfigSetParams = parse_params(params_or_object(params))?;
let response = process_app_request(
state,
AppRequest::ConfigSet {
key: parsed.key,
value: parsed.value,
},
)
.await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/config/unset" => {
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
let response =
process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/config/list" => {
let response = process_app_request(state, AppRequest::ConfigList).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/models" => {
let response = process_app_request(state, AppRequest::Models).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"app/thread_loaded_list" | "app/thread-loaded-list" => {
let response = process_app_request(state, AppRequest::ThreadLoadedList).await;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"prompt/capabilities" => StdioDispatchResult {
result: json!({
"methods": ["prompt/request", "prompt/run"]
}),
should_exit: false,
},
"prompt/request" | "prompt/run" => {
let request: PromptRequest = parse_params(params)?;
let response = handle_prompt_request(state, request).await?;
StdioDispatchResult {
result: serde_json::to_value(response)
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
should_exit: false,
}
}
"shutdown" => StdioDispatchResult {
result: json!({"ok": true, "status": "stopped"}),
should_exit: true,
},
_ => return Err(JsonRpcError::method_not_found(method)),
};
Ok(outcome)
}
async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
match req {
AppRequest::Capabilities => AppResponse {
ok: true,
data: json!({
"routes": ["/thread", "/app", "/prompt", "/tool", "/jobs", "/mcp/startup"],
"config": ["get", "set", "unset", "list"],
"events": ["response_start", "response_delta", "response_end", "tool_call_start", "tool_call_result", "mcp_startup_update", "mcp_startup_complete"],
"transport": "stdio+http",
"config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
}),
events: Vec::new(),
},
AppRequest::ConfigGet { key } => {
let cfg = state.config.read().await;
AppResponse {
ok: true,
data: json!({ "key": key, "value": cfg.get_value(&key) }),
events: Vec::new(),
}
}
AppRequest::ConfigSet { key, value } => {
let mut cfg = state.config.write().await;
let result = cfg.set_value(&key, &value);
let ok = result.is_ok();
let message = result.err().map(|e| e.to_string());
let snapshot = cfg.clone();
drop(cfg);
let _ = persist_config(state, snapshot).await;
AppResponse {
ok,
data: json!({ "key": key, "value": value, "error": message }),
events: Vec::new(),
}
}
AppRequest::ConfigUnset { key } => {
let mut cfg = state.config.write().await;
let result = cfg.unset_value(&key);
let ok = result.is_ok();
let message = result.err().map(|e| e.to_string());
let snapshot = cfg.clone();
drop(cfg);
let _ = persist_config(state, snapshot).await;
AppResponse {
ok,
data: json!({ "key": key, "error": message }),
events: Vec::new(),
}
}
AppRequest::ConfigList => {
let cfg = state.config.read().await;
AppResponse {
ok: true,
data: json!({ "values": cfg.list_values() }),
events: Vec::new(),
}
}
AppRequest::Models => AppResponse {
ok: true,
data: json!({ "models": state.registry.list() }),
events: Vec::new(),
},
AppRequest::ThreadLoadedList => {
let mut runtime = state.runtime.lock().await;
let response = runtime
.handle_thread(deepseek_protocol::ThreadRequest::List(
deepseek_protocol::ThreadListParams {
include_archived: false,
limit: Some(50),
},
))
.await;
match response {
Ok(thread_resp) => AppResponse {
ok: true,
data: json!({ "threads": thread_resp.threads }),
events: thread_resp.events,
},
Err(err) => AppResponse {
ok: false,
data: json!({ "error": err.to_string() }),
events: Vec::new(),
},
}
}
}
}
async fn persist_config(state: &AppState, config: deepseek_config::ConfigToml) -> Result<()> {
if state.config_path.is_none() {
return Ok(());
}
let mut store = ConfigStore::load(state.config_path.clone())?;
store.config = config;
store.save()
}
+33
View File
@@ -0,0 +1,33 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use deepseek_app_server::{AppServerOptions, run};
#[derive(Debug, Parser)]
#[command(
name = "deepseek-app-server",
about = "Run the DeepSeek app-server transport"
)]
struct Cli {
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = 8787)]
port: u16,
#[arg(long)]
config: Option<PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let listen: SocketAddr = format!("{}:{}", cli.host, cli.port)
.parse()
.with_context(|| format!("invalid listen address {}:{}", cli.host, cli.port))?;
run(AppServerOptions {
listen,
config_path: cli.config,
})
.await
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "deepseek-tui-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Codex-style CLI facade for DeepSeek workspace architecture"
[[bin]]
name = "deepseek"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.7.5" }
deepseek-app-server = { path = "../app-server", version = "0.7.5" }
deepseek-config = { path = "../config", version = "0.7.5" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
deepseek-state = { path = "../state", version = "0.7.5" }
chrono.workspace = true
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile = "3.16"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "deepseek-config"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Config schema and precedence model for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "deepseek-core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.7.5" }
deepseek-config = { path = "../config", version = "0.7.5" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.7.5" }
deepseek-hooks = { path = "../hooks", version = "0.7.5" }
deepseek-mcp = { path = "../mcp", version = "0.7.5" }
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
deepseek-state = { path = "../state", version = "0.7.5" }
deepseek-tools = { path = "../tools", version = "0.7.5" }
serde_json.workspace = true
tokio.workspace = true
uuid.workspace = true
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "deepseek-execpolicy"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Execution policy and approval model parity for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
serde.workspace = true
+191
View File
@@ -0,0 +1,191 @@
use std::collections::HashSet;
use anyhow::Result;
use deepseek_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AskForApproval {
UnlessTrusted,
OnFailure,
OnRequest,
Reject {
sandbox_approval: bool,
rules: bool,
mcp_elicitations: bool,
},
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecPolicyAmendment {
pub prefixes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecApprovalRequirement {
Skip {
bypass_sandbox: bool,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
NeedsApproval {
reason: String,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
},
Forbidden {
reason: String,
},
}
impl ExecApprovalRequirement {
pub fn reason(&self) -> &str {
match self {
ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
ExecApprovalRequirement::Forbidden { reason } => reason,
}
}
pub fn phase(&self) -> &'static str {
match self {
ExecApprovalRequirement::Skip { .. } => "allowed",
ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
ExecApprovalRequirement::Forbidden { .. } => "forbidden",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecPolicyDecision {
pub allow: bool,
pub requires_approval: bool,
pub requirement: ExecApprovalRequirement,
pub matched_rule: Option<String>,
}
impl ExecPolicyDecision {
pub fn reason(&self) -> &str {
self.requirement.reason()
}
}
#[derive(Debug, Clone)]
pub struct ExecPolicyContext<'a> {
pub command: &'a str,
pub cwd: &'a str,
pub ask_for_approval: AskForApproval,
pub sandbox_mode: Option<&'a str>,
}
#[derive(Debug, Clone, Default)]
pub struct ExecPolicyEngine {
trusted_prefixes: Vec<String>,
denied_prefixes: Vec<String>,
approved_for_session: HashSet<String>,
}
impl ExecPolicyEngine {
pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
Self {
trusted_prefixes,
denied_prefixes,
approved_for_session: HashSet::new(),
}
}
pub fn remember_session_approval(&mut self, approval_key: String) {
self.approved_for_session.insert(approval_key);
}
pub fn is_session_approved(&self, approval_key: &str) -> bool {
self.approved_for_session.contains(approval_key)
}
pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
let normalized = normalize_command(ctx.command);
if let Some(rule) = self
.denied_prefixes
.iter()
.find(|rule| normalized.starts_with(&normalize_command(rule)))
{
return Ok(ExecPolicyDecision {
allow: false,
requires_approval: false,
matched_rule: Some(rule.clone()),
requirement: ExecApprovalRequirement::Forbidden {
reason: format!("Command blocked by denied prefix rule '{rule}'"),
},
});
}
let trusted_rule = self
.trusted_prefixes
.iter()
.find(|rule| normalized.starts_with(&normalize_command(rule)))
.cloned();
let is_trusted = trusted_rule.is_some();
let requirement = match ctx.ask_for_approval {
AskForApproval::Never => ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
AskForApproval::Reject { rules, .. } if rules => ExecApprovalRequirement::Forbidden {
reason: "Policy is configured to reject rule-exceptions.".to_string(),
},
_ => ExecApprovalRequirement::NeedsApproval {
reason: if is_trusted {
"Approval requested by policy mode.".to_string()
} else {
"Unmatched command prefix requires approval.".to_string()
},
proposed_execpolicy_amendment: if is_trusted {
None
} else {
Some(ExecPolicyAmendment {
prefixes: vec![first_token(ctx.command)],
})
},
proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
host: ctx.cwd.to_string(),
action: NetworkPolicyRuleAction::Allow,
}],
},
};
let (allow, requires_approval) = match requirement {
ExecApprovalRequirement::Skip { .. } => (true, false),
ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
ExecApprovalRequirement::Forbidden { .. } => (false, false),
};
Ok(ExecPolicyDecision {
allow,
requires_approval,
matched_rule: trusted_rule,
requirement,
})
}
}
fn normalize_command(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn first_token(command: &str) -> String {
command
.split_whitespace()
.next()
.unwrap_or_default()
.to_string()
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "deepseek-hooks"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Hook dispatch and notifications parity for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+170
View File
@@ -0,0 +1,170 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use deepseek_protocol::EventFrame;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookEvent {
ResponseStart {
response_id: String,
},
ResponseDelta {
response_id: String,
delta: String,
},
ResponseEnd {
response_id: String,
},
ToolLifecycle {
response_id: String,
tool_name: String,
phase: String,
payload: Value,
},
JobLifecycle {
job_id: String,
phase: String,
progress: Option<u8>,
detail: Option<String>,
},
ApprovalLifecycle {
approval_id: String,
phase: String,
reason: Option<String>,
},
GenericEventFrame {
frame: EventFrame,
},
}
impl HookEvent {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
}
}
#[async_trait]
pub trait HookSink: Send + Sync {
async fn emit(&self, event: &HookEvent) -> Result<()>;
}
#[derive(Default)]
pub struct StdoutHookSink;
#[async_trait]
impl HookSink for StdoutHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
println!("{}", event.to_json());
Ok(())
}
}
pub struct JsonlHookSink {
path: PathBuf,
}
impl JsonlHookSink {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
#[async_trait]
impl HookSink for JsonlHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("failed to create hook log directory {}", parent.display())
})?;
}
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
let payload = json!({
"at": Utc::now().to_rfc3339(),
"event": event
});
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
file.write_all(encoded.as_bytes())
.await
.context("failed to write hook event")?;
file.write_all(b"\n")
.await
.context("failed to write hook event newline")?;
Ok(())
}
}
pub struct WebhookHookSink {
url: String,
client: reqwest::Client,
}
impl WebhookHookSink {
pub fn new(url: String) -> Self {
Self {
url,
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl HookSink for WebhookHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
let mut retries = 0usize;
loop {
let resp = self
.client
.post(&self.url)
.json(&json!({
"at": Utc::now().to_rfc3339(),
"event": event,
}))
.send()
.await;
match resp {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => {
if retries >= 2 {
anyhow::bail!("webhook returned non-success status {}", response.status());
}
}
Err(err) => {
if retries >= 2 {
return Err(err).context("webhook request failed");
}
}
}
retries += 1;
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
}
}
}
#[derive(Default, Clone)]
pub struct HookDispatcher {
sinks: Vec<Arc<dyn HookSink>>,
}
impl HookDispatcher {
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
self.sinks.push(sink);
}
pub async fn emit(&self, event: HookEvent) {
for sink in &self.sinks {
let _ = sink.emit(&event).await;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "deepseek-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "MCP server lifecycle and tool proxy compatibility for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
serde.workspace = true
serde_json.workspace = true
+893
View File
@@ -0,0 +1,893 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use anyhow::{Context, Result, bail};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolFilter {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerDefinition {
pub config: McpServerConfig,
#[serde(default)]
pub filter: ToolFilter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum McpStartupStatus {
Starting,
Ready,
Failed { error: String },
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupUpdateEvent {
pub server_name: String,
pub status: McpStartupStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupFailure {
pub server_name: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupCompleteEvent {
pub ready: Vec<String>,
pub failed: Vec<McpStartupFailure>,
pub cancelled: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor {
pub server_name: String,
pub tool_name: String,
pub qualified_name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResourceDescriptor {
pub server_name: String,
pub uri: String,
pub description: Option<String>,
}
pub trait McpManagedClient: Send + Sync {
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value>;
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>>;
fn read_resource(&self, uri: &str) -> Result<Value>;
}
#[derive(Debug, Default)]
pub struct InMemoryMcpClient {
tools: HashMap<String, Value>,
resources: HashMap<String, Value>,
}
impl InMemoryMcpClient {
pub fn with_tool(mut self, name: &str, sample_result: Value) -> Self {
self.tools.insert(name.to_string(), sample_result);
self
}
pub fn with_resource(mut self, uri: &str, data: Value) -> Self {
self.resources.insert(uri.to_string(), data);
self
}
}
impl McpManagedClient for InMemoryMcpClient {
fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
Ok(self
.tools
.keys()
.map(|name| McpToolDescriptor {
server_name: "in-memory".to_string(),
tool_name: name.clone(),
qualified_name: name.clone(),
description: None,
})
.collect())
}
fn call_tool(&self, tool_name: &str, _arguments: Value) -> Result<Value> {
self.tools
.get(tool_name)
.cloned()
.with_context(|| format!("tool '{tool_name}' not found"))
}
fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
Ok(self
.resources
.keys()
.map(|uri| McpResourceDescriptor {
server_name: "in-memory".to_string(),
uri: uri.clone(),
description: None,
})
.collect())
}
fn read_resource(&self, uri: &str) -> Result<Value> {
self.resources
.get(uri)
.cloned()
.with_context(|| format!("resource '{uri}' not found"))
}
}
#[derive(Default)]
pub struct McpManager {
configs: HashMap<String, (McpServerConfig, ToolFilter)>,
clients: HashMap<String, Box<dyn McpManagedClient>>,
}
impl McpManager {
pub fn register_server(
&mut self,
config: McpServerConfig,
filter: ToolFilter,
client: Box<dyn McpManagedClient>,
) {
self.clients.insert(config.name.clone(), client);
self.configs.insert(config.name.clone(), (config, filter));
}
pub fn start_all<F>(&self, mut emit: F) -> McpStartupCompleteEvent
where
F: FnMut(McpStartupUpdateEvent),
{
let mut ready = Vec::new();
let mut failed = Vec::new();
let mut cancelled = Vec::new();
for (server_name, (cfg, _)) in &self.configs {
if !cfg.enabled {
emit(McpStartupUpdateEvent {
server_name: server_name.clone(),
status: McpStartupStatus::Cancelled,
});
cancelled.push(server_name.clone());
continue;
}
emit(McpStartupUpdateEvent {
server_name: server_name.clone(),
status: McpStartupStatus::Starting,
});
if self.clients.contains_key(server_name) {
emit(McpStartupUpdateEvent {
server_name: server_name.clone(),
status: McpStartupStatus::Ready,
});
ready.push(server_name.clone());
} else {
let error = "client not registered".to_string();
emit(McpStartupUpdateEvent {
server_name: server_name.clone(),
status: McpStartupStatus::Failed {
error: error.clone(),
},
});
failed.push(McpStartupFailure {
server_name: server_name.clone(),
error,
});
}
}
McpStartupCompleteEvent {
ready,
failed,
cancelled,
}
}
pub fn stop_server(&mut self, server_name: &str) -> Result<()> {
self.clients
.remove(server_name)
.with_context(|| format!("server '{server_name}' is not running"))?;
Ok(())
}
pub fn unregister_server(&mut self, server_name: &str) -> Result<()> {
let had_config = self.configs.remove(server_name).is_some();
self.clients.remove(server_name);
if !had_config {
bail!("server '{server_name}' is not registered");
}
Ok(())
}
pub fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
let mut out = Vec::new();
for (server_name, (_, filter)) in &self.configs {
let Some(client) = self.clients.get(server_name) else {
continue;
};
let tools = client.list_tools()?;
for tool in tools {
if !allowed_by_filter(&tool.tool_name, filter) {
continue;
}
let qualified_name = qualify_tool_name(server_name, &tool.tool_name);
out.push(McpToolDescriptor {
server_name: server_name.clone(),
tool_name: tool.tool_name,
qualified_name,
description: tool.description,
});
}
}
Ok(out)
}
pub fn call_tool(&self, server_name: &str, tool_name: &str, arguments: Value) -> Result<Value> {
let client = self
.clients
.get(server_name)
.with_context(|| format!("MCP server '{server_name}' not available"))?;
client.call_tool(tool_name, arguments)
}
pub fn call_qualified_tool(
&self,
qualified_tool_name: &str,
arguments: Value,
) -> Result<Value> {
let (server_name, tool_name) = parse_qualified_tool_name(qualified_tool_name)
.with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"))?;
self.call_tool(&server_name, &tool_name, arguments)
}
pub fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
let mut out = Vec::new();
for server_name in self.configs.keys() {
let Some(client) = self.clients.get(server_name) else {
continue;
};
for mut resource in client.list_resources()? {
resource.server_name = server_name.clone();
out.push(resource);
}
}
Ok(out)
}
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<Value> {
let client = self
.clients
.get(server_name)
.with_context(|| format!("MCP server '{server_name}' not available"))?;
client.read_resource(uri)
}
pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result<Vec<Value>> {
let mut notices = Vec::new();
for server_name in self.configs.keys() {
notices.push(json!({
"server_name": server_name,
"method": "codex/sandbox-state/update",
"params": {
"sandbox_mode": sandbox_mode,
"cwd": cwd
}
}));
}
Ok(notices)
}
}
fn default_true() -> bool {
true
}
fn allowed_by_filter(name: &str, filter: &ToolFilter) -> bool {
if filter.deny.iter().any(|pattern| pattern == name) {
return false;
}
if filter.allow.is_empty() {
return true;
}
filter.allow.iter().any(|pattern| pattern == name)
}
fn sanitize_component(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn qualify_tool_name(server: &str, tool: &str) -> String {
let mut name = format!(
"mcp__{}__{}",
sanitize_component(server),
sanitize_component(tool)
);
if name.len() > 64 {
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
let hash = format!("{:x}", hasher.finish());
name.truncate(48);
name.push('_');
name.push_str(&hash[..12]);
}
name
}
fn parse_qualified_tool_name(value: &str) -> Result<(String, String)> {
let Some(stripped) = value.strip_prefix("mcp__") else {
bail!("missing mcp__ prefix");
};
let mut split = stripped.splitn(2, "__");
let server = split
.next()
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.context("missing server segment")?;
let tool = split
.next()
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.context("missing tool segment")?;
Ok((server, tool))
}
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[serde(default)]
jsonrpc: Option<String>,
#[serde(default)]
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug)]
struct JsonRpcError {
code: i64,
message: String,
data: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct ToolsListParams {
#[serde(default)]
server: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ToolsCallParams {
#[serde(default)]
name: Option<String>,
#[serde(default)]
tool: Option<String>,
#[serde(default)]
server: Option<String>,
#[serde(default)]
arguments: Value,
}
#[derive(Debug, Deserialize)]
struct ResourcesListParams {
#[serde(default)]
server: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResourcesReadParams {
#[serde(default)]
server: Option<String>,
uri: String,
}
#[derive(Debug, Deserialize)]
struct ServerRegisterParams {
server: McpServerConfig,
#[serde(default)]
filter: ToolFilter,
#[serde(default = "default_true")]
start: bool,
}
#[derive(Debug, Deserialize)]
struct ServerNameParams {
name: String,
}
struct StdioMcpState {
manager: McpManager,
definitions: HashMap<String, McpServerDefinition>,
running: HashMap<String, bool>,
lifecycle_state: String,
}
pub fn run_stdio_server(
initial_definitions: Vec<McpServerDefinition>,
) -> Result<Vec<McpServerDefinition>> {
use std::io::{self, BufRead, Write};
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut stderr = io::stderr();
let mut state = build_stdio_state(initial_definitions);
for line in stdin.lock().lines() {
let line = line.context("failed to read stdio line")?;
if line.trim().is_empty() {
continue;
}
let request: JsonRpcRequest = match serde_json::from_str(&line) {
Ok(value) => value,
Err(err) => {
let msg = jsonrpc_error(
None,
JsonRpcError::parse_error(format!("invalid json: {err}")),
);
writeln!(stdout, "{msg}")?;
stdout.flush()?;
continue;
}
};
if request
.jsonrpc
.as_deref()
.is_some_and(|version| version != "2.0")
{
let response = jsonrpc_error(
request.id,
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
);
writeln!(stdout, "{response}")?;
stdout.flush()?;
continue;
}
let response = match dispatch_stdio_request(&mut state, &request.method, request.params) {
Ok((result, should_exit)) => {
let payload = jsonrpc_result(request.id, result);
writeln!(stdout, "{payload}")?;
stdout.flush()?;
if should_exit {
break;
}
continue;
}
Err(err) => jsonrpc_error(request.id, err),
};
writeln!(stdout, "{response}")?;
stdout.flush()?;
}
state.lifecycle_state = "stopped".to_string();
let _ = writeln!(stderr, "deepseek-mcp stdio server exited");
let mut definitions: Vec<McpServerDefinition> = state.definitions.into_values().collect();
definitions.sort_by(|a, b| a.config.name.cmp(&b.config.name));
Ok(definitions)
}
fn build_stdio_state(initial_definitions: Vec<McpServerDefinition>) -> StdioMcpState {
let mut manager = McpManager::default();
let mut definitions = HashMap::new();
let mut running = HashMap::new();
for definition in initial_definitions {
let name = definition.config.name.clone();
let should_start = definition.config.enabled;
definitions.insert(name.clone(), definition.clone());
if should_start {
manager.register_server(
definition.config.clone(),
definition.filter.clone(),
default_stdio_client(&name),
);
running.insert(name, true);
} else {
running.insert(name, false);
}
}
StdioMcpState {
manager,
definitions,
running,
lifecycle_state: "running".to_string(),
}
}
fn default_stdio_client(server_name: &str) -> Box<dyn McpManagedClient> {
let health_uri = format!("mcp://{server_name}/health");
let capabilities_uri = format!("mcp://{server_name}/capabilities");
Box::new(
InMemoryMcpClient::default()
.with_tool(
"health",
json!({
"status": "ok",
"server_name": server_name
}),
)
.with_tool(
"capabilities",
json!({
"tools": ["health", "capabilities"],
"resources": [health_uri.clone(), capabilities_uri.clone()]
}),
)
.with_resource(
&health_uri,
json!({
"status": "ok",
"server_name": server_name
}),
)
.with_resource(
&capabilities_uri,
json!({
"server_name": server_name,
"methods": [
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"server/list",
"server/register",
"server/start",
"server/stop",
"server/unregister"
]
}),
),
)
}
fn default_rpc_methods() -> Vec<&'static str> {
vec![
"initialize",
"healthz",
"capabilities",
"tools/list",
"tools/call",
"resources/list",
"resources/read",
"server/list",
"server/register",
"server/start",
"server/stop",
"server/unregister",
"shutdown",
]
}
fn lifecycle_snapshot(state: &StdioMcpState) -> Value {
let mut servers: Vec<Value> = state
.definitions
.iter()
.map(|(name, definition)| {
let is_running = state.running.get(name).copied().unwrap_or(false);
json!({
"name": name,
"enabled": definition.config.enabled,
"running": is_running,
"command": definition.config.command.clone(),
"args": definition.config.args.clone(),
})
})
.collect();
servers.sort_by(|a, b| {
let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default();
let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default();
a_name.cmp(b_name)
});
let running_count = state.running.values().filter(|running| **running).count();
json!({
"status": state.lifecycle_state,
"servers": servers,
"counts": {
"defined": state.definitions.len(),
"running": running_count
}
})
}
fn params_or_object(params: Value) -> Value {
if params.is_null() { json!({}) } else { params }
}
fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
}
fn parse_server_from_uri(uri: &str) -> Option<String> {
let stripped = uri.strip_prefix("mcp://")?;
let server = stripped.split('/').next()?;
if server.is_empty() {
None
} else {
Some(server.to_string())
}
}
fn dispatch_stdio_request(
state: &mut StdioMcpState,
method: &str,
params: Value,
) -> std::result::Result<(Value, bool), JsonRpcError> {
match method {
"initialize" | "capabilities" => Ok((
json!({
"server": "deepseek-mcp",
"transport": "stdio",
"methods": default_rpc_methods(),
"lifecycle": lifecycle_snapshot(state)
}),
false,
)),
"healthz" => Ok((
json!({
"status": "ok",
"service": "deepseek-mcp",
"transport": "stdio",
"lifecycle": lifecycle_snapshot(state)
}),
false,
)),
"tools/list" => {
let parsed: ToolsListParams = parse_params(params_or_object(params))?;
let mut tools = state
.manager
.list_tools()
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
if let Some(server) = parsed.server {
tools.retain(|tool| tool.server_name == server);
}
Ok((json!({ "tools": tools }), false))
}
"tools/call" => {
let parsed: ToolsCallParams = parse_params(params_or_object(params))?;
let ToolsCallParams {
name,
tool,
server,
arguments,
} = parsed;
let tool_name = name
.or(tool)
.context("missing tool name")
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
let arguments = if arguments.is_null() {
json!({})
} else {
arguments
};
let result = if tool_name.starts_with("mcp__") {
state
.manager
.call_qualified_tool(&tool_name, arguments)
.map_err(|err| JsonRpcError::internal(err.to_string()))?
} else {
let server = server
.context("missing server for unqualified tool")
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
state
.manager
.call_tool(&server, &tool_name, arguments)
.map_err(|err| JsonRpcError::internal(err.to_string()))?
};
Ok((json!({ "result": result }), false))
}
"resources/list" => {
let parsed: ResourcesListParams = parse_params(params_or_object(params))?;
let mut resources = state
.manager
.list_resources()
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
if let Some(server) = parsed.server {
resources.retain(|resource| resource.server_name == server);
}
Ok((json!({ "resources": resources }), false))
}
"resources/read" => {
let parsed: ResourcesReadParams = parse_params(params_or_object(params))?;
let ResourcesReadParams { server, uri } = parsed;
let server_name = server
.or_else(|| parse_server_from_uri(&uri))
.context("missing server for resource read")
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
let value = state
.manager
.read_resource(&server_name, &uri)
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
Ok((json!({ "resource": value }), false))
}
"server/list" | "servers/list" => {
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
}
"server/register" | "servers/register" => {
let parsed: ServerRegisterParams = parse_params(params_or_object(params))?;
let name = parsed.server.name.clone();
if name.trim().is_empty() {
return Err(JsonRpcError::invalid_params(
"server.name must not be empty",
));
}
if state.definitions.contains_key(&name) {
let _ = state.manager.unregister_server(&name);
}
state.definitions.insert(
name.clone(),
McpServerDefinition {
config: parsed.server.clone(),
filter: parsed.filter.clone(),
},
);
let should_run = parsed.start && parsed.server.enabled;
if should_run {
state.manager.register_server(
parsed.server.clone(),
parsed.filter.clone(),
default_stdio_client(&name),
);
}
state.running.insert(name, should_run);
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
}
"server/start" | "servers/start" => {
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
let definition = state
.definitions
.get(&parsed.name)
.cloned()
.with_context(|| format!("server '{}' is not defined", parsed.name))
.map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
if !definition.config.enabled {
return Err(JsonRpcError::invalid_params(format!(
"server '{}' is disabled",
parsed.name
)));
}
if !state.running.get(&parsed.name).copied().unwrap_or(false) {
state.manager.register_server(
definition.config.clone(),
definition.filter.clone(),
default_stdio_client(&parsed.name),
);
state.running.insert(parsed.name, true);
}
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
}
"server/stop" | "servers/stop" => {
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
if state.running.get(&parsed.name).copied().unwrap_or(false) {
state
.manager
.stop_server(&parsed.name)
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
}
state.running.insert(parsed.name, false);
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
}
"server/unregister" | "servers/unregister" => {
let parsed: ServerNameParams = parse_params(params_or_object(params))?;
if state.definitions.remove(&parsed.name).is_none() {
return Err(JsonRpcError::invalid_params(format!(
"server '{}' is not defined",
parsed.name
)));
}
let _ = state.manager.unregister_server(&parsed.name);
state.running.remove(&parsed.name);
Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
}
"shutdown" => {
state.lifecycle_state = "shutting_down".to_string();
Ok((
json!({
"ok": true,
"lifecycle": lifecycle_snapshot(state)
}),
true,
))
}
_ => Err(JsonRpcError::method_not_found(method)),
}
}
fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"result": result
})
}
fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"error": {
"code": err.code,
"message": err.message,
"data": err.data
}
})
}
impl JsonRpcError {
fn parse_error(message: impl Into<String>) -> Self {
Self {
code: -32700,
message: message.into(),
data: None,
}
}
fn invalid_request(message: impl Into<String>) -> Self {
Self {
code: -32600,
message: message.into(),
data: None,
}
}
fn method_not_found(method: &str) -> Self {
Self {
code: -32601,
message: format!("unsupported method: {method}"),
data: None,
}
}
fn invalid_params(message: impl Into<String>) -> Self {
Self {
code: -32602,
message: message.into(),
data: None,
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
code: -32603,
message: message.into(),
data: None,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "deepseek-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Codex-style app-server protocol frames for DeepSeek workspace architecture"
[dependencies]
serde.workspace = true
serde_json.workspace = true
+451
View File
@@ -0,0 +1,451 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope<T> {
pub request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
pub body: T,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ThreadStatus {
Running,
Idle,
Completed,
Failed,
Paused,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionSource {
Interactive,
Resume,
Fork,
Api,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub id: String,
pub preview: String,
pub ephemeral: bool,
pub model_provider: String,
pub created_at: i64,
pub updated_at: i64,
pub status: ThreadStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
pub cwd: PathBuf,
pub cli_version: String,
pub source: SessionSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadStartParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadResumeParams {
pub thread_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub developer_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<String>,
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadForkParams {
pub thread_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub developer_instructions: Option<String>,
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadListParams {
#[serde(default)]
pub include_archived: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadReadParams {
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadSetNameParams {
pub thread_id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ThreadRequest {
Create {
#[serde(default)]
metadata: Value,
},
Start(ThreadStartParams),
Resume(ThreadResumeParams),
Fork(ThreadForkParams),
List(ThreadListParams),
Read(ThreadReadParams),
SetName(ThreadSetNameParams),
Archive {
thread_id: String,
},
Unarchive {
thread_id: String,
},
Message {
thread_id: String,
input: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadResponse {
pub thread_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<Thread>,
#[serde(default)]
pub threads: Vec<Thread>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<String>,
#[serde(default)]
pub events: Vec<EventFrame>,
#[serde(default)]
pub data: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AppRequest {
Capabilities,
ConfigGet { key: String },
ConfigSet { key: String, value: String },
ConfigUnset { key: String },
ConfigList,
Models,
ThreadLoadedList,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppResponse {
pub ok: bool,
pub data: Value,
#[serde(default)]
pub events: Vec<EventFrame>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptResponse {
pub output: String,
pub model: String,
#[serde(default)]
pub events: Vec<EventFrame>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AskForApproval {
UnlessTrusted,
OnFailure,
OnRequest,
Reject {
sandbox_approval: bool,
rules: bool,
mcp_elicitations: bool,
},
Never,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
Function,
Mcp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalShellParams {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolPayload {
Function {
arguments: String,
},
Custom {
input: String,
},
LocalShell {
params: LocalShellParams,
},
Mcp {
server: String,
tool: String,
raw_arguments: Value,
#[serde(skip_serializing_if = "Option::is_none")]
raw_tool_call_id: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolOutput {
Function {
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<Value>,
success: bool,
},
Mcp {
result: Value,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NetworkPolicyRuleAction {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetworkPolicyAmendment {
pub host: String,
pub action: NetworkPolicyRuleAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReviewDecision {
Approved,
ApprovedExecpolicyAmendment,
ApprovedForSession,
NetworkPolicyAmendment {
host: String,
action: NetworkPolicyRuleAction,
},
Denied,
Abort,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpStartupStatus {
Starting,
Ready,
Failed { error: String },
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupUpdateEvent {
pub server_name: String,
pub status: McpStartupStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupFailure {
pub server_name: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpStartupCompleteEvent {
pub ready: Vec<String>,
pub failed: Vec<McpStartupFailure>,
pub cancelled: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkApprovalContext {
pub host: String,
pub protocol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecApprovalRequestEvent {
pub call_id: String,
pub approval_id: String,
pub turn_id: String,
pub command: String,
pub cwd: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_approval_context: Option<NetworkApprovalContext>,
#[serde(default)]
pub proposed_execpolicy_amendment: Vec<String>,
#[serde(default)]
pub proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
#[serde(default)]
pub additional_permissions: Vec<String>,
#[serde(default)]
pub available_decisions: Vec<ReviewDecision>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum EventFrame {
ResponseStart {
response_id: String,
},
ResponseDelta {
response_id: String,
delta: String,
},
ResponseEnd {
response_id: String,
},
ToolCallStart {
response_id: String,
tool_name: String,
arguments: Value,
},
ToolCallResult {
response_id: String,
tool_name: String,
output: Value,
},
McpStartupUpdate {
update: McpStartupUpdateEvent,
},
McpStartupComplete {
summary: McpStartupCompleteEvent,
},
McpToolCallBegin {
server_name: String,
tool_name: String,
},
McpToolCallEnd {
server_name: String,
tool_name: String,
ok: bool,
},
ExecApprovalRequest {
request: ExecApprovalRequestEvent,
},
ApplyPatchApprovalRequest {
request: ExecApprovalRequestEvent,
},
ElicitationRequest {
server_name: String,
request_id: String,
prompt: String,
},
ExecCommandBegin {
command: String,
cwd: String,
},
ExecCommandOutputDelta {
command: String,
delta: String,
},
ExecCommandEnd {
command: String,
exit_code: i32,
},
PatchApplyBegin {
path: String,
},
PatchApplyEnd {
path: String,
ok: bool,
},
TurnStarted {
turn_id: String,
},
TurnComplete {
turn_id: String,
},
TurnAborted {
turn_id: String,
reason: String,
},
Error {
response_id: String,
message: String,
},
}
+50
View File
@@ -0,0 +1,50 @@
use deepseek_protocol::{EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams};
#[test]
fn thread_resume_params_round_trip() {
let request = ThreadRequest::Resume(ThreadResumeParams {
thread_id: "thread-123".to_string(),
history: None,
path: None,
model: Some("deepseek-v4-pro".to_string()),
model_provider: Some("deepseek".to_string()),
cwd: None,
approval_policy: Some("on-request".to_string()),
sandbox: Some("workspace-write".to_string()),
config: None,
base_instructions: Some("base".to_string()),
developer_instructions: Some("dev".to_string()),
personality: Some("default".to_string()),
persist_extended_history: true,
});
let encoded = serde_json::to_string(&request).expect("serialize request");
let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
match decoded {
ThreadRequest::Resume(params) => {
assert_eq!(params.thread_id, "thread-123");
assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro"));
assert!(params.persist_extended_history);
}
other => panic!("unexpected request: {other:?}"),
}
}
#[test]
fn thread_list_params_defaults_are_serializable() {
let request = ThreadRequest::List(ThreadListParams {
include_archived: false,
limit: Some(20),
});
let encoded = serde_json::to_string_pretty(&request).expect("serialize list request");
assert!(encoded.contains("include_archived"));
}
#[test]
fn event_frame_serialization_contains_expected_tag() {
let frame = EventFrame::TurnComplete {
turn_id: "turn-1".to_string(),
};
let encoded = serde_json::to_string(&frame).expect("serialize frame");
assert!(encoded.contains("turn_complete"));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "deepseek-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Secret storage backends (OS keyring with file fallback) for DeepSeek workspace"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
dirs = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
[dev-dependencies]
tempfile = "3.16"
+578
View File
@@ -0,0 +1,578 @@
//! Secret storage for DeepSeek API keys.
//!
//! Provides a small abstraction (`KeyringStore`) plus a default
//! implementation backed by the OS keyring (`DefaultKeyringStore`),
//! a file-based fallback for headless Linux (`FileKeyringStore`), and
//! an in-memory store for tests (`InMemoryKeyringStore`).
//!
//! Higher-level lookup goes through [`Secrets::resolve`], which checks
//! the keyring first and falls back to environment variables. The
//! caller (typically the config crate) then falls back to plaintext
//! TOML if both are empty — that final layer lives outside this crate
//! so the precedence is explicit at the call site.
//!
//! Hard rule: **keyring → env → config-file**. Never swap.
#![deny(missing_docs)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Default OS keychain service name. macOS users can verify entries with
/// `security find-generic-password -s deepseek -a <provider>`.
pub const DEFAULT_SERVICE: &str = "deepseek";
/// Errors that may arise from a [`KeyringStore`] backend.
#[derive(Debug, Error)]
pub enum SecretsError {
/// Underlying OS keyring backend reported an error.
#[error("keyring backend error: {0}")]
Keyring(String),
/// File-backed fallback I/O error.
#[error("file-backed secret store I/O error: {0}")]
Io(#[from] std::io::Error),
/// File-backed fallback JSON (de)serialisation error.
#[error("file-backed secret store JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Caught when a stored secret on disk has unsafe permissions.
#[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
InsecurePermissions {
/// Absolute path to the secrets file.
path: PathBuf,
/// Observed unix permission mode.
mode: u32,
},
}
/// Abstract secret store; concrete implementations may use the OS
/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
/// map (tests).
pub trait KeyringStore: Send + Sync {
/// Read a secret. Returns `Ok(None)` if no entry exists.
fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
/// Write a secret, replacing any existing value.
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
/// Remove a secret. Should not error if the entry is absent.
fn delete(&self, key: &str) -> Result<(), SecretsError>;
/// Short, human-readable name of the backend (used by `doctor`).
fn backend_name(&self) -> &'static str;
}
/// OS keyring backend (macOS Keychain, Windows Credential Manager,
/// Linux Secret Service / kwallet).
#[derive(Debug, Clone)]
pub struct DefaultKeyringStore {
/// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
service: String,
}
impl Default for DefaultKeyringStore {
fn default() -> Self {
Self::new(DEFAULT_SERVICE)
}
}
impl DefaultKeyringStore {
/// Build a new store with the given service name.
#[must_use]
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
/// Probe the OS keyring without writing anything. Returns `Ok(())` if
/// a backend is reachable, otherwise an error describing why not.
pub fn probe(&self) -> Result<(), SecretsError> {
// `Entry::new` is enough to surface "no backend / no storage" on
// headless Linux; no actual read happens until `.get_password()`.
let entry = keyring::Entry::new(&self.service, "__probe__")
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(keyring::Error::PlatformFailure(err)) => {
Err(SecretsError::Keyring(format!("platform failure: {err}")))
}
Err(keyring::Error::NoStorageAccess(err)) => {
Err(SecretsError::Keyring(format!("no storage access: {err}")))
}
Err(other) => Err(SecretsError::Keyring(other.to_string())),
}
}
}
impl KeyringStore for DefaultKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
entry
.set_password(value)
.map_err(|err| SecretsError::Keyring(err.to_string()))
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(SecretsError::Keyring(err.to_string())),
}
}
fn backend_name(&self) -> &'static str {
"system keyring"
}
}
/// In-memory keyring (tests only).
#[derive(Debug, Default)]
pub struct InMemoryKeyringStore {
entries: Mutex<HashMap<String, String>>,
}
impl InMemoryKeyringStore {
/// Create an empty store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl KeyringStore for InMemoryKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
Ok(self.entries.lock().unwrap().get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
self.entries
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
fn backend_name(&self) -> &'static str {
"in-memory (test)"
}
}
/// JSON-on-disk fallback for headless environments without a Secret
/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
/// with mode `0600`.
#[derive(Debug, Clone)]
pub struct FileKeyringStore {
/// Absolute path to the JSON file.
path: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct FileSecretsBlob {
#[serde(default)]
entries: HashMap<String, String>,
}
impl FileKeyringStore {
/// Build a store backed by the given JSON file path.
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
/// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
/// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
pub fn default_path() -> Result<PathBuf, SecretsError> {
let home = dirs::home_dir().ok_or_else(|| {
SecretsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve home directory for FileKeyringStore",
))
})?;
Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
}
/// Path used for storage.
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
if !self.path.exists() {
return Ok(FileSecretsBlob::default());
}
// Reject files with unsafe permissions on unix. On Windows the
// ACL model is too different to enforce here; the caller is
// responsible for placing the file in a per-user directory.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(&self.path)?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(SecretsError::InsecurePermissions {
path: self.path.clone(),
mode,
});
}
}
let raw = fs::read_to_string(&self.path)?;
if raw.trim().is_empty() {
return Ok(FileSecretsBlob::default());
}
let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
Ok(blob)
}
fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions();
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
let body = serde_json::to_string_pretty(blob)?;
fs::write(&self.path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&self.path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&self.path, perms)?;
}
Ok(())
}
}
impl KeyringStore for FileKeyringStore {
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
let blob = self.load_unlocked()?;
Ok(blob.entries.get(key).cloned())
}
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked().unwrap_or_default();
blob.entries.insert(key.to_string(), value.to_string());
self.store_unlocked(&blob)
}
fn delete(&self, key: &str) -> Result<(), SecretsError> {
let mut blob = self.load_unlocked().unwrap_or_default();
blob.entries.remove(key);
self.store_unlocked(&blob)
}
fn backend_name(&self) -> &'static str {
"file-based (~/.deepseek/secrets/)"
}
}
/// High-level façade combining a [`KeyringStore`] with environment
/// variable fallbacks.
///
/// Lookup precedence: **keyring → env → none**. Callers that also have
/// a TOML config layer must wire that themselves at the very end of
/// the chain.
#[derive(Clone)]
pub struct Secrets {
/// Underlying secret store.
pub store: Arc<dyn KeyringStore>,
/// Owner identifier within the keyring (typically "deepseek"); the
/// `key` parameter passed to `resolve` is mapped to a slot in the
/// store as-is, while envs are looked up by canonical name.
service: String,
}
impl std::fmt::Debug for Secrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secrets")
.field("backend", &self.store.backend_name())
.field("service", &self.service)
.finish()
}
}
impl Secrets {
/// Build a new façade around a store.
#[must_use]
pub fn new(store: Arc<dyn KeyringStore>) -> Self {
Self {
store,
service: DEFAULT_SERVICE.to_string(),
}
}
/// Construct the platform-appropriate default backend. On platforms
/// where an OS keyring backend is reachable this returns
/// [`DefaultKeyringStore`]; otherwise it falls back to
/// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
pub fn auto_detect() -> Self {
let default_store = DefaultKeyringStore::default();
match default_store.probe() {
Ok(()) => Self::new(Arc::new(default_store)),
Err(err) => {
tracing::warn!(
"OS keyring unavailable ({err}); falling back to file-backed secret store"
);
let path = FileKeyringStore::default_path()
.unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
Self::new(Arc::new(FileKeyringStore::new(path)))
}
}
}
/// Backend label, suitable for `doctor` output.
#[must_use]
pub fn backend_name(&self) -> &'static str {
self.store.backend_name()
}
/// Resolve a secret with `keyring → env → none` precedence.
///
/// `name` is the canonical provider name (`"deepseek"`,
/// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
/// Empty strings on either layer are treated as "not set".
#[must_use]
pub fn resolve(&self, name: &str) -> Option<String> {
if let Ok(Some(v)) = self.store.get(name)
&& !v.trim().is_empty()
{
return Some(v);
}
env_for(name)
}
/// Convenience: write a secret through the underlying store.
pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
self.store.set(name, value)
}
/// Convenience: delete a secret through the underlying store.
pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
self.store.delete(name)
}
/// Convenience: read a secret directly (no env fallback).
pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
self.store.get(name)
}
}
/// Map a canonical provider name to its environment variable, returning
/// the value if non-empty.
#[must_use]
pub fn env_for(name: &str) -> Option<String> {
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
"deepseek" => &["DEEPSEEK_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"novita" => &["NOVITA_API_KEY"],
// NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
// catalog endpoint accepts the same DeepSeek-issued key when no
// dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
}
"openai" => &["OPENAI_API_KEY"],
_ => return None,
};
for var in candidates {
if let Ok(value) = std::env::var(var)
&& !value.trim().is_empty()
{
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
/// Serialise env-mutating tests: tests in this module poke
/// `DEEPSEEK_API_KEY` etc., which is process-global.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
fn clear_known_envs() {
for var in [
"DEEPSEEK_API_KEY",
"OPENROUTER_API_KEY",
"NOVITA_API_KEY",
"NVIDIA_API_KEY",
"NVIDIA_NIM_API_KEY",
"OPENAI_API_KEY",
] {
// Safety: tests serialise on env_lock(); the broader
// workspace has the same pattern in `crates/config`.
unsafe { std::env::remove_var(var) };
}
}
#[test]
fn in_memory_store_round_trips() {
let store = InMemoryKeyringStore::new();
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-test").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
store.set("deepseek", "sk-replaced").unwrap();
assert_eq!(
store.get("deepseek").unwrap(),
Some("sk-replaced".to_string())
);
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
// Deleting an absent key is a no-op.
store.delete("missing").unwrap();
}
#[test]
fn resolve_prefers_keyring_over_env() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", "ring-key").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_falls_back_to_env_when_keyring_empty() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn resolve_returns_none_when_both_layers_empty() {
let _lock = env_lock();
clear_known_envs();
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("deepseek"), None);
}
#[test]
fn resolve_treats_blank_keyring_value_as_unset() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
let store = Arc::new(InMemoryKeyringStore::new());
store.set("deepseek", " ").unwrap();
let secrets = Secrets::new(store);
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
}
#[test]
fn nvidia_env_aliases_resolve() {
let _lock = env_lock();
clear_known_envs();
// Safety: env mutation guarded by env_lock().
unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
// Safety: env mutation guarded by env_lock().
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
}
#[cfg(unix)]
#[test]
fn file_store_round_trips_with_secure_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("secrets.json");
let store = FileKeyringStore::new(path.clone());
assert_eq!(store.get("deepseek").unwrap(), None);
store.set("deepseek", "sk-disk").unwrap();
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
store.set("openrouter", "or-disk").unwrap();
assert_eq!(
store.get("openrouter").unwrap(),
Some("or-disk".to_string())
);
// First entry must still be intact.
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
store.delete("deepseek").unwrap();
assert_eq!(store.get("deepseek").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn file_store_rejects_world_readable_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("secrets.json");
fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&path, perms).unwrap();
let store = FileKeyringStore::new(path);
let err = store.get("deepseek").unwrap_err();
assert!(
matches!(err, SecretsError::InsecurePermissions { .. }),
"unexpected error: {err}"
);
}
#[test]
fn file_store_default_path_uses_home() {
// We don't override HOME here (other tests do); we just check the
// shape of the path is `<home>/.deepseek/secrets/secrets.json`.
let path = FileKeyringStore::default_path().unwrap();
assert!(
path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
"unexpected default path: {}",
path.display()
);
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "deepseek-state"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Session/thread persistence and recovery model for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
dirs.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
+950
View File
@@ -0,0 +1,950 @@
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ThreadStatus {
Running,
Idle,
Completed,
Failed,
Paused,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionSource {
Interactive,
Resume,
Fork,
Api,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadMetadata {
pub id: String,
pub rollout_path: Option<PathBuf>,
pub preview: String,
pub ephemeral: bool,
pub model_provider: String,
pub created_at: i64,
pub updated_at: i64,
pub status: ThreadStatus,
pub path: Option<PathBuf>,
pub cwd: PathBuf,
pub cli_version: String,
pub source: SessionSource,
pub name: Option<String>,
pub sandbox_policy: Option<String>,
pub approval_mode: Option<String>,
pub archived: bool,
pub archived_at: Option<i64>,
pub git_sha: Option<String>,
pub git_branch: Option<String>,
pub git_origin_url: Option<String>,
pub memory_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DynamicToolRecord {
pub position: i64,
pub name: String,
pub description: Option<String>,
pub input_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageRecord {
pub id: i64,
pub thread_id: String,
pub role: String,
pub content: String,
pub item: Option<Value>,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointRecord {
pub thread_id: String,
pub checkpoint_id: String,
pub state: Value,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JobStateStatus {
Queued,
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStateRecord {
pub id: String,
pub name: String,
pub status: JobStateStatus,
pub progress: Option<u8>,
pub detail: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone)]
pub struct ThreadListFilters {
pub include_archived: bool,
pub limit: Option<usize>,
}
impl Default for ThreadListFilters {
fn default() -> Self {
Self {
include_archived: false,
limit: Some(50),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionIndexEntry {
thread_id: String,
thread_name: Option<String>,
updated_at: i64,
rollout_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct StateStore {
db_path: PathBuf,
session_index_path: PathBuf,
}
impl StateStore {
pub fn open(path: Option<PathBuf>) -> Result<Self> {
let db_path = path.unwrap_or_else(default_state_db_path);
let session_index_path = db_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("session_index.jsonl");
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create state directory {}", parent.display())
})?;
}
let store = Self {
db_path,
session_index_path,
};
store.init_schema()?;
Ok(store)
}
pub fn db_path(&self) -> &Path {
&self.db_path
}
fn conn(&self) -> Result<Connection> {
Connection::open(&self.db_path)
.with_context(|| format!("failed to open state db {}", self.db_path.display()))
}
fn init_schema(&self) -> Result<()> {
let conn = self.conn()?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT,
preview TEXT NOT NULL,
ephemeral INTEGER NOT NULL,
model_provider TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
status TEXT NOT NULL,
path TEXT,
cwd TEXT NOT NULL,
cli_version TEXT NOT NULL,
source TEXT NOT NULL,
title TEXT,
sandbox_policy TEXT,
approval_mode TEXT,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
memory_mode TEXT
);
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
input_schema TEXT NOT NULL,
PRIMARY KEY (thread_id, position),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
item_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT NOT NULL,
checkpoint_id TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY(thread_id, checkpoint_id),
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER,
detail TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
"#,
)
.context("failed to initialize thread schema")?;
Ok(())
}
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
let conn = self.conn()?;
conn.execute(
r#"
INSERT INTO threads (
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
git_sha, git_branch, git_origin_url, memory_mode
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21
)
ON CONFLICT(id) DO UPDATE SET
rollout_path=excluded.rollout_path,
preview=excluded.preview,
ephemeral=excluded.ephemeral,
model_provider=excluded.model_provider,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
status=excluded.status,
path=excluded.path,
cwd=excluded.cwd,
cli_version=excluded.cli_version,
source=excluded.source,
title=excluded.title,
sandbox_policy=excluded.sandbox_policy,
approval_mode=excluded.approval_mode,
archived=excluded.archived,
archived_at=excluded.archived_at,
git_sha=excluded.git_sha,
git_branch=excluded.git_branch,
git_origin_url=excluded.git_origin_url,
memory_mode=excluded.memory_mode
"#,
params![
thread.id,
path_to_opt_string(thread.rollout_path.as_deref()),
thread.preview,
bool_to_i64(thread.ephemeral),
thread.model_provider,
thread.created_at,
thread.updated_at,
thread_status_to_str(&thread.status),
path_to_opt_string(thread.path.as_deref()),
thread.cwd.display().to_string(),
thread.cli_version,
session_source_to_str(&thread.source),
thread.name,
thread.sandbox_policy,
thread.approval_mode,
bool_to_i64(thread.archived),
thread.archived_at,
thread.git_sha,
thread.git_branch,
thread.git_origin_url,
thread.memory_mode,
],
)
.context("failed to upsert thread metadata")?;
self.append_thread_name(
&thread.id,
thread.name.clone(),
thread.updated_at,
thread.rollout_path.clone(),
)?;
Ok(())
}
pub fn get_thread(&self, id: &str) -> Result<Option<ThreadMetadata>> {
let conn = self.conn()?;
conn.query_row(
r#"
SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
git_sha, git_branch, git_origin_url, memory_mode
FROM threads
WHERE id = ?1
"#,
params![id],
row_to_thread,
)
.optional()
.context("failed to read thread")
}
pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
let conn = self.conn()?;
let sql = if filters.include_archived {
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1"
} else {
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
};
let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
let limit = i64::try_from(filters.limit.unwrap_or(50)).unwrap_or(50);
let mut rows = stmt
.query(params![limit])
.context("failed to query threads")?;
let mut out = Vec::new();
while let Some(row) = rows.next().context("failed to iterate thread rows")? {
out.push(row_to_thread(row)?);
}
Ok(out)
}
pub fn mark_archived(&self, id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"UPDATE threads SET archived = 1, archived_at = ?2, status = ?3 WHERE id = ?1",
params![
id,
Utc::now().timestamp(),
thread_status_to_str(&ThreadStatus::Archived)
],
)
.context("failed to archive thread")?;
Ok(())
}
pub fn mark_unarchived(&self, id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"UPDATE threads SET archived = 0, archived_at = NULL WHERE id = ?1",
params![id],
)
.context("failed to unarchive thread")?;
Ok(())
}
pub fn delete_thread(&self, id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute("DELETE FROM threads WHERE id = ?1", params![id])
.context("failed to delete thread")?;
Ok(())
}
pub fn set_thread_memory_mode(&self, id: &str, mode: Option<&str>) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"UPDATE threads SET memory_mode = ?2 WHERE id = ?1",
params![id, mode],
)
.context("failed to update thread memory mode")?;
Ok(())
}
pub fn get_thread_memory_mode(&self, id: &str) -> Result<Option<String>> {
let conn = self.conn()?;
conn.query_row(
"SELECT memory_mode FROM threads WHERE id = ?1",
params![id],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.context("failed to read thread memory mode")
.map(Option::flatten)
}
pub fn persist_dynamic_tools(
&self,
thread_id: &str,
tools: &[DynamicToolRecord],
) -> Result<()> {
let mut conn = self.conn()?;
let tx = conn
.transaction()
.context("failed to begin dynamic tools transaction")?;
tx.execute(
"DELETE FROM thread_dynamic_tools WHERE thread_id = ?1",
params![thread_id],
)
.context("failed to clear dynamic tools")?;
for tool in tools {
tx.execute(
"INSERT INTO thread_dynamic_tools(thread_id, position, name, description, input_schema) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
thread_id,
tool.position,
tool.name,
tool.description,
tool.input_schema.to_string()
],
)
.with_context(|| format!("failed to persist dynamic tool {}", tool.name))?;
}
tx.commit().context("failed to commit dynamic tools")?;
Ok(())
}
pub fn get_dynamic_tools(&self, thread_id: &str) -> Result<Vec<DynamicToolRecord>> {
let conn = self.conn()?;
let mut stmt = conn
.prepare(
"SELECT position, name, description, input_schema FROM thread_dynamic_tools WHERE thread_id = ?1 ORDER BY position ASC",
)
.context("failed to prepare get dynamic tools query")?;
let mut rows = stmt
.query(params![thread_id])
.context("failed to query dynamic tools")?;
let mut out = Vec::new();
while let Some(row) = rows.next().context("failed to iterate dynamic tools")? {
let input_schema_raw: String =
row.get(3).context("failed to read tool input schema")?;
let input_schema: Value =
serde_json::from_str(&input_schema_raw).with_context(|| {
format!("failed to parse input schema for dynamic tool in thread {thread_id}")
})?;
out.push(DynamicToolRecord {
position: row.get(0).context("failed to read tool position")?,
name: row.get(1).context("failed to read tool name")?,
description: row.get(2).context("failed to read tool description")?,
input_schema,
});
}
Ok(out)
}
pub fn append_message(
&self,
thread_id: &str,
role: &str,
content: &str,
item: Option<Value>,
) -> Result<i64> {
let conn = self.conn()?;
let created_at = Utc::now().timestamp();
let item_json = item
.as_ref()
.map(serde_json::to_string)
.transpose()
.context("failed to serialize message item payload")?;
conn.execute(
"INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![thread_id, role, content, item_json, created_at],
)
.with_context(|| format!("failed to append message for thread {thread_id}"))?;
Ok(conn.last_insert_rowid())
}
pub fn list_messages(
&self,
thread_id: &str,
limit: Option<usize>,
) -> Result<Vec<MessageRecord>> {
let conn = self.conn()?;
let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
let mut stmt = conn
.prepare(
"SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2",
)
.context("failed to prepare message listing query")?;
let mut rows = stmt
.query(params![thread_id, limit])
.with_context(|| format!("failed to list messages for thread {thread_id}"))?;
let mut out = Vec::new();
while let Some(row) = rows.next().context("failed to iterate message rows")? {
let item_json: Option<String> = row.get(4).context("failed to read item json")?;
let item = item_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.with_context(|| {
format!("failed to parse message item json in thread {thread_id}")
})?;
out.push(MessageRecord {
id: row.get(0).context("failed to read message id")?,
thread_id: row.get(1).context("failed to read message thread id")?,
role: row.get(2).context("failed to read message role")?,
content: row.get(3).context("failed to read message content")?,
item,
created_at: row.get(5).context("failed to read message timestamp")?,
});
}
Ok(out)
}
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
let conn = self.conn()?;
conn.execute(
"DELETE FROM messages WHERE thread_id = ?1",
params![thread_id],
)
.with_context(|| format!("failed to clear messages for thread {thread_id}"))
}
pub fn save_checkpoint(
&self,
thread_id: &str,
checkpoint_id: &str,
state: &Value,
) -> Result<()> {
let conn = self.conn()?;
let state_json =
serde_json::to_string(state).context("failed to encode checkpoint state")?;
conn.execute(
r#"
INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(thread_id, checkpoint_id) DO UPDATE SET
state_json = excluded.state_json,
created_at = excluded.created_at
"#,
params![thread_id, checkpoint_id, state_json, Utc::now().timestamp()],
)
.with_context(|| {
format!("failed to save checkpoint {checkpoint_id} for thread {thread_id}")
})?;
Ok(())
}
pub fn load_checkpoint(
&self,
thread_id: &str,
checkpoint_id: Option<&str>,
) -> Result<Option<CheckpointRecord>> {
let conn = self.conn()?;
if let Some(checkpoint_id) = checkpoint_id {
let row = conn
.query_row(
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
params![thread_id, checkpoint_id],
|row| {
let state_json: String = row.get(2)?;
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
Ok(CheckpointRecord {
thread_id: row.get(0)?,
checkpoint_id: row.get(1)?,
state,
created_at: row.get(3)?,
})
},
)
.optional()
.with_context(|| {
format!("failed to load checkpoint {checkpoint_id} for thread {thread_id}")
})?;
return Ok(row);
}
conn.query_row(
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT 1",
params![thread_id],
|row| {
let state_json: String = row.get(2)?;
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
Ok(CheckpointRecord {
thread_id: row.get(0)?,
checkpoint_id: row.get(1)?,
state,
created_at: row.get(3)?,
})
},
)
.optional()
.with_context(|| format!("failed to load latest checkpoint for thread {thread_id}"))
}
pub fn list_checkpoints(
&self,
thread_id: &str,
limit: Option<usize>,
) -> Result<Vec<CheckpointRecord>> {
let conn = self.conn()?;
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
let mut stmt = conn
.prepare(
"SELECT thread_id, checkpoint_id, state_json, created_at FROM checkpoints WHERE thread_id = ?1 ORDER BY created_at DESC LIMIT ?2",
)
.context("failed to prepare checkpoint list query")?;
let mut rows = stmt
.query(params![thread_id, limit])
.with_context(|| format!("failed to list checkpoints for thread {thread_id}"))?;
let mut out = Vec::new();
while let Some(row) = rows.next().context("failed to iterate checkpoint rows")? {
let state_json: String = row.get(2).context("failed to read checkpoint state json")?;
let state = serde_json::from_str(&state_json).unwrap_or(Value::Null);
out.push(CheckpointRecord {
thread_id: row.get(0).context("failed to read checkpoint thread id")?,
checkpoint_id: row.get(1).context("failed to read checkpoint id")?,
state,
created_at: row.get(3).context("failed to read checkpoint timestamp")?,
});
}
Ok(out)
}
pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
params![thread_id, checkpoint_id],
)
.with_context(|| {
format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
})?;
Ok(())
}
pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
let conn = self.conn()?;
conn.execute(
r#"
INSERT INTO jobs(id, name, status, progress, detail, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
status = excluded.status,
progress = excluded.progress,
detail = excluded.detail,
created_at = excluded.created_at,
updated_at = excluded.updated_at
"#,
params![
job.id,
job.name,
job_state_status_to_str(&job.status),
job.progress.map(i64::from),
job.detail,
job.created_at,
job.updated_at
],
)
.with_context(|| format!("failed to upsert job {}", job.id))?;
Ok(())
}
pub fn get_job(&self, id: &str) -> Result<Option<JobStateRecord>> {
let conn = self.conn()?;
conn.query_row(
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs WHERE id = ?1",
params![id],
|row| {
let status_raw: String = row.get(2)?;
let progress: Option<i64> = row.get(3)?;
Ok(JobStateRecord {
id: row.get(0)?,
name: row.get(1)?,
status: job_state_status_from_str(&status_raw),
progress: progress.and_then(|v| u8::try_from(v).ok()),
detail: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.optional()
.with_context(|| format!("failed to read job {id}"))
}
pub fn list_jobs(&self, limit: Option<usize>) -> Result<Vec<JobStateRecord>> {
let conn = self.conn()?;
let limit = i64::try_from(limit.unwrap_or(100)).unwrap_or(100);
let mut stmt = conn
.prepare(
"SELECT id, name, status, progress, detail, created_at, updated_at FROM jobs ORDER BY updated_at DESC LIMIT ?1",
)
.context("failed to prepare job list query")?;
let mut rows = stmt
.query(params![limit])
.context("failed to query persisted jobs")?;
let mut out = Vec::new();
while let Some(row) = rows.next().context("failed to iterate persisted jobs")? {
let status_raw: String = row.get(2).context("failed to read job status")?;
let progress: Option<i64> = row.get(3).context("failed to read job progress")?;
out.push(JobStateRecord {
id: row.get(0).context("failed to read job id")?,
name: row.get(1).context("failed to read job name")?,
status: job_state_status_from_str(&status_raw),
progress: progress.and_then(|v| u8::try_from(v).ok()),
detail: row.get(4).context("failed to read job detail")?,
created_at: row.get(5).context("failed to read job created_at")?,
updated_at: row.get(6).context("failed to read job updated_at")?,
});
}
Ok(out)
}
pub fn delete_job(&self, id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute("DELETE FROM jobs WHERE id = ?1", params![id])
.with_context(|| format!("failed to delete job {id}"))?;
Ok(())
}
pub fn find_rollout_path_by_id(&self, id: &str) -> Result<Option<PathBuf>> {
let conn = self.conn()?;
conn.query_row(
"SELECT rollout_path FROM threads WHERE id = ?1",
params![id],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.context("failed to lookup rollout path")
.map(|opt| opt.flatten().map(PathBuf::from))
}
pub fn append_thread_name(
&self,
thread_id: &str,
thread_name: Option<String>,
updated_at: i64,
rollout_path: Option<PathBuf>,
) -> Result<()> {
if let Some(parent) = self.session_index_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create session index directory {}",
parent.display()
)
})?;
}
let entry = SessionIndexEntry {
thread_id: thread_id.to_string(),
thread_name,
updated_at,
rollout_path,
};
let encoded =
serde_json::to_string(&entry).context("failed to serialize session index entry")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.session_index_path)
.with_context(|| {
format!(
"failed to open session index {}",
self.session_index_path.display()
)
})?;
writeln!(file, "{encoded}").context("failed to append session index entry")?;
Ok(())
}
pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result<Option<String>> {
let map = self.session_index_map()?;
Ok(map
.get(thread_id)
.and_then(|entry| entry.thread_name.clone()))
}
pub fn find_thread_names_by_ids(
&self,
ids: &[String],
) -> Result<HashMap<String, Option<String>>> {
let map = self.session_index_map()?;
let mut out = HashMap::new();
for id in ids {
let name = map.get(id).and_then(|entry| entry.thread_name.clone());
out.insert(id.clone(), name);
}
Ok(out)
}
pub fn find_thread_path_by_name_str(&self, name: &str) -> Result<Option<PathBuf>> {
let map = self.session_index_map()?;
let matched = map
.values()
.filter(|entry| {
entry
.thread_name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(name))
})
.max_by_key(|entry| entry.updated_at);
Ok(matched.and_then(|entry| entry.rollout_path.clone()))
}
fn session_index_map(&self) -> Result<HashMap<String, SessionIndexEntry>> {
if !self.session_index_path.exists() {
return Ok(HashMap::new());
}
let file = OpenOptions::new()
.read(true)
.open(&self.session_index_path)
.with_context(|| {
format!(
"failed to read session index {}",
self.session_index_path.display()
)
})?;
let reader = BufReader::new(file);
let mut latest = HashMap::<String, SessionIndexEntry>::new();
for line in reader.lines() {
let line = line.context("failed to read session index line")?;
if line.trim().is_empty() {
continue;
}
let parsed: SessionIndexEntry =
serde_json::from_str(&line).context("failed to parse session index entry")?;
latest.insert(parsed.thread_id.clone(), parsed);
}
Ok(latest)
}
}
fn default_state_db_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".deepseek")
.join("state.db")
}
fn bool_to_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
fn i64_to_bool(value: i64) -> bool {
value != 0
}
fn thread_status_to_str(status: &ThreadStatus) -> &'static str {
match status {
ThreadStatus::Running => "running",
ThreadStatus::Idle => "idle",
ThreadStatus::Completed => "completed",
ThreadStatus::Failed => "failed",
ThreadStatus::Paused => "paused",
ThreadStatus::Archived => "archived",
}
}
fn thread_status_from_str(value: &str) -> ThreadStatus {
match value {
"running" => ThreadStatus::Running,
"idle" => ThreadStatus::Idle,
"completed" => ThreadStatus::Completed,
"failed" => ThreadStatus::Failed,
"paused" => ThreadStatus::Paused,
"archived" => ThreadStatus::Archived,
_ => ThreadStatus::Idle,
}
}
fn session_source_to_str(source: &SessionSource) -> &'static str {
match source {
SessionSource::Interactive => "interactive",
SessionSource::Resume => "resume",
SessionSource::Fork => "fork",
SessionSource::Api => "api",
SessionSource::Unknown => "unknown",
}
}
fn session_source_from_str(value: &str) -> SessionSource {
match value {
"interactive" => SessionSource::Interactive,
"resume" => SessionSource::Resume,
"fork" => SessionSource::Fork,
"api" => SessionSource::Api,
_ => SessionSource::Unknown,
}
}
fn path_to_opt_string(path: Option<&Path>) -> Option<String> {
path.map(|p| p.display().to_string())
}
fn job_state_status_to_str(status: &JobStateStatus) -> &'static str {
match status {
JobStateStatus::Queued => "queued",
JobStateStatus::Running => "running",
JobStateStatus::Completed => "completed",
JobStateStatus::Failed => "failed",
JobStateStatus::Cancelled => "cancelled",
}
}
fn job_state_status_from_str(value: &str) -> JobStateStatus {
match value {
"queued" => JobStateStatus::Queued,
"running" => JobStateStatus::Running,
"completed" => JobStateStatus::Completed,
"failed" => JobStateStatus::Failed,
"cancelled" => JobStateStatus::Cancelled,
_ => JobStateStatus::Queued,
}
}
fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
let status_raw: String = row.get(7)?;
let source_raw: String = row.get(11)?;
let rollout_path: Option<String> = row.get(1)?;
let path: Option<String> = row.get(8)?;
Ok(ThreadMetadata {
id: row.get(0)?,
rollout_path: rollout_path.map(PathBuf::from),
preview: row.get(2)?,
ephemeral: i64_to_bool(row.get(3)?),
model_provider: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
status: thread_status_from_str(&status_raw),
path: path.map(PathBuf::from),
cwd: PathBuf::from(row.get::<_, String>(9)?),
cli_version: row.get(10)?,
source: session_source_from_str(&source_raw),
name: row.get(12)?,
sandbox_policy: row.get(13)?,
approval_mode: row.get(14)?,
archived: i64_to_bool(row.get(15)?),
archived_at: row.get(16)?,
git_sha: row.get(17)?,
git_branch: row.get(18)?,
git_origin_url: row.get(19)?,
memory_mode: row.get(20)?,
})
}
+72
View File
@@ -0,0 +1,72 @@
use std::path::PathBuf;
use deepseek_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
fn temp_state_path(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"deepseek_state_test_{}_{}_{}.db",
label,
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
))
}
#[test]
fn upsert_and_resume_thread_metadata() {
let path = temp_state_path("upsert_resume");
let store = StateStore::open(Some(path.clone())).expect("open state store");
let now = chrono::Utc::now().timestamp();
let thread = ThreadMetadata {
id: "thread-test-1".to_string(),
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
preview: "hello".to_string(),
ephemeral: false,
model_provider: "deepseek".to_string(),
created_at: now,
updated_at: now,
status: ThreadStatus::Running,
path: Some(PathBuf::from("/tmp/project")),
cwd: PathBuf::from("/tmp/project"),
cli_version: "0.0.0-test".to_string(),
source: SessionSource::Interactive,
name: Some("Test Thread".to_string()),
sandbox_policy: Some("workspace-write".to_string()),
approval_mode: Some("on-request".to_string()),
archived: false,
archived_at: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
memory_mode: Some("extended".to_string()),
};
store.upsert_thread(&thread).expect("upsert thread");
let loaded = store
.get_thread("thread-test-1")
.expect("read thread")
.expect("thread must exist");
assert_eq!(loaded.id, "thread-test-1");
assert_eq!(loaded.name.as_deref(), Some("Test Thread"));
assert_eq!(loaded.memory_mode.as_deref(), Some("extended"));
assert_eq!(
loaded.rollout_path,
Some(PathBuf::from("/tmp/rollout.jsonl"))
);
store
.mark_archived("thread-test-1")
.expect("archive thread");
let archived = store
.get_thread("thread-test-1")
.expect("read archived thread")
.expect("thread exists after archive");
assert!(archived.archived);
let listed = store
.list_threads(ThreadListFilters {
include_archived: true,
limit: Some(10),
})
.expect("list threads");
assert!(!listed.is_empty());
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "deepseek-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Tool invocation lifecycle, schema validation, and scheduler parallelism for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.7.5" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
uuid.workspace = true
+469
View File
@@ -0,0 +1,469 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
/// Capabilities that a tool may have or require.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolCapability {
/// Tool only reads data, never modifies state.
ReadOnly,
/// Tool writes to the filesystem.
WritesFiles,
/// Tool executes arbitrary shell commands.
ExecutesCode,
/// Tool makes network requests.
Network,
/// Tool can be run in a sandbox.
Sandboxable,
/// Tool requires user approval before execution.
RequiresApproval,
}
/// Approval requirement for a tool.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApprovalRequirement {
/// Never needs approval: safe read-only operations.
#[default]
Auto,
/// Suggest approval but allow user to skip.
Suggest,
/// Always require explicit user approval.
Required,
}
/// Errors that can occur during tool execution.
#[derive(Debug, Clone)]
pub enum ToolError {
InvalidInput { message: String },
MissingField { field: String },
PathEscape { path: PathBuf },
ExecutionFailed { message: String },
Timeout { seconds: u64 },
NotAvailable { message: String },
PermissionDenied { message: String },
}
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidInput { message } => {
write!(f, "Failed to validate input: {message}")
}
Self::MissingField { field } => {
write!(
f,
"Failed to validate input: missing required field '{field}'"
)
}
Self::PathEscape { path } => {
write!(
f,
"Failed to resolve path '{}': path escapes workspace",
path.display()
)
}
Self::ExecutionFailed { message } => {
write!(f, "Failed to execute tool: {message}")
}
Self::Timeout { seconds } => {
write!(
f,
"Failed to execute tool: operation timed out after {seconds}s"
)
}
Self::NotAvailable { message } => {
write!(f, "Failed to locate tool: {message}")
}
Self::PermissionDenied { message } => {
write!(f, "Failed to authorize tool execution: {message}")
}
}
}
}
impl std::error::Error for ToolError {}
impl ToolError {
#[must_use]
pub fn invalid_input(msg: impl Into<String>) -> Self {
Self::InvalidInput {
message: msg.into(),
}
}
#[must_use]
pub fn missing_field(field: impl Into<String>) -> Self {
Self::MissingField {
field: field.into(),
}
}
#[must_use]
pub fn execution_failed(msg: impl Into<String>) -> Self {
Self::ExecutionFailed {
message: msg.into(),
}
}
#[must_use]
pub fn path_escape(path: impl Into<PathBuf>) -> Self {
Self::PathEscape { path: path.into() }
}
#[must_use]
pub fn not_available(msg: impl Into<String>) -> Self {
Self::NotAvailable {
message: msg.into(),
}
}
#[must_use]
pub fn permission_denied(msg: impl Into<String>) -> Self {
Self::PermissionDenied {
message: msg.into(),
}
}
}
/// Result of a tool execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
/// The output content, which may be JSON or plain text.
pub content: String,
/// Whether the execution was successful.
pub success: bool,
/// Optional structured metadata.
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl ToolResult {
/// Create a successful result with content.
#[must_use]
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
success: true,
metadata: None,
}
}
/// Create an error result with message.
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
content: message.into(),
success: false,
metadata: None,
}
}
/// Create a successful result from JSON.
pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
Ok(Self {
content: serde_json::to_string_pretty(value)?,
success: true,
metadata: None,
})
}
/// Add metadata to the result.
#[must_use]
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
}
/// Helper to extract a required string field from JSON input.
pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> {
input.get(field).and_then(Value::as_str).ok_or_else(|| {
// When the field is missing, list the fields the caller *did*
// supply so the model can spot the mismatch without a retry.
let provided: Vec<&str> = input
.as_object()
.map(|obj| obj.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
if provided.is_empty() {
ToolError::missing_field(field)
} else {
let hint = format!(
"missing required field '{field}'. Input provided: {}",
provided.join(", ")
);
ToolError::invalid_input(hint)
}
})
}
/// Helper to extract an optional string field from JSON input.
#[must_use]
pub fn optional_str<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
input.get(field).and_then(Value::as_str)
}
/// Helper to extract a required u64 field from JSON input.
pub fn required_u64(input: &Value, field: &str) -> std::result::Result<u64, ToolError> {
input
.get(field)
.and_then(Value::as_u64)
.ok_or_else(|| ToolError::missing_field(field))
}
/// Helper to extract an optional u64 field with default.
#[must_use]
pub fn optional_u64(input: &Value, field: &str, default: u64) -> u64 {
input.get(field).and_then(Value::as_u64).unwrap_or(default)
}
/// Helper to extract an optional bool field with default.
#[must_use]
pub fn optional_bool(input: &Value, field: &str, default: bool) -> bool {
input.get(field).and_then(Value::as_bool).unwrap_or(default)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub input_schema: Value,
pub output_schema: Value,
pub supports_parallel_tool_calls: bool,
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfiguredToolSpec {
pub spec: ToolSpec,
pub supports_parallel_tool_calls: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallSource {
Direct,
JsRepl,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub name: String,
pub payload: ToolPayload,
pub source: ToolCallSource,
pub raw_tool_call_id: Option<String>,
}
impl ToolCall {
pub fn execution_subject(&self, fallback_cwd: &str) -> (String, String, &'static str) {
match &self.payload {
ToolPayload::LocalShell { params } => (
params.command.clone(),
params
.cwd
.clone()
.unwrap_or_else(|| fallback_cwd.to_string()),
"shell",
),
_ => (self.name.clone(), fallback_cwd.to_string(), "tool"),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolInvocation {
pub call_id: String,
pub tool_name: String,
pub payload: ToolPayload,
pub source: ToolCallSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FunctionCallError {
ToolNotFound { name: String },
KindMismatch { expected: ToolKind, got: ToolKind },
MutatingToolRejected { name: String },
TimedOut { name: String, timeout_ms: u64 },
Cancelled { name: String },
ExecutionFailed { name: String, error: String },
}
#[async_trait]
pub trait ToolHandler: Send + Sync {
fn kind(&self) -> ToolKind;
fn matches_kind(&self, kind: ToolKind) -> bool {
self.kind() == kind
}
fn is_mutating(&self) -> bool {
false
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> std::result::Result<ToolOutput, FunctionCallError>;
}
#[derive(Debug, Default)]
pub struct ToolCallRuntime {
pub parallel_execution: Arc<RwLock<()>>,
}
#[derive(Default)]
pub struct ToolRegistry {
handlers: HashMap<String, Arc<dyn ToolHandler>>,
specs: HashMap<String, ConfiguredToolSpec>,
runtime: ToolCallRuntime,
}
impl ToolRegistry {
pub fn register(&mut self, spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Result<()> {
let name = spec.name.clone();
self.specs.insert(
name.clone(),
ConfiguredToolSpec {
supports_parallel_tool_calls: spec.supports_parallel_tool_calls,
spec,
},
);
self.handlers.insert(name, handler);
Ok(())
}
pub fn list_specs(&self) -> Vec<ConfiguredToolSpec> {
self.specs.values().cloned().collect()
}
pub async fn dispatch(
&self,
call: ToolCall,
allow_mutating: bool,
) -> std::result::Result<ToolOutput, FunctionCallError> {
let handler = self.handlers.get(&call.name).cloned().ok_or_else(|| {
FunctionCallError::ToolNotFound {
name: call.name.clone(),
}
})?;
let configured =
self.specs
.get(&call.name)
.cloned()
.ok_or_else(|| FunctionCallError::ToolNotFound {
name: call.name.clone(),
})?;
let payload_kind = tool_payload_kind(&call.payload);
let expected = handler.kind();
if !handler.matches_kind(payload_kind) {
return Err(FunctionCallError::KindMismatch {
expected,
got: payload_kind,
});
}
if handler.is_mutating() && !allow_mutating {
return Err(FunctionCallError::MutatingToolRejected { name: call.name });
}
let invocation = ToolInvocation {
call_id: call
.raw_tool_call_id
.clone()
.unwrap_or_else(|| format!("tool-call-{}", uuid::Uuid::new_v4())),
tool_name: call.name.clone(),
payload: call.payload,
source: call.source,
};
if configured.supports_parallel_tool_calls {
let _guard = self.runtime.parallel_execution.read().await;
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
.await
} else {
let _guard = self.runtime.parallel_execution.write().await;
self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation)
.await
}
}
async fn execute_with_timeout(
&self,
handler: Arc<dyn ToolHandler>,
timeout_ms: Option<u64>,
invocation: ToolInvocation,
) -> std::result::Result<ToolOutput, FunctionCallError> {
if let Some(timeout_ms) = timeout_ms {
let name = invocation.tool_name.clone();
match tokio::time::timeout(
Duration::from_millis(timeout_ms),
handler.handle(invocation),
)
.await
{
Ok(result) => result,
Err(_) => Err(FunctionCallError::TimedOut { name, timeout_ms }),
}
} else {
handler.handle(invocation).await
}
}
}
fn tool_payload_kind(payload: &ToolPayload) -> ToolKind {
match payload {
ToolPayload::Mcp { .. } => ToolKind::Mcp,
ToolPayload::Function { .. }
| ToolPayload::Custom { .. }
| ToolPayload::LocalShell { .. } => ToolKind::Function,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn tool_result_json_round_trips_content() {
let result = ToolResult::json(&json!({"ok": true})).expect("json");
assert!(result.success);
assert!(result.content.contains("\"ok\": true"));
}
#[test]
fn helper_extractors_validate_shape() {
let input = json!({"name": "demo", "count": 7, "enabled": true});
assert_eq!(required_str(&input, "name").expect("name"), "demo");
assert_eq!(optional_u64(&input, "count", 0), 7);
assert!(optional_bool(&input, "enabled", false));
assert!(matches!(
required_u64(&input, "name"),
Err(ToolError::MissingField { .. })
));
}
#[test]
fn required_str_reports_provided_fields_on_missing_required_field() {
let input = json!({"path": "src/lib.rs", "content": "new body"});
let err = required_str(&input, "replace").expect_err("replace is missing");
let message = err.to_string();
assert!(message.contains("missing required field 'replace'"));
assert!(message.contains("Input provided:"));
assert!(message.contains("path"));
assert!(message.contains("content"));
}
#[test]
fn tool_error_display_matches_legacy_text() {
let err = ToolError::missing_field("path");
assert_eq!(
err.to_string(),
"Failed to validate input: missing required field 'path'"
);
}
}
+70
View File
@@ -0,0 +1,70 @@
use std::sync::Arc;
use async_trait::async_trait;
use deepseek_protocol::{ToolKind, ToolOutput, ToolPayload};
use deepseek_tools::{
ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec,
};
use serde_json::json;
struct EchoHandler;
#[async_trait]
impl ToolHandler for EchoHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn is_mutating(&self) -> bool {
false
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> std::result::Result<ToolOutput, deepseek_tools::FunctionCallError> {
Ok(ToolOutput::Function {
body: Some(json!({
"tool": invocation.tool_name,
"call_id": invocation.call_id
})),
success: true,
})
}
}
#[tokio::test]
async fn dispatches_function_tool_with_parallel_flag() {
let mut registry = ToolRegistry::default();
registry
.register(
ToolSpec {
name: "echo".to_string(),
input_schema: json!({"type":"object"}),
output_schema: json!({"type":"object"}),
supports_parallel_tool_calls: true,
timeout_ms: Some(1000),
},
Arc::new(EchoHandler),
)
.expect("register tool");
let output = registry
.dispatch(
ToolCall {
name: "echo".to_string(),
payload: ToolPayload::Function {
arguments: "{\"message\":\"hi\"}".to_string(),
},
source: ToolCallSource::Direct,
raw_tool_call_id: Some("call-1".to_string()),
},
true,
)
.await
.expect("dispatch tool");
match output {
ToolOutput::Function { success, .. } => assert!(success),
other => panic!("unexpected output: {other:?}"),
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "deepseek-tui-core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Event-driven TUI state machine scaffold for DeepSeek workspace architecture"
+192
View File
@@ -0,0 +1,192 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Chat,
Diff,
Tasks,
Agents,
Status,
Jobs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiEvent {
KeyPressed(char),
PromptSubmitted(String),
ResponseDelta(String),
ToolStarted(String),
ToolFinished(String),
JobQueued(String),
JobProgress { job_id: String, progress: u8 },
JobCompleted(String),
ApprovalRequested(String),
ApprovalResolved(String),
PauseRequested,
ResumeRequested,
Tick,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiEffect {
Render,
PersistCheckpoint,
ScheduleBackgroundRefresh,
EmitStatusLine(String),
}
#[derive(Debug, Clone)]
pub struct UiState {
pub active_pane: Pane,
pub paused: bool,
pub last_response_delta: Option<String>,
pub active_tool: Option<String>,
pub pending_tasks: usize,
pub active_jobs: usize,
pub pending_approvals: usize,
pub status_line: String,
}
impl Default for UiState {
fn default() -> Self {
Self {
active_pane: Pane::Chat,
paused: false,
last_response_delta: None,
active_tool: None,
pending_tasks: 0,
active_jobs: 0,
pending_approvals: 0,
status_line: "ready".to_string(),
}
}
}
impl UiState {
pub fn reduce(&mut self, event: UiEvent) -> Vec<UiEffect> {
match event {
UiEvent::KeyPressed('1') => {
self.active_pane = Pane::Chat;
vec![UiEffect::Render]
}
UiEvent::KeyPressed('2') => {
self.active_pane = Pane::Diff;
vec![UiEffect::Render]
}
UiEvent::KeyPressed('3') => {
self.active_pane = Pane::Tasks;
vec![UiEffect::Render]
}
UiEvent::KeyPressed('4') => {
self.active_pane = Pane::Agents;
vec![UiEffect::Render]
}
UiEvent::KeyPressed('5') => {
self.active_pane = Pane::Jobs;
vec![UiEffect::Render]
}
UiEvent::PromptSubmitted(_) => {
self.pending_tasks = self.pending_tasks.saturating_add(1);
self.status_line = "prompt submitted".to_string();
vec![
UiEffect::Render,
UiEffect::PersistCheckpoint,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ResponseDelta(delta) => {
self.last_response_delta = Some(delta);
self.status_line = "streaming response".to_string();
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ToolStarted(name) => {
self.active_tool = Some(name.clone());
self.status_line = format!("tool running: {name}");
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ToolFinished(name) => {
self.active_tool = None;
self.pending_tasks = self.pending_tasks.saturating_sub(1);
self.status_line = format!("tool finished: {name}");
vec![
UiEffect::Render,
UiEffect::PersistCheckpoint,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::JobQueued(_) => {
self.active_jobs = self.active_jobs.saturating_add(1);
self.status_line = "job queued".to_string();
vec![UiEffect::Render, UiEffect::PersistCheckpoint]
}
UiEvent::JobProgress { progress, .. } => {
self.status_line = format!("job progress: {}%", progress.min(100));
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::JobCompleted(_) => {
self.active_jobs = self.active_jobs.saturating_sub(1);
self.status_line = "job completed".to_string();
vec![
UiEffect::Render,
UiEffect::PersistCheckpoint,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ApprovalRequested(_) => {
self.pending_approvals = self.pending_approvals.saturating_add(1);
self.status_line = "approval requested".to_string();
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ApprovalResolved(_) => {
self.pending_approvals = self.pending_approvals.saturating_sub(1);
self.status_line = "approval resolved".to_string();
vec![
UiEffect::Render,
UiEffect::PersistCheckpoint,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::PauseRequested => {
self.paused = true;
self.status_line = "paused".to_string();
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::ResumeRequested => {
self.paused = false;
self.status_line = "resumed".to_string();
vec![
UiEffect::Render,
UiEffect::EmitStatusLine(self.status_line.clone()),
]
}
UiEvent::Tick => vec![UiEffect::ScheduleBackgroundRefresh],
UiEvent::KeyPressed(_) => Vec::new(),
}
}
pub fn snapshot(&self) -> String {
format!(
"pane={:?};paused={};pending_tasks={};active_jobs={};pending_approvals={};active_tool={};status={}",
self.active_pane,
self.paused,
self.pending_tasks,
self.active_jobs,
self.pending_approvals,
self.active_tool.clone().unwrap_or_default(),
self.status_line
)
}
}
+25
View File
@@ -0,0 +1,25 @@
use deepseek_tui_core::{Pane, UiEvent, UiState};
#[test]
fn reducer_produces_stable_snapshot_for_core_workflow() {
let mut state = UiState::default();
state.reduce(UiEvent::PromptSubmitted("hello".to_string()));
state.reduce(UiEvent::ToolStarted("web.search".to_string()));
state.reduce(UiEvent::ResponseDelta("partial".to_string()));
state.reduce(UiEvent::ToolFinished("web.search".to_string()));
state.reduce(UiEvent::ApprovalRequested("approval-1".to_string()));
state.reduce(UiEvent::ApprovalResolved("approval-1".to_string()));
state.reduce(UiEvent::JobQueued("job-1".to_string()));
state.reduce(UiEvent::JobProgress {
job_id: "job-1".to_string(),
progress: 60,
});
state.reduce(UiEvent::JobCompleted("job-1".to_string()));
state.reduce(UiEvent::KeyPressed('5'));
assert_eq!(state.active_pane, Pane::Jobs);
assert_eq!(
state.snapshot(),
"pane=Jobs;paused=false;pending_tasks=0;active_jobs=0;pending_approvals=0;active_tool=;status=job completed"
);
}
+76
View File
@@ -0,0 +1,76 @@
[package]
name = "deepseek-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Terminal UI for DeepSeek"
[[bin]]
name = "deepseek-tui"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.100"
arboard = "3.4"
deepseek-secrets = { path = "../secrets", version = "0.7.5" }
deepseek-tools = { path = "../tools", version = "0.7.5" }
async-stream = "0.3.6"
async-trait = "0.1"
bytes = "1.11.0"
base64 = "0.22.1"
axum = { version = "0.8.4", features = ["json"] }
clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
colored = "3.0.0"
crossterm = "0.28"
csv = "1.4"
dotenvy = "0.15.7"
dirs = "6.0.0"
futures-util = "0.3.31"
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"] }
rustyline = "15.0.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
shellexpand = "3"
toml = "0.9.7"
tokio = { version = "1.49.0", features = ["full"] }
tokio-util = { version = "0.7.16", features = ["io"] }
unicode-width = "0.2"
unicode-segmentation = "1.12"
uuid = { version = "1.11", features = ["v4"] }
tokio-stream = "0.1"
chrono = { version = "0.4", features = ["serde"] }
tempfile = "3.16"
thiserror = "2.0"
tracing = "0.1"
tower-http = { version = "0.6", features = ["cors"] }
wait-timeout = "0.2"
multimap = "0.10.0"
shlex = "1.3.0"
starlark = "0.13.0"
tiny_http = "0.12"
portable-pty = "0.8"
zeroize = "1.8.2"
ignore = "0.4"
image = { version = "0.25", default-features = false, features = ["png"] }
pdf-extract = "0.7"
tar = "0.4"
flate2 = "1.1"
sha2 = "0.10"
[dev-dependencies]
wiremock = "0.6"
pretty_assertions = "1.4"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.60", features = ["Win32_Foundation"] }
@@ -0,0 +1,420 @@
<!--
This SKILL.md is ported from OpenAI's codex repo (MIT-licensed).
Source: https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/skill-creator/SKILL.md
-->
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends deepseek's capabilities with specialized knowledge, workflows, or tool integrations.
metadata:
short-description: Create or update a skill
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained folders that extend deepseek's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform deepseek from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share the context window with everything else deepseek needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
**Default assumption: deepseek is already very smart.** Only add context deepseek doesn't already have. Challenge each piece of information: "Does deepseek really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of deepseek as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
### Protect Validation Integrity
You may use subagents during iteration to validate whether a skill works on realistic tasks or whether a suspected problem is real. This is most useful when you want an independent pass on the skill's behavior, outputs, or failure modes after a revision. Only do this when it is possible to start new subagents.
When using subagents for validation, treat that as an evaluation surface. The goal is to learn whether the skill generalizes, not whether another agent can reconstruct the answer from leaked context.
Prefer raw artifacts such as example prompts, outputs, diffs, logs, or traces. Give the minimum task-local context needed to perform the validation. Avoid passing the intended answer, suspected bug, intended fix, or your prior conclusions unless the validation explicitly requires them.
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
├── agents/ (recommended)
│ └── openai.yaml - UI metadata for skill lists and chips
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
Every SKILL.md consists of:
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that deepseek reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
#### Agents metadata (recommended)
- UI-facing metadata for skill lists and chips
- Read references/openai_yaml.md before generating values and follow its descriptions and constraints
- Create: human-facing `display_name`, `short_description`, and `default_prompt` by reading the skill
- Generate deterministically by passing the values as `--interface key=value` to `scripts/generate_openai_yaml.py` or `scripts/init_skill.py`
- On updates: validate `agents/openai.yaml` still matches SKILL.md; regenerate if stale
- Only include other optional interface fields (icons, brand color) if explicitly provided
- See references/openai_yaml.md for field definitions and examples
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by deepseek for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform deepseek's process and thinking.
- **When to include**: For documentation that deepseek should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when deepseek determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output deepseek produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables deepseek to use files without loading them into context
#### What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by deepseek (Unlimited because scripts can be executed without reading into context window)
#### Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
**Pattern 1: High-level guide with references**
```markdown
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
```
deepseek loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
**Pattern 2: Domain-specific organization**
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
```
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
```
When a user asks about sales metrics, deepseek only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
```
When the user chooses AWS, deepseek only reads aws.md.
**Pattern 3: Conditional details**
Show basic content, link to advanced content:
```markdown
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
```
deepseek reads REDLINING.md or OOXML.md only when the user needs those features.
**Important guidelines:**
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so deepseek can see the full scope when previewing.
## Skill Creation Process
Skill creation involves these steps:
1. Understand the skill with concrete examples
2. Plan reusable skill contents (scripts, references, assets)
3. Initialize the skill (run init_skill.py)
4. Edit the skill (implement resources and write SKILL.md)
5. Validate the skill (run quick_validate.py)
6. Iterate based on real usage and forward-test complex skills.
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
### Skill Naming
- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`).
- When generating names, generate a name under 64 characters (letters, digits, hyphens).
- Prefer short, verb-led phrases that describe the action.
- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).
- Name the skill folder exactly after the skill name.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
- "Where should I create this skill? If you do not have a preference, I will place it in `$DEEPSEEK_HOME/skills` (or `~/.deepseek/skills` when `DEEPSEEK_HOME` is unset) so deepseek can discover it automatically."
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists. In this case, continue to the next step.
Before running `init_skill.py`, ask where the user wants the skill created. If they do not specify a location, default to `$DEEPSEEK_HOME/skills`; when `DEEPSEEK_HOME` is unset, fall back to `~/.deepseek/skills` so the skill is auto-discovered.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory> [--resources scripts,references,assets] [--examples]
```
Examples:
```bash
scripts/init_skill.py my-skill --path "${DEEPSEEK_HOME:-$HOME/.deepseek}/skills"
scripts/init_skill.py my-skill --path "${DEEPSEEK_HOME:-$HOME/.deepseek}/skills" --resources scripts,references
scripts/init_skill.py my-skill --path ~/work/skills --resources scripts --examples
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates `agents/openai.yaml` using agent-generated `display_name`, `short_description`, and `default_prompt` passed via `--interface key=value`
- Optionally creates resource directories based on `--resources`
- Optionally adds example files when `--examples` is set
After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files.
Generate `display_name`, `short_description`, and `default_prompt` by reading the skill, then pass them as `--interface key=value` to `init_skill.py` or regenerate with:
```bash
scripts/generate_openai_yaml.py <path/to/skill-folder> --interface key=value
```
Only include other optional interface fields when the user explicitly provides them. For full field descriptions and examples, see references/openai_yaml.md.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of deepseek to use. Include information that would be beneficial and non-obvious to deepseek. Consider what procedural knowledge, domain-specific details, or reusable assets would help another deepseek instance execute these tasks more effectively.
After substantial revisions, or if the skill is particularly tricky, you should use subagents to forward-test the skill on realistic tasks or artifacts. When doing so, pass the artifact under validation rather than your diagnosis of what is wrong, and keep the prompt generic enough that success depends on transferable reasoning rather than hidden ground truth.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required.
#### Update SKILL.md
**Writing Guidelines:** Always use imperative/infinitive form.
##### Frontmatter
Write the YAML frontmatter with `name` and `description`:
- `name`: The skill name
- `description`: This is the primary triggering mechanism for your skill, and helps deepseek understand when to use the skill.
- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to deepseek.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when deepseek needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
##### Body
Write instructions for using the skill and its bundled resources.
### Step 5: Validate the Skill
Once development of the skill is complete, validate the skill folder to catch basic issues early:
```bash
scripts/quick_validate.py <path/to/skill-folder>
```
The validation script checks YAML frontmatter format, required fields, and naming rules. If validation fails, fix the reported issues and run the command again.
### Step 6: Iterate
After testing the skill, you may detect the skill is complex enough that it requires forward-testing; or users may request improvements.
User testing often this happens right after using the skill, with fresh context of how the skill performed.
**Forward-testing and iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again
5. Forward-test if it is reasonable and appropriate
## Forward-testing
To forward-test, launch subagents as a way to stress test the skill with minimal context.
Subagents should *not* know that they are being asked to test the skill. They should be treated as
an agent asked to perform a task by the user. Prompts to subagents should look like:
`Use $skill-x at /path/to/skill-x to solve problem y`
Not:
`Review the skill at /path/to/skill-x; pretend a user asks you to...`
Decision rule for forward-testing:
- Err on the side of forward-testing
- Ask for approval if you think there's a risk that forward-testing would:
* take a long time,
* require additional approvals from the user, or
* modify live production systems
In these cases, show the user your proposed prompt and request (1) a yes/no decision, and
(2) any suggested modifictions.
Considerations when forward-testing:
- use fresh threads for independent passes
- pass the skill, and a request in a similar way the user would.
- pass raw artifacts, not your conclusions
- avoid showing expected answers or intended fixes
- rebuild context from source artifacts after each iteration
- review the subagent's output and reasoning and emitted artifacts
- avoid leaving artifacts the agent can find on disk between iterations;
clean up subagents' artifacts to avoid additional contamination.
If forward-testing only succeeds when subagents see leaked context, tighten the skill or the
forward-testing setup before trusting the result.
+38
View File
@@ -0,0 +1,38 @@
//! Lightweight audit logging for sensitive operations.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use chrono::Utc;
use serde_json::{Value, json};
/// Append an audit event to `~/.deepseek/audit.log`.
///
/// This helper is best-effort by design: callers should not fail critical flows
/// if audit persistence fails.
pub fn log_sensitive_event(event: &str, details: Value) {
if let Err(err) = append_event(event, details) {
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
let path = default_audit_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let record = json!({
"ts": Utc::now().to_rfc3339(),
"event": event,
"details": details,
});
writeln!(file, "{}", serde_json::to_string(&record)?)?;
Ok(())
}
fn default_audit_path() -> anyhow::Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(home.join(".deepseek").join("audit.log"))
}
+932
View File
@@ -0,0 +1,932 @@
//! Durable automation records and scheduler support.
//!
//! Automations are local-first recurring jobs that enqueue standard background
//! tasks. This module stores automation definitions and run history under
//! `~/.deepseek/automations` (or `DEEPSEEK_AUTOMATIONS_DIR` override).
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike, Utc, Weekday};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::task_manager::{NewTaskRequest, SharedTaskManager, TaskStatus};
const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 1;
const CURRENT_RUN_SCHEMA_VERSION: u32 = 1;
const fn default_automation_schema_version() -> u32 {
CURRENT_AUTOMATION_SCHEMA_VERSION
}
const fn default_run_schema_version() -> u32 {
CURRENT_RUN_SCHEMA_VERSION
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationStatus {
Active,
Paused,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationRunStatus {
Queued,
Running,
Completed,
Failed,
Canceled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRecord {
#[serde(default = "default_automation_schema_version")]
pub schema_version: u32,
pub id: String,
pub name: String,
pub prompt: String,
pub rrule: String,
#[serde(default)]
pub cwds: Vec<PathBuf>,
pub status: AutomationStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_run_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_run_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRunRecord {
#[serde(default = "default_run_schema_version")]
pub schema_version: u32,
pub id: String,
pub automation_id: String,
pub scheduled_for: DateTime<Utc>,
pub status: AutomationRunStatus,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAutomationRequest {
pub name: String,
pub prompt: String,
pub rrule: String,
#[serde(default)]
pub cwds: Vec<PathBuf>,
#[serde(default)]
pub status: Option<AutomationStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateAutomationRequest {
pub name: Option<String>,
pub prompt: Option<String>,
pub rrule: Option<String>,
pub cwds: Option<Vec<PathBuf>>,
pub status: Option<AutomationStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutomationFrequency {
Hourly,
Weekly,
}
#[derive(Debug, Clone)]
pub enum AutomationSchedule {
Hourly {
interval_hours: u32,
byday: Option<Vec<Weekday>>,
},
Weekly {
byday: Vec<Weekday>,
byhour: u32,
byminute: u32,
},
}
impl AutomationSchedule {
pub fn parse_rrule(rrule: &str) -> Result<Self> {
let mut parts: BTreeMap<String, String> = BTreeMap::new();
for raw in rrule.split(';') {
let item = raw.trim();
if item.is_empty() {
continue;
}
let Some((k, v)) = item.split_once('=') else {
bail!("Invalid RRULE segment '{item}'");
};
parts.insert(k.trim().to_ascii_uppercase(), v.trim().to_ascii_uppercase());
}
let freq = match parts.get("FREQ").map(String::as_str) {
Some("HOURLY") => AutomationFrequency::Hourly,
Some("WEEKLY") => AutomationFrequency::Weekly,
Some(other) => bail!("Unsupported RRULE FREQ '{other}'. Supported: HOURLY and WEEKLY"),
None => bail!("RRULE must include FREQ"),
};
match freq {
AutomationFrequency::Hourly => {
for key in parts.keys() {
if key != "FREQ" && key != "INTERVAL" && key != "BYDAY" {
bail!(
"Unsupported RRULE field '{key}' for HOURLY. Allowed: FREQ,INTERVAL,BYDAY"
);
}
}
let interval_hours = parts
.get("INTERVAL")
.map(|v| v.parse::<u32>())
.transpose()
.context("Failed to parse INTERVAL")?
.unwrap_or(1);
if interval_hours == 0 {
bail!("INTERVAL must be >= 1 for HOURLY schedules");
}
let byday = parts
.get("BYDAY")
.map(|value| parse_byday(value))
.transpose()?;
Ok(Self::Hourly {
interval_hours,
byday,
})
}
AutomationFrequency::Weekly => {
for key in parts.keys() {
if key != "FREQ" && key != "BYDAY" && key != "BYHOUR" && key != "BYMINUTE" {
bail!(
"Unsupported RRULE field '{key}' for WEEKLY. Allowed: FREQ,BYDAY,BYHOUR,BYMINUTE"
);
}
}
let byday_raw = parts
.get("BYDAY")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYDAY"))?;
let byday = parse_byday(byday_raw)?;
if byday.is_empty() {
bail!("BYDAY cannot be empty for WEEKLY schedules");
}
let byhour = parts
.get("BYHOUR")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYHOUR"))?
.parse::<u32>()
.context("Failed to parse BYHOUR")?;
let byminute = parts
.get("BYMINUTE")
.ok_or_else(|| anyhow::anyhow!("WEEKLY schedules require BYMINUTE"))?
.parse::<u32>()
.context("Failed to parse BYMINUTE")?;
if byhour > 23 {
bail!("BYHOUR must be between 0 and 23");
}
if byminute > 59 {
bail!("BYMINUTE must be between 0 and 59");
}
Ok(Self::Weekly {
byday,
byhour,
byminute,
})
}
}
}
pub fn next_after(&self, after: DateTime<Utc>) -> Result<DateTime<Utc>> {
let local_after = after.with_timezone(&Local);
match self {
Self::Hourly {
interval_hours,
byday,
} => {
let mut candidate = local_after + Duration::hours(i64::from(*interval_hours))
- Duration::seconds(i64::from(local_after.second()))
- Duration::nanoseconds(i64::from(local_after.nanosecond()));
if let Some(days) = byday {
for _ in 0..(24 * 21) {
if days.contains(&candidate.weekday()) {
return Ok(candidate.with_timezone(&Utc));
}
candidate += Duration::hours(i64::from(*interval_hours));
}
bail!("Unable to compute next HOURLY run for BYDAY filter");
}
Ok(candidate.with_timezone(&Utc))
}
Self::Weekly {
byday,
byhour,
byminute,
} => {
for day_offset in 0..15 {
let date = local_after.date_naive() + Duration::days(i64::from(day_offset));
if !byday.contains(&date.weekday()) {
continue;
}
let Some(candidate_naive) = date.and_hms_opt(*byhour, *byminute, 0) else {
continue;
};
if let Some(candidate) = resolve_local_datetime(candidate_naive)
&& candidate > local_after
{
return Ok(candidate.with_timezone(&Utc));
}
}
bail!("Unable to compute next WEEKLY run");
}
}
}
}
fn resolve_local_datetime(naive: chrono::NaiveDateTime) -> Option<DateTime<Local>> {
Local
.from_local_datetime(&naive)
.single()
.or_else(|| Local.from_local_datetime(&naive).earliest())
.or_else(|| Local.from_local_datetime(&naive).latest())
}
fn parse_byday(value: &str) -> Result<Vec<Weekday>> {
let mut days = Vec::new();
for token in value.split(',') {
let day = match token.trim().to_ascii_uppercase().as_str() {
"MO" => Weekday::Mon,
"TU" => Weekday::Tue,
"WE" => Weekday::Wed,
"TH" => Weekday::Thu,
"FR" => Weekday::Fri,
"SA" => Weekday::Sat,
"SU" => Weekday::Sun,
other => bail!("Invalid BYDAY value '{other}'"),
};
if !days.contains(&day) {
days.push(day);
}
}
Ok(days)
}
#[derive(Debug, Clone)]
pub struct AutomationManager {
automations_dir: PathBuf,
runs_dir: PathBuf,
}
impl AutomationManager {
pub fn open(root: PathBuf) -> Result<Self> {
let automations_dir = root.join("automations");
let runs_dir = root.join("runs");
fs::create_dir_all(&automations_dir)
.with_context(|| format!("Failed to create {}", automations_dir.display()))?;
fs::create_dir_all(&runs_dir)
.with_context(|| format!("Failed to create {}", runs_dir.display()))?;
Ok(Self {
automations_dir,
runs_dir,
})
}
pub fn default_location() -> Result<Self> {
Self::open(default_automations_dir())
}
fn automation_path(&self, id: &str) -> PathBuf {
self.automations_dir.join(format!("{id}.json"))
}
fn runs_dir_for(&self, automation_id: &str) -> PathBuf {
self.runs_dir.join(automation_id)
}
fn run_path(&self, automation_id: &str, run_id: &str) -> PathBuf {
self.runs_dir_for(automation_id)
.join(format!("{run_id}.json"))
}
pub fn create_automation(&self, req: CreateAutomationRequest) -> Result<AutomationRecord> {
validate_name_and_prompt(&req.name, &req.prompt)?;
let schedule = AutomationSchedule::parse_rrule(&req.rrule)?;
let now = Utc::now();
let status = req.status.unwrap_or(AutomationStatus::Active);
let next_run_at = if matches!(status, AutomationStatus::Active) {
Some(schedule.next_after(now)?)
} else {
None
};
let record = AutomationRecord {
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
name: req.name.trim().to_string(),
prompt: req.prompt.trim().to_string(),
rrule: req.rrule.trim().to_ascii_uppercase(),
cwds: req.cwds,
status,
created_at: now,
updated_at: now,
next_run_at,
last_run_at: None,
};
self.save_automation(&record)?;
Ok(record)
}
pub fn get_automation(&self, id: &str) -> Result<AutomationRecord> {
let path = self.automation_path(id);
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read automation {}", path.display()))?;
let record: AutomationRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
bail!(
"Automation schema v{} is newer than supported v{}",
record.schema_version,
CURRENT_AUTOMATION_SCHEMA_VERSION
);
}
Ok(record)
}
pub fn save_automation(&self, record: &AutomationRecord) -> Result<()> {
write_json_atomic(&self.automation_path(&record.id), record)
}
pub fn list_automations(&self) -> Result<Vec<AutomationRecord>> {
let mut out = Vec::new();
for entry in fs::read_dir(&self.automations_dir)
.with_context(|| format!("Failed to read {}", self.automations_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let record: AutomationRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
bail!(
"Automation schema v{} is newer than supported v{}",
record.schema_version,
CURRENT_AUTOMATION_SCHEMA_VERSION
);
}
out.push(record);
}
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
Ok(out)
}
pub fn update_automation(
&self,
id: &str,
req: UpdateAutomationRequest,
) -> Result<AutomationRecord> {
let mut existing = self.get_automation(id)?;
if let Some(name) = req.name {
if name.trim().is_empty() {
bail!("Automation name cannot be empty");
}
existing.name = name.trim().to_string();
}
if let Some(prompt) = req.prompt {
if prompt.trim().is_empty() {
bail!("Automation prompt cannot be empty");
}
existing.prompt = prompt.trim().to_string();
}
if let Some(rrule) = req.rrule {
let normalized = rrule.trim().to_ascii_uppercase();
AutomationSchedule::parse_rrule(&normalized)?;
existing.rrule = normalized;
if matches!(existing.status, AutomationStatus::Active) {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
}
}
if let Some(cwds) = req.cwds {
existing.cwds = cwds;
}
if let Some(status) = req.status {
existing.status = status;
if matches!(status, AutomationStatus::Paused) {
existing.next_run_at = None;
} else {
let schedule = AutomationSchedule::parse_rrule(&existing.rrule)?;
existing.next_run_at = Some(schedule.next_after(Utc::now())?);
}
}
existing.updated_at = Utc::now();
self.save_automation(&existing)?;
Ok(existing)
}
pub fn pause_automation(&self, id: &str) -> Result<AutomationRecord> {
self.update_automation(
id,
UpdateAutomationRequest {
status: Some(AutomationStatus::Paused),
..UpdateAutomationRequest::default()
},
)
}
pub fn resume_automation(&self, id: &str) -> Result<AutomationRecord> {
self.update_automation(
id,
UpdateAutomationRequest {
status: Some(AutomationStatus::Active),
..UpdateAutomationRequest::default()
},
)
}
pub fn delete_automation(&self, id: &str) -> Result<AutomationRecord> {
let existing = self.get_automation(id)?;
let path = self.automation_path(id);
fs::remove_file(&path)
.with_context(|| format!("Failed to delete automation {}", path.display()))?;
let runs_dir = self.runs_dir_for(id);
if runs_dir.exists() {
fs::remove_dir_all(&runs_dir).with_context(|| {
format!("Failed to delete automation runs {}", runs_dir.display())
})?;
}
Ok(existing)
}
pub fn list_runs(
&self,
automation_id: &str,
limit: Option<usize>,
) -> Result<Vec<AutomationRunRecord>> {
let dir = self.runs_dir_for(automation_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in
fs::read_dir(&dir).with_context(|| format!("Failed to read {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let run: AutomationRunRecord = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
bail!(
"Automation run schema v{} is newer than supported v{}",
run.schema_version,
CURRENT_RUN_SCHEMA_VERSION
);
}
out.push(run);
}
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
if let Some(limit) = limit {
out.truncate(limit);
}
Ok(out)
}
fn save_run(&self, run: &AutomationRunRecord) -> Result<()> {
let dir = self.runs_dir_for(&run.automation_id);
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
write_json_atomic(&self.run_path(&run.automation_id, &run.id), run)
}
async fn enqueue_run_task(
&self,
automation: &AutomationRecord,
run: &mut AutomationRunRecord,
task_manager: &SharedTaskManager,
) -> Result<()> {
let workspace = automation.cwds.first().cloned();
let new_task = NewTaskRequest {
prompt: automation.prompt.clone(),
model: None,
workspace,
mode: Some("agent".to_string()),
allow_shell: Some(false),
trust_mode: Some(false),
auto_approve: Some(true),
};
match task_manager.add_task(new_task).await {
Ok(task) => {
run.status = AutomationRunStatus::Running;
run.started_at = Some(Utc::now());
run.task_id = Some(task.id.clone());
run.thread_id = task.thread_id.clone();
run.turn_id = task.turn_id.clone();
run.error = None;
Ok(())
}
Err(err) => {
run.status = AutomationRunStatus::Failed;
run.ended_at = Some(Utc::now());
run.error = Some(format!("Failed to enqueue task: {err}"));
Ok(())
}
}
}
pub async fn run_now(
&self,
automation_id: &str,
task_manager: &SharedTaskManager,
) -> Result<AutomationRunRecord> {
let mut automation = self.get_automation(automation_id)?;
let now = Utc::now();
let mut run = AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: automation.id.clone(),
scheduled_for: now,
status: AutomationRunStatus::Queued,
created_at: now,
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
};
self.enqueue_run_task(&automation, &mut run, task_manager)
.await?;
self.save_run(&run)?;
automation.updated_at = Utc::now();
if matches!(
run.status,
AutomationRunStatus::Completed
| AutomationRunStatus::Failed
| AutomationRunStatus::Canceled
) {
automation.last_run_at = run.ended_at.or(Some(Utc::now()));
}
self.save_automation(&automation)?;
Ok(run)
}
pub async fn scheduler_tick(&self, task_manager: &SharedTaskManager) -> Result<()> {
let now = Utc::now();
let mut automations = self.list_automations()?;
for automation in &mut automations {
if !matches!(automation.status, AutomationStatus::Active) {
continue;
}
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
if automation.next_run_at.is_none() {
automation.next_run_at = Some(schedule.next_after(now)?);
automation.updated_at = now;
self.save_automation(automation)?;
continue;
}
let due_at = automation.next_run_at.expect("checked above");
if due_at > now {
continue;
}
// Idempotency: if a run already exists for this schedule slot, skip enqueue and
// advance next_run_at.
let existing_for_slot = self
.list_runs(&automation.id, Some(25))?
.into_iter()
.any(|run| run.scheduled_for == due_at);
if existing_for_slot {
automation.next_run_at = Some(schedule.next_after(due_at)?);
automation.updated_at = now;
self.save_automation(automation)?;
continue;
}
let mut run = AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: automation.id.clone(),
scheduled_for: due_at,
status: AutomationRunStatus::Queued,
created_at: now,
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
};
self.enqueue_run_task(automation, &mut run, task_manager)
.await?;
self.save_run(&run)?;
automation.updated_at = now;
automation.next_run_at = Some(schedule.next_after(due_at)?);
self.save_automation(automation)?;
}
Ok(())
}
pub async fn reconcile_run_statuses(&self, task_manager: &SharedTaskManager) -> Result<()> {
let automations = self.list_automations()?;
for automation in automations {
let runs = self.list_runs(&automation.id, Some(100))?;
for mut run in runs {
if !matches!(
run.status,
AutomationRunStatus::Queued | AutomationRunStatus::Running
) {
continue;
}
let Some(task_id) = run.task_id.clone() else {
continue;
};
let task = match task_manager.get_task(&task_id).await {
Ok(task) => task,
Err(_) => continue,
};
run.thread_id = task.thread_id.clone();
run.turn_id = task.turn_id.clone();
let mut changed = false;
match task.status {
TaskStatus::Queued => {
if !matches!(run.status, AutomationRunStatus::Queued) {
run.status = AutomationRunStatus::Queued;
changed = true;
}
}
TaskStatus::Running => {
if !matches!(run.status, AutomationRunStatus::Running) {
run.status = AutomationRunStatus::Running;
changed = true;
}
if run.started_at.is_none() {
run.started_at = Some(task.started_at.unwrap_or_else(Utc::now));
changed = true;
}
}
TaskStatus::Completed => {
run.status = AutomationRunStatus::Completed;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
run.error = None;
changed = true;
}
TaskStatus::Failed => {
run.status = AutomationRunStatus::Failed;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
run.error = task.error.clone();
changed = true;
}
TaskStatus::Canceled => {
run.status = AutomationRunStatus::Canceled;
run.started_at = run.started_at.or(task.started_at);
run.ended_at = task.ended_at.or(Some(Utc::now()));
changed = true;
}
}
if changed {
self.save_run(&run)?;
if matches!(
run.status,
AutomationRunStatus::Completed
| AutomationRunStatus::Failed
| AutomationRunStatus::Canceled
) {
let mut updated_automation = self.get_automation(&automation.id)?;
updated_automation.last_run_at = run.ended_at.or(Some(Utc::now()));
updated_automation.updated_at = Utc::now();
self.save_automation(&updated_automation)?;
}
}
}
}
Ok(())
}
}
fn validate_name_and_prompt(name: &str, prompt: &str) -> Result<()> {
if name.trim().is_empty() {
bail!("Automation name is required");
}
if prompt.trim().is_empty() {
bail!("Automation prompt is required");
}
Ok(())
}
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = serde_json::to_string_pretty(value)?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, content).with_context(|| format!("Failed to write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| {
format!(
"Failed to move temporary file {} to {}",
tmp.display(),
path.display()
)
})?;
Ok(())
}
pub fn default_automations_dir() -> PathBuf {
if let Ok(path) = std::env::var("DEEPSEEK_AUTOMATIONS_DIR") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
dirs::home_dir()
.map(|home| home.join(".deepseek").join("automations"))
.unwrap_or_else(|| PathBuf::from(".deepseek").join("automations"))
}
pub type SharedAutomationManager = Arc<Mutex<AutomationManager>>;
#[derive(Debug, Clone)]
pub struct AutomationSchedulerConfig {
pub tick_interval_secs: u64,
}
impl Default for AutomationSchedulerConfig {
fn default() -> Self {
Self {
tick_interval_secs: 15,
}
}
}
pub fn spawn_scheduler(
automations: SharedAutomationManager,
task_manager: SharedTaskManager,
cancel: CancellationToken,
config: AutomationSchedulerConfig,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let interval = config.tick_interval_secs.max(5);
loop {
if cancel.is_cancelled() {
break;
}
{
let manager = automations.lock().await;
if let Err(err) = manager.scheduler_tick(&task_manager).await {
tracing::warn!("automation scheduler tick failed: {err}");
}
if let Err(err) = manager.reconcile_run_statuses(&task_manager).await {
tracing::warn!("automation reconcile failed: {err}");
}
}
tokio::select! {
_ = cancel.cancelled() => break,
_ = sleep(std::time::Duration::from_secs(interval)) => {}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hourly_rrule() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=HOURLY;INTERVAL=2;BYDAY=MO,TU").expect("parse");
match parsed {
AutomationSchedule::Hourly {
interval_hours,
byday,
} => {
assert_eq!(interval_hours, 2);
assert_eq!(byday.expect("byday").len(), 2);
}
_ => panic!("expected hourly"),
}
}
#[test]
fn parses_weekly_rrule() {
let parsed =
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYDAY=MO,WE;BYHOUR=9;BYMINUTE=30")
.expect("parse");
match parsed {
AutomationSchedule::Weekly {
byday,
byhour,
byminute,
} => {
assert_eq!(byday.len(), 2);
assert_eq!(byhour, 9);
assert_eq!(byminute, 30);
}
_ => panic!("expected weekly"),
}
}
#[test]
fn rejects_invalid_rrule_fields() {
let err =
AutomationSchedule::parse_rrule("FREQ=WEEKLY;BYSECOND=5").expect_err("should fail");
assert!(err.to_string().contains("Unsupported RRULE field"));
}
#[test]
fn deletes_automation_and_runs() {
let tempdir = tempfile::tempdir().expect("tempdir");
let manager = AutomationManager::open(tempdir.path().to_path_buf()).expect("manager");
let created = manager
.create_automation(CreateAutomationRequest {
name: "Delete me".to_string(),
prompt: "prompt".to_string(),
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
cwds: Vec::new(),
status: Some(AutomationStatus::Active),
})
.expect("create");
let run = AutomationRunRecord {
schema_version: CURRENT_RUN_SCHEMA_VERSION,
id: Uuid::new_v4().to_string(),
automation_id: created.id.clone(),
scheduled_for: Utc::now(),
status: AutomationRunStatus::Queued,
created_at: Utc::now(),
started_at: None,
ended_at: None,
task_id: None,
thread_id: None,
turn_id: None,
error: None,
};
manager.save_run(&run).expect("save run");
assert!(manager.runs_dir_for(&created.id).exists());
manager
.delete_automation(&created.id)
.expect("delete automation");
assert!(manager.get_automation(&created.id).is_err());
assert!(!manager.runs_dir_for(&created.id).exists());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+406
View File
@@ -0,0 +1,406 @@
//! Responses API helpers for the experimental DeepSeek endpoint.
//!
//! Gated behind `DEEPSEEK_EXPERIMENTAL_RESPONSES_API`. Normal traffic uses
//! chat completions via `crate::client::chat`.
use anyhow::{Context, Result};
use serde_json::{Value, json};
use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, Tool, ToolCaller};
use super::{
DeepSeekClient, ERROR_BODY_MAX_BYTES, api_url, apply_reasoning_effort, bounded_error_text,
from_api_tool_name, parse_usage, system_to_instructions, to_api_tool_name,
};
#[derive(Debug)]
pub(super) struct ResponsesFallback {
pub(super) status: u16,
pub(super) body: String,
}
impl DeepSeekClient {
pub(super) async fn create_message_responses(
&self,
request: &MessageRequest,
) -> Result<Result<MessageResponse, ResponsesFallback>> {
let mut body = json!({
"model": request.model,
"input": build_responses_input(&request.messages),
"store": false,
"max_output_tokens": request.max_tokens,
});
if let Some(instructions) = system_to_instructions(request.system.clone()) {
body["instructions"] = json!(instructions);
}
if let Some(temperature) = request.temperature {
body["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
body["top_p"] = json!(top_p);
}
if let Some(tools) = request.tools.as_ref() {
body["tools"] = json!(tools.iter().map(tool_to_responses).collect::<Vec<_>>());
}
if let Some(choice) = request.tool_choice.as_ref() {
body["tool_choice"] = choice.clone();
}
apply_reasoning_effort(
&mut body,
request.reasoning_effort.as_deref(),
self.api_provider,
);
let url = api_url(&self.base_url, "responses");
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
let status = response.status();
if status.as_u16() == 404 || status.as_u16() == 405 {
let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
return Ok(Err(ResponsesFallback {
status: status.as_u16(),
body,
}));
}
if !status.is_success() {
let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
anyhow::bail!("Failed to call DeepSeek Responses API: HTTP {status}: {error_text}");
}
let response_text = response.text().await.unwrap_or_default();
let value: Value =
serde_json::from_str(&response_text).context("Failed to parse Responses API JSON")?;
let message = parse_responses_message(&value)?;
Ok(Ok(message))
}
}
fn build_responses_input(messages: &[Message]) -> Vec<Value> {
let mut items = Vec::new();
for message in messages {
let role = message.role.as_str();
let text_type = if role == "user" {
"input_text"
} else {
"output_text"
};
for block in &message.content {
match block {
ContentBlock::Text { text, .. } => {
items.push(json!({
"type": "message",
"role": role,
"content": [{
"type": text_type,
"text": text,
}]
}));
}
ContentBlock::ToolUse {
id,
name,
input,
caller,
} => {
let args = serde_json::to_string(input).unwrap_or_else(|_| input.to_string());
let mut item = json!({
"type": "function_call",
"call_id": id,
"name": to_api_tool_name(name),
"arguments": args,
});
if let Some(caller) = caller {
item["caller"] = json!({
"type": caller.caller_type,
"tool_id": caller.tool_id,
});
}
items.push(item);
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} => {
let mut item = json!({
"type": "function_call_output",
"call_id": tool_use_id,
"output": content,
});
if let Some(is_error) = is_error {
item["is_error"] = json!(is_error);
}
items.push(item);
}
ContentBlock::Thinking { .. } => {}
ContentBlock::ServerToolUse { id, name, input } => {
items.push(json!({
"type": "server_tool_use",
"id": id,
"name": name,
"input": input,
}));
}
ContentBlock::ToolSearchToolResult {
tool_use_id,
content,
} => {
items.push(json!({
"type": "tool_search_tool_result",
"tool_use_id": tool_use_id,
"content": content,
}));
}
ContentBlock::CodeExecutionToolResult {
tool_use_id,
content,
} => {
items.push(json!({
"type": "code_execution_tool_result",
"tool_use_id": tool_use_id,
"content": content,
}));
}
}
}
}
items
}
fn tool_to_responses(tool: &Tool) -> Value {
let tool_type = tool.tool_type.as_deref().unwrap_or("function");
let mut value = if tool_type == "function" {
json!({
"type": "function",
"name": to_api_tool_name(&tool.name),
"description": tool.description,
"parameters": tool.input_schema,
})
} else if tool_type == "code_execution_20250825" {
json!({
"type": tool_type,
"name": to_api_tool_name(&tool.name),
})
} else {
json!({
"type": tool_type,
"name": to_api_tool_name(&tool.name),
"description": tool.description,
"input_schema": tool.input_schema,
})
};
if let Some(allowed_callers) = &tool.allowed_callers {
value["allowed_callers"] = json!(allowed_callers);
}
if let Some(defer_loading) = tool.defer_loading {
value["defer_loading"] = json!(defer_loading);
}
if let Some(input_examples) = &tool.input_examples {
value["input_examples"] = json!(input_examples);
}
if let Some(strict) = tool.strict {
value["strict"] = json!(strict);
}
value
}
fn parse_responses_message(payload: &Value) -> Result<MessageResponse> {
let id = payload
.get("id")
.and_then(Value::as_str)
.unwrap_or("response")
.to_string();
let model = payload
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let usage = parse_usage(payload.get("usage"));
let mut content = Vec::new();
if let Some(output) = payload.get("output").and_then(Value::as_array) {
for item in output {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
match item_type {
"message" => {
if let Some(role) = item.get("role").and_then(Value::as_str)
&& role != "assistant"
{
continue;
}
if let Some(content_items) = item.get("content").and_then(Value::as_array) {
for content_item in content_items {
let content_type = content_item
.get("type")
.and_then(Value::as_str)
.unwrap_or("output_text");
if content_type != "output_text" && content_type != "text" {
continue;
}
if let Some(text) = content_item.get("text").and_then(Value::as_str)
&& !text.trim().is_empty()
{
content.push(ContentBlock::Text {
text: text.to_string(),
cache_control: None,
});
}
}
}
}
"function_call" => {
let call_id = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.unwrap_or("tool_call")
.to_string();
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
let input = match item.get("arguments") {
Some(Value::String(raw)) => {
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()))
}
Some(other) => other.clone(),
None => Value::Null,
};
let caller = item.get("caller").and_then(|v| {
v.get("type")
.and_then(Value::as_str)
.map(|caller_type| ToolCaller {
caller_type: caller_type.to_string(),
tool_id: v
.get("tool_id")
.and_then(Value::as_str)
.map(std::string::ToString::to_string),
})
});
content.push(ContentBlock::ToolUse {
id: call_id,
name: from_api_tool_name(&name),
input,
caller,
});
}
"function_call_output" => {
let tool_use_id = item
.get("call_id")
.or_else(|| item.get("tool_use_id"))
.and_then(Value::as_str)
.unwrap_or("tool_call")
.to_string();
let content_text = item
.get("output")
.or_else(|| item.get("content"))
.map(|v| {
if let Some(s) = v.as_str() {
s.to_string()
} else {
v.to_string()
}
})
.unwrap_or_default();
let is_error = item.get("is_error").and_then(Value::as_bool);
content.push(ContentBlock::ToolResult {
tool_use_id,
content: content_text,
is_error,
content_blocks: None,
});
}
"server_tool_use" => {
let id = item
.get("id")
.and_then(Value::as_str)
.unwrap_or("server_tool")
.to_string();
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("server_tool")
.to_string();
let input = item.get("input").cloned().unwrap_or(Value::Null);
content.push(ContentBlock::ServerToolUse { id, name, input });
}
"tool_search_tool_result" => {
let tool_use_id = item
.get("tool_use_id")
.and_then(Value::as_str)
.unwrap_or("tool_search")
.to_string();
let content_value = item.get("content").cloned().unwrap_or(Value::Null);
content.push(ContentBlock::ToolSearchToolResult {
tool_use_id,
content: content_value,
});
}
"code_execution_tool_result" => {
let tool_use_id = item
.get("tool_use_id")
.and_then(Value::as_str)
.unwrap_or("code_execution")
.to_string();
let content_value = item.get("content").cloned().unwrap_or(Value::Null);
content.push(ContentBlock::CodeExecutionToolResult {
tool_use_id,
content: content_value,
});
}
"reasoning" => {
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
let summary_text = summary
.iter()
.filter_map(|s| s.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
if !summary_text.trim().is_empty() {
content.push(ContentBlock::Thinking {
thinking: summary_text,
});
}
}
}
_ => {}
}
}
}
if content.is_empty()
&& let Some(text) = payload.get("output_text").and_then(Value::as_str)
&& !text.trim().is_empty()
{
content.push(ContentBlock::Text {
text: text.to_string(),
cache_control: None,
});
}
Ok(MessageResponse {
id,
r#type: "message".to_string(),
role: "assistant".to_string(),
content,
model,
stop_reason: None,
stop_sequence: None,
container: payload
.get("container")
.cloned()
.and_then(|v| serde_json::from_value(v).ok()),
usage,
})
}
File diff suppressed because it is too large. Load diff
+125
View File
@@ -0,0 +1,125 @@
//! Local media attachment commands.
use std::path::{Path, PathBuf};
use super::CommandResult;
use crate::tui::app::App;
pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
return CommandResult::error("Usage: /attach <image-or-video-path>");
};
let path = resolve_attachment_path(raw_path, &app.workspace);
let Ok(path) = path.canonicalize() else {
return CommandResult::error(format!("Attachment not found: {}", path.display()));
};
if !path.is_file() {
return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
}
let Some(kind) = media_kind(&path) else {
return CommandResult::error(
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
);
};
app.insert_media_attachment(kind, &path, None);
CommandResult::message(format!("Attached {kind}: {}", path.display()))
}
fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
let path = expand_home(unquoted);
if path.is_absolute() {
path
} else {
workspace.join(path)
}
}
fn expand_home(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
} else if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(path)
}
fn media_kind(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
"mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use tempfile::TempDir;
fn app_with_workspace(tmpdir: &TempDir) -> App {
App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn attach_inserts_image_reference() {
let tmpdir = TempDir::new().expect("tempdir");
let image_path = tmpdir.path().join("photo.png");
std::fs::write(&image_path, b"not actually decoded").expect("write image fixture");
let mut app = app_with_workspace(&tmpdir);
let result = attach(&mut app, Some("photo.png"));
assert!(result.message.expect("message").contains("Attached image"));
assert!(app.input.contains("[Attached image:"));
let canonical_path = image_path.canonicalize().expect("canonical image path");
assert!(app.input.contains(&canonical_path.display().to_string()));
}
#[test]
fn attach_rejects_unsupported_extension() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
let mut app = app_with_workspace(&tmpdir);
let result = attach(&mut app, Some("notes.txt"));
assert!(
result
.message
.expect("message")
.contains("Unsupported attachment type")
);
assert!(app.input.is_empty());
}
}
+881
View File
@@ -0,0 +1,881 @@
//! Config commands: config, settings, mode switches, trust, logout
use std::path::{Path, PathBuf};
use super::CommandResult;
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
use crate::settings::Settings;
use crate::tui::app::{App, AppAction, AppMode, OnboardingState, SidebarFocus};
use crate::tui::approval::ApprovalMode;
/// Open the interactive config editor modal.
pub fn show_config(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenConfigView)
}
/// Show persistent settings
pub fn show_settings(_app: &mut App) -> CommandResult {
match Settings::load() {
Ok(settings) => CommandResult::message(settings.display()),
Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
}
}
/// Open the `/statusline` multi-select picker for configuring footer items.
pub fn status_line(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenStatusPicker)
}
/// Persist `tui.status_items` to `~/.deepseek/config.toml` without disturbing
/// the rest of the file. We round-trip through `toml::Value` so any keys we
/// don't know about (provider blocks, MCP, etc.) survive the write
/// untouched.
///
/// Returns the path written so the caller can surface it in a status toast.
pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;
let path = config_toml_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}
let mut doc: toml::Value = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("config.toml root must be a table")?;
let tui_entry = table
.entry("tui".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
let tui_table = tui_entry
.as_table_mut()
.context("`tui` section in config.toml must be a table")?;
let array = items
.iter()
.map(|item| toml::Value::String(item.key().to_string()))
.collect::<Vec<_>>();
tui_table.insert("status_items".to_string(), toml::Value::Array(array));
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
fs::write(&path, body)
.with_context(|| format!("failed to write config at {}", path.display()))?;
Ok(path)
}
fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;
let path = config_toml_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}
let mut doc: toml::Value = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("config.toml root must be a table")?;
table.insert(key.to_string(), toml::Value::String(value.to_string()));
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
fs::write(&path, body)
.with_context(|| format!("failed to write config at {}", path.display()))?;
Ok(path)
}
/// Resolve the path to `~/.deepseek/config.toml` (or
/// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
/// never write to a different file than the one we read.
fn config_toml_path() -> anyhow::Result<PathBuf> {
use anyhow::Context;
if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let trimmed = env.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?;
Ok(home.join(".deepseek").join("config.toml"))
}
/// Modify a setting at runtime
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
let key = key.to_lowercase();
match key.as_str() {
"model" => {
let Some(model) = normalize_model_name(value) else {
return CommandResult::error(format!(
"Invalid model '{value}'. Expected a DeepSeek model ID. Common models: {}",
COMMON_DEEPSEEK_MODELS.join(", ")
));
};
app.model = model.clone();
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
return CommandResult::with_message_and_action(
format!("model = {model}"),
AppAction::UpdateCompaction(app.compaction_config()),
);
}
"approval_mode" | "approval" => {
let mode = match value.to_lowercase().as_str() {
"auto" => Some(ApprovalMode::Auto),
"suggest" | "suggested" | "on-request" | "untrusted" => Some(ApprovalMode::Suggest),
"never" => Some(ApprovalMode::Never),
_ => None,
};
return match mode {
Some(m) => {
app.approval_mode = m;
CommandResult::message(format!("approval_mode = {}", m.label()))
}
None => CommandResult::error(
"Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never",
),
};
}
"mcp_config_path" | "mcp" => {
if value.trim().is_empty() {
return CommandResult::error("mcp_config_path cannot be empty");
}
app.mcp_config_path = PathBuf::from(expand_tilde(value));
app.mcp_restart_required = true;
let message = if persist {
match persist_root_string_key("mcp_config_path", value) {
Ok(path) => format!(
"mcp_config_path = {} (saved to {}; restart required for MCP tool pool)",
app.mcp_config_path.display(),
path.display()
),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
format!(
"mcp_config_path = {} (session only; restart required for MCP tool pool)",
app.mcp_config_path.display()
)
};
return CommandResult::message(message);
}
_ => {}
}
let mut settings = match Settings::load() {
Ok(s) => s,
Err(e) if !persist => {
app.status_message = Some(format!(
"Settings unavailable; applying session-only override ({e})"
));
Settings::default()
}
Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
};
if let Err(e) = settings.set(&key, value) {
return CommandResult::error(format!("{e}"));
}
let mut action = None;
match key.as_str() {
"auto_compact" | "compact" => {
app.auto_compact = settings.auto_compact;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"calm_mode" | "calm" => {
app.calm_mode = settings.calm_mode;
app.mark_history_updated();
}
"low_motion" | "motion" => {
app.low_motion = settings.low_motion;
app.needs_redraw = true;
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
app.mark_history_updated();
}
"show_tool_details" | "tool_details" => {
app.show_tool_details = settings.show_tool_details;
app.mark_history_updated();
}
"composer_density" | "composer" => {
app.composer_density =
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
app.needs_redraw = true;
}
"composer_border" | "border" => {
app.composer_border = settings.composer_border;
app.needs_redraw = true;
}
"transcript_spacing" | "spacing" => {
app.transcript_spacing =
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
app.mark_history_updated();
}
"default_mode" | "mode" => {
let mode = AppMode::from_setting(&settings.default_mode);
app.set_mode(mode);
}
"max_history" | "history" => {
app.max_input_history = settings.max_input_history;
}
"default_model" => {
if let Some(ref model) = settings.default_model {
app.model.clone_from(model);
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
}
"sidebar_width" | "sidebar" => {
app.sidebar_width_percent = settings.sidebar_width_percent;
app.mark_history_updated();
}
"sidebar_focus" | "focus" => {
app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
}
_ => {}
}
let display_value = match key.as_str() {
"default_mode" | "mode" => settings.default_mode.clone(),
_ => value.to_string(),
};
let message = if persist {
if let Err(e) = settings.save() {
return CommandResult::error(format!("Failed to save: {e}"));
}
format!("{key} = {display_value} (saved)")
} else {
format!("{key} = {display_value} (session only, add --save to persist)")
};
CommandResult {
message: Some(message),
action,
}
}
/// Modify a setting at runtime
#[allow(dead_code)]
pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
let Some(args) = args else {
let available = Settings::available_settings()
.iter()
.map(|(k, d)| format!(" {k}: {d}"))
.collect::<Vec<_>>()
.join("\n");
return CommandResult::message(format!(
"Usage: /set <key> <value>\n\n\
Available settings:\n{available}\n\n\
Session-only settings:\n \
model: Current model\n \
approval_mode: auto | suggest | never\n\n\
Add --save to persist to settings file."
));
};
let parts: Vec<&str> = args.splitn(2, ' ').collect();
if parts.len() < 2 {
return CommandResult::error("Usage: /set <key> <value>");
}
let key = parts[0].to_lowercase();
let (value, should_save) = if parts[1].ends_with(" --save") {
(parts[1].trim_end_matches(" --save").trim(), true)
} else {
(parts[1].trim(), false)
};
set_config_value(app, &key, value, should_save)
}
/// Enable YOLO mode (shell + trust + auto-approve)
pub fn yolo(app: &mut App) -> CommandResult {
app.set_mode(AppMode::Yolo);
CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
}
/// Legacy alias for the removed normal mode.
pub fn normal_mode(app: &mut App) -> CommandResult {
app.set_mode(AppMode::Agent);
CommandResult::message("Normal mode was removed. Switched to Agent mode.")
}
/// Enable agent mode (autonomous tool use with approvals)
pub fn agent_mode(app: &mut App) -> CommandResult {
app.set_mode(AppMode::Agent);
CommandResult::message("Agent mode enabled.")
}
/// Enable plan mode (tool planning, then choose execution route)
pub fn plan_mode(app: &mut App) -> CommandResult {
app.set_mode(AppMode::Plan);
CommandResult::message(
"Plan mode enabled. Describe your goal and I will create a plan before execution.",
)
}
/// Manage workspace-level trust and the per-path allowlist.
///
/// Subcommands:
/// - `/trust` – show current state and trusted external paths
/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
/// - `/trust off` – disable workspace-level trust mode
/// - `/trust add <path>` – add a directory to the allowlist (#29)
/// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
/// - `/trust list` – list trusted external paths for this workspace
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
let mut parts = raw.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("").to_lowercase();
let rest = parts.next().map(str::trim).unwrap_or("");
let workspace = app.workspace.clone();
match sub.as_str() {
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
"on" | "enable" | "yes" | "y" => {
app.trust_mode = true;
CommandResult::message(
"Workspace trust mode enabled — agent file tools can now read/write any path. \
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
)
}
"off" | "disable" | "no" | "n" => {
app.trust_mode = false;
CommandResult::message("Workspace trust mode disabled.")
}
"add" => trust_add(&workspace, rest),
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
other => CommandResult::error(format!(
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
)),
}
}
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
let mut lines = Vec::new();
lines.push(format!(
"Workspace trust mode: {}",
if app.trust_mode {
"enabled"
} else {
"disabled"
}
));
if trust.paths().is_empty() {
if force_paths {
lines.push("No external paths trusted from this workspace.".to_string());
} else {
lines.push(
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
.to_string(),
);
}
} else {
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
for path in trust.paths() {
lines.push(format!(" • {}", path.display()));
}
}
CommandResult::message(lines.join("\n"))
}
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
if raw.is_empty() {
return CommandResult::error(
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
);
}
let path = PathBuf::from(expand_tilde(raw));
if !path.exists() {
return CommandResult::error(format!(
"Path not found: {} — supply an existing directory or file.",
path.display()
));
}
match crate::workspace_trust::add(workspace, &path) {
Ok(stored) => CommandResult::message(format!(
"Added to trust list for this workspace: {}",
stored.display()
)),
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
}
}
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
if raw.is_empty() {
return CommandResult::error("Usage: /trust remove <path>");
}
let path = PathBuf::from(expand_tilde(raw));
match crate::workspace_trust::remove(workspace, &path) {
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
}
}
fn expand_tilde(raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest).to_string_lossy().into_owned();
} else if raw == "~"
&& let Some(home) = dirs::home_dir()
{
return home.to_string_lossy().into_owned();
}
raw.to_string()
}
/// Logout - clear API key and return to onboarding
pub fn logout(app: &mut App) -> CommandResult {
match clear_api_key() {
Ok(()) => {
app.onboarding = OnboardingState::ApiKey;
app.onboarding_needs_api_key = true;
app.api_key_input.clear();
app.api_key_cursor = 0;
CommandResult::message("Logged out. Enter a new API key to continue.")
}
Err(e) => CommandResult::error(format!("Failed to clear API key: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::{App, TuiOptions};
use crate::tui::approval::ApprovalMode;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct EnvGuard {
home: Option<OsString>,
userprofile: Option<OsString>,
deepseek_config_path: Option<OsString>,
}
impl EnvGuard {
fn new(home: &Path) -> Self {
let home_str = OsString::from(home.as_os_str());
let config_path = home.join(".deepseek").join("config.toml");
let config_str = OsString::from(config_path.as_os_str());
let home_prev = env::var_os("HOME");
let userprofile_prev = env::var_os("USERPROFILE");
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("HOME", &home_str);
env::set_var("USERPROFILE", &home_str);
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
}
Self {
home: home_prev,
userprofile: userprofile_prev,
deepseek_config_path: deepseek_config_prev,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = self.home.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("HOME", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("HOME");
}
}
if let Some(value) = self.userprofile.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("USERPROFILE", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("USERPROFILE");
}
}
if let Some(value) = self.deepseek_config_path.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_CONFIG_PATH", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("DEEPSEEK_CONFIG_PATH");
}
}
}
}
fn create_test_app() -> App {
let options = TuiOptions {
model: "test-model".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_yolo_command_sets_all_flags() {
let mut app = create_test_app();
let _ = yolo(&mut app);
assert!(app.allow_shell);
assert!(app.trust_mode);
assert!(app.yolo);
assert_eq!(app.approval_mode, ApprovalMode::Auto);
assert_eq!(app.mode, AppMode::Yolo);
}
#[test]
fn test_mode_switch_commands() {
let mut app = create_test_app();
let _ = normal_mode(&mut app);
assert_eq!(app.mode, AppMode::Agent);
let _ = agent_mode(&mut app);
assert_eq!(app.mode, AppMode::Agent);
let _ = plan_mode(&mut app);
assert_eq!(app.mode, AppMode::Plan);
}
#[test]
fn test_show_config_opens_config_editor() {
let mut app = create_test_app();
app.total_tokens = 1234;
let result = show_config(&mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
#[test]
fn test_show_settings_loads_from_file() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = show_settings(&mut app);
// Settings should load (may use defaults if file doesn't exist)
assert!(result.message.is_some());
}
#[test]
fn test_set_without_args_shows_usage() {
let mut app = create_test_app();
let result = set_config(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Usage: /set"));
assert!(msg.contains("Available settings:"));
}
#[test]
fn test_set_model_updates_app_state() {
let mut app = create_test_app();
let _old_model = app.model.clone();
let result = set_config(&mut app, Some("model deepseek-v4-flash"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-v4-flash"));
assert_eq!(app.model, "deepseek-v4-flash");
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn test_set_model_accepts_future_deepseek_model_id() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("model deepseek-v4"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-v4"));
assert_eq!(app.model, "deepseek-v4");
}
#[test]
fn test_set_model_with_save_flag() {
let mut app = create_test_app();
let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
// Note: This test may fail in environments where settings can't be saved
// The important thing is that the model is updated
assert_eq!(app.model, "deepseek-v4-flash");
}
#[test]
fn test_set_default_mode_normal_save_reports_normalized_value() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-default-mode-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = set_config(&mut app, Some("default_mode normal --save"));
let msg = result.message.unwrap();
assert_eq!(msg, "default_mode = agent (saved)");
assert_eq!(app.mode, AppMode::Agent);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
assert!(saved.contains("default_mode = \"agent\""));
}
#[test]
fn test_set_approval_mode_valid_values() {
let mut app = create_test_app();
// Test auto
let result = set_config(&mut app, Some("approval_mode auto"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
// Test suggest
let result = set_config(&mut app, Some("approval_mode suggest"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
// Test never
let result = set_config(&mut app, Some("approval_mode never"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn test_set_approval_mode_invalid_value() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("approval_mode invalid"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Invalid approval_mode"));
}
#[test]
fn test_set_without_save_flag() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = set_config(&mut app, Some("auto_compact true"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(session only"));
}
#[test]
fn test_set_composer_border_updates_live_app() {
let _lock = lock_test_env();
let mut app = create_test_app();
app.composer_border = true;
let result = set_config(&mut app, Some("composer_border false"));
assert!(result.message.is_some());
assert!(!app.composer_border);
assert!(app.needs_redraw);
}
#[test]
fn test_trust_on_enables_flag() {
let mut app = create_test_app();
assert!(!app.trust_mode);
let result = trust(&mut app, Some("on"));
let msg = result.message.expect("message");
assert!(msg.contains("Workspace trust mode enabled"));
assert!(app.trust_mode);
}
#[test]
fn test_trust_status_default_lists_state() {
let mut app = create_test_app();
let result = trust(&mut app, None);
let msg = result.message.expect("status message");
assert!(msg.contains("Workspace trust mode"));
}
#[test]
fn test_trust_add_requires_path() {
let mut app = create_test_app();
let result = trust(&mut app, Some("add"));
let msg = result.message.expect("error message");
assert!(msg.starts_with("Error:"), "got {msg:?}");
}
#[test]
fn test_logout_clears_api_key_state() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-logout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
let mut app = create_test_app();
let result = logout(&mut app);
assert!(result.message.is_some());
assert_eq!(app.onboarding, OnboardingState::ApiKey);
assert!(app.onboarding_needs_api_key);
assert!(app.api_key_input.is_empty());
assert_eq!(app.api_key_cursor, 0);
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("api_key"));
}
#[test]
fn test_set_invalid_setting() {
let _lock = lock_test_env();
let mut app = create_test_app();
let _result = set_config(&mut app, Some("nonexistent value"));
// Should either error or handle as session setting
// The current implementation tries to set it in Settings
// which may succeed or fail depending on Settings implementation
}
#[test]
fn test_set_key_without_value() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("model"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Usage: /set"));
}
#[test]
fn persist_status_items_writes_tui_section_to_config_toml() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-statusline-persist-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let items = vec![
crate::config::StatusItem::Mode,
crate::config::StatusItem::Model,
crate::config::StatusItem::Cost,
];
let path = persist_status_items(&items).expect("persist should succeed");
let body = fs::read_to_string(&path).expect("written file should be readable");
assert!(body.contains("[tui]"), "expected [tui] section in {body}");
assert!(
body.contains("status_items"),
"expected status_items key in {body}"
);
assert!(body.contains("\"mode\""), "expected mode key in {body}");
assert!(body.contains("\"cost\""), "expected cost key in {body}");
}
#[test]
fn persist_status_items_preserves_existing_unrelated_keys() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-statusline-preserve-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
// Seed the config with a sentinel key the picker MUST NOT clobber.
fs::write(
&path,
"api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
)
.unwrap();
let written = persist_status_items(&[crate::config::StatusItem::Mode])
.expect("persist should succeed");
let body = fs::read_to_string(&written).expect("written file should be readable");
assert!(
body.contains("api_key = \"sentinel-key\""),
"round-trip lost api_key: {body}"
);
assert!(
body.contains("model = \"deepseek-v4-pro\""),
"round-trip lost model: {body}"
);
assert!(
body.contains("status_items"),
"expected status_items in {body}"
);
}
}
+469
View File
@@ -0,0 +1,469 @@
//! Core commands: help, clear, exit, model
use std::fmt::Write;
use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name};
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
use super::CommandResult;
/// Show help information
pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult {
if let Some(topic) = topic {
// Show help for specific command
if let Some(cmd) = super::get_command_info(topic) {
let mut help = format!(
"{}\n\n {}\n\n Usage: {}",
cmd.name, cmd.description, cmd.usage
);
if !cmd.aliases.is_empty() {
let _ = write!(help, "\n Aliases: {}", cmd.aliases.join(", "));
}
return CommandResult::message(help);
}
return CommandResult::error(format!("Unknown command: {topic}"));
}
// Show help overlay
if app.view_stack.top_kind() != Some(ModalKind::Help) {
app.view_stack.push(HelpView::new());
}
CommandResult::ok()
}
/// Clear conversation history
pub fn clear(app: &mut App) -> CommandResult {
app.clear_history();
app.mark_history_updated();
app.api_messages.clear();
app.system_prompt = None;
app.transcript_selection.clear();
app.queued_messages.clear();
app.queued_draft = None;
app.total_conversation_tokens = 0;
let todos_cleared = app.clear_todos();
app.tool_log.clear();
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.pending_tool_uses.clear();
app.last_exec_wait_command = None;
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
app.current_session_id = None;
let message = if todos_cleared {
"Conversation cleared".to_string()
} else {
"Conversation cleared (plan state busy; run /clear again if needed)".to_string()
};
CommandResult::with_message_and_action(
message,
AppAction::SyncSession {
messages: Vec::new(),
system_prompt: None,
model: app.model.clone(),
workspace: app.workspace.clone(),
},
)
}
/// Exit the application
pub fn exit() -> CommandResult {
CommandResult::action(AppAction::Quit)
}
/// Switch or view current model. With no argument, open the two-pane
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
/// way to flip both knobs without memorising the docs.
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
if let Some(name) = model_name {
let Some(model_id) = normalize_model_name(name) else {
return CommandResult::error(format!(
"Invalid model '{name}'. Expected a DeepSeek model ID. Common models: {}",
COMMON_DEEPSEEK_MODELS.join(", ")
));
};
let old_model = app.model.clone();
app.model = model_id.clone();
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
CommandResult::with_message_and_action(
format!("Model changed: {old_model} → {model_id}"),
AppAction::UpdateCompaction(app.compaction_config()),
)
} else {
CommandResult::action(AppAction::OpenModelPicker)
}
}
/// Fetch and list available models from the configured API endpoint.
pub fn models(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::FetchModels)
}
/// List sub-agent status from the engine
pub fn subagents(app: &mut App) -> CommandResult {
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
app.view_stack
.push(SubAgentsView::new(app.subagent_cache.clone()));
}
app.status_message = Some("Fetching sub-agent status...".to_string());
CommandResult::action(AppAction::ListSubAgents)
}
/// Show `DeepSeek` dashboard and docs links
pub fn deepseek_links() -> CommandResult {
CommandResult::message(
"DeepSeek Links:\n\
─────────────────────────────\n\
Dashboard: https://platform.deepseek.com\n\
Docs: https://platform.deepseek.com/docs\n\n\
Tip: API keys are available in the dashboard console.",
)
}
/// Show home dashboard with stats and quick actions
pub fn home_dashboard(app: &mut App) -> CommandResult {
let mut stats = String::new();
// Basic info
let _ = writeln!(stats, "DeepSeek TUI Home Dashboard");
let _ = writeln!(stats, "============================================");
// Model & mode
let _ = writeln!(stats, "Model: {}", app.model);
let _ = writeln!(stats, "Mode: {}", app.mode.label());
let _ = writeln!(stats, "Workspace: {}", app.workspace.display());
// Session stats
let history_count = app.history.len();
let total_tokens = app.total_conversation_tokens;
let queued_messages = app.queued_messages.len();
let _ = writeln!(stats, "History: {} messages", history_count);
let _ = writeln!(stats, "Tokens: {} (session)", total_tokens);
if queued_messages > 0 {
let _ = writeln!(stats, "Queued: {} messages", queued_messages);
}
// Sub-agents
let subagent_count = app.subagent_cache.len();
if subagent_count > 0 {
let _ = writeln!(stats, "Sub-agents: {} active", subagent_count);
}
// Active skill
if let Some(skill) = &app.active_skill {
let _ = writeln!(stats, "Skill: {} (active)", skill);
}
// Quick actions section
let _ = writeln!(stats, "\nQuick Actions");
let _ = writeln!(stats, "--------------------------------------------");
let _ = writeln!(stats, "/links - Dashboard & API links");
let _ = writeln!(stats, "/skills - List available skills");
let _ = writeln!(
stats,
"/config - Open interactive configuration editor"
);
let _ = writeln!(stats, "/settings - Show persistent settings");
let _ = writeln!(stats, "/model - Switch or view model");
let _ = writeln!(stats, "/subagents - List sub-agent status");
let _ = writeln!(stats, "/task list - Show background task queue");
let _ = writeln!(stats, "/help - Show help");
// Mode-specific tips
let _ = writeln!(stats, "\nMode Tips");
let _ = writeln!(stats, "--------------------------------------------");
match app.mode {
AppMode::Agent => {
let _ = writeln!(stats, "Agent mode - Use tools for autonomous tasks");
let _ = writeln!(
stats,
" Use Ctrl+X to review in Plan mode before executing"
);
let _ = writeln!(stats, " Type /yolo to enable full tool access");
}
AppMode::Yolo => {
let _ = writeln!(stats, "YOLO mode - Full tool access, no approvals");
let _ = writeln!(stats, " Be careful with destructive operations!");
}
AppMode::Plan => {
let _ = writeln!(stats, "Plan mode - Design before implementing");
let _ = writeln!(stats, " Use /plan to create structured checklists");
}
}
CommandResult::message(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::Message;
use crate::tui::app::{App, AppMode, TuiOptions};
use crate::tui::history::HistoryCell;
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_help_unknown_command() {
let mut app = create_test_app();
let result = help(&mut app, Some("nonexistent"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Unknown command"));
assert!(result.action.is_none());
}
#[test]
fn test_help_known_command() {
let mut app = create_test_app();
let result = help(&mut app, Some("clear"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("clear"));
assert!(msg.contains("Clear conversation history"));
assert!(msg.contains("Usage: /clear"));
}
#[test]
fn test_help_config_topic_uses_interactive_editor_text() {
let mut app = create_test_app();
let result = help(&mut app, Some("config"));
let msg = result.message.expect("help topic should return message");
assert!(msg.contains("config"));
assert!(msg.contains("Open interactive configuration editor"));
assert!(msg.contains("Usage: /config"));
}
#[test]
fn test_help_links_topic_shows_aliases() {
let mut app = create_test_app();
let result = help(&mut app, Some("links"));
let msg = result.message.expect("help topic should return message");
assert!(msg.contains("links"));
assert!(msg.contains("Show DeepSeek dashboard and docs links"));
assert!(msg.contains("Usage: /links"));
assert!(msg.contains("Aliases: dashboard, api"));
}
#[test]
fn test_help_pushes_overlay() {
let mut app = create_test_app();
assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
let result = help(&mut app, None);
assert_eq!(result.message, None);
assert_eq!(result.action, None);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
}
#[test]
fn test_help_does_not_duplicate_overlay() {
let mut app = create_test_app();
help(&mut app, None);
let initial_kind = app.view_stack.top_kind();
help(&mut app, None);
assert_eq!(app.view_stack.top_kind(), initial_kind);
}
#[test]
fn test_clear_resets_all_state() {
let mut app = create_test_app();
// Set up some state
app.history.push(HistoryCell::User {
content: "test".to_string(),
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.total_conversation_tokens = 100;
app.tool_log.push("test".to_string());
app.current_session_id = Some("existing-session".to_string());
let result = clear(&mut app);
assert!(result.message.is_some());
assert!(app.history.is_empty());
assert!(app.api_messages.is_empty());
assert_eq!(app.total_conversation_tokens, 0);
assert!(app.tool_log.is_empty());
assert!(app.tool_cells.is_empty());
assert!(app.tool_details_by_cell.is_empty());
assert!(app.current_session_id.is_none());
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
}
#[test]
fn test_exit_returns_quit_action() {
let result = exit();
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::Quit)));
}
#[test]
fn test_model_change_updates_state() {
let mut app = create_test_app();
let old_model = app.model.clone();
let result = model(&mut app, Some("deepseek-v4-flash"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains(&old_model));
assert!(msg.contains("deepseek-v4-flash"));
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
assert_eq!(app.model, "deepseek-v4-flash");
assert_eq!(app.last_prompt_tokens, None);
assert_eq!(app.last_completion_tokens, None);
}
#[test]
fn test_model_change_accepts_future_deepseek_model() {
let mut app = create_test_app();
let result = model(&mut app, Some("deepseek-v4"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("deepseek-v4"));
assert_eq!(app.model, "deepseek-v4");
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn test_model_change_rejects_invalid_model() {
let mut app = create_test_app();
let result = model(&mut app, Some("gpt-4"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Invalid model"));
assert!(msg.contains("DeepSeek model ID"));
assert!(msg.contains("deepseek-v4-pro"));
assert!(msg.contains("deepseek-v4-flash"));
assert!(result.action.is_none());
}
#[test]
fn test_model_without_args_opens_picker() {
let mut app = create_test_app();
let result = model(&mut app, None);
assert_eq!(result.message, None);
assert_eq!(result.action, Some(AppAction::OpenModelPicker));
}
#[test]
fn test_models_triggers_fetch_action() {
let mut app = create_test_app();
let result = models(&mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::FetchModels)));
}
#[test]
fn test_subagents_pushes_view_and_sets_status() {
let mut app = create_test_app();
let result = subagents(&mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
assert_eq!(
app.status_message,
Some("Fetching sub-agent status...".to_string())
);
}
#[test]
fn test_deepseek_links() {
let result = deepseek_links();
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("DeepSeek Links"));
assert!(msg.contains("https://platform.deepseek.com"));
assert!(result.action.is_none());
}
#[test]
fn test_home_dashboard_includes_all_sections() {
let mut app = create_test_app();
app.total_conversation_tokens = 1234;
let result = home_dashboard(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("DeepSeek TUI Home Dashboard"));
assert!(msg.contains("Model:"));
assert!(msg.contains("Mode:"));
assert!(msg.contains("Workspace:"));
assert!(msg.contains("History:"));
assert!(msg.contains("Tokens:"));
assert!(msg.contains("Quick Actions"));
assert!(msg.contains("Mode Tips"));
assert!(result.action.is_none());
}
#[test]
fn test_home_dashboard_shows_queued_when_present() {
let mut app = create_test_app();
app.queued_messages
.push_back(crate::tui::app::QueuedMessage::new(
"test".to_string(),
None,
));
let result = home_dashboard(&mut app);
let msg = result.message.unwrap();
assert!(msg.contains("Queued:"));
}
#[test]
fn test_home_dashboard_mode_tips_for_each_mode() {
let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
for mode in modes {
let mut app = create_test_app();
app.mode = mode;
let result = home_dashboard(&mut app);
let msg = result.message.unwrap();
assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
}
}
#[test]
fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() {
let mut app = create_test_app();
let result = home_dashboard(&mut app);
let msg = result
.message
.expect("home dashboard should return message");
assert!(msg.contains("/links - Dashboard & API links"));
assert!(msg.contains("/config - Open interactive configuration editor"));
assert!(
!msg.lines()
.any(|line| line.trim_start().starts_with("/set "))
);
assert!(!msg.contains("/deepseek"));
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Cycle commands: `/cycles` (list past cycle boundaries) and
//! `/cycle <n>` (show one cycle's briefing in detail).
use std::fmt::Write;
use crate::tui::app::App;
use super::CommandResult;
/// `/cycles` — list past cycle handoffs in compact form.
pub fn list_cycles(app: &App) -> CommandResult {
if app.cycle_briefings.is_empty() {
let msg = format!(
"No cycle boundaries have fired yet (current cycle: 1, threshold: {} tokens for {}).",
app.cycle.threshold_for(&app.model),
app.model
);
return CommandResult::message(msg);
}
let mut out = String::new();
let _ = writeln!(
out,
"Cycle handoffs in this session ({} total). Active cycle: {}.",
app.cycle_briefings.len(),
app.cycle_count.saturating_add(1),
);
out.push('\n');
for brief in &app.cycle_briefings {
let preview = first_line(&brief.briefing_text, 80);
let _ = writeln!(
out,
" cycle {n} @ {ts} briefing: {tokens} tokens ─ {preview}",
n = brief.cycle,
ts = brief.timestamp.to_rfc3339(),
tokens = brief.token_estimate,
preview = preview,
);
}
out.push('\n');
out.push_str("Use `/cycle <n>` to show the full briefing for a specific cycle.\n");
CommandResult::message(out)
}
/// `/cycle <n>` — print the full briefing for cycle `n`.
pub fn show_cycle(app: &App, arg: Option<&str>) -> CommandResult {
let Some(raw) = arg.map(str::trim) else {
return CommandResult::error(
"Usage: /cycle <n> — n is the cycle number from /cycles".to_string(),
);
};
if raw.is_empty() {
return CommandResult::error("Usage: /cycle <n>".to_string());
}
let Ok(n) = raw.parse::<u32>() else {
return CommandResult::error(format!(
"Cycle number must be a positive integer (got '{raw}')."
));
};
let Some(brief) = app.cycle_briefings.iter().find(|b| b.cycle == n) else {
let known: Vec<String> = app
.cycle_briefings
.iter()
.map(|b| b.cycle.to_string())
.collect();
let known_str = if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
};
return CommandResult::error(format!(
"Cycle {n} not found in this session. Known cycles: {known_str}."
));
};
let mut out = String::new();
let _ = writeln!(
out,
"── Cycle {n} ({ts}) briefing: {tokens} tokens ──",
n = brief.cycle,
ts = brief.timestamp.to_rfc3339(),
tokens = brief.token_estimate,
);
out.push('\n');
out.push_str(brief.briefing_text.trim());
out.push('\n');
CommandResult::message(out)
}
/// `/recall <query>` — user-initiated BM25 search of cycle archives.
///
/// Synchronous wrapper around `tools::recall_archive::RecallArchiveTool` so
/// users can probe the archive without invoking the model. Output is the
/// same JSON payload the agent would see; the assistant pretty-prints
/// short results and dumps long ones inline.
pub fn recall_archive(app: &App, arg: Option<&str>) -> CommandResult {
use crate::tools::recall_archive::RecallArchiveTool;
use crate::tools::spec::{ToolContext, ToolSpec};
let Some(raw) = arg.map(str::trim) else {
return CommandResult::error("Usage: /recall <query>".to_string());
};
if raw.is_empty() {
return CommandResult::error("Usage: /recall <query>".to_string());
}
let session_id = app
.current_session_id
.clone()
.unwrap_or_else(|| "workspace".to_string());
let context = ToolContext::new(app.workspace.clone()).with_state_namespace(session_id);
let tool = RecallArchiveTool;
let input = serde_json::json!({"query": raw});
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(tool.execute(input, &context))
});
match result {
Ok(res) => CommandResult::message(res.content),
Err(err) => CommandResult::error(format!("recall_archive failed: {err}")),
}
}
/// Truncate `text` to its first non-empty line, capped at `max_chars`.
fn first_line(text: &str, max_chars: usize) -> String {
let line = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("");
if line.chars().count() <= max_chars {
line.to_string()
} else {
let prefix: String = line.chars().take(max_chars).collect();
format!("{prefix}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cycle_manager::CycleBriefing;
use crate::tui::app::{App, TuiOptions};
use chrono::Utc;
use std::path::PathBuf;
fn test_options() -> TuiOptions {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
}
}
#[test]
fn list_cycles_reports_no_boundaries_yet() {
let app = App::new(test_options(), &crate::config::Config::default());
let res = list_cycles(&app);
assert!(res.message.is_some());
assert!(
res.message
.as_deref()
.unwrap()
.contains("No cycle boundaries")
);
}
#[test]
fn show_cycle_rejects_nonexistent_cycle() {
let app = App::new(test_options(), &crate::config::Config::default());
let res = show_cycle(&app, Some("3"));
let msg = res.message.expect("error message");
assert!(msg.contains("Cycle 3 not found"), "got: {msg}");
}
#[test]
fn list_and_show_cycles_render_briefings() {
let mut app = App::new(test_options(), &crate::config::Config::default());
app.cycle_briefings.push(CycleBriefing {
cycle: 1,
timestamp: Utc::now(),
briefing_text: "Decision: chose A; constraint: no async.".to_string(),
token_estimate: 12,
});
app.cycle_count = 1;
let listed = list_cycles(&app).message.expect("list message");
assert!(listed.contains("cycle 1"));
assert!(listed.contains("12 tokens"));
let shown = show_cycle(&app, Some("1")).message.expect("show message");
assert!(shown.contains("Decision: chose A"));
}
#[test]
fn show_cycle_validates_argument() {
let app = App::new(test_options(), &crate::config::Config::default());
let res = show_cycle(&app, None);
let msg = res.message.expect("error message");
assert!(msg.contains("Usage: /cycle"));
let res = show_cycle(&app, Some("not-a-number"));
let msg = res.message.expect("error message");
assert!(msg.contains("must be a positive integer"));
}
}
+434
View File
@@ -0,0 +1,434 @@
#![allow(clippy::items_after_test_module)]
//! Debug commands: tokens, cost, system, context, undo, retry
use super::CommandResult;
use crate::compaction::estimate_input_tokens_conservative;
use crate::models::{SystemPrompt, context_window_for_model};
use crate::tui::app::{App, AppAction};
use crate::tui::history::HistoryCell;
fn token_count(value: Option<u32>) -> String {
value.map_or_else(|| "not reported".to_string(), |tokens| tokens.to_string())
}
fn active_context_summary(app: &App) -> String {
let estimated =
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
match context_window_for_model(&app.model) {
Some(window) => {
let used = estimated.min(window as usize);
let percent = (used as f64 / f64::from(window) * 100.0).clamp(0.0, 100.0);
format!("~{used} / {window} ({percent:.1}%)")
}
None => format!("~{estimated} / unknown window"),
}
}
fn cache_summary(app: &App) -> String {
match (
app.last_prompt_cache_hit_tokens,
app.last_prompt_cache_miss_tokens,
) {
(Some(hit), Some(miss)) => format!("{hit} hit / {miss} miss"),
(Some(hit), None) => format!("{hit} hit / miss not reported"),
(None, Some(miss)) => format!("hit not reported / {miss} miss"),
(None, None) => "not reported".to_string(),
}
}
/// Show token usage for session
pub fn tokens(app: &mut App) -> CommandResult {
let message_count = app.api_messages.len();
let chat_count = app.history.len();
CommandResult::message(format!(
"Token Usage:\n\
─────────────────────────────\n\
Active context: {}\n\
Last API input: {} (turn telemetry; may count repeated prefix across tool rounds)\n\
Last API output: {}\n\
Cache hit/miss: {} (telemetry/cost only)\n\
Cumulative tokens: {} (session usage telemetry)\n\
Approx session cost: ${:.4}\n\
API messages: {}\n\
Chat messages: {}\n\
Model: {}",
active_context_summary(app),
token_count(app.last_prompt_tokens),
token_count(app.last_completion_tokens),
cache_summary(app),
app.total_tokens,
app.session_cost,
message_count,
chat_count,
app.model,
))
}
/// Show session cost breakdown
pub fn cost(app: &mut App) -> CommandResult {
CommandResult::message(format!(
"Session Cost:\n\
─────────────────────────────\n\
Approx total spent: ${:.4}\n\n\
Cost estimates are approximate and use provider usage telemetry when available.\n\n\
DeepSeek API Pricing:\n\
─────────────────────────────\n\
Pricing details are not configured in this CLI.",
app.session_cost,
))
}
/// Show current system prompt
pub fn system_prompt(app: &mut App) -> CommandResult {
let prompt_text = match &app.system_prompt {
Some(SystemPrompt::Text(text)) => text.clone(),
Some(SystemPrompt::Blocks(blocks)) => blocks
.iter()
.map(|b| b.text.clone())
.collect::<Vec<_>>()
.join("\n\n---\n\n"),
None => "(no system prompt)".to_string(),
};
// Truncate if too long
let display = if prompt_text.len() > 500 {
// Find a valid UTF-8 char boundary at or before byte 500
let truncate_at = prompt_text
.char_indices()
.take_while(|(i, _)| *i <= 500)
.last()
.map_or(0, |(i, _)| i);
format!(
"{}...\n\n(truncated, {} chars total)",
&prompt_text[..truncate_at],
prompt_text.len()
)
} else {
prompt_text
};
CommandResult::message(format!(
"System Prompt ({} mode):\n─────────────────────────────\n{}",
app.mode.label(),
display
))
}
/// Show context window usage
pub fn context(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenContextInspector)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::{ContentBlock, Message, SystemBlock};
use crate::tui::app::{App, TuiOptions};
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_tokens_shows_usage_info() {
let mut app = create_test_app();
app.total_tokens = 1234;
app.session_cost = 0.05;
app.last_prompt_tokens = Some(100);
app.last_completion_tokens = Some(25);
app.last_prompt_cache_hit_tokens = Some(70);
app.last_prompt_cache_miss_tokens = Some(30);
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "test".to_string(),
cache_control: None,
}],
});
app.history.push(HistoryCell::User {
content: "test".to_string(),
});
let result = tokens(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Token Usage"));
assert!(msg.contains("Active context:"));
assert!(msg.contains("Last API input:"));
assert!(msg.contains("Last API output:"));
assert!(msg.contains("Cache hit/miss:"));
assert!(msg.contains("70 hit / 30 miss"));
assert!(msg.contains("Cumulative tokens:"));
assert!(msg.contains("Approx session cost:"));
assert!(msg.contains("API messages:"));
assert!(msg.contains("Chat messages:"));
assert!(msg.contains("Model:"));
}
#[test]
fn test_cost_shows_spending_info() {
let mut app = create_test_app();
app.session_cost = 0.1234;
let result = cost(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session Cost"));
assert!(msg.contains("Approx total spent:"));
assert!(msg.contains("approximate"));
assert!(msg.contains("$0.1234"));
}
#[test]
fn test_system_prompt_displays_text() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Test system prompt"));
}
#[test]
fn test_system_prompt_displays_blocks() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Blocks(vec![
SystemBlock {
block_type: "text".to_string(),
text: "Block 1".to_string(),
cache_control: None,
},
SystemBlock {
block_type: "text".to_string(),
text: "Block 2".to_string(),
cache_control: None,
},
]));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Block 1"));
assert!(msg.contains("Block 2"));
}
#[test]
fn test_system_prompt_none() {
let mut app = create_test_app();
app.system_prompt = None;
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(no system prompt)"));
}
#[test]
fn test_system_prompt_truncates_long_text() {
let mut app = create_test_app();
let long_text = "x".repeat(600);
app.system_prompt = Some(SystemPrompt::Text(long_text));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("..."));
assert!(msg.contains("chars total"));
}
#[test]
fn test_context_shows_usage_stats() {
let mut app = create_test_app();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
let result = context(&mut app);
assert!(matches!(
result.action,
Some(AppAction::OpenContextInspector)
));
assert!(result.message.is_none());
}
#[test]
fn test_undo_removes_last_exchange() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Hi".to_string(),
streaming: false,
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![],
});
let initial_history_len = app.history.len();
let initial_api_len = app.api_messages.len();
let result = undo(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Removed"));
assert!(app.history.len() < initial_history_len);
assert!(app.api_messages.len() < initial_api_len);
}
#[test]
fn test_undo_nothing_to_undo() {
let mut app = create_test_app();
// Clear any default history
app.history.clear();
app.api_messages.clear();
let result = undo(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
}
#[test]
fn test_retry_with_previous_message() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Test message".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("Test message"));
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
}
#[test]
fn test_retry_no_previous_message() {
let mut app = create_test_app();
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No previous request to retry"));
assert!(result.action.is_none());
}
#[test]
fn test_retry_truncates_long_input() {
let mut app = create_test_app();
let long_input = "x".repeat(100);
app.history.push(HistoryCell::User {
content: long_input.clone(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("..."));
}
}
/// Remove last message pair (user + assistant)
pub fn undo(app: &mut App) -> CommandResult {
// Remove from display history (up to the last user message)
let mut removed_count = 0;
while !app.history.is_empty() {
let last_is_user = matches!(app.history.last(), Some(HistoryCell::User { .. }));
app.pop_history();
removed_count += 1;
if last_is_user {
break;
}
}
// Remove from API messages
while let Some(last) = app.api_messages.last() {
if last.role == "user" {
app.api_messages.pop();
break;
}
app.api_messages.pop();
}
if removed_count > 0 {
// Keep tool/index mappings consistent after truncation.
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.mark_history_updated();
CommandResult::message(format!("Removed {removed_count} message(s)"))
} else {
CommandResult::message("Nothing to undo")
}
}
/// Retry last request - remove last exchange and re-send the user's message
pub fn retry(app: &mut App) -> CommandResult {
let last_user_input = app.history.iter().rev().find_map(|cell| match cell {
HistoryCell::User { content } => Some(content.clone()),
_ => None,
});
match last_user_input {
Some(input) => {
undo(app);
let display_input = if input.len() > 50 {
let truncate_at = input
.char_indices()
.take_while(|(i, _)| *i <= 50)
.last()
.map_or(0, |(i, _)| i);
format!("{}...", &input[..truncate_at])
} else {
input.clone()
};
CommandResult::with_message_and_action(
format!("Retrying: {display_input}"),
AppAction::SendMessage(input),
)
}
None => CommandResult::error("No previous request to retry"),
}
}
@@ -151,3 +151,124 @@ fn extract_cargo_name(content: &str) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_init_creates_agents_md() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = init(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Created AGENTS.md"));
let agents_path = tmpdir.path().join("AGENTS.md");
assert!(agents_path.exists());
}
#[test]
fn test_init_fails_if_exists() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Create file first
std::fs::write(tmpdir.path().join("AGENTS.md"), "existing").unwrap();
let result = init(&mut app);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("already exists"));
}
#[test]
fn test_detect_project_type_rust() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(
tmpdir.path().join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Rust"));
assert!(info.contains("cargo build"));
assert!(info.contains("cargo test"));
}
#[test]
fn test_detect_project_type_node() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Node.js"));
assert!(info.contains("npm install"));
}
#[test]
fn test_detect_project_type_python() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Python"));
}
#[test]
fn test_detect_project_type_go() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Go"));
}
#[test]
fn test_detect_project_type_unknown() {
let tmpdir = TempDir::new().unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Unknown"));
}
#[test]
fn test_extract_cargo_name() {
let cargo = r#"
[package]
name = "my-project"
version = "1.0.0"
"#;
assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string()));
}
#[test]
fn test_extract_cargo_name_single_quotes() {
let cargo = r#"name = 'single-quoted'"#;
assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string()));
}
#[test]
fn test_extract_cargo_name_not_found() {
let cargo = "[package]\nversion = \"1.0.0\"";
assert_eq!(extract_cargo_name(cargo), None);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Shell job-center commands.
use crate::tui::app::{App, AppAction, ShellJobAction};
use super::CommandResult;
pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
return CommandResult::action(AppAction::ShellJob(ShellJobAction::List));
}
let mut parts = raw.splitn(3, char::is_whitespace);
let action = parts.next().unwrap_or("").to_ascii_lowercase();
let id = parts.next().map(str::trim).filter(|s| !s.is_empty());
let rest = parts.next().map(str::trim).unwrap_or("");
match action.as_str() {
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
"show" | "inspect" => match id {
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
id: id.to_string(),
})),
None => CommandResult::error("Usage: /jobs show <id>"),
},
"poll" | "wait" => match id {
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll {
id: id.to_string(),
wait: action == "wait",
})),
None => CommandResult::error("Usage: /jobs poll <id>"),
},
"stdin" | "send" => match id {
Some(id) if !rest.is_empty() => {
CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
id: id.to_string(),
input: rest.to_string(),
close: false,
}))
}
_ => CommandResult::error("Usage: /jobs stdin <id> <input>"),
},
"close-stdin" | "eof" => match id {
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
id: id.to_string(),
input: String::new(),
close: true,
})),
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
},
"cancel" | "kill" | "stop" => match id {
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel {
id: id.to_string(),
})),
None => CommandResult::error("Usage: /jobs cancel <id>"),
},
_ => CommandResult::error(
"Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <id>|cancel <id>]",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn app() -> App {
App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 2,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn parses_job_actions() {
let mut app = app();
let show = jobs(&mut app, Some("show shell_abcd"));
assert!(matches!(
show.action,
Some(AppAction::ShellJob(ShellJobAction::Show { id })) if id == "shell_abcd"
));
let send = jobs(&mut app, Some("stdin shell_abcd y"));
assert!(matches!(
send.action,
Some(AppAction::ShellJob(ShellJobAction::SendStdin { id, input, close: false }))
if id == "shell_abcd" && input == "y"
));
}
}
+116
View File
@@ -0,0 +1,116 @@
//! In-TUI MCP manager command parser.
use crate::tui::app::{App, AppAction, McpUiAction};
use super::CommandResult;
pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") {
return CommandResult::action(AppAction::Mcp(McpUiAction::Show));
}
let mut parts = raw.split_whitespace();
let action = parts.next().unwrap_or("").to_ascii_lowercase();
match action.as_str() {
"init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init {
force: parts.any(|part| part == "--force" || part == "-f"),
})),
"add" => parse_add(parts.collect()),
"enable" => match parse_name(parts.next(), "Usage: /mcp enable <name>") {
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })),
Err(msg) => CommandResult::error(msg),
},
"disable" => match parse_name(parts.next(), "Usage: /mcp disable <name>") {
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })),
Err(msg) => CommandResult::error(msg),
},
"remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove <name>") {
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })),
Err(msg) => CommandResult::error(msg),
},
"validate" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)),
"reload" | "reconnect" => CommandResult::action(AppAction::Mcp(McpUiAction::Reload)),
_ => CommandResult::error(
"Usage: /mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
),
}
}
fn parse_name(name: Option<&str>, usage: &str) -> Result<String, String> {
match name {
Some(name) if !name.trim().is_empty() => Ok(name.to_string()),
_ => Err(usage.to_string()),
}
}
fn parse_add(parts: Vec<&str>) -> CommandResult {
if parts.len() < 3 {
return CommandResult::error(
"Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>",
);
}
match parts[0].to_ascii_lowercase().as_str() {
"stdio" => CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio {
name: parts[1].to_string(),
command: parts[2].to_string(),
args: parts[3..].iter().map(|s| (*s).to_string()).collect(),
})),
"http" | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp {
name: parts[1].to_string(),
url: parts[2].to_string(),
})),
_ => CommandResult::error(
"Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn app() -> App {
App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 2,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn parses_add_and_validate() {
let mut app = app();
let add = mcp(&mut app, Some("add stdio local node server.js"));
assert!(matches!(
add.action,
Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
if name == "local" && command == "node" && args == vec!["server.js".to_string()]
));
let validate = mcp(&mut app, Some("validate"));
assert!(matches!(
validate.action,
Some(AppAction::Mcp(McpUiAction::Validate))
));
}
}
+754
View File
@@ -0,0 +1,754 @@
//! Slash command registry and dispatch system
//!
//! This module provides a modular command system inspired by Codex-rs.
//! Commands are organized by category and dispatched through a central registry.
mod attachment;
mod config;
mod core;
mod cycle;
mod debug;
mod init;
mod jobs;
mod mcp;
mod note;
mod provider;
mod queue;
mod restore;
mod review;
mod session;
mod skills;
mod task;
use crate::tui::app::{App, AppAction};
/// Result of executing a command
#[derive(Debug, Clone)]
pub struct CommandResult {
/// Optional message to display to the user
pub message: Option<String>,
/// Optional action for the app to take
pub action: Option<AppAction>,
}
impl CommandResult {
/// Create an empty result (command succeeded with no output)
pub fn ok() -> Self {
Self {
message: None,
action: None,
}
}
/// Create a result with just a message
pub fn message(msg: impl Into<String>) -> Self {
Self {
message: Some(msg.into()),
action: None,
}
}
/// Create a result with an action
pub fn action(action: AppAction) -> Self {
Self {
message: None,
action: Some(action),
}
}
/// Create a result with both message and action
#[allow(dead_code)]
pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self {
Self {
message: Some(msg.into()),
action: Some(action),
}
}
/// Create an error message result
pub fn error(msg: impl Into<String>) -> Self {
Self {
message: Some(format!("Error: {}", msg.into())),
action: None,
}
}
}
/// Command metadata for help and autocomplete
#[derive(Debug, Clone, Copy)]
pub struct CommandInfo {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub description: &'static str,
pub usage: &'static str,
}
impl CommandInfo {
pub fn requires_argument(&self) -> bool {
self.usage.contains('<') || self.usage.contains('[')
}
pub fn palette_command(&self) -> String {
if self.requires_argument() {
format!("/{} ", self.name)
} else {
format!("/{}", self.name)
}
}
pub fn palette_description(&self) -> String {
if self.aliases.is_empty() {
self.description.to_string()
} else {
format!("{} aliases: {}", self.description, self.aliases.join(", "))
}
}
}
/// All registered commands
pub const COMMANDS: &[CommandInfo] = &[
// Core commands
CommandInfo {
name: "help",
aliases: &["?"],
description: "Show help information",
usage: "/help [command]",
},
CommandInfo {
name: "clear",
aliases: &[],
description: "Clear conversation history",
usage: "/clear",
},
CommandInfo {
name: "exit",
aliases: &["quit", "q"],
description: "Exit the application",
usage: "/exit",
},
CommandInfo {
name: "model",
aliases: &[],
description: "Switch or view current model",
usage: "/model [name]",
},
CommandInfo {
name: "models",
aliases: &[],
description: "List available models from API",
usage: "/models",
},
CommandInfo {
name: "provider",
aliases: &[],
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
usage: "/provider [name]",
},
CommandInfo {
name: "queue",
aliases: &["queued"],
description: "View or edit queued messages",
usage: "/queue [list|edit <n>|drop <n>|clear]",
},
CommandInfo {
name: "subagents",
aliases: &["agents"],
description: "List sub-agent status",
usage: "/subagents",
},
CommandInfo {
name: "links",
aliases: &["dashboard", "api"],
description: "Show DeepSeek dashboard and docs links",
usage: "/links",
},
CommandInfo {
name: "home",
aliases: &["stats", "overview"],
description: "Show home dashboard with stats and quick actions",
usage: "/home",
},
CommandInfo {
name: "note",
aliases: &[],
description: "Append note to persistent notes file (.deepseek/notes.md)",
usage: "/note <text>",
},
CommandInfo {
name: "attach",
aliases: &["image", "media"],
description: "Attach image/video media; use @path for text files or directories",
usage: "/attach <path>",
},
CommandInfo {
name: "task",
aliases: &["tasks"],
description: "Manage background tasks",
usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
},
CommandInfo {
name: "jobs",
aliases: &["job"],
description: "Inspect and control background shell jobs",
usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]",
},
CommandInfo {
name: "mcp",
aliases: &[],
description: "Open or manage MCP servers",
usage: "/mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
},
// Session commands
CommandInfo {
name: "save",
aliases: &[],
description: "Save session to file",
usage: "/save [path]",
},
CommandInfo {
name: "sessions",
aliases: &["resume"],
description: "Open session picker",
usage: "/sessions",
},
CommandInfo {
name: "load",
aliases: &[],
description: "Load session from file",
usage: "/load [path]",
},
CommandInfo {
name: "compact",
aliases: &[],
description: "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)",
usage: "/compact",
},
CommandInfo {
name: "context",
aliases: &["ctx"],
description: "Open compact session context inspector",
usage: "/context",
},
CommandInfo {
name: "cycles",
aliases: &[],
description: "List checkpoint-restart cycle handoffs in this session",
usage: "/cycles",
},
CommandInfo {
name: "cycle",
aliases: &[],
description: "Show the carry-forward briefing for a specific cycle",
usage: "/cycle <n>",
},
CommandInfo {
name: "recall",
aliases: &[],
description: "Search prior cycle archives (BM25 over message text)",
usage: "/recall <query>",
},
CommandInfo {
name: "export",
aliases: &[],
description: "Export conversation to markdown",
usage: "/export [path]",
},
// Config commands
CommandInfo {
name: "config",
aliases: &[],
description: "Open interactive configuration editor",
usage: "/config",
},
CommandInfo {
name: "yolo",
aliases: &[],
description: "Enable YOLO mode (shell + trust + auto-approve)",
usage: "/yolo",
},
CommandInfo {
name: "agent",
aliases: &[],
description: "Switch to agent mode",
usage: "/agent",
},
CommandInfo {
name: "plan",
aliases: &[],
description: "Switch to plan mode and review suggested implementation steps",
usage: "/plan",
},
CommandInfo {
name: "trust",
aliases: &[],
description: "Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)",
usage: "/trust [on|off|add <path>|remove <path>|list]",
},
CommandInfo {
name: "logout",
aliases: &[],
description: "Clear API key and return to setup",
usage: "/logout",
},
// Debug commands
CommandInfo {
name: "tokens",
aliases: &[],
description: "Show token usage for session",
usage: "/tokens",
},
CommandInfo {
name: "system",
aliases: &[],
description: "Show current system prompt",
usage: "/system",
},
CommandInfo {
name: "context",
aliases: &[],
description: "Show context window usage",
usage: "/context",
},
CommandInfo {
name: "undo",
aliases: &[],
description: "Remove last message pair",
usage: "/undo",
},
CommandInfo {
name: "retry",
aliases: &[],
description: "Retry the last request",
usage: "/retry",
},
CommandInfo {
name: "init",
aliases: &[],
description: "Generate AGENTS.md for project",
usage: "/init",
},
CommandInfo {
name: "settings",
aliases: &[],
description: "Show persistent settings",
usage: "/settings",
},
CommandInfo {
name: "statusline",
aliases: &["status"],
description: "Configure which items appear in the footer",
usage: "/statusline",
},
// Skills commands
CommandInfo {
name: "skills",
aliases: &[],
description: "List local skills (or --remote to browse the curated registry)",
usage: "/skills [--remote]",
},
CommandInfo {
name: "skill",
aliases: &[],
description: "Activate a skill, or install/update/uninstall/trust a community skill",
usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
},
CommandInfo {
name: "review",
aliases: &[],
description: "Run a structured code review on a file, diff, or PR",
usage: "/review <target>",
},
CommandInfo {
name: "restore",
aliases: &[],
description: "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
usage: "/restore [N]",
},
// RLM command
CommandInfo {
name: "rlm",
aliases: &["recursive"],
description: "Recursive Language Model (RLM) turn — store the prompt in a Python REPL and let the model write code to process it, with `llm_query()` / `sub_rlm()` for sub-LLM calls.",
usage: "/rlm <prompt>",
},
// Debug/cost command
CommandInfo {
name: "cost",
aliases: &[],
description: "Show session cost breakdown",
usage: "/cost",
},
];
/// Execute a slash command
pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect();
let command = parts[0].to_lowercase();
let command = command.strip_prefix('/').unwrap_or(&command);
let arg = parts.get(1).map(|s| s.trim());
// Match command or alias
match command {
// Core commands
"help" | "?" => core::help(app, arg),
"clear" => core::clear(app),
"exit" | "quit" | "q" => core::exit(),
"model" => core::model(app, arg),
"models" => core::models(app),
"provider" => provider::provider(app, arg),
"queue" | "queued" => queue::queue(app, arg),
"subagents" | "agents" => core::subagents(app),
"links" | "dashboard" | "api" => core::deepseek_links(),
"home" | "stats" | "overview" => core::home_dashboard(app),
"note" => note::note(app, arg),
"attach" | "image" | "media" => attachment::attach(app, arg),
"task" | "tasks" => task::task(app, arg),
"jobs" | "job" => jobs::jobs(app, arg),
"mcp" => mcp::mcp(app, arg),
// Session commands
"save" => session::save(app, arg),
"sessions" | "resume" => session::sessions(app),
"load" => session::load(app, arg),
"compact" => session::compact(app),
"cycles" => cycle::list_cycles(app),
"cycle" => cycle::show_cycle(app, arg),
"recall" => cycle::recall_archive(app, arg),
"export" => session::export(app, arg),
// Config commands
"config" => config::show_config(app),
"settings" => config::show_settings(app),
"statusline" | "status" => config::status_line(app),
"yolo" => config::yolo(app),
"agent" => config::agent_mode(app),
"plan" => config::plan_mode(app),
"trust" => config::trust(app, arg),
"logout" => config::logout(app),
// Debug commands
"tokens" => debug::tokens(app),
"cost" => debug::cost(app),
"system" => debug::system_prompt(app),
"context" | "ctx" => debug::context(app),
"undo" => debug::undo(app),
"retry" => debug::retry(app),
// Project commands
"init" => init::init(app),
// Skills commands
"skills" => skills::list_skills(app, arg),
"skill" => skills::run_skill(app, arg),
"review" => review::review(app, arg),
"restore" => restore::restore(app, arg),
// RLM command
"rlm" | "recursive" => rlm(app, arg),
// Legacy command migrations (kept out of registry/autocomplete intentionally).
"set" => CommandResult::error(
"The /set command was retired. Use /config to edit settings and /settings to inspect current values.",
),
"normal" => config::normal_mode(app),
"deepseek" => CommandResult::error(
"The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).",
),
_ => {
let suggestions = suggest_command_names(command, 3);
if suggestions.is_empty() {
CommandResult::error(format!(
"Unknown command: /{command}. Type /help for available commands."
))
} else {
let list = suggestions
.into_iter()
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
.join(", ");
CommandResult::error(format!(
"Unknown command: /{command}. Did you mean: {list}? Type /help for available commands."
))
}
}
}
}
/// Update a configuration value programmatically (used by interactive UI views).
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
config::set_config_value(app, key, value, persist)
}
/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under
/// `tui.status_items`. See [`config::persist_status_items`] for details.
pub fn persist_status_items(
items: &[crate::config::StatusItem],
) -> anyhow::Result<std::path::PathBuf> {
config::persist_status_items(items)
}
/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from
/// Zhang et al. (arXiv:2512.24601).
///
/// The user's prompt text is passed as the argument. It will be stored
/// in the REPL as the `PROMPT` variable. The root LLM will only see
/// metadata about the REPL state, never the prompt text directly.
pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult {
let prompt = match arg {
Some(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => {
return CommandResult::error(
"Usage: /rlm <prompt>\n\n\
Process a prompt using a Recursive Language Model (RLM).\n\
The prompt is stored in a REPL and the model writes code\n\
to decompose and process it recursively."
.to_string(),
);
}
};
// Sanity-check: RLM is most useful for longer prompts.
if prompt.len() < 50 {
return CommandResult::message(
"Tip: RLM is designed for processing LONG prompts (>100 chars). \
For short queries, just type the message directly."
.to_string(),
);
}
let model = app.model.clone();
let child_model = "deepseek-v4-flash".to_string();
// Paper experiments use depth=1 (one level of `sub_rlm`); we default to
// depth=2 so the model can recurse twice if it chooses to.
let max_depth: u32 = 2;
CommandResult::with_message_and_action(
format!(
"Starting RLM turn for {} chars of prompt using {} (child={}, depth={})...",
prompt.len(),
model,
child_model,
max_depth,
),
AppAction::Rlm {
prompt,
model,
child_model,
max_depth,
},
)
}
/// Get command info by name or alias
pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
let name = name.strip_prefix('/').unwrap_or(name);
COMMANDS
.iter()
.find(|cmd| cmd.name == name || cmd.aliases.contains(&name))
}
/// Get all commands matching a prefix (for autocomplete)
#[allow(dead_code)]
pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
COMMANDS
.iter()
.filter(|cmd| {
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
})
.collect()
}
fn edit_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
if a.is_empty() {
return b.chars().count();
}
if b.is_empty() {
return a.chars().count();
}
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, a_ch) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, b_ch) in b_chars.iter().enumerate() {
let cost = if a_ch == *b_ch { 0 } else { 1 };
let delete = prev[j + 1] + 1;
let insert = curr[j] + 1;
let substitute = prev[j] + cost;
curr[j + 1] = delete.min(insert).min(substitute);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn suggest_command_names(input: &str, limit: usize) -> Vec<String> {
let query = input.trim().to_ascii_lowercase();
if query.is_empty() || limit == 0 {
return Vec::new();
}
let mut scored: Vec<(u8, usize, String)> = Vec::new();
for command in COMMANDS {
let mut best: Option<(u8, usize)> = None;
for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) {
let candidate = candidate.to_ascii_lowercase();
let prefix_match = candidate.starts_with(&query) || query.starts_with(&candidate);
let contains_match = candidate.contains(&query) || query.contains(&candidate);
let distance = edit_distance(&candidate, &query);
let close_typo = distance <= 2;
if !(prefix_match || contains_match || close_typo) {
continue;
}
let rank = if prefix_match {
0
} else if contains_match {
1
} else {
2
};
match best {
Some((best_rank, best_distance))
if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
_ => best = Some((rank, distance)),
}
}
if let Some((rank, distance)) = best {
scored.push((rank, distance, command.name.to_string()));
}
}
scored.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.cmp(&b.1))
.then_with(|| a.2.cmp(&b.2))
});
scored
.into_iter()
.take(limit)
.map(|(_, _, name)| name)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, AppAction, TuiOptions};
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn command_registry_contains_config_and_links_but_not_set_or_deepseek() {
assert!(COMMANDS.iter().any(|cmd| cmd.name == "config"));
assert!(COMMANDS.iter().any(|cmd| cmd.name == "links"));
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set"));
assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek"));
}
#[test]
fn links_command_has_dashboard_and_api_aliases() {
let links = COMMANDS
.iter()
.find(|cmd| cmd.name == "links")
.expect("links command should exist");
assert_eq!(links.aliases, &["dashboard", "api"]);
}
#[test]
fn execute_config_opens_config_view_action() {
let mut app = create_test_app();
let result = execute("/config", &mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
#[test]
fn execute_links_and_aliases_return_links_message() {
let mut app = create_test_app();
for cmd in ["/links", "/dashboard", "/api"] {
let result = execute(cmd, &mut app);
let msg = result.message.expect("links commands should return text");
assert!(msg.contains("https://platform.deepseek.com"));
assert!(result.action.is_none());
}
}
#[test]
fn removed_set_and_deepseek_commands_show_migration_hints() {
let mut app = create_test_app();
let set_result = execute("/set model deepseek-v4-pro", &mut app);
let set_msg = set_result
.message
.expect("legacy command should return an error message");
assert!(set_msg.contains("The /set command was retired"));
assert!(set_msg.contains("/config"));
assert!(set_msg.contains("/settings"));
assert!(set_result.action.is_none());
let deepseek_result = execute("/deepseek", &mut app);
let deepseek_msg = deepseek_result
.message
.expect("legacy command should return an error message");
assert!(deepseek_msg.contains("The /deepseek command was renamed"));
assert!(deepseek_msg.contains("/links"));
assert!(deepseek_msg.contains("/dashboard"));
assert!(deepseek_msg.contains("/api"));
assert!(deepseek_result.action.is_none());
}
#[test]
fn unknown_command_suggests_nearest_match() {
let mut app = create_test_app();
let result = execute("/modle", &mut app);
let msg = result
.message
.expect("unknown command should return an error message");
assert!(msg.contains("Unknown command: /modle"));
assert!(msg.contains("Did you mean:"));
assert!(msg.contains("/model"));
}
#[test]
fn unknown_command_without_close_match_keeps_help_guidance() {
let mut app = create_test_app();
let result = execute("/zzzzzz", &mut app);
let msg = result
.message
.expect("unknown command should return an error message");
assert!(msg.contains("Unknown command: /zzzzzz"));
assert!(msg.contains("Type /help for available commands."));
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Note command: append to persistent notes file
use crate::tui::app::App;
use std::fs;
use std::io::Write;
use super::CommandResult;
/// Append a note to the persistent notes file
pub fn note(app: &mut App, content: Option<&str>) -> CommandResult {
let note_content = match content {
Some(c) => c.trim(),
None => {
return CommandResult::error("Usage: /note <text>");
}
};
if note_content.is_empty() {
return CommandResult::error("Note content cannot be empty");
}
// Determine notes path: workspace/.deepseek/notes.md
let notes_path = app.workspace.join(".deepseek").join("notes.md");
// Ensure parent directory exists
if let Some(parent) = notes_path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
return CommandResult::error(format!("Failed to create notes directory: {e}"));
}
// Append to notes file
let mut file = match fs::OpenOptions::new()
.create(true)
.append(true)
.open(&notes_path)
{
Ok(f) => f,
Err(e) => {
return CommandResult::error(format!("Failed to open notes file: {e}"));
}
};
// Write separator and note content
if let Err(e) = writeln!(file, "\n---\n{}", note_content) {
return CommandResult::error(format!("Failed to write note: {e}"));
}
CommandResult::message(format!("Note appended to {}", notes_path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_note_without_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /note"));
}
#[test]
fn test_note_with_empty_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, Some(" "));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("cannot be empty"));
}
#[test]
fn test_note_appends_to_file() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, Some("Test note content"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Note appended to"));
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
assert!(notes_path.exists());
let content = std::fs::read_to_string(&notes_path).unwrap();
assert!(content.contains("Test note content"));
}
#[test]
fn test_note_multiple_appends() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
note(&mut app, Some("First note"));
note(&mut app, Some("Second note"));
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
let content = std::fs::read_to_string(&notes_path).unwrap();
assert!(content.contains("First note"));
assert!(content.contains("Second note"));
// Should have two separators
assert_eq!(content.matches("---").count(), 2);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Provider switching: flip between DeepSeek, hosted providers, and self-hosted
//! OpenAI-compatible DeepSeek V4 servers at runtime.
//!
//! `/provider` with no args opens the picker modal (#52). `/provider <name>`
//! keeps the v0.6.6 CLI form for muscle-memory + scripted use.
use crate::config::{ApiProvider, normalize_model_name};
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Switch or view the current LLM backend.
///
/// With no args, opens the picker modal. With `<provider> [model]`, performs
/// the switch directly (e.g. `/provider nim flash` lands on
/// `deepseek-ai/deepseek-v4-flash`). The optional model accepts shorthand
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
let Some(args) = trimmed else {
return CommandResult::action(AppAction::OpenProviderPicker);
};
let mut parts = args.split_whitespace();
let name = parts.next().unwrap_or("");
let model_arg = parts.next();
let Some(target) = ApiProvider::parse(name) else {
return CommandResult::error(format!(
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang."
));
};
let model = match model_arg {
None => None,
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
Some(normalized) => Some(normalized),
None => {
return CommandResult::error(format!(
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
));
}
},
};
if target == app.api_provider && model.is_none() {
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
}
CommandResult::action(AppAction::SwitchProvider {
provider: target,
model,
})
}
fn expand_model_alias(name: &str) -> String {
match name.trim().to_ascii_lowercase().as_str() {
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn no_args_opens_picker_modal() {
let mut app = create_test_app();
let result = provider(&mut app, None);
assert!(result.message.is_none());
assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
}
#[test]
fn unknown_provider_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("anthropic"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Unknown provider"));
assert!(msg.contains("openrouter"));
assert!(msg.contains("novita"));
assert!(result.action.is_none());
}
#[test]
fn switch_to_openrouter_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("openrouter"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Openrouter);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switch_to_novita_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("novita"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Novita);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switch_to_fireworks_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("fireworks pro"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Fireworks);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switch_to_sglang_flash_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("sglang flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Sglang);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switching_to_active_provider_without_model_is_a_noop() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Already on provider"));
assert!(result.action.is_none());
}
#[test]
fn switch_to_nim_emits_action_without_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nvidia-nim"));
assert!(result.message.is_none());
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_flash_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_pro_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim pro"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn switch_to_active_provider_with_new_model_still_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Deepseek);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn invalid_model_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim gpt-4"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Invalid model"));
assert!(result.action.is_none());
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Queue commands: queue list/edit/drop/clear
use crate::tui::app::App;
use super::CommandResult;
const PREVIEW_LIMIT: usize = 120;
pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult {
let arg = args.unwrap_or("").trim();
if arg.is_empty() || arg.eq_ignore_ascii_case("list") {
return list_queue(app);
}
let mut parts = arg.split_whitespace();
let action = parts.next().unwrap_or("").to_lowercase();
match action.as_str() {
"edit" => edit_queue(app, parts.next()),
"drop" | "remove" | "rm" => drop_queue(app, parts.next()),
"clear" => clear_queue(app),
_ => CommandResult::error("Usage: /queue [list|edit <n>|drop <n>|clear]"),
}
}
fn list_queue(app: &mut App) -> CommandResult {
let mut lines = Vec::new();
let queued = app.queued_message_count();
if let Some(draft) = app.queued_draft.as_ref() {
lines.push("Editing queued message:".to_string());
lines.push(format!("- {}", truncate_preview(&draft.display)));
}
if queued == 0 {
if lines.is_empty() {
return CommandResult::message("No queued messages");
}
return CommandResult::message(lines.join("\n"));
}
lines.push(format!("Queued messages ({queued}):"));
for (idx, message) in app.queued_messages.iter().enumerate() {
lines.push(format!(
"{}. {}",
idx + 1,
truncate_preview(&message.display)
));
}
lines.push("Tip: /queue edit <n> to edit, /queue drop <n> to remove".to_string());
CommandResult::message(lines.join("\n"))
}
fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult {
if app.queued_draft.is_some() {
return CommandResult::error(
"Already editing a queued message. Send it or /queue clear to discard.",
);
}
let index = match parse_index(index) {
Ok(index) => index,
Err(err) => return CommandResult::error(err),
};
let Some(message) = app.remove_queued_message(index) else {
return CommandResult::error("Queued message not found");
};
app.input = message.display.clone();
app.cursor_position = app.input.len();
app.queued_draft = Some(message);
app.status_message = Some(format!("Editing queued message {}", index + 1));
CommandResult::message(format!(
"Editing queued message {} (press Enter to re-queue/send)",
index + 1
))
}
fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult {
let index = match parse_index(index) {
Ok(index) => index,
Err(err) => return CommandResult::error(err),
};
if app.remove_queued_message(index).is_none() {
return CommandResult::error("Queued message not found");
}
CommandResult::message(format!("Dropped queued message {}", index + 1))
}
fn clear_queue(app: &mut App) -> CommandResult {
let queued = app.queued_message_count();
let had_draft = app.queued_draft.take().is_some();
app.queued_messages.clear();
if queued == 0 && !had_draft {
return CommandResult::message("Queue already empty");
}
CommandResult::message("Queue cleared")
}
fn parse_index(input: Option<&str>) -> Result<usize, &'static str> {
let Some(input) = input else {
return Err("Missing index. Usage: /queue edit <n> or /queue drop <n>");
};
let raw = input
.parse::<usize>()
.map_err(|_| "Index must be a positive number")?;
if raw == 0 {
return Err("Index must be >= 1");
}
Ok(raw - 1)
}
fn truncate_preview(text: &str) -> String {
if text.chars().count() <= PREVIEW_LIMIT {
return text.to_string();
}
let mut out = String::new();
for ch in text.chars().take(PREVIEW_LIMIT.saturating_sub(3)) {
out.push(ch);
}
out.push_str("...");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, QueuedMessage, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_queue_list_empty() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No queued messages"));
}
#[test]
fn test_queue_list_with_messages() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("First message".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Second message".to_string(), None));
let result = queue(&mut app, Some("list"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Queued messages (2)"));
assert!(msg.contains("1. First message"));
assert!(msg.contains("2. Second message"));
}
#[test]
fn test_queue_edit_missing_index() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Test".to_string(), None));
let result = queue(&mut app, Some("edit"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Missing index"));
}
#[test]
fn test_queue_edit_invalid_index() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("edit abc"));
assert!(result.message.is_some());
assert!(
result
.message
.unwrap()
.contains("must be a positive number")
);
}
#[test]
fn test_queue_edit_not_found() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("edit 1"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("not found"));
}
#[test]
fn test_queue_edit_already_editing() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("First".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Second".to_string(), None));
// Start editing
queue(&mut app, Some("edit 1"));
// Try to edit another
let result = queue(&mut app, Some("edit 2"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Already editing"));
}
#[test]
fn test_queue_edit_success() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Original message".to_string(), None));
let result = queue(&mut app, Some("edit 1"));
assert!(result.message.is_some());
assert_eq!(app.input, "Original message");
assert_eq!(app.cursor_position, app.input.len());
assert!(app.queued_draft.is_some());
}
#[test]
fn test_queue_drop_success() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("To drop".to_string(), None));
let initial_count = app.queued_messages.len();
let result = queue(&mut app, Some("drop 1"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Dropped queued message"));
assert_eq!(app.queued_messages.len(), initial_count - 1);
}
#[test]
fn test_queue_clear() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Message 1".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Message 2".to_string(), None));
let result = queue(&mut app, Some("clear"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Queue cleared"));
assert!(app.queued_messages.is_empty());
}
#[test]
fn test_queue_clear_already_empty() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("clear"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Queue already empty"));
}
#[test]
fn test_truncate_preview_short_text() {
let result = truncate_preview("Short text");
assert_eq!(result, "Short text");
}
#[test]
fn test_truncate_preview_long_text() {
let long_text = "x".repeat(200);
let result = truncate_preview(&long_text);
assert!(result.len() <= PREVIEW_LIMIT + 3);
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_preview_unicode() {
let text = "Hello 世界 🌍";
let result = truncate_preview(text);
assert_eq!(result, text);
}
}
+255
View File
@@ -0,0 +1,255 @@
//! `/restore` slash command — roll back the workspace to a prior snapshot.
//!
//! `/restore` (no arg) lists the most recent snapshots so the user can
//! see what's available. `/restore <N>` restores the *N*th-most-recent
//! snapshot, where `N=1` is the newest. In non-YOLO mode we refuse to
//! mutate files unless the user has explicitly trusted the workspace
//! (`/trust on` or YOLO) — the user can always view the list, just not
//! one-shot revert without a safety net.
use super::CommandResult;
use crate::snapshot::SnapshotRepo;
use crate::tui::app::App;
const LIST_LIMIT: usize = 10;
/// Entry point for `/restore [N]`.
pub fn restore(app: &mut App, arg: Option<&str>) -> CommandResult {
let workspace = app.workspace.clone();
let repo = match SnapshotRepo::open_or_init(&workspace) {
Ok(r) => r,
Err(e) => {
return CommandResult::error(format!(
"Snapshot repo unavailable for {}: {e}",
workspace.display(),
));
}
};
let snapshots = match repo.list(LIST_LIMIT) {
Ok(s) => s,
Err(e) => return CommandResult::error(format!("Failed to list snapshots: {e}")),
};
if snapshots.is_empty() {
return CommandResult::message(
"No snapshots yet. Send a message to create the first pre-turn snapshot.",
);
}
let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
return CommandResult::message(format_listing(&snapshots));
};
let n: usize = match arg.parse() {
Ok(n) if n >= 1 => n,
_ => {
return CommandResult::error(format!(
"Usage: /restore <N> (N is 1-based; got '{arg}')",
));
}
};
if n > snapshots.len() {
return CommandResult::error(format!(
"Only {} snapshot(s) available; asked for #{n}.",
snapshots.len(),
));
}
// Non-YOLO sessions get a confirmation gate. We don't have a true
// modal-confirmation path inside slash commands today, so the gate
// is "require trust mode" — `/trust on` or YOLO. Users in plain
// Agent mode get a clear message explaining how to proceed.
if !(app.yolo || app.trust_mode) {
return CommandResult::message(format!(
"Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\
Run `/trust on` or `/yolo` first, then re-run `/restore {n}`.",
snapshots[n - 1].label,
));
}
let target = &snapshots[n - 1];
if let Err(e) = repo.restore(&target.id) {
return CommandResult::error(format!("Restore failed: {e}"));
}
CommandResult::message(format!(
"Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.",
target.label,
short_sha(target.id.as_str()),
))
}
fn format_listing(snapshots: &[crate::snapshot::Snapshot]) -> String {
let mut out = String::from("Recent snapshots (newest first; pass /restore <N> to revert):\n");
for (i, s) in snapshots.iter().enumerate() {
out.push_str(&format!(
" #{:<2} {} {}\n",
i + 1,
short_sha(s.id.as_str()),
s.label,
));
}
out
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::TuiOptions;
use std::sync::MutexGuard;
use tempfile::TempDir;
fn make_app(tmp: &TempDir, yolo: bool) -> App {
let workspace = tmp.path().to_path_buf();
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmp.path().join("skills"),
memory_path: tmp.path().join("memory.md"),
notes_path: tmp.path().join("notes.txt"),
mcp_config_path: tmp.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo,
resume_session_id: None,
};
App::new(options, &Config::default())
}
/// Pins HOME to a tempdir for the duration of the test under the
/// crate-wide env mutex.
struct ScopedHome {
prev: Option<std::ffi::OsString>,
_guard: MutexGuard<'static, ()>,
}
impl Drop for ScopedHome {
fn drop(&mut self) {
// SAFETY: process-wide lock still held.
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
fn scoped_home(tmp: &TempDir) -> ScopedHome {
let guard = lock_test_env();
let prev = std::env::var_os("HOME");
// SAFETY: serialised by the global env lock.
unsafe {
std::env::set_var("HOME", tmp.path());
}
ScopedHome {
prev,
_guard: guard,
}
}
#[test]
fn restore_with_no_snapshots_shows_empty_message() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("No snapshots"));
}
#[test]
fn restore_lists_when_no_arg_provided() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v2").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, None);
let msg = result.message.expect("expected message");
assert!(msg.contains("post-turn:1"));
assert!(msg.contains("pre-turn:1"));
assert!(msg.contains("#1"));
assert!(msg.contains("#2"));
}
#[test]
fn restore_in_yolo_reverts_workspace() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
let f = app.workspace.join("a.txt");
std::fs::write(&f, b"original").unwrap();
repo.snapshot("pre-turn:1").unwrap();
std::fs::write(&f, b"clobbered").unwrap();
repo.snapshot("post-turn:1").unwrap();
let result = restore(&mut app, Some("2"));
assert!(result.message.unwrap().contains("Restored"));
let after = std::fs::read_to_string(&f).unwrap();
assert_eq!(after, "original");
}
#[test]
fn restore_outside_trust_mode_refuses() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, false);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("1"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Refusing"));
assert!(msg.contains("/trust on"));
}
#[test]
fn restore_invalid_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("99"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Only 1 snapshot"));
}
#[test]
fn restore_zero_index_returns_error() {
let tmp = TempDir::new().unwrap();
let _home = scoped_home(&tmp);
let mut app = make_app(&tmp, true);
// Need at least one snapshot so we exercise the parse-index
// branch instead of the "no snapshots" early return.
let repo = SnapshotRepo::open_or_init(&app.workspace).unwrap();
std::fs::write(app.workspace.join("a.txt"), b"v1").unwrap();
repo.snapshot("pre-turn:1").unwrap();
let result = restore(&mut app, Some("0"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Usage:"));
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Review command: activate review skill and send a target immediately.
use crate::skills::{SkillRegistry, default_skills_dir};
use crate::tui::app::{App, AppAction};
use crate::tui::history::HistoryCell;
use super::CommandResult;
fn warnings_suffix(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
return String::new();
}
format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- "))
}
pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
let target = args.unwrap_or("").trim();
if target.is_empty() {
return CommandResult::error("Usage: /review <target>");
}
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let mut warnings = warnings_suffix(&registry);
let mut skill = registry.get("review").cloned();
let global_dir = default_skills_dir();
if skill.is_none() && global_dir != skills_dir {
let registry = SkillRegistry::discover(&global_dir);
if warnings.is_empty() {
warnings = warnings_suffix(&registry);
} else if !registry.warnings().is_empty() {
warnings.push_str(&format!("\n- {}", registry.warnings().join("\n- ")));
}
skill = registry.get("review").cloned();
}
let skill = match skill {
Some(skill) => skill,
None => {
let global_display = global_dir.display();
return CommandResult::error(format!(
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.{}",
skills_dir.display(),
global_display,
warnings
));
}
};
let instruction = format!(
"You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
skill.name, skill.body
);
app.add_message(HistoryCell::System {
content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
});
app.active_skill = Some(instruction);
CommandResult::action(AppAction::SendMessage(target.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
fn create_review_skill_dir(tmpdir: &TempDir) {
let skill_dir = tmpdir.path().join("skills").join("review");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: review\ndescription: Code review skill\n---\nReview the code",
)
.unwrap();
}
#[test]
fn test_review_without_target() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /review"));
}
#[test]
fn test_review_without_skill_installed() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Set skills dir to empty temp dir
app.skills_dir = tmpdir.path().join("nonexistent_skills");
let result = review(&mut app, Some("file.rs"));
// The command should either error about missing skill or work if global skill exists
assert!(result.message.is_some() || result.action.is_some());
}
#[test]
fn test_review_with_skill_activates_and_sends() {
let tmpdir = TempDir::new().unwrap();
create_review_skill_dir(&tmpdir);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, Some("file.rs"));
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
assert!(app.active_skill.is_some());
assert!(!app.history.is_empty());
}
}
+417
View File
@@ -0,0 +1,417 @@
//! Session commands: save, load, compact, export
use std::fmt::Write;
use std::path::PathBuf;
use crate::session_manager::create_saved_session_with_mode;
use crate::tui::app::{App, AppAction};
use crate::tui::history::{HistoryCell, history_cells_from_message};
use crate::tui::session_picker::SessionPickerView;
use super::CommandResult;
/// Save session to file
pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
let save_path = if let Some(p) = path {
PathBuf::from(p)
} else {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
PathBuf::from(format!("session_{timestamp}.json"))
};
let messages = app.api_messages.clone();
let session = create_saved_session_with_mode(
&messages,
&app.model,
&app.workspace,
u64::from(app.total_tokens),
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
let sessions_dir = save_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
match std::fs::create_dir_all(&sessions_dir) {
Ok(()) => {
let json = match serde_json::to_string_pretty(&session) {
Ok(j) => j,
Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")),
};
match std::fs::write(&save_path, json) {
Ok(()) => {
app.current_session_id = Some(session.metadata.id.clone());
CommandResult::message(format!(
"Session saved to {} (ID: {})",
save_path.display(),
&session.metadata.id[..8]
))
}
Err(e) => CommandResult::error(format!("Failed to save session: {e}")),
}
}
Err(e) => CommandResult::error(format!("Failed to create directory: {e}")),
}
}
/// Load session from file
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
let load_path = if let Some(p) = path {
if p.contains('/') || p.contains('\\') {
PathBuf::from(p)
} else {
app.workspace.join(p)
}
} else {
return CommandResult::error("Usage: /load <path>");
};
let content = match std::fs::read_to_string(&load_path) {
Ok(c) => c,
Err(e) => {
return CommandResult::error(format!("Failed to read session file: {e}"));
}
};
let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
return CommandResult::error(format!("Failed to parse session file: {e}"));
}
};
app.api_messages.clone_from(&session.messages);
app.clear_history();
let cells_to_add: Vec<_> = app
.api_messages
.iter()
.flat_map(history_cells_from_message)
.collect();
app.extend_history(cells_to_add);
app.mark_history_updated();
app.transcript_selection.clear();
app.model.clone_from(&session.metadata.model);
app.update_model_compaction_budget();
app.workspace.clone_from(&session.metadata.workspace);
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.total_conversation_tokens = app.total_tokens;
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
app.current_session_id = Some(session.metadata.id.clone());
if let Some(sp) = session.system_prompt {
app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
}
app.scroll_to_bottom();
CommandResult::with_message_and_action(
format!(
"Session loaded from {} (ID: {}, {} messages)",
load_path.display(),
&session.metadata.id[..8],
session.metadata.message_count
),
crate::tui::app::AppAction::SyncSession {
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
},
)
}
/// Trigger context compaction
pub fn compact(_app: &mut App) -> CommandResult {
// Trigger immediate compaction via engine
CommandResult::with_message_and_action(
"Context compaction triggered...".to_string(),
AppAction::CompactContext,
)
}
/// Export conversation to markdown
pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
let export_path = path.map_or_else(
|| {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
PathBuf::from(format!("chat_export_{timestamp}.md"))
},
PathBuf::from,
);
let mut content = String::new();
content.push_str("# Chat Export\n\n");
let _ = write!(
content,
"**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n",
app.model,
app.workspace.display(),
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
);
for cell in &app.history {
let (role, body) = match cell {
HistoryCell::User { content } => ("**You:**", content.clone()),
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
HistoryCell::System { content } => ("*System:*", content.clone()),
HistoryCell::Error { message, severity } => match severity {
crate::error_taxonomy::ErrorSeverity::Warning => ("**Warning:**", message.clone()),
crate::error_taxonomy::ErrorSeverity::Info => ("*Info:*", message.clone()),
_ => ("**Error:**", message.clone()),
},
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)),
HistoryCell::ArchivedContext {
level,
range,
summary,
..
} => (
"**Archived Context:**",
format!("L{level} [{range}]: {summary}"),
),
};
let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim());
}
match std::fs::write(&export_path, content) {
Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())),
Err(e) => CommandResult::error(format!("Failed to export: {e}")),
}
}
/// Open the session picker UI
pub fn sessions(app: &mut App) -> CommandResult {
app.view_stack.push(SessionPickerView::new());
CommandResult::ok()
}
fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String {
tool.lines(width)
.into_iter()
.map(line_to_string)
.collect::<Vec<_>>()
.join("\n")
}
fn render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String {
cell.lines(width)
.into_iter()
.map(line_to_string)
.collect::<Vec<_>>()
.join("\n")
}
fn line_to_string(line: ratatui::text::Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_save_creates_file_and_sets_session_id() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let save_path = tmpdir.path().join("test_session.json");
let result = save(&mut app, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session saved to"));
assert!(msg.contains("ID:"));
assert!(app.current_session_id.is_some());
assert!(save_path.exists());
}
#[test]
fn test_save_with_default_path_uses_workspace() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = save(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
// Should create file in workspace with timestamp name
// Give it a moment to ensure file is written
std::thread::sleep(std::time::Duration::from_millis(10));
let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
.collect();
// Test passes if file was created or if save returned success message
assert!(!entries.is_empty() || msg.contains("Session saved"));
}
#[test]
fn test_save_serialization_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// This should work normally since SavedSession is serializable
// Testing error path would require mocking, which is complex
let save_path = tmpdir.path().join("test.json");
let result = save(&mut app, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
}
#[test]
fn test_load_without_path_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /load"));
}
#[test]
fn test_load_nonexistent_file_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app, Some("nonexistent.json"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Failed to read"));
}
#[test]
fn test_load_invalid_json_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let bad_file = tmpdir.path().join("bad.json");
std::fs::write(&bad_file, "not valid json").unwrap();
let result = load(&mut app, Some(bad_file.to_str().unwrap()));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Failed to parse"));
}
#[test]
fn test_load_valid_session_restores_state() {
let tmpdir = TempDir::new().unwrap();
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
// Set up some state to save
app1.api_messages.push(crate::models::Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app1.total_tokens = 500;
let save_path = tmpdir.path().join("test.json");
save(&mut app1, Some(save_path.to_str().unwrap()));
// Create new app and load
let mut app2 = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session loaded from"));
assert!(msg.contains("ID:"));
assert!(msg.contains("messages"));
assert_eq!(app2.api_messages.len(), 1);
assert_eq!(app2.total_tokens, 500);
assert!(app2.current_session_id.is_some());
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
}
#[test]
fn test_compact_toggles_state() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = compact(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("compaction") || msg.contains("Compact"));
assert!(matches!(result.action, Some(AppAction::CompactContext)));
}
#[test]
fn test_export_crees_markdown_file() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Hi there".to_string(),
streaming: false,
});
let export_path = tmpdir.path().join("export.md");
let result = export(&mut app, Some(export_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Exported to"));
assert!(export_path.exists());
let content = std::fs::read_to_string(&export_path).unwrap();
assert!(content.contains("# Chat Export"));
assert!(content.contains("**Model:**"));
assert!(content.contains("**You:**"));
assert!(content.contains("**Assistant:**"));
}
#[test]
fn test_export_with_default_path() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = export(&mut app, None);
assert!(result.message.is_some());
// Should create file with timestamp name in current dir
let entries: Vec<_> = std::fs::read_dir(".")
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
.collect();
// Clean up
for entry in &entries {
let _ = std::fs::remove_file(entry.path());
}
assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
}
#[test]
fn test_sessions_pushes_picker_view() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let initial_kind = app.view_stack.top_kind();
let result = sessions(&mut app);
assert_eq!(result.message, None);
assert!(result.action.is_none());
// View should have changed (session picker should be on top)
assert_ne!(app.view_stack.top_kind(), initial_kind);
}
}
+474
View File
@@ -0,0 +1,474 @@
//! Skills commands: skills, skill
use std::fmt::Write;
use crate::network_policy::NetworkPolicy;
use crate::skills::SkillRegistry;
use crate::skills::install::{
self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource,
RegistryFetchResult, UpdateResult,
};
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
use super::CommandResult;
fn render_skill_warnings(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
return String::new();
}
let mut out = String::new();
let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
for warning in registry.warnings() {
let _ = writeln!(out, " - {warning}");
}
out
}
/// List all available skills. Pass `--remote` (or `remote`) to fetch the
/// curated registry instead of scanning the local skills directory.
pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
if let Some(arg) = arg {
let trimmed = arg.trim();
if trimmed == "--remote" || trimmed == "remote" {
return list_remote_skills(app);
}
if !trimmed.is_empty() {
return CommandResult::error("Usage: /skills [--remote]");
}
}
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let warnings = render_skill_warnings(&registry);
if registry.is_empty() {
let msg = format!(
"No skills found.\n\n\
Skills location: {}\n\n\
To add skills, create directories with SKILL.md files:\n \
{}/my-skill/SKILL.md\n\n\
Format:\n \
---\n \
name: my-skill\n \
description: What this skill does\n \
allowed-tools: read_file, list_dir\n \
---\n\n \
<instructions here>{warnings}",
skills_dir.display(),
skills_dir.display()
);
return CommandResult::message(msg);
}
let mut output = format!("Available skills ({}):\n", registry.len());
output.push_str("─────────────────────────────\n");
for skill in registry.list() {
let _ = writeln!(output, " /{} - {}", skill.name, skill.description);
}
let _ = write!(
output,
"\nUse /skill <name> to run a skill\nSkills location: {}{}",
skills_dir.display(),
warnings
);
CommandResult::message(output)
}
/// Run a specific skill — activates skill for next user message, or
/// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
let raw = match name {
Some(n) => n.trim(),
None => {
return CommandResult::error(
"Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>",
);
}
};
// Sub-command dispatch happens before the activation path so users can't
// accidentally activate a skill literally named "install".
let mut iter = raw.splitn(2, char::is_whitespace);
let head = iter.next().unwrap_or("").trim();
let rest = iter.next().unwrap_or("").trim();
match head {
"install" => return install_skill(app, rest),
"update" => return update_skill(app, rest),
"uninstall" => return uninstall_skill(app, rest),
"trust" => return trust_skill(app, rest),
_ => {}
}
activate_skill(app, raw)
}
fn activate_skill(app: &mut App, name: &str) -> CommandResult {
// `/skill new` is a friendly alias for `/skill skill-creator`.
let name = if name == "new" { "skill-creator" } else { name };
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
if let Some(skill) = registry.get(name) {
let instruction = format!(
"You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
skill.name, skill.body
);
app.add_message(HistoryCell::System {
content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
});
app.active_skill = Some(instruction);
CommandResult::message(format!(
"Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.",
skill.name, skill.description
))
} else {
let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
let warnings = render_skill_warnings(&registry);
if available.is_empty() {
CommandResult::error(format!(
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
))
} else {
CommandResult::error(format!(
"Skill '{}' not found.\n\nAvailable skills: {}{}",
name,
available.join(", "),
warnings
))
}
}
}
// ─── /skill install ────────────────────────────────────────────────────────
fn install_skill(app: &mut App, spec: &str) -> CommandResult {
if spec.is_empty() {
return CommandResult::error(
"Usage: /skill install <github:owner/repo|https://…|<registry-name>>",
);
}
let source = match InstallSource::parse(spec) {
Ok(s) => s,
Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
};
let skills_dir = app.skills_dir.clone();
let (network, max_size, registry_url) = installer_settings(app);
let outcome = run_async(async move {
install::install_with_registry(
source,
&skills_dir,
max_size,
&network,
false,
&registry_url,
)
.await
});
match outcome {
Ok(InstallOutcome::Installed(installed)) => {
let path_str = path_or_default(&installed.path);
CommandResult::message(format!(
"Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.",
installed.name, spec, path_str
))
}
Ok(InstallOutcome::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(InstallOutcome::NetworkDenied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
}
}
// ─── /skill update ─────────────────────────────────────────────────────────
fn update_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill update <name>");
}
let skills_dir = app.skills_dir.clone();
let (network, max_size, registry_url) = installer_settings(app);
let owned_name = name.to_string();
let outcome = run_async(async move {
install::update_with_registry(&owned_name, &skills_dir, max_size, &network, &registry_url)
.await
});
match outcome {
Ok(UpdateResult::NoChange) => {
CommandResult::message(format!("Skill '{name}': no upstream change."))
}
Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!(
"Skill '{}' updated. Location: {}",
installed.name,
path_or_default(&installed.path)
)),
Ok(UpdateResult::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(UpdateResult::NetworkDenied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
}
}
// ─── /skill uninstall ──────────────────────────────────────────────────────
fn uninstall_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill uninstall <name>");
}
match install::uninstall(name, &app.skills_dir) {
Ok(()) => CommandResult::message(format!("Removed skill '{name}'.")),
Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
}
}
// ─── /skill trust ──────────────────────────────────────────────────────────
fn trust_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill trust <name>");
}
match install::trust(name, &app.skills_dir) {
Ok(()) => CommandResult::message(format!(
"Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/."
)),
Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
}
}
// ─── /skills --remote ──────────────────────────────────────────────────────
/// List skills available in the configured curated registry.
pub fn list_remote_skills(app: &mut App) -> CommandResult {
let (network, _max_size, registry_url) = installer_settings(app);
let registry = run_async(async move { install::fetch_registry(&network, &registry_url).await });
match registry {
Ok(RegistryFetchResult::Loaded(doc)) => {
if doc.skills.is_empty() {
return CommandResult::message("Registry is empty.");
}
let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
out.push_str("─────────────────────────────\n");
for (name, entry) in &doc.skills {
let _ = writeln!(
out,
" {name} — {} (source: {})",
entry.description.clone().unwrap_or_default(),
entry.source
);
}
let _ = write!(out, "\nInstall with: /skill install <name>");
CommandResult::message(out)
}
Ok(RegistryFetchResult::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(RegistryFetchResult::Denied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Failed to fetch registry: {err:#}")),
}
}
// ─── helpers ───────────────────────────────────────────────────────────────
/// Read the active config knobs for the installer.
///
/// We load `Config::load` on demand because [`App`] does not carry a `Config`
/// field — and loading is cheap (small TOML file) compared to the network
/// round-trip the install/update operation will incur next. If the config
/// fails to parse, we fall back to defaults so the user still gets a
/// network-gated install rather than a silent crash.
fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
let cfg = crate::config::Config::load(None, None).unwrap_or_default();
let network = cfg
.network
.clone()
.map(|policy| policy.into_runtime())
.unwrap_or_default();
let skills_cfg = cfg.skills.as_ref();
let max_size = skills_cfg
.and_then(|s| s.max_install_size_bytes)
.unwrap_or(DEFAULT_MAX_SIZE_BYTES);
let registry_url = skills_cfg
.and_then(|s| s.registry_url.clone())
.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
(network, max_size, registry_url)
}
fn run_async<F, T>(future: F) -> T
where
F: std::future::Future<Output = T>,
{
// We're on the TUI's thread, which is part of the multi-threaded runtime.
// `block_in_place` + `Handle::current().block_on` is the pattern used by
// `commands/cycle.rs` to bridge sync slash-command handlers back into the
// async ecosystem.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}
fn path_or_default(path: &std::path::Path) -> String {
path.file_name()
.map(|n| {
// Display with parent so the user sees the full skill location.
// We intentionally use `display()` here because it's just for
// user-facing output, not for path comparisons.
let parent = path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
if parent.is_empty() {
n.to_string_lossy().to_string()
} else {
format!("{parent}/{}", n.to_string_lossy())
}
})
.unwrap_or_else(|| path.display().to_string())
}
fn needs_approval_message(host: &str) -> String {
format!(
"Network policy requires approval for {host}.\n\
Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.deepseek/config.toml), then retry."
)
}
fn network_denied_message(host: &str) -> String {
format!(
"Network policy denied access to {host}.\n\
Remove the deny entry from ~/.deepseek/config.toml under [network] or contact your administrator."
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
let skill_dir = tmpdir.path().join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
}
#[test]
fn test_list_skills_empty_directory() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No skills found"));
assert!(msg.contains("Skills location:"));
}
#[test]
fn test_list_skills_with_skills() {
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
"test-skill",
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Available skills"));
assert!(msg.contains("/test-skill"));
}
#[test]
fn test_skill_subcommand_dispatch_install_usage() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Empty install spec → usage hint, not invalid-source error.
let result = run_skill(&mut app, Some("install"));
let msg = result.message.unwrap();
assert!(msg.contains("/skill install"), "got: {msg}");
}
#[test]
fn test_skill_subcommand_dispatch_uninstall_missing() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("uninstall absent-skill"));
let msg = result.message.unwrap();
assert!(msg.contains("not installed"), "got: {msg}");
}
#[test]
fn test_run_skill_without_name() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /skill"));
}
#[test]
fn test_run_skill_not_found() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("nonexistent"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("not found"));
}
#[test]
fn test_run_skill_activates() {
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
"test-skill",
"---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("test-skill"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Skill 'test-skill' activated"));
assert!(msg.contains("A test skill"));
assert!(app.active_skill.is_some());
assert!(!app.history.is_empty());
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Task commands: add/list/show/cancel
use crate::tui::app::{App, AppAction};
use super::CommandResult;
pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
return CommandResult::action(AppAction::TaskList);
}
let mut parts = raw.splitn(2, char::is_whitespace);
let action = parts.next().unwrap_or("").to_ascii_lowercase();
let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty());
match action.as_str() {
"add" => {
let Some(prompt) = remainder else {
return CommandResult::error("Usage: /task add <prompt>");
};
CommandResult::action(AppAction::TaskAdd {
prompt: prompt.to_string(),
})
}
"list" => CommandResult::action(AppAction::TaskList),
"show" => {
let Some(id) = remainder else {
return CommandResult::error("Usage: /task show <id>");
};
CommandResult::action(AppAction::TaskShow { id: id.to_string() })
}
"cancel" | "stop" => {
let Some(id) = remainder else {
return CommandResult::error("Usage: /task cancel <id>");
};
CommandResult::action(AppAction::TaskCancel { id: id.to_string() })
}
_ => CommandResult::error("Usage: /task [add <prompt>|list|show <id>|cancel <id>]"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn app() -> App {
App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 2,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn parses_add_and_cancel() {
let mut app = app();
let add = task(&mut app, Some("add write tests"));
assert!(matches!(
add.action,
Some(AppAction::TaskAdd { prompt }) if prompt == "write tests"
));
let cancel = task(&mut app, Some("cancel task_1234"));
assert!(matches!(
cancel.action,
Some(AppAction::TaskCancel { id }) if id == "task_1234"
));
}
#[test]
fn validates_usage() {
let mut app = app();
let result = task(&mut app, Some("add"));
assert!(result.message.is_some());
assert!(result.action.is_none());
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
//! Capacity-aware guardrail controller for context pressure management.
use std::collections::{HashMap, VecDeque};
/// Controller settings.
#[derive(Debug, Clone, PartialEq)]
pub struct CapacityControllerConfig {
pub enabled: bool,
pub low_risk_max: f64,
pub medium_risk_max: f64,
pub severe_min_slack: f64,
pub severe_violation_ratio: f64,
pub refresh_cooldown_turns: u64,
pub replan_cooldown_turns: u64,
pub max_replay_per_turn: usize,
pub min_turns_before_guardrail: u64,
pub profile_window: usize,
pub model_priors: HashMap<String, f64>,
pub fallback_default: f64,
}
impl Default for CapacityControllerConfig {
fn default() -> Self {
let mut model_priors = HashMap::new();
model_priors.insert("deepseek_v3_2_chat".to_string(), 3.9);
model_priors.insert("deepseek_v3_2_reasoner".to_string(), 4.1);
model_priors.insert("deepseek_v4_pro".to_string(), 3.5);
model_priors.insert("deepseek_v4_flash".to_string(), 4.2);
Self {
// OFF BY DEFAULT. The capacity controller's main intervention,
// `TargetedContextRefresh`, runs `compact_messages_safe` which
// rewrites the live conversation — visually identical to the
// agent "restarting" mid-turn. Power users running V4 on a 1M
// context window simply don't need this guardrail; the failure
// mode it protects against (context overflow) is rare in
// practice and self-correcting (the model surfaces a clear
// error). Users who do want the controller back can enable it
// via `capacity.enabled = true` in `~/.deepseek/config.toml`.
enabled: false,
// Thresholds retained for the opt-in path; tuning notes live
// in git history (#63 follow-up).
low_risk_max: 0.50,
medium_risk_max: 0.62,
severe_min_slack: -0.25,
severe_violation_ratio: 0.40,
refresh_cooldown_turns: 6,
replan_cooldown_turns: 5,
max_replay_per_turn: 1,
min_turns_before_guardrail: 4,
profile_window: 8,
model_priors,
fallback_default: 3.8,
}
}
}
impl CapacityControllerConfig {
/// Build effective capacity config from app config.
#[must_use]
pub fn from_app_config(config: &crate::config::Config) -> Self {
let mut out = Self::default();
let Some(capacity) = config.capacity.as_ref() else {
return out;
};
if let Some(v) = capacity.enabled {
out.enabled = v;
}
if let Some(v) = capacity.low_risk_max {
out.low_risk_max = v;
}
if let Some(v) = capacity.medium_risk_max {
out.medium_risk_max = v;
}
if let Some(v) = capacity.severe_min_slack {
out.severe_min_slack = v;
}
if let Some(v) = capacity.severe_violation_ratio {
out.severe_violation_ratio = v;
}
if let Some(v) = capacity.refresh_cooldown_turns {
out.refresh_cooldown_turns = v;
}
if let Some(v) = capacity.replan_cooldown_turns {
out.replan_cooldown_turns = v;
}
if let Some(v) = capacity.max_replay_per_turn {
out.max_replay_per_turn = v;
}
if let Some(v) = capacity.min_turns_before_guardrail {
out.min_turns_before_guardrail = v;
}
if let Some(v) = capacity.profile_window {
out.profile_window = v.max(2);
}
if let Some(v) = capacity.deepseek_v3_2_chat_prior {
out.model_priors.insert("deepseek_v3_2_chat".to_string(), v);
}
if let Some(v) = capacity.deepseek_v3_2_reasoner_prior {
out.model_priors
.insert("deepseek_v3_2_reasoner".to_string(), v);
}
if let Some(v) = capacity.deepseek_v4_pro_prior {
out.model_priors.insert("deepseek_v4_pro".to_string(), v);
}
if let Some(v) = capacity.deepseek_v4_flash_prior {
out.model_priors.insert("deepseek_v4_flash".to_string(), v);
}
if let Some(v) = capacity.fallback_default_prior {
out.fallback_default = v;
}
out
}
}
/// Guardrail decision output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardrailAction {
NoIntervention,
TargetedContextRefresh,
VerifyWithToolReplay,
VerifyAndReplan,
}
impl GuardrailAction {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
GuardrailAction::NoIntervention => "no_intervention",
GuardrailAction::TargetedContextRefresh => "targeted_context_refresh",
GuardrailAction::VerifyWithToolReplay => "verify_with_tool_replay",
GuardrailAction::VerifyAndReplan => "verify_and_replan",
}
}
}
/// Coarse failure risk band.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskBand {
Low,
Medium,
High,
}
impl RiskBand {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
RiskBand::Low => "low",
RiskBand::Medium => "medium",
RiskBand::High => "high",
}
}
}
/// Input used to observe current turn pressure.
#[derive(Debug, Clone)]
pub struct CapacityObservationInput {
pub turn_index: u64,
pub model: String,
pub action_count_this_turn: usize,
pub tool_calls_recent_window: usize,
pub unique_reference_ids_recent_window: usize,
pub context_used_ratio: f64,
}
/// Rolling slack profile.
#[derive(Debug, Clone, Copy, Default)]
pub struct DynamicSlackProfile {
pub final_slack: f64,
pub min_slack: f64,
pub violation_ratio: f64,
pub slack_volatility: f64,
pub slack_drop: f64,
}
/// Per-checkpoint capacity snapshot.
#[derive(Debug, Clone)]
pub struct CapacitySnapshot {
pub turn_index: u64,
pub h_hat: f64,
pub c_hat: f64,
pub slack: f64,
pub profile: DynamicSlackProfile,
pub p_fail: f64,
pub risk_band: RiskBand,
pub severe: bool,
}
/// Full controller decision including reason and block flags.
#[derive(Debug, Clone)]
pub struct CapacityDecision {
pub action: GuardrailAction,
pub reason: String,
pub cooldown_blocked: bool,
}
#[derive(Debug, Clone, Default)]
struct GuardrailRuntimeState {
last_refresh_turn: Option<u64>,
last_replan_turn: Option<u64>,
replay_count_this_turn: usize,
replay_disabled_turn: Option<u64>,
intervention_applied_turn: Option<u64>,
}
/// Capacity controller.
#[derive(Debug, Clone)]
pub struct CapacityController {
config: CapacityControllerConfig,
slack_window: VecDeque<f64>,
recent_tool_counts: VecDeque<usize>,
recent_ref_counts: VecDeque<usize>,
state: GuardrailRuntimeState,
last_snapshot: Option<CapacitySnapshot>,
}
impl CapacityController {
#[must_use]
pub fn new(config: CapacityControllerConfig) -> Self {
Self {
config,
slack_window: VecDeque::new(),
recent_tool_counts: VecDeque::new(),
recent_ref_counts: VecDeque::new(),
state: GuardrailRuntimeState::default(),
last_snapshot: None,
}
}
pub fn observe_pre_turn(
&mut self,
input: CapacityObservationInput,
) -> Option<CapacitySnapshot> {
self.observe(input)
}
pub fn observe_post_tool(
&mut self,
input: CapacityObservationInput,
) -> Option<CapacitySnapshot> {
self.observe(input)
}
/// Decide intervention from the latest snapshot, with cooldown and safety gates.
#[must_use]
pub fn decide(
&mut self,
turn_index: u64,
snapshot: Option<&CapacitySnapshot>,
) -> CapacityDecision {
if !self.config.enabled {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "capacity_controller_disabled".to_string(),
cooldown_blocked: false,
};
}
let Some(snapshot) = snapshot else {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "missing_capacity_data_fail_open".to_string(),
cooldown_blocked: false,
};
};
if turn_index < self.config.min_turns_before_guardrail {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "min_turns_before_guardrail_not_reached".to_string(),
cooldown_blocked: false,
};
}
let proposed = decide_policy(&self.config, snapshot);
if proposed == GuardrailAction::NoIntervention {
return CapacityDecision {
action: proposed,
reason: "low_risk_no_intervention".to_string(),
cooldown_blocked: false,
};
}
if self
.state
.intervention_applied_turn
.is_some_and(|t| t == turn_index)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "intervention_already_applied_this_turn".to_string(),
cooldown_blocked: true,
};
}
match proposed {
GuardrailAction::TargetedContextRefresh => {
if self
.state
.last_refresh_turn
.is_some_and(|last| turn_index <= last + self.config.refresh_cooldown_turns)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "refresh_cooldown_active".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::VerifyWithToolReplay => {
if self
.state
.replay_disabled_turn
.is_some_and(|t| t == turn_index)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "replay_disabled_for_turn".to_string(),
cooldown_blocked: true,
};
}
if self.state.replay_count_this_turn >= self.config.max_replay_per_turn {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "max_replay_per_turn_reached".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::VerifyAndReplan => {
if self
.state
.last_replan_turn
.is_some_and(|last| turn_index <= last + self.config.replan_cooldown_turns)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "replan_cooldown_active".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::NoIntervention => {}
}
CapacityDecision {
action: proposed,
reason: "policy_selected_action".to_string(),
cooldown_blocked: false,
}
}
pub fn mark_turn_start(&mut self, turn_index: u64) {
let new_turn = match self.last_snapshot.as_ref() {
None => true,
Some(snapshot) => snapshot.turn_index != turn_index,
};
if new_turn {
self.state.replay_count_this_turn = 0;
self.state.replay_disabled_turn = None;
self.state.intervention_applied_turn = None;
}
}
pub fn mark_intervention_applied(&mut self, turn_index: u64, action: GuardrailAction) {
self.state.intervention_applied_turn = Some(turn_index);
match action {
GuardrailAction::TargetedContextRefresh => {
self.state.last_refresh_turn = Some(turn_index);
}
GuardrailAction::VerifyWithToolReplay => {
self.state.replay_count_this_turn =
self.state.replay_count_this_turn.saturating_add(1);
}
GuardrailAction::VerifyAndReplan => {
self.state.last_replan_turn = Some(turn_index);
}
GuardrailAction::NoIntervention => {}
}
}
pub fn mark_replay_failed(&mut self, turn_index: u64) {
self.state.replay_disabled_turn = Some(turn_index);
}
#[must_use]
pub fn last_snapshot(&self) -> Option<&CapacitySnapshot> {
self.last_snapshot.as_ref()
}
fn observe(&mut self, input: CapacityObservationInput) -> Option<CapacitySnapshot> {
if !self.config.enabled {
return None;
}
let context_used_ratio = input.context_used_ratio.clamp(0.0, 2.0);
let action_complexity_bits = log2_1p(input.action_count_this_turn);
let tool_complexity_bits = log2_1p(input.tool_calls_recent_window);
let ref_complexity_bits = log2_1p(input.unique_reference_ids_recent_window);
let context_pressure_bits = 6.0 * context_used_ratio;
let h_hat = (0.35 * action_complexity_bits)
+ (0.30 * tool_complexity_bits)
+ (0.20 * ref_complexity_bits)
+ (0.15 * context_pressure_bits);
let c_hat = self.model_prior(&input.model);
let slack = c_hat - h_hat;
push_window(&mut self.slack_window, slack, self.config.profile_window);
push_window(
&mut self.recent_tool_counts,
input.tool_calls_recent_window,
self.config.profile_window,
);
push_window(
&mut self.recent_ref_counts,
input.unique_reference_ids_recent_window,
self.config.profile_window,
);
let profile = compute_profile(&self.slack_window);
let z = (-1.65 * profile.final_slack)
+ (-0.85 * profile.min_slack)
+ (1.35 * profile.violation_ratio)
+ (0.70 * profile.slack_volatility)
+ (0.28 * profile.slack_drop)
- 0.12;
let p_fail = sigmoid(z).clamp(0.0, 1.0);
let risk_band = if p_fail <= self.config.low_risk_max {
RiskBand::Low
} else if p_fail <= self.config.medium_risk_max {
RiskBand::Medium
} else {
RiskBand::High
};
let severe = profile.min_slack <= self.config.severe_min_slack
|| profile.violation_ratio >= self.config.severe_violation_ratio;
let snapshot = CapacitySnapshot {
turn_index: input.turn_index,
h_hat,
c_hat,
slack,
profile,
p_fail,
risk_band,
severe,
};
self.last_snapshot = Some(snapshot.clone());
Some(snapshot)
}
fn model_prior(&self, model: &str) -> f64 {
let normalized = normalize_model_prior_key(model);
self.config
.model_priors
.get(normalized)
.copied()
.unwrap_or(self.config.fallback_default)
}
}
/// Pure policy mapping for snapshot -> action.
#[must_use]
pub fn decide_policy(
_config: &CapacityControllerConfig,
snapshot: &CapacitySnapshot,
) -> GuardrailAction {
match snapshot.risk_band {
RiskBand::Low => GuardrailAction::NoIntervention,
RiskBand::Medium => GuardrailAction::TargetedContextRefresh,
RiskBand::High if snapshot.severe => GuardrailAction::VerifyAndReplan,
RiskBand::High => GuardrailAction::VerifyWithToolReplay,
}
}
fn normalize_model_prior_key(model: &str) -> &str {
// Strip optional "deepseek-ai/" NIM namespace prefix before pattern matching.
let model = model.strip_prefix("deepseek-ai/").unwrap_or(model);
let lower = model.to_ascii_lowercase();
// V4 variants must be checked before the generic V3/chat/reasoner branches
// because those branches do not contain "v4" tokens and the ordering prevents
// accidental cross-matches.
if lower.contains("v4-pro") || lower.contains("v4_pro") {
"deepseek_v4_pro"
} else if lower.contains("v4-flash") || lower.contains("v4_flash") {
"deepseek_v4_flash"
} else if lower.contains("reasoner") || lower.contains("r1") {
"deepseek_v3_2_reasoner"
} else if lower.contains("chat") || lower.contains("v3") {
"deepseek_v3_2_chat"
} else {
"fallback_default"
}
}
fn log2_1p(v: usize) -> f64 {
(1.0 + (v as f64)).log2()
}
fn push_window<T>(window: &mut VecDeque<T>, value: T, max_len: usize) {
window.push_back(value);
while window.len() > max_len {
window.pop_front();
}
}
fn compute_profile(window: &VecDeque<f64>) -> DynamicSlackProfile {
if window.is_empty() {
return DynamicSlackProfile::default();
}
let values: Vec<f64> = window.iter().copied().collect();
let final_slack = *values.last().unwrap_or(&0.0);
let min_slack = values.iter().copied().fold(f64::INFINITY, f64::min);
let violations = values.iter().filter(|v| **v <= 0.0).count() as f64;
let violation_ratio = violations / (values.len() as f64);
let deltas: Vec<f64> = values.windows(2).map(|w| w[1] - w[0]).collect();
let slack_drop = if values.len() >= 2 {
(values[values.len() - 2] - values[values.len() - 1]).max(0.0)
} else {
0.0
};
let slack_volatility = if deltas.is_empty() {
0.0
} else {
let mean = deltas.iter().sum::<f64>() / (deltas.len() as f64);
let var = deltas
.iter()
.map(|delta| {
let centered = *delta - mean;
centered * centered
})
.sum::<f64>()
/ (deltas.len() as f64);
var.sqrt()
};
DynamicSlackProfile {
final_slack,
min_slack,
violation_ratio,
slack_volatility,
slack_drop,
}
}
fn sigmoid(z: f64) -> f64 {
if z >= 0.0 {
let ez = (-z).exp();
1.0 / (1.0 + ez)
} else {
let ez = z.exp();
ez / (1.0 + ez)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_snapshot(p_fail: f64, severe: bool, risk_band: RiskBand) -> CapacitySnapshot {
CapacitySnapshot {
turn_index: 3,
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
profile: DynamicSlackProfile {
final_slack: 2.8,
min_slack: if severe { -0.5 } else { 0.2 },
violation_ratio: if severe { 0.6 } else { 0.1 },
slack_volatility: 0.2,
slack_drop: 0.1,
},
p_fail,
risk_band,
severe,
}
}
#[test]
fn low_risk_maps_to_no_intervention() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.2, false, RiskBand::Low);
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::NoIntervention);
}
#[test]
fn medium_risk_maps_to_refresh() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.5, false, RiskBand::Medium);
assert_eq!(
decide_policy(&cfg, &snap),
GuardrailAction::TargetedContextRefresh
);
}
#[test]
fn high_non_severe_maps_to_replay() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.8, false, RiskBand::High);
assert_eq!(
decide_policy(&cfg, &snap),
GuardrailAction::VerifyWithToolReplay
);
}
#[test]
fn high_severe_maps_to_replan() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.9, true, RiskBand::High);
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::VerifyAndReplan);
}
#[test]
fn default_controller_is_disabled_and_does_not_observe() {
let cfg = CapacityControllerConfig::default();
assert!(!cfg.enabled);
let mut controller = CapacityController::new(cfg);
let snapshot = controller.observe_pre_turn(CapacityObservationInput {
turn_index: 1,
model: "deepseek-v4-pro".to_string(),
action_count_this_turn: 10,
tool_calls_recent_window: 10,
unique_reference_ids_recent_window: 10,
context_used_ratio: 0.95,
});
assert!(snapshot.is_none());
let decision = controller.decide(1, snapshot.as_ref());
assert_eq!(decision.action, GuardrailAction::NoIntervention);
assert_eq!(decision.reason, "capacity_controller_disabled");
}
#[test]
fn app_config_without_capacity_keeps_controller_disabled() {
let cfg = CapacityControllerConfig::from_app_config(&crate::config::Config::default());
assert!(!cfg.enabled);
assert_eq!(cfg.low_risk_max, 0.50);
assert_eq!(cfg.refresh_cooldown_turns, 6);
assert_eq!(cfg.min_turns_before_guardrail, 4);
assert_eq!(cfg.model_priors.get("deepseek_v4_pro"), Some(&3.5));
assert_eq!(cfg.model_priors.get("deepseek_v4_flash"), Some(&4.2));
}
#[test]
fn normalize_v4_pro_variants() {
assert_eq!(
normalize_model_prior_key("deepseek-v4-pro"),
"deepseek_v4_pro"
);
assert_eq!(
normalize_model_prior_key("deepseek-v4_pro"),
"deepseek_v4_pro"
);
assert_eq!(
normalize_model_prior_key("deepseek-ai/deepseek-v4-pro"),
"deepseek_v4_pro"
);
assert_eq!(
normalize_model_prior_key("deepseek-ai/deepseek-v4_pro"),
"deepseek_v4_pro"
);
}
#[test]
fn normalize_v4_flash_variants() {
assert_eq!(
normalize_model_prior_key("deepseek-v4-flash"),
"deepseek_v4_flash"
);
assert_eq!(
normalize_model_prior_key("deepseek-v4_flash"),
"deepseek_v4_flash"
);
assert_eq!(
normalize_model_prior_key("deepseek-ai/deepseek-v4-flash"),
"deepseek_v4_flash"
);
assert_eq!(
normalize_model_prior_key("deepseek-ai/deepseek-v4_flash"),
"deepseek_v4_flash"
);
}
#[test]
fn normalize_v3_and_reasoner_unchanged() {
assert_eq!(
normalize_model_prior_key("deepseek-chat"),
"deepseek_v3_2_chat"
);
assert_eq!(
normalize_model_prior_key("deepseek-v3-chat"),
"deepseek_v3_2_chat"
);
assert_eq!(
normalize_model_prior_key("deepseek-reasoner"),
"deepseek_v3_2_reasoner"
);
assert_eq!(
normalize_model_prior_key("deepseek-r1"),
"deepseek_v3_2_reasoner"
);
assert_eq!(
normalize_model_prior_key("unknown-model"),
"fallback_default"
);
}
#[test]
fn v4_priors_loaded_into_default_config() {
let cfg = CapacityControllerConfig::default();
assert_eq!(cfg.model_priors.get("deepseek_v4_pro").copied(), Some(3.5));
assert_eq!(
cfg.model_priors.get("deepseek_v4_flash").copied(),
Some(4.2)
);
}
#[test]
fn cooldown_blocks_repeated_action() {
// Capacity controller is opt-in (off by default since v0.6.2). This
// test exercises the cooldown logic, so explicitly enable it.
let config = CapacityControllerConfig {
enabled: true,
..CapacityControllerConfig::default()
};
let mut controller = CapacityController::new(config);
let turn_index = 5;
controller.mark_turn_start(turn_index);
controller.mark_intervention_applied(turn_index, GuardrailAction::TargetedContextRefresh);
let snapshot = make_snapshot(0.5, false, RiskBand::Medium);
let decision = controller.decide(turn_index + 1, Some(&snapshot));
assert_eq!(decision.action, GuardrailAction::NoIntervention);
assert!(decision.cooldown_blocked);
}
/// Hot-path microbench for `compute_profile`. Run with:
///
/// ```text
/// cargo test -p deepseek-tui --release capacity::tests::bench_compute_profile -- --ignored --nocapture
/// ```
///
/// Establishes a baseline cost so we can detect regressions when the
/// observation cadence is high (50+ message turns × per-step calls). Adds
/// no dev-deps; we measure with `Instant` and print rather than gating CI.
#[test]
#[ignore]
fn bench_compute_profile() {
use std::time::Instant;
for &window_len in &[16usize, 64, 256, 1024] {
let mut window: VecDeque<f64> = VecDeque::with_capacity(window_len);
for i in 0..window_len {
#[allow(clippy::cast_precision_loss)]
window.push_back((i as f64).sin() * 0.5);
}
let iters = 100_000usize;
let start = Instant::now();
for _ in 0..iters {
let profile = compute_profile(&window);
std::hint::black_box(profile);
}
let elapsed = start.elapsed();
let per_call_ns = elapsed.as_nanos() as f64 / iters as f64;
println!(
"compute_profile window={window_len:>4} total={:?} per-call={per_call_ns:>8.0}ns",
elapsed
);
}
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Persistent memory snapshots for capacity controller interventions.
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
/// Canonical compact state persisted by interventions.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CanonicalState {
pub goal: String,
pub constraints: Vec<String>,
pub confirmed_facts: Vec<String>,
pub open_loops: Vec<String>,
pub pending_actions: Vec<String>,
pub critical_refs: Vec<String>,
}
/// Replay verification metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayInfo {
pub tool_id: String,
pub tool_name: String,
pub pass: bool,
pub diff_summary: String,
}
/// JSONL record written for each intervention.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapacityMemoryRecord {
pub id: String,
pub ts: String,
pub turn_index: u64,
pub action_trigger: String,
pub h_hat: f64,
pub c_hat: f64,
pub slack: f64,
pub risk_band: String,
pub canonical_state: CanonicalState,
pub source_message_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replay_info: Option<ReplayInfo>,
}
fn capacity_memory_dirs() -> Vec<PathBuf> {
if let Ok(raw) = std::env::var("DEEPSEEK_CAPACITY_MEMORY_DIR") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return vec![PathBuf::from(shellexpand::tilde(trimmed).as_ref())];
}
}
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".deepseek").join("memory"));
}
let cwd = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".deepseek")
.join("memory");
dirs.push(cwd);
dirs.dedup();
dirs
}
pub fn append_capacity_record(session_id: &str, record: &CapacityMemoryRecord) -> Result<PathBuf> {
let candidates = candidate_session_memory_paths(session_id);
append_capacity_record_to_candidates(&candidates, record)
}
pub fn append_capacity_record_to_path(path: &Path, record: &CapacityMemoryRecord) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create memory directory {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
let line =
serde_json::to_string(record).context("Failed to serialize capacity memory record")?;
writeln!(file, "{line}")
.with_context(|| format!("Failed to write memory record {}", path.display()))?;
Ok(())
}
pub fn load_last_k_capacity_records(
session_id: &str,
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
let candidates = candidate_session_memory_paths(session_id);
load_last_k_capacity_records_from_candidates(&candidates, k)
}
pub fn load_last_k_capacity_records_from_path(
path: &Path,
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
if k == 0 || !path.exists() {
return Ok(Vec::new());
}
let file = OpenOptions::new()
.read(true)
.open(path)
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
let reader = BufReader::new(file);
let mut records = Vec::new();
for line in reader.lines() {
let line = line.with_context(|| format!("Failed reading {}", path.display()))?;
if line.trim().is_empty() {
continue;
}
if let Ok(record) = serde_json::from_str::<CapacityMemoryRecord>(&line) {
records.push(record);
}
}
if records.len() > k {
Ok(records.split_off(records.len() - k))
} else {
Ok(records)
}
}
fn candidate_session_memory_paths(session_id: &str) -> Vec<PathBuf> {
capacity_memory_dirs()
.into_iter()
.map(|dir| dir.join(format!("{session_id}.jsonl")))
.collect()
}
fn append_capacity_record_to_candidates(
paths: &[PathBuf],
record: &CapacityMemoryRecord,
) -> Result<PathBuf> {
let mut last_err: Option<anyhow::Error> = None;
for path in paths {
match append_capacity_record_to_path(path, record) {
Ok(()) => return Ok(path.clone()),
Err(err) => last_err = Some(err),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("No capacity memory path candidates available")))
}
fn load_last_k_capacity_records_from_candidates(
paths: &[PathBuf],
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
if k == 0 {
return Ok(Vec::new());
}
let mut newest: Option<(SystemTime, Vec<CapacityMemoryRecord>)> = None;
let mut last_err: Option<anyhow::Error> = None;
for path in paths {
if !path.exists() {
continue;
}
match load_last_k_capacity_records_from_path(path, k) {
Ok(records) => {
if records.is_empty() {
continue;
}
let modified = fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let should_replace = newest
.as_ref()
.map(|(current, _)| modified >= *current)
.unwrap_or(true);
if should_replace {
newest = Some((modified, records));
}
}
Err(err) => last_err = Some(err),
}
}
if let Some((_, records)) = newest {
return Ok(records);
}
if let Some(err) = last_err {
return Err(err);
}
Ok(Vec::new())
}
#[must_use]
pub fn new_record_id() -> String {
format!("cap_{}", &uuid::Uuid::new_v4().to_string()[..8])
}
#[must_use]
pub fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn memory_jsonl_round_trip() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("session.jsonl");
let record = CapacityMemoryRecord {
id: "cap_1".to_string(),
ts: now_rfc3339(),
turn_index: 2,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.2,
c_hat: 3.8,
slack: 2.6,
risk_band: "medium".to_string(),
canonical_state: CanonicalState {
goal: "Ship feature".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
append_capacity_record_to_path(&path, &record).expect("append");
let records = load_last_k_capacity_records_from_path(&path, 1).expect("load");
assert_eq!(records.len(), 1);
assert_eq!(records[0].canonical_state.goal, "Ship feature");
}
#[test]
fn append_falls_back_to_next_candidate_path() {
let tmp = tempdir().expect("tempdir");
let blocked_root = tmp.path().join("blocked");
fs::write(&blocked_root, "file").expect("create blocking file");
let blocked_path = blocked_root.join("session.jsonl");
let fallback_path = tmp.path().join("fallback").join("session.jsonl");
let record = CapacityMemoryRecord {
id: "cap_fallback".to_string(),
ts: now_rfc3339(),
turn_index: 1,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
risk_band: "medium".to_string(),
canonical_state: CanonicalState::default(),
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
let chosen = append_capacity_record_to_candidates(
&[blocked_path.clone(), fallback_path.clone()],
&record,
)
.expect("append with fallback");
assert_eq!(chosen, fallback_path);
assert!(chosen.exists());
}
#[test]
fn load_prefers_newest_candidate_records() {
let tmp = tempdir().expect("tempdir");
let older = tmp.path().join("older.jsonl");
let newer = tmp.path().join("newer.jsonl");
let old_record = CapacityMemoryRecord {
id: "cap_old".to_string(),
ts: now_rfc3339(),
turn_index: 1,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
risk_band: "medium".to_string(),
canonical_state: CanonicalState {
goal: "old".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
let new_record = CapacityMemoryRecord {
id: "cap_new".to_string(),
ts: now_rfc3339(),
turn_index: 2,
action_trigger: "verify_and_replan".to_string(),
h_hat: 1.4,
c_hat: 3.8,
slack: 2.4,
risk_band: "high".to_string(),
canonical_state: CanonicalState {
goal: "new".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m2".to_string()],
replay_info: None,
};
append_capacity_record_to_path(&older, &old_record).expect("write older");
std::thread::sleep(std::time::Duration::from_millis(10));
append_capacity_record_to_path(&newer, &new_record).expect("write newer");
let records = load_last_k_capacity_records_from_candidates(&[older, newer], 1)
.expect("load newest records");
assert_eq!(records.len(), 1);
assert_eq!(records[0].canonical_state.goal, "new");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Plain-language session coherence state derived from capacity events.
use serde::{Deserialize, Serialize};
use crate::core::capacity::{GuardrailAction, RiskBand};
/// User-facing coherence ladder for session health.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoherenceState {
#[default]
Healthy,
GettingCrowded,
RefreshingContext,
VerifyingRecentWork,
ResettingPlan,
}
impl CoherenceState {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::GettingCrowded => "getting crowded",
Self::RefreshingContext => "refreshing context",
Self::VerifyingRecentWork => "verifying recent work",
Self::ResettingPlan => "resetting plan",
}
}
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::Healthy => "The session is stable and focused.",
Self::GettingCrowded => "The session is approaching context pressure.",
Self::RefreshingContext => "The engine is refreshing context before continuing.",
Self::VerifyingRecentWork => {
"The engine is checking recent tool results before continuing."
}
Self::ResettingPlan => {
"The engine is rebuilding from canonical context and replanning."
}
}
}
}
/// Synthetic input to the coherence reducer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoherenceSignal {
CapacityDecision {
risk_band: RiskBand,
action: GuardrailAction,
cooldown_blocked: bool,
},
CapacityIntervention {
action: GuardrailAction,
},
CompactionStarted,
CompactionCompleted,
CompactionFailed,
}
/// Pure transition function for the plain-language coherence ladder.
#[must_use]
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
match signal {
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
CoherenceSignal::CapacityIntervention { action }
| CoherenceSignal::CapacityDecision { action, .. } => match action {
GuardrailAction::NoIntervention => match signal {
CoherenceSignal::CapacityDecision {
risk_band,
cooldown_blocked,
..
} => {
if cooldown_blocked {
return current;
}
match risk_band {
RiskBand::Low => CoherenceState::Healthy,
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
}
}
_ => current,
},
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
let log = [
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Low,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::TargetedContextRefresh,
cooldown_blocked: false,
},
CoherenceSignal::CompactionCompleted,
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyWithToolReplay,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyAndReplan,
cooldown_blocked: false,
},
];
let mut state = CoherenceState::Healthy;
let mut states = Vec::new();
for signal in log {
state = next_coherence_state(state, signal);
states.push(state);
}
assert_eq!(
states,
vec![
CoherenceState::Healthy,
CoherenceState::GettingCrowded,
CoherenceState::RefreshingContext,
CoherenceState::Healthy,
CoherenceState::VerifyingRecentWork,
CoherenceState::ResettingPlan,
]
);
}
}
File diff suppressed because it is too large. Load diff
+127
View File
@@ -0,0 +1,127 @@
//! Approval + user-input handshake for the agent loop.
//!
//! Extracted from `core/engine.rs` (P1.3). The agent loop blocks on these
//! two futures whenever a tool requires explicit approval (`await_tool_approval`)
//! or whenever a tool requests live user input (`await_user_input`). Channels
//! and engine state stay private to the parent module.
use crate::core::events::Event;
use crate::tools::spec::ToolError;
use crate::tools::user_input::{UserInputRequest, UserInputResponse};
use super::Engine;
#[derive(Debug, Clone)]
pub(super) enum ApprovalDecision {
Approved {
id: String,
},
Denied {
id: String,
},
/// Retry a tool with an elevated sandbox policy.
RetryWithPolicy {
id: String,
policy: crate::sandbox::SandboxPolicy,
},
}
#[derive(Debug, Clone)]
pub(super) enum UserInputDecision {
Submitted {
id: String,
response: UserInputResponse,
},
Cancelled {
id: String,
},
}
/// Result of awaiting tool approval from the user.
#[derive(Debug)]
pub(super) enum ApprovalResult {
/// User approved the tool execution.
Approved,
/// User denied the tool execution.
Denied,
/// User requested retry with an elevated sandbox policy.
RetryWithPolicy(crate::sandbox::SandboxPolicy),
}
impl Engine {
pub(super) async fn await_tool_approval(
&mut self,
tool_id: &str,
) -> Result<ApprovalResult, ToolError> {
loop {
tokio::select! {
_ = self.cancel_token.cancelled() => {
return Err(ToolError::execution_failed(
"Request cancelled while awaiting approval".to_string(),
));
}
decision = self.rx_approval.recv() => {
let Some(decision) = decision else {
return Err(ToolError::execution_failed(
"Approval channel closed".to_string(),
));
};
match decision {
ApprovalDecision::Approved { id } if id == tool_id => {
return Ok(ApprovalResult::Approved);
}
ApprovalDecision::Denied { id } if id == tool_id => {
return Ok(ApprovalResult::Denied);
}
ApprovalDecision::RetryWithPolicy { id, policy } if id == tool_id => {
return Ok(ApprovalResult::RetryWithPolicy(policy));
}
_ => continue,
}
}
}
}
}
pub(super) async fn await_user_input(
&mut self,
tool_id: &str,
request: UserInputRequest,
) -> Result<UserInputResponse, ToolError> {
let _ = self
.tx_event
.send(Event::UserInputRequired {
id: tool_id.to_string(),
request,
})
.await;
loop {
tokio::select! {
_ = self.cancel_token.cancelled() => {
return Err(ToolError::execution_failed(
"Request cancelled while awaiting user input".to_string(),
));
}
decision = self.rx_user_input.recv() => {
let Some(decision) = decision else {
return Err(ToolError::execution_failed(
"User input channel closed".to_string(),
));
};
match decision {
UserInputDecision::Submitted { id, response } if id == tool_id => {
return Ok(response);
}
UserInputDecision::Cancelled { id } if id == tool_id => {
return Err(ToolError::execution_failed(
"User input cancelled".to_string(),
));
}
_ => continue,
}
}
}
}
}
}
+973
View File
@@ -0,0 +1,973 @@
//! Capacity-controller checkpoints and interventions for the engine loop.
//!
//! Extracted from `core/engine.rs` for issue #74. The main turn loop still
//! decides when checkpoints run; this module owns the guardrail policy side
//! effects, replay verification, canonical-state persistence, and event
//! emission helpers.
use super::*;
impl Engine {
pub(super) async fn run_capacity_pre_request_checkpoint(
&mut self,
turn: &TurnContext,
client: Option<&DeepSeekClient>,
mode: AppMode,
) -> bool {
let snapshot = self
.capacity_controller
.observe_pre_turn(self.capacity_observation(turn));
let decision = self
.capacity_controller
.decide(self.turn_counter, snapshot.as_ref());
self.emit_capacity_decision(turn, snapshot.as_ref(), &decision)
.await;
if decision.action != GuardrailAction::TargetedContextRefresh {
return false;
}
self.apply_targeted_context_refresh(turn, client, mode, snapshot.as_ref())
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_capacity_post_tool_checkpoint(
&mut self,
turn: &TurnContext,
mode: AppMode,
tool_registry: Option<&crate::tools::ToolRegistry>,
tool_exec_lock: Arc<RwLock<()>>,
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
_step_error_count: usize,
_consecutive_tool_error_steps: u32,
) -> bool {
let snapshot = self
.capacity_controller
.observe_post_tool(self.capacity_observation(turn));
let decision = self
.capacity_controller
.decide(self.turn_counter, snapshot.as_ref());
self.emit_capacity_decision(turn, snapshot.as_ref(), &decision)
.await;
match decision.action {
GuardrailAction::VerifyWithToolReplay => {
let _ = self
.apply_verify_with_tool_replay(
turn,
mode,
snapshot.as_ref(),
tool_registry,
tool_exec_lock,
mcp_pool,
)
.await;
false
}
GuardrailAction::VerifyAndReplan => {
self.apply_verify_and_replan(turn, mode, snapshot.as_ref(), "high_risk_post_tool")
.await
}
GuardrailAction::NoIntervention | GuardrailAction::TargetedContextRefresh => false,
}
}
pub(super) async fn run_capacity_error_escalation_checkpoint(
&mut self,
turn: &TurnContext,
mode: AppMode,
step_error_count: usize,
consecutive_tool_error_steps: u32,
error_categories: &[ErrorCategory],
) -> bool {
if step_error_count == 0 && consecutive_tool_error_steps < 2 {
return false;
}
// Categorize this step's failures by typed `ErrorCategory` rather than
// substring-matching error strings. Context overflow always escalates;
// network / rate-limit / timeout are transient and skip escalation;
// anything else only escalates with consecutive consecutive failures.
let has_context_overflow = error_categories.contains(&ErrorCategory::InvalidInput);
let only_transient = !error_categories.is_empty()
&& error_categories.iter().all(|c| {
matches!(
c,
ErrorCategory::Network | ErrorCategory::RateLimit | ErrorCategory::Timeout
)
});
if only_transient && !has_context_overflow {
return false;
}
if !has_context_overflow && consecutive_tool_error_steps < 2 {
return false;
}
let snapshot = self
.capacity_controller
.last_snapshot()
.cloned()
.or_else(|| {
self.capacity_controller
.observe_pre_turn(self.capacity_observation(turn))
});
let Some(snapshot) = snapshot else {
return false;
};
let repeated_failures = step_error_count >= 2 || consecutive_tool_error_steps >= 2;
let mut forced = snapshot.clone();
if repeated_failures && !(snapshot.risk_band == RiskBand::High && snapshot.severe) {
forced.risk_band = RiskBand::High;
forced.severe = true;
}
let decision = self
.capacity_controller
.decide(self.turn_counter, Some(&forced));
self.emit_capacity_decision(turn, Some(&forced), &decision)
.await;
if decision.action != GuardrailAction::VerifyAndReplan {
return false;
}
let category_labels: Vec<String> = error_categories.iter().map(|c| c.to_string()).collect();
self.apply_verify_and_replan(
turn,
mode,
Some(&forced),
&format!(
"error_escalation: step_errors={}, consecutive_steps={}, categories={}",
step_error_count,
consecutive_tool_error_steps,
category_labels.join(",")
),
)
.await
}
pub(super) fn capacity_observation(&self, turn: &TurnContext) -> CapacityObservationInput {
let message_window = self.config.capacity.profile_window.max(8) * 3;
let action_count_this_turn = usize::try_from(turn.step)
.unwrap_or(usize::MAX)
.saturating_add(turn.tool_calls.len())
.saturating_add(1);
let tool_calls_recent_window = self.recent_tool_call_count(message_window);
let unique_reference_ids_recent_window =
self.recent_unique_reference_count(message_window, turn);
let context_window = usize::try_from(
context_window_for_model(&self.session.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS),
)
.unwrap_or(usize::try_from(DEFAULT_CONTEXT_WINDOW_TOKENS).unwrap_or(128_000))
.max(1);
let context_used_ratio = (self.estimated_input_tokens() as f64) / (context_window as f64);
CapacityObservationInput {
turn_index: self.turn_counter,
model: self.session.model.clone(),
action_count_this_turn,
tool_calls_recent_window,
unique_reference_ids_recent_window,
context_used_ratio,
}
}
pub(super) fn recent_tool_call_count(&self, message_window: usize) -> usize {
self.session
.messages
.iter()
.rev()
.take(message_window)
.map(|msg| {
msg.content
.iter()
.filter(|block| {
matches!(
block,
ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. }
)
})
.count()
})
.sum()
}
pub(super) fn recent_unique_reference_count(
&self,
message_window: usize,
turn: &TurnContext,
) -> usize {
let mut refs = std::collections::HashSet::new();
for msg in self.session.messages.iter().rev().take(message_window) {
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, .. } => {
refs.insert(id.clone());
}
ContentBlock::ToolResult { tool_use_id, .. } => {
refs.insert(tool_use_id.clone());
}
ContentBlock::Text { text, .. } => {
for token in text.split_whitespace() {
if token.contains('/') || token.contains('.') {
refs.insert(
token
.trim_matches(|c: char| ",.;:()[]{}".contains(c))
.to_string(),
);
}
}
}
ContentBlock::Thinking { .. }
| ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => {}
}
}
}
for tool_call in turn.tool_calls.iter().rev().take(8) {
refs.insert(tool_call.id.clone());
}
for path in self.session.working_set.top_paths(8) {
refs.insert(path);
}
refs.retain(|item| !item.is_empty());
refs.len()
}
pub(super) async fn emit_coherence_signal(
&mut self,
signal: CoherenceSignal,
reason: impl Into<String>,
) {
let next = next_coherence_state(self.coherence_state, signal);
self.coherence_state = next;
let _ = self
.tx_event
.send(Event::CoherenceState {
state: next,
label: next.label().to_string(),
description: next.description().to_string(),
reason: reason.into(),
})
.await;
}
pub(super) async fn emit_compaction_started(
&mut self,
id: String,
auto: bool,
message: String,
) {
let _ = self
.tx_event
.send(Event::CompactionStarted {
id,
auto,
message: message.clone(),
})
.await;
self.emit_coherence_signal(CoherenceSignal::CompactionStarted, message)
.await;
}
pub(super) async fn emit_compaction_completed(
&mut self,
id: String,
auto: bool,
message: String,
messages_before: Option<usize>,
messages_after: Option<usize>,
) {
let _ = self
.tx_event
.send(Event::CompactionCompleted {
id,
auto,
message: message.clone(),
messages_before,
messages_after,
})
.await;
self.emit_coherence_signal(CoherenceSignal::CompactionCompleted, message)
.await;
}
pub(super) async fn emit_compaction_failed(&mut self, id: String, auto: bool, message: String) {
let _ = self
.tx_event
.send(Event::CompactionFailed {
id,
auto,
message: message.clone(),
})
.await;
self.emit_coherence_signal(CoherenceSignal::CompactionFailed, message)
.await;
}
pub(super) async fn emit_capacity_decision(
&mut self,
turn: &TurnContext,
snapshot: Option<&CapacitySnapshot>,
decision: &CapacityDecision,
) {
let Some(snapshot) = snapshot else {
return;
};
let _ = self
.tx_event
.send(Event::CapacityDecision {
session_id: self.session.id.clone(),
turn_id: turn.id.clone(),
h_hat: snapshot.h_hat,
c_hat: snapshot.c_hat,
slack: snapshot.slack,
min_slack: snapshot.profile.min_slack,
violation_ratio: snapshot.profile.violation_ratio,
p_fail: snapshot.p_fail,
risk_band: snapshot.risk_band.as_str().to_string(),
action: decision.action.as_str().to_string(),
cooldown_blocked: decision.cooldown_blocked,
reason: decision.reason.clone(),
})
.await;
self.emit_coherence_signal(
CoherenceSignal::CapacityDecision {
risk_band: snapshot.risk_band,
action: decision.action,
cooldown_blocked: decision.cooldown_blocked,
},
format!(
"capacity_decision: risk={} action={} reason={}",
snapshot.risk_band.as_str(),
decision.action.as_str(),
decision.reason
),
)
.await;
}
pub(super) async fn emit_capacity_intervention(
&mut self,
turn: &TurnContext,
action: GuardrailAction,
before_prompt_tokens: usize,
after_prompt_tokens: usize,
replay_outcome: Option<String>,
replan_performed: bool,
) {
let _ = self
.tx_event
.send(Event::CapacityIntervention {
session_id: self.session.id.clone(),
turn_id: turn.id.clone(),
action: action.as_str().to_string(),
before_prompt_tokens,
after_prompt_tokens,
compaction_size_reduction: before_prompt_tokens.saturating_sub(after_prompt_tokens),
replay_outcome,
replan_performed,
})
.await;
self.emit_coherence_signal(
CoherenceSignal::CapacityIntervention { action },
format!("capacity_intervention: action={}", action.as_str()),
)
.await;
}
pub(super) async fn apply_targeted_context_refresh(
&mut self,
turn: &TurnContext,
client: Option<&DeepSeekClient>,
mode: AppMode,
snapshot: Option<&CapacitySnapshot>,
) -> bool {
let before_tokens = self.estimated_input_tokens();
let compaction_pins = self
.session
.working_set
.pinned_message_indices(&self.session.messages, &self.session.workspace);
let compaction_paths = self.session.working_set.top_paths(24);
let mut refreshed = false;
let should_run_summary_compaction = self.config.compaction.enabled
&& should_compact(
&self.session.messages,
&self.config.compaction,
Some(&self.session.workspace),
Some(&compaction_pins),
Some(&compaction_paths),
);
if should_run_summary_compaction && let Some(client) = client {
match compact_messages_safe(
client,
&self.session.messages,
&self.config.compaction,
Some(&self.session.workspace),
Some(&compaction_pins),
Some(&compaction_paths),
)
.await
{
Ok(result) => {
if !result.messages.is_empty() || self.session.messages.is_empty() {
self.session.messages = result.messages;
self.merge_compaction_summary(result.summary_prompt);
refreshed = true;
}
}
Err(err) => {
let _ = self
.tx_event
.send(Event::status(format!(
"Capacity refresh compaction failed: {err}. Falling back to local trim."
)))
.await;
}
}
}
if !refreshed {
let target_budget = context_input_budget(&self.session.model, TURN_MAX_OUTPUT_TOKENS)
.unwrap_or(self.config.compaction.token_threshold.max(1));
if self.estimated_input_tokens() > target_budget {
let trimmed = self.trim_oldest_messages_to_budget(target_budget);
refreshed = trimmed > 0;
}
}
if !refreshed {
return false;
}
let canonical = self.build_canonical_state(turn, None);
let source_message_ids = self.capacity_source_message_ids(turn);
let record = self.build_capacity_record(
turn,
GuardrailAction::TargetedContextRefresh,
snapshot,
canonical.clone(),
source_message_ids,
None,
);
let pointer = self
.persist_capacity_record(turn, GuardrailAction::TargetedContextRefresh, &record)
.await;
self.merge_compaction_summary(Some(self.canonical_prompt(
&canonical,
&pointer,
GuardrailAction::TargetedContextRefresh,
None,
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let after_tokens = self.estimated_input_tokens();
self.emit_capacity_intervention(
turn,
GuardrailAction::TargetedContextRefresh,
before_tokens,
after_tokens,
None,
false,
)
.await;
self.capacity_controller
.mark_intervention_applied(self.turn_counter, GuardrailAction::TargetedContextRefresh);
true
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn apply_verify_with_tool_replay(
&mut self,
turn: &TurnContext,
mode: AppMode,
snapshot: Option<&CapacitySnapshot>,
tool_registry: Option<&crate::tools::ToolRegistry>,
tool_exec_lock: Arc<RwLock<()>>,
mut mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
) -> bool {
let before_tokens = self.estimated_input_tokens();
let Some(candidate) = self.select_replay_candidate(turn, tool_registry) else {
return false;
};
if McpPool::is_mcp_tool(&candidate.name) && mcp_pool.is_none() {
mcp_pool = self.ensure_mcp_pool().await.ok();
}
let supports_parallel = if McpPool::is_mcp_tool(&candidate.name) {
mcp_tool_is_parallel_safe(&candidate.name)
} else {
tool_registry
.and_then(|registry| registry.get(&candidate.name))
.is_some_and(|spec| spec.supports_parallel())
};
let interactive = (candidate.name == "exec_shell"
&& candidate
.input
.get("interactive")
.and_then(serde_json::Value::as_bool)
== Some(true))
|| candidate.name == REQUEST_USER_INPUT_NAME;
let replay_result = Self::execute_tool_with_lock(
tool_exec_lock,
supports_parallel,
interactive,
self.tx_event.clone(),
candidate.name.clone(),
candidate.input.clone(),
tool_registry,
mcp_pool.clone(),
None,
)
.await;
let (pass, replay_outcome, diff_summary) = match replay_result {
Ok(output) => {
let original = candidate.result.as_deref().unwrap_or_default();
let replay = output.content.as_str();
let equal = original.trim() == replay.trim();
let diff = if equal {
"output_match".to_string()
} else {
format!(
"output_mismatch: original='{}' replay='{}'",
summarize_text(original, 140),
summarize_text(replay, 140)
)
};
(
equal,
if equal {
"pass".to_string()
} else {
"conflict".to_string()
},
diff,
)
}
Err(err) => {
self.capacity_controller
.mark_replay_failed(self.turn_counter);
(
false,
"error".to_string(),
format!("replay_error: {}", summarize_text(&err.to_string(), 180)),
)
}
};
let verification_note = format!(
"[verification replay] tool={} pass={} details={}",
candidate.name, pass, diff_summary
);
self.add_session_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: candidate.id.clone(),
content: verification_note.clone(),
is_error: None,
content_blocks: None,
}],
})
.await;
if !pass {
self.capacity_controller
.mark_replay_failed(self.turn_counter);
}
let canonical = self.build_canonical_state(
turn,
Some(if pass {
"replay verification passed"
} else {
"replay verification failed or conflicted"
}),
);
let replay_info = Some(ReplayInfo {
tool_id: candidate.id.clone(),
tool_name: candidate.name.clone(),
pass,
diff_summary: diff_summary.clone(),
});
let source_message_ids = self.capacity_source_message_ids(turn);
let record = self.build_capacity_record(
turn,
GuardrailAction::VerifyWithToolReplay,
snapshot,
canonical.clone(),
source_message_ids,
replay_info,
);
let pointer = self
.persist_capacity_record(turn, GuardrailAction::VerifyWithToolReplay, &record)
.await;
self.merge_compaction_summary(Some(self.canonical_prompt(
&canonical,
&pointer,
GuardrailAction::VerifyWithToolReplay,
Some(&verification_note),
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let after_tokens = self.estimated_input_tokens();
self.emit_capacity_intervention(
turn,
GuardrailAction::VerifyWithToolReplay,
before_tokens,
after_tokens,
Some(replay_outcome),
false,
)
.await;
self.capacity_controller
.mark_intervention_applied(self.turn_counter, GuardrailAction::VerifyWithToolReplay);
true
}
pub(super) async fn apply_verify_and_replan(
&mut self,
turn: &TurnContext,
mode: AppMode,
snapshot: Option<&CapacitySnapshot>,
reason: &str,
) -> bool {
let before_tokens = self.estimated_input_tokens();
let canonical = self.build_canonical_state(turn, Some(reason));
let source_message_ids = self.capacity_source_message_ids(turn);
let record = self.build_capacity_record(
turn,
GuardrailAction::VerifyAndReplan,
snapshot,
canonical.clone(),
source_message_ids,
None,
);
let pointer = self
.persist_capacity_record(turn, GuardrailAction::VerifyAndReplan, &record)
.await;
let latest_user = self
.session
.messages
.iter()
.rev()
.find(|msg| {
msg.role == "user"
&& msg
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text { .. }))
})
.cloned();
let latest_verified = self
.session
.messages
.iter()
.rev()
.find(|msg| {
msg.role == "user"
&& msg.content.iter().any(|block| match block {
ContentBlock::ToolResult { content, .. } => {
content.contains("[verification replay]")
}
_ => false,
})
})
.cloned();
self.session.messages.clear();
if let Some(msg) = latest_user {
self.session.messages.push(msg);
}
if let Some(msg) = latest_verified {
self.session.messages.push(msg);
}
self.merge_compaction_summary(Some(self.canonical_prompt(
&canonical,
&pointer,
GuardrailAction::VerifyAndReplan,
Some("Replan now from canonical state. Keep steps minimal and verifiable."),
)));
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
let _ = self
.tx_event
.send(Event::status(
"Capacity guardrail: context reset to canonical state; replanning step."
.to_string(),
))
.await;
let after_tokens = self.estimated_input_tokens();
self.emit_capacity_intervention(
turn,
GuardrailAction::VerifyAndReplan,
before_tokens,
after_tokens,
None,
true,
)
.await;
self.capacity_controller
.mark_intervention_applied(self.turn_counter, GuardrailAction::VerifyAndReplan);
true
}
pub(super) fn select_replay_candidate(
&self,
turn: &TurnContext,
tool_registry: Option<&crate::tools::ToolRegistry>,
) -> Option<TurnToolCall> {
turn.tool_calls
.iter()
.rev()
.find(|call| {
call.error.is_none()
&& call.result.is_some()
&& self.tool_is_replayable_read_only(&call.name, tool_registry)
})
.cloned()
}
pub(super) fn tool_is_replayable_read_only(
&self,
tool_name: &str,
tool_registry: Option<&crate::tools::ToolRegistry>,
) -> bool {
if tool_name == MULTI_TOOL_PARALLEL_NAME || tool_name == REQUEST_USER_INPUT_NAME {
return false;
}
if McpPool::is_mcp_tool(tool_name) {
return mcp_tool_is_read_only(tool_name);
}
tool_registry
.and_then(|registry| registry.get(tool_name))
.is_some_and(|spec| spec.is_read_only())
}
pub(super) fn build_canonical_state(
&self,
turn: &TurnContext,
note: Option<&str>,
) -> CanonicalState {
let goal = self
.session
.messages
.iter()
.rev()
.find_map(|msg| {
if msg.role != "user" {
return None;
}
msg.content.iter().find_map(|block| match block {
ContentBlock::Text { text, .. } => Some(summarize_text(text, 220)),
_ => None,
})
})
.unwrap_or_else(|| "Continue current task from compact state".to_string());
let mut constraints = vec![
format!("model={}", self.session.model),
format!("workspace={}", self.session.workspace.display()),
];
if let Some(note) = note {
constraints.push(summarize_text(note, 180));
}
let mut confirmed_facts = Vec::new();
for msg in self.session.messages.iter().rev() {
for block in &msg.content {
if let ContentBlock::ToolResult { content, .. } = block {
if content.starts_with("Error:") {
continue;
}
confirmed_facts.push(summarize_text(content, 180));
if confirmed_facts.len() >= 4 {
break;
}
}
}
if confirmed_facts.len() >= 4 {
break;
}
}
let open_loops: Vec<String> = turn
.tool_calls
.iter()
.rev()
.filter_map(|call| {
call.error
.as_ref()
.map(|error| format!("{}: {}", call.name, summarize_text(error, 180)))
})
.take(4)
.collect();
let pending_actions: Vec<String> = if open_loops.is_empty() {
vec!["Continue with next smallest verifiable step".to_string()]
} else {
vec![
"Re-evaluate failed tool steps with narrower scope".to_string(),
"Re-derive plan from canonical facts before further edits".to_string(),
]
};
let mut critical_refs = self.session.working_set.top_paths(8);
for tool_call in turn.tool_calls.iter().rev().take(4) {
critical_refs.push(format!("tool:{}", tool_call.id));
}
critical_refs.dedup();
CanonicalState {
goal,
constraints,
confirmed_facts,
open_loops,
pending_actions,
critical_refs,
}
}
pub(super) fn canonical_prompt(
&self,
canonical: &CanonicalState,
pointer: &str,
action: GuardrailAction,
extra: Option<&str>,
) -> SystemPrompt {
let mut lines = vec![
COMPACTION_SUMMARY_MARKER.to_string(),
format!("Capacity Canonical State [{}]", action.as_str()),
format!("Goal: {}", canonical.goal),
"Constraints:".to_string(),
];
for item in &canonical.constraints {
lines.push(format!("- {}", summarize_text(item, 200)));
}
lines.push("Confirmed Facts:".to_string());
for item in &canonical.confirmed_facts {
lines.push(format!("- {}", summarize_text(item, 200)));
}
lines.push("Open Loops:".to_string());
if canonical.open_loops.is_empty() {
lines.push("- none".to_string());
} else {
for item in &canonical.open_loops {
lines.push(format!("- {}", summarize_text(item, 200)));
}
}
lines.push("Pending Actions:".to_string());
for item in &canonical.pending_actions {
lines.push(format!("- {}", summarize_text(item, 200)));
}
lines.push("Critical Refs:".to_string());
for item in &canonical.critical_refs {
lines.push(format!("- {}", summarize_text(item, 200)));
}
if let Some(extra) = extra {
lines.push(format!("Instruction: {}", summarize_text(extra, 240)));
}
lines.push(format!("Memory Pointer: {pointer}"));
SystemPrompt::Blocks(vec![crate::models::SystemBlock {
block_type: "text".to_string(),
text: lines.join("\n"),
cache_control: None,
}])
}
pub(super) fn capacity_source_message_ids(&self, turn: &TurnContext) -> Vec<String> {
let mut ids: Vec<String> = turn
.tool_calls
.iter()
.rev()
.take(8)
.map(|call| call.id.clone())
.collect();
ids.reverse();
ids
}
pub(super) fn build_capacity_record(
&self,
turn: &TurnContext,
action: GuardrailAction,
snapshot: Option<&CapacitySnapshot>,
canonical: CanonicalState,
source_message_ids: Vec<String>,
replay_info: Option<ReplayInfo>,
) -> CapacityMemoryRecord {
let (h_hat, c_hat, slack, risk_band) = snapshot
.map(|s| (s.h_hat, s.c_hat, s.slack, s.risk_band.as_str().to_string()))
.unwrap_or_else(|| (0.0, 0.0, 0.0, "unknown".to_string()));
CapacityMemoryRecord {
id: new_record_id(),
ts: now_rfc3339(),
turn_index: self.turn_counter,
action_trigger: action.as_str().to_string(),
h_hat,
c_hat,
slack,
risk_band,
canonical_state: canonical,
source_message_ids: if source_message_ids.is_empty() {
vec![turn.id.clone()]
} else {
source_message_ids
},
replay_info,
}
}
pub(super) async fn persist_capacity_record(
&mut self,
turn: &TurnContext,
action: GuardrailAction,
record: &CapacityMemoryRecord,
) -> String {
let pointer = format!("memory://{}/{}", self.session.id, record.id);
if let Err(err) = append_capacity_record(&self.session.id, record) {
let _ = self
.tx_event
.send(Event::CapacityMemoryPersistFailed {
session_id: self.session.id.clone(),
turn_id: turn.id.clone(),
action: action.as_str().to_string(),
error: summarize_text(&err.to_string(), 280),
})
.await;
return format!("{pointer}?persist=failed");
}
pointer
}
pub(super) fn rehydrate_latest_canonical_state(&mut self) {
let Ok(records) = load_last_k_capacity_records(&self.session.id, 1) else {
return;
};
let Some(last) = records.last() else {
return;
};
let pointer = format!("memory://{}/{}", self.session.id, last.id);
let prompt = self.canonical_prompt(
&last.canonical_state,
&pointer,
GuardrailAction::NoIntervention,
Some("Rehydrated canonical state from memory."),
);
self.merge_compaction_summary(Some(prompt));
}
}
+300
View File
@@ -0,0 +1,300 @@
//! Tool dispatch — plan/execute helpers for the per-turn tool batch.
//!
//! Extracted from `core/engine.rs` (P1.3). The high-level ordering still
//! lives in `Engine::handle_deepseek_turn`; this module owns:
//!
//! * Streaming-buffer parsing into a finalized `serde_json::Value` tool input
//! (`final_tool_input`, `parse_tool_input`, fenced/JSON segment helpers).
//! * The `multi_tool_use.parallel` payload parser.
//! * Policy predicates the turn loop consults — when a batch can run in
//! parallel, when an `update_plan` step should stop the turn, when a Plan
//! prompt should force a plan-first hop, and the small set of read-only
//! MCP tools that are safe to run in parallel.
//! * The tool execution plan/outcome types the batch driver passes around.
//!
//! All items are `pub(super)`-only: the public engine surface (Op/Event,
//! `EngineHandle`, `spawn_engine`) stays in `core/engine.rs`.
use serde_json::json;
use crate::models::ToolCaller;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::app::AppMode;
use super::ToolUseState;
// === Types ============================================================
#[allow(dead_code)] // `index` mirrors batch order for diagnostic ergonomics.
pub(super) struct ToolExecOutcome {
pub(super) index: usize,
pub(super) id: String,
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) started_at: std::time::Instant,
pub(super) result: Result<ToolResult, ToolError>,
}
#[derive(Debug, Clone)]
pub(super) struct ToolExecutionPlan {
pub(super) index: usize,
pub(super) id: String,
pub(super) name: String,
pub(super) input: serde_json::Value,
pub(super) caller: Option<ToolCaller>,
pub(super) interactive: bool,
pub(super) approval_required: bool,
pub(super) approval_description: String,
pub(super) supports_parallel: bool,
pub(super) read_only: bool,
pub(super) blocked_error: Option<ToolError>,
}
#[derive(Debug, serde::Serialize)]
pub(super) struct ParallelToolResultEntry {
pub(super) tool_name: String,
pub(super) success: bool,
pub(super) content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) error: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub(super) struct ParallelToolResult {
pub(super) results: Vec<ParallelToolResultEntry>,
}
// Hold the lock guard for the duration of a tool execution.
// The inner guards are held for RAII purposes (dropped when the guard is dropped).
pub(super) enum ToolExecGuard<'a> {
Read(#[allow(dead_code)] tokio::sync::RwLockReadGuard<'a, ()>),
Write(#[allow(dead_code)] tokio::sync::RwLockWriteGuard<'a, ()>),
}
// === Streaming-buffer parsing =========================================
/// Promote a streaming `ToolUseState` to a finalized JSON input.
///
/// Order of preference:
///
/// 1. `input_buffer` (the raw streamed delta concatenation) — parsed as
/// JSON. This is the most authoritative because it's what the model
/// actually emitted.
/// 2. `input` (the per-delta best-effort parse mirror) — used when the
/// buffer is empty (pre-streaming tool calls take this path).
/// 3. `input_buffer` non-empty but unparseable → fall back to `input`
/// (the per-delta parser has already mirrored the most recent valid
/// partial parse into `tool_state.input`).
pub(super) fn final_tool_input(state: &ToolUseState) -> serde_json::Value {
if !state.input_buffer.trim().is_empty()
&& let Some(parsed) = parse_tool_input(&state.input_buffer)
{
return parsed;
}
state.input.clone()
}
pub(super) fn parse_tool_input(buffer: &str) -> Option<serde_json::Value> {
let trimmed = buffer.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
return Some(value);
}
if let Some(stripped) = strip_code_fences(trimmed)
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&stripped)
{
return Some(value);
}
if let Ok(serde_json::Value::String(inner)) = serde_json::from_str::<serde_json::Value>(trimmed)
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&inner)
{
return Some(value);
}
extract_json_segment(trimmed)
.and_then(|segment| serde_json::from_str::<serde_json::Value>(&segment).ok())
}
fn strip_code_fences(text: &str) -> Option<String> {
if !text.contains("```") {
return None;
}
let mut lines = Vec::new();
for line in text.lines() {
if line.trim_start().starts_with("```") {
continue;
}
lines.push(line);
}
let stripped = lines.join("\n");
let stripped = stripped.trim();
if stripped.is_empty() {
None
} else {
Some(stripped.to_string())
}
}
fn extract_json_segment(text: &str) -> Option<String> {
extract_balanced_segment(text, '{', '}').or_else(|| extract_balanced_segment(text, '[', ']'))
}
fn extract_balanced_segment(text: &str, open: char, close: char) -> Option<String> {
let start = text.find(open)?;
let mut depth = 0i32;
let mut end = None;
for (offset, ch) in text[start..].char_indices() {
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
end = Some(start + offset + ch.len_utf8());
break;
}
}
}
end.map(|end_idx| text[start..end_idx].to_string())
}
fn normalize_parallel_tool_name(raw: &str) -> String {
let mut name = raw.trim();
for prefix in ["functions.", "tools.", "tool."] {
if let Some(stripped) = name.strip_prefix(prefix) {
name = stripped;
break;
}
}
name.to_string()
}
pub(super) fn parse_parallel_tool_calls(
input: &serde_json::Value,
) -> Result<Vec<(String, serde_json::Value)>, ToolError> {
let tool_uses = input
.get("tool_uses")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::missing_field("tool_uses"))?;
if tool_uses.is_empty() {
return Err(ToolError::invalid_input(
"multi_tool_use.parallel requires at least one tool call",
));
}
let mut calls = Vec::with_capacity(tool_uses.len());
for item in tool_uses {
let name = item
.get("recipient_name")
.or_else(|| item.get("tool_name"))
.or_else(|| item.get("name"))
.or_else(|| item.get("tool"))
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::missing_field("recipient_name"))?;
let params = item
.get("parameters")
.or_else(|| item.get("input"))
.or_else(|| item.get("args"))
.or_else(|| item.get("arguments"))
.cloned()
.unwrap_or_else(|| json!({}));
calls.push((normalize_parallel_tool_name(name), params));
}
Ok(calls)
}
// === Dispatch policy ==================================================
pub(super) fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool {
!plans.is_empty()
&& plans.iter().all(|plan| {
plan.read_only && plan.supports_parallel && !plan.approval_required && !plan.interactive
})
}
pub(super) fn should_stop_after_plan_tool(
mode: AppMode,
tool_name: &str,
result: &Result<ToolResult, ToolError>,
) -> bool {
mode == AppMode::Plan && tool_name == "update_plan" && result.is_ok()
}
pub(super) fn should_force_update_plan_first(mode: AppMode, content: &str) -> bool {
if mode != AppMode::Plan {
return false;
}
let lower = content.to_ascii_lowercase();
let asks_for_direct_plan = [
"quick plan",
"short plan",
"simple plan",
"3-step plan",
"3 step plan",
"three-step plan",
"three step plan",
"high-level plan",
"high level plan",
"give me a plan",
"make a plan",
"outline a plan",
"draft a plan",
]
.iter()
.any(|needle| lower.contains(needle));
if !asks_for_direct_plan {
return false;
}
let asks_for_repo_exploration = [
"inspect the repo",
"inspect the code",
"explore the repo",
"search the repo",
"read the code",
"review the code",
"analyze the code",
"investigate",
"look through",
"understand the current",
"ground it in the codebase",
"based on the codebase",
]
.iter()
.any(|needle| lower.contains(needle));
!asks_for_repo_exploration
}
pub(super) fn mcp_tool_is_parallel_safe(name: &str) -> bool {
matches!(
name,
"list_mcp_resources"
| "list_mcp_resource_templates"
| "mcp_read_resource"
| "read_mcp_resource"
| "mcp_get_prompt"
)
}
pub(super) fn mcp_tool_is_read_only(name: &str) -> bool {
matches!(
name,
"list_mcp_resources"
| "list_mcp_resource_templates"
| "mcp_read_resource"
| "read_mcp_resource"
| "mcp_get_prompt"
)
}
pub(super) fn mcp_tool_approval_description(name: &str) -> String {
if mcp_tool_is_read_only(name) {
format!("Read-only MCP tool '{name}'")
} else {
format!("MCP tool '{name}' may have side effects")
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+278
View File
@@ -0,0 +1,278 @@
//! Events emitted by the core engine to the UI.
//!
//! These events flow from the engine to the TUI via a channel,
//! enabling non-blocking, real-time updates.
use std::path::PathBuf;
use serde_json::Value;
use crate::core::coherence::CoherenceState;
use crate::error_taxonomy::ErrorEnvelope;
use crate::models::{Message, SystemPrompt, Usage};
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::SubAgentResult;
use crate::tools::user_input::UserInputRequest;
/// Final status for a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnOutcomeStatus {
Completed,
Interrupted,
Failed,
}
/// Events emitted by the engine to update the UI.
#[derive(Debug, Clone)]
pub enum Event {
// === Streaming Events ===
/// A new message block has started
MessageStarted {
#[allow(dead_code)]
index: usize,
},
/// Incremental text content delta
MessageDelta {
#[allow(dead_code)]
index: usize,
content: String,
},
/// Message block completed
MessageComplete {
#[allow(dead_code)]
index: usize,
},
/// Thinking block started
ThinkingStarted {
#[allow(dead_code)]
index: usize,
},
/// Incremental thinking content delta
ThinkingDelta {
#[allow(dead_code)]
index: usize,
content: String,
},
/// Thinking block completed
ThinkingComplete {
#[allow(dead_code)]
index: usize,
},
// === Tool Events ===
/// Tool call initiated
ToolCallStarted {
id: String,
name: String,
input: Value,
},
/// Tool execution progress (for long-running tools)
#[allow(dead_code)]
ToolCallProgress { id: String, output: String },
/// Tool call completed
ToolCallComplete {
id: String,
name: String,
result: Result<ToolResult, ToolError>,
},
// === Turn Lifecycle ===
/// A new turn has started (user sent a message)
TurnStarted { turn_id: String },
/// The turn is complete (no more tool calls)
TurnComplete {
usage: Usage,
status: TurnOutcomeStatus,
error: Option<String>,
},
/// Context compaction started.
CompactionStarted {
id: String,
auto: bool,
message: String,
},
/// Context compaction completed.
CompactionCompleted {
id: String,
auto: bool,
message: String,
/// Number of messages before compaction.
#[allow(dead_code)]
messages_before: Option<usize>,
/// Number of messages after compaction.
#[allow(dead_code)]
messages_after: Option<usize>,
},
/// Context compaction failed.
CompactionFailed {
id: String,
auto: bool,
message: String,
},
/// Checkpoint-restart cycle boundary advanced (issue #124). The previous
/// cycle has already been archived to disk; the engine has swapped its
/// in-memory message buffer for the seed messages of cycle `to`.
/// Carries the full briefing record so the UI can populate
/// `app.cycle_briefings` for `/cycle <n>`.
CycleAdvanced {
from: u32,
to: u32,
briefing: crate::cycle_manager::CycleBriefing,
},
/// Capacity decision telemetry.
#[allow(dead_code)]
CapacityDecision {
session_id: String,
turn_id: String,
h_hat: f64,
c_hat: f64,
slack: f64,
min_slack: f64,
violation_ratio: f64,
p_fail: f64,
risk_band: String,
action: String,
cooldown_blocked: bool,
reason: String,
},
/// Capacity intervention telemetry.
#[allow(dead_code)]
CapacityIntervention {
session_id: String,
turn_id: String,
action: String,
before_prompt_tokens: usize,
after_prompt_tokens: usize,
compaction_size_reduction: usize,
replay_outcome: Option<String>,
replan_performed: bool,
},
/// Capacity memory persistence failure telemetry.
#[allow(dead_code)]
CapacityMemoryPersistFailed {
session_id: String,
turn_id: String,
action: String,
error: String,
},
/// Plain-language session coherence state.
CoherenceState {
state: CoherenceState,
label: String,
description: String,
reason: String,
},
// === Sub-Agent Events ===
/// A sub-agent has been spawned
AgentSpawned { id: String, prompt: String },
/// Sub-agent progress update
AgentProgress { id: String, status: String },
/// Sub-agent completed
AgentComplete { id: String, result: String },
/// Sub-agent listing
AgentList { agents: Vec<SubAgentResult> },
/// Structured sub-agent mailbox envelope (issue #128). Carries the
/// monotonic seq + the typed `MailboxMessage` so the UI can route each
/// envelope to the correct in-transcript card.
SubAgentMailbox {
seq: u64,
message: crate::tools::subagent::MailboxMessage,
},
// === System Events ===
/// An error occurred
Error {
envelope: ErrorEnvelope,
#[allow(dead_code)]
recoverable: bool,
},
/// Status message for UI display
Status { message: String },
/// Pause terminal input events (for interactive subprocesses)
PauseEvents,
/// Resume terminal input events after subprocess completion
ResumeEvents,
/// Request user approval for a tool call
ApprovalRequired {
id: String,
tool_name: String,
description: String,
/// Fingerprint key for per‑call approval caching (§5.A).
approval_key: String,
},
/// Request user input for a tool call
UserInputRequired {
id: String,
request: UserInputRequest,
},
/// Authoritative API conversation state from the engine session.
///
/// The UI receives granular display events, but those are not always a
/// lossless representation of the API transcript. DeepSeek can emit
/// reasoning directly followed by tool calls without a visible assistant
/// text block, and that assistant message still has to be persisted for
/// later `reasoning_content` replay.
SessionUpdated {
messages: Vec<Message>,
system_prompt: Option<SystemPrompt>,
model: String,
workspace: PathBuf,
},
/// Request user decision after sandbox denial
#[allow(dead_code)]
ElevationRequired {
tool_id: String,
tool_name: String,
command: Option<String>,
denial_reason: String,
blocked_network: bool,
blocked_write: bool,
},
}
impl Event {
/// Create an error event from a categorized envelope. The envelope's own
/// `recoverable` flag controls whether the UI flips into offline mode.
pub fn error(envelope: ErrorEnvelope) -> Self {
let recoverable = envelope.recoverable;
Event::Error {
envelope,
recoverable,
}
}
/// Create a new status event
pub fn status(message: impl Into<String>) -> Self {
Event::Status {
message: message.into(),
}
}
}
@@ -9,8 +9,9 @@
//! - `session`: Session state management
//! - `turn`: Turn context and tracking
#![allow(dead_code)]
pub mod capacity;
pub mod capacity_memory;
pub mod coherence;
pub mod engine;
pub mod events;
pub mod ops;
+33 -6
View File
@@ -16,29 +16,39 @@ pub enum Op {
content: String,
mode: AppMode,
model: String,
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// `None` lets the provider apply its default.
reasoning_effort: Option<String>,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
},
/// Cancel the current request
#[allow(dead_code)]
CancelRequest,
/// Approve a tool call that requires permission
#[allow(dead_code)]
ApproveToolCall { id: String },
/// Deny a tool call that requires permission
#[allow(dead_code)]
DenyToolCall { id: String },
/// Spawn a sub-agent (for RLM mode)
/// Spawn a sub-agent
#[allow(dead_code)]
SpawnSubAgent { prompt: String },
/// List current sub-agents and their status
ListSubAgents,
/// Change the operating mode
#[allow(dead_code)]
ChangeMode { mode: AppMode },
/// Update the model being used
#[allow(dead_code)]
SetModel { model: String },
/// Update auto-compaction settings
@@ -52,6 +62,24 @@ pub enum Op {
workspace: PathBuf,
},
/// Run context compaction immediately.
CompactContext,
/// Run a Recursive Language Model (RLM) turn per Algorithm 1 of
/// Zhang et al. (arXiv:2512.24601). The prompt is stored in the REPL
/// as `context`; the root LLM only sees metadata.
Rlm {
/// The user's prompt — stored in REPL, NOT in the LLM context.
content: String,
/// The model to use for root LLM calls.
model: String,
/// The model to use for sub-LLM (llm_query) calls.
child_model: String,
/// Recursion budget for `sub_rlm()` calls. Paper experiments use
/// depth=1; defaults set by the `/rlm` command.
max_depth: u32,
},
/// Shutdown the engine
Shutdown,
}
@@ -62,20 +90,19 @@ impl Op {
content: impl Into<String>,
mode: AppMode,
model: impl Into<String>,
reasoning_effort: Option<String>,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
) -> Self {
Op::SendMessage {
content: content.into(),
mode,
model: model.into(),
reasoning_effort,
allow_shell,
trust_mode,
auto_approve,
}
}
/// Create a cancel operation
pub fn cancel() -> Self {
Op::CancelRequest
}
}
@@ -2,9 +2,11 @@
//!
//! Tracks conversation history, token usage, and session metadata.
use crate::cycle_manager::CycleBriefing;
use crate::models::{Message, SystemPrompt, Usage};
use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::working_set::WorkingSet;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
/// Session state for the engine.
@@ -13,11 +15,18 @@ pub struct Session {
/// Model being used
pub model: String,
/// Reasoning-effort tier for DeepSeek thinking mode:
/// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
/// apply its own defaults.
pub reasoning_effort: Option<String>,
/// Workspace directory
pub workspace: PathBuf,
/// System prompt (optional)
pub system_prompt: Option<SystemPrompt>,
/// Persisted summary blocks generated by context compaction.
pub compaction_summary_prompt: Option<SystemPrompt>,
/// Conversation history (API format)
pub messages: Vec<Message>,
@@ -31,6 +40,9 @@ pub struct Session {
/// Whether to trust paths outside workspace
pub trust_mode: bool,
/// Whether the current session should auto-approve tool safety checks.
pub auto_approve: bool,
/// Notes file path
pub notes_path: PathBuf,
@@ -45,6 +57,19 @@ pub struct Session {
/// Repo-aware working set for context management.
pub working_set: WorkingSet,
/// Number of cycle boundaries crossed in this session (issue #124). The
/// active cycle index is `cycle_count + 1` (cycles are 1-based for users).
pub cycle_count: u32,
/// UTC start time of the *current* cycle. Updated when the engine resets
/// the conversation buffer. Used by archive headers and the `/cycles`
/// command's display.
pub current_cycle_started: DateTime<Utc>,
/// Briefings produced at past cycle boundaries, in chronological order.
/// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens.
pub cycle_briefings: Vec<CycleBriefing>,
}
/// Cumulative usage statistics for a session.
@@ -53,7 +78,9 @@ pub struct Session {
pub struct SessionUsage {
pub input_tokens: u64,
pub output_tokens: u64,
#[allow(dead_code)]
pub cache_creation_input_tokens: u64,
#[allow(dead_code)]
pub cache_read_input_tokens: u64,
}
@@ -62,11 +89,12 @@ impl SessionUsage {
pub fn add(&mut self, usage: &Usage) {
self.input_tokens += u64::from(usage.input_tokens);
self.output_tokens += u64::from(usage.output_tokens);
}
/// Total tokens used
pub fn total(&self) -> u64 {
self.input_tokens + self.output_tokens
if let Some(tokens) = usage.prompt_cache_miss_tokens {
self.cache_creation_input_tokens += u64::from(tokens);
}
if let Some(tokens) = usage.prompt_cache_hit_tokens {
self.cache_read_input_tokens += u64::from(tokens);
}
}
}
@@ -86,12 +114,15 @@ impl Session {
Self {
model,
reasoning_effort: None,
workspace,
system_prompt: None,
compaction_summary_prompt: None,
messages: Vec::new(),
total_usage: SessionUsage::default(),
allow_shell,
trust_mode,
auto_approve: false,
notes_path,
mcp_config_path,
id: uuid::Uuid::new_v4().to_string(),
@@ -101,16 +132,12 @@ impl Session {
None
},
working_set: WorkingSet::default(),
cycle_count: 0,
current_cycle_started: Utc::now(),
cycle_briefings: Vec::new(),
}
}
/// Get project instructions as a system prompt block (if available)
pub fn get_project_instructions(&self) -> Option<String> {
self.project_context
.as_ref()
.and_then(super::super::project_context::ProjectContext::as_system_block)
}
/// Add a message to the conversation
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
@@ -121,14 +148,4 @@ impl Session {
self.working_set
.rebuild_from_messages(&self.messages, &self.workspace);
}
/// Clear the conversation history
pub fn clear(&mut self) {
self.messages.clear();
}
/// Get the message count
pub fn message_count(&self) -> usize {
self.messages.len()
}
}
@@ -448,72 +448,6 @@ pub fn has_tool_call_markers(text: &str) -> bool {
|| text.contains("<invoke ")
}
/// Clean streaming text by removing partial tool call markers and thinking tags.
/// This is used during streaming to prevent raw markers from appearing in the UI.
pub fn clean_streaming_text(text: &str) -> String {
let mut result = text.to_string();
// Remove thinking tags
let thinking_regex = get_thinking_regex();
result = thinking_regex.replace_all(&result, "").to_string();
// Remove [TOOL_CALL] blocks entirely
let tool_call_regex = get_tool_call_regex();
result = tool_call_regex.replace_all(&result, "").to_string();
// Remove XML-style partial markers that might appear during streaming
let patterns_to_remove = [
r"\[TOOL_CALL\]",
r"\[/TOOL_CALL\]",
r"</?deepseek:tool_call[^>]*>",
r"</?tool_call[^>]*>",
r"<invoke\s+name\s*=\s*[^>]*>",
r"</invoke>",
r"</?parameter[^>]*>",
r"</?param[^>]*>",
r"</?function_calls>",
r"</?antml:invoke[^>]*>",
r"</?antml:function_calls>",
// Also remove the tool call content patterns
r"\{tool\s*=>\s*[^}]+\}",
];
for pattern in patterns_to_remove {
if let Ok(regex) = Regex::new(pattern) {
result = regex.replace_all(&result, "").to_string();
}
}
// Clean up extra whitespace and empty lines
result = result
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
result.trim().to_string()
}
/// Check if a streaming chunk contains the start of a tool call marker.
/// This is used to suppress streaming output when we detect the start of a tool block.
pub fn is_tool_call_start(text: &str) -> bool {
text.contains("[TOOL_CALL]")
|| text.contains("<deepseek:tool_call")
|| text.contains("<tool_call")
|| text.contains("<invoke ")
|| text.contains("<function_calls>")
|| text.contains("<function_calls>")
}
/// Check if a streaming chunk contains the end of a tool call marker.
pub fn is_tool_call_end(text: &str) -> bool {
text.contains("[/TOOL_CALL]")
|| text.contains("</deepseek:tool_call>")
|| text.contains("</tool_call>")
|| text.contains("</invoke>")
|| text.contains("</function_calls>")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2,8 +2,20 @@
//!
//! A "turn" is one user message and the resulting AI response,
//! including any tool calls that occur.
//!
//! ## Snapshot lifecycle hooks
//!
//! [`pre_turn_snapshot`] and [`post_turn_snapshot`] book-end a turn by
//! taking a workspace-level snapshot into a side git repo (see
//! `crate::snapshot`). They are intentionally non-blocking and
//! non-fatal: any IO error is logged at WARN and swallowed so a busted
//! filesystem or missing `git` binary never derails the agent loop.
//! `/restore N` and the `revert_turn` tool both consume these
//! snapshots.
use crate::models::Usage;
use crate::snapshot::SnapshotRepo;
use std::path::Path;
use std::time::{Duration, Instant};
/// Context for a single turn (user message + AI response).
@@ -13,6 +25,7 @@ pub struct TurnContext {
pub id: String,
/// When the turn started
#[allow(dead_code)]
pub started_at: Instant,
/// Current step in the turn (tool call iteration)
@@ -25,6 +38,7 @@ pub struct TurnContext {
pub tool_calls: Vec<TurnToolCall>,
/// Whether the turn has been cancelled
#[allow(dead_code)]
pub cancelled: bool,
/// Usage for this turn
@@ -55,6 +69,7 @@ impl TurnContext {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
..Usage::default()
},
}
}
@@ -76,11 +91,13 @@ impl TurnContext {
}
/// Cancel the turn
#[allow(dead_code)]
pub fn cancel(&mut self) {
self.cancelled = true;
}
/// Get the elapsed time
#[allow(dead_code)]
pub fn elapsed(&self) -> Duration {
self.started_at.elapsed()
}
@@ -89,6 +106,55 @@ impl TurnContext {
pub fn add_usage(&mut self, usage: &Usage) {
self.usage.input_tokens += usage.input_tokens;
self.usage.output_tokens += usage.output_tokens;
self.usage.prompt_cache_hit_tokens = add_optional_usage(
self.usage.prompt_cache_hit_tokens,
usage.prompt_cache_hit_tokens,
);
self.usage.prompt_cache_miss_tokens = add_optional_usage(
self.usage.prompt_cache_miss_tokens,
usage.prompt_cache_miss_tokens,
);
self.usage.reasoning_tokens =
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
}
}
fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
match (total, delta) {
(Some(total), Some(delta)) => Some(total.saturating_add(delta)),
(None, Some(delta)) => Some(delta),
(Some(total), None) => Some(total),
(None, None) => None,
}
}
/// Take a `pre-turn:<seq>` workspace snapshot.
///
/// Returns the snapshot SHA on success, `None` on any error. Errors are
/// logged at WARN; the turn loop must not block on this.
pub fn pre_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}"))
}
/// Take a `post-turn:<seq>` workspace snapshot. Same failure model as
/// [`pre_turn_snapshot`].
pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
snapshot_with_label(workspace, &format!("post-turn:{turn_seq}"))
}
fn snapshot_with_label(workspace: &Path, label: &str) -> Option<String> {
match SnapshotRepo::open_or_init(workspace) {
Ok(repo) => match repo.snapshot(label) {
Ok(id) => Some(id.0),
Err(e) => {
tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}");
None
}
},
Err(e) => {
tracing::warn!(target: "snapshot", "snapshot repo init failed: {e}");
None
}
}
}
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
//! Whale/DeepSeek terminal theme tokens.
//!
//! A small, deliberately flat module that names the color, border, and
//! padding choices the TUI is already making. All values match the dark
//! palette previously hard-coded against [`crate::palette`]; a single
//! source-of-truth change here can swap the skin later. Visible output
//! is not changed by introducing this module.
//!
//! The only consumers today are the plan and tool cell renderers in
//! [`crate::tui::history`] and the sidebar section chrome in
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
//! directly until they are migrated in a later slice.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{BorderType, Borders, Padding};
use crate::palette;
use crate::tui::history::ToolStatus;
/// Visual variant exposed by the theme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
}
/// Centralized visual tokens for sidebar, plan, and tool rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub variant: Variant,
// Sidebar / section chrome
pub section_borders: Borders,
pub section_border_type: BorderType,
pub section_border_color: Color,
pub section_bg: Color,
pub section_title_color: Color,
pub section_padding: Padding,
// Tool cell color tokens
pub tool_title_color: Color,
pub tool_value_color: Color,
pub tool_label_color: Color,
pub tool_running_accent: Color,
pub tool_success_accent: Color,
pub tool_failed_accent: Color,
// Plan cell color tokens
pub plan_progress_color: Color,
pub plan_summary_color: Color,
pub plan_explanation_color: Color,
pub plan_pending_color: Color,
pub plan_in_progress_color: Color,
pub plan_completed_color: Color,
}
impl Theme {
/// The current dark theme. Visible output today uses these values.
#[must_use]
pub const fn dark() -> Self {
Self {
variant: Variant::Dark,
section_borders: Borders::ALL,
section_border_type: BorderType::Plain,
section_border_color: palette::BORDER_COLOR,
section_bg: palette::DEEPSEEK_INK,
section_title_color: palette::DEEPSEEK_BLUE,
// Horizontal padding only. `Padding::uniform(1)` ate two rows of
// each sidebar panel — for compact terminals where Plan/Todos/Tasks
// get ~3 rows total via the 25% layout split, that left zero rows
// for content (#63 follow-up: panels rendered as empty boxes even
// when "No todos" / "No active plan" should have shown).
section_padding: Padding::horizontal(1),
tool_title_color: palette::TEXT_SOFT,
tool_value_color: palette::TEXT_MUTED,
tool_label_color: palette::TEXT_DIM,
tool_running_accent: palette::ACCENT_TOOL_LIVE,
tool_success_accent: palette::TEXT_DIM,
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
plan_progress_color: palette::STATUS_SUCCESS,
plan_summary_color: palette::TEXT_MUTED,
plan_explanation_color: palette::TEXT_DIM,
plan_pending_color: palette::TEXT_MUTED,
plan_in_progress_color: palette::STATUS_WARNING,
plan_completed_color: palette::STATUS_SUCCESS,
}
}
/// Pick the right tool accent for a given [`ToolStatus`].
#[must_use]
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
match status {
ToolStatus::Running => self.tool_running_accent,
ToolStatus::Success => self.tool_success_accent,
ToolStatus::Failed => self.tool_failed_accent,
}
}
/// Bold tool title style (e.g. "Plan", "Shell").
#[must_use]
pub fn tool_title_style(self) -> Style {
Style::default()
.fg(self.tool_title_color)
.add_modifier(Modifier::BOLD)
}
/// Right-side status text ("running", "done", "issue") style.
#[must_use]
pub fn tool_status_style(self, status: ToolStatus) -> Style {
Style::default().fg(self.tool_status_color(status))
}
/// Detail label style ("command:", "time:", step markers).
#[must_use]
pub fn tool_label_style(self) -> Style {
Style::default().fg(self.tool_label_color)
}
/// Default value style for tool detail rows.
#[must_use]
pub fn tool_value_style(self) -> Style {
Style::default().fg(self.tool_value_color)
}
}
/// Returns the active theme used by the TUI today.
///
/// Today this is always `Theme::dark()`. A future PR can wire this to an
/// `App` field or a config setting in five lines.
#[must_use]
pub const fn active_theme() -> Theme {
Theme::dark()
}
#[cfg(test)]
mod tests {
use super::{Theme, Variant, active_theme};
use crate::palette;
use crate::tui::history::ToolStatus;
#[test]
fn active_theme_returns_dark() {
assert_eq!(active_theme(), Theme::dark());
}
#[test]
fn dark_theme_matches_existing_palette_choices() {
let theme = Theme::dark();
assert_eq!(theme.variant, Variant::Dark);
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
}
#[test]
fn tool_status_color_maps_each_status() {
let theme = Theme::dark();
assert_eq!(
theme.tool_status_color(ToolStatus::Running),
theme.tool_running_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Success),
theme.tool_success_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Failed),
theme.tool_failed_accent
);
}
}
+477
View File
@@ -0,0 +1,477 @@
//! Shared error taxonomy across client, tools, runtime, and UI.
use std::fmt;
use crate::llm_client::LlmError;
use crate::tools::spec::ToolError;
/// Broad category for typed error handling and policy decisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCategory {
Network,
Authentication,
Authorization,
RateLimit,
Timeout,
InvalidInput,
Parse,
Tool,
State,
Internal,
}
/// Severity hint for UI and logs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorSeverity {
Info,
Warning,
Error,
Critical,
}
/// Unified envelope used when crossing subsystem boundaries.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ErrorEnvelope {
pub category: ErrorCategory,
pub severity: ErrorSeverity,
pub recoverable: bool,
pub code: String,
pub message: String,
}
impl fmt::Display for ErrorCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Network => "network",
Self::Authentication => "authentication",
Self::Authorization => "authorization",
Self::RateLimit => "rate_limit",
Self::Timeout => "timeout",
Self::InvalidInput => "invalid_input",
Self::Parse => "parse",
Self::Tool => "tool",
Self::State => "state",
Self::Internal => "internal",
};
f.write_str(label)
}
}
impl fmt::Display for ErrorSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
Self::Critical => "critical",
};
f.write_str(label)
}
}
impl fmt::Display for ErrorEnvelope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}: {}", self.severity, self.code, self.message)
}
}
impl std::error::Error for ErrorEnvelope {}
impl ErrorEnvelope {
#[must_use]
pub fn new(
category: ErrorCategory,
severity: ErrorSeverity,
recoverable: bool,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
category,
severity,
recoverable,
code: code.into(),
message: message.into(),
}
}
/// Recoverable internal error — stream stalls, transient retries, generic
/// engine errors that the user can resolve by retrying. Severity is
/// `Warning` so the UI surfaces it in amber rather than red.
#[must_use]
pub fn transient(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Internal,
ErrorSeverity::Warning,
true,
"transient",
message,
)
}
/// Non-recoverable internal error — missing client, spawn failure, etc.
/// Flips the session into offline mode.
#[must_use]
pub fn fatal(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
false,
"fatal",
message,
)
}
/// Authentication failure — fatal and blocks the session.
#[must_use]
pub fn fatal_auth(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Authentication,
ErrorSeverity::Critical,
false,
"auth_fatal",
message,
)
}
/// Context length / overflow — invalid input, recoverable via /compact.
#[must_use]
pub fn context_overflow(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
true,
"context_overflow",
message,
)
}
/// Recoverable network / transport hiccup.
#[must_use]
pub fn network(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Network,
ErrorSeverity::Warning,
true,
"network_transient",
message,
)
}
/// Tool execution failure.
#[must_use]
pub fn tool(message: impl Into<String>) -> Self {
Self::new(
ErrorCategory::Tool,
ErrorSeverity::Error,
true,
"tool_failed",
message,
)
}
/// Build an envelope by classifying a raw error message string. Used at
/// boundaries where the underlying error type was already stringified.
#[must_use]
pub fn classify(message: impl Into<String>, recoverable: bool) -> Self {
let message = message.into();
let category = classify_error_message(&message);
let severity = match category {
ErrorCategory::Authentication => ErrorSeverity::Critical,
ErrorCategory::RateLimit | ErrorCategory::Timeout | ErrorCategory::Network => {
ErrorSeverity::Warning
}
ErrorCategory::InvalidInput | ErrorCategory::Authorization | ErrorCategory::Parse => {
ErrorSeverity::Error
}
ErrorCategory::Tool | ErrorCategory::State | ErrorCategory::Internal => {
if recoverable {
ErrorSeverity::Warning
} else {
ErrorSeverity::Error
}
}
};
Self::new(
category,
severity,
recoverable,
category.to_string(),
message,
)
}
}
impl From<LlmError> for ErrorEnvelope {
fn from(value: LlmError) -> Self {
match value {
LlmError::RateLimited { message, .. } => Self::new(
ErrorCategory::RateLimit,
ErrorSeverity::Warning,
true,
"llm_rate_limited",
message,
),
LlmError::ServerError { status, message } => Self::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
true,
format!("llm_server_{status}"),
message,
),
LlmError::NetworkError(message) => Self::new(
ErrorCategory::Network,
ErrorSeverity::Error,
true,
"llm_network_error",
message,
),
LlmError::Timeout(duration) => Self::new(
ErrorCategory::Timeout,
ErrorSeverity::Warning,
true,
"llm_timeout",
format!("Request timed out after {duration:?}"),
),
LlmError::AuthenticationError(message) => Self::new(
ErrorCategory::Authentication,
ErrorSeverity::Critical,
false,
"llm_auth_error",
message,
),
LlmError::InvalidRequest { message, .. } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_invalid_request",
message,
),
LlmError::ModelError(message) => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_model_error",
message,
),
LlmError::ContentPolicyError(message) => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"llm_content_policy",
message,
),
LlmError::ParseError(message) => Self::new(
ErrorCategory::Parse,
ErrorSeverity::Error,
false,
"llm_parse_error",
message,
),
LlmError::ContextLengthError(message) => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_context_length",
message,
),
LlmError::Other(message) => Self::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
true,
"llm_other",
message,
),
}
}
}
/// Classify an error message string into an ErrorCategory.
///
/// Uses heuristic keyword matching on the lowercased message.
/// This is a replacement for ad-hoc string matching in callers.
#[must_use]
pub fn classify_error_message(message: &str) -> ErrorCategory {
let lower = message.to_lowercase();
if lower.contains("maximum context length")
|| lower.contains("context length")
|| lower.contains("context_length")
|| lower.contains("prompt is too long")
|| (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
|| lower.contains("context window")
{
return ErrorCategory::InvalidInput;
}
if lower.contains("rate limit")
|| lower.contains("too many requests")
|| lower.contains("429")
|| lower.contains("quota")
{
return ErrorCategory::RateLimit;
}
if lower.contains("timeout") || lower.contains("timed out") {
return ErrorCategory::Timeout;
}
if lower.contains("auth") || lower.contains("unauthorized") || lower.contains("api key") {
return ErrorCategory::Authentication;
}
if lower.contains("permission") || lower.contains("forbidden") || lower.contains("denied") {
return ErrorCategory::Authorization;
}
if lower.contains("network")
|| lower.contains("connection")
|| lower.contains("dns")
|| lower.contains("temporarily unavailable")
|| lower.contains(" 502 ")
|| lower.contains(" 503 ")
|| lower.contains(" 504 ")
|| lower.starts_with("502 ")
|| lower.starts_with("503 ")
|| lower.starts_with("504 ")
|| lower.ends_with(" 502")
|| lower.ends_with(" 503")
|| lower.ends_with(" 504")
|| lower == "502"
|| lower == "503"
|| lower == "504"
{
return ErrorCategory::Network;
}
if lower.contains("parse") || lower.contains("syntax") || lower.contains("malformed") {
return ErrorCategory::Parse;
}
if lower.contains("not found")
|| lower.contains("unavailable")
|| lower.contains("not available")
{
return ErrorCategory::State;
}
if lower.contains("tool") {
return ErrorCategory::Tool;
}
ErrorCategory::Internal
}
impl From<ToolError> for ErrorEnvelope {
fn from(value: ToolError) -> Self {
match value {
ToolError::InvalidInput { message } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"tool_invalid_input",
message,
),
ToolError::MissingField { field } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"tool_missing_field",
format!("Missing required field: {field}"),
),
ToolError::PathEscape { path } => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"tool_path_escape",
format!("Path escapes workspace: {}", path.display()),
),
ToolError::ExecutionFailed { message } => Self::new(
ErrorCategory::Tool,
ErrorSeverity::Error,
true,
"tool_execution_failed",
message,
),
ToolError::Timeout { seconds } => Self::new(
ErrorCategory::Timeout,
ErrorSeverity::Warning,
true,
"tool_timeout",
format!("Tool timed out after {seconds}s"),
),
ToolError::NotAvailable { message } => Self::new(
ErrorCategory::State,
ErrorSeverity::Error,
false,
"tool_not_available",
message,
),
ToolError::PermissionDenied { message } => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"tool_permission_denied",
message,
),
}
}
}
/// Stream‑level error discriminated by origin.
///
/// Each variant maps to an `ErrorCategory` so the UI can render
/// stream‑specific icons or formatting. Wired into engine.rs at the three
/// stream guard sites (chunk timeout, max-bytes overflow, max-duration).
#[derive(Debug, Clone)]
pub enum StreamError {
/// Stream stalled — no chunk received within the idle timeout.
Stall { timeout_secs: u64 },
/// Stream exceeded content size limit.
Overflow { limit_bytes: usize },
/// Stream exceeded wall‑clock duration limit.
DurationLimit { limit_secs: u64 },
}
impl StreamError {
/// Convert directly into an `ErrorEnvelope` for emission on the engine
/// event channel. Stalls are warning-severity and recoverable; size and
/// duration limits are errors (the user must restart the turn).
#[must_use]
pub fn into_envelope(self) -> ErrorEnvelope {
match self {
Self::Stall { timeout_secs } => ErrorEnvelope::new(
ErrorCategory::Timeout,
ErrorSeverity::Warning,
true,
"stream_stall",
format!("Stream stalled: no data received for {timeout_secs}s, closing stream"),
),
Self::Overflow { limit_bytes } => ErrorEnvelope::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
true,
"stream_overflow",
format!("Stream exceeded maximum content size of {limit_bytes} bytes, closing"),
),
Self::DurationLimit { limit_secs } => ErrorEnvelope::new(
ErrorCategory::Timeout,
ErrorSeverity::Error,
true,
"stream_duration_limit",
format!("Stream exceeded maximum duration of {limit_secs}s, closing"),
),
}
}
}
impl fmt::Display for StreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Stall { timeout_secs } => {
write!(f, "Stream stalled after {timeout_secs}s idle")
}
Self::Overflow { limit_bytes } => {
write!(f, "Stream exceeded {limit_bytes} bytes limit")
}
Self::DurationLimit { limit_secs } => {
write!(f, "Stream exceeded {limit_secs}s duration limit")
}
}
}
}
impl std::error::Error for StreamError {}
+109 -3
View File
@@ -6,9 +6,10 @@
use anyhow::{Context, Result, anyhow};
use ignore::WalkBuilder;
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
@@ -102,6 +103,14 @@ pub struct EvalHarnessConfig {
pub shell_expect_token: String,
/// Maximum characters stored for step output summaries.
pub max_output_chars: usize,
/// When set, every step is appended as a JSON Lines fixture to a file
/// inside this directory. The fixture file is named after the scenario
/// (e.g. `offline-tool-loop.jsonl`). Each line follows the schema:
/// `{ "request": <step descriptor>, "response_events": [<events>] }`.
/// The mock LLM client (`crate::llm_client::mock`) can replay these
/// fixtures for deterministic offline tests. See
/// `crates/tui/tests/README.md` for the full record/replay flow.
pub record_dir: Option<PathBuf>,
}
impl Default for EvalHarnessConfig {
@@ -117,6 +126,7 @@ impl Default for EvalHarnessConfig {
shell_command,
shell_expect_token: "eval-harness".to_string(),
max_output_chars: 240,
record_dir: None,
}
}
}
@@ -273,26 +283,122 @@ impl EvalHarness {
success: true,
duration,
error: None,
output: Some(output),
output: Some(output.clone()),
});
if let Some(dir) = self.config.record_dir.as_deref() {
let _ = record_fixture(
dir,
&self.config.scenario_name,
FixtureRecord::ok(kind, &output),
);
}
Some(value)
}
Err(err) => {
stats.errors += 1;
let err_str = err.to_string();
steps.push(EvalStep {
kind,
tool_name: kind.tool_name(),
success: false,
duration,
error: Some(err.to_string()),
error: Some(err_str.clone()),
output: None,
});
if let Some(dir) = self.config.record_dir.as_deref() {
let _ = record_fixture(
dir,
&self.config.scenario_name,
FixtureRecord::err(kind, &err_str),
);
}
None
}
}
}
}
// === Fixture record/replay format ===========================================
//
// The `--record` flag writes one JSON object per line to a `.jsonl` file:
//
// { "request": { "step": "list_dir", "kind": "List" },
// "response_events": [{ "type": "ok", "output": "…" }] }
//
// The mock LLM client replays these fixtures via
// `MockLlmClient::push_message_response` (or the streaming variant) by mapping
// each `response_events` array onto a canned `Vec<StreamEvent>`.
//
// This format is intentionally minimal — additional fields (timing, model,
// usage) can be added without breaking older fixtures because each line is a
// self-contained JSON object.
/// Schema for one line of a `--record` JSONL fixture file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureRecord {
/// Step descriptor (`{ step, kind }`).
pub request: serde_json::Value,
/// One or more synthetic response events.
pub response_events: Vec<serde_json::Value>,
}
impl FixtureRecord {
fn ok(kind: ScenarioStepKind, output: &str) -> Self {
Self {
request: serde_json::json!({
"step": kind.tool_name(),
"kind": format!("{kind:?}"),
}),
response_events: vec![serde_json::json!({
"type": "ok",
"output": output,
})],
}
}
fn err(kind: ScenarioStepKind, error: &str) -> Self {
Self {
request: serde_json::json!({
"step": kind.tool_name(),
"kind": format!("{kind:?}"),
}),
response_events: vec![serde_json::json!({
"type": "error",
"error": error,
})],
}
}
}
/// Append one fixture record to `<dir>/<scenario>.jsonl` (creating dir + file
/// if missing). Best-effort: I/O errors are returned but generally ignored by
/// the harness so a recording failure does not mask the run's primary result.
pub fn record_fixture(dir: &Path, scenario_name: &str, record: FixtureRecord) -> Result<PathBuf> {
fs::create_dir_all(dir)
.with_context(|| format!("failed to create fixture dir: {}", dir.display()))?;
let safe_scenario = scenario_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let path = dir.join(format!("{safe_scenario}.jsonl"));
let line = serde_json::to_string(&record).context("failed to serialize fixture record")?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open fixture file: {}", path.display()))?;
writeln!(file, "{line}")
.with_context(|| format!("failed to write fixture line to {}", path.display()))?;
Ok(path)
}
impl Default for EvalHarness {
fn default() -> Self {
Self::new(EvalHarnessConfig::default())
@@ -1,5 +1,3 @@
#![allow(dead_code)]
use std::fs::OpenOptions;
use std::io::Read;
use std::io::Seek;
File renamed without changes.
File renamed without changes.
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more