diff --git a/CHANGELOG.md b/CHANGELOG.md
index 577fb2a..f00bb4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,9 +45,10 @@
- Upgraded the terminal stack to exact Ratatui 0.30.2, ratatui-core 0.1.2,
tui-markdown 0.3.8, Crossterm 0.29.0, and unicode-width 0.2.2 versions.
Dext carries a narrow exact-source ratatui-core compatibility patch for its
- inline viewport; the real-PTY suite now gates streaming input, populated
- resize bursts, whole-screen clears, cursor-query counts, replay bounds, and a
- bounded completion wait that tolerates slower macOS CI hosts.
+ inline viewport; the real-PTY suite now starts each child with a controlling
+ terminal and gates streaming input, populated resize bursts, whole-screen
+ clears, cursor-query counts, replay bounds, and a bounded completion wait
+ that tolerates slower macOS CI hosts.
- macOS Seatbelt profiles now allow both canonical `/private/...` scratch paths
and their standard `/var` or `/tmp` aliases, keeping temp APIs confined and
usable.
@@ -62,6 +63,18 @@
project, user, or `DEXT_SHELVES_DIR` roots; direct pack roots and
`DEXT_PACKS_DIR` are no longer discovery inputs.
+- Replaced `/plan` with conversational planning turn policies. The objective
+ tracker now classifies planning/analysis-only prompts (including explicit
+ “don’t change anything” phrasing) and bare plan approvals (“go”, “proceed
+ with the plan”); Dext injects a matching advisory-only or
+ implementation-authorized turn policy into the volatile runtime status —
+ never the cached stable prompt — so weaker models get deterministic per-turn
+ structure without hard tool gating. Explicit mutation intent always
+ overrides advisory phrasing, question-phrased prompts are never read as
+ approvals, and mid-turn queued user updates re-evaluate the policy;
+ approval prompts and `/sandbox-profile read-only` remain the enforcement
+ layers.
+
### Removed
- Removed the completed repository-local `.auto` prompt-efficiency experiment scaffold and now ignore the entire root `.auto/` workspace. Dext runtime/build/CI and the user-owned autoresearch pack do not depend on those project experiment files.
@@ -89,8 +102,13 @@
- Removed the subagent feature completely: `/subagent` slash command,
`subagent-runtime` CLI subcommand, detached/inline runners, steering,
quality gates, TUI state, session artifacts dir, and all associated
- tests/fixtures. `/plan` preserved via a direct read-only planner.
- Net -1544 lines.
+ tests/fixtures. Net -1544 lines.
+- Removed the unused `/plan` slash command and its hidden read-only planner turn,
+ temporary agent-state swapping, duplicated CLI/TUI dispatch, completion entry,
+ welcome tip, and planner-only regression test. Planning is now an ordinary
+ conversation: ask Dext to inspect and propose a plan without editing, revise
+ it in context, then tell it to proceed. A former `/plan ...` input is no
+ longer intercepted and is delivered as a normal prompt.
- Removed all repository-owned and embedded pack payloads. Dext ships the pack
lifecycle and shelf integration, but no pack content; users own and
distribute shelf repositories separately.
diff --git a/Cargo.lock b/Cargo.lock
index 7040de2..aa4edd2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -568,7 +568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -736,9 +736,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
-version = "0.4.15"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -1742,7 +1742,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1941,7 +1941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2499,7 +2499,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/DEXT.md b/DEXT.md
index 8205109..c2a0b32 100644
--- a/DEXT.md
+++ b/DEXT.md
@@ -60,10 +60,23 @@ When changing Dext itself:
## Context files
- `DEXT.md` is tracked project guidance and is auto-injected from the sandbox
- root and its ancestors. Keep it terse and machine-facing.
-- `recall.md` is an optional ignored prompt cache. It is auto-injected when
- present, but Dext does not create or update it automatically.
-- Do not create or update `recall.md` unless the user asks.
+ root and its ancestors. Keep it terse and machine-facing. DEXT.md is
+ human-authored policy; agents never edit it.
+- `recall.md` is agent working memory, ignored by Git and auto-injected when
+ present.
+
+## Recall policy
+- Create/update `recall.md` without asking, at task boundaries (task done,
+ correction received, gotcha resolved) — not mid-exploration.
+- Hard cap ~4KB. To add when full, prune the least-valuable entry in the same
+ edit.
+- One line per entry: date, tag `[fact]`|`[pref]`|`[proc]`|`[fix]`, session id.
+- Declarative observations only — record what is true, never what to do.
+ Imperatives and instructions to future sessions are forbidden; DEXT.md alone
+ carries policy.
+- Never write secrets, credentials, or personal data.
+- New entries end with `?`. Strip the `?` only when a later session relies on
+ the entry and it holds. Delete entries the repo contradicts.
## Packs and shelves
- Packs may optionally declare a reviewed `runtime.json` one-shot helper to expose dynamic tools, bounded state, continuation/steering effects, and markdown views; executable activation and write-risk calls retain approval, sandbox, credential-scrubbing, and checkpoint controls.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 83e99f4..4aaef29 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -45,7 +45,7 @@ Dext is a Rust terminal agent packaged as one binary. Most behavior is still int
- Built-in GLM, ChatGPT/Codex, OpenAI, Anthropic, Kimi Code, DeepSeek, and local OpenAI-compatible profiles.
- Live llama.cpp runtime context probing for the local provider; unavailable local servers fall back cleanly without aborting startup.
- API-key, ChatGPT OAuth, and Anthropic Claude Pro/Max OAuth login flows; runtime auth retains whether a resolved secret is an API key or OAuth token. OAuth callback binding is loopback-only, accepted connections use blocking I/O under one two-second complete-header deadline, result pages wait for exchange/storage completion, exchange/refresh transport is bounded and redirect-free, and active OAuth credentials are rechecked at user-turn boundaries.
- - Request builders for Anthropic, OpenAI-compatible, and ChatGPT/Codex response APIs.
+ - Request builders for Anthropic, OpenAI-compatible, and ChatGPT/Codex response APIs. Public adaptive Anthropic models (Sonnet 4.6, Sonnet 5, Opus 4.6/4.7/4.8, Opus 5, and Fable 5) omit `thinking.display`; transformed OAuth and API-key request fixtures verify the same adaptive shape.
- Model alias normalization and provider/model switching helpers.
- `src/claude_subscription.rs`
@@ -132,7 +132,7 @@ Dext is a Rust terminal agent packaged as one binary. Most behavior is still int
- `vendor/ratatui-core/`
- Exact upstream `ratatui-core 0.1.2` source selected through `[patch.crates-io]`.
- - Narrow inline-terminal fixes that avoid synchronous cursor-query stalls and whole-display clears during resize.
+ - Narrow inline-terminal fixes that avoid synchronous cursor-query stalls and extra whole-display clears before Dext's owned resize replay, plus an origin-reset primitive that clears the visible display before stale-width scrollback is purged so complete transcript reconstruction replays the intro exactly once.
- Hunk rationale and refresh instructions in `vendor/ratatui-core/DEXT_PATCH.md`.
## Tool model
@@ -279,6 +279,6 @@ cargo test --release --locked
cargo test --release --locked --test tui_smoke -- --nocapture
```
-The TUI smoke suite launches the real compiled binary inside a pseudo-terminal. In addition to launch/help/exit coverage, it checks narrow and wide layouts, multiline input, live-stream input, resize survival, bounded cursor queries, zero whole-screen resize clears, and completed output after resize. Renderer changes also follow the live-terminal checks in [`TUI.md`](TUI.md).
+The TUI smoke suite launches the real compiled binary inside a pseudo-terminal. In addition to launch/help/exit coverage, it checks narrow and wide layouts, multiline input, live-stream input, resize survival, bounded cursor queries, a visible-display clear before each paired scrollback purge, exactly one Dext intro per replay segment, terminal-height-bounded complete replay, and completed output after resize. Renderer changes also follow the live-terminal checks in [`TUI.md`](TUI.md).
On Windows CI and release builders, the scheduler-sensitive `fast_bash_command_returns_without_100ms_poll_tail` regression runs alone after the remaining release tests. Its original `<90 ms` assertion remains unchanged; isolation prevents unrelated suite load from obscuring the process-wait regression it measures. The external-runner stdin-backpressure regression still requires bounded completion under the shared deadline, but accepts either the stdin-write or root-process timeout phase on Windows because pipe buffering can make the full write complete at the deadline boundary; Unix continues to require the stdin-write phase. The tool-call mock provider consumes its bounded `Content-Length` request body before responding so Windows does not reset the connection with unread request data.
diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md
index b50f126..f71ae39 100644
--- a/docs/RISK_REGISTER.md
+++ b/docs/RISK_REGISTER.md
@@ -18,7 +18,7 @@ Priorities: **P1** needs planned mitigation before a stronger security/reliabili
| R-010 | Privacy · local recovery · P3 | Low with normal Git push; medium with mirror/backup sharing | Medium | High | Checkpoint refs and owner-private sidecars/blobs may retain sensitive file content and symlink targets. Write-risk arbitrary-command checkpoints can preserve existing untracked entries within bounded limits. Mirroring `refs/dext/*`, copying full repository metadata, or sharing state backups can disclose it. Evidence: checkpoint storage in `src/git_checkpoints.rs` and warnings in `SECURITY.md`. | On Unix, checkpoint storage containers are current-user-owned, `.dext` is not group/world-writable, and managed checkpoint/sidecar/blob directories are owner-private; unsafe restore containers fail closed, prune retains unsafe blob entries and sidecar directory trees with bounded warnings, and orphan top-level sidecar symlinks are unlinked without traversal. Blobs are content-addressed and verified before/while restoring; current manifests record exact direct-sidecar membership, while ambiguous missing artifacts in older manifests fail before mutation; every manifest path is validated by one repo-relative rule that a row cannot opt out of; recognized retired rows require intact headers, complete retired field grammar, and live-ref OID matches; retention publishes the compacted manifest before deleting expired/retired refs or artifacts so cleanup failure leaves orphan state rather than a manifest naming deleted recovery points, while corruption or tampering fails closed; runtime manifest reads are capped at 16 MiB; cache reuse requires stable source/blob metadata fingerprints; `.dext/` is locally excluded; arbitrary-command inventory/capture is capped at 500 paths, 8 MiB/file, and 32 MiB/checkpoint; non-UTF-8/unsupported/capped entries require partial-recovery approval; failed creation cleans new blobs; automatic retention is 20 checkpoints/seven days and prunes valid unreferenced blobs; normal Git pushes do not include hidden refs; docs warn against mirror-pushing Dext refs. | Add an explicit sensitive-checkpoint inventory/prune workflow and optional checkpoint disable/exclude patterns · recovery/privacy owner · review when capture scope, retention, backup, or ref publication behavior changes. |
| R-011 | Maintainability · architecture · P2 | High for routine core changes | Medium | High | `src/main.rs`, `src/tui.rs`, and the in-crate test surface remain large; unrelated runtime concerns still share broad compile/review boundaries. Evidence: module line counts and the responsibility map in `docs/ARCHITECTURE.md`. | Provider streaming, tool rounds/journal, checkpoints, mutation previews, sandbox, policy, pack runtime, packs, shelves, seats, sessions, and orchestration already have focused modules; the unused work-map/focus/track vertical slice and its TUI/event/test surface were removed rather than extracted; tool metadata has one registry for required fields and execution flags; release tests cover cross-module behavior. | Extract one stable seam at a time (HTTP tool, diagnostics, doctor, export, eval) with behavior-preserving tests; do not combine extraction with feature work · architecture owner · review when adding another substantial `main.rs` subsystem or when merge/compile cost blocks delivery. |
| R-012 | Security · state · same-user concurrency · P2 | Low in normal private state; medium with a hostile same-user process | High | Medium | Seat reads/writes validate path components, ancestors, ownership, modes, links, and opened-file identity, but path-based ancestor validation and later open/rename are not one descriptor-relative transaction. A hostile same-user process can replace an ancestor after validation and redirect or disrupt Seat metadata/pointer operations. Evidence: `src/seats.rs` ancestor validation and atomic path replacement. | Portable ids; owner-safe Unix state ancestors; owner-private managed directories; no-follow single-link record reads; inode revalidation; bounded schema/content; cross-process operation lock; atomic secret-file replacement; transactional reset rollback attempt; documented same-user boundary. | Use descriptor-relative no-follow traversal and handle-relative atomic replacement for the complete Seat path, or place Seat operations inside an OS-isolated trusted state service · state/security owner · review on Seat path changes or any state-redirection incident. |
-| R-013 | Reliability · provider compatibility · P1 | High when Anthropic changes its private Claude Code contract | High for affected subscription users | High | Claude Pro/Max routing depends on an unofficial, version-specific OAuth and request shape that Anthropic can change without API compatibility guarantees; provider terms may also restrict third-party subscription clients. Evidence: pinned Claude Code 2.1.224 constants and billing/checksum/header construction in `src/claude_subscription.rs`. | Compatibility applies only to typed OAuth credentials on the marked built-in Anthropic profile at the exact official base URL; API keys, custom endpoints, GLM, and Kimi bypass it; malformed body shapes fail closed; OAuth callback binding is loopback-only, accepted connections use blocking I/O, and complete request headers/state are validated under one two-second total deadline; exchange/refresh uses bounded redirect-free transport, rechecks active OAuth expiry per user turn, and preserves refresh rotation; fake-transport/vector tests cover fingerprints, checksum, headers, identity validation, callback outcomes, refresh persistence, and provider isolation; docs disclose unofficial status and terms responsibility. | Revalidate OAuth endpoints/scopes and public wire vectors against each supported Claude Code contract before changing the pin; disable or update the route on provider rejection rather than adding permissive fallback shaping · provider/security owner · every Claude compatibility change or authentication incident. |
+| R-013 | Reliability · provider compatibility · P1 | High when Anthropic changes its private Claude Code contract | High for affected subscription users | High | Claude Pro/Max routing depends on an unofficial, version-specific OAuth and request shape that Anthropic can change without API compatibility guarantees; provider terms may also restrict third-party subscription clients. Evidence: pinned Claude Code 2.1.224 constants and billing/checksum/header construction in `src/claude_subscription.rs`. | Compatibility applies only to typed OAuth credentials on the marked built-in Anthropic profile at the exact official base URL; API keys, custom endpoints, GLM, and Kimi bypass it; malformed body shapes fail closed; OAuth callback binding is loopback-only, accepted connections use blocking I/O, and complete request headers/state are validated under one two-second total deadline; exchange/refresh uses bounded redirect-free transport, rechecks active OAuth expiry per user turn, and preserves refresh rotation; fake-transport/vector tests cover fingerprints, checksum, headers, identity validation, callback outcomes, refresh persistence, provider isolation, OAuth-transformed adaptive request fields, API-key parity, and visible thinking-delta/signature round trips; docs disclose unofficial status and terms responsibility. | Revalidate OAuth endpoints/scopes and public wire vectors against each supported Claude Code contract before changing the pin; disable or update the route on provider rejection rather than adding permissive fallback shaping · provider/security owner · every Claude compatibility change or authentication incident. |
## Review protocol
diff --git a/docs/TUI.md b/docs/TUI.md
index a4bbda6..1c1ae26 100644
--- a/docs/TUI.md
+++ b/docs/TUI.md
@@ -1,20 +1,24 @@
# Terminal UI
-Dext's interactive interface is an inline Ratatui application in the regular terminal buffer. It preserves native terminal scrollback instead of taking over the alternate screen. The backend viewer is the only alternate-screen surface.
+Dext's interactive interface is an inline Ratatui application in the regular terminal buffer. It uses native terminal scrollback during ordinary operation instead of taking over the alternate screen. On every effective transcript-pane width change, Dext deliberately replaces that scrollback immediately with a complete replay at the new width. The backend viewer is the only alternate-screen surface.
## Behavior contract
TUI and dependency changes must preserve these behaviors:
- The main interface remains an inline viewport in the regular terminal buffer.
-- Completed transcript output remains in native terminal scrollback.
+- Completed transcript output remains in native terminal scrollback during ordinary operation. Every effective transcript-pane width change immediately purges stale-width terminal history and rebuilds Dext's complete logical transcript; pre-Dext shell scrollback is intentionally not preserved by that rebuild.
- The settled banner, transcript, composer, status rows, expansion state, spacing, and styling change only through explicit TUI work, never merely because dependencies changed.
- The startup welcome stays in inline transcript scrollback, starts with one transcript-owned blank separator row below CLI diagnostics, and uses a compact four-zone layout: a Dext/version brand row, an adaptive working-directory and cached Git summary at 80 columns or wider, exactly two Model/Approval facts between rules, and one rotating tip drawn from verified TUI features. Width calculations and truncation use terminal cell width, and the Git probe runs off the render loop with only an 8 ms startup wait before falling back to path-only rendering.
-- The empty composer prompt is `❯ Type a request… @ files · / commands`; typing, login, permission, and paste-preview behavior retain their existing paths. Slash completion mirrors the canonical handled commands, including `/privacy`, `/preview`, `/context`, `/tool-profile`, `/diagnostics`, `/shelves`, `/project-extensions`, and `/undo`.
+- The empty composer prompt is `❯ Type a request… @ files · / commands`; typing, login, permission, and paste-preview behavior retain their existing paths. Slash completion mirrors the canonical handled commands, including `/privacy`, `/preview`, `/context`, `/tool-profile`, `/diagnostics`, `/shelves`, `/project-extensions`, and `/undo`. `/login` completion shows every provider id exactly once and suppresses duplicate numbered-selector entries.
+- Structured slash listings use the established `/sessions` hierarchy: count and section headers, two-space names, four-space details, and detached `Use:` footers. Dense name/description catalogs such as `/help` use aligned rows at 64 columns and wider and fall back to the stacked hierarchy when narrow. The TUI supplies its actual transcript-pane width; output keeps a two-cell gutter and a 120-column readability cap. `/system` preserves source/prompt paragraphs, blank lines, and leading indentation while wrapping prose. Dynamic fields are sanitized before layout; every physical row is bounded by Unicode display cells, with `?` replacing only a grapheme that cannot fit in an otherwise impossible one-cell measure. An explicit structured-slash event retains those layouts even when ANSI color is disabled. Generic slash confirmations, including `/model` and thinking-effort status, retain the faded info treatment.
- Frugal mode applies the stricter pseudo-tool-protocol sanitizer to partial-stream recovery, completed transcript/thinking blocks, live details, and the inspector: serialized or multiline tool-call-like assistant payloads are replaced with `[tool call redacted; waiting for structured tool event]` while surrounding prose remains visible. Standard mode retains the narrower legacy line detector.
- The main status row shows the exact `main` branch label as `Main`, including `Main (dirty)` when the working tree is dirty, without renaming the branch or changing any other branch casing. It keeps a live cumulative agent-active elapsed clock at its right edge while Dext works; the clock pauses and hides while Dext is idle awaiting input.
+- Anthropic thinking deltas are retained in the provider event stream and finalized with their signatures for tool-loop replay. The TUI shows live and completed thinking only while verbose display is enabled (the default); toggling verbose hides it without changing stored provider blocks. `stream-json` exposes thinking events, while console text and final JSON omit thinking content.
- Input and the viewport remain responsive while output streams and while the terminal is resized.
-- Resize replay is cohesive: no item-by-item reconstruction, whole-screen flash, cursor-query stall, or cursor-query timeout.
+- Resize replay follows a full-ownership model. On every effective transcript-pane width change, Dext uses one synchronized update to clear the visible display and reset the inline viewport to the origin without a cursor query, purge stale-width scrollback, and immediately rebuild the complete logical transcript at the observed width before appending pending output. Clearing before purging removes the still-visible old intro before logical history replays it once. There is no quiet-settle debounce, visible-suffix overwrite, or short-history exception. This removes mixed old/new wrapping, duplicate transcript copies, and width/height-shrink bookkeeping edge cases; the deliberate tradeoffs are complete replay work during resize bursts and replacement of pre-Dext shell scrollback.
+- Pending permission prompts render inside the inline viewport, never into scrollback; only the compact decision line is appended once resolved. Approval prompts and decisions must not trigger a full-history re-emit.
+- Pending transcript insertion keeps an already prepared failed batch separate from newly queued raw output. A retry reuses that prepared batch without regrouping or reranking it; new output is prepared only after the retry succeeds.
- The backend viewer remains the only alternate-screen surface.
- `Ctrl+L` opens a read-only todo modal in the inline UI; it never enters the alternate screen and remains available during ordinary idle or busy work. Permission and local-auth prompts intentionally retain input and rendering priority.
@@ -52,11 +56,12 @@ Unmodified Ratatui 0.30.2 regressed Dext's inline experience. Its fallback `inse
Enabling Ratatui's `scrolling-regions` feature was rejected because it changed settled rendering and expanded the backend dependency graph.
-Dext patches the exact upstream `ratatui-core 0.1.2` source through `[patch.crates-io]`. The patch is limited to three inline-terminal corrections:
+Dext patches the exact upstream `ratatui-core 0.1.2` source through `[patch.crates-io]`. The patch is limited to four inline-terminal corrections:
1. `Terminal::clear` preserves Ratatui's tracked cursor position instead of synchronously querying the backend.
2. Fallback `insert_before` clears the viewport directly rather than calling the cursor-preserving public clear.
3. Horizontal shrink avoids `ClearType::All` for inline viewports; the normal viewport clear and full next draw remain in place.
+4. `Terminal::reset_inline_viewport` clears the visible display, resets both diff buffers, and anchors an inline viewport at the terminal origin without querying the cursor. On every effective transcript-pane width change, Dext calls it before Crossterm purges stale-width scrollback, then replays its complete logical transcript once at the observed width.
The vendored source and hunk-level rationale live under `vendor/ratatui-core/`. This is a narrow compatibility patch, not a renderer fork. Remove it when a released upstream version satisfies the same regression gate without changing settled behavior.
@@ -68,23 +73,24 @@ Run the complete renderer gate after any TUI or terminal dependency change:
cargo fmt --all -- --check
cargo clippy -p dext --all-targets --all-features --locked --no-deps -- -D warnings
cargo audit --deny warnings
+cargo deny check licenses
cargo test -p ratatui-core --lib --locked
cargo build --release --locked
cargo test --release --locked
cargo test --release --locked --test tui_smoke -- --nocapture
```
-The PTY smoke suite exercises the real binary and requires:
+The PTY smoke suite starts each Dext child in a fresh session with the slave PTY as its controlling terminal and applies resize geometry through that slave endpoint, matching real terminal resize delivery on macOS and Linux. Resize assertions wait for the replay marker with a bounded deadline rather than assuming a fixed scheduler delay on shared CI hosts. It exercises the real binary and requires:
- banner and composer visibility at narrow and wide sizes;
- editable input during live streaming;
- process survival and responsive input through a populated-history resize burst;
-- zero whole-screen clears during inline resize;
+- one visible-display clear before one scrollback purge for every effective populated-transcript width change, followed immediately by a complete logical-transcript replay at the observed width with exactly one Dext intro;
+- repeated frames at the same width do not rebuild, while simultaneous width/height shrink still reconstructs the complete transcript from the origin;
- cursor queries bounded by resize events rather than transcript size;
-- terminal-height-bounded replay chunks;
-- completed stream output and accepted input after resize, with a bounded 10-second completion wait so slower macOS CI hosts do not create false negatives.
+- replay chunks bounded by terminal height, with pending output appended only after reconstruction;
-Before releasing a renderer/backend update, also perform a live WSL2 check because ConPTY latency and perceptual flicker cannot be fully modeled by the Linux PTY. Resize a populated streaming session repeatedly and reject any visible replay, flash, input stall, scrollback loss, or mode-switching change. Native Linux and tmux checks are also recommended when terminal behavior changes.
+Before releasing a renderer/backend update, also perform a live WSL2 check because ConPTY latency and perceptual flicker cannot be fully modeled by the Linux PTY. Resize a populated streaming session repeatedly and reject any crash, input stall, mixed-width or duplicate history, unexpected scrollback loss outside the documented full-ownership rebuild, or mode-switching change. Full replay during each observed width change and loss of pre-Dext shell scrollback are documented tradeoffs, not regressions. Native Linux and tmux checks are also recommended when terminal behavior changes.
## Dependency maintenance
diff --git a/docs/USAGE.md b/docs/USAGE.md
index a38c99d..4d8d08a 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -138,6 +138,8 @@ The OAuth-backed `chatgpt` provider remains on the Codex Responses contract. Its
Built-in input/cached-input/output prices per million tokens are Sol `$5/$0.50/$30`, Terra `$2.50/$0.25/$15`, and Luna `$1/$0.10/$6`; above 272,000 input tokens, Dext applies the documented 2× input/cache and 1.5× output tier unless explicit pricing overrides are set.
+The built-in Anthropic catalog enables adaptive thinking for Sonnet 4.6, Sonnet 5, Opus 4.6/4.7/4.8, Opus 5, and Fable 5. These requests carry the selected output effort and omit `thinking.display`, allowing the provider stream to include thinking deltas. The transformed official OAuth request and the API-key request retain the same adaptive fields. `xhigh` effort maps natively on Sonnet 5, Opus 4.7/4.8, Opus 5, and Fable 5; `max` additionally covers Sonnet 4.6 and Opus 4.6; unsupported extended levels downgrade to `high`. Sonnet 5, Opus 5, and Fable 5 declare 1,000,000-token context windows; other Claude models keep the 200K fallback. Built-in input/cache-read/output prices per million tokens are Sonnet 5 `$2/$0.20/$10`, Opus 4.5–4.8 and Opus 5 `$5/$0.50/$25`, earlier Sonnet `$3/$0.30/$15`, Fable 5 `$10/$1/$50`, and Opus 4.1-and-earlier `$15/$1.50/$75`; Anthropic documents standard per-token rates across the full 1M window for these models, so no long-context tier applies. TUI visibility remains a presentation choice: verbose display shows thinking, `stream-json` emits thinking events, and console text/final JSON omit thinking content.
+
The built-in Kimi Code catalog uses Anthropic Messages semantics at `https://api.kimi.com/coding`, defaults to K3, and reports zero incremental token cost because access is covered by the coding plan. Verified K3 metadata enables adaptive thinking with `max` effort and preserves empty thinking signatures required by that model; these compatibility rules do not apply to generic/custom Anthropic profiles.
## Local Qwen / llama.cpp
@@ -173,6 +175,8 @@ Start Dext:
dext
```
+Planning is conversational, not a mode. Ask for a plan (“plan the refactor”, “review this for bugs, don’t change anything”) and Dext applies an advisory-only turn policy: read-only tools and a structured Goal/Findings/Steps/Risks answer. Revise the plan in the same thread, then approve it (“go”, “proceed with the plan”) and Dext converts the agreed steps into todos and executes them. The policy steers the model; approval prompts and `/sandbox-profile read-only` remain the hard enforcement layers.
+
Useful slash commands:
```text
diff --git a/docs/index.html b/docs/index.html
index f699ba3..9590a40 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -439,7 +439,7 @@
The agent that lives in your terminal.
Dext — Technical Documentation
Dext is a single-binary Rust coding agent that runs from your terminal. It keeps project-scoped session state and gives the model a curated set of native tools for filesystem operations, search, shell execution, data processing, HTTP requests, Git operations, and task tracking.
Dext is source-first: prompts, runtime state, tool policies, provider wiring, and the TUI all live in the repository with no external service required beyond your chosen model provider.
-
Explicit continuity, not autonomous memory: By default, Dext autosaves session state under a project-specific key, but restores it only when you use --resume or a session command. Optional recall.md and Seat summaries are user-authored context; --no-session disables durable session/log writes.
+
Policy-governed continuity: By default, Dext autosaves session state under a project-specific key, but restores it only when you use --resume or a session command. recall.md is bounded agent working memory: project DEXT.md policy decides whether the agent maintains it, and every native write stays inside normal tool approval, mutation preview, checkpoint, 4 KiB size, and privacy-redaction controls. Seat summaries are user-authored context; --no-session disables durable session/log writes.
Canonical documentation: this GitHub Pages site is the main technical reference and is deployed from docs/ after each reviewed change to main. Focused Markdown guides remain supplemental. Open non-documentation risks are tracked in the risk register.
Developer verification: run cargo fmt --all -- --check, Clippy with warnings denied, cargo audit --deny warnings, cargo deny check licenses, the vendored ratatui-core tests, release build/tests, and the PTY smoke suite. Reinstall with cargo install --path . --force --locked so the binary on PATH matches the source. Renderer-specific requirements are documented in docs/TUI.md.
@@ -647,7 +647,7 @@
Main.rs — Agent Core
Tui.rs — Terminal UI .rs
-
An inline TUI built on Ratatui + Crossterm that renders in the regular terminal buffer and preserves native terminal scrollback. The backend output viewer is the only alternate-screen surface.
+
An inline TUI built on Ratatui + Crossterm that renders in the regular terminal buffer and uses native terminal scrollback during ordinary operation. On every effective transcript-pane width change, Dext immediately replaces stale-width scrollback with a complete replay of its logical transcript at the observed width. The backend output viewer is the only alternate-screen surface.
Layout
Transcript area — Scrollable message history with user, assistant, tool, thinking, and steering lines
@@ -835,7 +835,7 @@
Layout
Startup Welcome
The startup welcome remains inline transcript content, so it scrolls away naturally with the conversation. One transcript-owned blank separator row keeps it visually distinct from CLI approval and sandbox diagnostics, including after inline viewport placement or replay. Its brand row shows Dext and the version, plus the working directory and cached Git branch/state at 80 columns or wider; narrower terminals drop that right segment. Exactly two facts, Model and Approval, sit between horizontal rules, followed by one rotating tip selected from verified commands and key bindings. Terminal-cell width controls alignment and path truncation. Git status uses one git status --porcelain=v1 --branch probe off the render loop, with only an 8 ms startup wait before path-only fallback. The empty composer reads ❯ Type a request… @ files · / commands.
Permission Prompts
-
Tool calls requiring permission show inline prompts with risk tier color coding (yellow for Read/Write, red for Danger). Choices: Once, Always (session), Deny.
+
Tool calls requiring permission render the pending prompt inside the inline viewport (with risk tier color coding: yellow for Read/Write, red for Danger), not into terminal scrollback. Choices: Once, Always (session), Deny. Only the compact decision line is appended to the transcript once resolved. Because inline scrollback is append-only, this keeps approvals from re-emitting (and therefore duplicating) the full history that older builds produced for the prompt spotlight and prompt-to-result swap.
Key Bindings
Key
Action
@@ -859,8 +859,8 @@
Tool Result Rendering
Markdown Rendering
Assistant text blocks are rendered using tui-markdown with sanitization and terminal-safe formatting rules. Frugal mode applies the stricter pseudo-tool-protocol sanitizer across partial-stream recovery, transcript rendering, and the inspector: serialized or multiline tool-call-like assistant payloads are replaced with [tool call redacted; waiting for structured tool event] while surrounding prose remains visible. Standard mode retains the narrower legacy line detector. Rendering preserves readability under narrow widths and avoids alternate-screen dependence. Thinking and steering blocks use a light or dark contrast palette. DEXT_THEME=light|dark is the explicit override; otherwise Dext converts the terminal's COLORFGBG 16/256-color background index to luminance when present and falls back to dark.
Dependency and resize contract
-
The exact stack is ratatui 0.30.2, ratatui-core 0.1.2, tui-markdown 0.3.8, crossterm 0.29.0, and unicode-width 0.2.2. The lockfile pins Ratatui's transitive lru cache to patched 0.18.2. Dext selects exact versions and patches exact vendored ratatui-core source to avoid synchronous cursor-position queries during inline replay and whole-display clears during horizontal shrink.
-
The real-PTY regression suite verifies editable input during streaming, resize survival with populated history, zero whole-screen resize clears, bounded cursor queries, terminal-height-bounded replay chunks, and completed output after resize. Its final stream marker has a bounded 10-second wait so slower macOS CI hosts do not create false negatives. See docs/TUI.md and vendor/ratatui-core/DEXT_PATCH.md for the maintained contract and patch rationale.
+
The exact stack is ratatui 0.30.2, ratatui-core 0.1.2, tui-markdown 0.3.8, crossterm 0.29.0, and unicode-width 0.2.2. The lockfile pins Ratatui's transitive lru cache to patched 0.18.2. Dext selects exact versions and patches exact vendored ratatui-core source to avoid synchronous cursor-position queries during inline replay and extra whole-display clears during horizontal shrink, and to add Terminal::reset_inline_viewport. Every effective transcript-pane width change uses one synchronized terminal update: Dext clears the visible display and resets the inline viewport to the origin without a cursor query, purges stale-width scrollback, and immediately rebuilds the complete logical transcript at the observed width before appending pending output. Clearing before purging removes the still-visible old intro before logical history replays it once. Repeated frames at the same width do not rebuild. There is no quiet-settle debounce, visible-suffix overwrite, or short-history exception. This removes mixed-width history, duplicate copies, and width/height-shrink bookkeeping edge cases; the explicit tradeoffs are complete replay work during resize bursts and replacement of pre-Dext shell scrollback. A prepared insertion batch that fails remains separate from newly queued raw output, so retry does not regroup or rerank it.
+
The real-PTY regression suite starts each Dext child in a fresh session with the slave PTY as its controlling terminal and applies resize geometry through that slave endpoint, matching real terminal resize delivery on macOS and Linux. Resize assertions wait for the replay marker with a bounded deadline rather than assuming a fixed scheduler delay on shared CI hosts. It verifies editable input during streaming, resize survival with populated history, bounded cursor queries, a visible-display clear before each paired scrollback purge, exactly one Dext intro in every replay segment, terminal-height-bounded complete replay, reconstruction through simultaneous width/height shrink, no repeated rebuild at a stable width, and completed output after resize. Its final stream marker has a bounded 10-second wait so slower macOS CI hosts do not create false negatives. See docs/TUI.md and vendor/ratatui-core/DEXT_PATCH.md for the maintained contract and patch rationale.
Safety & Recovery
@@ -1118,7 +1118,7 @@
Real-PTY TUI Regression Suite (tests/tui_smoke.rs)
Editable input while output streams and process survival during populated-history resize bursts
-
Zero whole-screen clears, resize-bounded cursor queries, and terminal-height-bounded replay chunks
+
One visible-display clear before one scrollback purge per effective populated-transcript width change, exactly one Dext intro per replay segment, resize-bounded cursor queries, and terminal-height-bounded complete replay chunks
Completed stream output and accepted input after resize, with no crash markers
Run with: cargo test --release --locked --test tui_smoke -- --nocapture
@@ -1207,14 +1207,16 @@
Other Subcommands
Slash Commands
+
Slash completion lists each /login provider exactly once by its provider id; numbered selector aliases remain accepted but are not shown as duplicate login choices. Planning is conversational rather than a separate mode: ask Dext to inspect and propose a plan without editing, revise it in the same thread, then tell it to proceed. The retired /plan command is no longer intercepted, so equivalent text is handled as an ordinary prompt. When a prompt reads as planning/analysis-only (for example “plan …”, “review …”, “don’t change anything”), Dext injects an advisory-only turn policy into the volatile runtime status directing read-only tools and a structured Goal/Findings/Steps/Risks answer; a bare approval such as “go” or “proceed with the plan” injects an implementation policy directing the agreed plan into todos before editing. Explicit mutation requests always win over scoping clauses, question-phrased prompts (ending in ?) are never treated as approvals, and mid-turn queued user updates re-evaluate the policy so a steering approval or hold-off takes effect immediately. The policy note steers rather than blocks — approval prompts and /sandbox-profile read-only remain the deterministic enforcement layers for untrusted or weaker models.
+
Structured listings from /help, /tools, /system, packs, shelves, and sessions retain the established /sessions hierarchy: compact count headers, bold section labels, two-space names, four-space details, and detached Use: footers. Dense name/description catalogs such as /help use aligned rows at 64 columns and wider, with hanging description wraps, and fall back to stacked rows on narrow panes. The TUI passes its actual transcript-pane width into slash rendering; output keeps a two-cell gutter and a 120-column readability cap. /system preserves prompt paragraphs, blank lines, and leading indentation while wrapping prose rather than hard-splitting every display-width chunk. Dynamic fields have terminal escapes, controls, line separators, and bidi formatting controls removed before layout. Every physical row is bounded by Unicode display cells; only a grapheme that cannot fit in an otherwise impossible one-cell measure is replaced with ?. An explicit structured-slash event keeps listings free of dim bullet prefixes when ANSI color is disabled; generic confirmations such as /model and thinking-effort status retain the faded info treatment.
Command
Description
-
/help
Show available commands
+
/help
Show available commands as a grouped listing in the established /sessions presentation
/quit, /exit
Exit Dext
/reset
Clear conversation history
-
/tools [default|full]
List or switch provider-visible tools
+
/tools [default|full]
List (profile, exposed, permission-gated, explicit session grants, hidden sections) or switch provider-visible tools
/history
Show turn count and last 5 messages
-
/system [text]
Show or replace system prompt
+
/system [text]
Show the composed system prompt with its source files, or replace the base prompt
/allow <tool>
Auto-approve a native or active runtime tool for this session
/revoke <tool>
Remove auto-approval for a tool
/allowed
List native and active-runtime grants
@@ -1242,8 +1244,7 @@
Slash Commands
/save <name>
Save history/config to named JSONL session
/export [html|jsonl] [path]
Export the current session
/resume [name]
Resume latest or named session
-
/sessions ...
List/analyze/brief/grep/failures/verification/decisions session history
-
/plan <task>
Run read-only planning and seed the plan into history
+
/sessions ...
List session history in the established session presentation, or analyze/brief/grep/failures/verification/decisions
/budget [cap]
Set/show a dollar, token, or combined cap; off clears it
/pack create|list|inspect|run
Create or use shelf-contained packs
/shelves
Show shelf registry
@@ -1392,8 +1393,8 @@
OpenAI API
Anthropic
-
The catalog includes current Sonnet, Opus, Fable, and Haiku model variants. Adaptive-thinking requests send no thinking.display preference, so always-adaptive models (Opus 4.7/4.8, Fable) stream visible thinking like Sonnet 4.6 instead of suppressing it with display:"omitted".
-
Context fallback: 200K tokens. dext auth login anthropic or /login anthropic starts the unofficial Claude Pro/Max subscription OAuth flow; re-run with web to replace an existing OAuth credential. ANTHROPIC_API_KEY remains the standard Anthropic Console API-key path.
+
The catalog includes current Sonnet, Opus, Fable, and Haiku model variants. Adaptive-capable public models (Sonnet 4.6, Sonnet 5, Opus 4.6/4.7/4.8, Opus 5, and Fable 5) send adaptive thinking plus the selected output effort without a thinking.display member, allowing Anthropic to return thinking deltas instead of requesting an omitted display. xhigh effort maps natively on Sonnet 5, Opus 4.7/4.8, Opus 5, and Fable 5; max additionally covers Sonnet 4.6 and Opus 4.6; unsupported extended levels downgrade to high. Built-in pricing follows the published per-model rates (Sonnet 5 $2/$10, Opus 4.5–4.8 and Opus 5 $5/$25, Fable 5 $10/$50, Opus 4.1-and-earlier $15/$75 per MTok input/output), which Anthropic applies at standard per-token rates across the full context window. The TUI presents thinking only while verbose display is enabled; stream-json emits thinking events, while console text and final JSON omit thinking content.
+
Context fallback: 200K tokens; Sonnet 5, Opus 5, and Fable 5 declare 1M-token windows. dext auth login anthropic or /login anthropic starts the unofficial Claude Pro/Max subscription OAuth flow; re-run with web to replace an existing OAuth credential. ANTHROPIC_API_KEY remains the standard Anthropic Console API-key path.
Subscription OAuth uses Dext-native request construction with a Claude Code 2.1.224-compatible billing/version fingerprint, seeded XXH64 body checksum, Agent SDK system block, OAuth beta/header set, and per-session/per-request UUIDs. Optional identity metadata is read in memory from ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json only after bounded regular non-symlink validation; identifiers are not copied, printed, or persisted by Dext.
This compatibility path is unofficial, may be restricted by provider terms, and is version-specific. It fails closed during request construction and is isolated from API keys, non-official endpoints, GLM, Kimi, and custom Anthropic-compatible profiles.
@@ -1429,7 +1430,7 @@
.env (~/.dext/.env or $DEXT_HOME/.env)
DEXT.md (sandbox ancestry)
Tracked machine-facing project guidance auto-injected into the system prompt. Dext scans the sandbox root and ancestors, labels it as project-controlled guidance, and does not modify the file automatically.
recall.md (optional sandbox ancestry)
-
Optional ignored prompt cache. It is auto-injected only when present; Dext does not create or update it automatically.
+
Git-ignored agent working memory, auto-injected into the system prompt when present. Whether the agent maintains it is project policy carried by DEXT.md, not an unconditional built-in action. Native write_file, edit_file, and multi_edit mutations whose target filename is recall.md (ASCII case-insensitive) are rejected when the resulting file would exceed 4 KiB; injected recall payloads also share one aggregate 4 KiB budget across sandbox ancestry, so external or shell-written files cannot bypass the context bound. Under the active privacy mode (enabled by default), those native mutation payloads are privacy-redacted before preview, approval, journaling, assistant-history persistence, and disk write, so reviewed, durable, and later model-visible forms agree and secret-shaped content the model saw cannot persist through a native recall mutation. Recall content is redacted again before prompt injection to cover external writers; /privacy off disables these scrubs along with all other redaction. Raw source-file hashes remain provenance evidence. Writes retain normal approval, sandbox, mutation-preview, and checkpoint controls.
hooks.json (project root or DEXT_HOOKS_FILE)
Project-root hooks configuration loaded from hooks.json by default, or from DEXT_HOOKS_FILE when set. This is not the same as pack phooks.json. Hook execution still requires its own approval. Approved pre_tool and post_tool hooks receive privacy-redacted DEXT_TOOL_INPUT; approved post_tool hooks also receive privacy-redacted DEXT_TOOL_RESULT, not raw input or output.
diff --git a/src/events.rs b/src/events.rs
index 0e873d6..c23b961 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -119,6 +119,7 @@ pub(crate) enum AgentEvent {
Warn(String),
Error(String),
Slash(String),
+ StructuredSlash(String),
TurnEnd {
usage: Usage,
failed: bool,
diff --git a/src/list_render.rs b/src/list_render.rs
index f72976d..065fd99 100644
--- a/src/list_render.rs
+++ b/src/list_render.rs
@@ -1,18 +1,22 @@
-//! Compact terminal-first list rendering shared by `/pack`, `/sessions`, `/shelves`, and `/help`.
+//! Compact terminal-first rendering based on the established `/sessions` layout
+//! and shared by `/pack`, `/shelves`, `/help`, `/tools`, and `/system`.
//!
-//! All list renderers share the same look: a bold header, separated per-entry
-//! blocks with a prominent name, hanging-indent wrapped descriptions, and a
-//! detached `Use:` footer. Styling is emitted as ANSI escapes only when color is
-//! enabled (interactive TTY, not piped, `NO_COLOR` unset, `TERM != dumb`); the
-//! TUI translates these escapes back into styled spans.
+//! All structured renderers use the session layout: a bold count header, bold
+//! section labels, two-space entry names, four-space details, separated blocks,
+//! and a detached `Use:` footer. Styling is emitted as ANSI escapes only when
+//! color is enabled (interactive TTY, not piped, `NO_COLOR` unset,
+//! `TERM != dumb`); the TUI translates these escapes back into styled spans.
use std::fmt::Write as _;
use std::path::Path;
use crate::session::user_home_dir;
-const MIN_WIDTH: usize = 20;
const DEFAULT_WIDTH: usize = 100;
+const MAX_WIDTH: usize = 120;
+// Keep wrapped text off the terminal's right edge and cap the measure for
+// readability on wide terminals.
+const RIGHT_GUTTER: usize = 2;
/// Rendering knobs for list views. `color` should already account for TTY,
/// `NO_COLOR`, and `TERM=dumb`; `width` is the wrapped column budget.
@@ -24,10 +28,10 @@ pub(crate) struct ListOptions {
}
impl ListOptions {
- pub(crate) fn detect(verbose: bool) -> Self {
+ pub(crate) fn detect_with_width(verbose: bool, width: Option) -> Self {
Self {
color: use_color(),
- width: terminal_width(),
+ width: width.map_or_else(terminal_width, width_for_terminal_cols),
verbose,
}
}
@@ -43,7 +47,7 @@ impl ListOptions {
}
pub(crate) fn effective_width(&self) -> usize {
- self.width.max(MIN_WIDTH)
+ self.width.max(1)
}
}
@@ -56,39 +60,120 @@ pub(crate) fn use_color() -> bool {
&& std::io::stdout().is_terminal()
}
-/// Terminal width from the controlling TTY, clamped to a sane default for
-/// non-TTY output (pipes, redirects, tests).
+/// Terminal width from the controlling TTY minus a small right gutter,
+/// clamped to a readable maximum measure. Non-TTY output (pipes, redirects,
+/// tests) keeps the fixed default.
+pub(crate) fn width_for_terminal_cols(cols: usize) -> usize {
+ cols.saturating_sub(RIGHT_GUTTER).clamp(1, MAX_WIDTH)
+}
+
pub(crate) fn terminal_width() -> usize {
- if let Ok((cols, _)) = crossterm::terminal::size()
- && cols >= MIN_WIDTH as u16
- {
- return cols as usize;
+ if let Ok((cols, _)) = crossterm::terminal::size() {
+ return width_for_terminal_cols(cols as usize);
}
DEFAULT_WIDTH
}
// --- styling primitives -----------------------------------------------------
+fn terminal_escape_end(text: &str, start: usize) -> usize {
+ let bytes = text.as_bytes();
+ let mut i = start.saturating_add(1);
+ if i >= bytes.len() {
+ return i;
+ }
+ match bytes[i] {
+ b'[' => {
+ i += 1;
+ while i < bytes.len() {
+ let byte = bytes[i];
+ i += 1;
+ if (0x40..=0x7e).contains(&byte) {
+ break;
+ }
+ }
+ }
+ b']' | b'P' | b'^' | b'_' => {
+ i += 1;
+ while i < bytes.len() {
+ if bytes[i] == 0x07 {
+ return i + 1;
+ }
+ if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\') {
+ return i + 2;
+ }
+ i += 1;
+ }
+ }
+ _ => {
+ i += text[i..].chars().next().map_or(0, char::len_utf8);
+ }
+ }
+ i.min(bytes.len())
+}
+
+fn is_bidi_format_control(ch: char) -> bool {
+ matches!(
+ ch,
+ '\u{061c}'
+ | '\u{200e}'
+ | '\u{200f}'
+ | '\u{202a}'..='\u{202e}'
+ | '\u{2066}'..='\u{2069}'
+ )
+}
+
+fn terminal_safe_text(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ let mut i = 0usize;
+ while i < text.len() {
+ if text.as_bytes()[i] == 0x1b {
+ i = terminal_escape_end(text, i);
+ continue;
+ }
+ let Some(ch) = text[i..].chars().next() else {
+ break;
+ };
+ i += ch.len_utf8();
+ match ch {
+ '\r' => {
+ if !text[i..].starts_with('\n') {
+ out.push('\n');
+ }
+ }
+ '\n' | '\u{2028}' | '\u{2029}' => out.push('\n'),
+ '\t' => out.push_str(" "),
+ _ if ch.is_control() || is_bidi_format_control(ch) => {}
+ _ => out.push(ch),
+ }
+ }
+ out
+}
+
/// Bold (`\x1b[1m`) when color is enabled; identity otherwise.
pub(crate) fn bold(s: &str, color: bool) -> String {
+ let s = terminal_safe_text(s);
if color {
format!("\x1b[1m{s}\x1b[0m")
} else {
- s.to_string()
+ s
}
}
/// Dim/faint (`\x1b[2m`) when color is enabled.
pub(crate) fn dim(s: &str, color: bool) -> String {
+ let s = terminal_safe_text(s);
if color {
format!("\x1b[2m{s}\x1b[0m")
} else {
- s.to_string()
+ s
}
}
/// Inline styled label such as `source:` keys.
pub(crate) fn label(key: &str, value: &str, color: bool) -> String {
+ let key = terminal_safe_text(key);
+ let value = terminal_safe_text(value);
if color {
format!("\x1b[36m{key}\x1b[0m {value}")
} else {
@@ -131,23 +216,29 @@ pub(crate) fn display_path(path: &Path, opts: &ListOptions, root: &Path) -> Stri
// --- word wrap --------------------------------------------------------------
-fn word_chunks(word: &str, width: usize) -> Vec<&str> {
+fn word_chunks(word: &str, width: usize) -> Vec {
let mut chunks = Vec::new();
- let mut start = 0usize;
+ let mut chunk = String::new();
let mut cells = 0usize;
- for c in crate::tui::display_clusters(word) {
- if cells > 0 && cells + c.width > width {
- chunks.push(&word[start..c.byte_start]);
- start = c.byte_start;
+ for cluster in crate::tui::display_clusters(word) {
+ let source = &word[cluster.byte_start..cluster.byte_start + cluster.byte_len];
+ let (text, cluster_width) = if cluster.width > width {
+ ("?", 1)
+ } else {
+ (source, cluster.width)
+ };
+ if cells > 0 && cells + cluster_width > width {
+ chunks.push(std::mem::take(&mut chunk));
cells = 0;
}
- cells += c.width;
+ chunk.push_str(text);
+ cells += cluster_width;
}
- if start < word.len() {
- chunks.push(&word[start..]);
+ if !chunk.is_empty() {
+ chunks.push(chunk);
}
if chunks.is_empty() {
- chunks.push(word);
+ chunks.push(word.to_string());
}
chunks
}
@@ -155,6 +246,8 @@ fn word_chunks(word: &str, width: usize) -> Vec<&str> {
/// Word-wrap `text` to `width` columns, returning lines. Each line is at most
/// `width` display columns except for a single glyph wider than `width`.
pub(crate) fn wrap_lines(text: &str, width: usize) -> Vec {
+ let sanitized = terminal_safe_text(text);
+ let text = sanitized.as_str();
let width = width.max(1);
let mut out = Vec::new();
for paragraph in text.split('\n') {
@@ -167,17 +260,17 @@ pub(crate) fn wrap_lines(text: &str, width: usize) -> Vec {
let mut line_w = 0usize;
for word in words {
for chunk in word_chunks(word, width) {
- let w = unicode_width::UnicodeWidthStr::width(chunk);
+ let w = unicode_width::UnicodeWidthStr::width(chunk.as_str());
if line.is_empty() {
- line.push_str(chunk);
+ line.push_str(&chunk);
line_w = w;
} else if line_w + 1 + w > width {
out.push(std::mem::take(&mut line));
- line.push_str(chunk);
+ line.push_str(&chunk);
line_w = w;
} else {
line.push(' ');
- line.push_str(chunk);
+ line.push_str(&chunk);
line_w += 1 + w;
}
}
@@ -194,6 +287,8 @@ pub(crate) fn wrap_lines(text: &str, width: usize) -> Vec {
/// Append `text` wrapped to `width` with a `hang`-column hanging indent.
pub(crate) fn write_wrapped(out: &mut String, text: &str, hang: usize, width: usize) {
+ let width = width.max(1);
+ let hang = hang.min(width.saturating_sub(1));
let body_w = width.saturating_sub(hang).max(1);
let pad = " ".repeat(hang);
for line in wrap_lines(text, body_w) {
@@ -201,8 +296,168 @@ pub(crate) fn write_wrapped(out: &mut String, text: &str, hang: usize, width: us
}
}
+/// Preserve input line and indentation boundaries while word-wrapping prose.
+/// Oversized tokens are split only when they cannot fit on an otherwise empty line.
+pub(crate) fn write_preformatted_wrapped(out: &mut String, text: &str, width: usize) {
+ let safe = terminal_safe_text(text);
+ let width = width.max(1);
+ for line in safe.split('\n') {
+ if line.trim().is_empty() {
+ out.push('\n');
+ continue;
+ }
+ let body = line.trim_start_matches(' ');
+ let indent = line
+ .len()
+ .saturating_sub(body.len())
+ .min(width.saturating_sub(1));
+ let padding = " ".repeat(indent);
+ for wrapped in wrap_lines(body, width.saturating_sub(indent).max(1)) {
+ let _ = writeln!(out, "{padding}{wrapped}");
+ }
+ }
+}
+
+fn write_indented_preformatted(
+ out: &mut String,
+ text: &str,
+ indent: usize,
+ width: usize,
+ color: bool,
+) {
+ let width = width.max(1);
+ let indent = indent.min(width.saturating_sub(1));
+ let body_width = width.saturating_sub(indent).max(1);
+ let padding = " ".repeat(indent);
+ for source_line in text.split('\n') {
+ for chunk in word_chunks(source_line, body_width) {
+ let _ = writeln!(out, "{padding}{}", bold(&chunk, color));
+ }
+ }
+}
+
// --- layout helpers ---------------------------------------------------------
+/// Render one compact four-space-indented metadata line. Embedded line breaks
+/// remain indented so untrusted values cannot escape the detail column.
+pub(crate) fn render_metadata(meta: &[(&str, String)], opts: &ListOptions) -> String {
+ let width = opts.effective_width();
+ let safe_pairs: Vec<(String, String)> = meta
+ .iter()
+ .map(|(key, value)| {
+ (
+ terminal_safe_text(key).trim().to_string(),
+ terminal_safe_text(value),
+ )
+ })
+ .collect();
+ if safe_pairs.is_empty() {
+ return String::new();
+ }
+ let metadata_indent = 4.min(width.saturating_sub(1));
+ let metadata_padding = " ".repeat(metadata_indent);
+ let inline_plain = safe_pairs
+ .iter()
+ .map(|(key, value)| format!("{key}: {value}"))
+ .collect::>()
+ .join(" ");
+ let mut out = String::new();
+ if !inline_plain.contains('\n')
+ && metadata_indent
+ .saturating_add(unicode_width::UnicodeWidthStr::width(inline_plain.as_str()))
+ <= width
+ {
+ let inline_styled = safe_pairs
+ .iter()
+ .map(|(key, value)| label(&format!("{key}:"), value, opts.color))
+ .collect::>()
+ .join(" ");
+ let _ = writeln!(out, "{metadata_padding}{inline_styled}");
+ return out;
+ }
+
+ let body_width = width.saturating_sub(metadata_indent).max(1);
+ for (key, value) in safe_pairs {
+ let key = format!("{key}:");
+ let pair = if value.is_empty() {
+ key.clone()
+ } else {
+ format!("{key} {value}")
+ };
+ for (index, row) in wrap_lines(&pair, body_width).into_iter().enumerate() {
+ if index == 0
+ && let Some(rest) = row.strip_prefix(&key)
+ {
+ let _ = writeln!(
+ out,
+ "{metadata_padding}{}",
+ label(&key, rest.trim_start(), opts.color)
+ );
+ } else {
+ let _ = writeln!(out, "{metadata_padding}{row}");
+ }
+ }
+ }
+ out
+}
+
+/// Render compact name/description rows. Wide views align descriptions into a
+/// shared column; narrow views retain the familiar stacked session hierarchy.
+pub(crate) fn render_entry_rows(entries: &[(&str, &str)], opts: &ListOptions) -> String {
+ const MIN_COLUMNS_WIDTH: usize = 64;
+ const MAX_NAME_WIDTH: usize = 38;
+ const DESCRIPTION_GAP: usize = 3;
+
+ let width = opts.effective_width();
+ let safe_entries: Vec<(String, String)> = entries
+ .iter()
+ .map(|(name, description)| {
+ (
+ terminal_safe_text(name).trim().to_string(),
+ terminal_safe_text(description).trim().to_string(),
+ )
+ })
+ .collect();
+ let name_width = safe_entries
+ .iter()
+ .map(|(name, _)| unicode_width::UnicodeWidthStr::width(name.as_str()))
+ .max()
+ .unwrap_or(0)
+ .min(MAX_NAME_WIDTH);
+ let description_indent = 2 + name_width + DESCRIPTION_GAP;
+ let use_columns = width >= MIN_COLUMNS_WIDTH
+ && description_indent.saturating_add(20) <= width
+ && safe_entries.iter().all(|(name, _)| {
+ !name.contains('\n')
+ && unicode_width::UnicodeWidthStr::width(name.as_str()) <= name_width
+ });
+
+ let mut out = String::new();
+ for (name, description) in safe_entries {
+ if !use_columns {
+ write_indented_preformatted(&mut out, &name, 2, width, opts.color);
+ if !description.is_empty() {
+ write_wrapped(&mut out, &description, 4, width);
+ }
+ continue;
+ }
+
+ let display_width = unicode_width::UnicodeWidthStr::width(name.as_str());
+ let padding = " ".repeat(name_width.saturating_sub(display_width) + DESCRIPTION_GAP);
+ let wrapped = wrap_lines(
+ &description,
+ width.saturating_sub(description_indent).max(1),
+ );
+ let first = wrapped.first().map_or("", String::as_str);
+ let _ = writeln!(out, " {}{padding}{first}", bold(&name, opts.color));
+ let continuation_padding = " ".repeat(description_indent);
+ for line in wrapped.iter().skip(1) {
+ let _ = writeln!(out, "{continuation_padding}{line}");
+ }
+ }
+ out
+}
+
/// Render a single list entry block: a bold name line, an indented description
/// (wrapped), and indented metadata pairs. Ends with a trailing blank line so
/// consecutive entries are visually separated.
@@ -214,39 +469,75 @@ pub(crate) fn render_entry(
) -> String {
let mut out = String::new();
let hang = 4;
- let _ = writeln!(out, " {}", bold(name, opts.color));
- if !description.trim().is_empty() {
- write_wrapped(&mut out, description.trim(), hang, opts.effective_width());
+ let safe_name = terminal_safe_text(name);
+ write_indented_preformatted(&mut out, &safe_name, 2, opts.effective_width(), opts.color);
+ let safe_description = terminal_safe_text(description);
+ if !safe_description.trim().is_empty() {
+ write_wrapped(
+ &mut out,
+ safe_description.trim(),
+ hang,
+ opts.effective_width(),
+ );
}
if !meta.is_empty() {
- let pairs: Vec = meta
- .iter()
- .map(|(k, v)| label(&format!("{k}:"), v, opts.color))
- .collect();
- let _ = writeln!(out, "{}{}", " ".repeat(hang), pairs.join(" "));
+ out.push_str(&render_metadata(meta, opts));
}
out.push('\n');
out
}
+/// Render a bold section heading in the established session layout.
+pub(crate) fn render_section_header(title: &str, opts: &ListOptions) -> String {
+ let mut out = String::new();
+ let safe_title = terminal_safe_text(title);
+ write_indented_preformatted(&mut out, &safe_title, 0, opts.effective_width(), opts.color);
+ out
+}
+
/// Standard `Use:` footer block.
pub(crate) fn render_footer(commands: &[&str], opts: &ListOptions) -> String {
- let mut out = String::new();
- let _ = writeln!(out, "{}", bold("Use:", opts.color));
+ let mut out = render_section_header("Use:", opts);
for cmd in commands {
- let _ = writeln!(out, " {cmd}");
+ let safe_cmd = terminal_safe_text(cmd);
+ write_indented_preformatted(&mut out, &safe_cmd, 2, opts.effective_width(), false);
}
out
}
-/// Standard list header line: `Title found`.
+/// Standard list header line: `Title `.
+pub(crate) fn render_count_header(
+ title: &str,
+ count: usize,
+ noun: &str,
+ opts: &ListOptions,
+) -> String {
+ let safe_title = terminal_safe_text(title);
+ let count_label = terminal_safe_text(&format!("{count} {noun}"));
+ let compact = format!("{safe_title} {count_label}");
+ if !compact.contains('\n')
+ && unicode_width::UnicodeWidthStr::width(compact.as_str()) <= opts.effective_width()
+ {
+ return format!(
+ "{} {}\n",
+ bold(&safe_title, opts.color),
+ dim(&count_label, opts.color),
+ );
+ }
+
+ let mut out = String::new();
+ write_indented_preformatted(&mut out, &safe_title, 0, opts.effective_width(), opts.color);
+ for source_line in count_label.split('\n') {
+ for chunk in word_chunks(source_line, opts.effective_width()) {
+ let _ = writeln!(out, "{}", dim(&chunk, opts.color));
+ }
+ }
+ out
+}
+
+/// Discovery-list header line: `Title found`.
pub(crate) fn render_header(title: &str, count: usize, opts: &ListOptions) -> String {
- format!(
- "{} {} {}\n",
- bold(title, opts.color),
- dim(&count.to_string(), opts.color),
- dim("found", opts.color),
- )
+ render_count_header(title, count, "found", opts)
}
/// Parse `--verbose` / `-v` / `--paths` out of a slash argument, returning
diff --git a/src/main.rs b/src/main.rs
index d3f04dd..8c5d703 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -701,6 +701,18 @@ fn maybe_preserve_partial_stream(
true
}
+fn sanitize_recall_tool_inputs_for_history(
+ blocks: &mut [Block],
+ privacy: &PrivacyPolicy,
+ root: &Path,
+) {
+ for block in blocks {
+ if let Block::ToolUse { name, input, .. } = block {
+ privacy.redact_recall_tool_input(name, input, root);
+ }
+ }
+}
+
fn maybe_preserve_assistant_response(
blocks: &[Block],
history: &mut Vec,
@@ -1551,6 +1563,12 @@ impl OutputMode {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SlashPresentation {
+ Faded,
+ Structured,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashUiUpdate {
None,
@@ -1749,7 +1767,7 @@ impl EventSink for ConsoleSink {
AgentEvent::Info(s) => println!("{s}"),
AgentEvent::Warn(s) => eprintln!("{s}"),
AgentEvent::Error(s) => eprintln!("{s}"),
- AgentEvent::Slash(s) => println!("{s}"),
+ AgentEvent::Slash(s) | AgentEvent::StructuredSlash(s) => println!("{s}"),
AgentEvent::TurnEnd { usage, .. } => {
println!(
"{}",
@@ -1944,18 +1962,6 @@ fn emit_external_telemetry(sink: &mut dyn EventSink, state: &orchestrator::TurnR
});
}
-struct SilentSink;
-
-impl EventSink for SilentSink {
- fn emit(&mut self, _event: AgentEvent) {}
-
- fn request_permission(&mut self, _name: &str, _input: &Value) -> Choice {
- Choice::Deny
- }
-
- fn local_auth_prompt(&mut self, _tool: &str, _message: &str) {}
-}
-
#[cfg(test)]
struct NullSink;
@@ -2557,14 +2563,14 @@ fn anthropic_output_config_effort(model: &str, effort: ThinkingEffort) -> Option
ThinkingEffort::Medium => "medium",
ThinkingEffort::High => "high",
ThinkingEffort::XHigh => {
- if anthropic_model_supports_extended_effort(model) {
+ if anthropic_model_supports_xhigh_effort(model) {
"xhigh"
} else {
"high"
}
}
ThinkingEffort::Max => {
- if anthropic_model_supports_extended_effort(model) {
+ if anthropic_model_supports_max_effort(model) {
"max"
} else {
"high"
@@ -2574,19 +2580,30 @@ fn anthropic_output_config_effort(model: &str, effort: ThinkingEffort) -> Option
Some(effort.to_string())
}
-fn anthropic_model_is_always_adaptive(model: &str) -> bool {
+// Effort availability follows the official effort doc: `xhigh` exists on
+// Sonnet 5, Opus 4.7/4.8, Opus 5, and Fable 5; `max` additionally covers the
+// 4.6 generation.
+fn anthropic_model_supports_xhigh_effort(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase();
model.contains("opus-4-7")
|| model.contains("opus-4.7")
|| model.contains("opus-4-8")
|| model.contains("opus-4.8")
+ || model.contains("opus-5")
+ || model.contains("opus5")
+ || model.contains("sonnet-5")
+ || model.contains("sonnet5")
|| model.contains("fable-5")
|| model.contains("fable5")
- || model.contains("mythos")
}
-fn anthropic_model_supports_extended_effort(model: &str) -> bool {
- anthropic_model_is_always_adaptive(model)
+fn anthropic_model_supports_max_effort(model: &str) -> bool {
+ let model = model.trim().to_ascii_lowercase();
+ anthropic_model_supports_xhigh_effort(&model)
+ || model.contains("opus-4-6")
+ || model.contains("opus-4.6")
+ || model.contains("sonnet-4-6")
+ || model.contains("sonnet-4.6")
}
fn anthropic_model_supports_adaptive_thinking(model: &str) -> bool {
@@ -2597,11 +2614,14 @@ fn anthropic_model_supports_adaptive_thinking(model: &str) -> bool {
|| model.contains("opus-4.7")
|| model.contains("opus-4-8")
|| model.contains("opus-4.8")
+ || model.contains("opus-5")
+ || model.contains("opus5")
|| model.contains("sonnet-4-6")
|| model.contains("sonnet-4.6")
+ || model.contains("sonnet-5")
+ || model.contains("sonnet5")
|| model.contains("fable-5")
|| model.contains("fable5")
- || model.contains("mythos")
}
fn uses_anthropic_adaptive_thinking(provider_id: &str, model: &str) -> bool {
@@ -2698,8 +2718,6 @@ struct AnthropicThinking {
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
budget_tokens: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- display: Option<&'static str>,
}
#[derive(Serialize, Clone)]
@@ -9866,6 +9884,21 @@ fn json_prompt_value(value: &Value) -> String {
prompt_safe_json_text(serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
}
+fn utc_date_from_unix_secs(timestamp: u64) -> String {
+ let z = (timestamp / 86_400) as i64 + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let day_of_era = z - era * 146_097;
+ let year_of_era =
+ (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
+ let mut year = year_of_era + era * 400;
+ let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
+ let month_prime = (5 * day_of_year + 2) / 153;
+ let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
+ let month = month_prime + if month_prime < 10 { 3 } else { -9 };
+ year += i64::from(month <= 2);
+ format!("{year:04}-{month:02}-{day:02}")
+}
+
fn prompt_env_value(raw: &str, max_bytes: usize) -> String {
if max_bytes == 0 {
return String::new();
@@ -10353,7 +10386,7 @@ const DEFAULT_SYSTEM: &str = "You are dext, a terse coding CLI agent running loc
- Use only exposed tools via real provider calls; never print call JSON/syntax or bash envelopes. Obey approval and sandbox policy; if denied, ask. Use unsafe pip only if requested; avoid external state-store mutations.
- Before each call, honor runtime notes and Context State. At PIVOT REQUIRED or a pattern, stop repeating and pivot or ask.
- `[queued-user-update]` is literal active user input. Never dismiss path-only or context-looking updates; inspect an exact path first—read_file for a file, fd/rg for a directory—not guessed paths or bash/sudo discovery, and address it in the response.
-- For nontrivial work use todo. Treat DEXT.md/recall.md as guidance; modify neither unless asked.
+- For nontrivial work use todo; when the user approves a proposed plan, convert its agreed steps to todos before editing. Treat DEXT.md as human-authored policy and never modify it. Treat recall.md as working memory governed by applicable DEXT.md guidance.
- Prefer native tools: fd for files, rg for text/symbols, then focused read_file/read_symbol; use git_diff, edits, and http for their domains. Parallelize independent reads. Bash is only for orchestration, build/test/install, or gaps. Absolute reads are allowed; writes stay confined.
- Read before editing. Inspect tracked diffs first and use native git_commit. Keep calls and results focused; reuse reads instead of repeating them.
- Bash calls are atomic: backgrounding/nohup/disown cannot persist; setsid is unsupported. Use an OS supervisor with a dext- unit for requested persistent services. Inspect stderr, validate external sources before scaling, and ask on auth failure.
@@ -10363,6 +10396,10 @@ const DEFAULT_SYSTEM: &str = "You are dext, a terse coding CLI agent running loc
const FRUGAL_TOOL_PROTOCOL_NOTE: &str = "Frugal workflow: never try to prefill the TUI input/composer. For nontrivial work, define small steps by required input and observable output; run independent reads in parallel, reuse verified results, and repair only the failed step.";
+const ADVISORY_TURN_RUNTIME_NOTE: &str = "advisory_only=true — the user asked for planning/analysis, not changes. Use read-only tools (read_file, read_symbol, fd, rg, git_diff, todo_read); do not call write_file/edit_file/multi_edit/git_commit/todo_write, mutating bash, or network tools this turn. Finish with sections: Goal, Findings, Steps (numbered), Risks (omit if none) — then stop. Implementation begins only after explicit user approval (e.g. 'go').";
+
+const IMPLEMENTATION_TURN_RUNTIME_NOTE: &str = "implementation_authorized=true — the user approved execution. If the recent conversation contains an agreed plan, first record its steps with todo_write, then execute them in order: read before editing, keep todo status current, verify after changes.";
+
#[cfg(test)]
fn prompt_context_file_hash(path: &Path) -> Option {
read_utf8_regular_file_with_limit(path, PROMPT_CONTEXT_FILE_MAX_BYTES, None, "prompt context")
@@ -10670,15 +10707,6 @@ fn push_env_section(env: &mut String, heading: &str, body: String, cap: usize, h
}
}
-const READ_ONLY_TOOLS: &[&str] = &[
- "read_file",
- "read_symbol",
- "fd",
- "rg",
- "git_diff",
- "todo_read",
-];
-
fn todo_summary_from_path(path: &Path, max_items: usize) -> Option {
let content =
read_utf8_regular_file_with_limit(path, TODO_STATE_MAX_BYTES, None, "todo summary").ok()?;
@@ -10727,18 +10755,6 @@ fn read_session_todo_summary(root: &Path, session_id: &str, max_items: usize) ->
.or_else(|| read_project_todo_summary(root, max_items))
}
-const PLAN_SYSTEM: &str = "\
-You are a planning agent. You have READ-ONLY tools: read_file, read_symbol, fd, rg, \
-git_diff, todo_read. Explore the codebase and produce a concrete implementation plan.
-
-Output sections, in this order:
-1. Task — restate in one sentence.
-2. Files — paths you'll touch, one per line, each with a brief reason.
-3. Plan — numbered steps, short imperative sentences.
-4. Risks — assumptions or open questions (omit if none).
-
-Be terse. Plan only — do NOT write code.";
-
const COMPACT_SYSTEM: &str = "\
You are a transcript summarizer. Output ONLY a dense, factual resume packet using these exact sections:\n\
Task\n\
@@ -11097,42 +11113,62 @@ struct ToolsCommandResult {
fn render_tools_status(agent: &Agent) -> String {
use std::fmt::Write as _;
+ let opts = list_render::ListOptions::detect_with_width(false, agent.slash_render_width);
let header = agent.session_header();
let mut out = String::new();
- let _ = writeln!(
- out,
- "tools: {} (schemas {})",
- agent.tool_context_profile().as_str(),
- agent.wire_tool_profile().as_str()
- );
- let _ = writeln!(out, "usage: /tools [status|default|full]");
- let _ = writeln!(
+ let _ = write!(
out,
- "exposed ({}): {}",
- header.exposed_tools.len(),
- render_limited_csv(&header.exposed_tools, SLASH_LIST_LIMIT, "(none)", "tools")
+ "{}",
+ list_render::render_count_header("Tools", header.exposed_tools.len(), "exposed", &opts)
);
- let _ = writeln!(
- out,
- "approval-required ({}): {}",
- header.approval_required_tools.len(),
- render_limited_csv(
- &header.approval_required_tools,
- SLASH_LIST_LIMIT,
- "(none)",
- "tools"
- )
+
+ let profile_meta = vec![
+ ("toolset", agent.tool_context_profile().as_str().to_string()),
+ ("schemas", agent.wire_tool_profile().as_str().to_string()),
+ ("approval", agent.approval_profile.as_str().to_string()),
+ ];
+ out.push_str(&list_render::render_section_header("Profile", &opts));
+ out.push_str(&list_render::render_metadata(&profile_meta, &opts));
+ out.push('\n');
+
+ let push_names_section = |out: &mut String, title: &str, names: &[String], empty: &str| {
+ out.push_str(&list_render::render_section_header(
+ &format!("{title} ({})", names.len()),
+ &opts,
+ ));
+ if names.is_empty() {
+ list_render::write_wrapped(out, empty, 4, opts.effective_width());
+ } else {
+ let shown: Vec<&str> = names
+ .iter()
+ .take(SLASH_LIST_LIMIT)
+ .map(String::as_str)
+ .collect();
+ list_render::write_wrapped(out, &shown.join(", "), 4, opts.effective_width());
+ if names.len() > SLASH_LIST_LIMIT {
+ list_render::write_wrapped(
+ out,
+ &format!("… [{} more tools omitted]", names.len() - SLASH_LIST_LIMIT),
+ 2,
+ opts.effective_width(),
+ );
+ }
+ }
+ out.push('\n');
+ };
+
+ push_names_section(&mut out, "Exposed", &header.exposed_tools, "(none)");
+ push_names_section(
+ &mut out,
+ "Permission-gated",
+ &header.approval_required_tools,
+ "(none)",
);
- let _ = writeln!(
- out,
- "auto-approved now ({}): {}",
- header.auto_approved_tools.len(),
- render_limited_csv(
- &header.auto_approved_tools,
- SLASH_LIST_LIMIT,
- "(none)",
- "tools"
- )
+ push_names_section(
+ &mut out,
+ "Explicit session grants",
+ &header.allowed,
+ "(none; use /allow )",
);
let hidden_specialized: Vec = tools::specialized_tool_names()
@@ -11140,12 +11176,242 @@ fn render_tools_status(agent: &Agent) -> String {
.map(str::to_string)
.collect();
if !hidden_specialized.is_empty() {
- let _ = writeln!(
- out,
- "hidden until /tools full: {}",
- hidden_specialized.join(", ")
+ push_names_section(
+ &mut out,
+ "Hidden until /tools full",
+ &hidden_specialized,
+ "(none)",
+ );
+ }
+
+ let _ = write!(
+ out,
+ "{}",
+ list_render::render_footer(
+ &["/tools default|full", "/allow ", "/revoke "],
+ &opts,
+ )
+ );
+ out.trim_end().to_string()
+}
+
+fn render_system_prompt_view(agent: &Agent) -> String {
+ use std::fmt::Write as _;
+
+ let opts = list_render::ListOptions::detect_with_width(false, agent.slash_render_width);
+ let details = agent.compose_system_details();
+ let composed = format!("{}\n\n{}", details.stable, details.env);
+ let mut out = String::new();
+ let _ = write!(
+ out,
+ "{}",
+ list_render::render_count_header("System prompt", composed.chars().count(), "chars", &opts,)
+ );
+ out.push('\n');
+
+ out.push_str(&list_render::render_section_header("Sources", &opts));
+ out.push_str(&list_render::render_metadata(
+ &[(
+ "base prompt",
+ format!("{} chars", agent.system.chars().count()),
+ )],
+ &opts,
+ ));
+ for path in &details.prompt_sources {
+ list_render::write_wrapped(
+ &mut out,
+ &list_render::display_path(path, &opts, &agent.sandbox_root),
+ 4,
+ opts.effective_width(),
);
}
+ out.push('\n');
+
+ out.push_str(&list_render::render_section_header("Prompt", &opts));
+ list_render::write_preformatted_wrapped(
+ &mut out,
+ &cap_bytes_with_hint(
+ composed,
+ SLASH_TEXT_CAP,
+ "system prompt display truncated; use /system to replace the base prompt.",
+ ),
+ opts.effective_width(),
+ );
+ out.push('\n');
+ let _ = write!(
+ out,
+ "{}",
+ list_render::render_footer(&["/system (replace the base prompt)"], &opts)
+ );
+ out.trim_end().to_string()
+}
+
+const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[
+ (
+ "Core",
+ &[
+ ("/help", "show this list"),
+ ("/quit, /exit", "exit dext"),
+ ("/reset", "clear conversation history"),
+ ],
+ ),
+ (
+ "Tools & policy",
+ &[
+ (
+ "/tools [default|full]",
+ "list or switch provider-visible tools",
+ ),
+ ("/history", "show turn count and last 5 messages"),
+ ("/system [text]", "show or replace the system prompt"),
+ (
+ "/allow ",
+ "auto-approve a native or active runtime tool",
+ ),
+ ("/revoke ", "remove auto-approval"),
+ ("/allowed", "list native and active-runtime grants"),
+ (
+ "/trust [on|off|status]",
+ "auto-approve all privileged tools",
+ ),
+ (
+ "/privacy [on|strict|off|status]",
+ "redact sensitive tool output before model context",
+ ),
+ (
+ "/approval [profile]",
+ "ask · auto-read · auto-write · never · always",
+ ),
+ ("/preview [mode]", "off|simple|git mutation previews"),
+ (
+ "/sandbox-profile [profile]",
+ "read-only · workspace-write · danger-full-access",
+ ),
+ ("/budget [cap|off]", "show/set budget cap ($ or tokens)"),
+ ],
+ ),
+ (
+ "Packs & shelves",
+ &[
+ (
+ "/pack [list|inspect|run|create]",
+ "create, discover, or invoke shelf packs",
+ ),
+ (
+ "/shelves",
+ "list typed shelf manifests and ability metadata",
+ ),
+ (
+ "/project-extensions [status|reset]",
+ "inspect or reset repository extension approval",
+ ),
+ ("/sandbox [path]", "show or change the sandbox root"),
+ ],
+ ),
+ (
+ "Provider & auth",
+ &[
+ (
+ "/model [id]",
+ "show or change model (persists per provider)",
+ ),
+ ("/providers", "list providers + auth status"),
+ ("/provider [id|#]", "show or switch active provider"),
+ (
+ "/models [provider|#|all]",
+ "list curated models for active/authenticated providers",
+ ),
+ (
+ "/login [provider|#] [token|web|import]",
+ "login or store token/key",
+ ),
+ ("/login cancel", "abort a pending OAuth or browser login"),
+ ("/logout [provider|#]", "remove stored key for provider"),
+ ],
+ ),
+ (
+ "Context & diagnostics",
+ &[
+ (
+ "/effort [level]",
+ "model reasoning depth/tool persistence: off · minimal · low · medium · high · xhigh · max",
+ ),
+ (
+ "/reasoning-mode [mode]",
+ "standard · pro (official OpenAI GPT-5.6 Responses only)",
+ ),
+ (
+ "/context [standard|frugal]",
+ "context/cap mode; local providers default to frugal",
+ ),
+ (
+ "/tool-profile [lean|full]",
+ "provider tool schema verbosity (default lean)",
+ ),
+ (
+ "/compact [status|auto|N]",
+ "summarize older history or set the auto-compaction threshold",
+ ),
+ ("/usage", "cumulative token usage this session"),
+ ("/status", "runtime diagnostics (provider, auth, model)"),
+ ("/tokens", "approximate tokens per message + top hogs"),
+ (
+ "/diagnostics",
+ "rust-analyzer diagnostics (fallback: cargo check)",
+ ),
+ ],
+ ),
+ (
+ "Sessions",
+ &[
+ (
+ "/save ",
+ "write history + config to sessions dir as JSONL",
+ ),
+ (
+ "/export [html|jsonl] [path]",
+ "export session (default JSONL)",
+ ),
+ (
+ "/resume [name]",
+ "load the latest autosaved or a named session",
+ ),
+ (
+ "/sessions",
+ "list sessions; also analyze · brief · grep · failures · verify-log · decisions",
+ ),
+ ("/session", "alias for /sessions"),
+ ("/hooks [reload]", "show hook config or reload from disk"),
+ (
+ "/undo [--apply|--list|]",
+ "preview or restore latest checkpoint",
+ ),
+ ("/version", "show dext version"),
+ ],
+ ),
+];
+
+fn render_help_listing(width: Option) -> String {
+ use std::fmt::Write as _;
+
+ let opts = list_render::ListOptions::detect_with_width(false, width);
+ let total: usize = HELP_GROUPS.iter().map(|(_, entries)| entries.len()).sum();
+ let mut out = String::new();
+ let _ = write!(
+ out,
+ "{}",
+ list_render::render_count_header("Commands", total, "total", &opts)
+ );
+ for (group, entries) in HELP_GROUPS {
+ out.push_str(&list_render::render_section_header(group, &opts));
+ out.push_str(&list_render::render_entry_rows(entries, &opts));
+ out.push('\n');
+ }
+ let _ = write!(
+ out,
+ "{}",
+ list_render::render_footer(&["/ — [args] optional, required"], &opts,)
+ );
out.trim_end().to_string()
}
@@ -12098,7 +12364,6 @@ fn slash_command_name(text: &str) -> Option<&str> {
| "session"
| "hooks"
| "undo"
- | "plan"
)
.then_some(cmd)
}
@@ -13142,6 +13407,7 @@ struct Agent {
git_context: Option,
silent: bool,
pretty: bool,
+ slash_render_width: Option,
max_iterations: Option,
session_usage: Usage,
// Usage of the most recent provider request. This is the context-pressure
@@ -13159,6 +13425,9 @@ struct Agent {
pending_pack_runtime_prompts: Vec<(String, u64)>,
project_extensions_approved: Option,
suppress_pack_activation: bool,
+ // Per-turn planning/execution policy note injected into the volatile env
+ // tail; set from the objective at each user-turn start, never persisted.
+ turn_policy_note: Option<&'static str>,
state_lock: Option>,
session_enabled: bool,
session_id: String,
@@ -13353,10 +13622,12 @@ impl Agent {
pending_pack_runtime_prompts: Vec::new(),
project_extensions_approved: None,
suppress_pack_activation: false,
+ turn_policy_note: None,
sandbox_root,
git_context,
silent: false,
pretty,
+ slash_render_width: None,
max_iterations: None,
session_usage: Usage::default(),
last_request_usage: Usage::default(),
@@ -15197,21 +15468,31 @@ impl Agent {
}
}
+ let mut recall_budget = privacy::RECALL_MEMORY_MAX_BYTES;
for (label, path, content, content_hash) in &recall_sections {
- if context_budget == 0 {
+ if context_budget == 0 || recall_budget == 0 {
break;
}
prompt_sources.push(path.clone());
prompt_source_hashes.push((path.clone(), content_hash.clone()));
+ let content = self.privacy.redact_text(content).text;
+ let recall_truncated = content.len() > recall_budget;
+ let content = cap_bytes_with_hint_to_total(
+ &content,
+ recall_budget,
+ "recall.md capped at 4096 bytes total; prune lower-value entries.",
+ );
+ recall_budget = recall_budget.saturating_sub(content.len());
let label = prompt_env_value(label, 512);
let heading = format!("Recall (recall.md from {label})");
match push_prompt_context_section(
&mut stable,
&heading,
- content,
+ &content,
&mut context_budget,
"recall.md truncated; keep durable facts concise.",
) {
+ PromptContextSectionResult::Complete if recall_truncated => break,
PromptContextSectionResult::Complete => {}
PromptContextSectionResult::Truncated => break,
PromptContextSectionResult::Omitted => {
@@ -15256,13 +15537,15 @@ impl Agent {
env.push_str(&format!(" git={}", prompt_env_value(git, 256)));
}
env.push_str(&format!(
- " provider={} model={} effort={} context={} approval={} sandbox={}\n",
+ " provider={} model={} effort={} context={} approval={} sandbox={} session={} date={}\n",
prompt_env_value(&self.provider_id, 128),
prompt_env_value(&self.model, 256),
self.thinking_effort.as_str(),
self.context_mode.as_str(),
self.approval_profile.as_str(),
- self.sandbox_profile.as_str()
+ self.sandbox_profile.as_str(),
+ prompt_env_value(&self.session_id, 128),
+ utc_date_from_unix_secs(unix_timestamp_secs())
));
if let Some(seat) = &self.seat {
@@ -15307,6 +15590,15 @@ impl Agent {
&format!("work ledger trimmed for {}.", caps.suffix),
);
}
+ if let Some(policy) = self.turn_policy_note {
+ push_env_section(
+ &mut env,
+ "Turn policy",
+ policy.to_string(),
+ 1_000,
+ "turn policy trimmed.",
+ );
+ }
let context_state = self.context_state_prompt();
if !context_state.trim().is_empty() {
push_env_section(
@@ -15448,6 +15740,22 @@ impl Agent {
preview
}
+ // Mid-turn steering can change planning intent: an approval must retire an
+ // advisory note immediately, and a new hold-off must reinstate one.
+ fn update_turn_policy_from_steering(&mut self, steering_text: &str) {
+ let objective = orchestrator::ObjectiveTracker::from_user_prompt(steering_text);
+ // A question asks about proceeding; it neither grants nor revokes
+ // approval, so it must not clear an active advisory policy.
+ let question = steering_text.trim_end().ends_with('?');
+ if objective.planned_execution() {
+ self.turn_policy_note = Some(IMPLEMENTATION_TURN_RUNTIME_NOTE);
+ } else if objective.apply_fixes_allowed() && !question {
+ self.turn_policy_note = None;
+ } else if objective.advisory_only() {
+ self.turn_policy_note = Some(ADVISORY_TURN_RUNTIME_NOTE);
+ }
+ }
+
fn inject_queued_steering(
&mut self,
turn_state: &mut orchestrator::TurnRuntimeState,
@@ -15494,6 +15802,9 @@ impl Agent {
)
};
let combined = pending_steering.join("\n\n");
+ if !combined.trim().is_empty() {
+ self.update_turn_policy_from_steering(&combined);
+ }
let user_update = if combined.trim().is_empty() {
"(runtime control command only)".to_string()
} else {
@@ -16049,7 +16360,6 @@ impl Agent {
Some(AnthropicThinking {
kind: if kimi_adaptive { "adaptive" } else { "enabled" },
budget_tokens: None,
- display: None,
}),
Some(AnthropicOutputConfig { effort }),
)
@@ -16058,7 +16368,6 @@ impl Agent {
let thinking = effort.as_ref().map(|_| AnthropicThinking {
kind: "adaptive",
budget_tokens: None,
- display: None,
});
(
thinking,
@@ -16073,7 +16382,6 @@ impl Agent {
.map(|budget_tokens| AnthropicThinking {
kind: "enabled",
budget_tokens: Some(budget_tokens),
- display: None,
}),
None,
)
@@ -16212,11 +16520,6 @@ impl Agent {
}
}
- fn composed_system_prompt(&self) -> String {
- let (sys_stable, sys_env) = self.compose_system_parts();
- format!("{sys_stable}\n\n{sys_env}")
- }
-
fn session_provenance_from(
&self,
details: &SystemParts,
@@ -17374,108 +17677,6 @@ impl Agent {
Ok(())
}
- async fn run_plan(&mut self, task: String) -> Result<()> {
- let plan = self.generate_read_only_plan(&task).await?;
- self.sink.emit(AgentEvent::Slash(format!(
- "=== PLAN ===\n{plan}\n=== END ==="
- )));
- self.history.push(Message {
- role: "user".to_string(),
- content: vec![Block::Text {
- text: format!("Task: {task}\n\nProposed plan:\n\n{plan}"),
- }],
- });
- self.history.push(Message {
- role: "assistant".to_string(),
- content: vec![Block::Text {
- text: "Plan ready. Say 'go' to execute, or give revisions.".to_string(),
- }],
- });
- Ok(())
- }
-
- async fn generate_read_only_plan(&mut self, task: &str) -> Result {
- let saved_system = std::mem::replace(&mut self.system, PLAN_SYSTEM.to_string());
- let saved_tools = std::mem::replace(
- &mut self.tools,
- provider_tool_definitions()
- .into_iter()
- .filter(|tool| READ_ONLY_TOOLS.contains(&tool.name))
- .collect(),
- );
- let saved_max_iterations = self.max_iterations.replace(15);
- let saved_history = std::mem::take(&mut self.history);
- let saved_silent = self.silent;
- let saved_pretty = self.pretty;
- let saved_sink = std::mem::replace(&mut self.sink, Box::new(SilentSink));
- let saved_suppress_checkpoints = self.suppress_checkpoints;
- let saved_hooks = self.hooks.clone();
- let saved_pack_hook_env = self.pack_hook_env.clone();
- let saved_active_pack_hook_paths = self.active_pack_hook_paths.clone();
- let saved_active_pack_runtime = self.active_pack_runtime.take();
- let saved_pending_pack_runtime_prompts =
- std::mem::take(&mut self.pending_pack_runtime_prompts);
- let saved_suppress_pack_activation = self.suppress_pack_activation;
- let saved_work_ledger = self.work_ledger.clone();
- let saved_budget_exhausted = self.budget_exhausted;
- self.silent = true;
- self.pretty = false;
- self.suppress_checkpoints = true;
- self.suppress_pack_activation = true;
- self.hooks = Hooks::default();
- self.pack_hook_env.clear();
- self.active_pack_hook_paths.clear();
- self.budget_exhausted = false;
-
- let prompt = format!("Produce a read-only implementation plan for this task:\n\n{task}");
- let chat_result = self.chat(prompt).await;
-
- let plan = self
- .history
- .iter()
- .rev()
- .find_map(|message| {
- if message.role != "assistant" {
- return None;
- }
- let text: String = message
- .content
- .iter()
- .filter_map(|block| match block {
- Block::Text { text } | Block::PartialStream { text } => Some(text.as_str()),
- _ => None,
- })
- .collect::>()
- .join("");
- if text.trim().is_empty() {
- None
- } else {
- Some(text)
- }
- })
- .unwrap_or_else(|| "(planner returned no text)".to_string());
-
- self.history = saved_history;
- self.system = saved_system;
- self.tools = saved_tools;
- self.max_iterations = saved_max_iterations;
- self.silent = saved_silent;
- self.pretty = saved_pretty;
- self.suppress_checkpoints = saved_suppress_checkpoints;
- self.hooks = saved_hooks;
- self.pack_hook_env = saved_pack_hook_env;
- self.active_pack_hook_paths = saved_active_pack_hook_paths;
- self.active_pack_runtime = saved_active_pack_runtime;
- self.pending_pack_runtime_prompts = saved_pending_pack_runtime_prompts;
- self.suppress_pack_activation = saved_suppress_pack_activation;
- self.work_ledger = saved_work_ledger;
- self.budget_exhausted = saved_budget_exhausted;
- self.sink = saved_sink;
-
- chat_result?;
- Ok(plan)
- }
-
async fn chat(&mut self, user_input: String) -> Result<()> {
self.chat_with_pack_activation(user_input, false).await
}
@@ -17610,6 +17811,19 @@ impl Agent {
.unwrap_or_default(),
);
let objective_line = objective.display_line();
+ self.turn_policy_note = if objective.advisory_only() {
+ Some(ADVISORY_TURN_RUNTIME_NOTE)
+ } else if objective.planned_execution() {
+ Some(IMPLEMENTATION_TURN_RUNTIME_NOTE)
+ } else {
+ None
+ };
+ if let Some(policy) = self.turn_policy_note {
+ let label = policy.split_whitespace().next().unwrap_or("turn policy");
+ self.sink
+ .emit(AgentEvent::Info(format!("[turn-policy] {label}")));
+ self.append_latest_log("turn_policy", label);
+ }
self.update_work_ledger_from_objective(&objective);
self.sink
.emit(AgentEvent::Info(format!("[{}]", objective_line)));
@@ -18071,6 +18285,12 @@ impl Agent {
continue;
}
+ let mut history_blocks = blocks.clone();
+ sanitize_recall_tool_inputs_for_history(
+ &mut history_blocks,
+ &self.privacy,
+ &self.sandbox_root,
+ );
self.finalize_turn_usage_metrics(&mut usage, &blocks);
self.last_request_usage = usage;
@@ -18098,7 +18318,11 @@ impl Agent {
action_contract_must_mutate = true;
}
- if maybe_preserve_assistant_response(&blocks, &mut self.history, self.context_mode) {
+ if maybe_preserve_assistant_response(
+ &history_blocks,
+ &mut self.history,
+ self.context_mode,
+ ) {
self.checkpoint_latest_session("after_assistant_message");
}
@@ -18856,8 +19080,12 @@ fn render_session_entry(
}
fn render_session_listing(root: &Path) -> String {
+ render_session_listing_width(root, None)
+}
+
+fn render_session_listing_width(root: &Path, width: Option) -> String {
use std::fmt::Write as _;
- let opts = list_render::ListOptions::detect(false);
+ let opts = list_render::ListOptions::detect_with_width(false, width);
let latest_path = latest_session_path(root);
let sessions_root = named_sessions_dir_for_root(root);
@@ -18892,7 +19120,7 @@ fn render_session_listing(root: &Path) -> String {
list_render::render_header("Sessions", total, &opts)
);
- let _ = writeln!(out, "{}", list_render::bold("Latest", opts.color));
+ out.push_str(&list_render::render_section_header("Latest", &opts));
if latest_exists {
let modified = latest_path.metadata().ok().and_then(|m| m.modified().ok());
out.push_str(&render_session_entry(
@@ -18903,42 +19131,58 @@ fn render_session_listing(root: &Path) -> String {
root,
));
} else {
- let _ = writeln!(
- out,
- " (none yet; send a message to create {})",
- list_render::display_path(&latest_path, &opts, root)
+ list_render::write_wrapped(
+ &mut out,
+ &format!(
+ "(none yet; send a message to create {})",
+ list_render::display_path(&latest_path, &opts, root)
+ ),
+ 4,
+ opts.effective_width(),
);
}
out.push('\n');
- let _ = writeln!(out, "{}", list_render::bold("Autosaved", opts.color));
+ out.push_str(&list_render::render_section_header("Autosaved", &opts));
if autosaved_sessions.is_empty() {
- let _ = writeln!(
- out,
- " (none in {})",
- list_render::display_path(&sessions_root, &opts, root)
+ list_render::write_wrapped(
+ &mut out,
+ &format!(
+ "(none in {})",
+ list_render::display_path(&sessions_root, &opts, root)
+ ),
+ 4,
+ opts.effective_width(),
);
} else {
for (name, path, modified) in autosaved_sessions.iter().take(SLASH_LIST_LIMIT) {
out.push_str(&render_session_entry(path, name, *modified, &opts, root));
}
if autosaved_sessions.len() > SLASH_LIST_LIMIT {
- let _ = writeln!(
- out,
- " … [{} more session dirs omitted]",
- autosaved_sessions.len() - SLASH_LIST_LIMIT
+ list_render::write_wrapped(
+ &mut out,
+ &format!(
+ "… [{} more session dirs omitted]",
+ autosaved_sessions.len() - SLASH_LIST_LIMIT
+ ),
+ 2,
+ opts.effective_width(),
);
}
}
out.push('\n');
- let _ = writeln!(out, "{}", list_render::bold("Named", opts.color));
+ out.push_str(&list_render::render_section_header("Named", &opts));
match &named_records {
Ok(records) if records.is_empty() => {
- let _ = writeln!(
- out,
- " (none in {}; use /save )",
- list_render::display_path(&named_sessions_dir_for_root(root), &opts, root)
+ list_render::write_wrapped(
+ &mut out,
+ &format!(
+ "(none in {}; use /save )",
+ list_render::display_path(&named_sessions_dir_for_root(root), &opts, root)
+ ),
+ 4,
+ opts.effective_width(),
);
}
Ok(records) => {
@@ -18952,15 +19196,24 @@ fn render_session_listing(root: &Path) -> String {
));
}
if records.len() > SLASH_LIST_LIMIT {
- let _ = writeln!(
- out,
- " … [{} more named sessions omitted]",
- records.len() - SLASH_LIST_LIMIT
+ list_render::write_wrapped(
+ &mut out,
+ &format!(
+ "… [{} more named sessions omitted]",
+ records.len() - SLASH_LIST_LIMIT
+ ),
+ 2,
+ opts.effective_width(),
);
}
}
Err(e) => {
- let _ = writeln!(out, " [err] {e:#}");
+ list_render::write_wrapped(
+ &mut out,
+ &format!("[err] {e:#}"),
+ 2,
+ opts.effective_width(),
+ );
}
}
@@ -20920,6 +21173,7 @@ fn diagnostics_approved(agent: &mut Agent) -> bool {
fn handle_slash(line: &str, agent: &mut Agent) -> Option {
use std::fmt::Write as _;
let mut ui_update = SlashUiUpdate::None;
+ let mut presentation = SlashPresentation::Faded;
let line = line.trim();
if !is_slash_command(line) {
return None;
@@ -20954,15 +21208,21 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
} else {
match sub {
"" | "list" | "ls" => {
+ presentation = SlashPresentation::Structured;
let (_, inline_verbose) = list_render::take_verbose(pack_arg);
let verbose = leading_verbose || inline_verbose;
let _ = write!(
w,
"{}",
- packs::render_pack_listing_opts(&agent.sandbox_root, verbose)
+ packs::render_pack_listing_opts_width(
+ &agent.sandbox_root,
+ verbose,
+ agent.slash_render_width,
+ )
);
}
"inspect" | "info" | "show" => {
+ presentation = SlashPresentation::Structured;
let selector = parts.next().unwrap_or("").trim();
if selector.is_empty() {
let _ = writeln!(w, "usage: /pack inspect ");
@@ -21019,182 +21279,19 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
}
}
"shelf" | "shelves" => {
+ presentation = SlashPresentation::Structured;
let _ = write!(
w,
"{}",
- shelves::render_registry_listing(&agent.shelf_registry)
+ shelves::render_registry_listing_width(
+ &agent.shelf_registry,
+ agent.slash_render_width,
+ )
);
}
"help" | "?" => {
- let _ = writeln!(w, "── Core ──");
- let _ = writeln!(w, " /help show this");
- let _ = writeln!(w, " /quit, /exit exit dext");
- let _ = writeln!(w, " /reset clear conversation history");
- let _ = writeln!(w);
- let _ = writeln!(w, "── Tools & policy ──");
- let _ = writeln!(
- w,
- " /tools [default|full] list or switch provider-visible tools"
- );
- let _ = writeln!(
- w,
- " /history show turn count and last 5 messages"
- );
- let _ = writeln!(
- w,
- " /system [text] show or replace the system prompt"
- );
- let _ = writeln!(
- w,
- " /allow auto-approve a native or active runtime tool"
- );
- let _ = writeln!(w, " /revoke remove auto-approval");
- let _ = writeln!(
- w,
- " /allowed list native and active-runtime grants"
- );
- let _ = writeln!(
- w,
- " /trust [on|off|status] auto-approve all privileged tools"
- );
- let _ = writeln!(
- w,
- " /privacy [on|strict|off|status] redact sensitive tool output before model context"
- );
- let _ = writeln!(
- w,
- " /approval [profile] ask|auto-read|auto-write|never|always"
- );
- let _ = writeln!(
- w,
- " /preview [mode] off|simple|git mutation previews"
- );
- let _ = writeln!(
- w,
- " /sandbox-profile [profile] read-only|workspace-write|danger-full-access"
- );
- let _ = writeln!(
- w,
- " /budget [cap|off] show/set budget cap ($ or tokens)"
- );
- let _ = writeln!(w);
- let _ = writeln!(w, "── Packs & shelves ──");
- let _ = writeln!(
- w,
- " /pack [list|inspect|run|create] create, discover, or invoke shelf packs"
- );
- let _ = writeln!(
- w,
- " /shelves list typed shelf manifests and ability metadata"
- );
- let _ = writeln!(
- w,
- " /project-extensions [status|reset] inspect or reset repository extension approval"
- );
- let _ = writeln!(
- w,
- " /sandbox [path] show or change the sandbox root"
- );
- let _ = writeln!(w);
- let _ = writeln!(w, "── Provider & auth ──");
- let _ = writeln!(
- w,
- " /model [id] show or change model (persists per provider)"
- );
- let _ = writeln!(
- w,
- " /providers list providers + auth status"
- );
- let _ = writeln!(
- w,
- " /provider [id|#] show or switch active provider"
- );
- let _ = writeln!(
- w,
- " /models [provider|#|all] list curated models for active/authenticated providers"
- );
- let _ = writeln!(
- w,
- " /login [provider|#] [token|web|import] login or store token/key"
- );
- let _ = writeln!(
- w,
- " /logout [provider|#] remove stored key for provider"
- );
- let _ = writeln!(
- w,
- " /login cancel abort a pending OAuth or browser login"
- );
- let _ = writeln!(w);
- let _ = writeln!(w, "── Context & diagnostics ──");
- let _ = writeln!(
- w,
- " /effort [level] set model reasoning depth/tool persistence: off|minimal|low|medium|high|xhigh|max"
- );
- let _ = writeln!(
- w,
- " /reasoning-mode [mode] select standard|pro (active only for official OpenAI GPT-5.6 Responses)"
- );
- let _ = writeln!(
- w,
- " /context [standard|frugal] context/cap mode; local providers default to frugal"
- );
- let _ = writeln!(
- w,
- " /tool-profile [lean|full] provider tool schema verbosity (default lean)"
- );
- let _ = writeln!(
- w,
- " /compact [status|auto|N] summarize older history or set the auto-compaction threshold"
- );
- let _ = writeln!(
- w,
- " /usage show cumulative token usage this session"
- );
- let _ = writeln!(
- w,
- " /status show runtime diagnostics (provider, auth, model)"
- );
- let _ = writeln!(
- w,
- " /tokens approximate tokens per message + top hogs"
- );
- let _ = writeln!(
- w,
- " /diagnostics run rust-analyzer diagnostics (fallback: cargo check)"
- );
- let _ = writeln!(w);
- let _ = writeln!(w, "── Sessions ──");
- let _ = writeln!(
- w,
- " /save write history + config to sessions dir as JSONL"
- );
- let _ = writeln!(
- w,
- " /export [html|jsonl] [path] export session (HTML or JSONL; default JSONL)"
- );
- let _ = writeln!(
- w,
- " /resume [name] load the latest autosaved or a named session"
- );
- let _ = writeln!(
- w,
- " /sessions list latest + autosaved/named sessions; /sessions analyze|brief|grep|failures|verify-log|decisions"
- );
- let _ = writeln!(w, " /session alias for /sessions");
- let _ = writeln!(
- w,
- " /plan run a read-only planner, seed the plan into history"
- );
- let _ = writeln!(
- w,
- " /hooks [reload] show hook config or reload from disk"
- );
- let _ = writeln!(
- w,
- " /undo [--apply|--list|] preview or restore latest checkpoint"
- );
- let _ = writeln!(w, " /version show dext version");
+ presentation = SlashPresentation::Structured;
+ let _ = writeln!(w, "{}", render_help_listing(agent.slash_render_width));
}
"version" => {
let _ = writeln!(w, "dext {}", env!("CARGO_PKG_VERSION"));
@@ -21229,6 +21326,12 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
let _ = writeln!(w, "cleared {n} messages");
}
"tools" => {
+ if matches!(
+ arg.to_ascii_lowercase().as_str(),
+ "" | "status" | "list" | "ls"
+ ) {
+ presentation = SlashPresentation::Structured;
+ }
let result = handle_tools_command(agent, arg);
let _ = writeln!(w, "{}", result.output);
}
@@ -21254,19 +21357,15 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
}
"system" => {
if arg.is_empty() {
- let composed = agent.composed_system_prompt();
+ presentation = SlashPresentation::Structured;
+ let _ = writeln!(w, "{}", render_system_prompt_view(agent));
+ } else {
+ agent.system = arg.to_string();
let _ = writeln!(
w,
- "{}",
- cap_bytes_with_hint(
- composed,
- SLASH_TEXT_CAP,
- "system prompt display truncated; use /system to replace the base prompt.",
- )
+ "system prompt replaced ({} chars)",
+ agent.system.chars().count()
);
- } else {
- agent.system = arg.to_string();
- let _ = writeln!(w, "system prompt replaced ({} chars)", agent.system.len());
}
}
"allow" => {
@@ -21522,19 +21621,22 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
let _ = writeln!(w, "[err] {e:#}");
}
},
- "providers" => match (load_provider_catalog(), load_auth_store()) {
- (Ok(catalog), Ok(store)) => {
- let active = resolve_active_provider_id(&catalog);
- let _ = writeln!(w, "active provider: {active}");
- let _ = writeln!(w, "{}", render_provider_list(&catalog, &store, &active));
- let _ = writeln!(w, "provider catalog: {}", provider_catalog_path().display());
- let _ = writeln!(w, "auth store: {}", auth_store_path().display());
- let _ = writeln!(w, "runtime: {}", agent.provider_status_line());
- }
- (Err(e), _) | (_, Err(e)) => {
- let _ = writeln!(w, "[err] {e:#}");
+ "providers" => {
+ presentation = SlashPresentation::Structured;
+ match (load_provider_catalog(), load_auth_store()) {
+ (Ok(catalog), Ok(store)) => {
+ let active = resolve_active_provider_id(&catalog);
+ let _ = writeln!(w, "active provider: {active}");
+ let _ = writeln!(w, "{}", render_provider_list(&catalog, &store, &active));
+ let _ = writeln!(w, "provider catalog: {}", provider_catalog_path().display());
+ let _ = writeln!(w, "auth store: {}", auth_store_path().display());
+ let _ = writeln!(w, "runtime: {}", agent.provider_status_line());
+ }
+ (Err(e), _) | (_, Err(e)) => {
+ let _ = writeln!(w, "[err] {e:#}");
+ }
}
- },
+ }
"provider" => {
if arg.is_empty() {
let _ = writeln!(w, "{}", agent.provider_status_line());
@@ -21562,27 +21664,32 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
}
}
}
- "models" => match (load_provider_catalog(), load_auth_store()) {
- (Ok(catalog), Ok(store)) => {
- let active = resolve_active_provider_id(&catalog);
- let list = match arg {
- "" | "all" => list_models_for_available_providers(&catalog, &store, &active),
- _ => provider_id_from_selector(&catalog, arg)
- .and_then(|target| list_models_for_provider(&catalog, &target)),
- };
- match list {
- Ok(list) => {
- let _ = writeln!(w, "{list}");
- }
- Err(e) => {
- let _ = writeln!(w, "[err] {e:#}");
+ "models" => {
+ presentation = SlashPresentation::Structured;
+ match (load_provider_catalog(), load_auth_store()) {
+ (Ok(catalog), Ok(store)) => {
+ let active = resolve_active_provider_id(&catalog);
+ let list = match arg {
+ "" | "all" => {
+ list_models_for_available_providers(&catalog, &store, &active)
+ }
+ _ => provider_id_from_selector(&catalog, arg)
+ .and_then(|target| list_models_for_provider(&catalog, &target)),
+ };
+ match list {
+ Ok(list) => {
+ let _ = writeln!(w, "{list}");
+ }
+ Err(e) => {
+ let _ = writeln!(w, "[err] {e:#}");
+ }
}
}
+ (Err(e), _) | (_, Err(e)) => {
+ let _ = writeln!(w, "[err] {e:#}");
+ }
}
- (Err(e), _) | (_, Err(e)) => {
- let _ = writeln!(w, "[err] {e:#}");
- }
- },
+ }
"login" => {
if arg.eq_ignore_ascii_case("cancel") {
let cancelled_oauth = cancel_pending_oauth_login();
@@ -21594,6 +21701,7 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
let _ = writeln!(w, "no login is waiting for credentials");
}
} else if arg.is_empty() {
+ presentation = SlashPresentation::Structured;
match (load_provider_catalog(), load_auth_store()) {
(Ok(catalog), Ok(store)) => {
let active = resolve_active_provider_id(&catalog);
@@ -22018,12 +22126,17 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
}
}
"sessions" | "session" => {
+ presentation = SlashPresentation::Structured;
let mut parts = arg.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("");
let rest = parts.next().unwrap_or("").trim();
match sub {
"" | "list" => {
- let _ = write!(w, "{}", render_session_listing(&agent.sandbox_root));
+ let _ = write!(
+ w,
+ "{}",
+ render_session_listing_width(&agent.sandbox_root, agent.slash_render_width,)
+ );
}
"analyze" | "analysis" => {
let selector = if rest.is_empty() { "latest" } else { rest };
@@ -22254,7 +22367,10 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option {
if out.ends_with('\n') {
out.pop();
}
- agent.sink.emit(AgentEvent::Slash(out));
+ match presentation {
+ SlashPresentation::Faded => agent.sink.emit(AgentEvent::Slash(out)),
+ SlashPresentation::Structured => agent.sink.emit(AgentEvent::StructuredSlash(out)),
+ }
}
match ui_update {
SlashUiUpdate::None => {}
@@ -24207,21 +24323,6 @@ async fn main() -> Result<()> {
continue;
}
- if input == "/plan" || input.starts_with("/plan ") {
- let task = input.strip_prefix("/plan").unwrap_or("").trim();
- if task.is_empty() {
- println!("usage: /plan ");
- } else {
- agent_busy_flag.store(true, std::sync::atomic::Ordering::SeqCst);
- if let Err(e) = agent.run_plan(task.to_string()).await {
- eprintln!("[plan error] {e:#}");
- }
- agent_busy_flag.store(false, std::sync::atomic::Ordering::SeqCst);
- }
- autosave_latest(&mut agent);
- continue;
- }
-
if input == "/pack"
|| input.starts_with("/pack ")
|| input == "/packs"
diff --git a/src/main_tests.rs b/src/main_tests.rs
index c9016a9..b47a01b 100644
--- a/src/main_tests.rs
+++ b/src/main_tests.rs
@@ -97,6 +97,7 @@ fn test_agent(root: &Path) -> Agent {
git_context: None,
silent: true,
pretty: false,
+ slash_render_width: None,
max_iterations: Some(1),
session_usage: Usage::default(),
last_request_usage: Usage::default(),
@@ -110,6 +111,7 @@ fn test_agent(root: &Path) -> Agent {
pending_pack_runtime_prompts: Vec::new(),
project_extensions_approved: None,
suppress_pack_activation: false,
+ turn_policy_note: None,
state_lock: None,
session_enabled: true,
session_id: session_id.clone(),
@@ -5621,6 +5623,8 @@ fn slash_routing_distinguishes_commands_from_absolute_and_wsl_paths() {
"/track",
"/branches",
"/browser-recipe",
+ "/plan",
+ "/plan this change",
] {
assert!(!is_slash_command(retired), "{retired}");
}
@@ -7311,8 +7315,9 @@ fn sessions_listing_includes_project_latest_without_named_sessions() -> Result<(
assert!(listing.contains("latest"), "{listing}");
assert!(listing.contains("Named"), "{listing}");
let project_named_dir = named_sessions_dir_for_root(&project);
+ let compact = listing.split_whitespace().collect::();
assert!(
- listing.contains(&format!("none in {}", project_named_dir.display())),
+ compact.contains(&format!("nonein{}", project_named_dir.display())),
"{listing}"
);
assert!(listing.contains("use /save "), "{listing}");
@@ -7367,8 +7372,17 @@ fn named_sessions_are_project_scoped_by_default() -> Result<()> {
assert_ne!(alpha_path, beta_path);
assert_eq!(resolve_session_selector(&alpha, "shared")?, alpha_path);
assert_eq!(resolve_session_selector(&beta, "shared")?, beta_path);
- assert!(render_session_listing(&alpha).contains(&alpha_path.display().to_string()));
- assert!(render_session_listing(&beta).contains(&beta_path.display().to_string()));
+ for (project, path) in [(&alpha, &alpha_path), (&beta, &beta_path)] {
+ let listing = render_session_listing_width(project, Some(40));
+ let compact = listing.split_whitespace().collect::();
+ assert!(compact.contains(&path.display().to_string()), "{listing}");
+ assert!(
+ listing
+ .lines()
+ .all(|line| unicode_width::UnicodeWidthStr::width(line) <= 40),
+ "{listing}"
+ );
+ }
Ok(())
})();
@@ -9058,6 +9072,72 @@ fn later_tool_in_round_checkpoints_state_from_earlier_mutation() {
let _ = std::fs::remove_dir_all(root);
}
+#[test]
+fn recall_native_writes_enforce_exact_four_kib_cap() {
+ let root = temp_test_dir("recall-write-cap");
+ let mut agent = test_agent(&root);
+ agent.session_enabled = false;
+ agent.set_approval_profile(ApprovalProfile::Always);
+ let runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("build runtime");
+
+ let exact = "x".repeat(RECALL_MEMORY_MAX_BYTES);
+ let mut exact_state = orchestrator::TurnRuntimeState::new();
+ let exact_outcome = runtime
+ .block_on(agent.execute_tool_round(ToolRoundContext {
+ tool_calls: vec![(
+ "call-recall-exact".to_string(),
+ "write_file".to_string(),
+ json!({"path": "recall.md", "content": exact}),
+ )],
+ iterations: 1,
+ turn_id: "turn-recall-exact".to_string(),
+ objective_apply_fixes_allowed: true,
+ turn_state: &mut exact_state,
+ denied_signatures: HashSet::new(),
+ hooks_approval_decided: true,
+ hooks_approved: false,
+ }))
+ .expect("execute exact recall write");
+ assert!(exact_outcome.mutation_succeeded);
+ assert_eq!(
+ std::fs::read(root.join("recall.md")).unwrap().len(),
+ RECALL_MEMORY_MAX_BYTES
+ );
+
+ let mut oversized_state = orchestrator::TurnRuntimeState::new();
+ let oversized_outcome = runtime
+ .block_on(agent.execute_tool_round(ToolRoundContext {
+ tool_calls: vec![(
+ "call-recall-oversized".to_string(),
+ "write_file".to_string(),
+ json!({"path": "recall.md", "content": "y".repeat(RECALL_MEMORY_MAX_BYTES + 1)}),
+ )],
+ iterations: 2,
+ turn_id: "turn-recall-oversized".to_string(),
+ objective_apply_fixes_allowed: true,
+ turn_state: &mut oversized_state,
+ denied_signatures: HashSet::new(),
+ hooks_approval_decided: true,
+ hooks_approved: false,
+ }))
+ .expect("reject oversized recall write");
+ assert!(!oversized_outcome.mutation_succeeded);
+ assert_eq!(
+ std::fs::read(root.join("recall.md")).unwrap().len(),
+ RECALL_MEMORY_MAX_BYTES
+ );
+ let (content, _) = last_tool_result(&agent.history).expect("oversized tool result");
+ assert!(
+ content.contains("4096-byte working-memory cap"),
+ "{content}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
#[test]
fn interrupted_builtin_call_refuses_to_start_work() {
let root = temp_test_dir("builtin-interrupt-refuses-start");
@@ -9555,6 +9635,79 @@ fn privacy_redacts_sensitive_tool_output_and_strict_mode_blocks_secret_paths() {
let _ = std::fs::remove_dir_all(&root);
}
+#[test]
+fn recall_memory_targeting_and_tool_inputs_are_case_insensitive_and_redacted() {
+ let root = temp_test_dir("recall-input-redaction");
+ let policy = PrivacyPolicy::default();
+
+ assert!(is_recall_memory_file(Path::new("recall.md")));
+ assert!(is_recall_memory_file(Path::new("nested/RECALL.MD")));
+ assert!(!is_recall_memory_file(Path::new("recall.md.bak")));
+
+ let secret = format!("API_{}={}", "KEY", "abcdefghijklmnop");
+ for (tool, mut input) in [
+ (
+ "write_file",
+ json!({"path": "RECALL.MD", "content": secret.clone()}),
+ ),
+ (
+ "edit_file",
+ json!({"path": "notes/recall.md", "old_string": secret.clone(), "new_string": secret.clone()}),
+ ),
+ (
+ "multi_edit",
+ json!({"path": "recall.md", "edits": [{"old_string": secret.clone(), "new_string": secret.clone()}]}),
+ ),
+ ] {
+ assert!(policy.redact_recall_tool_input(tool, &mut input, &root));
+ let serialized = input.to_string();
+ assert!(!serialized.contains("abcdefghijklmnop"), "{serialized}");
+ assert!(serialized.contains("[REDACTED_SECRET]"), "{serialized}");
+ }
+
+ let mut unrelated = json!({"path": "recall.md.bak", "content": secret.clone()});
+ assert!(!policy.redact_recall_tool_input("write_file", &mut unrelated, &root));
+ assert_eq!(unrelated["content"], secret.as_str());
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn recall_tool_inputs_are_redacted_before_assistant_history_persistence() {
+ let root = temp_test_dir("recall-history-redaction");
+ let policy = PrivacyPolicy::default();
+ let secret = format!("API_{}={}", "KEY", "abcdefghijklmnop");
+ let redacted = format!("API_{}={}", "KEY", "[REDACTED_SECRET]");
+ let mut blocks = vec![
+ Block::ToolUse {
+ id: "recall-write".to_string(),
+ name: "write_file".to_string(),
+ input: json!({"path": "recall.md", "content": secret.clone()}),
+ },
+ Block::ToolUse {
+ id: "ordinary-write".to_string(),
+ name: "write_file".to_string(),
+ input: json!({"path": "notes.md", "content": secret.clone()}),
+ },
+ ];
+
+ sanitize_recall_tool_inputs_for_history(&mut blocks, &policy, &root);
+
+ let Block::ToolUse { input: recall, .. } = &blocks[0] else {
+ panic!("expected recall tool use");
+ };
+ assert_eq!(recall["content"], redacted);
+ let Block::ToolUse {
+ input: ordinary, ..
+ } = &blocks[1]
+ else {
+ panic!("expected ordinary tool use");
+ };
+ assert_eq!(ordinary["content"], secret);
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
#[test]
fn privacy_env_defaults_to_redaction_and_requires_explicit_strict_paths() {
let _guard = env_lock();
@@ -16435,6 +16588,74 @@ fn compose_system_parts_caps_dext_md() {
let _ = std::fs::remove_dir_all(&root);
}
+#[test]
+fn turn_policy_note_rides_volatile_env_only_when_set() {
+ let root = temp_test_dir("turn-policy-env");
+ let root = std::fs::canonicalize(root).expect("canonical temp dir");
+ let mut agent = test_agent(&root);
+
+ let (_, env) = agent.compose_system_parts();
+ assert!(!env.contains("Turn policy"), "{env}");
+
+ agent.turn_policy_note = Some(ADVISORY_TURN_RUNTIME_NOTE);
+ let (stable, env) = agent.compose_system_parts();
+ assert!(env.contains("## Turn policy"), "{env}");
+ assert!(env.contains("advisory_only=true"), "{env}");
+ assert!(env.contains("read_file, read_symbol, fd, rg"), "{env}");
+ assert!(
+ !stable.contains("advisory_only=true"),
+ "turn policy must stay out of the cached stable block: {stable}"
+ );
+
+ agent.turn_policy_note = Some(IMPLEMENTATION_TURN_RUNTIME_NOTE);
+ let (_, env) = agent.compose_system_parts();
+ assert!(env.contains("implementation_authorized=true"), "{env}");
+ assert!(env.contains("todo_write"), "{env}");
+
+ agent.turn_policy_note = None;
+ let (_, env) = agent.compose_system_parts();
+ assert!(!env.contains("Turn policy"), "{env}");
+
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+#[test]
+fn steering_updates_turn_policy_note() {
+ let root = temp_test_dir("steering-turn-policy");
+ let root = std::fs::canonicalize(root).expect("canonical temp dir");
+ let mut agent = test_agent(&root);
+
+ agent.turn_policy_note = Some(ADVISORY_TURN_RUNTIME_NOTE);
+ agent.update_turn_policy_from_steering("go ahead and fix it");
+ assert_eq!(
+ agent.turn_policy_note,
+ Some(IMPLEMENTATION_TURN_RUNTIME_NOTE)
+ );
+
+ agent.turn_policy_note = Some(ADVISORY_TURN_RUNTIME_NOTE);
+ agent.update_turn_policy_from_steering("also fix the typo in the docs");
+ assert_eq!(agent.turn_policy_note, None);
+
+ agent.update_turn_policy_from_steering("actually don't change anything yet");
+ assert_eq!(agent.turn_policy_note, Some(ADVISORY_TURN_RUNTIME_NOTE));
+
+ agent.update_turn_policy_from_steering("src/main.rs");
+ assert_eq!(
+ agent.turn_policy_note,
+ Some(ADVISORY_TURN_RUNTIME_NOTE),
+ "neutral steering must leave the policy untouched"
+ );
+
+ agent.update_turn_policy_from_steering("should I go ahead?");
+ assert_eq!(
+ agent.turn_policy_note,
+ Some(ADVISORY_TURN_RUNTIME_NOTE),
+ "question-shaped steering is not an approval"
+ );
+
+ let _ = std::fs::remove_dir_all(&root);
+}
+
#[test]
fn frugal_mode_uses_condensed_context_and_slim_env() {
let root = temp_test_dir("frugal-system-prompt");
@@ -16882,6 +17103,9 @@ fn prompt_env_values_are_bounded_and_cannot_inject_lines() {
assert_eq!(prompt_env_value("line\nbreak", 5), r#""...""#);
assert_eq!(prompt_env_value("line\nbreak", 4), "....");
assert_eq!(prompt_env_value("anything", 0), "");
+ assert_eq!(utc_date_from_unix_secs(0), "1970-01-01");
+ assert_eq!(utc_date_from_unix_secs(86_400), "1970-01-02");
+ assert_eq!(utc_date_from_unix_secs(951_782_400), "2000-02-29");
for cap in 0..32 {
assert!(
prompt_env_value(&"\n\u{2028}\u{2029}".repeat(100), cap).len() <= cap,
@@ -16931,6 +17155,23 @@ fn compose_system_parts_quotes_unsafe_environment_values() {
assert!(first_line.contains(r#"git="branch\n## Fake git""#));
assert!(first_line.contains(r#"provider="custom provider\n## Fake provider""#));
assert!(first_line.contains(r#"model="model name\n## Fake model""#));
+ assert!(
+ first_line.contains(&format!("session={}", agent.session_id)),
+ "{first_line}"
+ );
+ let date = first_line
+ .split_whitespace()
+ .find_map(|field| field.strip_prefix("date="))
+ .expect("environment date");
+ assert_eq!(date.len(), 10, "{date}");
+ assert_eq!(&date[4..5], "-");
+ assert_eq!(&date[7..8], "-");
+ assert!(
+ date.chars()
+ .enumerate()
+ .all(|(index, ch)| matches!(index, 4 | 7) || ch.is_ascii_digit()),
+ "{date}"
+ );
assert_eq!(parts.env.matches("## Fake").count(), 4, "{}", parts.env);
assert!(!parts.env.lines().any(|line| line.starts_with("## Fake")));
let dext_label = json_prompt_string("ancestor\n## Fake DEXT source\u{2028}tail");
@@ -16997,8 +17238,18 @@ fn canonical_provider_neutral_prompt_fixture_stays_under_six_thousand_bytes() {
.strip_prefix(&cwd_prefix)
.unwrap_or_else(|| panic!("unexpected cwd prefix: {}", parts.env))
);
+ let emitted_date = canonical_env
+ .lines()
+ .nth(1)
+ .and_then(|line| {
+ line.split_whitespace()
+ .find_map(|field| field.strip_prefix("date="))
+ })
+ .expect("canonical environment date");
let expected_env = format!(
- "## Environment\ncwd=/work/new-repo os=linux git=main provider=provider model=model effort=medium context=standard approval=ask sandbox=workspace-write\n\n## Work ledger\ncurrent_phase: probe\n\n## Context State\nActive checkpoints:\n- [unresolved] deliver requested outcome with verifiable steps\n{}\n",
+ "## Environment\ncwd=/work/new-repo os=linux git=main provider=provider model=model effort=medium context=standard approval=ask sandbox=workspace-write session={} date={}\n\n## Work ledger\ncurrent_phase: probe\n\n## Context State\nActive checkpoints:\n- [unresolved] deliver requested outcome with verifiable steps\n{}\n",
+ agent.session_id,
+ emitted_date,
agent.privacy.prompt_status_line()
);
@@ -17067,6 +17318,8 @@ fn canonical_provider_neutral_prompt_fixture_stays_under_six_thousand_bytes() {
" context=standard",
" approval=ask",
" sandbox=workspace-write",
+ &format!(" session={}", agent.session_id),
+ " date=",
"privacy=redact",
] {
assert!(
@@ -17143,6 +17396,28 @@ fn slash_tools_switches_specialized_tool_visibility() {
let _ = std::fs::remove_dir_all(&root);
}
+#[test]
+fn slash_presentation_distinguishes_structured_views_and_faded_confirmations() {
+ let root = temp_test_dir("slash-presentation");
+ let root = std::fs::canonicalize(root).expect("canonical temp dir");
+ let mut agent = test_agent(&root);
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ agent.set_sink(Box::new(ChannelSink { tx }));
+
+ assert_eq!(handle_slash("/help", &mut agent), Some(true));
+ assert_eq!(handle_slash("/effort high", &mut agent), Some(true));
+
+ let events = drain_events(&mut rx);
+ assert!(events.iter().any(
+ |event| matches!(event, AgentEvent::StructuredSlash(text) if text.contains("Commands"))
+ ));
+ assert!(events.iter().any(
+ |event| matches!(event, AgentEvent::Slash(text) if text.contains("thinking effort: high"))
+ ));
+
+ let _ = std::fs::remove_dir_all(&root);
+}
+
#[test]
fn slash_allow_and_allowed_include_active_runtime_tools() {
let root = temp_test_dir("slash-runtime-allow");
@@ -17200,7 +17475,7 @@ fn slash_system_displays_composed_prompt_with_project_context() {
let slash = drain_events(&mut rx)
.into_iter()
.find_map(|event| match event {
- AgentEvent::Slash(text) => Some(text),
+ AgentEvent::StructuredSlash(text) => Some(text),
_ => None,
})
.unwrap_or_default();
@@ -17232,6 +17507,112 @@ fn compose_system_parts_includes_recall_md() {
let _ = std::fs::remove_dir_all(&root);
}
+#[test]
+fn recall_prompt_injection_has_one_aggregate_four_kib_payload_budget() {
+ let root = temp_test_dir("recall-aggregate-prompt-cap");
+ let root = std::fs::canonicalize(root).expect("canonical temp dir");
+ let mut agent = test_agent(&root);
+ agent.system = "base-system".to_string();
+ let first = "x".repeat(3_000);
+ let second = "y".repeat(3_000);
+ *agent.prompt_scan_cache.lock().expect("prompt scan cache") = Some(PromptScanCache {
+ epoch: agent.prompt_scan_epoch,
+ include_project_extensions: false,
+ dext_md: PromptContextScan::default(),
+ recall: PromptContextScan {
+ sections: vec![
+ (
+ "ancestor".to_string(),
+ root.join("ancestor-recall.md"),
+ first.clone(),
+ sha256_hex_str(&first),
+ ),
+ (
+ ".".to_string(),
+ root.join("recall.md"),
+ second.clone(),
+ sha256_hex_str(&second),
+ ),
+ ],
+ ..PromptContextScan::default()
+ },
+ pack_summary: None,
+ shelf_summary: None,
+ });
+
+ let parts = agent.compose_system_details();
+ let payload_bytes = parts.stable.matches('x').count() + parts.stable.matches('y').count();
+ assert!(payload_bytes <= RECALL_MEMORY_MAX_BYTES, "{payload_bytes}");
+ assert_eq!(parts.stable.matches('x').count(), first.len());
+ assert!(parts.stable.matches('y').count() < second.len());
+ assert!(
+ parts
+ .stable
+ .contains("recall.md capped at 4096 bytes total"),
+ "{}",
+ parts.stable
+ );
+ assert_eq!(parts.prompt_sources.len(), 2);
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn recall_journal_digest_uses_redacted_input() {
+ let _guard = env_lock();
+ let root = temp_test_dir("recall-journal-redaction");
+ let mut agent = test_agent(&root);
+ agent.set_approval_profile(ApprovalProfile::Always);
+ let secret = format!("API_{}={}", "KEY", "abcdefghijklmnop");
+ let raw_input = json!({"path": "recall.md", "content": secret});
+ let mut expected_input = raw_input.clone();
+ assert!(
+ agent
+ .privacy
+ .redact_recall_tool_input("write_file", &mut expected_input, &root)
+ );
+ let runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("build runtime");
+ let mut turn_state = orchestrator::TurnRuntimeState::new();
+ runtime
+ .block_on(agent.execute_tool_round(ToolRoundContext {
+ tool_calls: vec![(
+ "call-recall-journal".to_string(),
+ "write_file".to_string(),
+ raw_input.clone(),
+ )],
+ iterations: 1,
+ turn_id: "turn-recall-journal".to_string(),
+ objective_apply_fixes_allowed: true,
+ turn_state: &mut turn_state,
+ denied_signatures: HashSet::new(),
+ hooks_approval_decided: true,
+ hooks_approved: false,
+ }))
+ .expect("execute journaled recall write");
+
+ let entries = tool_journal::load_for_session_file(&agent.latest_session_path)
+ .expect("load tool journal")
+ .expect("tool journal entries");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(
+ entries[0].input_sha256,
+ tool_journal::input_sha256(&expected_input).expect("redacted input digest")
+ );
+ assert_ne!(
+ entries[0].input_sha256,
+ tool_journal::input_sha256(&raw_input).expect("raw input digest")
+ );
+ assert_eq!(
+ std::fs::read_to_string(root.join("recall.md")).expect("read redacted recall"),
+ expected_input["content"].as_str().unwrap()
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
#[test]
fn local_provider_defaults_to_frugal_without_changing_frontier_default() {
assert_eq!(
@@ -17550,7 +17931,8 @@ fn systems_preserve_tool_protocol_guardrails_and_table_guidance() {
"bash/sudo discovery",
"address it in the response",
"For nontrivial work use todo",
- "modify neither unless asked",
+ "Treat DEXT.md as human-authored policy and never modify it",
+ "Treat recall.md as working memory governed by applicable DEXT.md guidance",
"Prefer native tools",
"fd for files, rg for text/symbols",
"Parallelize independent reads",
@@ -17853,6 +18235,36 @@ fn model_context_window_uses_builtin_chatgpt_profile_when_catalog_isolated() ->
result
}
+#[test]
+fn model_context_window_uses_builtin_anthropic_profile_when_catalog_isolated() -> Result<()> {
+ let _guard = env_lock();
+ clear_cached_local_llama_context_windows();
+ let root = temp_test_dir("ctx-window-builtin-anthropic");
+ let root = std::fs::canonicalize(&root)?;
+ unsafe {
+ std::env::set_var("DEXT_HOME", &root);
+ std::env::remove_var("DEXT_CONTEXT_WINDOW");
+ std::env::remove_var("DEXT_CONTEXT_WINDOW_TOKENS");
+ }
+
+ let result = {
+ for model in ["claude-sonnet-5", "claude-opus-5", "claude-fable-5"] {
+ assert_eq!(model_context_window(model), 1_000_000, "{model}");
+ }
+ assert_eq!(model_context_window("claude-sonnet-4-6"), 200_000);
+ assert_eq!(model_context_window("claude-opus-4-8"), 200_000);
+ Ok(())
+ };
+
+ unsafe {
+ std::env::remove_var("DEXT_HOME");
+ std::env::remove_var("DEXT_CONTEXT_WINDOW");
+ std::env::remove_var("DEXT_CONTEXT_WINDOW_TOKENS");
+ }
+ let _ = std::fs::remove_dir_all(&root);
+ result
+}
+
#[test]
fn llama_context_parser_prefers_runtime_ctx_fields() {
assert_eq!(
@@ -19209,7 +19621,7 @@ fn gpt_5_6_pricing_applies_documented_long_context_tier() {
}
#[test]
-fn anthropic_fable_pricing_matches_console_session_cost() {
+fn anthropic_fable_pricing_matches_published_rates() {
let pricing = usage_pricing_default_for(
"anthropic",
ApiProvider::Anthropic,
@@ -19226,14 +19638,48 @@ fn anthropic_fable_pricing_matches_console_session_cost() {
let estimate = pricing.estimate(usage);
assert!(
- (estimate - 5.83).abs() < 0.0001,
- "expected $5.83, got ${estimate:.8}"
+ (estimate - 4.9736735).abs() < 0.0001,
+ "expected $4.9736735, got ${estimate:.8}"
);
assert!((pricing.output / pricing.input - 5.0).abs() < 0.000001);
assert!((pricing.cache_read / pricing.input - 0.1).abs() < 0.000001);
assert!((pricing.cache_create / pricing.input - 1.25).abs() < 0.000001);
}
+#[test]
+fn anthropic_generation_pricing_matches_published_rates() {
+ let for_model = |model: &str| {
+ usage_pricing_default_for(
+ "anthropic",
+ ApiProvider::Anthropic,
+ "https://api.anthropic.com",
+ model,
+ )
+ };
+ for (model, input, output, cache_read, cache_create) in [
+ ("claude-sonnet-5", 2.0, 10.0, 0.2, 2.5),
+ ("claude-opus-5", 5.0, 25.0, 0.5, 6.25),
+ ("claude-opus-4-8", 5.0, 25.0, 0.5, 6.25),
+ ("claude-opus-4-6", 5.0, 25.0, 0.5, 6.25),
+ ("claude-opus-4-5-20251101", 5.0, 25.0, 0.5, 6.25),
+ ("claude-opus-4-1", 15.0, 75.0, 1.5, 18.75),
+ ("claude-sonnet-4-6", 3.0, 15.0, 0.3, 3.75),
+ ("claude-fable-5", 10.0, 50.0, 1.0, 12.5),
+ ] {
+ let pricing = for_model(model);
+ assert_eq!(
+ (
+ pricing.input,
+ pricing.output,
+ pricing.cache_read,
+ pricing.cache_create
+ ),
+ (input, output, cache_read, cache_create),
+ "{model}"
+ );
+ }
+}
+
#[test]
fn anthropic_wire_cost_is_repriced_for_supported_claude_models() {
let root = temp_test_dir("anthropic-reprice-wire-cost");
@@ -19253,7 +19699,7 @@ fn anthropic_wire_cost_is_repriced_for_supported_claude_models() {
agent.finalize_usage_metrics(&mut usage);
assert!(
- (usage.estimated_cost_usd() - 5.83).abs() < 0.0001,
+ (usage.estimated_cost_usd() - 4.9736735).abs() < 0.0001,
"expected Anthropic model pricing to override stale wire/default cost, got ${:.8}",
usage.estimated_cost_usd()
);
@@ -22184,7 +22630,7 @@ fn oauth_callback_cannot_complete_a_different_provider_login() -> Result<()> {
}
#[test]
-fn anthropic_subscription_body_is_scoped_to_official_oauth_profile() -> Result<()> {
+fn anthropic_subscription_body_is_scoped_and_preserves_adaptive_fields() -> Result<()> {
let root = temp_test_dir("anthropic-subscription-body");
let root = std::fs::canonicalize(&root)?;
let profile = built_in_provider_profiles()
@@ -22196,8 +22642,9 @@ fn anthropic_subscription_body_is_scoped_to_official_oauth_profile() -> Result<(
agent.api_provider = profile.api_provider;
agent.provider_profile = Some(profile);
agent.base_url = "https://api.anthropic.com".to_string();
- agent.model = "claude-sonnet-4-6".to_string();
+ agent.model = "claude-opus-4-8".to_string();
agent.auth_kind = RuntimeAuthKind::OAuth;
+ agent.thinking_effort = ThinkingEffort::XHigh;
agent.history = vec![Message {
role: "user".to_string(),
content: vec![Block::Text {
@@ -22221,6 +22668,9 @@ fn anthropic_subscription_body_is_scoped_to_official_oauth_profile() -> Result<(
claude_subscription::AGENT_SDK_SYSTEM_PROMPT
);
assert_eq!(body["system"][2]["text"], "Dext system");
+ assert_eq!(body["thinking"]["type"], "adaptive");
+ assert!(body["thinking"].get("display").is_none(), "{body}");
+ assert_eq!(body["output_config"]["effort"], "xhigh");
agent.auth_kind = RuntimeAuthKind::ApiKey;
let (_, api_body) =
@@ -22228,6 +22678,38 @@ fn anthropic_subscription_body_is_scoped_to_official_oauth_profile() -> Result<(
let api_body: Value = serde_json::from_slice(&api_body)?;
assert_eq!(api_body["system"][0]["text"], "Dext system");
assert_eq!(api_body["system"].as_array().map(Vec::len), Some(1));
+ assert_eq!(api_body["thinking"]["type"], "adaptive");
+ assert!(api_body["thinking"].get("display").is_none(), "{api_body}");
+ assert_eq!(api_body["output_config"]["effort"], "xhigh");
+
+ agent.model = "claude-sonnet-5".to_string();
+ agent.auth_kind = RuntimeAuthKind::OAuth;
+ let (_, sonnet_body) =
+ agent.build_streaming_request("Dext system", "env", &system, &[], "unused")?;
+ let sonnet_body: Value = serde_json::from_slice(&sonnet_body)?;
+ assert_eq!(
+ sonnet_body["system"][1]["text"],
+ claude_subscription::AGENT_SDK_SYSTEM_PROMPT
+ );
+ assert_eq!(sonnet_body["thinking"]["type"], "adaptive");
+ assert!(
+ sonnet_body["thinking"].get("display").is_none(),
+ "{sonnet_body}"
+ );
+ assert_eq!(sonnet_body["output_config"]["effort"], "xhigh");
+
+ agent.auth_kind = RuntimeAuthKind::ApiKey;
+ let (_, sonnet_api_body) =
+ agent.build_streaming_request("Dext system", "env", &system, &[], "unused")?;
+ let sonnet_api_body: Value = serde_json::from_slice(&sonnet_api_body)?;
+ assert_eq!(sonnet_api_body["system"][0]["text"], "Dext system");
+ assert_eq!(sonnet_api_body["thinking"]["type"], "adaptive");
+ assert!(
+ sonnet_api_body["thinking"].get("display").is_none(),
+ "{sonnet_api_body}"
+ );
+ assert_eq!(sonnet_api_body["output_config"]["effort"], "xhigh");
+ agent.model = "claude-opus-4-8".to_string();
agent.auth_kind = RuntimeAuthKind::OAuth;
agent.base_url = "https://api.anthropic.com".to_string();
@@ -23516,30 +23998,37 @@ fn claude_anthropic_streaming_request_uses_adaptive_thinking_output_config() ->
assert_eq!(value["thinking"]["budget_tokens"], 6_144);
assert!(value.get("output_config").is_none(), "{value}");
- agent.model = "claude-sonnet-4-6".to_string();
- agent.thinking_effort = ThinkingEffort::Medium;
- let (_, body) = agent.build_streaming_request("sys", "env", &sys_blocks, &[], "unused")?;
- let value: Value = serde_json::from_slice(&body)?;
- assert_eq!(value["thinking"]["type"], "adaptive");
- assert!(value["thinking"].get("display").is_none(), "{value}");
- assert!(value["thinking"].get("budget_tokens").is_none(), "{value}");
- assert_eq!(value["output_config"]["effort"], "medium");
-
- agent.model = "claude-opus-4-8".to_string();
- agent.thinking_effort = ThinkingEffort::XHigh;
- let (_, body) = agent.build_streaming_request("sys", "env", &sys_blocks, &[], "unused")?;
- let value: Value = serde_json::from_slice(&body)?;
- assert_eq!(value["thinking"]["type"], "adaptive");
- assert!(value["thinking"].get("display").is_none(), "{value}");
- assert_eq!(value["output_config"]["effort"], "xhigh");
-
- agent.model = "claude-fable-5".to_string();
- agent.thinking_effort = ThinkingEffort::Max;
- let (_, body) = agent.build_streaming_request("sys", "env", &sys_blocks, &[], "unused")?;
- let value: Value = serde_json::from_slice(&body)?;
- assert_eq!(value["thinking"]["type"], "adaptive");
- assert!(value["thinking"].get("display").is_none(), "{value}");
- assert_eq!(value["output_config"]["effort"], "max");
+ for (model, thinking_effort, provider_effort) in [
+ ("claude-sonnet-4-6", ThinkingEffort::Medium, "medium"),
+ ("claude-sonnet-4-6", ThinkingEffort::XHigh, "high"),
+ ("claude-sonnet-4-6", ThinkingEffort::Max, "max"),
+ ("claude-sonnet-5", ThinkingEffort::Medium, "medium"),
+ ("claude-sonnet-5", ThinkingEffort::XHigh, "xhigh"),
+ ("claude-sonnet-5", ThinkingEffort::Max, "max"),
+ ("claude-opus-4-6", ThinkingEffort::High, "high"),
+ ("claude-opus-4-6", ThinkingEffort::XHigh, "high"),
+ ("claude-opus-4-6", ThinkingEffort::Max, "max"),
+ ("claude-opus-4-7", ThinkingEffort::XHigh, "xhigh"),
+ ("claude-opus-4-8", ThinkingEffort::XHigh, "xhigh"),
+ ("claude-opus-5", ThinkingEffort::XHigh, "xhigh"),
+ ("claude-opus-5", ThinkingEffort::Max, "max"),
+ ("claude-fable-5", ThinkingEffort::Max, "max"),
+ ] {
+ agent.model = model.to_string();
+ agent.thinking_effort = thinking_effort;
+ let (_, body) = agent.build_streaming_request("sys", "env", &sys_blocks, &[], "unused")?;
+ let value: Value = serde_json::from_slice(&body)?;
+ assert_eq!(value["thinking"]["type"], "adaptive", "{model}");
+ assert!(
+ value["thinking"].get("display").is_none(),
+ "{model}: {value}"
+ );
+ assert!(
+ value["thinking"].get("budget_tokens").is_none(),
+ "{model}: {value}"
+ );
+ assert_eq!(value["output_config"]["effort"], provider_effort, "{model}");
+ }
agent.model = "claude-opus-4-1".to_string();
agent.thinking_effort = ThinkingEffort::Off;
@@ -23552,6 +24041,26 @@ fn claude_anthropic_streaming_request_uses_adaptive_thinking_output_config() ->
Ok(())
}
+#[test]
+fn anthropic_builtin_catalog_lists_generation_5_models() {
+ let profile = built_in_provider_profiles()
+ .into_iter()
+ .find(|profile| profile.id == "anthropic")
+ .expect("anthropic profile");
+ assert_eq!(profile.default_model, "claude-sonnet-4-6");
+ for model in ["claude-sonnet-5", "claude-opus-5", "claude-fable-5"] {
+ assert!(
+ profile.models.iter().any(|entry| entry == model),
+ "{model} missing from builtin catalog"
+ );
+ assert_eq!(
+ profile.model_context_windows.get(model),
+ Some(&1_000_000),
+ "{model}"
+ );
+ }
+}
+
#[test]
fn kimi_builtin_metadata_is_isolated_from_existing_provider_profiles() {
let profiles = built_in_provider_profiles();
@@ -24798,8 +25307,8 @@ async fn anthropic_unfinished_tool_call_automatically_continues() {
}
#[tokio::test]
-async fn anthropic_stream_omitted_thinking_preserves_signature_for_roundtrip() {
- let root = temp_test_dir("anthropic-omitted-thinking-stream");
+async fn anthropic_stream_visible_thinking_reaches_sink_and_preserves_signed_roundtrip() {
+ let root = temp_test_dir("anthropic-visible-thinking-stream");
let root = std::fs::canonicalize(&root).expect("canonical temp dir");
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("local addr");
@@ -24815,7 +25324,7 @@ async fn anthropic_stream_omitted_thinking_preserves_signature_for_roundtrip() {
assert!(read > 0, "client closed before sending request headers");
request.extend_from_slice(&buf[..read]);
}
- let body = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig-full\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"redacted_thinking\",\"data\":\"opaque-redacted\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"answer\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
+ let body = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"visible reasoning\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig-full\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"redacted_thinking\",\"data\":\"opaque-redacted\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"answer\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
@@ -24826,14 +25335,26 @@ async fn anthropic_stream_omitted_thinking_preserves_signature_for_roundtrip() {
let mut agent = test_agent(&root);
agent.api_provider = ApiProvider::Anthropic;
+ agent.model = "claude-sonnet-5".to_string();
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ agent.set_sink(Box::new(ChannelSink { tx }));
let resp = reqwest::get(format!("http://{addr}/stream"))
.await
.expect("response");
let ParsedProviderStream { blocks, .. } = agent.read_stream(resp).await.expect("parse stream");
+ let events = drain_events(&mut rx);
+ assert!(events.iter().any(|event| matches!(
+ event,
+ AgentEvent::ThinkingDelta(text) if text == "visible reasoning"
+ )));
+ assert!(events.iter().any(|event| matches!(
+ event,
+ AgentEvent::ThinkingBlockComplete(text) if text == "visible reasoning"
+ )));
assert!(
matches!(
blocks.first(),
- Some(Block::Thinking { text, signature: Some(signature) }) if text.is_empty() && signature == "sig-full"
+ Some(Block::Thinking { text, signature: Some(signature) }) if text == "visible reasoning" && signature == "sig-full"
),
"{blocks:?}"
);
@@ -25836,67 +26357,6 @@ async fn compact_uses_deterministic_evidence_fallback_when_summary_request_error
let _ = std::fs::remove_dir_all(&root);
}
-#[tokio::test(flavor = "current_thread")]
-async fn read_only_plan_suppresses_internal_planner_events_hooks_and_restores_sink() {
- let root = temp_test_dir("plan-silent-sink");
- let root = std::fs::canonicalize(&root).expect("canonical temp dir");
- std::fs::write(
- root.join("hooks.json"),
- r#"{"user_prompt":[{"match":"*","command":"printf fired > hook-fired"}]}"#,
- )
- .expect("write hooks");
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
- let addr = listener.local_addr().expect("local addr");
- let server = std::thread::spawn(move || {
- let (mut stream, _) = listener.accept().expect("accept");
- let mut request = [0u8; 4096];
- let _ = stream.read(&mut request);
- let body = "data: {\"choices\":[{\"delta\":{\"content\":\"plan text\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
- let response = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n{}",
- body.len(),
- body
- );
- std::io::Write::write_all(&mut stream, response.as_bytes()).expect("write response");
- });
-
- let mut agent = test_agent(&root);
- agent.api_provider = ApiProvider::OpenAi;
- agent.provider_id = "local".to_string();
- agent.provider_requires_api_key = false;
- agent.api_key.clear();
- agent.base_url = format!("http://{addr}");
- agent.model = DEFAULT_LOCAL_MODEL.to_string();
- agent.hooks = Hooks::load(&root);
- let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
- agent.set_sink(Box::new(ChannelSink { tx }));
-
- let plan = agent
- .generate_read_only_plan("write a plan")
- .await
- .expect("plan completes");
- assert_eq!(plan, "plan text");
- assert!(
- drain_events(&mut rx).is_empty(),
- "internal planner events must not leak to the active sink"
- );
- assert!(
- !root.join("hook-fired").exists(),
- "planning must not fire user_prompt hooks"
- );
-
- agent.sink.emit(AgentEvent::Slash("restored".to_string()));
- assert!(
- drain_events(&mut rx)
- .into_iter()
- .any(|event| matches!(event, AgentEvent::Slash(text) if text == "restored")),
- "original sink should be restored after planning"
- );
-
- server.join().expect("server thread");
- let _ = std::fs::remove_dir_all(&root);
-}
-
#[test]
fn packs_discover_user_global_pack_from_dext_home() -> Result<()> {
let _guard = env_lock();
@@ -26372,7 +26832,7 @@ fn slash_shelves_lists_typed_manifest_registry() {
let slash = drain_events(&mut rx)
.into_iter()
.find_map(|event| match event {
- AgentEvent::Slash(text) => Some(text),
+ AgentEvent::StructuredSlash(text) => Some(text),
_ => None,
})
.unwrap_or_default();
@@ -26451,7 +26911,7 @@ fn slash_pack_list_and_inspect_use_discovered_packs() -> Result<()> {
let slash_text = drain_events(&mut rx)
.into_iter()
.filter_map(|event| match event {
- AgentEvent::Slash(text) => Some(text),
+ AgentEvent::StructuredSlash(text) => Some(text),
_ => None,
})
.collect::>()
@@ -26491,7 +26951,7 @@ fn slash_pack_verbose_flag_lists_with_paths() -> Result<()> {
let slash_text = drain_events(&mut rx)
.into_iter()
.find_map(|event| match event {
- AgentEvent::Slash(text) => Some(text),
+ AgentEvent::StructuredSlash(text) => Some(text),
_ => None,
})
.unwrap_or_default();
@@ -26510,7 +26970,7 @@ fn slash_pack_verbose_flag_lists_with_paths() -> Result<()> {
let followup = drain_events(&mut rx)
.into_iter()
.filter_map(|event| match event {
- AgentEvent::Slash(text) => Some(text),
+ AgentEvent::Slash(text) | AgentEvent::StructuredSlash(text) => Some(text),
_ => None,
})
.collect::>()
@@ -26687,9 +27147,181 @@ fn list_render_wrap_splits_long_words() {
}
#[test]
-fn list_render_bold_only_with_color() {
+fn list_render_keeps_session_blueprint_and_sanitizes_controls() {
+ assert_eq!(list_render::width_for_terminal_cols(0), 1);
+ assert_eq!(list_render::width_for_terminal_cols(1), 1);
+ assert_eq!(list_render::width_for_terminal_cols(2), 1);
+ assert_eq!(list_render::width_for_terminal_cols(19), 17);
+ assert_eq!(list_render::width_for_terminal_cols(500), 120);
+
+ let opts = list_render::ListOptions::fixed(false, 80);
+ let mut out = list_render::render_count_header("Items", 1, "found", &opts);
+ out.push_str(&list_render::render_section_header(
+ "Unsafe\rsection\u{2028}next\u{2029}last\u{202e}spoof\u{2066}isolated\u{0007}\x1b]0;hidden\u{0007}",
+ &opts,
+ ));
+ out.push_str(&list_render::render_entry(
+ "entry\tname\x1b[31m!\x1b[0m",
+ "description with controls\u{0007}\x1b]0;hidden\u{0007}",
+ &[("source", "project\rspoofed\x1b[31mred\x1b[0m".to_string())],
+ &opts,
+ ));
+ out.push_str(&list_render::render_footer(
+ &["/command\x1b]0;hidden\u{0007} "],
+ &opts,
+ ));
+
+ assert!(!out.contains(['\x1b', '\r', '\t', '\u{0007}']), "{out:?}");
+ assert!(!out.contains("hidden"), "{out:?}");
+ assert_eq!(
+ out,
+ "Items 1 found\nUnsafe\nsection\nnext\nlastspoofisolated\n entry name!\n description with controls\n source: project\n spoofedred\n\nUse:\n /command \n"
+ );
+}
+
+#[test]
+fn list_render_bounds_every_structured_row_by_display_cells() {
+ let width = 12;
+ let opts = list_render::ListOptions::fixed(false, width);
+ let mut out = list_render::render_count_header("界界界界界界界", 123, "found", &opts);
+ out.push_str(&list_render::render_section_header("分類\r見出し", &opts));
+ out.push_str(&list_render::render_entry(
+ "項目 名前界界界",
+ "説明 界界界界界 長い説明",
+ &[("metadata-key", "値界界界界界界".to_string())],
+ &opts,
+ ));
+ out.push_str(&list_render::render_footer(
+ &["/command-with-a-long-name "],
+ &opts,
+ ));
+
+ assert!(!out.contains(['\x1b', '\r', '\t', '\u{0007}']), "{out:?}");
+ for line in out.lines() {
+ assert!(
+ unicode_width::UnicodeWidthStr::width(line) <= width,
+ "line exceeds {width} cells: {line:?}\n{out}"
+ );
+ }
+ assert!(
+ out.contains("項目 "),
+ "preformatted spacing was lost: {out}"
+ );
+
+ let wide_opts = list_render::ListOptions::fixed(false, 80);
+ let multiline =
+ list_render::render_entry_rows(&[("unsafe\rname", "description\rcontinued")], &wide_opts);
+ assert_eq!(
+ multiline,
+ " unsafe\n name\n description\n continued\n"
+ );
+}
+
+#[test]
+fn list_render_one_cell_rows_replace_only_impossible_wide_clusters() {
+ let one_cell = list_render::ListOptions::fixed(false, 1);
+ let mut out = list_render::render_count_header("界", 1, "件", &one_cell);
+ out.push_str(&list_render::render_entry(
+ "🙂",
+ "界 🙂",
+ &[("界", "🙂".to_string())],
+ &one_cell,
+ ));
+ out.push_str(&list_render::render_footer(&["/界🙂"], &one_cell));
+
+ assert!(
+ out.lines()
+ .all(|line| unicode_width::UnicodeWidthStr::width(line) <= 1),
+ "{out:?}"
+ );
+ assert!(!out.contains(['界', '🙂']), "{out:?}");
+ assert!(out.contains('?'), "{out:?}");
+
+ let two_cells = list_render::ListOptions::fixed(false, 2);
+ let preserved = list_render::render_section_header("界🙂", &two_cells);
+ assert!(preserved.contains('界'), "{preserved:?}");
+ assert!(preserved.contains('🙂'), "{preserved:?}");
+ assert!(
+ preserved
+ .lines()
+ .all(|line| unicode_width::UnicodeWidthStr::width(line) <= 2),
+ "{preserved:?}"
+ );
+}
+
+#[test]
+fn structured_slash_renderers_follow_session_blueprint() {
+ let root = temp_test_dir("structured-slash-session-blueprint");
+ let agent = test_agent(&root);
+
+ let help = render_help_listing(Some(100));
+ assert!(help.starts_with("Commands "), "{help}");
+ assert!(
+ help.contains("\nCore\n /help show this list\n /quit, /exit exit dext\n"),
+ "{help}"
+ );
+ assert!(
+ help.ends_with("Use:\n / — [args] optional, required"),
+ "{help}"
+ );
+
+ let tools = render_tools_status(&agent);
+ assert!(tools.starts_with("Tools "), "{tools}");
+ assert!(
+ tools.contains(&format!(
+ "\nProfile\n toolset: {} schemas: {} approval: {}\n",
+ agent.tool_context_profile().as_str(),
+ agent.wire_tool_profile().as_str(),
+ agent.approval_profile.as_str(),
+ )),
+ "{tools}"
+ );
+ assert!(
+ tools.ends_with("Use:\n /tools default|full\n /allow \n /revoke "),
+ "{tools}"
+ );
+
+ let system = render_system_prompt_view(&agent);
+ assert!(system.starts_with("System prompt "), "{system}");
+ assert!(
+ system.contains("\n\nSources\n base prompt: "),
+ "{system}"
+ );
+ assert!(system.contains("\n\nPrompt\n"), "{system}");
+ assert!(
+ system.ends_with("\nUse:\n /system (replace the base prompt)"),
+ "{system}"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn empty_pack_listing_uses_session_style_header_and_search_section() {
+ let root = temp_test_dir("empty-pack-list");
+ let opts = list_render::ListOptions::fixed(false, 12);
+ let out = packs::render_pack_list(&[], &opts, &root);
+
+ assert!(out.starts_with("Packs\n0 found\nSearch paths\n"), "{out}");
+ assert!(
+ out.lines()
+ .all(|line| unicode_width::UnicodeWidthStr::width(line) <= 12),
+ "{out}"
+ );
+ let compact = out.split_whitespace().collect::();
+ assert!(compact.contains(".dext/shelves/*/packs"), "{out}");
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn list_render_bold_only_with_color_and_strips_source_escapes() {
assert_eq!(list_render::bold("x", false), "x");
assert_eq!(list_render::bold("x", true), "\x1b[1mx\x1b[0m");
+ assert_eq!(
+ list_render::bold("x\x1b[31my\x1b[0m", true),
+ "\x1b[1mxy\x1b[0m"
+ );
}
#[test]
@@ -26716,11 +27348,13 @@ fn session_listing_shows_header_and_footer() -> Result<()> {
agent.save_latest_session()?;
let listing = render_session_listing(&project);
- assert!(listing.contains("Sessions"), "{listing}");
- assert!(listing.contains("Latest"), "{listing}");
- assert!(listing.contains("Named"), "{listing}");
- assert!(listing.contains("Autosaved"), "{listing}");
- assert!(listing.contains("Use:"), "{listing}");
+ assert!(
+ listing.starts_with("Sessions 2 found\nLatest\n latest\n"),
+ "{listing}"
+ );
+ assert!(listing.contains("\nAutosaved\n"), "{listing}");
+ assert!(listing.contains("\nNamed\n"), "{listing}");
+ assert!(listing.contains("\nUse:\n /resume [name]\n"), "{listing}");
Ok(())
})();
unsafe {
diff --git a/src/mutation_preview.rs b/src/mutation_preview.rs
index 14bb0e0..fa4c0a3 100644
--- a/src/mutation_preview.rs
+++ b/src/mutation_preview.rs
@@ -55,6 +55,13 @@ impl PreparedMutation {
std::str::from_utf8(&self.after).expect("native text mutation prepared from UTF-8")
}
+ /// Rewrite pending content before preview/approval/apply so the user
+ /// reviews and the disk receives the same bytes. The before-image and
+ /// expected fingerprint are untouched.
+ pub(crate) fn rewrite_after_text(&mut self, rewrite: impl FnOnce(&str) -> String) {
+ self.after = rewrite(self.after_text()).into_bytes();
+ }
+
pub(crate) fn preview(&self) -> MutationPreview {
compute_preview(
self.path.clone(),
diff --git a/src/orchestrator.rs b/src/orchestrator.rs
index a5f4ed9..c9cfe6c 100644
--- a/src/orchestrator.rs
+++ b/src/orchestrator.rs
@@ -408,6 +408,8 @@ pub(crate) struct ObjectiveTracker {
pub(crate) summary: String,
pub(crate) checkpoints: Vec,
pub(crate) apply_fixes_requested: bool,
+ pub(crate) advisory_only_requested: bool,
+ pub(crate) plan_execution_requested: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -565,19 +567,162 @@ fn explicit_implementation_requested(lowered: &str) -> bool {
&["fix", "them"],
&["fix", "this"],
&["fix", "the"],
+ &["fix", "anything"],
+ &["fix", "any"],
+ &["fix", "issues"],
+ &["fix", "errors"],
+ &["fix", "bugs"],
+ &["fix", "problems"],
+ &["fix", "whatever"],
&["implement"],
+ &["execute", "the", "plan"],
+ &["execute", "that", "plan"],
+ &["start", "implementation"],
+ &["proceed", "with", "implementation"],
+ &["proceed", "with", "the", "plan"],
+ &["go", "ahead"],
+ &["rip", "it", "out"],
+ &["rip", "out"],
&["patch"],
&["merge"],
&["go", "for", "it"],
&["handle", "my", "todo"],
&["make", "changes"],
&["update", "the", "code"],
+ &["update", "docs"],
+ &["update", "documentation"],
+ &["update", "tests"],
&["do", "it"],
]
.iter()
+ .any(|sequence| contains_non_negated_sequence(lowered, sequence))
+}
+
+fn planning_requested_as_action(words: &[&str]) -> bool {
+ for (index, word) in words.iter().enumerate() {
+ if *word != "plan" {
+ continue;
+ }
+ if words.get(index + 1).is_some_and(|next| {
+ matches!(
+ *next,
+ "mode" | "command" | "feature" | "implementation" | "workflow" | "slash"
+ )
+ }) {
+ continue;
+ }
+ let previous = index.checked_sub(1).and_then(|i| words.get(i)).copied();
+ let polite = index >= 2
+ && matches!(
+ words.get(index - 2).copied(),
+ Some("can" | "could" | "would")
+ )
+ && words.get(index - 1) == Some(&"you");
+ let lets = index >= 2
+ && words.get(index - 2) == Some(&"let")
+ && words.get(index - 1) == Some(&"s");
+ if index == 0
+ || polite
+ || lets
+ || matches!(
+ previous,
+ Some(
+ "please"
+ | "first"
+ | "then"
+ | "also"
+ | "just"
+ | "only"
+ | "create"
+ | "draft"
+ | "propose"
+ | "produce"
+ | "outline"
+ | "lets"
+ | "to"
+ | "me"
+ )
+ )
+ {
+ return true;
+ }
+ }
+ [
+ &["want", "a", "plan"][..],
+ &["want", "to", "plan"],
+ &["need", "a", "plan"],
+ &["give", "me", "a", "plan"],
+ &["show", "me", "a", "plan"],
+ &["come", "up", "with", "a", "plan"],
+ ]
+ .iter()
+ .any(|sequence| {
+ words
+ .windows(sequence.len())
+ .any(|window| window == *sequence)
+ })
+}
+
+fn explicit_no_mutation_requested(lowered: &str) -> bool {
+ [
+ &["plan", "only"][..],
+ &["only", "plan"],
+ &["just", "plan"],
+ &["do", "not", "implement"],
+ &["do", "not", "fix"],
+ &["do", "not", "patch"],
+ &["do", "not", "make", "changes"],
+ &["do", "not", "make", "any", "changes"],
+ &["do", "not", "change", "anything"],
+ &["do", "not", "edit", "anything"],
+ &["do", "not", "modify", "anything"],
+ &["don", "t", "implement"],
+ &["don", "t", "fix"],
+ &["don", "t", "patch"],
+ &["don", "t", "make", "changes"],
+ &["don", "t", "change", "anything"],
+ &["don", "t", "edit", "anything"],
+ &["dont", "implement"],
+ &["dont", "fix"],
+ &["dont", "patch"],
+ &["dont", "make", "changes"],
+ &["dont", "change", "anything"],
+ &["dont", "edit", "anything"],
+ &["no", "changes", "yet"],
+ &["no", "edits", "yet"],
+ &["without", "making", "changes"],
+ &["without", "editing"],
+ &["without", "modifying"],
+ &["look", "but", "don", "t", "touch"],
+ &["look", "but", "dont", "touch"],
+ ]
+ .iter()
.any(|sequence| contains_word_sequence(lowered, sequence))
}
+fn planned_execution_requested(lowered: &str) -> bool {
+ let words = normalized_words(lowered);
+ if matches!(
+ words.as_slice(),
+ ["go"] | ["proceed"] | ["approved"] | ["ship", "it"] | ["continue", "work"]
+ ) {
+ return true;
+ }
+ [
+ &["execute", "the", "plan"][..],
+ &["execute", "that", "plan"],
+ &["implement", "the", "plan"],
+ &["implement", "that", "plan"],
+ &["proceed", "with", "the", "plan"],
+ &["start", "implementation"],
+ &["go", "ahead"],
+ &["go", "for", "it"],
+ &["do", "it"],
+ ]
+ .iter()
+ .any(|sequence| contains_non_negated_sequence(lowered, sequence))
+}
+
fn explicit_apply_fixes_requested(lowered: &str) -> bool {
let words = normalized_words(lowered);
cleanup_requested_as_action(&words)
@@ -598,6 +743,8 @@ impl ObjectiveTracker {
summary: "(empty prompt)".to_string(),
checkpoints: Vec::new(),
apply_fixes_requested: false,
+ advisory_only_requested: false,
+ plan_execution_requested: false,
};
}
@@ -606,13 +753,26 @@ impl ObjectiveTracker {
let cleanup_requested = cleanup_requested_as_action(&words);
let commit_requested = commit_requested_as_action(&words);
let implementation_requested = explicit_implementation_requested(&lowered);
- let apply_fixes_requested = explicit_apply_fixes_requested(&lowered);
+ // A trailing question mark means the user is asking about proceeding,
+ // not granting approval; never inject the implementation policy for it.
+ let plan_execution_requested =
+ !compact.ends_with('?') && planned_execution_requested(&lowered);
+ let apply_fixes_requested =
+ explicit_apply_fixes_requested(&lowered) || plan_execution_requested;
+ let planning_requested = planning_requested_as_action(&words);
+ let analysis_requested = any_word_starts_with(&words, &["analy", "review"]);
+ // Explicit mutation intent wins: a "don't touch X" scoping clause inside
+ // a fix request must not demote the turn to advisory.
+ let advisory_only_requested = !apply_fixes_requested
+ && (explicit_no_mutation_requested(&lowered)
+ || planning_requested
+ || analysis_requested);
let mut checkpoints: Vec = Vec::new();
- if any_word_starts_with(&words, &["plan"]) {
+ if planning_requested {
checkpoints.push("produce execution plan".to_string());
}
- if any_word_starts_with(&words, &["analy", "review"]) {
+ if analysis_requested {
checkpoints.push("analyze current behavior and constraints".to_string());
}
if implementation_requested {
@@ -654,6 +814,8 @@ impl ObjectiveTracker {
summary,
checkpoints,
apply_fixes_requested,
+ advisory_only_requested,
+ plan_execution_requested,
}
}
@@ -661,6 +823,14 @@ impl ObjectiveTracker {
self.apply_fixes_requested
}
+ pub(crate) fn advisory_only(&self) -> bool {
+ self.advisory_only_requested
+ }
+
+ pub(crate) fn planned_execution(&self) -> bool {
+ self.plan_execution_requested
+ }
+
pub(crate) fn display_line(&self) -> String {
if self.checkpoints.is_empty() {
format!("objective: {}", self.summary)
@@ -914,7 +1084,6 @@ fn checkpoint_satisfied(checkpoint: &str, evidence: &ObjectiveEvidence) -> bool
"recall.md",
"pending",
"follow-up",
- "dext.md",
],
)
}
@@ -1114,7 +1283,7 @@ fn commands_contain(commands: &[String], needles: &[&str]) -> bool {
}
fn is_decision_log_path(path: &str) -> bool {
- path.ends_with("recall.md") || path.ends_with("DEXT.md")
+ path.to_ascii_lowercase().ends_with("recall.md")
}
fn adaptive_tool_cap_for_pressure(
@@ -1776,6 +1945,66 @@ mod tests {
);
}
+ #[test]
+ fn advisory_and_plan_execution_intent_detection() {
+ for advisory in [
+ "plan the auth refactor",
+ "please plan the migration first",
+ "plan the removal, do not implement",
+ "let's plan how to restructure the session store",
+ "give me a plan for the upgrade",
+ "review dext for bugs",
+ "analyze the resize flicker, no changes yet",
+ "how does compaction work? don't change anything",
+ ] {
+ let tracker = ObjectiveTracker::from_user_prompt(advisory);
+ assert!(tracker.advisory_only(), "{advisory}");
+ assert!(!tracker.apply_fixes_allowed(), "{advisory}");
+ }
+
+ for mutating in [
+ "fix the read-only sandbox bug",
+ "fix the bug but don't change anything else",
+ "plan then implement the retry backoff",
+ "review the flow, then apply fixes",
+ "review plan mode and rip it out",
+ "update docs for the new flag",
+ ] {
+ let tracker = ObjectiveTracker::from_user_prompt(mutating);
+ assert!(!tracker.advisory_only(), "{mutating}");
+ assert!(tracker.apply_fixes_allowed(), "{mutating}");
+ }
+
+ for approval in [
+ "go",
+ "proceed",
+ "approved",
+ "ship it",
+ "go ahead",
+ "proceed with the plan",
+ "execute the plan",
+ ] {
+ let tracker = ObjectiveTracker::from_user_prompt(approval);
+ assert!(tracker.planned_execution(), "{approval}");
+ assert!(tracker.apply_fixes_allowed(), "{approval}");
+ assert!(!tracker.advisory_only(), "{approval}");
+ }
+
+ for neutral in [
+ "summarize the team's commitment risks",
+ "what cleanup is still pending?",
+ "the plan mode code is in main.rs",
+ "we should not go with option B",
+ "should I go ahead?",
+ "how should I go ahead with this?",
+ "execute the plan?",
+ ] {
+ let tracker = ObjectiveTracker::from_user_prompt(neutral);
+ assert!(!tracker.planned_execution(), "{neutral}");
+ assert!(!tracker.advisory_only(), "{neutral}");
+ }
+ }
+
#[test]
fn cleanup_and_commit_task_does_not_require_an_extra_file_edit() {
let tracker =
diff --git a/src/packs.rs b/src/packs.rs
index 2aaf2bd..4ba3227 100644
--- a/src/packs.rs
+++ b/src/packs.rs
@@ -472,9 +472,17 @@ pub(crate) fn render_pack_listing(root: &Path) -> String {
}
pub(crate) fn render_pack_listing_opts(root: &Path, verbose: bool) -> String {
+ render_pack_listing_opts_width(root, verbose, None)
+}
+
+pub(crate) fn render_pack_listing_opts_width(
+ root: &Path,
+ verbose: bool,
+ width: Option,
+) -> String {
render_pack_list(
&discover_packs(root),
- &list_render::ListOptions::detect(verbose),
+ &list_render::ListOptions::detect_with_width(verbose, width),
root,
)
}
@@ -487,7 +495,15 @@ pub(crate) fn render_pack_list(
) -> String {
use std::fmt::Write as _;
if packs.is_empty() {
- return "Packs none found\nsearch paths: .dext/shelves/*/packs, DEXT_SHELVES_DIR, ~/.dext/shelves/*/packs".to_string();
+ let mut out = list_render::render_header("Packs", 0, opts);
+ out.push_str(&list_render::render_section_header("Search paths", opts));
+ list_render::write_wrapped(
+ &mut out,
+ ".dext/shelves/*/packs, DEXT_SHELVES_DIR, ~/.dext/shelves/*/packs",
+ 2,
+ opts.effective_width(),
+ );
+ return out.trim_end().to_string();
}
let mut out = String::new();
let _ = write!(
@@ -512,10 +528,11 @@ pub(crate) fn render_pack_list(
out.push_str(&list_render::render_entry(&pack.name, desc, &meta, opts));
}
if packs.len() > PACK_LIST_LIMIT {
- let _ = writeln!(
- out,
- " … [{} more packs omitted]",
- packs.len() - PACK_LIST_LIMIT
+ list_render::write_wrapped(
+ &mut out,
+ &format!("… [{} more packs omitted]", packs.len() - PACK_LIST_LIMIT),
+ 2,
+ opts.effective_width(),
);
}
out.push_str(&list_render::render_footer(
diff --git a/src/privacy.rs b/src/privacy.rs
index a3807c6..77174e6 100644
--- a/src/privacy.rs
+++ b/src/privacy.rs
@@ -8,6 +8,42 @@ use serde_json::Value;
use crate::{byte_suffix_at_char_boundary, canonicalize_read_tool_path, provider, str_array};
+pub(crate) const RECALL_MEMORY_MAX_BYTES: usize = 4 * 1024;
+
+pub(crate) fn is_recall_memory_file(path: &Path) -> bool {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("recall.md"))
+}
+
+fn targets_recall_memory(root: &Path, input: &Value) -> bool {
+ let Some(raw_path) = input["path"].as_str() else {
+ return false;
+ };
+ let raw_path = Path::new(raw_path);
+ is_recall_memory_file(raw_path)
+ || crate::session::canonicalize_mutation_path(root, raw_path.to_string_lossy().as_ref())
+ .ok()
+ .is_some_and(|path| is_recall_memory_file(&path))
+}
+
+fn redact_json_strings(policy: &PrivacyPolicy, value: &mut Value) {
+ match value {
+ Value::String(text) => *text = policy.redact_text(text).text,
+ Value::Array(items) => {
+ for item in items {
+ redact_json_strings(policy, item);
+ }
+ }
+ Value::Object(fields) => {
+ for value in fields.values_mut() {
+ redact_json_strings(policy, value);
+ }
+ }
+ _ => {}
+ }
+}
+
pub(crate) fn text_is_potential_local_secret(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
@@ -317,6 +353,40 @@ impl PrivacyPolicy {
out
}
+ pub(crate) fn redact_recall_tool_input(
+ &self,
+ tool_name: &str,
+ input: &mut Value,
+ root: &Path,
+ ) -> bool {
+ if !matches!(tool_name, "write_file" | "edit_file" | "multi_edit")
+ || !targets_recall_memory(root, input)
+ {
+ return false;
+ }
+ match tool_name {
+ "write_file" => {
+ if let Some(content) = input.get_mut("content") {
+ redact_json_strings(self, content);
+ }
+ }
+ "edit_file" => {
+ for field in ["old_string", "new_string"] {
+ if let Some(value) = input.get_mut(field) {
+ redact_json_strings(self, value);
+ }
+ }
+ }
+ "multi_edit" => {
+ if let Some(edits) = input.get_mut("edits") {
+ redact_json_strings(self, edits);
+ }
+ }
+ _ => unreachable!(),
+ }
+ true
+ }
+
pub(crate) fn redact_text(&self, text: &str) -> PrivacyRedaction {
if !self.enabled || text.is_empty() {
return PrivacyRedaction {
diff --git a/src/provider.rs b/src/provider.rs
index 7933684..8449000 100644
--- a/src/provider.rs
+++ b/src/provider.rs
@@ -487,13 +487,23 @@ fn builtin_model_pricing(provider_id: &str, model: &str) -> Option
"glm" => Some(model_pricing(1.0, 5.0, 0.1, 1.25)),
"deepseek" if model.contains("reasoner") => Some(model_pricing(0.55, 2.19, 0.14, 0.55)),
"deepseek" if model.contains("chat") => Some(model_pricing(0.27, 1.1, 0.07, 0.27)),
- "anthropic" if model.contains("fable") => Some(model_pricing(
- 11.721718363700392,
- 58.60859181850196,
- 1.1721718363700393,
- 14.65214795462549,
- )),
+ "anthropic" if model.contains("fable") => Some(model_pricing(10.0, 50.0, 1.0, 12.5)),
+ // Opus 4.5 through Opus 5 share one published rate; Opus 4.1-and-earlier
+ // retain legacy pricing via the plain "opus" arm below.
+ "anthropic"
+ if [
+ "opus-5", "opus5", "opus-4-5", "opus-4.5", "opus-4-6", "opus-4.6", "opus-4-7",
+ "opus-4.7", "opus-4-8", "opus-4.8",
+ ]
+ .iter()
+ .any(|generation| model.contains(generation)) =>
+ {
+ Some(model_pricing(5.0, 25.0, 0.5, 6.25))
+ }
"anthropic" if model.contains("opus") => Some(model_pricing(15.0, 75.0, 1.5, 18.75)),
+ "anthropic" if model.contains("sonnet-5") || model.contains("sonnet5") => {
+ Some(model_pricing(2.0, 10.0, 0.2, 2.5))
+ }
"anthropic" if model.contains("sonnet") => Some(model_pricing(3.0, 15.0, 0.3, 3.75)),
"anthropic" if model.contains("haiku-4-5") || model.contains("haiku-4.5") => {
Some(model_pricing(1.0, 5.0, 0.1, 1.25))
@@ -747,6 +757,8 @@ pub(crate) fn built_in_provider_profiles() -> Vec {
default_model: "claude-sonnet-4-6".to_string(),
models: vec![
"claude-sonnet-4-6".to_string(),
+ "claude-sonnet-5".to_string(),
+ "claude-opus-5".to_string(),
"claude-opus-4-8".to_string(),
"claude-opus-4-7".to_string(),
"claude-opus-4-6".to_string(),
@@ -772,7 +784,11 @@ pub(crate) fn built_in_provider_profiles() -> Vec {
}),
notes: Some("Claude Pro/Max subscription OAuth is the default /login flow. ANTHROPIC_API_KEY continues to use standard API-key billing.".to_string()),
context_window: Some(200_000),
- model_context_windows: HashMap::new(),
+ model_context_windows: HashMap::from([
+ ("claude-sonnet-5".to_string(), 1_000_000),
+ ("claude-opus-5".to_string(), 1_000_000),
+ ("claude-fable-5".to_string(), 1_000_000),
+ ]),
model_effort_levels: HashMap::new(),
request_contract: Some(RequestContract::AnthropicMessages),
model_aliases: HashMap::new(),
diff --git a/src/shelves.rs b/src/shelves.rs
index 88af82e..2e71517 100644
--- a/src/shelves.rs
+++ b/src/shelves.rs
@@ -779,11 +779,29 @@ fn push_shelf_manifest_dir(
}
pub(crate) fn render_registry_listing(registry: &ShelfRegistry) -> String {
+ render_registry_listing_width(registry, None)
+}
+
+pub(crate) fn render_registry_listing_width(
+ registry: &ShelfRegistry,
+ width: Option,
+) -> String {
use std::fmt::Write as _;
- let opts = crate::list_render::ListOptions::detect(false);
+ let opts = crate::list_render::ListOptions::detect_with_width(false, width);
if registry.is_empty() {
- return "Shelves none found\nsearch paths: .dext/shelves/*/shelf.json, DEXT_SHELVES_DIR, ~/.dext/shelves/*/shelf.json".to_string();
+ let mut out = crate::list_render::render_header("Shelves", 0, &opts);
+ out.push_str(&crate::list_render::render_section_header(
+ "Search paths",
+ &opts,
+ ));
+ crate::list_render::write_wrapped(
+ &mut out,
+ ".dext/shelves/*/shelf.json, DEXT_SHELVES_DIR, ~/.dext/shelves/*/shelf.json",
+ 2,
+ opts.effective_width(),
+ );
+ return out.trim_end().to_string();
}
let manifests = registry.manifests();
@@ -813,16 +831,20 @@ pub(crate) fn render_registry_listing(registry: &ShelfRegistry) -> String {
let resolved = registry.resolve();
if !resolved.is_empty() {
out.push('\n');
- let _ = writeln!(
- out,
- "{}",
- crate::list_render::bold("Resolved abilities", opts.color)
- );
+ out.push_str(&crate::list_render::render_section_header(
+ "Resolved abilities",
+ &opts,
+ ));
for ability in resolved.iter().take(50) {
out.push_str(&format_resolved_ability_styled(ability, &opts));
}
if resolved.len() > 50 {
- let _ = writeln!(out, " … [{} more abilities omitted]", resolved.len() - 50);
+ crate::list_render::write_wrapped(
+ &mut out,
+ &format!("… [{} more abilities omitted]", resolved.len() - 50),
+ 2,
+ opts.effective_width(),
+ );
}
}
out.push_str(&crate::list_render::render_footer(
diff --git a/src/tool_round.rs b/src/tool_round.rs
index f68aca7..76e4c0b 100644
--- a/src/tool_round.rs
+++ b/src/tool_round.rs
@@ -14,6 +14,7 @@ pub(crate) struct PlannedCall {
pub(crate) event_call_id: String,
pub(crate) name: String,
pub(crate) input: Value,
+ pub(crate) journal_input: Value,
pub(crate) input_str: String,
pub(crate) summary: String,
pub(crate) hosts: Vec,
@@ -98,10 +99,13 @@ impl Agent {
let mut plans: Vec = Vec::new();
let mut journal_terminal_errors: Vec = Vec::new();
for (ordinal, (id, name, input)) in tool_calls.into_iter().enumerate() {
+ let mut journal_input = input.clone();
+ self.privacy
+ .redact_recall_tool_input(&name, &mut journal_input, &self.sandbox_root);
let event_call_id = normalize_tool_call_id(&id, 0, ordinal);
- let input_str = input.to_string();
+ let input_str = journal_input.to_string();
let summary = summarize_call(&name, &input);
- let call_sig = format!("{name}\n{input_str}");
+ let call_sig = format!("{name}\n{input}");
let hosts = tool_policy::hosts_for_tool_call(&name, &input);
let bulk_network = tool_policy::looks_like_bulk_network_call(&name, &input);
let cache_key = orchestrator::network_cache_key(&name, &input);
@@ -206,7 +210,24 @@ impl Agent {
if plan.is_none() {
match mutation_preview::prepare_tool_mutation(&name, &input, &self.sandbox_root) {
- Ok(prepared) => prepared_mutation = prepared,
+ Ok(mut prepared) => {
+ if let Some(mutation) = prepared.as_mut()
+ && privacy::is_recall_memory_file(mutation.path())
+ {
+ mutation.rewrite_after_text(|text| self.privacy.redact_text(text).text);
+ if mutation.after_text().len() > privacy::RECALL_MEMORY_MAX_BYTES {
+ plan = Some(Plan::Immediate {
+ content: format!(
+ "recall.md would exceed the {}-byte working-memory cap; prune lower-value entries in the same edit",
+ privacy::RECALL_MEMORY_MAX_BYTES
+ ),
+ is_error: Some(true),
+ });
+ prepared = None;
+ }
+ }
+ prepared_mutation = prepared;
+ }
Err(message) => {
plan = Some(Plan::Immediate {
content: message,
@@ -334,6 +355,7 @@ impl Agent {
event_call_id,
name,
input,
+ journal_input,
input_str,
summary,
hosts,
@@ -576,7 +598,7 @@ impl Agent {
call_id: &plans[idx].tool_use_id,
tool_name: &n,
summary: &tool_journal_summary(&n, &inp),
- input: &inp,
+ input: &plans[idx].journal_input,
},
) {
Ok(record_id) => plans[idx].journal_record_id = Some(record_id),
@@ -723,6 +745,7 @@ impl Agent {
event_call_id,
name,
input,
+ journal_input: _journal_input,
input_str,
summary,
hosts,
diff --git a/src/tui.rs b/src/tui.rs
index 4f48be9..735678e 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -4,7 +4,10 @@ use crossterm::event::{
KeyModifiers, KeyboardEnhancementFlags, MouseEvent, MouseEventKind,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
-use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode};
+use crossterm::terminal::{
+ BeginSynchronizedUpdate, Clear as CrosstermClear, ClearType as CrosstermClearType,
+ EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode,
+};
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
@@ -149,8 +152,6 @@ const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '
const LIVE_BACKEND_RING_CAP: usize = 256_000;
const LIVE_BACKEND_MAX_TOOLS: usize = 8;
const LIVE_OUTPUT_DRAIN_BATCH: usize = 32;
-const RESIZE_REPLAY_QUIET: Duration = Duration::from_millis(120);
-const RESIZE_REPLAY_MAX_LATENCY: Duration = Duration::from_millis(360);
const WELCOME_RIGHT_MIN_WIDTH: usize = 80;
const WELCOME_LABEL_GUTTER: usize = 14;
const TIPS: &[&str] = &[
@@ -161,7 +162,6 @@ const TIPS: &[&str] = &[
"Ctrl+O expands or collapses the latest tool output.",
"Ctrl+B opens captured bash output after a command starts.",
"Shift+Enter or Alt+Enter inserts a newline.",
- "Use /plan to run the read-only planner.",
];
#[derive(Clone, PartialEq, Eq, Hash)]
@@ -213,6 +213,8 @@ impl PermissionTier {
struct PendingPermission {
tool: String,
audit_label: String,
+ command: String,
+ risk: crate::tool_policy::CommandRisk,
tier: PermissionTier,
responder: std::sync::mpsc::SyncSender,
}
@@ -261,12 +263,6 @@ enum Line_ {
density_rank: usize,
expanded: bool,
},
- PermissionPrompt {
- tool: String,
- command: String,
- tier: PermissionTier,
- risk: crate::tool_policy::CommandRisk,
- },
PermissionResult {
command: String,
approved: bool,
@@ -277,6 +273,7 @@ enum Line_ {
message: String,
},
Info(String),
+ Slash(String),
RuntimeView {
pack: String,
title: String,
@@ -311,7 +308,7 @@ enum ToTui {
}
enum FromTui {
- Submit(String),
+ Submit { text: String, pane_width: u16 },
LoginInput(String),
LoginCancel,
CycleEffort(i8),
@@ -593,11 +590,6 @@ static SLASH_COMMANDS: &[SlashCmd] = &[
args: "[list|analyze|brief|grep|failures|verify-log|decisions]",
help: "alias for /sessions",
},
- SlashCmd {
- name: "/plan",
- args: "",
- help: "run read-only planner",
- },
SlashCmd {
name: "/pack",
args: "[list|inspect|run|create]",
@@ -680,10 +672,12 @@ fn provider_arg_completions(cmd: &str, arg_part: &str) -> Vec {
text: format!("/{cmd} {}", profile.id),
hint: hint.clone(),
});
- out.push(SlashCompletion {
- text: format!("/{cmd} {}", index),
- hint,
- });
+ if cmd != "login" {
+ out.push(SlashCompletion {
+ text: format!("/{cmd} {}", index),
+ hint,
+ });
+ }
}
out
@@ -895,6 +889,7 @@ struct TranscriptLayoutState {
struct TuiState {
pending_insert: Vec,
+ prepared_insert_retry: Vec,
transcript: Vec,
render_cache: HashMap,
render_cache_weight: usize,
@@ -1024,6 +1019,7 @@ impl TuiState {
) -> Self {
Self {
pending_insert: Vec::new(),
+ prepared_insert_retry: Vec::new(),
transcript: Vec::new(),
render_cache: HashMap::new(),
render_cache_weight: 0,
@@ -1494,6 +1490,7 @@ impl TuiState {
fn last_line_needs_history_spacing(&self) -> bool {
self.pending_insert
.last()
+ .or_else(|| self.prepared_insert_retry.last())
.or_else(|| self.transcript.last())
.is_some_and(|line| {
Self::line_needs_history_spacing(line)
@@ -1789,6 +1786,24 @@ impl TuiState {
pseudo_tool_protocol_text_for_context(text, self.context_mode)
}
+ fn apply_slash_output(&mut self, output: String, structured: bool) {
+ self.push_debug_event(format!("slash/system · {}", sanitize_display_text(&output)));
+ self.compacting = false;
+ self.compacting_resume_busy = false;
+ self.streaming_text.clear();
+ self.streaming_thinking.clear();
+ self.stream_started_at = None;
+ self.stream_chars = 0;
+ self.live_tools.clear();
+ self.set_agent_busy(false);
+ self.status = phase_status_text(&output).unwrap_or_else(|| "ready".into());
+ if structured {
+ self.queue(Line_::Slash(output));
+ } else {
+ self.queue(Line_::Info(output));
+ }
+ }
+
fn apply_event(&mut self, ev: AgentEvent) {
match ev {
AgentEvent::TurnStart => {
@@ -2208,19 +2223,8 @@ impl TuiState {
self.push_debug_event(format!("error · {}", sanitize_display_text(&s)));
self.queue(Line_::Error(s));
}
- AgentEvent::Slash(s) => {
- self.push_debug_event(format!("slash/system · {}", sanitize_display_text(&s)));
- self.compacting = false;
- self.compacting_resume_busy = false;
- self.streaming_text.clear();
- self.streaming_thinking.clear();
- self.stream_started_at = None;
- self.stream_chars = 0;
- self.live_tools.clear();
- self.set_agent_busy(false);
- self.status = phase_status_text(&s).unwrap_or_else(|| "ready".into());
- self.queue(Line_::Info(s));
- }
+ AgentEvent::Slash(s) => self.apply_slash_output(s, false),
+ AgentEvent::StructuredSlash(s) => self.apply_slash_output(s, true),
AgentEvent::TurnEnd { failed, .. } => {
self.push_debug_event(if failed {
"turn end · failed"
@@ -3221,21 +3225,14 @@ fn permission_prompt_text(
let tier = PermissionTier::from_risk(risk);
let accent = tier.accent();
let prefix_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
- let body_width = width.saturating_sub(2).max(1) as usize;
let mut lines: Vec> = Vec::new();
let mut push_line = |body: String, style: Style| {
- let first_prefix = "▌ ".to_string();
- let cont_prefix = "▌ ".to_string();
- let text_width = body_width.saturating_sub(first_prefix.len()).max(1);
+ let prefix = if width >= 3 { "▌ " } else { "" };
+ let text_width = usize::from(width).saturating_sub(text_width(prefix)).max(1);
let wrapped = wrap_plain_visual(&body, text_width);
- for (idx, row) in wrapped.into_iter().enumerate() {
- let prefix = if idx == 0 {
- &first_prefix
- } else {
- &cont_prefix
- };
+ for row in wrapped {
lines.push(Line::from(vec![
- Span::styled(prefix.clone(), prefix_style),
+ Span::styled(prefix.to_string(), prefix_style),
Span::styled(row, style),
]));
}
@@ -3252,35 +3249,6 @@ fn permission_prompt_text(
Text::from(lines)
}
-fn dim_text(text: &mut Text<'static>) {
- for line in &mut text.lines {
- for span in &mut line.spans {
- span.style = span.style.add_modifier(Modifier::DIM);
- }
- }
-}
-
-fn transcript_item_should_dim(item: &Line_, state: &TuiState) -> bool {
- (state.pending_perm.is_some() && !matches!(item, Line_::PermissionPrompt { .. }))
- || state.pending_local_auth.is_some()
-}
-
-fn replace_last_permission_entry(items: &mut Vec, replacement: Line_) -> bool {
- if let Some(idx) = items
- .iter()
- .rposition(|item| matches!(item, Line_::PermissionPrompt { .. }))
- {
- let resolved = matches!(replacement, Line_::PermissionResult { .. });
- items[idx] = replacement;
- if resolved && !matches!(items.get(idx + 1), Some(Line_::Blank)) {
- items.insert(idx + 1, Line_::Blank);
- }
- true
- } else {
- false
- }
-}
-
fn extract_path_from_summary(summary: &str) -> Option {
let after_colon = summary.split_once(": ").map(|(_, r)| r).unwrap_or(summary);
let path_part = after_colon
@@ -3603,12 +3571,14 @@ fn merge_consecutive_tools(items: Vec) -> Vec {
summary,
ok,
content,
+ group_count: new_count,
group_chunks: new_chunks,
group_lines: new_lines,
duration_secs: new_duration,
denied: new_denied,
dim: new_dim,
- ..
+ density_rank: new_density_rank,
+ expanded: new_expanded,
} = item
else {
unreachable!()
@@ -3624,14 +3594,14 @@ fn merge_consecutive_tools(items: Vec) -> Vec {
summary,
ok,
content,
- group_count: 1,
+ group_count: new_count,
group_lines: new_lines,
group_chunks: new_chunks,
duration_secs: new_duration,
denied: new_denied,
- dim: false,
- density_rank: 1,
- expanded: false,
+ dim: new_dim,
+ density_rank: new_density_rank,
+ expanded: new_expanded,
});
continue;
}
@@ -3639,8 +3609,8 @@ fn merge_consecutive_tools(items: Vec) -> Vec {
*l_duration = l_duration.saturating_add(new_duration);
*l_denied = *l_denied || new_denied;
*l_dim = *l_dim || new_dim;
- *group_count += 1;
- *group_lines += new_lines;
+ *group_count = group_count.saturating_add(new_count);
+ *group_lines = group_lines.saturating_add(new_lines);
group_chunks.extend(new_chunks);
*ls = grouped_tool_summary(ln, *lok, *group_count, *group_lines, group_chunks);
// Keep the first chunk as the preview content; expansion shows all.
@@ -3788,24 +3758,46 @@ fn push_diff_preview(
preview_lines.len().saturating_sub(take)
}
+fn ansi_escape_end(bytes: &[u8], start: usize) -> usize {
+ let mut i = start.saturating_add(1);
+ if i >= bytes.len() {
+ return i;
+ }
+ match bytes[i] {
+ b'[' => {
+ i += 1;
+ while i < bytes.len() {
+ let byte = bytes[i];
+ i += 1;
+ if (0x40..=0x7e).contains(&byte) {
+ break;
+ }
+ }
+ }
+ b']' | b'P' | b'^' | b'_' => {
+ i += 1;
+ while i < bytes.len() {
+ if bytes[i] == 0x07 {
+ return i + 1;
+ }
+ if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\') {
+ return i + 2;
+ }
+ i += 1;
+ }
+ }
+ _ => {}
+ }
+ i.min(bytes.len())
+}
+
fn strip_ansi_escapes(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == 0x1b {
- i += 1;
- if i < bytes.len() && bytes[i] == b'[' {
- i += 1;
- while i < bytes.len() {
- let b = bytes[i];
- i += 1;
- if (0x40..=0x7e).contains(&b) {
- break;
- }
- }
- continue;
- }
+ i = ansi_escape_end(bytes, i);
continue;
}
if let Some(ch) = text[i..].chars().next() {
@@ -3832,7 +3824,11 @@ fn ansi_to_spans(text: &str) -> Vec> {
macro_rules! flush {
() => {
if !buf.is_empty() {
- spans.push(Span::styled(std::mem::take(&mut buf), style));
+ let sanitized = sanitize_display_text(&buf);
+ if !sanitized.is_empty() {
+ spans.push(Span::styled(sanitized, style));
+ }
+ buf.clear();
}
};
}
@@ -3870,7 +3866,7 @@ fn ansi_to_spans(text: &str) -> Vec> {
i += 1;
}
} else {
- i += 1;
+ i = ansi_escape_end(bytes, i);
}
} else {
let ch = text[i..].chars().next().unwrap_or('\0');
@@ -3880,7 +3876,7 @@ fn ansi_to_spans(text: &str) -> Vec> {
}
flush!();
if spans.is_empty() {
- spans.push(Span::raw(strip_ansi_escapes(text)));
+ spans.push(Span::raw(sanitize_display_text(&strip_ansi_escapes(text))));
}
spans
}
@@ -3890,20 +3886,42 @@ fn has_ansi(text: &str) -> bool {
text.as_bytes().windows(2).any(|w| w == b"\x1b[")
}
-fn sanitize_display_text(text: &str) -> String {
- let text = strip_ansi_escapes(text);
+fn is_bidi_format_control(ch: char) -> bool {
+ matches!(
+ ch,
+ '\u{061c}'
+ | '\u{200e}'
+ | '\u{200f}'
+ | '\u{202a}'..='\u{202e}'
+ | '\u{2066}'..='\u{2069}'
+ )
+}
+
+fn normalize_display_line_breaks(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
- if chars.peek() == Some(&'\n') {
- continue;
+ if chars.peek() != Some(&'\n') {
+ out.push('\n');
}
- out.push('\n');
}
+ '\n' | '\u{2028}' | '\u{2029}' => out.push('\n'),
+ _ => out.push(ch),
+ }
+ }
+ out
+}
+
+fn sanitize_display_text(text: &str) -> String {
+ let text = strip_ansi_escapes(text);
+ let text = normalize_display_line_breaks(&text);
+ let mut out = String::with_capacity(text.len());
+ for ch in text.chars() {
+ match ch {
'\n' | '\t' => out.push(ch),
- _ if ch.is_control() => {}
+ _ if ch.is_control() || is_bidi_format_control(ch) => {}
_ => out.push(ch),
}
}
@@ -6242,14 +6260,6 @@ fn line_to_text(item: &Line_, width: u16) -> Text<'static> {
]));
}
}
- Line_::PermissionPrompt {
- tool,
- command,
- risk,
- ..
- } => {
- return permission_prompt_text(tool, command, *risk, width);
- }
Line_::PermissionResult {
command,
approved,
@@ -6283,6 +6293,12 @@ fn line_to_text(item: &Line_, width: u16) -> Text<'static> {
width,
);
}
+ Line_::Slash(s) => {
+ let normalized = normalize_display_line_breaks(s);
+ for seg in normalized.split('\n') {
+ lines.push(Line::from(ansi_to_spans(seg)));
+ }
+ }
Line_::Info(s) => {
let trimmed = s.trim_start();
if let Some(rest) = trimmed.strip_prefix("[sub]") {
@@ -6342,7 +6358,8 @@ fn line_to_text(item: &Line_, width: u16) -> Text<'static> {
} else if has_ansi(s) {
// List output (packs/sessions/shelves) styled with ANSI codes:
// render as styled spans without the dim-italic bullet treatment.
- for seg in s.split('\n') {
+ let normalized = normalize_display_line_breaks(s);
+ for seg in normalized.split('\n') {
lines.push(Line::from(ansi_to_spans(seg)));
}
} else {
@@ -6631,18 +6648,6 @@ fn cached_transcript_render(
width: u16,
) -> (Text<'static>, u16) {
let render_width = transcript_render_width(width);
- if let Line_::PermissionPrompt {
- tool,
- command,
- risk,
- ..
- } = item
- {
- let text = permission_prompt_text(tool, command, *risk, render_width);
- let height = text_visual_height(&text, render_width);
- return (text, height);
- }
-
let key = line_cache_key(item);
if state.render_cache.len() >= RENDER_CACHE_MAX_ENTRIES
&& !state.render_cache.contains_key(&key)
@@ -6654,11 +6659,7 @@ fn cached_transcript_render(
.get(&key)
.and_then(|entry| entry.renders.get(&render_width))
{
- let mut text = cached.text.clone();
- if transcript_item_should_dim(item, state) {
- dim_text(&mut text);
- }
- return (text, cached.height);
+ return (cached.text.clone(), cached.height);
}
if let Some(entry) = state.render_cache.get_mut(&key)
@@ -6700,10 +6701,6 @@ fn cached_transcript_render(
state.render_cache_weight = state.render_cache_weight.saturating_add(weight);
}
- let mut text = text;
- if transcript_item_should_dim(item, state) {
- dim_text(&mut text);
- }
(text, height)
}
@@ -6748,61 +6745,6 @@ fn next_transcript_tint(item: &Line_, tool_tint_parity: &mut bool) -> Option
,
- last_change: Instant,
- last_replay: Instant,
- burst_active: bool,
-}
-
-impl TranscriptResizeReplay {
- fn new(now: Instant) -> Self {
- Self {
- observed_width: None,
- last_change: now,
- last_replay: now,
- burst_active: false,
- }
- }
-
- fn should_replay(
- &mut self,
- width: u16,
- rendered_width: u16,
- has_transcript: bool,
- now: Instant,
- ) -> bool {
- if !has_transcript {
- self.observed_width = Some(width);
- self.last_change = now;
- self.last_replay = now;
- self.burst_active = false;
- return true;
- }
-
- if self.observed_width != Some(width) {
- let leading_edge = !self.burst_active;
- self.observed_width = Some(width);
- self.last_change = now;
- self.burst_active = true;
- if leading_edge || now.duration_since(self.last_replay) >= RESIZE_REPLAY_MAX_LATENCY {
- self.last_replay = now;
- return true;
- }
- return false;
- }
-
- if self.burst_active && now.duration_since(self.last_change) >= RESIZE_REPLAY_QUIET {
- self.burst_active = false;
- if rendered_width != width {
- self.last_replay = now;
- return true;
- }
- }
- false
- }
-}
-
struct PreparedTranscriptRender {
text: Arc>,
line_start: usize,
@@ -6812,6 +6754,39 @@ struct PreparedTranscriptRender {
tint_bg: Option,
}
+fn render_prepared_transcript(
+ buf: &mut ratatui::buffer::Buffer,
+ items: Vec,
+ render_width: u16,
+ top_padding: u16,
+) {
+ let mut y = buf.area.y.saturating_add(top_padding.min(buf.area.height));
+ for item in items {
+ let area = Rect {
+ y,
+ width: render_width.min(buf.area.width),
+ height: item.height,
+ ..buf.area
+ };
+ let text = borrowed_text_lines(item.text.as_ref(), item.line_start, item.line_end);
+ let para = Paragraph::new(text)
+ .wrap(Wrap { trim: false })
+ .scroll((item.scroll, 0));
+ Widget::render(para, area, buf);
+ if let Some(bg) = item.tint_bg {
+ for row in area.top()..area.bottom() {
+ for x in area.left()..area.right() {
+ let cell = &mut buf[(x, row)];
+ if cell.bg == Color::Reset {
+ cell.bg = bg;
+ }
+ }
+ }
+ }
+ y = y.saturating_add(item.height);
+ }
+}
+
fn insert_prepared_transcript(
terminal: &mut Terminal,
items: Vec,
@@ -6819,31 +6794,7 @@ fn insert_prepared_transcript(
height: u16,
) -> Result<(), B::Error> {
terminal.insert_before(height, move |buf| {
- let mut y = buf.area.y;
- for item in items {
- let area = Rect {
- y,
- width: render_width.min(buf.area.width),
- height: item.height,
- ..buf.area
- };
- let text = borrowed_text_lines(item.text.as_ref(), item.line_start, item.line_end);
- let para = Paragraph::new(text)
- .wrap(Wrap { trim: false })
- .scroll((item.scroll, 0));
- Widget::render(para, area, buf);
- if let Some(bg) = item.tint_bg {
- for row in area.top()..area.bottom() {
- for x in area.left()..area.right() {
- let cell = &mut buf[(x, row)];
- if cell.bg == Color::Reset {
- cell.bg = bg;
- }
- }
- }
- }
- y = y.saturating_add(item.height);
- }
+ render_prepared_transcript(buf, items, render_width, 0);
})?;
Ok(())
}
@@ -6866,6 +6817,7 @@ fn insert_transcript_items(
state: &mut TuiState,
items: &[Line_],
width: u16,
+ chunk_rows: u16,
tool_tint_parity: &mut bool,
) -> Result<(), B::Error> {
if items.is_empty() {
@@ -6873,7 +6825,6 @@ fn insert_transcript_items(
}
let render_width = transcript_render_width(width);
- let chunk_rows = terminal.size()?.height.max(1);
let mut chunk = Vec::new();
let mut chunk_height = 0u16;
@@ -6950,8 +6901,39 @@ fn insert_transcript_items(
}
}
- flush_prepared_transcript(terminal, &mut chunk, render_width, &mut chunk_height)?;
- Ok(())
+ flush_prepared_transcript(terminal, &mut chunk, render_width, &mut chunk_height)
+}
+
+#[cfg(test)]
+fn rebuild_transcript_from_origin(
+ terminal: &mut Terminal,
+ state: &mut TuiState,
+ width: u16,
+) -> Result<(), B::Error> {
+ terminal.reset_inline_viewport()?;
+ rebuild_transcript(terminal, state, width)
+}
+
+fn purge_and_rebuild_transcript(
+ terminal: &mut Terminal>,
+ state: &mut TuiState,
+ width: u16,
+) -> io::Result<()> {
+ crossterm::execute!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
+ let rebuild = (|| {
+ if renderable_transcript_chunk_rows(terminal, state, width)?.is_none() {
+ state.transcript_needs_rebuild = true;
+ return Ok(());
+ }
+ terminal.reset_inline_viewport()?;
+ crossterm::execute!(
+ terminal.backend_mut(),
+ CrosstermClear(CrosstermClearType::Purge)
+ )?;
+ rebuild_transcript(terminal, state, width)
+ })();
+ let end = crossterm::execute!(terminal.backend_mut(), EndSynchronizedUpdate);
+ rebuild.and(end)
}
fn rebuild_transcript(
@@ -6959,23 +6941,38 @@ fn rebuild_transcript(
state: &mut TuiState,
width: u16,
) -> Result<(), B::Error> {
- terminal.clear()?;
- // mem::take instead of clone: rebuilds fire on resize/expand and the transcript can be
- // large. Nothing in the insert path reads state.transcript, so loaning it out is safe.
+ let Some(chunk_rows) = renderable_transcript_chunk_rows(terminal, state, width)? else {
+ state.transcript_needs_rebuild = true;
+ return Ok(());
+ };
+
+ // mem::take avoids cloning a potentially large transcript during explicit
+ // expand/collapse and error-recovery rebuilds. Nothing in the insert path
+ // reads state.transcript, so loaning it out is safe.
let items = std::mem::take(&mut state.transcript);
sync_last_expandable(state, &items);
let mut tool_tint_parity = false;
- let rebuild_result =
- insert_transcript_items(terminal, state, &items, width, &mut tool_tint_parity);
+ let rebuild_result = insert_transcript_items(
+ terminal,
+ state,
+ &items,
+ width,
+ chunk_rows,
+ &mut tool_tint_parity,
+ );
state.transcript = items;
- if let Err(err) = rebuild_result {
- state.transcript_needs_rebuild = true;
- return Err(err);
+ match rebuild_result {
+ Ok(()) => {
+ state.tool_tint_parity = tool_tint_parity;
+ state.transcript_rendered_width = width;
+ state.transcript_needs_rebuild = false;
+ Ok(())
+ }
+ Err(err) => {
+ state.transcript_needs_rebuild = true;
+ Err(err)
+ }
}
- state.tool_tint_parity = tool_tint_parity;
- state.transcript_rendered_width = width;
- state.transcript_needs_rebuild = false;
- Ok(())
}
fn transcript_pane_width(area_width: u16, area_height: u16, state: &TuiState) -> u16 {
@@ -6984,6 +6981,24 @@ fn transcript_pane_width(area_width: u16, area_height: u16, state: &TuiState) ->
.width
}
+fn renderable_transcript_chunk_rows(
+ terminal: &mut Terminal,
+ state: &TuiState,
+ expected_width: u16,
+) -> Result