From b0d4814298e0ee08fc83a47afedb9344580e544d Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:25:36 -0500 Subject: [PATCH 01/15] Add Claude Sonnet 5 and Opus 5 with corrected Anthropic pricing Wire publicly documented claude-sonnet-5 and claude-opus-5 into the built-in Anthropic catalog with adaptive thinking, per-model xhigh/max effort routing from the official effort doc, and 1M-token context windows for Sonnet 5, Opus 5, and Fable 5. Correct pricing to the published rates: Opus 4.5-4.8 and Opus 5 at $5/$25 (previously the legacy $15/$75 applied to all Opus), Sonnet 5 at $2/$10, and Fable 5 at the published $10/$50 replacing the inferred console-derived rate. Anthropic bills the full 1M window at standard per-token rates, so no long-context tier is modeled. Also lands the adaptive-thinking cleanup: unreleased Mythos matchers removed and the always-None thinking.display member dropped from the request schema. Tests pin the effort downgrade matrix (including Opus/Sonnet 4.6 max without xhigh), generation pricing, catalog/context-window metadata, and Sonnet 5 OAuth/API-key adaptive request shapes. Docs updated in the same change. --- docs/ARCHITECTURE.md | 2 +- docs/RISK_REGISTER.md | 2 +- docs/TUI.md | 1 + docs/USAGE.md | 2 + docs/index.html | 4 +- src/main.rs | 33 ++++--- src/main_tests.rs | 207 +++++++++++++++++++++++++++++++++++------- src/provider.rs | 30 ++++-- src/usage.rs | 18 ++-- 9 files changed, 235 insertions(+), 64 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83e99f4..37b0cee 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` 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..dafe6d9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -13,6 +13,7 @@ TUI and dependency changes must preserve these behaviors: - 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`. - 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. - The backend viewer remains the only alternate-screen surface. diff --git a/docs/USAGE.md b/docs/USAGE.md index a38c99d..8aa0cc1 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 diff --git a/docs/index.html b/docs/index.html index f699ba3..2cf9631 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1392,8 +1392,8 @@

OpenAI API

Anthropic

diff --git a/src/main.rs b/src/main.rs index d3f04dd..193c613 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2557,14 +2557,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 +2574,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 +2608,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 +2712,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)] @@ -16049,7 +16061,6 @@ impl Agent { Some(AnthropicThinking { kind: if kimi_adaptive { "adaptive" } else { "enabled" }, budget_tokens: None, - display: None, }), Some(AnthropicOutputConfig { effort }), ) @@ -16058,7 +16069,6 @@ impl Agent { let thinking = effort.as_ref().map(|_| AnthropicThinking { kind: "adaptive", budget_tokens: None, - display: None, }); ( thinking, @@ -16073,7 +16083,6 @@ impl Agent { .map(|budget_tokens| AnthropicThinking { kind: "enabled", budget_tokens: Some(budget_tokens), - display: None, }), None, ) diff --git a/src/main_tests.rs b/src/main_tests.rs index c9016a9..2d016e8 100644 --- a/src/main_tests.rs +++ b/src/main_tests.rs @@ -17853,6 +17853,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 +19239,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 +19256,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 +19317,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 +22248,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 +22260,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 +22286,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 +22296,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 +23616,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 +23659,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 +24925,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 +24942,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 +24953,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:?}" ); 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/usage.rs b/src/usage.rs index b223b70..26f99b2 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -422,15 +422,19 @@ pub(crate) fn anthropic_pricing(model: &str) -> Option { return Some(UsagePricing::default()); } if model.contains("fable") { - // Inferred from Anthropic Console billing for claude-fable-5 until public rates are listed. - Some(UsagePricing::new( - 11.721718363700392, - 58.60859181850196, - 1.1721718363700393, - 14.65214795462549, - )) + Some(UsagePricing::new(10.0, 50.0, 1.0, 12.5)) + } else 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(UsagePricing::new(5.0, 25.0, 0.5, 6.25)) } else if model.contains("opus") { Some(UsagePricing::new(15.0, 75.0, 1.5, 18.75)) + } else if model.contains("sonnet-5") || model.contains("sonnet5") { + Some(UsagePricing::new(2.0, 10.0, 0.2, 2.5)) } else if model.contains("sonnet") { Some(UsagePricing::new(3.0, 15.0, 0.3, 3.75)) } else if model.contains("haiku-4-5") || model.contains("haiku-4.5") { From c98e98fde02b305c16c25dbc3c4d922c1ee799ff Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:45:40 -0500 Subject: [PATCH 02/15] Stop duplicating inline history on resize replay and approvals Width-change replay now repaints the visible transcript tail in place via a new vendored Terminal::overwrite_before (absolute writes above the inline viewport, no scrolling), so settled resizes no longer append a full re-wrapped copy of the transcript to terminal scrollback. Transcripts shorter than one screen keep the bounded insert replay; explicit rebuilds (Ctrl+O expansion) still re-emit intentionally. Approval prompts now render inside the viewport instead of scrollback, and decisions append only the compact result line. This removes both full-history re-emits per approval (dim spotlight + prompt-to-result swap) along with the Line_::PermissionPrompt transcript variant and the transcript dim machinery. --- docs/TUI.md | 6 +- docs/index.html | 4 +- src/tui.rs | 446 +++++++++++++++------ vendor/ratatui-core/DEXT_PATCH.md | 2 + vendor/ratatui-core/src/terminal/inline.rs | 96 +++++ 5 files changed, 432 insertions(+), 122 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index dafe6d9..aea9ad9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -15,7 +15,8 @@ TUI and dependency changes must preserve these behaviors: - 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 is cohesive: no item-by-item reconstruction, whole-screen flash, cursor-query stall, or cursor-query timeout. Width-change replay for transcripts at least one screen tall repaints the visible tail in place through the vendored `Terminal::overwrite_before` and must not append a duplicate history copy to terminal scrollback; shorter transcripts keep the bounded insert replay. +- 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. - 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. @@ -53,11 +54,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::overwrite_before` repaints rows directly above the inline viewport in place with absolute writes, so width-change replay does not scroll a duplicate transcript copy into terminal scrollback. 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. diff --git a/docs/index.html b/docs/index.html index 2cf9631..3a5230a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -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

@@ -859,7 +859,7 @@

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 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, and to add Terminal::overwrite_before, which repaints rows above the inline viewport in place. Width-change replay uses that overwrite for transcripts at least one screen tall, so a resize repaints the visible tail at the new width without appending a duplicate history copy to terminal scrollback; shorter transcripts keep the bounded insert replay, and rows already beyond the screen keep the terminal's native rewrap.

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.

diff --git a/src/tui.rs b/src/tui.rs index 4f48be9..aaf8451 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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, @@ -3252,35 +3248,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 @@ -6242,14 +6209,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, @@ -6631,18 +6590,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 +6601,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 +6643,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) } @@ -6812,6 +6751,38 @@ struct PreparedTranscriptRender { tint_bg: Option, } +fn render_prepared_transcript( + buf: &mut ratatui::buffer::Buffer, + items: Vec, + render_width: u16, +) { + 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); + } +} + fn insert_prepared_transcript( terminal: &mut Terminal, items: Vec, @@ -6819,31 +6790,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); })?; Ok(()) } @@ -6978,6 +6925,78 @@ fn rebuild_transcript( Ok(()) } +// Width-change replay: repaint the visible tail above the viewport in place. +// Inline scrollback is append-only, so replaying through insert_before +// permanently duplicates the whole transcript in terminal scrollback on every +// resize. The overwrite only covers rows above the viewport on the visible +// screen, so it requires a re-wrapped transcript at least one screen tall; +// shorter transcripts keep the insert replay, whose duplication is bounded by +// their own height. Deeper scrollback keeps the terminal's native rewrap. +fn overwrite_transcript_tail( + terminal: &mut Terminal, + state: &mut TuiState, + width: u16, +) -> Result { + let screen_rows = terminal.size()?.height; + if screen_rows == 0 { + return Ok(false); + } + let items = std::mem::take(&mut state.transcript); + let render_width = transcript_render_width(width); + let mut tool_tint_parity = false; + let mut rendered: Vec<(Arc>, u16, Option)> = + Vec::with_capacity(items.len()); + let mut total_height = 0u32; + for item in &items { + let (text, height) = cached_transcript_render(state, item, width); + let tint_bg = match item { + Line_::Thinking(_) => Some(thinking_bg()), + Line_::Steering(_) => Some(steering_bg()), + _ => next_transcript_tint(item, &mut tool_tint_parity), + }; + let height = height.max(1); + total_height = total_height.saturating_add(u32::from(height)); + rendered.push((Arc::new(text), height, tint_bg)); + } + if total_height < u32::from(screen_rows) { + state.transcript = items; + return Ok(false); + } + + let mut skip = total_height - u32::from(screen_rows); + let mut tail: Vec = Vec::new(); + for (text, height, tint_bg) in rendered { + let height = u32::from(height); + if skip >= height { + skip -= height; + continue; + } + let scroll = skip as u16; + skip = 0; + let line_end = text.lines.len(); + tail.push(PreparedTranscriptRender { + text, + line_start: 0, + line_end, + scroll, + height: (height - u32::from(scroll)) as u16, + tint_bg, + }); + } + + let overwrite = terminal.overwrite_before(screen_rows, move |buf| { + render_prepared_transcript(buf, tail, render_width); + }); + state.transcript = items; + if let Err(err) = overwrite { + state.transcript_needs_rebuild = true; + return Err(err); + } + state.tool_tint_parity = tool_tint_parity; + state.transcript_rendered_width = width; + Ok(true) +} + fn transcript_pane_width(area_width: u16, area_height: u16, state: &TuiState) -> u16 { compute_layout(Rect::new(0, 0, area_width, area_height), state) .transcript_area @@ -7011,7 +7030,14 @@ fn flush_pending_insert_for_width( return Ok(()); } let width_changed = state.transcript_rendered_width != width && !state.transcript.is_empty(); - if state.transcript_needs_rebuild || (width_changed && replay_width_change) { + if state.transcript_needs_rebuild { + // Explicit rebuilds (expand/collapse, error recovery) intentionally + // re-emit: their new content must become scrollable scrollback. + rebuild_transcript(terminal, state, width)?; + } else if width_changed + && replay_width_change + && !overwrite_transcript_tail(terminal, state, width)? + { rebuild_transcript(terminal, state, width)?; } if width_changed && state.transcript_rendered_width != width { @@ -7157,6 +7183,36 @@ fn cap_live_indicator_lines(mut lines: Vec>) -> Vec> lines } +// The pending approval prompt lives in the viewport, not the transcript: +// inline scrollback is append-only, so a scrollback prompt entry could only be +// updated after the decision by re-emitting the whole history. +fn pending_permission_prompt_text(state: &TuiState, width: u16) -> Option> { + let pending = state.pending_perm.as_ref()?; + if width == 0 { + return None; + } + Some(permission_prompt_text( + &pending.tool, + &pending.command, + pending.risk, + width, + )) +} + +fn cap_permission_prompt_lines( + mut lines: Vec>, + max_rows: usize, +) -> Vec> { + if max_rows == 0 || lines.len() <= max_rows { + return lines; + } + // Keep the command head and the trailing key-hint row when clipping. + let hint = lines.pop(); + lines.truncate(max_rows.saturating_sub(1)); + lines.extend(hint); + lines +} + fn count_lines_by_width(text: &Text<'_>, width: u16) -> usize { text_visual_height(text, width) as usize } @@ -7178,10 +7234,19 @@ fn collect_wrapped_lines(text: &Text<'static>, width: u16) -> Vec> fn render_transcript(frame: &mut ratatui::Frame, state: &mut TuiState, transcript_area: Rect) { let content_area = transcript_content_rect(transcript_area); let content_width = transcript_render_width(content_area.width); - let live_text = transcript_live_indicator_text(state, content_width); + let pending_prompt = pending_permission_prompt_text(state, content_width); + let prompt_active = pending_prompt.is_some(); + let live_text = pending_prompt.or_else(|| transcript_live_indicator_text(state, content_width)); let mut live_lines = live_text .as_ref() - .map(|text| cap_live_indicator_lines(collect_wrapped_lines(text, content_width))) + .map(|text| { + let lines = collect_wrapped_lines(text, content_width); + if prompt_active { + cap_permission_prompt_lines(lines, transcript_area.height as usize) + } else { + cap_live_indicator_lines(lines) + } + }) .unwrap_or_default(); if !live_lines.is_empty() && transcript_area.height as usize > live_lines.len() @@ -7262,21 +7327,14 @@ fn queue_permission_request( state.show_help = false; state.show_todos = false; state.status = "thinking".to_string(); - let prompt = Line_::PermissionPrompt { - tool: name.clone(), - command: command.clone(), - tier, - risk, - }; - if !replace_last_permission_entry(&mut state.pending_insert, prompt.clone()) - && !replace_last_permission_entry(&mut state.transcript, prompt.clone()) - { - state.queue(prompt); - } - state.transcript_needs_rebuild = true; + // The pending prompt renders inside the viewport, never into scrollback: + // inline scrollback is append-only, so updating a flushed prompt entry + // after the decision would force a duplicate full-history re-emit. state.pending_perm = Some(PendingPermission { tool: name, audit_label, + command, + risk, tier, responder, }); @@ -8725,17 +8783,11 @@ fn handle_key( if let Some(choice) = choice && let Some(pending) = state.pending_perm.take() { - let result = Line_::PermissionResult { + state.queue(Line_::PermissionResult { command: pending.audit_label.clone(), approved: !matches!(choice, Choice::Deny), always: matches!(choice, Choice::Always), - }; - if !replace_last_permission_entry(&mut state.pending_insert, result.clone()) - && !replace_last_permission_entry(&mut state.transcript, result.clone()) - { - state.queue(result); - } - state.transcript_needs_rebuild = true; + }); let _ = pending.responder.send(choice); match choice { Choice::Deny => { @@ -10132,6 +10184,8 @@ mod tests { state.pending_perm = Some(PendingPermission { tool: "bash".to_string(), audit_label: "echo $DEXT_MODEL".to_string(), + command: "echo $DEXT_MODEL".to_string(), + risk: crate::tool_policy::CommandRisk::Read, tier: PermissionTier::Read, responder: std::sync::mpsc::sync_channel(0).0, }); @@ -10467,6 +10521,8 @@ mod tests { state.pending_perm = Some(PendingPermission { tool: "bash".to_string(), audit_label: "echo $DEXT_MODEL".to_string(), + command: "echo $DEXT_MODEL".to_string(), + risk: crate::tool_policy::CommandRisk::Read, tier: PermissionTier::Read, responder: std::sync::mpsc::sync_channel(0).0, }); @@ -10479,6 +10535,111 @@ mod tests { assert!(!rendered.contains("awaiting permission")); } + #[test] + fn pending_permission_prompt_renders_in_viewport_not_scrollback() { + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + state.agent_busy = true; + state.pending_perm = Some(PendingPermission { + tool: "bash".to_string(), + audit_label: "echo ok".to_string(), + command: "echo ok".to_string(), + risk: crate::tool_policy::CommandRisk::Read, + tier: PermissionTier::Read, + responder: std::sync::mpsc::sync_channel(0).0, + }); + + let text = pending_permission_prompt_text(&state, 78).expect("prompt text"); + let flat: String = text + .lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + assert!(flat.contains("ask bash"), "{flat}"); + assert!(flat.contains("echo ok"), "{flat}"); + assert!(flat.contains("[y] once"), "{flat}"); + + let capped = cap_permission_prompt_lines( + vec![ + Line::from("head"), + Line::from("body-1"), + Line::from("body-2"), + Line::from("hint"), + ], + 3, + ); + let capped: Vec = capped + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + assert_eq!(capped, ["head", "body-1", "hint"]); + } + + #[test] + fn permission_flow_never_rebuilds_transcript_history() { + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + state.transcript.push(Line_::Assistant { + text: "earlier history".to_string(), + dim_prefix: false, + }); + let (permission_tx, permission_rx) = std::sync::mpsc::sync_channel(1); + queue_permission_request( + &mut state, + "bash".to_string(), + serde_json::json!({"command": "echo ok"}), + permission_tx, + ); + assert!(state.pending_perm.is_some()); + assert!( + state.pending_insert.is_empty(), + "pending prompt must not enter scrollback" + ); + assert!(!state.transcript_needs_rebuild); + + let (agent_tx, _agent_rx) = tokio::sync::mpsc::unbounded_channel(); + let (runtime_tx, _runtime_rx) = tokio::sync::mpsc::unbounded_channel(); + let (steering_tx, _steering_rx) = tokio::sync::mpsc::unbounded_channel(); + let interrupt = Arc::new(AtomicBool::new(false)); + handle_key( + &mut state, + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), + &agent_tx, + &runtime_tx, + &steering_tx, + &interrupt, + ); + + assert!(matches!( + permission_rx.try_recv().expect("choice"), + Choice::Once + )); + assert!( + !state.transcript_needs_rebuild, + "approval decisions must not re-emit history" + ); + assert!(matches!( + state.pending_insert.as_slice(), + [Line_::PermissionResult { approved: true, .. }, Line_::Blank] + )); + } + #[test] fn security_prompts_close_noncritical_overlays() { let mut state = TuiState::new( @@ -11542,16 +11703,12 @@ mod tests { ApprovalProfile::Ask, ThinkingEffort::Medium, ); - state.queue(Line_::PermissionPrompt { - tool: "bash".to_string(), - command: "echo ok".to_string(), - tier: PermissionTier::Read, - risk: crate::tool_policy::CommandRisk::Read, - }); let (permission_tx, permission_rx) = std::sync::mpsc::sync_channel(1); state.pending_perm = Some(PendingPermission { tool: "bash".to_string(), audit_label: "echo ok".to_string(), + command: "echo ok".to_string(), + risk: crate::tool_policy::CommandRisk::Read, tier: PermissionTier::Read, responder: permission_tx, }); @@ -14672,6 +14829,59 @@ mod tests { assert!(entry.renders.contains_key(&wider_render_width)); } + #[test] + fn resize_replay_overwrites_visible_tail_without_scrollback_growth() { + use ratatui::backend::TestBackend; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(8), + }, + ) + .expect("terminal"); + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + for index in 0..30 { + state.queue(Line_::Assistant { + text: format!( + "block {index} lorem ipsum dolor sit amet consectetur adipiscing elit" + ), + dim_prefix: false, + }); + } + let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); + flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); + assert_eq!(state.transcript_rendered_width, wide); + assert!( + terminal.backend().scrollback().area.height > 0, + "history must overflow into scrollback for this regression test" + ); + + terminal.backend_mut().resize(60, 20); + let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); + assert!(narrow < wide); + // TestBackend::resize reflows the scrollback buffer to the new width, + // so snapshot it after the resize settles and immediately before the + // replay flush: the replay itself must leave scrollback untouched. + let scrollback_before = terminal.backend().scrollback().clone(); + flush_pending_insert(&mut terminal, &mut state, narrow).expect("narrow flush"); + assert_eq!(state.transcript_rendered_width, narrow); + assert!(!state.transcript_needs_rebuild); + assert_eq!( + *terminal.backend().scrollback(), + scrollback_before, + "width replay must repaint in place, not append history to scrollback" + ); + } + #[test] fn pending_insert_waits_for_settled_transcript_width() { use ratatui::backend::TestBackend; diff --git a/vendor/ratatui-core/DEXT_PATCH.md b/vendor/ratatui-core/DEXT_PATCH.md index 3f442a1..86e91de 100644 --- a/vendor/ratatui-core/DEXT_PATCH.md +++ b/vendor/ratatui-core/DEXT_PATCH.md @@ -13,6 +13,8 @@ Dext uses this exact vendored source through the root `[patch.crates-io]` entry. - `src/terminal/inline.rs` - The no-scrolling-regions `insert_before` fallback calls `clear_viewport` directly instead of the public cursor-preserving `clear`. - Reason: this path has already positioned the cursor and does not need another backend round trip for every insertion chunk. + - Adds `Terminal::overwrite_before`, which draws the bottom rows of a rendered buffer directly above the inline viewport with absolute writes and no scrolling. + - Reason: inline scrollback is append-only, so replaying a resized transcript through `insert_before` permanently appends a duplicate history copy to terminal scrollback; the overwrite repaints the visible tail in place. - `src/terminal/resize.rs` - Horizontal shrink retains `ClearType::All` for fullscreen/fixed viewports but skips it for inline viewports. diff --git a/vendor/ratatui-core/src/terminal/inline.rs b/vendor/ratatui-core/src/terminal/inline.rs index c583f08..a5dde5f 100644 --- a/vendor/ratatui-core/src/terminal/inline.rs +++ b/vendor/ratatui-core/src/terminal/inline.rs @@ -119,6 +119,48 @@ impl Terminal { } } + /// Redraw content directly above the current inline viewport in place. + /// + /// Dext patch: inline resize replay must repaint the visible transcript tail at the new + /// terminal width. [`Terminal::insert_before`] scrolls the region above the viewport, so + /// replaying history through it appends a duplicate copy of that history to the terminal's + /// scrollback on every replay. This method instead draws the last `min(height, viewport_top)` + /// rows of the rendered buffer into the rows immediately above the viewport using plain + /// absolute-position writes: nothing scrolls, the viewport is untouched, and scrollback is + /// not mutated. + /// + /// When `height` exceeds the rows available above the viewport, the top of the rendered + /// buffer is skipped so the buffer's bottom rows land directly above the viewport. This has + /// no effect when the viewport is not inline or sits at the top of the screen. + pub fn overwrite_before(&mut self, height: u16, draw_fn: F) -> Result<(), B::Error> + where + F: FnOnce(&mut Buffer), + { + if !matches!(self.viewport, Viewport::Inline(_)) { + return Ok(()); + } + let viewport_top = self.viewport_area.top(); + if height == 0 || viewport_top == 0 { + return Ok(()); + } + let area = Rect { + x: 0, + y: 0, + width: self.viewport_area.width, + height, + }; + let mut buffer = Buffer::empty(area); + draw_fn(&mut buffer); + let to_draw = height.min(viewport_top); + let skip_cells = usize::from(height - to_draw) * usize::from(area.width); + self.draw_lines( + viewport_top - to_draw, + to_draw, + &buffer.content[skip_cells..], + )?; + Ok(()) + } + /// Implement `Self::insert_before` using standard backend capabilities. /// /// This is the fallback implementation when the `scrolling-regions` feature is disabled. It @@ -483,6 +525,60 @@ mod tests { assert_eq!(area, Rect::new(0, 0, 10, 4)); } + #[test] + fn overwrite_before_redraws_above_viewport_without_scrollback() { + // Diagram (terminal 10x6, viewport = Inline(2) anchored at y=4): + // + // 0 old-0 <- redraw target rows + // 1 old-1 + // 2 old-2 + // 3 old-3 + // 4 viewport-a <- viewport (must stay untouched) + // 5 viewport-b + // + // A 5-row buffer is drawn above a 4-row region: the top buffer row is + // skipped, the remaining rows land at y=0..4, and nothing scrolls into + // the scrollback buffer. + let mut backend = TestBackend::with_lines([ + "old-0 ", + "old-1 ", + "old-2 ", + "old-3 ", + "viewport-a", + "viewport-b", + ]); + backend + .set_cursor_position(Position { x: 0, y: 4 }) + .unwrap(); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(2), + }, + ) + .unwrap(); + let scrollback_before = terminal.backend().scrollback().clone(); + + terminal + .overwrite_before(5, |buf| { + let rows = ["skipped", "new-0", "new-1", "new-2", "new-3"]; + for (y, text) in rows.into_iter().enumerate() { + buf.set_string(0, y as u16, text, Style::default()); + } + }) + .unwrap(); + + terminal.backend().assert_buffer_lines([ + "new-0 ", + "new-1 ", + "new-2 ", + "new-3 ", + "viewport-a", + "viewport-b", + ]); + assert_eq!(*terminal.backend().scrollback(), scrollback_before); + } + #[cfg(not(feature = "scrolling-regions"))] mod no_scrolling_regions { use super::*; From 3261931c4882e18604fd3e4457834905bf249d7f Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:23:24 -0500 Subject: [PATCH 03/15] Harden inline resize replay after fresh-eye review Track transcript-owned visible rows so resize replay never overwrites unrelated shell output. Render only a screen-bounded transcript suffix, allocate narrow wrap growth with blank rows, and repaint both short and long histories without inserting duplicate content. Restore the backend cursor after direct history writes and report the actual overwritten row count. Also fix approval prompt cell-width accounting and zero/one-row clipping, ignore modified approval hotkeys, and safely deny any displaced pending request without blocking the TUI thread. --- docs/TUI.md | 7 +- docs/index.html | 4 +- src/tui.rs | 395 ++++++++++++++++----- vendor/ratatui-core/DEXT_PATCH.md | 4 +- vendor/ratatui-core/src/terminal/inline.rs | 30 +- 5 files changed, 335 insertions(+), 105 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index aea9ad9..085a057 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -15,7 +15,7 @@ TUI and dependency changes must preserve these behaviors: - 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. Width-change replay for transcripts at least one screen tall repaints the visible tail in place through the vendored `Terminal::overwrite_before` and must not append a duplicate history copy to terminal scrollback; shorter transcripts keep the bounded insert replay. +- Resize replay is cohesive: no item-by-item reconstruction, whole-screen flash, cursor-query stall, or cursor-query timeout. Width changes render only the screen-bounded transcript suffix, allocate any extra narrow-wrap rows as blank lines, and repaint all transcript-owned visible rows through the vendored `Terminal::overwrite_before`; short and long histories alike must not append duplicate transcript content to terminal 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. - 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. @@ -59,7 +59,7 @@ Dext patches the exact upstream `ratatui-core 0.1.2` source through `[patch.crat 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::overwrite_before` repaints rows directly above the inline viewport in place with absolute writes, so width-change replay does not scroll a duplicate transcript copy into terminal scrollback. +4. `Terminal::overwrite_before` repaints rows directly above the inline viewport in place with absolute writes, reports the actual replaced row count, and restores the backend cursor to the viewport origin without changing Ratatui's tracked frame cursor. Width-change replay uses blank row-delta insertion plus this overwrite, so neither short nor long transcripts scroll duplicate content into terminal scrollback. 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. @@ -84,7 +84,8 @@ The PTY smoke suite exercises the real binary and requires: - process survival and responsive input through a populated-history resize burst; - zero whole-screen clears during inline resize; - cursor queries bounded by resize events rather than transcript size; -- terminal-height-bounded replay chunks; +- resize replay rendering is bounded to the visible suffix rather than the full stored transcript; +- short and long transcript resizes leave existing terminal scrollback content unchanged; - 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. 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. diff --git a/docs/index.html b/docs/index.html index 3a5230a..680ce13 100644 --- a/docs/index.html +++ b/docs/index.html @@ -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, and to add Terminal::overwrite_before, which repaints rows above the inline viewport in place. Width-change replay uses that overwrite for transcripts at least one screen tall, so a resize repaints the visible tail at the new width without appending a duplicate history copy to terminal scrollback; shorter transcripts keep the bounded insert replay, and rows already beyond the screen keep the terminal's native rewrap.

-

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 whole-display clears during horizontal shrink, and to add Terminal::overwrite_before, which reports and repaints rows above the inline viewport in place while restoring the backend cursor to the viewport. Width-change replay renders only the screen-bounded transcript suffix; when narrowing needs more visible rows, it inserts only the blank row delta before repainting. Short and long histories therefore avoid appending duplicate transcript content to terminal scrollback, while rows already beyond the screen keep the terminal's native rewrap.

+

The real-PTY regression suite verifies editable input during streaming, resize survival with populated history, zero whole-screen resize clears, bounded cursor queries, screen-bounded resize rendering, no duplicate transcript insertion for short or long histories, 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

diff --git a/src/tui.rs b/src/tui.rs index aaf8451..dd1f8d0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -895,6 +895,7 @@ struct TuiState { render_cache: HashMap, render_cache_weight: usize, transcript_rendered_width: u16, + transcript_rendered_rows: u16, transcript_scroll_offset: usize, transcript_hover_expandable: Option, transcript_area: Rect, @@ -1024,6 +1025,7 @@ impl TuiState { render_cache: HashMap::new(), render_cache_weight: 0, transcript_rendered_width: 0, + transcript_rendered_rows: 0, transcript_scroll_offset: 0, transcript_hover_expandable: None, transcript_area: Rect::default(), @@ -3217,21 +3219,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), ])); } @@ -6755,8 +6750,9 @@ fn render_prepared_transcript( buf: &mut ratatui::buffer::Buffer, items: Vec, render_width: u16, + top_padding: u16, ) { - let mut y = buf.area.y; + let mut y = buf.area.y.saturating_add(top_padding.min(buf.area.height)); for item in items { let area = Rect { y, @@ -6790,7 +6786,7 @@ fn insert_prepared_transcript( height: u16, ) -> Result<(), B::Error> { terminal.insert_before(height, move |buf| { - render_prepared_transcript(buf, items, render_width); + render_prepared_transcript(buf, items, render_width, 0); })?; Ok(()) } @@ -6814,18 +6810,20 @@ fn insert_transcript_items( items: &[Line_], width: u16, tool_tint_parity: &mut bool, -) -> Result<(), B::Error> { +) -> Result { if items.is_empty() { - return Ok(()); + return Ok(0); } 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; + let mut inserted_rows = 0u32; for item in items { let (text, height) = cached_transcript_render(state, item, width); + inserted_rows = inserted_rows.saturating_add(u32::from(height)); let tint_bg = match item { Line_::Thinking(_) => Some(thinking_bg()), Line_::Steering(_) => Some(steering_bg()), @@ -6898,7 +6896,57 @@ fn insert_transcript_items( } flush_prepared_transcript(terminal, &mut chunk, render_width, &mut chunk_height)?; - Ok(()) + Ok(inserted_rows) +} + +fn prepare_transcript_tail( + state: &mut TuiState, + items: &[Line_], + width: u16, + row_budget: u16, +) -> (Vec, u16) { + let mut tool_tint_parity = state.tool_tint_parity; + let mut reverse_tail: Vec = Vec::new(); + let mut retained_rows = 0u16; + + for item in items.iter().rev() { + if retained_rows >= row_budget { + break; + } + let (text, height) = cached_transcript_render(state, item, width); + let tint_bg = match item { + Line_::Thinking(_) => Some(thinking_bg()), + Line_::Steering(_) => Some(steering_bg()), + Line_::Tool { + name, group_count, .. + } => { + tool_tint_parity = !tool_tint_parity; + if name == "read_file" && *group_count > 1 { + Some(Color::Indexed(235)) + } else if tool_tint_parity { + Some(Color::Indexed(236)) + } else { + None + } + } + _ => None, + }; + let take = height.min(row_budget.saturating_sub(retained_rows)); + let scroll = height.saturating_sub(take); + let text = Arc::new(text); + let line_end = text.lines.len(); + reverse_tail.push(PreparedTranscriptRender { + text, + line_start: 0, + line_end, + scroll, + height: take, + tint_bg, + }); + retained_rows = retained_rows.saturating_add(take); + } + reverse_tail.reverse(); + (reverse_tail, retained_rows) } fn rebuild_transcript( @@ -6907,94 +6955,83 @@ fn rebuild_transcript( 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. + // 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); state.transcript = items; - if let Err(err) = rebuild_result { - state.transcript_needs_rebuild = true; - return Err(err); - } + let inserted_rows = match rebuild_result { + Ok(inserted_rows) => inserted_rows, + Err(err) => { + state.transcript_needs_rebuild = true; + return Err(err); + } + }; state.tool_tint_parity = tool_tint_parity; state.transcript_rendered_width = width; + state.transcript_rendered_rows = inserted_rows + .min(u32::from(terminal.get_frame().area().top())) + .min(u32::from(u16::MAX)) as u16; state.transcript_needs_rebuild = false; Ok(()) } -// Width-change replay: repaint the visible tail above the viewport in place. -// Inline scrollback is append-only, so replaying through insert_before -// permanently duplicates the whole transcript in terminal scrollback on every -// resize. The overwrite only covers rows above the viewport on the visible -// screen, so it requires a re-wrapped transcript at least one screen tall; -// shorter transcripts keep the insert replay, whose duplication is bounded by -// their own height. Deeper scrollback keeps the terminal's native rewrap. +// Width-change replay repaints only rows owned by the transcript. Inline +// scrollback is append-only: replaying through insert_before would append a +// second content copy. When narrower wrapping needs more visible rows, insert +// only the blank row delta to allocate space, then overwrite the resulting +// bounded tail. Wider wrapping keeps the existing allocation and clears its +// unused leading rows, avoiding destructive scrollback deletion. fn overwrite_transcript_tail( terminal: &mut Terminal, state: &mut TuiState, width: u16, -) -> Result { +) -> Result<(), B::Error> { let screen_rows = terminal.size()?.height; - if screen_rows == 0 { - return Ok(false); + let viewport = terminal.get_frame().area(); + let viewport_top = viewport.top(); + let available_rows = screen_rows.saturating_sub(viewport.height); + let owned_rows = state.transcript_rendered_rows.min(viewport_top); + if screen_rows == 0 || viewport.height == screen_rows { + state.transcript_rendered_width = width; + state.transcript_rendered_rows = 0; + return Ok(()); } + let items = std::mem::take(&mut state.transcript); let render_width = transcript_render_width(width); - let mut tool_tint_parity = false; - let mut rendered: Vec<(Arc>, u16, Option)> = - Vec::with_capacity(items.len()); - let mut total_height = 0u32; - for item in &items { - let (text, height) = cached_transcript_render(state, item, width); - let tint_bg = match item { - Line_::Thinking(_) => Some(thinking_bg()), - Line_::Steering(_) => Some(steering_bg()), - _ => next_transcript_tint(item, &mut tool_tint_parity), - }; - let height = height.max(1); - total_height = total_height.saturating_add(u32::from(height)); - rendered.push((Arc::new(text), height, tint_bg)); - } - if total_height < u32::from(screen_rows) { + let (tail, retained_rows) = prepare_transcript_tail(state, &items, width, available_rows); + let allocated_rows = owned_rows.max(retained_rows); + let additional_rows = allocated_rows.saturating_sub(owned_rows); + let allocation = if additional_rows > 0 { + terminal.insert_before(additional_rows, |_| {}) + } else { + Ok(()) + }; + if let Err(err) = allocation { state.transcript = items; - return Ok(false); - } - - let mut skip = total_height - u32::from(screen_rows); - let mut tail: Vec = Vec::new(); - for (text, height, tint_bg) in rendered { - let height = u32::from(height); - if skip >= height { - skip -= height; - continue; - } - let scroll = skip as u16; - skip = 0; - let line_end = text.lines.len(); - tail.push(PreparedTranscriptRender { - text, - line_start: 0, - line_end, - scroll, - height: (height - u32::from(scroll)) as u16, - tint_bg, - }); - } - - let overwrite = terminal.overwrite_before(screen_rows, move |buf| { - render_prepared_transcript(buf, tail, render_width); - }); - state.transcript = items; - if let Err(err) = overwrite { state.transcript_needs_rebuild = true; return Err(err); } - state.tool_tint_parity = tool_tint_parity; + let top_padding = allocated_rows.saturating_sub(retained_rows); + let overwrite = terminal.overwrite_before(allocated_rows, move |buf| { + render_prepared_transcript(buf, tail, render_width, top_padding); + }); + state.transcript = items; + let overwritten_rows = match overwrite { + Ok(overwritten_rows) => overwritten_rows, + Err(err) => { + state.transcript_needs_rebuild = true; + return Err(err); + } + }; state.transcript_rendered_width = width; - Ok(true) + state.transcript_rendered_rows = overwritten_rows; + Ok(()) } fn transcript_pane_width(area_width: u16, area_height: u16, state: &TuiState) -> u16 { @@ -7034,11 +7071,8 @@ fn flush_pending_insert_for_width( // Explicit rebuilds (expand/collapse, error recovery) intentionally // re-emit: their new content must become scrollable scrollback. rebuild_transcript(terminal, state, width)?; - } else if width_changed - && replay_width_change - && !overwrite_transcript_tail(terminal, state, width)? - { - rebuild_transcript(terminal, state, width)?; + } else if width_changed && replay_width_change { + overwrite_transcript_tail(terminal, state, width)?; } if width_changed && state.transcript_rendered_width != width { return Ok(()); @@ -7095,10 +7129,15 @@ fn flush_prepared_items( } let mut tool_tint_parity = state.tool_tint_parity; - insert_transcript_items(terminal, state, items, width, &mut tool_tint_parity)?; + let inserted_rows = + insert_transcript_items(terminal, state, items, width, &mut tool_tint_parity)?; state.tool_tint_parity = tool_tint_parity; state.transcript.append(items); state.transcript_rendered_width = width; + state.transcript_rendered_rows = state + .transcript_rendered_rows + .saturating_add(inserted_rows.min(u32::from(u16::MAX)) as u16) + .min(terminal.get_frame().area().top()); Ok(()) } @@ -7203,12 +7242,19 @@ fn cap_permission_prompt_lines( mut lines: Vec>, max_rows: usize, ) -> Vec> { - if max_rows == 0 || lines.len() <= max_rows { + if max_rows == 0 { + return Vec::new(); + } + if lines.len() <= max_rows { + return lines; + } + if max_rows == 1 { + lines.truncate(1); return lines; } // Keep the command head and the trailing key-hint row when clipping. let hint = lines.pop(); - lines.truncate(max_rows.saturating_sub(1)); + lines.truncate(max_rows - 1); lines.extend(hint); lines } @@ -7327,6 +7373,14 @@ fn queue_permission_request( state.show_help = false; state.show_todos = false; state.status = "thinking".to_string(); + if let Some(previous) = state.pending_perm.take() { + state.queue(Line_::PermissionResult { + command: previous.audit_label, + approved: false, + always: false, + }); + let _ = previous.responder.try_send(Choice::Deny); + } // The pending prompt renders inside the viewport, never into scrollback: // inline scrollback is append-only, so updating a flushed prompt entry // after the decision would force a duplicate full-history re-emit. @@ -8771,13 +8825,26 @@ fn handle_key( .as_ref() .map(|pending| pending.tier.default_choice()) .unwrap_or(Choice::Deny); + let plain_choice_key = |modifiers: KeyModifiers| { + !modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::META, + ) + }; let choice = match (key.code, key.modifiers) { - (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => Some(Choice::Once), - (KeyCode::Char('a'), _) | (KeyCode::Char('A'), _) => Some(Choice::Always), - (KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => { + (KeyCode::Char('y') | KeyCode::Char('Y'), modifiers) if plain_choice_key(modifiers) => { + Some(Choice::Once) + } + (KeyCode::Char('a') | KeyCode::Char('A'), modifiers) if plain_choice_key(modifiers) => { + Some(Choice::Always) + } + (KeyCode::Char('n') | KeyCode::Char('N'), modifiers) if plain_choice_key(modifiers) => { Some(Choice::Deny) } - (KeyCode::Enter, _) => Some(default_choice), + (KeyCode::Esc, _) => Some(Choice::Deny), + (KeyCode::Enter, modifiers) if plain_choice_key(modifiers) => Some(default_choice), _ => None, }; if let Some(choice) = choice @@ -10584,6 +10651,75 @@ mod tests { }) .collect(); assert_eq!(capped, ["head", "body-1", "hint"]); + assert!(cap_permission_prompt_lines(vec![Line::from("head")], 0).is_empty()); + let one_row = cap_permission_prompt_lines( + vec![Line::from("head"), Line::from("body"), Line::from("hint")], + 1, + ); + assert_eq!(flatten_lines(&Text::from(one_row)), ["head"]); + + for width in 1..=8 { + let unicode_gutter = permission_prompt_text( + "bash", + "abcdefghij", + crate::tool_policy::CommandRisk::Read, + width, + ); + assert!( + unicode_gutter + .lines + .iter() + .all(|line| line.width() <= usize::from(width)), + "permission gutter must fit {width} terminal cells: {unicode_gutter:?}" + ); + } + } + + #[test] + fn replacing_pending_permission_denies_previous_request() { + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + let (first_tx, first_rx) = std::sync::mpsc::sync_channel(1); + let (second_tx, _second_rx) = std::sync::mpsc::sync_channel(1); + queue_permission_request( + &mut state, + "bash".to_string(), + serde_json::json!({"command": "first"}), + first_tx, + ); + queue_permission_request( + &mut state, + "bash".to_string(), + serde_json::json!({"command": "second"}), + second_tx, + ); + + assert!(matches!( + first_rx.try_recv().expect("first denied"), + Choice::Deny + )); + assert_eq!( + state + .pending_perm + .as_ref() + .map(|pending| pending.command.as_str()), + Some("second") + ); + assert!(matches!( + state.pending_insert.as_slice(), + [ + Line_::PermissionResult { + approved: false, + .. + }, + Line_::Blank + ] + )); } #[test] @@ -14829,6 +14965,83 @@ mod tests { assert!(entry.renders.contains_key(&wider_render_width)); } + #[test] + fn short_transcript_resize_replay_does_not_grow_scrollback() { + use ratatui::backend::TestBackend; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(8), + }, + ) + .expect("terminal"); + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + state.queue(Line_::Assistant { + text: "x".repeat(120), + dim_prefix: false, + }); + let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); + flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); + let owned_rows = state.transcript_rendered_rows; + assert!(owned_rows > 0 && owned_rows < terminal.size().unwrap().height); + + terminal.backend_mut().resize(28, 20); + let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); + let item = state.transcript[0].clone(); + let (_, narrow_rows) = cached_transcript_render(&mut state, &item, narrow); + assert!( + narrow_rows > owned_rows, + "test fixture must grow on narrow wrap: wide_rows={owned_rows} narrow_rows={narrow_rows} wide={wide} narrow={narrow} viewport_top={}", + terminal.get_frame().area().top() + ); + let scrollback_before = terminal.backend().scrollback().clone(); + flush_pending_insert(&mut terminal, &mut state, narrow).expect("narrow flush"); + + assert_eq!(state.transcript_rendered_width, narrow); + assert!( + state.transcript_rendered_rows > owned_rows, + "resize allocation did not grow: wide_rows={owned_rows} narrow_rows={narrow_rows} allocated={} viewport_top={}", + state.transcript_rendered_rows, + terminal.get_frame().area().top() + ); + assert_eq!(*terminal.backend().scrollback(), scrollback_before); + } + + #[test] + fn resize_tail_preparation_renders_only_screen_bounded_suffix() { + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + let items: Vec = (0..200) + .map(|index| Line_::Assistant { + text: format!("history block {index}"), + dim_prefix: false, + }) + .collect(); + + let (tail, retained_rows) = prepare_transcript_tail(&mut state, &items, 80, 12); + assert_eq!(retained_rows, 12); + assert!(tail.len() <= 12); + assert!(tail.len() < items.len()); + assert!( + state.render_cache.len() <= 12, + "resize replay should not re-render the full transcript" + ); + } + #[test] fn resize_replay_overwrites_visible_tail_without_scrollback_growth() { use ratatui::backend::TestBackend; diff --git a/vendor/ratatui-core/DEXT_PATCH.md b/vendor/ratatui-core/DEXT_PATCH.md index 86e91de..96f7f9f 100644 --- a/vendor/ratatui-core/DEXT_PATCH.md +++ b/vendor/ratatui-core/DEXT_PATCH.md @@ -13,8 +13,8 @@ Dext uses this exact vendored source through the root `[patch.crates-io]` entry. - `src/terminal/inline.rs` - The no-scrolling-regions `insert_before` fallback calls `clear_viewport` directly instead of the public cursor-preserving `clear`. - Reason: this path has already positioned the cursor and does not need another backend round trip for every insertion chunk. - - Adds `Terminal::overwrite_before`, which draws the bottom rows of a rendered buffer directly above the inline viewport with absolute writes and no scrolling. - - Reason: inline scrollback is append-only, so replaying a resized transcript through `insert_before` permanently appends a duplicate history copy to terminal scrollback; the overwrite repaints the visible tail in place. + - Adds `Terminal::overwrite_before`, which draws the bottom rows of a rendered buffer directly above the inline viewport with absolute writes, returns the number of rows actually replaced, and restores the backend cursor to the viewport origin without altering Ratatui's tracked frame cursor. + - Reason: inline scrollback is append-only, so replaying a resized transcript through `insert_before` permanently appends a duplicate history copy to terminal scrollback; Dext instead allocates only any blank narrow-wrap row delta, then uses the overwrite to repaint the screen-bounded visible suffix in place. - `src/terminal/resize.rs` - Horizontal shrink retains `ClearType::All` for fullscreen/fixed viewports but skips it for inline viewports. diff --git a/vendor/ratatui-core/src/terminal/inline.rs b/vendor/ratatui-core/src/terminal/inline.rs index a5dde5f..5466be8 100644 --- a/vendor/ratatui-core/src/terminal/inline.rs +++ b/vendor/ratatui-core/src/terminal/inline.rs @@ -130,18 +130,20 @@ impl Terminal { /// not mutated. /// /// When `height` exceeds the rows available above the viewport, the top of the rendered - /// buffer is skipped so the buffer's bottom rows land directly above the viewport. This has - /// no effect when the viewport is not inline or sits at the top of the screen. - pub fn overwrite_before(&mut self, height: u16, draw_fn: F) -> Result<(), B::Error> + /// buffer is skipped so the buffer's bottom rows land directly above the viewport. The + /// returned row count is the number of rows actually replaced; it can be smaller than + /// `height` when the viewport has fewer rows above it. Non-inline viewports and inline + /// viewports at the top return zero. + pub fn overwrite_before(&mut self, height: u16, draw_fn: F) -> Result where F: FnOnce(&mut Buffer), { if !matches!(self.viewport, Viewport::Inline(_)) { - return Ok(()); + return Ok(0); } let viewport_top = self.viewport_area.top(); if height == 0 || viewport_top == 0 { - return Ok(()); + return Ok(0); } let area = Rect { x: 0, @@ -158,7 +160,12 @@ impl Terminal { to_draw, &buffer.content[skip_cells..], )?; - Ok(()) + // Direct backend draws leave the hardware cursor on the rewritten history. Put it back in + // the viewport without changing Ratatui's tracked frame cursor; the next draw restores the + // application cursor through the normal render path. + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + Ok(to_draw) } /// Implement `Self::insert_before` using standard backend capabilities. @@ -558,8 +565,10 @@ mod tests { ) .unwrap(); let scrollback_before = terminal.backend().scrollback().clone(); + let viewport_before = terminal.get_frame().area(); + let tracked_cursor_before = terminal.last_known_cursor_pos; - terminal + let overwritten = terminal .overwrite_before(5, |buf| { let rows = ["skipped", "new-0", "new-1", "new-2", "new-3"]; for (y, text) in rows.into_iter().enumerate() { @@ -568,6 +577,9 @@ mod tests { }) .unwrap(); + assert_eq!(overwritten, 4); + assert_eq!(terminal.get_frame().area(), viewport_before); + assert_eq!(terminal.last_known_cursor_pos, tracked_cursor_before); terminal.backend().assert_buffer_lines([ "new-0 ", "new-1 ", @@ -576,6 +588,10 @@ mod tests { "viewport-a", "viewport-b", ]); + assert_eq!( + terminal.backend().cursor_position(), + terminal.viewport_area.as_position() + ); assert_eq!(*terminal.backend().scrollback(), scrollback_before); } From bc62dfd80290723c2cec334822bc17a3879002bc Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:43:48 -0500 Subject: [PATCH 04/15] fix(tui): deduplicate login provider completions --- docs/TUI.md | 2 +- docs/index.html | 1 + src/tui.rs | 34 ++++++++++++++++++++++++++-------- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 085a057..f46535a 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -10,7 +10,7 @@ TUI and dependency changes must preserve these behaviors: - Completed transcript output remains in native terminal scrollback. - 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. - 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. diff --git a/docs/index.html b/docs/index.html index 680ce13..7a01ca9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1207,6 +1207,7 @@

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.

KeyAction
diff --git a/src/tui.rs b/src/tui.rs index dd1f8d0..d1f9b7f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -676,10 +676,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 @@ -13242,10 +13244,10 @@ mod tests { } #[test] - fn model_arg_completions_lists_authenticated_provider_models() { + fn provider_and_model_arg_completions_use_canonical_choices() { let _guard = env_lock(); let root = std::env::temp_dir().join(format!( - "dext-tui-model-completions-{}-{}", + "dext-tui-provider-model-completions-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -13253,6 +13255,7 @@ mod tests { .as_nanos() )); std::fs::create_dir_all(&root).expect("create temp dir"); + let old_dext_home = std::env::var_os("DEXT_HOME"); unsafe { std::env::set_var("DEXT_HOME", &root); } @@ -13273,6 +13276,17 @@ mod tests { ); crate::save_auth_store(&store)?; + let login_texts = slash_completions("/login ") + .into_iter() + .map(|completion| completion.text) + .collect::>(); + let expected_login_texts = load_provider_catalog()? + .providers + .into_iter() + .map(|profile| format!("/login {}", profile.id)) + .collect::>(); + assert_eq!(login_texts, expected_login_texts); + let completions = model_arg_completions(""); let texts: Vec = completions.into_iter().map(|c| c.text).collect(); assert!( @@ -13288,10 +13302,14 @@ mod tests { })(); unsafe { - std::env::remove_var("DEXT_HOME"); + if let Some(value) = old_dext_home { + std::env::set_var("DEXT_HOME", value); + } else { + std::env::remove_var("DEXT_HOME"); + } } let _ = std::fs::remove_dir_all(&root); - result.expect("model completions should load auth-backed providers"); + result.expect("provider and model completions should use canonical choices"); } #[test] From 332e4e919af5b4d4c24a178059d6cfa0f584e98b Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:28:11 -0500 Subject: [PATCH 05/15] Replace /plan mode with conversational planning turn policies Remove the /plan slash command, its hidden read-only planner turn (run_plan, PLAN_SYSTEM, READ_ONLY_TOOLS, SilentSink plumbing), slash routing, TUI completion, and help/docs surface. The objective tracker now classifies planning/analysis-only prompts and bare plan approvals; Dext injects a matching advisory-only or implementation-authorized turn policy into the volatile runtime status, never the cached stable prompt. Question-phrased prompts are never read as approvals, explicit mutation intent overrides advisory scoping clauses, and mid-turn queued steering re-evaluates the policy so an approval or hold-off takes effect immediately. Approval prompts and /sandbox-profile read-only remain the enforcement layers. --- CHANGELOG.md | 21 +++- docs/USAGE.md | 2 + docs/index.html | 3 +- src/main.rs | 206 ++++++++++---------------------------- src/main_tests.rs | 132 +++++++++++++------------ src/orchestrator.rs | 236 +++++++++++++++++++++++++++++++++++++++++++- src/tui.rs | 17 ---- 7 files changed, 376 insertions(+), 241 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 577fb2a..920e577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,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 +101,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/docs/USAGE.md b/docs/USAGE.md index 8aa0cc1..4d8d08a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -175,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 7a01ca9..7c02061 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1207,7 +1207,7 @@

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.

+

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.

CommandDescription
/helpShow available commands
@@ -1244,7 +1244,6 @@

Slash Commands

- diff --git a/src/main.rs b/src/main.rs index 193c613..4217db8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1944,18 +1944,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; @@ -10365,7 +10353,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/recall.md as guidance; modify neither unless asked. - 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. @@ -10375,6 +10363,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") @@ -10682,15 +10674,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()?; @@ -10739,18 +10722,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\ @@ -12110,7 +12081,6 @@ fn slash_command_name(text: &str) -> Option<&str> { | "session" | "hooks" | "undo" - | "plan" ) .then_some(cmd) } @@ -13171,6 +13141,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, @@ -13365,6 +13338,7 @@ 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, @@ -15319,6 +15293,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( @@ -15460,6 +15443,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, @@ -15506,6 +15505,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 { @@ -17383,108 +17385,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 } @@ -17619,6 +17519,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))); @@ -21191,10 +21104,6 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option { " /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" @@ -24216,21 +24125,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 2d016e8..9452f2a 100644 --- a/src/main_tests.rs +++ b/src/main_tests.rs @@ -110,6 +110,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 +5622,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}"); } @@ -16435,6 +16438,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"); @@ -25975,67 +26046,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(); diff --git a/src/orchestrator.rs b/src/orchestrator.rs index a5f4ed9..ebdd76b 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) @@ -1776,6 +1946,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/tui.rs b/src/tui.rs index d1f9b7f..73379f1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -161,7 +161,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)] @@ -589,11 +588,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]", @@ -9585,17 +9579,6 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { } Err(msg) => agent.sink.emit(AgentEvent::Slash(msg.to_string())), } - } else if trimmed == "/plan" || trimmed.starts_with("/plan ") { - let task = trimmed.strip_prefix("/plan").unwrap_or("").trim(); - if task.is_empty() { - agent - .sink - .emit(AgentEvent::Slash("usage: /plan ".into())); - } else if let Err(e) = agent.run_plan(task.to_string()).await { - agent - .sink - .emit(AgentEvent::Error(format!("[plan error] {e:#}"))); - } } else if trimmed == "/pack" || trimmed.starts_with("/pack ") || trimmed == "/packs" From 8237971aaef0289398b18f7e41b187d9564df1e4 Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:52:48 -0500 Subject: [PATCH 06/15] Enable policy-governed recall.md memory, redact recall writes, restyle /help /tools /system - DEXT.md: replace do-not-touch recall.md rule with a recall policy the agent follows autonomously at task boundaries (declarative-only entries, 4KB cap, provenance tags, ?-until-confirmed, DEXT.md stays human-authored). - tool_round/mutation_preview: privacy-redact native write content destined for recall.md before preview, approval, and apply so reviewed and stored bytes match. - main: render /help, /tools, and /system through the shared list_render style (grouped sections, wrapped columns, Use: footer); drop the now-unused composed_system_prompt. - docs/index.html: same-change updates for recall.md semantics and the restyled slash listings. - Cargo.lock: h2 0.4.15 -> 0.4.16 (RUSTSEC-2026-0258). --- Cargo.lock | 12 +- DEXT.md | 21 +- docs/index.html | 10 +- src/main.rs | 500 +++++++++++++++++++++++----------------- src/mutation_preview.rs | 7 + src/tool_round.rs | 15 +- 6 files changed, 339 insertions(+), 226 deletions(-) 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/index.html b/docs/index.html index 7c02061..104ee14 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.
+
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. recall.md is policy-governed agent working memory: project DEXT.md policy decides whether the agent maintains it, and every write stays inside normal tool approval, mutation preview, checkpoint, 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.
@@ -1210,12 +1210,12 @@

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.

CommandDescription
/helpShow available commands
/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
/budget [cap]Set/show a dollar, token, or combined cap; off clears it
/pack create|list|inspect|runCreate or use shelf-contained packs
/shelvesShow shelf registry
- + - + - + @@ -1429,7 +1429,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 built-in behavior; Dext itself never writes it spontaneously. Native-tool writes targeting a recall.md file are privacy-redacted before preview, approval, and disk write, so the reviewed diff and the stored bytes are identical and secret-shaped content the model saw cannot persist into future prompts. 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/main.rs b/src/main.rs index 4217db8..d98f954 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11080,42 +11080,62 @@ struct ToolsCommandResult { fn render_tools_status(agent: &Agent) -> String { use std::fmt::Write as _; + let opts = list_render::ListOptions::detect(false); let header = agent.session_header(); let mut out = String::new(); - let _ = writeln!( + let _ = write!( out, - "tools: {} (schemas {})", - agent.tool_context_profile().as_str(), - agent.wire_tool_profile().as_str() + "{}", + list_render::render_header("Tools", header.exposed_tools.len(), &opts) ); - let _ = writeln!(out, "usage: /tools [status|default|full]"); + + let _ = writeln!(out, "{}", list_render::bold("Profile", opts.color)); let _ = writeln!( out, - "exposed ({}): {}", - header.exposed_tools.len(), - render_limited_csv(&header.exposed_tools, SLASH_LIST_LIMIT, "(none)", "tools") + " {} (schemas {})", + agent.tool_context_profile().as_str(), + agent.wire_tool_profile().as_str() ); - let _ = writeln!( - out, - "approval-required ({}): {}", - header.approval_required_tools.len(), - render_limited_csv( - &header.approval_required_tools, - SLASH_LIST_LIMIT, - "(none)", - "tools" - ) + out.push('\n'); + + let push_names_section = |out: &mut String, title: &str, names: &[String], empty: &str| { + let _ = writeln!( + out, + "{}", + list_render::bold(&format!("{title} ({})", names.len()), opts.color) + ); + if names.is_empty() { + let _ = writeln!(out, " {empty}"); + } 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 { + let _ = writeln!( + out, + " … [{} more tools omitted]", + names.len() - SLASH_LIST_LIMIT + ); + } + } + out.push('\n'); + }; + + push_names_section(&mut out, "Exposed", &header.exposed_tools, "(none)"); + push_names_section( + &mut out, + "Approval required", + &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, + "Auto-approved now", + &header.auto_approved_tools, + "(none; use /allow )", ); let hidden_specialized: Vec = tools::specialized_tool_names() @@ -11123,12 +11143,250 @@ fn render_tools_status(agent: &Agent) -> String { .map(str::to_string) .collect(); if !hidden_specialized.is_empty() { + 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(false); + let details = agent.compose_system_details(); + let composed = format!("{}\n\n{}", details.stable, details.env); + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + list_render::bold("System prompt", opts.color), + list_render::dim(&format!("{} chars", composed.len()), opts.color) + ); + out.push('\n'); + + let _ = writeln!(out, "{}", list_render::bold("Sources", opts.color)); + let _ = writeln!(out, " base prompt ({} chars)", agent.system.len()); + for path in &details.prompt_sources { let _ = writeln!( out, - "hidden until /tools full: {}", - hidden_specialized.join(", ") + " {}", + list_render::display_path(path, &opts, &agent.sandbox_root) ); } + out.push('\n'); + + let _ = writeln!(out, "{}", list_render::bold("Prompt", opts.color)); + let _ = writeln!( + out, + "{}", + cap_bytes_with_hint( + composed, + SLASH_TEXT_CAP, + "system prompt display truncated; use /system to replace the base prompt.", + ) + ); + 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() -> String { + use std::fmt::Write as _; + + const CMD_COL: usize = 30; + let opts = list_render::ListOptions::detect(false); + let total: usize = HELP_GROUPS.iter().map(|(_, entries)| entries.len()).sum(); + let mut out = String::new(); + let _ = write!( + out, + "{}", + list_render::render_header("Commands", total, &opts) + ); + for (group, entries) in HELP_GROUPS { + let _ = writeln!(out, "{}", list_render::bold(group, opts.color)); + for (cmd, desc) in *entries { + let prefix = format!(" {cmd}"); + if prefix.len() + 2 > CMD_COL { + let _ = writeln!(out, "{prefix}"); + list_render::write_wrapped(&mut out, desc, CMD_COL, opts.effective_width()); + } else { + let body_w = opts.effective_width().saturating_sub(CMD_COL).max(20); + let lines = list_render::wrap_lines(desc, body_w); + let _ = writeln!(out, "{prefix: — [args] optional, required"], &opts,) + ); out.trim_end().to_string() } @@ -16223,11 +16481,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, @@ -20948,171 +21201,7 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option { ); } "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, - " /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"); + let _ = writeln!(w, "{}", render_help_listing()); } "version" => { let _ = writeln!(w, "dext {}", env!("CARGO_PKG_VERSION")); @@ -21172,16 +21261,7 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option { } "system" => { if arg.is_empty() { - let composed = agent.composed_system_prompt(); - let _ = writeln!( - w, - "{}", - cap_bytes_with_hint( - composed, - SLASH_TEXT_CAP, - "system prompt display truncated; use /system to replace the base prompt.", - ) - ); + let _ = writeln!(w, "{}", render_system_prompt_view(agent)); } else { agent.system = arg.to_string(); let _ = writeln!(w, "system prompt replaced ({} chars)", agent.system.len()); 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/tool_round.rs b/src/tool_round.rs index f68aca7..64bc026 100644 --- a/src/tool_round.rs +++ b/src/tool_round.rs @@ -206,7 +206,20 @@ 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) => { + // recall.md is agent-authored memory that later re-enters + // the system prompt; scrub secrets before the content is + // previewed, approved, or written. + if let Some(mutation) = prepared.as_mut() + && mutation + .path() + .file_name() + .is_some_and(|f| f == "recall.md") + { + mutation.rewrite_after_text(|text| self.privacy.redact_text(text).text); + } + prepared_mutation = prepared; + } Err(message) => { plan = Some(Plan::Immediate { content: message, From 2b0f501df65ab9137d984df64ee45dff7a6246db Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:15:36 -0500 Subject: [PATCH 07/15] Keep list-style slash output off the terminal edge and fix header nouns - list_render: TTY measure is now terminal width minus a 2-column right gutter, capped at 100 columns, so wrapped listings stay readable and off the terminal border; non-TTY default unchanged. - render_count_header carries the header noun; /help shows "N total" and /tools "N exposed" instead of the discovery-style "N found". - docs/index.html: scope the recall.md write-redaction claim to the active privacy mode and document the readable-measure gutter. --- docs/index.html | 4 ++-- src/list_render.rs | 31 +++++++++++++++++++++++-------- src/main.rs | 4 ++-- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/index.html b/docs/index.html index 104ee14..9673b97 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1210,7 +1210,7 @@

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.

CommandDescription
/helpShow available commands
/helpShow available commands as a grouped, wrapped listing in the shared compact list style
/quit, /exitExit Dext
/resetClear conversation history
/tools [default|full]List or switch provider-visible tools
/tools [default|full]List (profile, exposed, approval-required, auto-approved, hidden sections) or switch provider-visible tools
/historyShow 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
/allowedList native and active-runtime grants
- + @@ -1429,7 +1429,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)

-

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 built-in behavior; Dext itself never writes it spontaneously. Native-tool writes targeting a recall.md file are privacy-redacted before preview, approval, and disk write, so the reviewed diff and the stored bytes are identical and secret-shaped content the model saw cannot persist into future prompts. Writes retain normal approval, sandbox, mutation-preview, and checkpoint controls.

+

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 built-in behavior; Dext itself never writes it spontaneously. Under the active privacy mode (enabled by default), native-tool writes targeting a recall.md file are privacy-redacted before preview, approval, and disk write, so the reviewed diff and the stored bytes are identical and secret-shaped content the model saw cannot persist into future prompts; /privacy off disables this scrub along with all other redaction. 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/list_render.rs b/src/list_render.rs index f72976d..67dfe7c 100644 --- a/src/list_render.rs +++ b/src/list_render.rs @@ -13,6 +13,9 @@ use crate::session::user_home_dir; const MIN_WIDTH: usize = 20; const DEFAULT_WIDTH: usize = 100; +// 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. @@ -56,13 +59,16 @@ 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 terminal_width() -> usize { if let Ok((cols, _)) = crossterm::terminal::size() && cols >= MIN_WIDTH as u16 { - return cols as usize; + return (cols as usize) + .saturating_sub(RIGHT_GUTTER) + .clamp(MIN_WIDTH, DEFAULT_WIDTH); } DEFAULT_WIDTH } @@ -239,16 +245,25 @@ pub(crate) fn render_footer(commands: &[&str], opts: &ListOptions) -> String { out } -/// Standard list header line: `Title found`. -pub(crate) fn render_header(title: &str, count: usize, opts: &ListOptions) -> String { +/// Standard list header line: `Title `. +pub(crate) fn render_count_header( + title: &str, + count: usize, + noun: &str, + opts: &ListOptions, +) -> String { format!( - "{} {} {}\n", + "{} {}\n", bold(title, opts.color), - dim(&count.to_string(), opts.color), - dim("found", opts.color), + dim(&format!("{count} {noun}"), opts.color), ) } +/// Discovery-list header line: `Title found`. +pub(crate) fn render_header(title: &str, count: usize, opts: &ListOptions) -> String { + render_count_header(title, count, "found", opts) +} + /// Parse `--verbose` / `-v` / `--paths` out of a slash argument, returning /// (remainder, verbose). pub(crate) fn take_verbose(arg: &str) -> (String, bool) { diff --git a/src/main.rs b/src/main.rs index d98f954..bc0681f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11086,7 +11086,7 @@ fn render_tools_status(agent: &Agent) -> String { let _ = write!( out, "{}", - list_render::render_header("Tools", header.exposed_tools.len(), &opts) + list_render::render_count_header("Tools", header.exposed_tools.len(), "exposed", &opts) ); let _ = writeln!(out, "{}", list_render::bold("Profile", opts.color)); @@ -11362,7 +11362,7 @@ fn render_help_listing() -> String { let _ = write!( out, "{}", - list_render::render_header("Commands", total, &opts) + list_render::render_count_header("Commands", total, "total", &opts) ); for (group, entries) in HELP_GROUPS { let _ = writeln!(out, "{}", list_render::bold(group, opts.color)); From 81e685f82e59bd3737c533ad72618a00179e1adc Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:26:47 -0500 Subject: [PATCH 08/15] Match recall.md redaction case-insensitively and fix stale docs term - tool_round: the prompt scan's stat("recall.md") also finds differently-cased names on case-insensitive filesystems, so the write-time privacy scrub now matches the filename ASCII case-insensitively instead of exactly, closing a macOS/Windows bypass. - docs/index.html: prompt-composition section says "recall working memory" instead of the stale "recall cache". --- docs/index.html | 2 +- src/tool_round.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/index.html b/docs/index.html index 9673b97..2c1a619 100644 --- a/docs/index.html +++ b/docs/index.html @@ -647,7 +647,7 @@

Main.rs — Agent Core

CommandDescription
/helpShow available commands as a grouped, wrapped listing in the shared compact list style
/helpShow available commands as a grouped, wrapped listing in the shared compact list style; list-style outputs wrap to a readable measure (terminal width minus a right-edge gutter, capped at 100 columns)
/quit, /exitExit Dext
/resetClear conversation history
/tools [default|full]List (profile, exposed, approval-required, auto-approved, hidden sections) or switch provider-visible tools
- + - + @@ -1243,7 +1244,7 @@

Slash Commands

- + diff --git a/src/list_render.rs b/src/list_render.rs index a7213d3..f6e6137 100644 --- a/src/list_render.rs +++ b/src/list_render.rs @@ -1,11 +1,11 @@ -//! Compact terminal-first list rendering shared by `/pack`, `/sessions`, `/shelves`, -//! `/help`, `/tools`, and `/system`. +//! 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; @@ -75,13 +75,57 @@ pub(crate) fn terminal_width() -> usize { // --- 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 terminal_safe_text(text: &str) -> String { let mut out = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { + 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 chars.peek() != Some(&'\n') { + if !text[i..].starts_with('\n') { out.push('\n'); } } @@ -252,6 +296,20 @@ pub(crate) fn write_preformatted_wrapped(out: &mut String, text: &str, width: us // --- 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 pairs: Vec = meta + .iter() + .map(|(k, v)| label(&format!("{k}:"), v, opts.color)) + .collect(); + let mut out = String::new(); + for line in pairs.join(" ").split('\n') { + let _ = writeln!(out, " {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. @@ -263,42 +321,32 @@ pub(crate) fn render_entry( ) -> String { let mut out = String::new(); let hang = 4; - let width = opts.effective_width(); - let name_indent = 2.min(width.saturating_sub(1)); - let name_width = width.saturating_sub(name_indent).max(1); - for line in wrap_lines(name, name_width) { - let _ = writeln!( - out, - "{}{}", - " ".repeat(name_indent), - bold(&line, opts.color) - ); + let safe_name = terminal_safe_text(name); + for line in safe_name.split('\n') { + let _ = writeln!(out, " {}", bold(line, opts.color)); } - if !description.trim().is_empty() { - write_wrapped(&mut out, description.trim(), hang, width); + 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 plain_pairs: Vec = meta.iter().map(|(k, v)| format!("{k}: {v}")).collect(); - let plain = plain_pairs.join(" "); - if hang + unicode_width::UnicodeWidthStr::width(plain.as_str()) <= width { - let pairs: Vec = meta - .iter() - .map(|(k, v)| label(&format!("{k}:"), v, opts.color)) - .collect(); - let _ = writeln!(out, "{}{}", " ".repeat(hang), pairs.join(" ")); - } else { - write_wrapped(&mut out, &plain, hang, width); - } + out.push_str(&render_metadata(meta, opts)); } out.push('\n'); out } -/// Render a width-bounded bold section heading. +/// 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(); - for line in wrap_lines(title, opts.effective_width()) { - let _ = writeln!(out, "{}", bold(&line, opts.color)); + let safe_title = terminal_safe_text(title); + for line in safe_title.split('\n') { + let _ = writeln!(out, "{}", bold(line, opts.color)); } out } @@ -307,7 +355,10 @@ pub(crate) fn render_section_header(title: &str, opts: &ListOptions) -> String { pub(crate) fn render_footer(commands: &[&str], opts: &ListOptions) -> String { let mut out = render_section_header("Use:", opts); for cmd in commands { - write_wrapped(&mut out, cmd, 2, opts.effective_width()); + let safe_cmd = terminal_safe_text(cmd); + for line in safe_cmd.split('\n') { + let _ = writeln!(out, " {line}"); + } } out } @@ -319,26 +370,11 @@ pub(crate) fn render_count_header( noun: &str, opts: &ListOptions, ) -> String { - let width = opts.effective_width(); - let count_text = format!("{count} {noun}"); - let combined_width = unicode_width::UnicodeWidthStr::width(title) - .saturating_add(2) - .saturating_add(unicode_width::UnicodeWidthStr::width(count_text.as_str())); - if combined_width <= width { - return format!( - "{} {}\n", - bold(title, opts.color), - dim(&count_text, opts.color), - ); - } - let mut out = String::new(); - for line in wrap_lines(title, width) { - let _ = writeln!(out, "{}", bold(&line, opts.color)); - } - for line in wrap_lines(&count_text, width) { - let _ = writeln!(out, "{}", dim(&line, opts.color)); - } - out + format!( + "{} {}\n", + bold(title, opts.color), + dim(&format!("{count} {noun}"), opts.color), + ) } /// Discovery-list header line: `Title found`. diff --git a/src/main.rs b/src/main.rs index 14046f5..dce4d72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11116,18 +11116,13 @@ fn render_tools_status(agent: &Agent) -> String { list_render::render_count_header("Tools", header.exposed_tools.len(), "exposed", &opts) ); - out.push_str(&list_render::render_section_header("Profiles", &opts)); - list_render::write_wrapped( - &mut out, - &format!( - "toolset: {} schemas: {} approval: {}", - agent.tool_context_profile().as_str(), - agent.wire_tool_profile().as_str(), - agent.approval_profile.as_str(), - ), - 4, - opts.effective_width(), - ); + 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| { @@ -11209,12 +11204,13 @@ fn render_system_prompt_view(agent: &Agent) -> String { out.push('\n'); out.push_str(&list_render::render_section_header("Sources", &opts)); - list_render::write_wrapped( - &mut out, - &format!("base prompt ({} chars)", agent.system.chars().count()), - 4, - opts.effective_width(), - ); + 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, @@ -11392,7 +11388,6 @@ const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[ fn render_help_listing() -> String { use std::fmt::Write as _; - const CMD_COL: usize = 30; let opts = list_render::ListOptions::detect(false); let total: usize = HELP_GROUPS.iter().map(|(_, entries)| entries.len()).sum(); let mut out = String::new(); @@ -11404,21 +11399,8 @@ fn render_help_listing() -> String { for (group, entries) in HELP_GROUPS { out.push_str(&list_render::render_section_header(group, &opts)); for (cmd, desc) in *entries { - let prefix = format!(" {cmd}"); - let prefix_width = unicode_width::UnicodeWidthStr::width(prefix.as_str()); - if prefix_width + 2 > CMD_COL || CMD_COL >= opts.effective_width() { - list_render::write_wrapped(&mut out, cmd, 2, opts.effective_width()); - list_render::write_wrapped(&mut out, desc, 4, opts.effective_width()); - } else { - let body_w = opts.effective_width().saturating_sub(CMD_COL).max(1); - let lines = list_render::wrap_lines(desc, body_w); - let _ = writeln!(out, "{prefix: String { list_render::render_header("Sessions", total, &opts) ); - out.push_str(&list_render::render_section_header("Latest", &opts)); + let _ = writeln!(out, "{}", list_render::bold("Latest", opts.color)); if latest_exists { let modified = latest_path.metadata().ok().and_then(|m| m.modified().ok()); out.push_str(&render_session_entry( @@ -19146,7 +19128,7 @@ fn render_session_listing(root: &Path) -> String { } out.push('\n'); - out.push_str(&list_render::render_section_header("Autosaved", &opts)); + let _ = writeln!(out, "{}", list_render::bold("Autosaved", opts.color)); if autosaved_sessions.is_empty() { let _ = writeln!( out, @@ -19167,7 +19149,7 @@ fn render_session_listing(root: &Path) -> String { } out.push('\n'); - out.push_str(&list_render::render_section_header("Named", &opts)); + let _ = writeln!(out, "{}", list_render::bold("Named", opts.color)); match &named_records { Ok(records) if records.is_empty() => { let _ = writeln!( diff --git a/src/main_tests.rs b/src/main_tests.rs index 1042d89..6722e26 100644 --- a/src/main_tests.rs +++ b/src/main_tests.rs @@ -7370,18 +7370,8 @@ 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); - let alpha_listing = render_session_listing(&alpha); - let beta_listing = render_session_listing(&beta); - let alpha_compact = alpha_listing.split_whitespace().collect::(); - let beta_compact = beta_listing.split_whitespace().collect::(); - assert!( - alpha_compact.contains(&alpha_path.display().to_string()), - "{alpha_listing}" - ); - assert!( - beta_compact.contains(&beta_path.display().to_string()), - "{beta_listing}" - ); + assert!(render_session_listing(&alpha).contains(&alpha_path.display().to_string())); + assert!(render_session_listing(&beta).contains(&beta_path.display().to_string())); Ok(()) })(); @@ -27123,56 +27113,106 @@ fn list_render_wrap_splits_long_words() { } #[test] -fn list_render_handles_tiny_widths_and_sanitizes_controls() { +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), 100); - let opts = list_render::ListOptions::fixed(false, 8); - let mut out = list_render::render_count_header("Long heading", 123, "total", &opts); + 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\rtitle\u{0007}", + "Unsafe\rsection\u{0007}\x1b]0;hidden\u{0007}", &opts, )); out.push_str(&list_render::render_entry( - "entry\tname", - "description with controls\u{0007}", - &[("source", "project\rspoofed".to_string())], + "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 "], &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\n entry name!\n description with controls\n source: project\n spoofedred\n\nUse:\n /command \n" + ); +} - assert!(!out.contains(['\r', '\t', '\u{0007}']), "{out:?}"); +#[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(); + assert!(help.starts_with("Commands "), "{help}"); assert!( - out.lines() - .all(|line| { unicode_width::UnicodeWidthStr::width(line) <= opts.effective_width() }), - "{out}" + help.contains("\nCore\n /help\n show this list\n\n /quit, /exit\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_wrapped_count_and_search_sections() { +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.contains("0 found"), "{out}"); - assert!(out.contains("Search"), "{out}"); - assert!( - out.lines() - .all(|line| { unicode_width::UnicodeWidthStr::width(line) <= opts.effective_width() }), - "{out}" - ); + assert!(out.starts_with("Packs 0 found\nSearch paths\n"), "{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() { +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] @@ -27199,11 +27239,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/tui.rs b/src/tui.rs index 923aca3..bccd1f5 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -271,7 +271,6 @@ enum Line_ { tool: String, message: String, }, - Slash(String), Info(String), RuntimeView { pack: String, @@ -2214,7 +2213,7 @@ impl TuiState { self.live_tools.clear(); self.set_agent_busy(false); self.status = phase_status_text(&s).unwrap_or_else(|| "ready".into()); - self.queue(Line_::Slash(s)); + self.queue(Line_::Info(s)); } AgentEvent::TurnEnd { failed, .. } => { self.push_debug_event(if failed { @@ -6260,12 +6259,6 @@ fn line_to_text(item: &Line_, width: u16) -> Text<'static> { width, ); } - Line_::Slash(s) => { - let normalized = s.replace("\r\n", "\n").replace('\r', "\n"); - 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]") { @@ -6325,7 +6318,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 = s.replace("\r\n", "\n").replace('\r', "\n"); + for seg in normalized.split('\n') { lines.push(Line::from(ansi_to_spans(seg))); } } else { @@ -10192,9 +10186,9 @@ mod tests { } #[test] - fn slash_lines_preserve_supported_sgr_and_sanitize_controls() { + fn structured_slash_info_preserves_supported_sgr_and_sanitizes_controls() { let text = line_to_text( - &Line_::Slash("\x1b[1mHeading\x1b[0m\r\nplain\u{0007}\x1b]0;title\u{0007}".to_string()), + &Line_::Info("\x1b[1mHeading\x1b[0m\r\nplain\u{0007}\x1b]0;title\u{0007}".to_string()), 80, ); assert_eq!(flatten_lines(&text), vec!["Heading", "plain"]); @@ -12609,7 +12603,7 @@ mod tests { state .pending_insert .last() - .is_some_and(|line| matches!(line, Line_::Slash(msg) if msg == "ok")) + .is_some_and(|line| matches!(line, Line_::Info(msg) if msg == "ok")) ); } From d2eb6c0b53c2a20587c142492c6ba8cc30ffeec0 Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:34:41 -0500 Subject: [PATCH 11/15] fix inline transcript replay on resize --- docs/ARCHITECTURE.md | 4 +- docs/TUI.md | 21 +- docs/index.html | 10 +- src/events.rs | 1 + src/list_render.rs | 246 ++++- src/main.rs | 218 ++-- src/main_tests.rs | 137 ++- src/packs.rs | 10 +- src/shelves.rs | 9 +- src/tui.rs | 1038 ++++++++++---------- tests/tui_smoke.rs | 86 +- vendor/ratatui-core/DEXT_PATCH.md | 8 +- vendor/ratatui-core/src/backend/test.rs | 8 + vendor/ratatui-core/src/terminal/inline.rs | 126 +-- 14 files changed, 1163 insertions(+), 759 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 37b0cee..4aaef29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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/TUI.md b/docs/TUI.md index da250bc..f2d3f37 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -1,23 +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`. `/login` completion shows every provider id exactly once and suppresses duplicate numbered-selector entries. -- Structured slash listings from `/help`, `/tools`, `/system`, packs, shelves, and sessions follow the established `/sessions` blueprint: a count header, bold section labels, two-space entry names, four-space details, blank-separated blocks, and a detached `Use:` footer. Long descriptions and prompt/list bodies wrap to the terminal width minus a two-cell right gutter, capped at 100 columns, while the session blueprint's compact header, metadata, and footer lines stay intact. Dynamic names, descriptions, metadata, and commands have terminal escapes and controls removed before layout; ANSI styling is then emitted only for an interactive color-capable stdout with `NO_COLOR` unset and `TERM != dumb`. The existing TUI info path translates that renderer-generated SGR styling into Ratatui spans while removing any other escape/control sequences. +- 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. Width changes render only the screen-bounded transcript suffix, allocate any extra narrow-wrap rows as blank lines, and repaint all transcript-owned visible rows through the vendored `Terminal::overwrite_before`; short and long histories alike must not append duplicate transcript content to terminal scrollback. +- 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. @@ -60,7 +61,7 @@ Dext patches the exact upstream `ratatui-core 0.1.2` source through `[patch.crat 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::overwrite_before` repaints rows directly above the inline viewport in place with absolute writes, reports the actual replaced row count, and restores the backend cursor to the viewport origin without changing Ratatui's tracked frame cursor. Width-change replay uses blank row-delta insertion plus this overwrite, so neither short nor long transcripts scroll duplicate content into terminal scrollback. +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. @@ -72,6 +73,7 @@ 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 @@ -83,13 +85,12 @@ The PTY smoke suite 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; -- resize replay rendering is bounded to the visible suffix rather than the full stored transcript; -- short and long transcript resizes leave existing terminal scrollback content unchanged; -- 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/index.html b/docs/index.html index bc86249..c267799 100644 --- a/docs/index.html +++ b/docs/index.html @@ -825,7 +825,7 @@

Auth Failure Detection

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
  • @@ -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, and to add Terminal::overwrite_before, which reports and repaints rows above the inline viewport in place while restoring the backend cursor to the viewport. Width-change replay renders only the screen-bounded transcript suffix; when narrowing needs more visible rows, it inserts only the blank row delta before repainting. Short and long histories therefore avoid appending duplicate transcript content to terminal scrollback, while rows already beyond the screen keep the terminal's native rewrap.

    -

    The real-PTY regression suite verifies editable input during streaming, resize survival with populated history, zero whole-screen resize clears, bounded cursor queries, screen-bounded resize rendering, no duplicate transcript insertion for short or long histories, 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 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)

  • Application launch, banner/composer rendering, help, multiline input, and clean Ctrl+D exit
  • 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

@@ -1208,7 +1208,7 @@

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 follow the established /sessions presentation: a compact count header, bold section labels, two-space entry names, four-space details, blank-separated blocks, and a detached Use: footer. Long descriptions and prompt/list bodies wrap to the terminal width minus a two-cell gutter, capped at 100 columns, without reflowing the compact session header, metadata, and footer lines. Dynamic names, descriptions, metadata, and commands have terminal escapes and controls removed before layout; interactive output then adds only renderer-owned ANSI styling, which the TUI preserves while removing unsupported escape and control sequences.

+

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.

CommandDescription
/helpShow available commands as a grouped, wrapped listing in the shared compact list style; list-style outputs wrap to a readable measure (terminal width minus a right-edge gutter, capped at 100 columns)
/helpShow available commands as a grouped listing in the established /sessions presentation
/quit, /exitExit Dext
/resetClear conversation history
/tools [default|full]List (profile, exposed, approval-required, auto-approved, hidden sections) or switch provider-visible tools
/tools [default|full]List (profile, exposed, permission-gated, explicit session grants, hidden sections) or switch provider-visible tools
/historyShow turn count and last 5 messages
/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
/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
/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|runCreate or use shelf-contained packs
/shelvesShow shelf registry
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 f6e6137..065fd99 100644 --- a/src/list_render.rs +++ b/src/list_render.rs @@ -13,6 +13,7 @@ use std::path::Path; use crate::session::user_home_dir; 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; @@ -27,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, } } @@ -63,7 +64,7 @@ pub(crate) fn use_color() -> bool { /// 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, DEFAULT_WIDTH) + cols.saturating_sub(RIGHT_GUTTER).clamp(1, MAX_WIDTH) } pub(crate) fn terminal_width() -> usize { @@ -111,6 +112,17 @@ fn terminal_escape_end(text: &str, start: usize) -> usize { 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; @@ -129,9 +141,9 @@ fn terminal_safe_text(text: &str) -> String { out.push('\n'); } } - '\n' => out.push(ch), + '\n' | '\u{2028}' | '\u{2029}' => out.push('\n'), '\t' => out.push_str(" "), - _ if ch.is_control() => {} + _ if ch.is_control() || is_bidi_format_control(ch) => {} _ => out.push(ch), } } @@ -204,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 } @@ -242,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; } } @@ -278,18 +296,42 @@ pub(crate) fn write_wrapped(out: &mut String, text: &str, hang: usize, width: us } } -/// Preserve input line boundaries while hard-wrapping each line to the display -/// width. Terminal controls are removed before rendering. +/// 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.is_empty() { + if line.trim().is_empty() { out.push('\n'); - } else { - for chunk in word_chunks(line, width) { - let _ = writeln!(out, "{chunk}"); - } + 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)); } } } @@ -299,13 +341,119 @@ pub(crate) fn write_preformatted_wrapped(out: &mut String, text: &str, width: us /// 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 pairs: Vec = meta + 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(|(k, v)| label(&format!("{k}:"), v, opts.color)) + .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 line in pairs.join(" ").split('\n') { - let _ = writeln!(out, " {line}"); + 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 } @@ -322,9 +470,7 @@ pub(crate) fn render_entry( let mut out = String::new(); let hang = 4; let safe_name = terminal_safe_text(name); - for line in safe_name.split('\n') { - let _ = writeln!(out, " {}", bold(line, opts.color)); - } + 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( @@ -345,9 +491,7 @@ pub(crate) fn render_entry( pub(crate) fn render_section_header(title: &str, opts: &ListOptions) -> String { let mut out = String::new(); let safe_title = terminal_safe_text(title); - for line in safe_title.split('\n') { - let _ = writeln!(out, "{}", bold(line, opts.color)); - } + write_indented_preformatted(&mut out, &safe_title, 0, opts.effective_width(), opts.color); out } @@ -356,9 +500,7 @@ pub(crate) fn render_footer(commands: &[&str], opts: &ListOptions) -> String { let mut out = render_section_header("Use:", opts); for cmd in commands { let safe_cmd = terminal_safe_text(cmd); - for line in safe_cmd.split('\n') { - let _ = writeln!(out, " {line}"); - } + write_indented_preformatted(&mut out, &safe_cmd, 2, opts.effective_width(), false); } out } @@ -370,11 +512,27 @@ pub(crate) fn render_count_header( noun: &str, opts: &ListOptions, ) -> String { - format!( - "{} {}\n", - bold(title, opts.color), - dim(&format!("{count} {noun}"), opts.color), - ) + 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`. diff --git a/src/main.rs b/src/main.rs index dce4d72..8c5d703 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1563,6 +1563,12 @@ impl OutputMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SlashPresentation { + Faded, + Structured, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SlashUiUpdate { None, @@ -1761,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!( "{}", @@ -11107,7 +11113,7 @@ struct ToolsCommandResult { fn render_tools_status(agent: &Agent) -> String { use std::fmt::Write as _; - let opts = list_render::ListOptions::detect(false); + let opts = list_render::ListOptions::detect_with_width(false, agent.slash_render_width); let header = agent.session_header(); let mut out = String::new(); let _ = write!( @@ -11192,7 +11198,7 @@ fn render_tools_status(agent: &Agent) -> String { fn render_system_prompt_view(agent: &Agent) -> String { use std::fmt::Write as _; - let opts = list_render::ListOptions::detect(false); + 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(); @@ -11274,12 +11280,12 @@ const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[ ), ( "/approval [profile]", - "ask|auto-read|auto-write|never|always", + "ask · auto-read · auto-write · never · always", ), ("/preview [mode]", "off|simple|git mutation previews"), ( "/sandbox-profile [profile]", - "read-only|workspace-write|danger-full-access", + "read-only · workspace-write · danger-full-access", ), ("/budget [cap|off]", "show/set budget cap ($ or tokens)"), ], @@ -11328,11 +11334,11 @@ const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[ &[ ( "/effort [level]", - "model reasoning depth/tool persistence: off|minimal|low|medium|high|xhigh|max", + "model reasoning depth/tool persistence: off · minimal · low · medium · high · xhigh · max", ), ( "/reasoning-mode [mode]", - "standard|pro (official OpenAI GPT-5.6 Responses only)", + "standard · pro (official OpenAI GPT-5.6 Responses only)", ), ( "/context [standard|frugal]", @@ -11372,7 +11378,7 @@ const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[ ), ( "/sessions", - "list sessions; also analyze|brief|grep|failures|verify-log|decisions", + "list sessions; also analyze · brief · grep · failures · verify-log · decisions", ), ("/session", "alias for /sessions"), ("/hooks [reload]", "show hook config or reload from disk"), @@ -11385,10 +11391,10 @@ const HELP_GROUPS: &[(&str, &[(&str, &str)])] = &[ ), ]; -fn render_help_listing() -> String { +fn render_help_listing(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 total: usize = HELP_GROUPS.iter().map(|(_, entries)| entries.len()).sum(); let mut out = String::new(); let _ = write!( @@ -11398,9 +11404,8 @@ fn render_help_listing() -> String { ); for (group, entries) in HELP_GROUPS { out.push_str(&list_render::render_section_header(group, &opts)); - for (cmd, desc) in *entries { - out.push_str(&list_render::render_entry(cmd, desc, &[], &opts)); - } + out.push_str(&list_render::render_entry_rows(entries, &opts)); + out.push('\n'); } let _ = write!( out, @@ -13402,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 @@ -13621,6 +13627,7 @@ impl Agent { git_context, silent: false, pretty, + slash_render_width: None, max_iterations: None, session_usage: Usage::default(), last_request_usage: Usage::default(), @@ -19073,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); @@ -19109,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( @@ -19120,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) => { @@ -19169,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(), + ); } } @@ -21137,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; @@ -21171,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 "); @@ -21236,14 +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, "{}", render_help_listing()); + presentation = SlashPresentation::Structured; + let _ = writeln!(w, "{}", render_help_listing(agent.slash_render_width)); } "version" => { let _ = writeln!(w, "dext {}", env!("CARGO_PKG_VERSION")); @@ -21278,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); } @@ -21303,6 +21357,7 @@ fn handle_slash(line: &str, agent: &mut Agent) -> Option { } "system" => { if arg.is_empty() { + presentation = SlashPresentation::Structured; let _ = writeln!(w, "{}", render_system_prompt_view(agent)); } else { agent.system = arg.to_string(); @@ -21566,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()); @@ -21606,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(); @@ -21638,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); @@ -22062,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 }; @@ -22298,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 => {} diff --git a/src/main_tests.rs b/src/main_tests.rs index 6722e26..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(), @@ -7314,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}"); @@ -7370,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(()) })(); @@ -17385,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"); @@ -17442,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(); @@ -17526,6 +17559,7 @@ fn recall_prompt_injection_has_one_aggregate_four_kib_payload_budget() { #[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); @@ -26798,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(); @@ -26877,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::>() @@ -26917,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(); @@ -26936,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::>() @@ -27118,12 +27152,12 @@ fn list_render_keeps_session_blueprint_and_sanitizes_controls() { 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), 100); + 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{0007}\x1b]0;hidden\u{0007}", + "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( @@ -27141,7 +27175,77 @@ fn list_render_keeps_session_blueprint_and_sanitizes_controls() { assert!(!out.contains("hidden"), "{out:?}"); assert_eq!( out, - "Items 1 found\nUnsafe\nsection\n entry name!\n description with controls\n source: project\n spoofedred\n\nUse:\n /command \n" + "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:?}" ); } @@ -27150,10 +27254,10 @@ 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(); + let help = render_help_listing(Some(100)); assert!(help.starts_with("Commands "), "{help}"); assert!( - help.contains("\nCore\n /help\n show this list\n\n /quit, /exit\n"), + help.contains("\nCore\n /help show this list\n /quit, /exit exit dext\n"), "{help}" ); assert!( @@ -27198,7 +27302,12 @@ fn empty_pack_listing_uses_session_style_header_and_search_section() { let opts = list_render::ListOptions::fixed(false, 12); let out = packs::render_pack_list(&[], &opts, &root); - assert!(out.starts_with("Packs 0 found\nSearch paths\n"), "{out}"); + 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}"); diff --git a/src/packs.rs b/src/packs.rs index 8c8f610..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, ) } diff --git a/src/shelves.rs b/src/shelves.rs index 7ebc9f5..2e71517 100644 --- a/src/shelves.rs +++ b/src/shelves.rs @@ -779,8 +779,15 @@ 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() { let mut out = crate::list_render::render_header("Shelves", 0, &opts); diff --git a/src/tui.rs b/src/tui.rs index bccd1f5..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] = &[ @@ -272,6 +273,7 @@ enum Line_ { message: String, }, Info(String), + Slash(String), RuntimeView { pack: String, title: String, @@ -306,7 +308,7 @@ enum ToTui { } enum FromTui { - Submit(String), + Submit { text: String, pane_width: u16 }, LoginInput(String), LoginCancel, CycleEffort(i8), @@ -887,11 +889,11 @@ struct TranscriptLayoutState { struct TuiState { pending_insert: Vec, + prepared_insert_retry: Vec, transcript: Vec, render_cache: HashMap, render_cache_weight: usize, transcript_rendered_width: u16, - transcript_rendered_rows: u16, transcript_scroll_offset: usize, transcript_hover_expandable: Option, transcript_area: Rect, @@ -1017,11 +1019,11 @@ impl TuiState { ) -> Self { Self { pending_insert: Vec::new(), + prepared_insert_retry: Vec::new(), transcript: Vec::new(), render_cache: HashMap::new(), render_cache_weight: 0, transcript_rendered_width: 0, - transcript_rendered_rows: 0, transcript_scroll_offset: 0, transcript_hover_expandable: None, transcript_area: Rect::default(), @@ -1488,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) @@ -1783,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 => { @@ -2202,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" @@ -3561,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!() @@ -3582,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; } @@ -3597,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. @@ -3874,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), } } @@ -6259,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]") { @@ -6318,7 +6358,7 @@ 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. - let normalized = s.replace("\r\n", "\n").replace('\r', "\n"); + let normalized = normalize_display_line_breaks(s); for seg in normalized.split('\n') { lines.push(Line::from(ansi_to_spans(seg))); } @@ -6705,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, @@ -6832,21 +6817,19 @@ fn insert_transcript_items( state: &mut TuiState, items: &[Line_], width: u16, + chunk_rows: u16, tool_tint_parity: &mut bool, -) -> Result { +) -> Result<(), B::Error> { if items.is_empty() { - return Ok(0); + return Ok(()); } 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; - let mut inserted_rows = 0u32; for item in items { let (text, height) = cached_transcript_render(state, item, width); - inserted_rows = inserted_rows.saturating_add(u32::from(height)); let tint_bg = match item { Line_::Thinking(_) => Some(thinking_bg()), Line_::Steering(_) => Some(steering_bg()), @@ -6918,58 +6901,39 @@ fn insert_transcript_items( } } - flush_prepared_transcript(terminal, &mut chunk, render_width, &mut chunk_height)?; - Ok(inserted_rows) + flush_prepared_transcript(terminal, &mut chunk, render_width, &mut chunk_height) } -fn prepare_transcript_tail( +#[cfg(test)] +fn rebuild_transcript_from_origin( + terminal: &mut Terminal, state: &mut TuiState, - items: &[Line_], width: u16, - row_budget: u16, -) -> (Vec, u16) { - let mut tool_tint_parity = state.tool_tint_parity; - let mut reverse_tail: Vec = Vec::new(); - let mut retained_rows = 0u16; +) -> Result<(), B::Error> { + terminal.reset_inline_viewport()?; + rebuild_transcript(terminal, state, width) +} - for item in items.iter().rev() { - if retained_rows >= row_budget { - break; +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(()); } - let (text, height) = cached_transcript_render(state, item, width); - let tint_bg = match item { - Line_::Thinking(_) => Some(thinking_bg()), - Line_::Steering(_) => Some(steering_bg()), - Line_::Tool { - name, group_count, .. - } => { - tool_tint_parity = !tool_tint_parity; - if name == "read_file" && *group_count > 1 { - Some(Color::Indexed(235)) - } else if tool_tint_parity { - Some(Color::Indexed(236)) - } else { - None - } - } - _ => None, - }; - let take = height.min(row_budget.saturating_sub(retained_rows)); - let scroll = height.saturating_sub(take); - let text = Arc::new(text); - let line_end = text.lines.len(); - reverse_tail.push(PreparedTranscriptRender { - text, - line_start: 0, - line_end, - scroll, - height: take, - tint_bg, - }); - retained_rows = retained_rows.saturating_add(take); - } - reverse_tail.reverse(); - (reverse_tail, retained_rows) + 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( @@ -6977,84 +6941,38 @@ fn rebuild_transcript( state: &mut TuiState, width: u16, ) -> Result<(), B::Error> { - terminal.clear()?; + 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; - let inserted_rows = match rebuild_result { - Ok(inserted_rows) => inserted_rows, - Err(err) => { - 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(()) } - }; - state.tool_tint_parity = tool_tint_parity; - state.transcript_rendered_width = width; - state.transcript_rendered_rows = inserted_rows - .min(u32::from(terminal.get_frame().area().top())) - .min(u32::from(u16::MAX)) as u16; - state.transcript_needs_rebuild = false; - Ok(()) -} - -// Width-change replay repaints only rows owned by the transcript. Inline -// scrollback is append-only: replaying through insert_before would append a -// second content copy. When narrower wrapping needs more visible rows, insert -// only the blank row delta to allocate space, then overwrite the resulting -// bounded tail. Wider wrapping keeps the existing allocation and clears its -// unused leading rows, avoiding destructive scrollback deletion. -fn overwrite_transcript_tail( - terminal: &mut Terminal, - state: &mut TuiState, - width: u16, -) -> Result<(), B::Error> { - let screen_rows = terminal.size()?.height; - let viewport = terminal.get_frame().area(); - let viewport_top = viewport.top(); - let available_rows = screen_rows.saturating_sub(viewport.height); - let owned_rows = state.transcript_rendered_rows.min(viewport_top); - if screen_rows == 0 || viewport.height == screen_rows { - state.transcript_rendered_width = width; - state.transcript_rendered_rows = 0; - return Ok(()); - } - - let items = std::mem::take(&mut state.transcript); - let render_width = transcript_render_width(width); - let (tail, retained_rows) = prepare_transcript_tail(state, &items, width, available_rows); - let allocated_rows = owned_rows.max(retained_rows); - let additional_rows = allocated_rows.saturating_sub(owned_rows); - let allocation = if additional_rows > 0 { - terminal.insert_before(additional_rows, |_| {}) - } else { - Ok(()) - }; - if let Err(err) = allocation { - state.transcript = items; - state.transcript_needs_rebuild = true; - return Err(err); - } - let top_padding = allocated_rows.saturating_sub(retained_rows); - let overwrite = terminal.overwrite_before(allocated_rows, move |buf| { - render_prepared_transcript(buf, tail, render_width, top_padding); - }); - state.transcript = items; - let overwritten_rows = match overwrite { - Ok(overwritten_rows) => overwritten_rows, Err(err) => { state.transcript_needs_rebuild = true; - return Err(err); + Err(err) } - }; - state.transcript_rendered_width = width; - state.transcript_rendered_rows = overwritten_rows; - Ok(()) + } } fn transcript_pane_width(area_width: u16, area_height: u16, state: &TuiState) -> u16 { @@ -7063,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, B::Error> { + if expected_width == 0 { + return Ok(None); + } + terminal.autoresize()?; + let size = terminal.size()?; + let current_width = transcript_pane_width(size.width, size.height, state); + if size.width == 0 || size.height == 0 || current_width == 0 || current_width != expected_width + { + return Ok(None); + } + Ok(Some(size.height)) +} + fn terminal_has_render_area(terminal: &Terminal) -> Result { let size = terminal.size()?; Ok(size.width > 0 && size.height > 0) @@ -7072,58 +7008,60 @@ fn current_transcript_pane_width( terminal: &mut Terminal, state: &TuiState, ) -> Result { - if !terminal_has_render_area(terminal)? { + let size = terminal.size()?; + if size.width == 0 || size.height == 0 { return Ok(0); } - terminal.autoresize()?; - let size = terminal.size()?; Ok(transcript_pane_width(size.width, size.height, state)) } -fn flush_pending_insert_for_width( +fn transcript_requires_rebuild(state: &TuiState, width: u16) -> bool { + width > 0 + && !state.transcript.is_empty() + && (state.transcript_needs_rebuild || state.transcript_rendered_width != width) +} + +fn flush_pending_insert( terminal: &mut Terminal, state: &mut TuiState, width: u16, - replay_width_change: bool, ) -> Result<(), B::Error> { - if !terminal_has_render_area(terminal)? { - return Ok(()); - } - let width_changed = state.transcript_rendered_width != width && !state.transcript.is_empty(); - if state.transcript_needs_rebuild { - // Explicit rebuilds (expand/collapse, error recovery) intentionally - // re-emit: their new content must become scrollable scrollback. - rebuild_transcript(terminal, state, width)?; - } else if width_changed && replay_width_change { - overwrite_transcript_tail(terminal, state, width)?; - } - if width_changed && state.transcript_rendered_width != width { + if renderable_transcript_chunk_rows(terminal, state, width)?.is_none() { return Ok(()); } - let raw: Vec = std::mem::take(&mut state.pending_insert); - let mut items: Vec = merge_consecutive_tools(raw); - if let Err(err) = flush_prepared_items(terminal, state, &mut items, width) { - state.pending_insert = items; - return Err(err); + if !state.prepared_insert_retry.is_empty() { + let mut retry = std::mem::take(&mut state.prepared_insert_retry); + match flush_prepared_items(terminal, state, &mut retry, width) { + Ok(true) => {} + Ok(false) => { + state.prepared_insert_retry = retry; + return Ok(()); + } + Err(err) => { + state.prepared_insert_retry = retry; + return Err(err); + } + } } - Ok(()) -} -fn flush_pending_insert( - terminal: &mut Terminal, - state: &mut TuiState, - width: u16, -) -> Result<(), B::Error> { - flush_pending_insert_for_width(terminal, state, width, true) + let mut items = std::mem::take(&mut state.pending_insert); + prepare_pending_items(state, &mut items); + match flush_prepared_items(terminal, state, &mut items, width) { + Ok(true) => Ok(()), + Ok(false) => { + state.prepared_insert_retry = items; + Ok(()) + } + Err(err) => { + state.prepared_insert_retry = items; + Err(err) + } + } } -fn flush_prepared_items( - terminal: &mut Terminal, - state: &mut TuiState, - items: &mut Vec, - width: u16, -) -> Result<(), B::Error> { +fn prepare_pending_items(state: &mut TuiState, items: &mut Vec) { + *items = merge_consecutive_tools(std::mem::take(items)); mark_retry_cycles(items); // Inline viewport output is real terminal scrollback: already-inserted lines cannot be // rewritten without appending another copy of the transcript. Keep grouping within the @@ -7146,22 +7084,35 @@ fn flush_prepared_items( if !expansion_active { sync_last_expandable(state, items); } +} + +fn flush_prepared_items( + terminal: &mut Terminal, + state: &mut TuiState, + items: &mut Vec, + width: u16, +) -> Result { + let Some(chunk_rows) = renderable_transcript_chunk_rows(terminal, state, width)? else { + return Ok(false); + }; if items.is_empty() { - return Ok(()); + return Ok(true); } let mut tool_tint_parity = state.tool_tint_parity; - let inserted_rows = - insert_transcript_items(terminal, state, items, width, &mut tool_tint_parity)?; + insert_transcript_items( + terminal, + state, + items, + width, + chunk_rows, + &mut tool_tint_parity, + )?; state.tool_tint_parity = tool_tint_parity; state.transcript.append(items); state.transcript_rendered_width = width; - state.transcript_rendered_rows = state - .transcript_rendered_rows - .saturating_add(inserted_rows.min(u32::from(u16::MAX)) as u16) - .min(terminal.get_frame().area().top()); - Ok(()) + Ok(true) } fn set_last_tool_expanded(items: &mut [Line_], name: &str, expanded: bool) -> bool { @@ -8672,7 +8623,10 @@ fn handle_login_input_key( }; state.clear_slash_completion_selection(); if non_secret_command { - let _ = agent_input.send(FromTui::Submit(text)); + let _ = agent_input.send(FromTui::Submit { + text, + pane_width: state.transcript_area.width, + }); } else { let _ = agent_input.send(FromTui::LoginInput(text)); } @@ -8940,10 +8894,12 @@ fn handle_key( let next_expanded = !block.expanded; let updated_pending = set_last_tool_expanded(&mut state.pending_insert, &name, next_expanded); + let updated_retry = + set_last_tool_expanded(&mut state.prepared_insert_retry, &name, next_expanded); let updated_transcript = set_last_tool_expanded(&mut state.transcript, &name, next_expanded); - if updated_pending || updated_transcript { - state.transcript_needs_rebuild = true; + if updated_pending || updated_retry || updated_transcript { + state.transcript_needs_rebuild |= updated_transcript; state.jump_transcript_to_bottom(); state.status = if next_expanded { "expanded".to_string() @@ -9106,7 +9062,10 @@ fn handle_key( state.history_idx = None; state.clear_input(); state.clear_slash_completion_selection(); - let _ = agent_input.send(FromTui::Submit(text)); + let _ = agent_input.send(FromTui::Submit { + text, + pane_width: state.transcript_area.width, + }); } (KeyCode::PageUp, _) => { let step = state.transcript_visible_lines.saturating_sub(2).max(1) as isize; @@ -9550,7 +9509,10 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { queue_welcome_banner(&mut state, banner); if let Some(task) = initial_task { state.queue(Line_::User(task.clone())); - let _ = in_tx.send(FromTui::Submit(task)); + let _ = in_tx.send(FromTui::Submit { + text: task, + pane_width: 0, + }); } // Move agent into a task; communicate via channels @@ -9564,7 +9526,8 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { let handle = tokio::spawn(async move { while let Some(cmd) = cmd_rx.recv().await { match cmd { - FromTui::Submit(text) => { + FromTui::Submit { text, pane_width } => { + agent.slash_render_width = (pane_width > 0).then_some(usize::from(pane_width)); if crate::is_slash_command(&text) { let trimmed = text.trim(); if let Some(parsed) = parse_compact_slash(trimmed) { @@ -9710,7 +9673,6 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { let tick = Duration::from_millis(80); let mut last_tick = Instant::now(); - let mut resize_replay = TranscriptResizeReplay::new(last_tick); while !state.quit { if state.backend_viewer_open { @@ -9740,13 +9702,12 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { } if terminal_has_render_area(&terminal)? { let width = current_transcript_pane_width(&mut terminal, &state)?; - let replay_width_change = resize_replay.should_replay( - width, - state.transcript_rendered_width, - !state.transcript.is_empty(), - Instant::now(), - ); - flush_pending_insert_for_width(&mut terminal, &mut state, width, replay_width_change)?; + if transcript_requires_rebuild(&state, width) { + purge_and_rebuild_transcript(&mut terminal, &mut state, width)?; + } + if !transcript_requires_rebuild(&state, width) { + flush_pending_insert(&mut terminal, &mut state, width)?; + } terminal.draw(|f| draw(f, &mut state))?; state.frame_count = state.frame_count.wrapping_add(1); } @@ -9829,7 +9790,12 @@ pub async fn run(mut agent: Agent, initial_task: Option) -> Result<()> { } if terminal_has_render_area(&terminal).unwrap_or(false) { if let Ok(width) = current_transcript_pane_width(&mut terminal, &state) { - let _ = flush_pending_insert(&mut terminal, &mut state, width); + if transcript_requires_rebuild(&state, width) { + let _ = purge_and_rebuild_transcript(&mut terminal, &mut state, width); + } + if !transcript_requires_rebuild(&state, width) { + let _ = flush_pending_insert(&mut terminal, &mut state, width); + } } let _ = terminal.draw(|f| draw(f, &mut state)); } @@ -12474,6 +12440,49 @@ mod tests { assert!(summary.contains("lines 6410-6413"), "{summary}"); } + #[test] + fn merge_consecutive_tools_is_idempotent_for_prepared_retry_batches() { + let mut items = merge_consecutive_tools(vec![ + tool_line( + "#1.44", + "read_file", + "read_file: src/main.rs (offset=6410, limit=2)", + Some(true), + "6410\talpha\n6411\tbravo\n", + ), + tool_line( + "#1.45", + "read_file", + "read_file: src/main.rs (offset=6412, limit=2)", + Some(true), + "6412\tcharlie\n6413\tdelta\n", + ), + ]); + let prepared = items.clone(); + assert!(merge_consecutive_tools(prepared.clone()) == prepared); + + items.push(tool_line( + "#1.46", + "read_file", + "read_file: src/main.rs (offset=6414, limit=2)", + Some(true), + "6414\techo\n6415\tfoxtrot\n", + )); + let merged = merge_consecutive_tools(items); + let Line_::Tool { + group_count, + group_lines, + group_chunks, + .. + } = &merged[0] + else { + panic!("expected grouped tool"); + }; + assert_eq!(*group_count, 3); + assert_eq!(*group_lines, 6); + assert_eq!(group_chunks.len(), 3); + } + #[test] fn density_separator_renders_every_tenth_tool_call() { let mut item = tool_line("#1.10", "bash", "bash: true", Some(true), "ok"); @@ -12567,7 +12576,7 @@ mod tests { } #[test] - fn slash_event_clears_live_preview_state() { + fn generic_slash_event_clears_live_preview_state_and_uses_faded_info() { let mut state = TuiState::new( "test-model".to_string(), model_context_window("test-model"), @@ -12607,6 +12616,25 @@ mod tests { ); } + #[test] + fn structured_slash_event_keeps_unadorned_layout_line() { + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + + state.apply_event(AgentEvent::StructuredSlash( + "Heading\n /help show help".to_string(), + )); + + assert!(state.pending_insert.last().is_some_and( + |line| matches!(line, Line_::Slash(msg) if msg == "Heading\n /help show help") + )); + } + #[test] fn user_cards_render_markdown() { let text = line_to_text(&Line_::User("# Heading\n- item".to_string()), 120); @@ -13965,7 +13993,10 @@ mod tests { ); match submit_rx.try_recv() { - Ok(FromTui::Submit(text)) => assert_eq!(text, command), + Ok(FromTui::Submit { text, pane_width }) => { + assert_eq!(text, command); + assert_eq!(pane_width, state.transcript_area.width); + } _ => panic!("expected ordinary slash-command submission"), } assert!(submit_rx.try_recv().is_err()); @@ -14680,17 +14711,18 @@ mod tests { } #[test] - fn failed_pending_insert_keeps_logical_transcript_state() { + fn failed_pending_insert_retries_prepared_batch_without_merging_new_output() { use ratatui::backend::{ClearType, TestBackend, WindowSize}; use ratatui::buffer::Cell; use ratatui::layout::{Position, Size}; use ratatui::{Terminal, TerminalOptions, Viewport}; - struct FailClearBackend { + struct FailOnceClearBackend { inner: TestBackend, + fail_clear: bool, } - impl Backend for FailClearBackend { + impl Backend for FailOnceClearBackend { type Error = io::Error; fn draw<'a, I>(&mut self, content: I) -> io::Result<()> @@ -14728,8 +14760,14 @@ mod tests { self.inner.clear().map_err(|error| match error {}) } - fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> { - Err(io::Error::other("injected clear failure")) + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + if std::mem::replace(&mut self.fail_clear, false) { + Err(io::Error::other("injected clear failure")) + } else { + self.inner + .clear_region(clear_type) + .map_err(|error| match error {}) + } } fn size(&self) -> io::Result { @@ -14745,8 +14783,9 @@ mod tests { } } - let backend = FailClearBackend { + let backend = FailOnceClearBackend { inner: TestBackend::new(80, 20), + fail_clear: true, }; let mut terminal = Terminal::with_options( backend, @@ -14762,28 +14801,121 @@ mod tests { ApprovalProfile::Ask, ThinkingEffort::Medium, ); - state.queue(Line_::Assistant { - text: "must remain pending".to_string(), - dim_prefix: false, - }); + state.pending_insert = vec![ + tool_line( + "#1.44", + "read_file", + "read_file: src/main.rs (offset=6410, limit=2)", + Some(true), + "6410\talpha\n6411\tbravo\n", + ), + tool_line( + "#1.45", + "read_file", + "read_file: src/main.rs (offset=6412, limit=2)", + Some(true), + "6412\tcharlie\n6413\tdelta\n", + ), + ]; let error = flush_pending_insert(&mut terminal, &mut state, 80) .expect_err("injected clear failure must escape"); assert!(error.to_string().contains("injected clear failure")); assert!(state.transcript.is_empty()); + assert!(state.pending_insert.is_empty()); + assert!(matches!( + state.prepared_insert_retry.as_slice(), + [Line_::Tool { + group_count: 2, + group_chunks, + .. + }] if group_chunks.len() == 2 + )); + + state.queue(tool_line( + "#1.46", + "read_file", + "read_file: src/main.rs (offset=6414, limit=2)", + Some(true), + "6414\techo\n6415\tfoxtrot\n", + )); + assert_eq!(state.prepared_insert_retry.len(), 1); assert!(matches!( state.pending_insert.as_slice(), - [Line_::Assistant { text, .. }] if text == "must remain pending" + [Line_::Blank, Line_::Tool { group_count: 1, .. }] + )); + + flush_pending_insert(&mut terminal, &mut state, 80).expect("retry prepared batch"); + + assert!(state.prepared_insert_retry.is_empty()); + assert!(state.pending_insert.is_empty()); + assert!(matches!( + state.transcript.as_slice(), + [ + Line_::Tool { group_count: 2, .. }, + Line_::Blank, + Line_::Tool { group_count: 1, .. } + ] )); } #[test] - fn zero_sized_terminal_defers_pending_insert_until_recovery() { + fn zero_sized_or_status_only_terminal_defers_pending_insert_until_recovery() { use ratatui::backend::TestBackend; use ratatui::{Terminal, TerminalOptions, Viewport}; - let backend = TestBackend::new(80, 0); + for minimized_height in [0, 1] { + let backend = TestBackend::new(80, minimized_height); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(8), + }, + ) + .expect("terminal"); + let mut state = TuiState::new( + "test-model".to_string(), + model_context_window("test-model"), + ".".to_string(), + ApprovalProfile::Ask, + ThinkingEffort::Medium, + ); + state.queue(Line_::Assistant { + text: "defer while minimized".to_string(), + dim_prefix: false, + }); + + let width = + current_transcript_pane_width(&mut terminal, &state).expect("minimized pane width"); + assert_eq!(width, 0); + flush_pending_insert(&mut terminal, &mut state, width) + .expect("minimized flush must defer"); + assert!(state.transcript.is_empty()); + assert!(matches!( + state.pending_insert.as_slice(), + [Line_::Assistant { text, .. }] if text == "defer while minimized" + )); + + terminal.backend_mut().resize(80, 20); + terminal.autoresize().expect("restore terminal size"); + let width = + current_transcript_pane_width(&mut terminal, &state).expect("restored width"); + flush_pending_insert(&mut terminal, &mut state, width).expect("flush after restore"); + assert!(state.pending_insert.is_empty()); + assert!(matches!( + state.transcript.as_slice(), + [Line_::Assistant { text, .. }] if text == "defer while minimized" + )); + } + } + + #[test] + fn replay_deferred_after_mid_frame_minimize_remains_required() { + use ratatui::backend::TestBackend; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(80, 20); let mut terminal = Terminal::with_options( backend, TerminalOptions { @@ -14799,28 +14931,27 @@ mod tests { ThinkingEffort::Medium, ); state.queue(Line_::Assistant { - text: "defer while minimized".to_string(), + text: "logical history survives minimize".to_string(), dim_prefix: false, }); + let width = current_transcript_pane_width(&mut terminal, &state).expect("initial width"); + flush_pending_insert(&mut terminal, &mut state, width).expect("initial flush"); + state.transcript_needs_rebuild = true; - flush_pending_insert_for_width(&mut terminal, &mut state, 1, true) - .expect("zero-sized flush must defer"); - assert!(state.transcript.is_empty()); - assert!(matches!( - state.pending_insert.as_slice(), - [Line_::Assistant { text, .. }] if text == "defer while minimized" - )); + terminal.backend_mut().resize(80, 0); + rebuild_transcript_from_origin(&mut terminal, &mut state, width) + .expect("zero-height replay must defer"); + + assert_eq!(state.transcript_rendered_width, width); + assert!(state.transcript_needs_rebuild); + assert!(transcript_requires_rebuild(&state, width)); + assert_eq!(state.transcript.len(), 1); terminal.backend_mut().resize(80, 20); terminal.autoresize().expect("restore terminal size"); - let width = current_transcript_pane_width(&mut terminal, &state).expect("restored width"); - flush_pending_insert_for_width(&mut terminal, &mut state, width, true) - .expect("flush after restore"); - assert!(state.pending_insert.is_empty()); - assert!(matches!( - state.transcript.as_slice(), - [Line_::Assistant { text, .. }] if text == "defer while minimized" - )); + rebuild_transcript_from_origin(&mut terminal, &mut state, width).expect("restored replay"); + assert!(!state.transcript_needs_rebuild); + assert!(!transcript_requires_rebuild(&state, width)); } #[test] @@ -14890,11 +15021,13 @@ mod tests { ThinkingEffort::Medium, ); let mut tint = false; + let chunk_rows = chunked_terminal.size().expect("chunk terminal size").height; insert_transcript_items( &mut chunked_terminal, &mut chunked_state, std::slice::from_ref(&item), width, + chunk_rows, &mut tint, ) .expect("chunked insert"); @@ -14911,52 +15044,7 @@ mod tests { } #[test] - fn resize_replay_uses_leading_trailing_and_forced_bounds() { - let start = Instant::now(); - let mut replay = TranscriptResizeReplay::new(start); - - assert!(replay.should_replay(100, 120, true, start)); - assert!(!replay.should_replay(90, 100, true, start + Duration::from_millis(25))); - assert!(!replay.should_replay(80, 100, true, start + Duration::from_millis(80))); - assert!(!replay.should_replay(80, 100, true, start + Duration::from_millis(150))); - assert!(replay.should_replay(80, 100, true, start + Duration::from_millis(200))); - - let mut continuous = TranscriptResizeReplay::new(start); - assert!(continuous.should_replay(100, 120, true, start)); - assert!(!continuous.should_replay(90, 100, true, start + Duration::from_millis(100))); - assert!(!continuous.should_replay(80, 100, true, start + Duration::from_millis(200))); - assert!(continuous.should_replay(70, 100, true, start + RESIZE_REPLAY_MAX_LATENCY)); - assert!(!continuous.should_replay( - 60, - 70, - true, - start + RESIZE_REPLAY_MAX_LATENCY + Duration::from_millis(25), - )); - } - - #[test] - fn resize_replay_without_history_never_defers_viewport_work() { - let start = Instant::now(); - let mut replay = TranscriptResizeReplay::new(start); - - assert!(replay.should_replay(80, 0, false, start)); - assert!(replay.should_replay(70, 0, false, start + Duration::from_millis(10))); - assert!(!replay.burst_active); - } - - #[test] - fn resize_rebuilds_existing_transcript_for_current_pane_width() { - use ratatui::backend::TestBackend; - use ratatui::{Terminal, TerminalOptions, Viewport}; - - let backend = TestBackend::new(90, 20); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(8), - }, - ) - .expect("terminal"); + fn every_effective_transcript_width_change_requires_rebuild() { let mut state = TuiState::new( "test-model".to_string(), model_context_window("test-model"), @@ -14964,130 +15052,23 @@ mod tests { ApprovalProfile::Ask, ThinkingEffort::Medium, ); - state.queue(Line_::Assistant { - text: "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu ".repeat(5), - dim_prefix: false, - }); - - let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); - flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); - assert_eq!(state.transcript.len(), 1); - assert_eq!(state.transcript_rendered_width, wide); - - terminal.backend_mut().resize(38, 20); - let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); - assert!(narrow < wide); - flush_pending_insert(&mut terminal, &mut state, narrow).expect("narrow flush"); - assert_eq!(state.transcript.len(), 1); - assert_eq!(state.transcript_rendered_width, narrow); - assert!(!state.transcript_needs_rebuild); - - let item = state.transcript[0].clone(); - let key = line_cache_key(&item); - let wide_render_width = transcript_render_width(wide); - let narrow_render_width = transcript_render_width(narrow); - { - let entry = state.render_cache.get(&key).expect("cache entry"); - assert!(entry.renders.contains_key(&wide_render_width)); - assert!(entry.renders.contains_key(&narrow_render_width)); - assert!( - entry.renders[&narrow_render_width].height - >= entry.renders[&wide_render_width].height - ); - } - - terminal.backend_mut().resize(110, 20); - let wider = current_transcript_pane_width(&mut terminal, &state).expect("wider width"); - assert!(wider > narrow); - flush_pending_insert(&mut terminal, &mut state, wider).expect("wider flush"); - assert_eq!(state.transcript.len(), 1); - assert_eq!(state.transcript_rendered_width, wider); - assert!(!state.transcript_needs_rebuild); - let wider_render_width = transcript_render_width(wider); - let entry = state.render_cache.get(&key).expect("cache entry"); - assert!(entry.renders.contains_key(&wider_render_width)); - } - - #[test] - fn short_transcript_resize_replay_does_not_grow_scrollback() { - use ratatui::backend::TestBackend; - use ratatui::{Terminal, TerminalOptions, Viewport}; - - let backend = TestBackend::new(90, 20); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(8), - }, - ) - .expect("terminal"); - let mut state = TuiState::new( - "test-model".to_string(), - model_context_window("test-model"), - ".".to_string(), - ApprovalProfile::Ask, - ThinkingEffort::Medium, - ); - state.queue(Line_::Assistant { - text: "x".repeat(120), + assert!(!transcript_requires_rebuild(&state, 60)); + state.transcript.push(Line_::Assistant { + text: "history".to_string(), dim_prefix: false, }); - let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); - flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); - let owned_rows = state.transcript_rendered_rows; - assert!(owned_rows > 0 && owned_rows < terminal.size().unwrap().height); - - terminal.backend_mut().resize(28, 20); - let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); - let item = state.transcript[0].clone(); - let (_, narrow_rows) = cached_transcript_render(&mut state, &item, narrow); - assert!( - narrow_rows > owned_rows, - "test fixture must grow on narrow wrap: wide_rows={owned_rows} narrow_rows={narrow_rows} wide={wide} narrow={narrow} viewport_top={}", - terminal.get_frame().area().top() - ); - let scrollback_before = terminal.backend().scrollback().clone(); - flush_pending_insert(&mut terminal, &mut state, narrow).expect("narrow flush"); - - assert_eq!(state.transcript_rendered_width, narrow); - assert!( - state.transcript_rendered_rows > owned_rows, - "resize allocation did not grow: wide_rows={owned_rows} narrow_rows={narrow_rows} allocated={} viewport_top={}", - state.transcript_rendered_rows, - terminal.get_frame().area().top() - ); - assert_eq!(*terminal.backend().scrollback(), scrollback_before); - } - - #[test] - fn resize_tail_preparation_renders_only_screen_bounded_suffix() { - let mut state = TuiState::new( - "test-model".to_string(), - model_context_window("test-model"), - ".".to_string(), - ApprovalProfile::Ask, - ThinkingEffort::Medium, - ); - let items: Vec = (0..200) - .map(|index| Line_::Assistant { - text: format!("history block {index}"), - dim_prefix: false, - }) - .collect(); - - let (tail, retained_rows) = prepare_transcript_tail(&mut state, &items, 80, 12); - assert_eq!(retained_rows, 12); - assert!(tail.len() <= 12); - assert!(tail.len() < items.len()); - assert!( - state.render_cache.len() <= 12, - "resize replay should not re-render the full transcript" - ); + state.transcript_rendered_width = 90; + assert!(!transcript_requires_rebuild(&state, 0)); + assert!(transcript_requires_rebuild(&state, 60)); + state.transcript_rendered_width = 60; + assert!(!transcript_requires_rebuild(&state, 60)); + state.transcript_needs_rebuild = true; + assert!(transcript_requires_rebuild(&state, 60)); } #[test] - fn resize_replay_overwrites_visible_tail_without_scrollback_growth() { + fn width_change_rebuilds_complete_logical_transcript_before_pending_output() { use ratatui::backend::TestBackend; use ratatui::{Terminal, TerminalOptions, Viewport}; @@ -15116,31 +15097,43 @@ mod tests { } let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); - assert_eq!(state.transcript_rendered_width, wide); - assert!( - terminal.backend().scrollback().area.height > 0, - "history must overflow into scrollback for this regression test" - ); + assert!(terminal.backend().scrollback().area.height > 0); + let logical_blocks = state.transcript.len(); - terminal.backend_mut().resize(60, 20); + terminal.backend_mut().resize(52, 20); let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); - assert!(narrow < wide); - // TestBackend::resize reflows the scrollback buffer to the new width, - // so snapshot it after the resize settles and immediately before the - // replay flush: the replay itself must leave scrollback untouched. - let scrollback_before = terminal.backend().scrollback().clone(); - flush_pending_insert(&mut terminal, &mut state, narrow).expect("narrow flush"); + assert!(transcript_requires_rebuild(&state, narrow)); + state.queue(Line_::Assistant { + text: "queued during resize 界🙂 café".to_string(), + dim_prefix: false, + }); + + terminal.backend_mut().purge_scrollback(); + rebuild_transcript_from_origin(&mut terminal, &mut state, narrow) + .expect("full narrow rebuild"); + flush_pending_insert(&mut terminal, &mut state, narrow).expect("pending narrow flush"); + assert_eq!(state.transcript_rendered_width, narrow); assert!(!state.transcript_needs_rebuild); - assert_eq!( - *terminal.backend().scrollback(), - scrollback_before, - "width replay must repaint in place, not append history to scrollback" - ); + assert!(!transcript_requires_rebuild(&state, narrow)); + assert_eq!(state.transcript.len(), logical_blocks + 2); + let rendered = terminal + .backend() + .scrollback() + .content + .iter() + .chain(terminal.backend().buffer().content.iter()) + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("block 0"), "{rendered}"); + assert!(rendered.contains("block 29"), "{rendered}"); + assert!(rendered.contains("queued during resize"), "{rendered}"); + assert!(rendered.contains('界'), "{rendered}"); + assert!(rendered.contains('🙂'), "{rendered}"); } #[test] - fn pending_insert_waits_for_settled_transcript_width() { + fn simultaneous_width_and_height_shrink_replays_from_origin() { use ratatui::backend::TestBackend; use ratatui::{Terminal, TerminalOptions, Viewport}; @@ -15159,41 +15152,38 @@ mod tests { ApprovalProfile::Ask, ThinkingEffort::Medium, ); - state.queue(Line_::Assistant { - text: "first block".to_string(), - dim_prefix: false, - }); + for index in 0..40 { + state.queue(Line_::Assistant { + text: format!("full-screen shrink block {index:02} with wrapped text"), + dim_prefix: false, + }); + } let wide = current_transcript_pane_width(&mut terminal, &state).expect("wide width"); flush_pending_insert(&mut terminal, &mut state, wide).expect("wide flush"); + let logical_len = state.transcript.len(); - terminal.backend_mut().resize(50, 20); + terminal.backend_mut().resize(36, 6); let narrow = current_transcript_pane_width(&mut terminal, &state).expect("narrow width"); - state.queue(Line_::Assistant { - text: "queued during resize".to_string(), - dim_prefix: false, - }); - state.input = "live resize".to_string(); - state.cursor = state.input.len(); - flush_pending_insert_for_width(&mut terminal, &mut state, narrow, false) - .expect("deferred resize flush"); - let mut frame_width = 0; - terminal - .draw(|frame| { - frame_width = frame.area().width; - draw(frame, &mut state); - }) - .expect("draw latest viewport width"); - assert_eq!(frame_width, 50); - assert_eq!(state.input_area.width, 50); - assert_eq!(state.transcript.len(), 1); - assert_eq!(state.pending_insert.len(), 2); - assert_eq!(state.transcript_rendered_width, wide); + assert!(transcript_requires_rebuild(&state, narrow)); + terminal.backend_mut().purge_scrollback(); + rebuild_transcript_from_origin(&mut terminal, &mut state, narrow) + .expect("full-screen rebuild"); - flush_pending_insert_for_width(&mut terminal, &mut state, narrow, true) - .expect("settled resize flush"); - assert!(state.pending_insert.is_empty()); - assert_eq!(state.transcript.len(), 3); + assert_eq!(state.transcript.len(), logical_len); assert_eq!(state.transcript_rendered_width, narrow); + assert_eq!(terminal.get_frame().area().top(), 0); + assert_eq!(terminal.get_frame().area().height, 6); + assert!(terminal.backend().scrollback().area.height > 0); + let rendered = terminal + .backend() + .scrollback() + .content + .iter() + .chain(terminal.backend().buffer().content.iter()) + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("block 00"), "{rendered}"); + assert!(rendered.contains("block 39"), "{rendered}"); } #[test] @@ -15973,6 +15963,40 @@ mod tests { assert!(joined.contains("7")); } + #[test] + fn generic_slash_lines_use_faded_info_presentation() { + let text = line_to_text(&Line_::Info("thinking effort: high".to_string()), 80); + assert_eq!(flatten_lines(&text), vec!["• thinking effort: high"]); + let style = span_style_for(&text, "thinking effort: high").expect("info style"); + assert_eq!(style.fg, Some(Color::DarkGray)); + assert!(style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn slash_lines_keep_structured_layout_without_ansi_or_info_bullets() { + let plain = line_to_text( + &Line_::Slash("Heading\n /help show help".to_string()), + 80, + ); + assert_eq!( + flatten_lines(&plain), + vec!["Heading", " /help show help"] + ); + + let styled = line_to_text( + &Line_::Slash("\x1b[1mHeading\x1b[0m\n /help show help".to_string()), + 80, + ); + assert_eq!( + flatten_lines(&styled), + vec!["Heading", " /help show help"] + ); + assert!( + span_style_for(&styled, "Heading") + .is_some_and(|style| style.add_modifier.contains(Modifier::BOLD)) + ); + } + #[test] fn ansi_info_lines_strip_escape_codes_without_reemitting_raw_escapes() { let text = line_to_text(&Line_::Info("\x1b[1mBold\x1b[0m plain".to_string()), 80); diff --git a/tests/tui_smoke.rs b/tests/tui_smoke.rs index 813e687..bd17bd5 100644 --- a/tests/tui_smoke.rs +++ b/tests/tui_smoke.rs @@ -91,6 +91,7 @@ fn tui_resize_keeps_inline_session_responsive_and_dsr_bounded() { pty.pump_for(&mut child, Duration::from_millis(160)) .expect("settle populated transcript"); let before_resize = pty.terminal_io_counts(); + let before_resize_capture = pty.capture.len(); let resize_burst = [ (100, 32), @@ -115,9 +116,17 @@ fn tui_resize_keeps_inline_session_responsive_and_dsr_bounded() { narrow_resize.dsr_queries <= resize_burst.len().saturating_add(1), "resize burst issued more than one cursor query per OS resize: {narrow_resize:?}" ); + assert!( + narrow_resize.clear_all > 0 && narrow_resize.clear_all <= resize_burst.len(), + "resize burst must rebuild at least once and at most once per effective width: {narrow_resize:?}" + ); assert_eq!( - narrow_resize.clear_all, 0, - "narrow resize cleared the whole inline screen: {narrow_resize:?}" + narrow_resize.purge_scrollback, narrow_resize.clear_all, + "every full replay must pair one scrollback purge with one display clear: {narrow_resize:?}" + ); + assert_resize_resets_clear_before_purge_and_replay_one_banner( + &pty.capture[before_resize_capture..], + narrow_resize.clear_all, ); let rendered_row_budget = TRANSCRIPT_BLOCKS .saturating_mul(ROWS_PER_SUBMITTED_COMMAND) @@ -125,16 +134,17 @@ fn tui_resize_keeps_inline_session_responsive_and_dsr_bounded() { .saturating_add(BANNER_ROW_BUDGET); let narrow_clear_bound = resize_clear_after_bound( rendered_row_budget, - 2, + resize_burst.len(), usize::from(TUI_NARROW_ROWS), resize_burst.len(), ); assert!( narrow_resize.clear_after_cursor <= narrow_clear_bound, - "narrow resize exceeded its two-replay, terminal-height chunk bound ({narrow_clear_bound}): {narrow_resize:?}" + "narrow resize exceeded its per-width, terminal-height chunk bound ({narrow_clear_bound}): {narrow_resize:?}" ); let before_wide = pty.terminal_io_counts(); + let before_wide_capture = pty.capture.len(); pty.resize(&child, TUI_WIDE_COLS, TUI_WIDE_ROWS) .expect("resize wide"); pty.pump_for(&mut child, Duration::from_millis(500)) @@ -146,8 +156,16 @@ fn tui_resize_keeps_inline_session_responsive_and_dsr_bounded() { "wide resize issued transcript-proportional cursor queries: {wide_resize:?}" ); assert_eq!( - wide_resize.clear_all, 0, - "wide resize cleared the whole inline screen: {wide_resize:?}" + wide_resize.clear_all, 1, + "one effective wide resize must clear the visible display once before full replay: {wide_resize:?}" + ); + assert_eq!( + wide_resize.purge_scrollback, wide_resize.clear_all, + "the wide replay must pair its scrollback purge and display clear: {wide_resize:?}" + ); + assert_resize_resets_clear_before_purge_and_replay_one_banner( + &pty.capture[before_wide_capture..], + wide_resize.clear_all, ); let wide_clear_bound = resize_clear_after_bound(rendered_row_budget, 1, usize::from(TUI_WIDE_ROWS), 1); @@ -497,15 +515,58 @@ fn assert_no_crash_text(visible: &str) { } } +fn byte_offsets(haystack: &[u8], needle: &[u8]) -> Vec { + if needle.is_empty() { + return Vec::new(); + } + haystack + .windows(needle.len()) + .enumerate() + .filter_map(|(index, window)| (window == needle).then_some(index)) + .collect() +} + +fn assert_resize_resets_clear_before_purge_and_replay_one_banner( + capture: &[u8], + expected_resets: usize, +) { + let clears = byte_offsets(capture, b"\x1b[2J"); + let purges = byte_offsets(capture, b"\x1b[3J"); + assert_eq!(clears.len(), expected_resets, "unexpected display clears"); + assert_eq!( + purges.len(), + expected_resets, + "unexpected scrollback purges" + ); + + for index in 0..expected_resets { + assert!( + clears[index] < purges[index] && (index == 0 || purges[index - 1] < clears[index]), + "reset {index} did not clear the visible display before purging scrollback" + ); + let segment_end = clears.get(index + 1).copied().unwrap_or(capture.len()); + let replay = strip_ansi(&String::from_utf8_lossy( + &capture[purges[index] + b"\x1b[3J".len()..segment_end], + )); + assert_eq!( + replay.matches("◆ Dext v").count(), + 1, + "reset {index} did not replay exactly one Dext intro: {replay:?}" + ); + } +} + fn resize_clear_after_bound( rendered_row_budget: usize, - replay_count: usize, + max_replay_count: usize, chunk_rows: usize, resize_events: usize, ) -> usize { - let chunks_per_replay = rendered_row_budget.div_ceil(chunk_rows.max(1)); - replay_count - .saturating_mul(chunks_per_replay.saturating_add(1)) + let item_boundary_chunks = rendered_row_budget + .div_ceil(chunk_rows.max(1)) + .saturating_add(1); + max_replay_count + .saturating_mul(item_boundary_chunks.saturating_add(1)) .saturating_add(resize_events) } @@ -513,6 +574,7 @@ fn resize_clear_after_bound( struct TerminalIoCounts { dsr_queries: usize, clear_all: usize, + purge_scrollback: usize, clear_after_cursor: usize, clear_current_line: usize, } @@ -524,6 +586,9 @@ impl std::ops::Sub for TerminalIoCounts { Self { dsr_queries: self.dsr_queries.saturating_sub(earlier.dsr_queries), clear_all: self.clear_all.saturating_sub(earlier.clear_all), + purge_scrollback: self + .purge_scrollback + .saturating_sub(earlier.purge_scrollback), clear_after_cursor: self .clear_after_cursor .saturating_sub(earlier.clear_after_cursor), @@ -667,6 +732,7 @@ impl Pty { TerminalIoCounts { dsr_queries: self.dsr_queries, clear_all: count_bytes(&self.capture, b"\x1b[2J"), + purge_scrollback: count_bytes(&self.capture, b"\x1b[3J"), clear_after_cursor: count_bytes(&self.capture, b"\x1b[J") + count_bytes(&self.capture, b"\x1b[0J"), clear_current_line: count_bytes(&self.capture, b"\x1b[2K"), diff --git a/vendor/ratatui-core/DEXT_PATCH.md b/vendor/ratatui-core/DEXT_PATCH.md index 96f7f9f..0a9ebc1 100644 --- a/vendor/ratatui-core/DEXT_PATCH.md +++ b/vendor/ratatui-core/DEXT_PATCH.md @@ -2,7 +2,7 @@ Upstream: `ratatui-core 0.1.2` from crates.io. -Dext uses this exact vendored source through the root `[patch.crates-io]` entry. The patch preserves Dext's inline viewport and native-scrollback behavior on the current Ratatui stack. It must remain small and should be removed when a released upstream version passes Dext's PTY and WSL checks without it. +Dext uses this exact vendored source through the root `[patch.crates-io]` entry. The patch preserves Dext's inline viewport and ordinary native-scrollback behavior on the current Ratatui stack, with the documented full-ownership resize rebuild. It must remain small and should be removed when a released upstream version passes Dext's PTY and WSL checks without it. ## Patch hunks @@ -13,12 +13,12 @@ Dext uses this exact vendored source through the root `[patch.crates-io]` entry. - `src/terminal/inline.rs` - The no-scrolling-regions `insert_before` fallback calls `clear_viewport` directly instead of the public cursor-preserving `clear`. - Reason: this path has already positioned the cursor and does not need another backend round trip for every insertion chunk. - - Adds `Terminal::overwrite_before`, which draws the bottom rows of a rendered buffer directly above the inline viewport with absolute writes, returns the number of rows actually replaced, and restores the backend cursor to the viewport origin without altering Ratatui's tracked frame cursor. - - Reason: inline scrollback is append-only, so replaying a resized transcript through `insert_before` permanently appends a duplicate history copy to terminal scrollback; Dext instead allocates only any blank narrow-wrap row delta, then uses the overwrite to repaint the screen-bounded visible suffix in place. + - Adds `Terminal::reset_inline_viewport`, which clears the visible display, resets both diff buffers, anchors an inline viewport at the terminal origin, and avoids a cursor query. + - Reason: Dext clears the still-visible stale-width display before purging scrollback on every effective transcript-pane width change; the complete logical transcript must then replay from a known origin without racing Crossterm's input reader or retaining a duplicate intro. - `src/terminal/resize.rs` - Horizontal shrink retains `ClearType::All` for fullscreen/fixed viewports but skips it for inline viewports. - - Reason: a whole-display clear flashes native scrollback and exposes transcript replay. The following viewport clear plus complete draw repaints Dext's inline surface. + - Reason: OS-level horizontal shrink must not perform an extra whole-display clear before Dext's owned synchronized clear/purge/full replay. Dext performs that replay immediately for every effective transcript-pane width change. ## Refresh procedure diff --git a/vendor/ratatui-core/src/backend/test.rs b/vendor/ratatui-core/src/backend/test.rs index b57fb37..a6ac2c1 100644 --- a/vendor/ratatui-core/src/backend/test.rs +++ b/vendor/ratatui-core/src/backend/test.rs @@ -134,6 +134,14 @@ impl TestBackend { &self.scrollback } + /// Clears the simulated terminal scrollback buffer. + pub fn purge_scrollback(&mut self) { + self.scrollback = Buffer::empty(Rect { + width: self.buffer.area.width, + ..Rect::ZERO + }); + } + /// Resizes the `TestBackend` to the specified width and height. pub fn resize(&mut self, width: u16, height: u16) { self.buffer.resize(Rect::new(0, 0, width, height)); diff --git a/vendor/ratatui-core/src/terminal/inline.rs b/vendor/ratatui-core/src/terminal/inline.rs index 5466be8..b41bc71 100644 --- a/vendor/ratatui-core/src/terminal/inline.rs +++ b/vendor/ratatui-core/src/terminal/inline.rs @@ -1,4 +1,4 @@ -use crate::backend::Backend; +use crate::backend::{Backend, ClearType}; use crate::buffer::{Buffer, Cell}; use crate::layout::{Position, Rect, Size}; use crate::terminal::{Terminal, Viewport}; @@ -119,53 +119,26 @@ impl Terminal { } } - /// Redraw content directly above the current inline viewport in place. + /// Clear the visible display and reset an inline viewport to the terminal origin. /// - /// Dext patch: inline resize replay must repaint the visible transcript tail at the new - /// terminal width. [`Terminal::insert_before`] scrolls the region above the viewport, so - /// replaying history through it appends a duplicate copy of that history to the terminal's - /// scrollback on every replay. This method instead draws the last `min(height, viewport_top)` - /// rows of the rendered buffer into the rows immediately above the viewport using plain - /// absolute-position writes: nothing scrolls, the viewport is untouched, and scrollback is - /// not mutated. - /// - /// When `height` exceeds the rows available above the viewport, the top of the rendered - /// buffer is skipped so the buffer's bottom rows land directly above the viewport. The - /// returned row count is the number of rows actually replaced; it can be smaller than - /// `height` when the viewport has fewer rows above it. Non-inline viewports and inline - /// viewports at the top return zero. - pub fn overwrite_before(&mut self, height: u16, draw_fn: F) -> Result - where - F: FnOnce(&mut Buffer), - { - if !matches!(self.viewport, Viewport::Inline(_)) { - return Ok(0); - } - let viewport_top = self.viewport_area.top(); - if height == 0 || viewport_top == 0 { - return Ok(0); - } - let area = Rect { - x: 0, - y: 0, - width: self.viewport_area.width, - height, + /// This is intended for applications that have deliberately purged terminal scrollback and + /// will immediately replay their complete logical transcript. It avoids a cursor query, resets + /// both diff buffers, and leaves non-inline viewports unchanged. + pub fn reset_inline_viewport(&mut self) -> Result<(), B::Error> { + let Viewport::Inline(requested_height) = self.viewport else { + return Ok(()); }; - let mut buffer = Buffer::empty(area); - draw_fn(&mut buffer); - let to_draw = height.min(viewport_top); - let skip_cells = usize::from(height - to_draw) * usize::from(area.width); - self.draw_lines( - viewport_top - to_draw, - to_draw, - &buffer.content[skip_cells..], - )?; - // Direct backend draws leave the hardware cursor on the rewritten history. Put it back in - // the viewport without changing Ratatui's tracked frame cursor; the next draw restores the - // application cursor through the normal render path. - self.backend - .set_cursor_position(self.viewport_area.as_position())?; - Ok(to_draw) + let size = self.backend.size()?; + let area = Rect::new(0, 0, size.width, size.height.min(requested_height)); + self.backend.set_cursor_position(Position::ORIGIN)?; + self.backend.clear_region(ClearType::All)?; + self.set_viewport_area(area); + self.buffers[0].reset(); + self.buffers[1].reset(); + self.last_known_area = size.into(); + self.last_known_cursor_pos = Position::ORIGIN; + self.backend.flush()?; + Ok(()) } /// Implement `Self::insert_before` using standard backend capabilities. @@ -472,6 +445,8 @@ pub(crate) fn compute_inline_size( #[cfg(test)] mod tests { + use alloc::format; + use crate::backend::{Backend, TestBackend}; use crate::layout::{Position, Rect, Size}; use crate::style::Style; @@ -533,24 +508,12 @@ mod tests { } #[test] - fn overwrite_before_redraws_above_viewport_without_scrollback() { - // Diagram (terminal 10x6, viewport = Inline(2) anchored at y=4): - // - // 0 old-0 <- redraw target rows - // 1 old-1 - // 2 old-2 - // 3 old-3 - // 4 viewport-a <- viewport (must stay untouched) - // 5 viewport-b - // - // A 5-row buffer is drawn above a 4-row region: the top buffer row is - // skipped, the remaining rows land at y=0..4, and nothing scrolls into - // the scrollback buffer. + fn reset_inline_viewport_starts_full_replay_from_origin() { let mut backend = TestBackend::with_lines([ - "old-0 ", - "old-1 ", - "old-2 ", - "old-3 ", + "shell-----", + "history---", + "old-0-----", + "old-1-----", "viewport-a", "viewport-b", ]); @@ -564,35 +527,22 @@ mod tests { }, ) .unwrap(); - let scrollback_before = terminal.backend().scrollback().clone(); - let viewport_before = terminal.get_frame().area(); - let tracked_cursor_before = terminal.last_known_cursor_pos; - - let overwritten = terminal - .overwrite_before(5, |buf| { - let rows = ["skipped", "new-0", "new-1", "new-2", "new-3"]; - for (y, text) in rows.into_iter().enumerate() { - buf.set_string(0, y as u16, text, Style::default()); + terminal + .insert_before(8, |buf| { + for y in 0..8 { + buf.set_string(0, y, format!("row-{y}"), Style::default()); } }) .unwrap(); + assert!(terminal.backend().scrollback().area.height > 0); - assert_eq!(overwritten, 4); - assert_eq!(terminal.get_frame().area(), viewport_before); - assert_eq!(terminal.last_known_cursor_pos, tracked_cursor_before); - terminal.backend().assert_buffer_lines([ - "new-0 ", - "new-1 ", - "new-2 ", - "new-3 ", - "viewport-a", - "viewport-b", - ]); - assert_eq!( - terminal.backend().cursor_position(), - terminal.viewport_area.as_position() - ); - assert_eq!(*terminal.backend().scrollback(), scrollback_before); + terminal.backend_mut().purge_scrollback(); + terminal.reset_inline_viewport().unwrap(); + + assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 10, 2)); + assert_eq!(terminal.backend().scrollback().area.height, 0); + terminal.backend().assert_buffer_lines([" "; 6]); + assert_eq!(terminal.backend().cursor_position(), Position::ORIGIN); } #[cfg(not(feature = "scrolling-regions"))] From 08f509766cb73743fbd8238b7ed6b084cac84e3b Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:31:29 -0500 Subject: [PATCH 12/15] test(tui): use a controlling PTY for resize coverage --- CHANGELOG.md | 7 ++++--- docs/TUI.md | 2 +- docs/index.html | 2 +- tests/tui_smoke.rs | 13 +++++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920e577..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. diff --git a/docs/TUI.md b/docs/TUI.md index f2d3f37..cbe95de 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -80,7 +80,7 @@ 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, matching real terminal resize delivery on macOS and Linux. It exercises the real binary and requires: - banner and composer visibility at narrow and wide sizes; - editable input during live streaming; diff --git a/docs/index.html b/docs/index.html index c267799..5bac28a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -860,7 +860,7 @@

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 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 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.

+

The real-PTY regression suite starts each Dext child in a fresh session with the slave PTY as its controlling terminal, matching real terminal resize delivery on macOS and Linux. 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

diff --git a/tests/tui_smoke.rs b/tests/tui_smoke.rs index bd17bd5..486a736 100644 --- a/tests/tui_smoke.rs +++ b/tests/tui_smoke.rs @@ -6,6 +6,7 @@ use std::fs::File; use std::io::{self, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -445,6 +446,18 @@ fn spawn_dext_with_env( for (key, value) in extra_env { cmd.env(key, value); } + unsafe { + cmd.pre_exec(|| { + // A real controlling PTY is required for macOS to deliver resize state consistently. + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY, 0) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } cmd.spawn() } From 1c49164377c0ef49e6abf4c7b781ac93440c19e3 Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:46:46 -0500 Subject: [PATCH 13/15] fix(tests): use portable ioctl request type --- tests/tui_smoke.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tui_smoke.rs b/tests/tui_smoke.rs index 486a736..21c2833 100644 --- a/tests/tui_smoke.rs +++ b/tests/tui_smoke.rs @@ -452,7 +452,7 @@ fn spawn_dext_with_env( if libc::setsid() == -1 { return Err(io::Error::last_os_error()); } - if libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY, 0) == -1 { + if libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 { return Err(io::Error::last_os_error()); } Ok(()) From 8453be46c7dcf2e03fbd8661fad0e9506b3e3818 Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:02:28 -0500 Subject: [PATCH 14/15] fix(tests): resize the controlling PTY endpoint --- docs/TUI.md | 2 +- docs/index.html | 2 +- tests/tui_smoke.rs | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index cbe95de..755f0a1 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -80,7 +80,7 @@ cargo test --release --locked cargo test --release --locked --test tui_smoke -- --nocapture ``` -The PTY smoke suite starts each Dext child in a fresh session with the slave PTY as its controlling terminal, matching real terminal resize delivery on macOS and Linux. It 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. It exercises the real binary and requires: - banner and composer visibility at narrow and wide sizes; - editable input during live streaming; diff --git a/docs/index.html b/docs/index.html index 5bac28a..b787042 100644 --- a/docs/index.html +++ b/docs/index.html @@ -860,7 +860,7 @@

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 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, matching real terminal resize delivery on macOS and Linux. 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.

+

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. 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

diff --git a/tests/tui_smoke.rs b/tests/tui_smoke.rs index 21c2833..1e595cb 100644 --- a/tests/tui_smoke.rs +++ b/tests/tui_smoke.rs @@ -730,7 +730,13 @@ impl Pty { ws_xpixel: 0, ws_ypixel: 0, }; - let rc = unsafe { libc::ioctl(self.master.as_raw_fd(), libc::TIOCSWINSZ, &winsize) }; + let rc = unsafe { + libc::ioctl( + self.slave.as_raw_fd(), + libc::TIOCSWINSZ as libc::c_ulong, + &winsize, + ) + }; if rc == -1 { return Err(io::Error::last_os_error()); } From 88585377431063c51b40228dcd8fff23b0e60b98 Mon Sep 17 00:00:00 2001 From: Wolf <264892643+SiliconState@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:17:44 -0500 Subject: [PATCH 15/15] test(tui): wait for resize replay markers --- docs/TUI.md | 2 +- docs/index.html | 2 +- tests/tui_smoke.rs | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 755f0a1..1c1ae26 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -80,7 +80,7 @@ cargo test --release --locked cargo test --release --locked --test tui_smoke -- --nocapture ``` -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. It 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; diff --git a/docs/index.html b/docs/index.html index b787042..9590a40 100644 --- a/docs/index.html +++ b/docs/index.html @@ -860,7 +860,7 @@

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 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. 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.

+

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

diff --git a/tests/tui_smoke.rs b/tests/tui_smoke.rs index 1e595cb..5161569 100644 --- a/tests/tui_smoke.rs +++ b/tests/tui_smoke.rs @@ -148,7 +148,19 @@ fn tui_resize_keeps_inline_session_responsive_and_dsr_bounded() { let before_wide_capture = pty.capture.len(); pty.resize(&child, TUI_WIDE_COLS, TUI_WIDE_ROWS) .expect("resize wide"); - pty.pump_for(&mut child, Duration::from_millis(500)) + let wide_rebuilt = pty + .wait_for_clear_all( + &mut child, + before_wide.clear_all.saturating_add(1), + Duration::from_secs(3), + ) + .expect("wait for wide resize replay"); + assert!( + wide_rebuilt, + "wide resize replay did not start within the bounded wait: {:?}", + pty.terminal_io_counts() - before_wide + ); + pty.pump_for(&mut child, Duration::from_millis(200)) .expect("settle wide resize"); let wide_resize = pty.terminal_io_counts() - before_wide; eprintln!("wide resize terminal I/O: {wide_resize:?}"); @@ -705,6 +717,30 @@ impl Pty { } } + fn wait_for_clear_all( + &mut self, + child: &mut Child, + minimum: usize, + timeout: Duration, + ) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + self.read_available()?; + self.answer_cursor_position_queries()?; + if self.terminal_io_counts().clear_all >= minimum { + return Ok(true); + } + if child.try_wait()?.is_some() { + self.read_available()?; + return Ok(self.terminal_io_counts().clear_all >= minimum); + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + fn pump_for(&mut self, child: &mut Child, duration: Duration) -> io::Result<()> { let deadline = Instant::now() + duration; loop {
CommandDescription
/helpShow available commands as a grouped listing in the established /sessions presentation