CI on PR #256 flagged two minor lint hits in the privacy lane:
- skills/mod.rs: rustfmt wanted the new regression test's
`let rendered = …` line collapsed to one chain.
- main.rs:1614: `selected_skills_dir` is already `&PathBuf`, so
passing `&selected_skills_dir` is a `&&PathBuf` and clippy's
`needless_borrow` triggers under `-D warnings`.
No behavior change; same coverage and outputs. Re-runs locally:
cargo fmt --all -- --check → clean
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings → clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anywhere the TUI, doctor stdout, setup stdout, or onboarding shows a
file path, it used to print the absolute form (e.g. /Users/<name>/...).
On macOS/Linux the home-directory segment reveals the OS account name,
which is often the same as a public handle — undesirable for users who
share screenshots, screencasts, or paste doctor output into a public
help request.
Adds `crate::utils::display_path` that contracts a leading $HOME to `~`
and falls through unchanged otherwise. Used at every viewer-visible site:
doctor: workspace, config.toml, MCP config, all skills dirs,
selected skills dir, tools dir, plugins dir
setup: workspace, skills/tools/plugins paths and status output
TUI: context inspector header, trust-directory onboarding,
shell-job cwd (sidebar + detail pager), subagent task header
Persisted state, audit log, session checkpoints, and LLM-bound system
prompts intentionally keep the absolute path — those need full fidelity
to resolve correctly across processes and the LLM provider sees
absolute paths anyway by virtue of the workspace summary.
`display_path` has 4 tests covering: home contraction, bare-`~` for
home itself, untouched-when-unrelated, and a username-prefix regression
guard (so `/Users/alice2/...` doesn't get rewritten when $HOME is
`/Users/alice`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`render_available_skills_context` rendered each skill's file path as
`<skills_dir>/<frontmatter-name>/SKILL.md`. The directory name and the
frontmatter `name` can differ — community installs and manually-placed
skills routinely have this drift — and when they do, the model is told
the file lives at a path that does not exist, so it can't open the
SKILL.md it needs to actually use the skill.
`Skill` now carries a `path: PathBuf` populated by `discover()` from the
real directory entry. Renderer uses it directly. Adds a regression test
that creates a skill at `weird-dir-name/SKILL.md` with `name: friendly-name`
and asserts the rendered prompt contains the real path and not the
fabricated one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Acceptance list called for tests of no-config / healthy / disabled /
failed servers. Healthy and failed already had a single dense
test (`command_palette_includes_mcp_discovery_and_failed_servers`);
no-config is implicit in the existing call sites that pass `None`
for the snapshot. Disabled was the actual gap — adds one focused
case asserting the `[disabled]` state tag appears in the rendered
description so users can see disabled servers in the palette
without opening the MCP manager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The architecture promises that session_manager, runtime_threads, and
task_manager reject persisted state from a newer schema_version on
load, so a downgraded binary fails loud instead of silently truncating
or corrupting data. Existing tests covered:
- session_manager::test_checkpoint_rejects_newer_schema
- task_manager (newer task schema rejection)
- runtime_threads::store_load_thread_rejects_newer_schema_version
Adds the missing coverage for the other persistence paths:
- session_manager::test_load_session_rejects_newer_schema
- session_manager::test_load_offline_queue_rejects_newer_schema
- runtime_threads::store_load_turn_rejects_newer_schema_version
- runtime_threads::store_load_item_rejects_newer_schema_version
Each writes a JSON file with schema_version = CURRENT + 1 (or 999),
loads through the public API, and asserts the error message contains
"newer than supported".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The render-skills test asserted `rendered.contains("test-skill/SKILL.md")`
which only matched on Unix; Windows uses backslashes via Path::display(),
so the assertion failed only in CI on windows-latest.
Build the expected substring through PathBuf::display() so the assertion
matches the platform-correct separator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps workspace, all internal path-deps, and npm wrapper (version +
deepseekBinaryVersion) from 0.8.1 → 0.8.2. Lockfile re-locked offline
to keep the registry index untouched.
Triggers auto-tag.yml on push, which creates v0.8.2 and fires
release.yml to build cross-platform binaries and draft the GitHub
Release. npm publish remains manual per CLAUDE.md release runbook.
Note: npm registry already has 0.8.2 published (with binaryVersion
0.8.1 from an earlier checkpoint). That release keeps working unchanged
because v0.8.1 binaries stay on GitHub. Repo state aligns to 0.8.2 so
the version-drift gate passes; next npm publish (which will need to be
0.8.3 since 0.8.2 is taken) will pick up binaryVersion=0.8.2 and pull
the new binaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5770a574 added crates/tui/src/bin/deepseek.rs as a shim that calls
deepseek_tui_cli::run_cli() so `cargo install deepseek-tui` would also
install the canonical `deepseek` command. But the cli crate already
declares an identically-named bin target, so workspace builds emit two
artifacts to target/release/deepseek(.exe) -- cargo prints `output
filename collision` and on Windows the second linker hits LNK1104:
cannot open file deepseek.exe (the first hold has not released).
The cli crate stays the single source of `deepseek`. Workspace default
members still produce both binaries (deepseek from cli, deepseek-tui
from tui), no collision. Cargo install path: `cargo install
deepseek-tui-cli` for the canonical `deepseek` command, `cargo install
deepseek-tui` for the TUI binary; npm wrapper unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a model-visible skills block to the system prompt (progressive
disclosure: lists name/description/path, never inlines SKILL.md bodies)
with a 12k-char prompt budget and a 512-char per-description cap.
EngineConfig gains skills_dir, threaded through the three construction
sites (TUI app, exec agent, runtime thread manager).
README skills section is rewritten to document the discovery order,
the SKILL.md frontmatter contract, and the install/update/uninstall/
trust commands. Adds Simplified Chinese README cross-link and full
README.zh-CN.md translation (DeepSeek went viral in CN -- discoverability
matters).
Tests cover happy path, empty/missing dir → None, oversize description
truncation with U+2026 marker, internal-whitespace collapse, and the
overflow-budget omission notice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the workspace/npm wrapper to 0.8.0 and fixes completed background shell jobs retaining live process handles, which could cause Too many open files, checkpoint save failures, shell spawn failures, and lag around send/close/Esc. Also includes Windows REPL bootstrap timeout hardening and Cargo/TUNA mirror install docs.
Includes:
- Post-turn freeze fix (reorder maybe_advance_cycle before TurnComplete)
- Enter/steering fix (QueueFollowUp when model is streaming)
- Esc fanout hardening (idempotent finalize methods)
- cargo fmt pass on new code
- CHANGELOG, README, and version bump across workspace + npm
- fix(#234): reorder cycle advancement before TurnComplete so the engine
loop doesn't block the terminal after the turn signal. User sees the
'↻ context refreshing...' status chip during briefing generation
instead of a frozen terminal with no feedback.
- fix(#250): Enter during streaming queues a follow-up (visible queued
text) instead of claiming to 'steer' a message that never reaches the
model. During tool-execution phases Enter correctly steers the active
turn via rx_steer, which is already drained before each API call.
- fix(#243): hardened Esc during sub-agent fanout — added idempotency
test proving finalize_active_cell_as_interrupted is safe when
TurnComplete arrives after Esc.
- close(#249): unicode search panic fix confirmed in v0.7.8, closing.
- feat: both fixes implemented by live sub-agents (agent_spawn) —
proving the sub-agent system works end-to-end.
* wip(v0.7.7): handoff baseline of partial sub-agent stabilization
Captures uncommitted work-in-progress on the v0.7.7 stabilization lane
so subsequent fixes have a stable starting point. Subsequent commits
finish the canonical SubAgentJob/SwarmJob model, fix sidebar/transcript/
footer agreement, copy/paste/cancel contract, checklist rendering, shell
summary preservation, monotonic spend, and version provenance.
Refs #235#236#237#238#239#240#241#242#243#244#245
* release: bump workspace version to 0.7.7 (#245)
Refs #245
* fix(v0.7.7): canonical swarm card binding, monotonic spend, checklist + shell summary
- Add `swarm_card_index: HashMap<swarm_id, history_index>` so overlapping
fanouts each project to their own FanoutCard. Eliminates the screenshot
contradiction where a stale background swarm's progress clobbered a
newer card (#236, #238).
- Suppress fanout-class tools (`agent_swarm`, `spawn_agents_on_csv`,
`rlm`, `agent_spawn`) from `active_tool_status_label` so the footer no
longer reports "tool agent_swarm · 1 active" while sidebar+card show
the actual worker counts (#236, #238).
- Add `App::displayed_session_cost` + `displayed_cost_high_water` so the
visible session+sub-agent total is monotonic across reconciliation
events (cache discounts, provisional → final). New tests: monotonicity
under negative reconciliation; duplicate dedup keeps display steady (#244).
- Preserve high-signal summary lines from the truncated tail of shell
output: `test result:`, `failures:`, `error[E…]`, `Finished`,
`Compiling`, panic markers. Stops the agent re-running cargo gates
just to see pass/fail under truncation (#242).
- Render `checklist_write` / `todo_*` results as a purpose-built
checklist card with completed/total + percent header, per-item status
markers, and a collapsing affordance for long lists. Plumbed through
the existing `GenericToolCell` so no new variant threading is needed (#241).
Refs #236#238#241#242#244
* fix(v0.7.7): Esc clears active tool entries optimistically (#243)
When Esc cancels the foreground turn we now finalize the active cell
immediately rather than waiting for the engine's TurnComplete echo to
drain. This stops the footer "tool ... · X active" chip from briefly
contradicting the cancelled state, and frees the composer for the next
message.
Background `block:false` swarms are intentionally NOT killed here — they
remain durable, tracked through `swarm_jobs` and `swarm_card_index` so
their FanoutCard updates as workers land. Subsequent `swarm_status` /
`swarm_result` / `swarm_cancel` tool calls see the canonical store.
New focused test verifies: after Esc, `active_cell` is None, the
background swarm record is preserved, and `is_loading` is cleared so
the composer can submit immediately.
Refs #243
* fix(v0.7.7): Windows .exe lookup + post-turn snapshot detach (#247, #234)
#247 — npm-distributed Windows package failed at runtime because the
Rust dispatcher's `delegate_to_tui` / `delegate_simple_tui` looked for a
sibling named exactly "deepseek-tui", while the actual file shipped by
`scripts/install.js` is `deepseek-tui.exe`. Replace both lookups with
`locate_sibling_tui_binary`, which:
- Honours `DEEPSEEK_TUI_BIN` for explicit overrides
- Tries `deepseek-tui{EXE_SUFFIX}` first (`.exe` on Windows, "" elsewhere)
- Falls back to suffix-less `deepseek-tui` on Windows so users who
applied the issue's manual workaround still launch successfully
- Emits a platform-correct error path in the bail message
Tests: `sibling_tui_candidate_picks_platform_correct_name`,
`sibling_tui_candidate_windows_falls_back_to_suffixless` (windows-only),
`locate_sibling_tui_binary_honours_env_override`.
#234 — Detach the post-turn workspace snapshot so `git add -A && git
commit` no longer pins the engine loop after `Event::TurnComplete`.
The snapshot still runs on `tokio::task::spawn_blocking`, but the
engine no longer awaits its `JoinHandle`, so the UI accepts input
(text, copy, paste, selection) without waiting for the bookkeeping to
finish. Cycle advance and pre-turn snapshot remain awaited — they are
correctness-sensitive and the cycle path already emits a status chip
("↻ context refreshing…") so the user has visible feedback.
Refs #234#247
* chore(v0.7.7): bump npm package version 0.7.6 → 0.7.7
Required by `scripts/release/check-versions.sh` ("Version drift" CI
gate); the workspace was bumped to 0.7.7 but `npm/deepseek-tui/package.json`
still reported 0.7.6, blocking PR #246 from going green.
Refs #245
- Bump workspace version to 0.7.6 (Cargo.toml + all crate internal dep pins)
- Bump npm wrapper version and deepseekBinaryVersion to 0.7.6
- Add v0.7.6 changelog entry: localization, paste burst, history search,
pending input preview, grouped /config editor, searchable help overlay,
Alt+↑ edit-last-queued, composer attachment management
- Update README with v0.7.6 features (localization, paste, history search)
- Archive v0.7.5 implementation plan to docs/archive/
- Update Cargo.lock
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.
#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
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.
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>