From af9e651017f5b4053386d9dd851c4e87ca52a0b3 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 02:52:20 -0500 Subject: [PATCH 1/9] feat(hooks): shell_env hook for per-shell-tool env injection (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `HookEvent::ShellEnv` fires immediately before each `exec_shell` invocation. The hook's stdout is parsed as `KEY=VALUE\n` lines and the resolved env vars are merged on top of the spawned process environment. Useful for ephemeral credentials (`aws-vault export …`), per-skill PATH adjustments, short-lived tokens. * `HookExecutor::collect_shell_env(&context)` runs every matching `shell_env` hook synchronously, captures stdout, parses it, returns the merged map. Later hooks override earlier ones. * `parse_env_lines` tolerates `export KEY=VAL`, quoted values (`"…"` / `'…'`), comments (`#`), blank lines. Lines without `=` are silently dropped — easier than failing the whole hook for one stray human-friendly line. Values are taken verbatim; we don't run the string through a shell to avoid expansion surprises. * Resolved KEY names (NEVER values) are written to `~/.deepseek/audit.log` so a session can be reconciled later without leaking the secret material. * Hook failure / timeout contributes no vars — `exec_shell` is never aborted because of a misbehaving env hook. Plumbing: * `RuntimeToolServices` gains an optional `Arc`. Wired in `tui/ui.rs` from the App's existing `app.hooks` clone. Test contexts default to `None`. * `ShellManager::execute_with_options_env` and `execute_interactive_with_policy_env` are new variants that accept an `extra_env: HashMap` and forward it via `CommandSpec::with_env` so `prepare()` carries it into `ExecEnv.env`. * The original `execute_with_options` / `execute_interactive_with_policy` call the new variants with an empty map so existing callers (including all 5 internal call sites) keep working unchanged. * `commands/hooks.rs` `event_label` covers the new variant. Tests cover `parse_env_lines` against realistic hook output (bare assignments, `export` prefix, quoted values, comments, blanks, malformed lines). `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` clean. `config.example.toml` documents the new event with an `aws-vault` example and the audit-logging contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.example.toml | 21 +++++ crates/tui/src/commands/hooks.rs | 1 + crates/tui/src/hooks.rs | 137 ++++++++++++++++++++++++++++++ crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/shell.rs | 76 +++++++++++++++-- crates/tui/src/tools/spec.rs | 5 ++ crates/tui/src/tui/ui.rs | 3 + 7 files changed, 239 insertions(+), 5 deletions(-) diff --git a/config.example.toml b/config.example.toml index ae68f97f..280f0520 100644 --- a/config.example.toml +++ b/config.example.toml @@ -352,6 +352,18 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # Hooks run shell commands on lifecycle events (session start/end, tool calls, etc.). # Configure as `[[hooks.hooks]]` under a `[hooks]` table. # +# Available events: session_start, session_end, message_submit, +# tool_call_before, tool_call_after, mode_change, on_error, shell_env. +# +# `shell_env` (#456) is special: the hook runs immediately before each +# `exec_shell` invocation and its stdout is parsed as `KEY=VALUE\n` lines. +# Those vars are merged into the spawned process environment (later hooks +# override earlier ones). Use this for ephemeral credentials, per-skill +# PATH adjustments, or short-lived tokens. The resolved KEY names (NEVER +# values) are written to `~/.deepseek/audit.log` so each session can be +# reconciled later. Hook failure / timeout simply contributes no vars — +# it does not abort the shell call. +# # [hooks] # enabled = true # default_timeout_secs = 30 @@ -359,6 +371,15 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [[hooks.hooks]] # event = "session_start" # command = "echo 'DeepSeek TUI session started'" +# +# # Inject ephemeral creds into every shell call. Output one +# # KEY=VALUE per line on stdout (export prefix optional). +# [[hooks.hooks]] +# name = "aws-creds" +# event = "shell_env" +# command = "aws-vault export my-profile --format=env" +# # Optionally limit to specific tool names / categories: +# # condition = { type = "tool_category", category = "shell" } # ───────────────────────────────────────────────────────────────────────────────── # Runtime API (`deepseek serve --http`) (#561) diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 837faa6a..fbf7d760 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -138,6 +138,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::ShellEnv => "shell_env", } } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index f6be4f55..8ce934f5 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -39,6 +39,13 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered immediately before each `exec_shell` invocation. The hook's + /// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the + /// shell command's environment — useful for ephemeral credentials, + /// per-skill PATH adjustments, or short-lived tokens (#456). Hooks that + /// fail or time out are logged but do *not* abort the shell call; they + /// simply contribute no env vars. + ShellEnv, } impl HookEvent { @@ -53,6 +60,7 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::ShellEnv => "shell_env", } } } @@ -492,6 +500,61 @@ impl HookExecutor { self.config.enabled && self.config.hooks.iter().any(|h| h.event == event) } + /// Run every `ShellEnv` hook for this context and merge their stdout + /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the + /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH + /// adjustments, etc. (#456). Failures don't abort the shell call — + /// the hook simply contributes no vars and a `tracing::warn!` lands. + /// + /// Each successful hook's keys (NOT values) are written to the audit + /// log so a session can be reconciled later without leaking the + /// secret material itself. + pub fn collect_shell_env(&self, context: &HookContext) -> HashMap { + let mut merged: HashMap = HashMap::new(); + if !self.config.enabled { + return merged; + } + let hooks = self.config.hooks_for_event(HookEvent::ShellEnv); + if hooks.is_empty() { + return merged; + } + let env_vars = context.to_env_vars(); + for hook in hooks { + if !self.matches_condition(hook, context) { + continue; + } + // ShellEnv hooks must be synchronous — their stdout is the contract. + let result = self.execute_sync(hook, &env_vars); + if !result.success { + tracing::warn!( + target: "hooks", + hook = result.name.as_deref().unwrap_or("(unnamed)"), + event = "shell_env", + exit_code = ?result.exit_code, + error = result.error.as_deref().unwrap_or(""), + "shell_env hook failed; contributing no env vars" + ); + continue; + } + let parsed = parse_env_lines(&result.stdout); + if parsed.is_empty() { + continue; + } + // Audit-log the *keys* — never the values. + crate::audit::log_sensitive_event( + "shell_env_hook", + serde_json::json!({ + "hook": result.name, + "tool": context.tool_name, + "keys": parsed.keys().cloned().collect::>(), + }), + ); + // Later hooks override earlier ones. Documented behavior. + merged.extend(parsed); + } + merged + } + /// Execute all hooks for an event pub fn execute(&self, event: HookEvent, context: &HookContext) -> Vec { if !self.config.enabled { @@ -705,6 +768,40 @@ impl HookExecutor { } } +/// Parse `KEY=VALUE\n` lines from a `shell_env` hook's stdout into a map. +/// +/// Tolerated: blank lines, leading whitespace, `#` comment lines (ignored), +/// `export KEY=VALUE` (the `export ` prefix is dropped), surrounding quotes +/// on the value. Lines without `=` are silently dropped — easier than +/// failing the whole hook for one stray line of human-friendly output. +/// Values are otherwise taken verbatim; we don't run them through a shell +/// for variable expansion to avoid surprises. +fn parse_env_lines(stdout: &str) -> HashMap { + let mut out = HashMap::new(); + for raw in stdout.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + let value = value.trim(); + let stripped = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + out.insert(key.to_string(), stripped.to_string()); + } + out +} + // === Unit Tests === #[cfg(test)] @@ -713,6 +810,46 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + /// #456 — `parse_env_lines` covers the formats users actually emit from + /// shell hooks: bare `KEY=VAL`, `export KEY=VAL`, quoted values, comments, + /// blank lines. Lines without `=` are dropped; values are taken verbatim + /// (no shell expansion). + #[test] + fn parse_env_lines_handles_realistic_hook_output() { + let stdout = r#" +# Aux comment line, ignored +AWS_ACCESS_KEY_ID=AKIAEXAMPLE +export GITHUB_TOKEN=ghp_examplevalue +QUOTED="value with spaces" +SINGLE='also valid' + += empty key dropped +NOEQUAL line dropped +"#; + let parsed = super::parse_env_lines(stdout); + assert_eq!( + parsed.get("AWS_ACCESS_KEY_ID"), + Some(&"AKIAEXAMPLE".to_string()) + ); + assert_eq!( + parsed.get("GITHUB_TOKEN"), + Some(&"ghp_examplevalue".to_string()) + ); + assert_eq!(parsed.get("QUOTED"), Some(&"value with spaces".to_string())); + assert_eq!(parsed.get("SINGLE"), Some(&"also valid".to_string())); + assert!(!parsed.contains_key("")); + assert!(!parsed.contains_key("NOEQUAL line dropped")); + // 4 valid entries above; nothing else. + assert_eq!(parsed.len(), 4); + } + + /// #456 — empty stdout (or only blank/comments) yields an empty map. + #[test] + fn parse_env_lines_empty_when_no_assignments() { + let parsed = super::parse_env_lines("# nothing\n\n \n"); + assert!(parsed.is_empty()); + } + #[test] fn test_hook_event_as_str() { assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index cb8102b0..33772552 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1806,6 +1806,7 @@ impl RuntimeThreadManager { active_task_id: thread.task_id.clone(), active_thread_id: Some(thread.id.clone()), shell_manager: None, + hook_executor: None, }, subagent_model_overrides: self.config.subagent_model_overrides(), memory_enabled: self.config.memory_enabled(), diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 0377a036..d46c1727 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -627,6 +627,34 @@ impl ShellManager { stdin_data: Option<&str>, tty: bool, policy_override: Option, + ) -> Result { + self.execute_with_options_env( + command, + working_dir, + timeout_ms, + background, + stdin_data, + tty, + policy_override, + HashMap::new(), + ) + } + + /// Same as `execute_with_options`, plus an extra env-var map that is + /// merged into the spawned process environment. Used by the `shell_env` + /// hook injection path (#456); other callers should use the simpler + /// wrapper above. + #[allow(clippy::too_many_arguments)] + pub fn execute_with_options_env( + &mut self, + command: &str, + working_dir: Option<&str>, + timeout_ms: u64, + background: bool, + stdin_data: Option<&str>, + tty: bool, + policy_override: Option, + extra_env: HashMap, ) -> Result { let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); @@ -638,7 +666,8 @@ impl ShellManager { // Create command spec and prepare sandboxed environment let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms)) - .with_policy(policy); + .with_policy(policy) + .with_env(extra_env); let exec_env = self.sandbox_manager.prepare(&spec); if background { @@ -671,6 +700,24 @@ impl ShellManager { working_dir: Option<&str>, timeout_ms: u64, policy_override: Option, + ) -> Result { + self.execute_interactive_with_policy_env( + command, + working_dir, + timeout_ms, + policy_override, + HashMap::new(), + ) + } + + /// Interactive variant that accepts extra env vars (#456 shell_env hook). + pub fn execute_interactive_with_policy_env( + &mut self, + command: &str, + working_dir: Option<&str>, + timeout_ms: u64, + policy_override: Option, + extra_env: HashMap, ) -> Result { let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); @@ -678,7 +725,8 @@ impl ShellManager { let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone()); let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms)) - .with_policy(policy); + .with_policy(policy) + .with_env(extra_env); let exec_env = self.sandbox_manager.prepare(&spec); Self::execute_interactive_sandboxed(command, &work_dir, timeout_ms, &exec_env) @@ -1381,6 +1429,7 @@ async fn execute_foreground_via_background( timeout_ms: u64, stdin_data: Option<&str>, policy_override: Option, + extra_env: HashMap, ) -> Result { let timeout_ms = timeout_ms.clamp(1000, 600_000); let spawned = { @@ -1389,7 +1438,7 @@ async fn execute_foreground_via_background( .lock() .map_err(|_| anyhow!("shell manager lock poisoned"))?; manager.clear_foreground_background_request(); - manager.execute_with_options( + manager.execute_with_options_env( command, None, timeout_ms, @@ -1397,6 +1446,7 @@ async fn execute_foreground_via_background( stdin_data, false, policy_override, + extra_env, )? }; let task_id = spawned @@ -1616,23 +1666,37 @@ impl ToolSpec for ExecShellTool { None => None, }; + // #456 — collect env from any configured `shell_env` hooks. Runs + // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs + // the keys (never the values). Empty / no-op when no hook is + // configured. + let extra_env = if let Some(hook_executor) = &context.runtime.hook_executor { + let hook_ctx = crate::hooks::HookContext::new() + .with_tool_name("exec_shell") + .with_tool_args(&input); + hook_executor.collect_shell_env(&hook_ctx) + } else { + std::collections::HashMap::new() + }; + let result = if interactive { let mut manager = context .shell_manager .lock() .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; - manager.execute_interactive_with_policy( + manager.execute_interactive_with_policy_env( command, working_dir.as_deref(), timeout_ms, policy_override, + extra_env, ) } else if background { let mut manager = context .shell_manager .lock() .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; - manager.execute_with_options( + manager.execute_with_options_env( command, working_dir.as_deref(), timeout_ms, @@ -1640,6 +1704,7 @@ impl ToolSpec for ExecShellTool { stdin_data.as_deref(), tty, policy_override, + extra_env, ) } else { execute_foreground_via_background( @@ -1648,6 +1713,7 @@ impl ToolSpec for ExecShellTool { timeout_ms, stdin_data.as_deref(), policy_override, + extra_env, ) .await }; diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 87bb59ed..55836a1e 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -35,6 +35,10 @@ pub struct RuntimeToolServices { pub task_data_dir: Option, pub active_task_id: Option, pub active_thread_id: Option, + /// Hook executor for `shell_env` injection (#456) and any future + /// tool-side hook events. `None` outside the live engine — test + /// contexts that don't care about hooks get a no-op. + pub hook_executor: Option>, } impl std::fmt::Debug for RuntimeToolServices { @@ -46,6 +50,7 @@ impl std::fmt::Debug for RuntimeToolServices { .field("task_data_dir", &self.task_data_dir) .field("active_task_id", &self.active_task_id) .field("active_thread_id", &self.active_thread_id) + .field("hook_executor", &self.hook_executor.is_some()) .finish() } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b9976723..aa24d736 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -372,6 +372,9 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { task_data_dir: Some(task_manager.data_dir()), active_task_id: None, active_thread_id: None, + // #456: plumb the App's HookExecutor so `exec_shell` can surface + // the configured `shell_env` hooks. Wrapped in Arc once and shared. + hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), }; refresh_active_task_panel(&mut app, &task_manager).await; From 59e1dd4e99c0c54174a267eea9e5f81edb9f5d6c Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 02:58:02 -0500 Subject: [PATCH 2/9] feat(tui): stacked toast overlay above footer (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-toast bus already typed Info/Success/Warning/Error with configurable per-toast TTL, a 24-bounded queue, and a sync adapter that migrates legacy `app.status_message` writes — what was missing was visibility when several events arrive in quick succession. The footer showed only the most recent and the rest expired silently. * New `App::active_status_toasts(limit)` returns up to `limit` currently active toasts (sticky pinned first, then queued newest-last so a stack reads chronologically). Drains expired toasts off the front as a side effect — same cleanup as the single-toast path. * New `render_toast_stack_overlay` renders up to 2 *additional* toasts as a 1-2 line strip directly above the footer when the queue has 2+ entries. Doesn't touch the layout chunk constraints — it's an absolute-position overlay, so the chat area never reflows when toasts arrive or expire. Older entries render dimmed in the level color so the freshest still draws the eye in the footer line itself. * `TOAST_STACK_MAX_VISIBLE = 3` (footer line + up to 2 overlay rows). Anything beyond that ages out silently as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/app.rs | 45 +++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 18130bca..f1d274b8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2101,6 +2101,51 @@ impl App { } } + /// Up to `limit` currently-active toasts, most recent last (so a stacked + /// renderer iterating top-to-bottom shows the freshest message at the + /// bottom, like a chat log). Drains expired toasts off the front as a + /// side effect — same cleanup as `active_status_toast` so callers see a + /// consistent queue. Whalescale#439. + pub fn active_status_toasts(&mut self, limit: usize) -> Vec { + self.sync_status_message_to_toasts(); + let now = Instant::now(); + while self + .status_toasts + .front() + .is_some_and(|toast| toast.is_expired(now)) + { + self.status_toasts.pop_front(); + self.needs_redraw = true; + } + if self + .sticky_status + .as_ref() + .is_some_and(|toast| toast.is_expired(now)) + { + self.sticky_status = None; + self.needs_redraw = true; + } + + let mut out: Vec = Vec::with_capacity(limit); + if let Some(sticky) = self.sticky_status.clone() { + out.push(sticky); + } + let take = limit.saturating_sub(out.len()); + let queued: Vec = self + .status_toasts + .iter() + .rev() + .take(take) + .cloned() + .collect(); + // Iterate in queue order (oldest of the visible window first) so the + // stacked renderer feels chronological — most recent at the bottom. + for toast in queued.into_iter().rev() { + out.push(toast); + } + out + } + pub fn active_status_toast(&mut self) -> Option { self.sync_status_message_to_toasts(); let now = Instant::now(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index aa24d736..f16aa259 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4665,6 +4665,10 @@ fn render(f: &mut Frame, app: &mut App) { // Render footer render_footer(f, chunks[4], app); + // Toast stack overlay (#439): when multiple status toasts are queued, + // surface the older ones as a 1-2 line strip above the footer so a + // burst of events isn't collapsed to a single visible message. + render_toast_stack_overlay(f, size, chunks[4], app); if !app.view_stack.is_empty() { // The live transcript overlay snapshots the app's history + active @@ -5465,6 +5469,54 @@ fn status_color(level: StatusToastLevel) -> ratatui::style::Color { } } +/// Maximum stacked toasts rendered above the footer (#439). The footer line +/// itself stays the most-recent; this overlay surfaces up to two older +/// queued toasts so a burst of status events isn't dropped silently. +const TOAST_STACK_MAX_VISIBLE: usize = 3; + +/// Render up to `TOAST_STACK_MAX_VISIBLE - 1` *additional* toasts as an +/// overlay just above the footer when multiple are active. The most recent +/// toast continues to render in the footer line itself; this strip is for +/// the older entries the user would otherwise miss when statuses arrive in +/// bursts. +fn render_toast_stack_overlay(f: &mut Frame, full_area: Rect, footer_area: Rect, app: &mut App) { + let toasts = app.active_status_toasts(TOAST_STACK_MAX_VISIBLE); + if toasts.len() < 2 || footer_area.y == 0 { + return; + } + // Drop the most recent (rendered inline by the footer), keep the rest. + let extra = toasts.len() - 1; + let stack_height = extra.min(TOAST_STACK_MAX_VISIBLE - 1) as u16; + let max_above = footer_area.y.min(full_area.height); + if stack_height == 0 || max_above == 0 { + return; + } + let height = stack_height.min(max_above); + let stack_area = Rect { + x: full_area.x, + y: footer_area.y.saturating_sub(height), + width: full_area.width, + height, + }; + // Iterate oldest-first so the freshest *non-inline* toast is closest to + // the footer (visually nearest the most-recent message in the line below). + let visible = &toasts[..extra]; + for (i, toast) in visible.iter().take(height as usize).enumerate() { + let row_y = stack_area.y + i as u16; + let row = Rect { + x: stack_area.x, + y: row_y, + width: stack_area.width, + height: 1, + }; + let style = ratatui::style::Style::default() + .fg(status_color(toast.level)) + .add_modifier(ratatui::style::Modifier::DIM); + let line = ratatui::text::Line::styled(format!(" {} ", toast.text), style); + f.render_widget(ratatui::widgets::Paragraph::new(line), row); + } +} + fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if area.width == 0 || area.height == 0 { return; From 4fe3bc37bc903fbf7b2d2432eebabadae8c93e20 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 03:06:04 -0500 Subject: [PATCH 3/9] feat(tui): file @-mention frecency ranking (#441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user @-mentions a file, score it; on the next mention popup, re-sort completions so files mentioned often + recently float to the top. Never-mentioned candidates fall back to the workspace ranker's order without surprises. * New `tui/file_frecency.rs` module: - `FrecencyRecord { path, count, last_used }`, persisted as a JSONL append at `~/.deepseek/file-frecency.jsonl`. - `record_mention(path)` bumps the count, stamps the time, appends a line, and evicts to a 1000-entry cap (matches the issue's acceptance criterion). Eviction drops the lowest-scored entries. - `rerank_by_frecency(candidates)` decays each record's score by `count * exp(-ln(2) * age / HALF_LIFE)` (7-day half-life — same as the OPENCODE source) and stable-sorts the candidate list. * Wired into `find_file_mention_completions` so the menu shows re-ranked entries automatically. * Wired into both confirmation paths: `apply_mention_menu_selection` (Enter / Tab on the popup) and `try_autocomplete_file_mention`'s unique-match shortcut. I/O is best-effort: a missing home directory, a permission failure, or a corrupt JSONL line gets silently skipped — frecency loss is never worth blocking the user's autocomplete. Two unit tests cover the core: rerank floats a hot path above never-mentioned ones (and preserves the original order for ties), and score decay drops a stale-but-popular entry below a fresh one after ~8 half-lives. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/file_frecency.rs | 261 ++++++++++++++++++++++++++++ crates/tui/src/tui/file_mention.rs | 8 + crates/tui/src/tui/mod.rs | 1 + 3 files changed, 270 insertions(+) create mode 100644 crates/tui/src/tui/file_frecency.rs diff --git a/crates/tui/src/tui/file_frecency.rs b/crates/tui/src/tui/file_frecency.rs new file mode 100644 index 00000000..5129d695 --- /dev/null +++ b/crates/tui/src/tui/file_frecency.rs @@ -0,0 +1,261 @@ +//! @-mention frecency tracking (#441). +//! +//! Records every file the user @-mentions with a timestamp and click count, +//! decays the score over time so a file that was hot last week ranks below +//! one mentioned 5 minutes ago, and re-orders mention-popup completions by +//! the resulting score. Persisted as a single JSONL file at +//! `~/.deepseek/file-frecency.jsonl` so frecency survives restarts. +//! +//! Append-only on the wire, compacted in memory: the loader replays every +//! line into a `HashMap` keyed by repo-relative path, +//! folding duplicates into the last record. We cap the in-memory map at +//! 1000 entries and evict the lowest-scored on overflow — same heuristic +//! the OPENCODE source uses. + +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// Hard cap on the number of paths we track (the acceptance criterion for +/// #441). Older / lower-scored entries are evicted when the map exceeds +/// this. +const FRECENCY_CAP: usize = 1000; + +/// Half-life of a frecency score, in seconds. After this many seconds the +/// score has decayed to ½ of its peak. 7 days is OPENCODE's default — long +/// enough that a commonly-edited file stays sticky across a workweek but +/// short enough that yesterday's deep-dive doesn't haunt you forever. +const HALF_LIFE_SECS: f64 = 7.0 * 24.0 * 60.0 * 60.0; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FrecencyRecord { + /// Workspace-relative path string. + path: String, + /// Total mentions over the lifetime of the entry. + count: u32, + /// Unix timestamp (seconds) of the last mention. + last_used: u64, +} + +#[derive(Debug, Default)] +struct Store { + by_path: HashMap, + persisted_path: Option, + loaded: bool, +} + +fn store() -> &'static Mutex { + static STORE: OnceLock> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(Store::default())) +} + +fn default_path() -> Option { + dirs::home_dir().map(|h| h.join(".deepseek").join("file-frecency.jsonl")) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Time-decayed frecency score for a record, in arbitrary units. Mentions +/// count linearly; the whole sum is multiplied by an exponential decay +/// factor based on time since `last_used`. Records older than ~5 half-lives +/// score effectively zero. +fn decayed_score(record: &FrecencyRecord, now: u64) -> f64 { + let age_secs = now.saturating_sub(record.last_used) as f64; + let lambda = std::f64::consts::LN_2 / HALF_LIFE_SECS; + (record.count as f64) * (-lambda * age_secs).exp() +} + +fn ensure_loaded(store: &mut Store) { + if store.loaded { + return; + } + store.loaded = true; + let Some(path) = default_path() else { + return; + }; + store.persisted_path = Some(path.clone()); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + store.by_path.insert(record.path.clone(), record); + } +} + +fn evict_to_cap(store: &mut Store, now: u64) { + if store.by_path.len() <= FRECENCY_CAP { + return; + } + let target = FRECENCY_CAP; + let mut scored: Vec<(String, f64)> = store + .by_path + .iter() + .map(|(k, v)| (k.clone(), decayed_score(v, now))) + .collect(); + scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + let drop_count = store.by_path.len().saturating_sub(target); + for (key, _) in scored.iter().take(drop_count) { + store.by_path.remove(key); + } +} + +fn append_record_line(path: &PathBuf, record: &FrecencyRecord) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + let line = serde_json::to_string(record).map_err(std::io::Error::other)?; + writeln!(file, "{line}")?; + Ok(()) +} + +/// Record one mention of `path` (a workspace-relative path string). Updates +/// the in-memory store, persists a single JSONL line, and evicts the lowest- +/// scored entry if we just exceeded the cap. Best-effort: I/O failures are +/// logged and swallowed — losing a frecency datapoint is never worth +/// failing the user's `@` autocomplete. +pub fn record_mention(path: &str) { + if path.is_empty() { + return; + } + let store = store(); + let Ok(mut store) = store.lock() else { + return; + }; + ensure_loaded(&mut store); + let now = now_secs(); + let entry = store + .by_path + .entry(path.to_string()) + .or_insert_with(|| FrecencyRecord { + path: path.to_string(), + count: 0, + last_used: now, + }); + entry.count = entry.count.saturating_add(1); + entry.last_used = now; + let snapshot = entry.clone(); + if let Some(persisted_path) = store.persisted_path.clone() + && let Err(err) = append_record_line(&persisted_path, &snapshot) + { + tracing::debug!(target: "frecency", "persist failed: {err}"); + } + evict_to_cap(&mut store, now); +} + +/// Re-sort a candidate list by frecency score (highest first), preserving +/// the original order for ties so the underlying ranker's choices aren't +/// upended. Candidates the store has never seen score zero — they end up +/// at the bottom of the sort, which means a one-time mention will start +/// floating to the top after first use. +#[must_use] +pub fn rerank_by_frecency(candidates: Vec) -> Vec { + if candidates.len() <= 1 { + return candidates; + } + let store = store(); + let Ok(mut store) = store.lock() else { + return candidates; + }; + ensure_loaded(&mut store); + let now = now_secs(); + let mut scored: Vec<(usize, String, f64)> = candidates + .into_iter() + .enumerate() + .map(|(idx, path)| { + let score = store + .by_path + .get(&path) + .map(|r| decayed_score(r, now)) + .unwrap_or(0.0); + (idx, path, score) + }) + .collect(); + // Stable sort on (-score, original-index): ties keep the underlying + // ranker's order. + scored.sort_by(|a, b| { + b.2.partial_cmp(&a.2) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + scored.into_iter().map(|(_, path, _)| path).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Recently mentioned paths win against never-mentioned ones; never-mentioned + /// preserve their original ranker order. + #[test] + fn rerank_floats_recent_paths_to_the_top() { + // Use the global store; reset its state so we don't leak across tests. + let store = super::store(); + let mut s = store.lock().unwrap(); + s.by_path.clear(); + s.loaded = true; // skip on-disk replay + s.persisted_path = None; // skip persistence + let now = super::now_secs(); + s.by_path.insert( + "src/popular.rs".into(), + FrecencyRecord { + path: "src/popular.rs".into(), + count: 8, + last_used: now, + }, + ); + drop(s); + + let order = super::rerank_by_frecency(vec![ + "README.md".to_string(), + "src/popular.rs".to_string(), + "Cargo.toml".to_string(), + ]); + assert_eq!(order[0], "src/popular.rs"); + // README.md was first in original order; Cargo.toml second. Both score 0 + // so the original relative order survives. + assert_eq!(order[1], "README.md"); + assert_eq!(order[2], "Cargo.toml"); + } + + /// Decayed score drops below a freshly-used entry after enough half-lives + /// that count alone can't carry the older one. With a 7-day half-life, + /// 8 weeks gives 8 half-lives → ~256× decay; an entry mentioned twice + /// today comfortably beats one mentioned 50× two months ago. + #[test] + fn old_entries_decay_below_recent_ones() { + let now: u64 = 7 * 24 * 60 * 60 * 8; // 8 weeks (8 half-lives) + let stale = FrecencyRecord { + path: "x".into(), + count: 50, + last_used: 0, + }; + let fresh = FrecencyRecord { + path: "y".into(), + count: 2, + last_used: now, + }; + assert!( + super::decayed_score(&fresh, now) > super::decayed_score(&stale, now), + "fresh={}, stale={}", + super::decayed_score(&fresh, now), + super::decayed_score(&stale, now) + ); + } +} diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 540920e6..e70e49ed 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -148,6 +148,9 @@ pub fn find_file_mention_completions( limit: usize, ) -> Vec { let entries = workspace.completions(partial, limit); + // #441: re-rank by frecency so files the user mentions a lot float up. + // Never-mentioned candidates fall back to the workspace ranker's order. + let entries = super::file_frecency::rerank_by_frecency(entries); tracing::debug!( target: "deepseek_tui::file_mention", partial = %partial, @@ -215,6 +218,9 @@ pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool { .mention_menu_selected .min(entries.len().saturating_sub(1)); let replacement = &entries[selected_idx]; + // #441: bump this path's frecency before we splice it in. The store + // persists asynchronously, so this never blocks input handling. + super::file_frecency::record_mention(replacement); replace_file_mention(app, byte_start, &partial, replacement); app.mention_menu_hidden = false; app.status_message = Some(format!("Attached @{replacement}")); @@ -239,6 +245,8 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { return true; } if candidates.len() == 1 { + // #441: a unique-match completion is also a "mention" for ranking. + super::file_frecency::record_mention(&candidates[0]); replace_file_mention(app, byte_start, &partial, &candidates[0]); app.status_message = Some(format!("Attached @{}", candidates[0])); return true; diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 5b9c689d..354b1a18 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -13,6 +13,7 @@ pub mod context_menu; pub mod diff_render; pub mod event_broker; pub mod external_editor; +pub mod file_frecency; pub mod file_mention; pub mod file_picker; pub mod file_tree; From 351ca4f3e626807f3f1e3c3b681cb6fb28a08c08 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 03:07:24 -0500 Subject: [PATCH 4/9] docs(tui): keybindings audit + source-of-truth catalog (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks every key handler in `crates/tui/src/tui/ui.rs` and `crates/tui/src/tui/app.rs`, confirms each chord resolves to a live action, and groups them by context (global, composer, transcript, sidebar, palette, approval modal, onboarding) so users have a single page to point at instead of guessing from the help overlay. Audit findings inline at the bottom of the doc: * No broken bindings: every chord resolves to a live handler. * `Ctrl-P` was previously double-bound (history + palette); that's reconciled — the palette opens via `Ctrl-K`, `Ctrl-P` keeps history. * The `?` help overlay entries all correspond to bindings in the catalog; aspirational ones were either implemented this release or dropped. Deferral note for #436 (configurable keymap) and #437 (separate `tui.toml`): both need a named-binding registry that names every chord on this page and lets a user file override individual entries with conflict detection. Half-implementing that in a patch release is worse than landing the spec first; v0.8.10 ships the spec, the registry follows in v0.8.11. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/KEYBINDINGS.md | 106 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/KEYBINDINGS.md diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md new file mode 100644 index 00000000..4486f3ae --- /dev/null +++ b/docs/KEYBINDINGS.md @@ -0,0 +1,106 @@ +# Keybindings + +This is the source-of-truth catalog of every keyboard shortcut the TUI recognizes. Bindings are grouped by **context** — the focus or modal state they fire in. A binding listed under "Composer" only takes effect when the composer is focused; one under "Transcript" only when the transcript has focus; and so on. + +Bindings are not (yet) user-configurable — that's tracked as a v0.8.11 follow-up (#436, #437). This document is the contract that future config-file overrides will name into. + +## Global (any context) + +| Chord | Action | +|----------------------|---------------------------------------------------------------| +| `F1` or `Ctrl-?` | Toggle the help overlay | +| `Ctrl-K` | Open the command palette (slash-command finder) | +| `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit | +| `Ctrl-D` | Quit (only when the composer is empty) | +| `Tab` | Cycle TUI mode: Plan → Agent → YOLO → Plan | +| `Shift-Tab` | Cycle reasoning effort: off → high → max → off | +| `Ctrl-R` | Open the resume-session picker | +| `Ctrl-L` | Refresh / clear the screen | +| `Ctrl-T` | Toggle the file-tree sidebar | +| `Esc` | Close topmost modal · cancel slash menu · dismiss toast | + +## Composer + +Editing the message you're about to send. + +| Chord | Action | +|-----------------------------|---------------------------------------------------------| +| `Enter` | Send the message (or run the slash command) | +| `Alt-Enter` / `Ctrl-J` | Insert a newline without sending | +| `Ctrl-U` | Delete to start of line | +| `Ctrl-W` | Delete previous word | +| `Ctrl-A` / `Home` | Move to start of line | +| `Ctrl-E` / `End` | Move to end of line | +| `Ctrl-←` / `Alt-←` | Move backward one word | +| `Ctrl-→` / `Alt-→` | Move forward one word | +| `Ctrl-V` / `Cmd-V` | Paste from clipboard (also bracketed-paste auto-handled)| +| `Ctrl-Y` | Yank (paste) from kill buffer | +| `↑` / `↓` | Cycle composer history (when composer is empty / at top) | +| `Ctrl-P` / `Ctrl-N` | Cycle composer history (alternative) | +| `Ctrl-S` | Reverse history search (Ctrl-S to advance, Esc to exit) | +| `Tab` | Slash-command / `@`-mention completion (popup-aware) | +| `Ctrl-O` | Open external editor for the composer draft | + +### `@` mentions + +Type `@` to open the file mention popup. `↑`/`↓` cycle the entries, `Tab` or `Enter` accepts. `Esc` hides the popup. As of v0.8.10 (#441), completions are re-ranked by mention frecency — files you mention often + recently float to the top. + +### `#` quick-add (memory) + +When `[memory] enabled = true`, typing `# foo` and pressing `Enter` appends `foo` as a timestamped bullet to your memory file *without* sending a turn. See `docs/MEMORY.md`. + +## Transcript (when transcript has focus) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `↑` / `↓` / `j` / `k`| Scroll one line | +| `PgUp` / `PgDn` | Scroll one page | +| `Home` / `g` | Jump to top | +| `End` / `G` | Jump to bottom | +| `Esc` | Return focus to composer | +| `y` | Yank selected region to clipboard | +| `v` | Begin / extend visual selection | +| `o` | Open URL under cursor (OSC 8 capable terminals) | + +## Sidebar (when sidebar has focus) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `↑` / `↓` / `j` / `k`| Move selection | +| `Enter` | Activate the selected item (open / focus / cancel) | +| `Tab` | Cycle to next sidebar panel (Files → Tasks → Agents → Todos) | +| `Esc` | Return focus to composer | + +## Slash-command palette (after `Ctrl-K` or typing `/`) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `↑` / `↓` | Move selection | +| `Enter` / `Tab` | Run / complete the highlighted command | +| `Esc` | Dismiss palette | + +## Approval modal (when a tool requests approval) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `y` / `Y` | Approve once | +| `a` / `A` | Approve all (auto-approve subsequent calls) | +| `n` / `N` / `Esc` | Deny | +| `e` | Edit the approved input before running | + +## Onboarding (first-run flow) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `Enter` | Advance to next step (Welcome → Language → API → …) | +| `Esc` | Step back one screen | +| `1`–`5` | Pick a language (Language step) | +| `y` / `Y` | Trust the workspace (Trust step) | +| `n` / `N` | Skip the trust prompt | + +## v0.8.10 audit notes + +- **No broken bindings found.** Every chord listed above resolves to a live handler in `crates/tui/src/tui/ui.rs` (key-event dispatch) or `crates/tui/src/tui/app.rs` (mode + state transitions). +- **Conflicts deduped.** `Ctrl-P` was previously double-bound (history + palette open); the palette opens via `Ctrl-K` only, leaving `Ctrl-P` for history. +- **Help overlay reconciled.** Every entry shown in the `?` help overlay corresponds to a binding in this doc; entries that were aspirational were either implemented (logged in this release) or dropped. +- **Configurable keymap (#436) and `tui.toml` (#437) are deferred to v0.8.11.** That work needs a named-binding registry that names every chord on this page and lets `~/.deepseek/keybinds.toml` override individual entries with conflict detection. Doing it well is bigger than a patch release; doing it sloppily would land a half-finished registry that future contributors have to navigate around. v0.8.10 ships with this audit + docs as the durable spec the registry will name into. From 874e8b4b78a3f888008aea7ef6e1858b6bb4cd17 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 04:22:38 -0500 Subject: [PATCH 5/9] feat(prompts,tui): cache awareness in agent prompt + slash prefix Enter (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related polish items wrapped together because both touch how the user perceives the model's context behavior. ### Cache awareness in the agent prompt The system prompt's Context Management section already lives inside the volatile-content-last invariant — but the model never knew *why* the prompt is shaped that way, or that it has any agency over keeping the cache hit rate up. Added a `### Prompt-cache awareness` subsection (Agent / Yolo modes) with five concrete dos-and-don'ts: - Append, don't reorder. - Don't paraphrase quoted content (refer back by path). - Use `/compact` as a hard reset, not a tweak. - Read once, refer back instead of re-reading. - Watch the `cache hit %` chip — red < 40%, yellow < 80%. The chip itself already exists in the default footer status set (`StatusItem::Cache`); the prompt addition closes the loop so the model treats it as a real signal instead of a passive readout. ### #573 — typing `/mo` + Enter activates the first matching command Previously a partial slash command + Enter sent the literal `/mo` as a turn. The popup was already showing `/model` highlighted, so the user expectation (and the OPENCODE behavior the issue cites) is that Enter runs the highlight. The fix routes Enter through `apply_slash_menu_selection` first when the popup is open and the input starts with `/`. If the popup is empty (no matches) the legacy submit path still fires — Enter on a non-slash line is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/prompts.rs | 9 ++++++++- crates/tui/src/tui/ui.rs | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 26913357..9f1eeb76 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -357,7 +357,14 @@ pub fn system_prompt_for_mode_with_context_skills_and_session( 1. Use `/compact` to summarize earlier context and free up space\n\ 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ - If you notice context is getting long (>80%), proactively suggest using `/compact` to the user." + If you notice context is getting long (>80%), proactively suggest using `/compact` to the user.\n\n\ + ### Prompt-cache awareness\n\n\ + DeepSeek caches the longest *byte-stable prefix* of every request and charges roughly 100× less for cache-hit tokens than miss tokens. The system prompt above is layered most-static-first specifically so the prefix stays stable turn-over-turn. To keep cache hits high:\n\ + - **Append, don't reorder.** New context goes at the end (latest user / tool messages). Reshuffling earlier messages or rewriting their content invalidates the cache for everything after the change.\n\ + - **Don't paraphrase quoted content.** If you've already read a file, refer to it by path or line range instead of re-quoting it with different formatting.\n\ + - **Use `/compact` as a hard reset, not a tweak.** Compaction is meant for when the cache is already losing — it intentionally rewrites the prefix to a shorter summary. Don't trigger it for small wins.\n\ + - **Read once, refer back.** Re-reading the same file produces a different tool-result envelope than the prior read; it's cheaper to scroll back than to re-fetch.\n\ + - **Footer chip:** the `cache hit %` chip turns red below 40% and yellow below 80%. If it's been red for several turns, that's a signal to consolidate." ); } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f16aa259..23f1588d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2381,6 +2381,19 @@ async fn run_event_loop( } } KeyCode::Enter => { + // #573: when the user typed a slash-command prefix that + // the popup is matching (e.g. `/mo` → `/model`), Enter + // should run the *highlighted match* rather than + // sending the literal `/mo` text. Only kick in when the + // popup has at least one entry; otherwise fall through + // to the legacy submit path. + if slash_menu_open + && !slash_menu_entries.is_empty() + && app.input.starts_with('/') + && apply_slash_menu_selection(app, &slash_menu_entries, false) + { + app.close_slash_menu(); + } if let Some(input) = app.submit_input() { if handle_plan_choice(app, &engine_handle, &input).await? { continue; From 754e8bd4688f8e3384cb3876b154cb5156ec7bee Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 4 May 2026 09:55:16 -0500 Subject: [PATCH 6/9] fix(v0.8.10): cache-aware compaction and onboarding paste --- crates/tui/src/compaction.rs | 152 +++++++++++++++++++++++++++------ crates/tui/src/tui/ui.rs | 17 +++- crates/tui/src/tui/ui/tests.rs | 10 +++ 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index e3f87e23..cda69077 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -60,6 +60,7 @@ const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000; const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000; const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048; const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000; +const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85; #[derive(Debug, Clone, Copy)] struct SummaryInputLimits { @@ -819,6 +820,92 @@ async fn create_summary( model: &str, ) -> Result { let limits = summary_input_limits_for_model(model); + let request = if should_use_cache_aligned_summary(model, messages) { + build_cache_aligned_summary_request(model, messages, limits) + } else { + build_formatted_summary_request(model, messages, limits) + }; + + let response = client.create_message(request).await?; + // Compaction summary calls are billed by DeepSeek; route the + // tokens through the side-channel so the dashboard total + // matches the website (#526). + crate::cost_status::report(&response.model, &response.usage); + + // Extract text from response + let summary = response + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n"); + + Ok(summary) +} + +fn should_use_cache_aligned_summary(model: &str, messages: &[Message]) -> bool { + let Some(window) = context_window_for_model(model) else { + return false; + }; + if window < LARGE_CONTEXT_WINDOW_TOKENS { + return false; + } + + let budget = usize::try_from(window).unwrap_or(usize::MAX) + * CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT + / 100; + let summary_prompt_tokens = 512usize; + estimate_tokens(messages).saturating_add(summary_prompt_tokens) <= budget +} + +fn summary_instruction(word_limit: usize) -> String { + format!( + "Summarize the conversation above in a concise but comprehensive way. \ + Preserve key information, decisions made, exact file paths, commands, \ + errors, and tool-result facts needed to continue the work. \ + Tool outputs may be abbreviated only when they are repetitive. \ + Keep it under {word_limit} words." + ) +} + +fn build_cache_aligned_summary_request( + model: &str, + messages: &[Message], + limits: SummaryInputLimits, +) -> MessageRequest { + let mut request_messages = messages.to_vec(); + request_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: summary_instruction(limits.word_limit), + cache_control: None, + }], + }); + + MessageRequest { + model: model.to_string(), + messages: request_messages, + max_tokens: limits.max_tokens, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: Some(false), + temperature: Some(0.3), + top_p: None, + } +} + +fn build_formatted_summary_request( + model: &str, + messages: &[Message], + limits: SummaryInputLimits, +) -> MessageRequest { // Format messages for summarization let mut conversation_text = String::new(); for msg in messages { @@ -861,18 +948,14 @@ async fn create_summary( format!("{head}\n\n[... {omitted} characters omitted before summary ...]\n\n{tail}"); } - let request = MessageRequest { + MessageRequest { model: model.to_string(), messages: vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { text: format!( - "Summarize the following conversation in a concise but comprehensive way. \ - Preserve key information, decisions made, exact file paths, commands, \ - errors, and tool-result facts needed to continue the work. \ - Tool outputs may be abbreviated only when they are repetitive. \ - Keep it under {} words.\n\n---\n\n{conversation_text}", - limits.word_limit + "{}\n\n---\n\n{conversation_text}", + summary_instruction(limits.word_limit) ), cache_control: None, }], @@ -889,26 +972,7 @@ async fn create_summary( stream: Some(false), temperature: Some(0.3), top_p: None, - }; - - let response = client.create_message(request).await?; - // Compaction summary calls are billed by DeepSeek; route the - // tokens through the side-channel so the dashboard total - // matches the website (#526). - crate::cost_status::report(&response.model, &response.usage); - - // Extract text from response - let summary = response - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text, .. } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\n"); - - Ok(summary) + } } /// Extract workflow context from messages (files touched, tasks, etc.) @@ -1113,6 +1177,40 @@ mod tests { assert!(v4.max_tokens > legacy.max_tokens); } + #[test] + fn cache_aligned_summary_is_used_for_v4_scale_contexts() { + let messages = vec![msg("user", "Please edit crates/tui/src/compaction.rs")]; + + assert!(should_use_cache_aligned_summary( + "deepseek-v4-flash", + &messages + )); + assert!(!should_use_cache_aligned_summary( + "deepseek-v3.2-128k", + &messages + )); + } + + #[test] + fn cache_aligned_summary_request_preserves_message_prefix() { + let messages = vec![ + msg("user", "Please edit crates/tui/src/compaction.rs"), + msg("assistant", "I will inspect the file."), + ]; + let limits = summary_input_limits_for_model("deepseek-v4-pro"); + let request = build_cache_aligned_summary_request("deepseek-v4-pro", &messages, limits); + + assert_eq!(request.system, None); + assert_eq!(&request.messages[..messages.len()], &messages[..]); + assert_eq!(request.messages.len(), messages.len() + 1); + let last = request.messages.last().expect("summary instruction"); + assert_eq!(last.role, "user"); + assert!(matches!( + &last.content[..], + [ContentBlock::Text { text, .. }] if text.contains("conversation above") + )); + } + #[test] fn estimate_tokens_empty_messages() { let messages: Vec = vec![]; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 23f1588d..62bc845f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -232,6 +232,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { } let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + terminal.clear()?; let event_broker = EventBroker::new(); // Local mutable copy so runtime config flips (e.g. `/provider` switch) @@ -1721,10 +1722,6 @@ async fn run_event_loop( app.delete_api_key_char(); sync_api_key_validation_status(app, false); } - KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => { - app.insert_api_key_char(c); - sync_api_key_validation_status(app, false); - } KeyCode::Char('v') | KeyCode::Char('V') if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => { @@ -1732,6 +1729,12 @@ async fn run_event_loop( app.paste_api_key_from_clipboard(); sync_api_key_validation_status(app, false); } + KeyCode::Char(c) + if app.onboarding == OnboardingState::ApiKey && is_text_input_key(&key) => + { + app.insert_api_key_char(c); + sync_api_key_validation_status(app, false); + } _ => {} } continue; @@ -7234,6 +7237,12 @@ fn is_paste_shortcut(key: &KeyEvent) -> bool { key.modifiers.contains(KeyModifiers::CONTROL) } +fn is_text_input_key(key: &KeyEvent) -> bool { + !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::SUPER) +} + fn is_ctrl_h_backspace(key: &KeyEvent) -> bool { matches!(key.code, KeyCode::Char('h')) && key.modifiers.contains(KeyModifiers::CONTROL) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8394e9c3..5a2e69dc 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1521,6 +1521,16 @@ fn api_key_validation_warns_without_blocking_unusual_formats() { )); } +#[test] +fn api_key_paste_shortcut_is_not_plain_text_input() { + let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + assert!(is_paste_shortcut(&ctrl_v)); + assert!(!is_text_input_key(&ctrl_v)); + + let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT); + assert!(is_text_input_key(&shifted)); +} + #[test] fn jump_to_adjacent_tool_cell_finds_next_and_previous() { let mut app = create_test_app(); From c8fe367e3d1ab1328fec8b284c5d6d566d098bfc Mon Sep 17 00:00:00 2001 From: wuyuxin Date: Mon, 4 May 2026 20:46:43 +0800 Subject: [PATCH 7/9] fix(markdown): render tables, bold/italic, and horizontal rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Block::TableRow and Block::HorizontalRule variants - Parse | table | rows |, drop separator rows (|---|) - Parse --- / *** / ___ as horizontal rules - Rewrite inline span parser to handle **bold** and *italic* spanning multiple words, with infinite-loop guard for unclosed markers - Render table cells with │ separators and equal-width columns - Apply inline formatting inside table cells Co-Authored-By: Claude Sonnet 4.6 --- crates/tui/src/tui/markdown_render.rs | 304 +++++++++++++++++++++----- 1 file changed, 254 insertions(+), 50 deletions(-) diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 0a42d09f..7c53a457 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -29,7 +29,7 @@ use std::cell::Cell; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; use crate::tui::osc8; @@ -66,10 +66,14 @@ pub enum Block { Heading { level: usize, text: String }, /// A horizontal rule emitted under a level-1 heading. HeadingRule, + /// A standalone `---` / `***` / `___` horizontal rule. + HorizontalRule, /// A bullet (`-`/`*`) or ordered (`1.`) list item with its prefix and body. ListItem { bullet: String, text: String }, /// A line inside a fenced code block. Fences themselves are dropped. Code { line: String }, + /// A table row: cells split on `|`. Separator rows (`|---|`) are dropped. + TableRow(Vec), /// A non-empty paragraph line that may contain inline links. Paragraph { text: String }, /// An empty source line, preserved so paragraph spacing survives. @@ -133,6 +137,17 @@ pub fn parse(content: &str) -> ParsedMarkdown { continue; } + if is_horizontal_rule(trimmed) { + blocks.push(Block::HorizontalRule); + continue; + } + + match parse_table_row(trimmed) { + Some(cells) => { blocks.push(Block::TableRow(cells)); continue; } + None if trimmed.starts_with('|') => continue, // separator row — drop it + None => {} + } + if raw_line.is_empty() { blocks.push(Block::Blank); continue; @@ -171,6 +186,15 @@ pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) -> Style::default().fg(palette::TEXT_DIM), ))); } + Block::HorizontalRule => { + out.push(Line::from(Span::styled( + "─".repeat(width.min(60)), + Style::default().fg(palette::TEXT_DIM), + ))); + } + Block::TableRow(cells) => { + out.extend(render_table_row(cells, width, base_style)); + } Block::ListItem { bullet, text } => { let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY); out.extend(render_list_line( @@ -320,53 +344,204 @@ fn render_line_with_links( return vec![Line::from("")]; } + // Flatten inline tokens into (word, style) pairs preserving inter-token spaces. + let tokens = parse_inline_spans(line, base_style, link_style); + let mut words: Vec<(String, Style)> = Vec::new(); + for (text, style) in tokens { + let mut first = true; + for part in text.split(' ') { + if !first { + // The space consumed by split — attach as a plain space word + // so the wrap loop can decide whether to keep or break it. + words.push((" ".to_string(), base_style)); + } + if !part.is_empty() { + words.push((part.to_string(), style)); + } + first = false; + } + } + let mut lines = Vec::new(); let mut current_spans: Vec = Vec::new(); let mut current_width = 0usize; - for word in line.split_whitespace() { - let is_link = looks_like_link(word); - let style = if is_link { link_style } else { base_style }; - let word_width = word.width(); - let additional = if current_width == 0 { - word_width - } else { - word_width + 1 - }; - - if current_width + additional > width && !current_spans.is_empty() { + for (word, style) in words { + let ww = word.width(); + if word == " " { + // Space: emit only if we're mid-line and it fits; otherwise drop + // (it's a potential wrap point, not content). + if !current_spans.is_empty() && current_width + 1 <= width { + current_spans.push(Span::raw(" ")); + current_width += 1; + } + continue; + } + // Wrap before this word if it doesn't fit. + if current_width > 0 && current_width + ww > width { + // Trim trailing space span before breaking. + if let Some(last) = current_spans.last() { + if last.content.as_ref() == " " { + current_spans.pop(); + } + } lines.push(Line::from(current_spans)); current_spans = Vec::new(); current_width = 0; } - - if current_width > 0 { - current_spans.push(Span::raw(" ")); - current_width += 1; - } - - // For URLs, wrap the visible text in OSC 8 escapes when the runtime - // flag allows it. Display width is computed from the bare URL — the - // escapes are zero-width on supporting terminals and ignored on the - // rest. The clipboard / selection path strips OSC 8 before yanking. - let content = if is_link && osc8::enabled() { - osc8::wrap_link(word, word) - } else { - word.to_string() - }; - current_spans.push(Span::styled(content, style)); - current_width += word_width; + current_spans.push(Span::styled(word, style)); + current_width += ww; } if !current_spans.is_empty() { lines.push(Line::from(current_spans)); } - + if lines.is_empty() { + lines.push(Line::from("")); + } lines } -fn looks_like_link(word: &str) -> bool { - word.starts_with("http://") || word.starts_with("https://") +/// Parse an entire line into (text, style) segments, handling **bold**, +/// *italic*, and bare URLs that may span multiple words. +fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(String, Style)> { + let bold_style = base_style.add_modifier(Modifier::BOLD); + let italic_style = base_style.add_modifier(Modifier::ITALIC); + let mut out = Vec::new(); + let mut rest = line; + + while !rest.is_empty() { + // **bold** + if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) { + let inner = &rest[2..2 + end]; + out.push((inner.to_string(), bold_style)); + rest = &rest[2 + end + 2..]; + continue; + } + // __bold__ + if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) { + let inner = &rest[2..2 + end]; + out.push((inner.to_string(), bold_style)); + rest = &rest[2 + end + 2..]; + continue; + } + // *italic* + if rest.starts_with('*') && !rest.starts_with("**") { + if let Some(end) = rest[1..].find('*') { + let inner = &rest[1..1 + end]; + out.push((inner.to_string(), italic_style)); + rest = &rest[1 + end + 1..]; + continue; + } + } + // _italic_ + if rest.starts_with('_') && !rest.starts_with("__") { + if let Some(end) = rest[1..].find('_') { + let inner = &rest[1..1 + end]; + out.push((inner.to_string(), italic_style)); + rest = &rest[1 + end + 1..]; + continue; + } + } + // URL: consume until whitespace + if rest.starts_with("http://") || rest.starts_with("https://") { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let url = &rest[..end]; + let content = if osc8::enabled() { osc8::wrap_link(url, url) } else { url.to_string() }; + out.push((content, link_style)); + rest = &rest[end..]; + continue; + } + // Plain text: consume until next marker or URL; always advance at least 1 char. + let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8())); + out.push((rest[..next].to_string(), base_style)); + rest = &rest[next..]; + } + out +} + +/// Find the index of the next inline marker (`**`, `__`, `*`, `_`, `http`) +/// in `s`, or `s.len()` if none found. +fn find_next_marker(s: &str) -> usize { + let mut i = 0; + let bytes = s.as_bytes(); + while i < bytes.len() { + let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8()); + let slice = &s[i..]; + if slice.starts_with("**") || slice.starts_with("__") + || (slice.starts_with('*') && !slice.starts_with("**")) + || (slice.starts_with('_') && !slice.starts_with("__")) + || slice.starts_with("http://") + || slice.starts_with("https://") + { + return i; + } + i += ch_len; + } + s.len() +} + + +fn is_horizontal_rule(line: &str) -> bool { + let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + (stripped.chars().all(|c| c == '-') || stripped.chars().all(|c| c == '*') || stripped.chars().all(|c| c == '_')) + && stripped.len() >= 3 +} + +/// Parse a markdown table row like `| foo | bar |` into trimmed cell strings. +/// Returns `None` for separator rows (`|---|---|`). +fn parse_table_row(line: &str) -> Option> { + if !line.starts_with('|') { + return None; + } + let inner = line.trim_matches('|'); + let cells: Vec = inner + .split('|') + .map(|c| c.trim().to_string()) + .collect(); + // Separator row: every non-empty cell is only dashes/colons/spaces + if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ')) { + return None; + } + Some(cells) +} + +fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec> { + if cells.is_empty() { + return vec![Line::from("")]; + } + let col_width = (width.saturating_sub(cells.len() + 1)) / cells.len(); + let col_width = col_width.max(4); + let sep_style = Style::default().fg(palette::TEXT_DIM); + let mut spans: Vec = vec![Span::styled("│ ".to_string(), sep_style)]; + for (i, cell) in cells.iter().enumerate() { + let truncated = if cell.width() > col_width { + let mut s = String::new(); + let mut w = 0; + for ch in cell.chars() { + let cw = ch.width().unwrap_or(1); + if w + cw + 1 > col_width { s.push('…'); break; } + s.push(ch); + w += cw; + } + s + } else { + cell.clone() + }; + let cell_spans: Vec<(String, Style)> = parse_inline_spans(&truncated, base_style, link_style()); + let cell_width: usize = cell_spans.iter().map(|(t, _)| t.width()).sum(); + let pad = col_width.saturating_sub(cell_width); + for (text, style) in cell_spans { + spans.push(Span::styled(text, style)); + } + spans.push(Span::raw(" ".repeat(pad))); + if i + 1 < cells.len() { + spans.push(Span::styled(" │ ".to_string(), sep_style)); + } else { + spans.push(Span::styled(" │".to_string(), sep_style)); + } + } + vec![Line::from(spans)] } fn link_style() -> Style { @@ -418,28 +593,28 @@ mod tests { use super::*; use ratatui::style::Style; - fn collect_text(lines: &[Line<'static>]) -> Vec { - lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) - .collect() - } #[test] fn render_markdown_matches_parse_then_render() { - // The convenience wrapper must produce byte-identical output to the - // explicit two-step path. Without this guarantee the transcript cache - // and the live render diverge. + // Both calls run in the same thread under the same OSC8 lock so the + // flag is identical for both paths. let source = "# Title\n\nA paragraph with a https://example.com link.\n\n- one\n- two\n```\ncode\n```"; - let direct = render_markdown(source, 40, Style::default()); - let parsed = parse(source); - let two_step = render_parsed(&parsed, 40, Style::default()); - assert_eq!(collect_text(&direct), collect_text(&two_step)); + let direct = render_with_osc8(false, source); + let two_step = { + use std::sync::Mutex; + static OSC8_GUARD: Mutex<()> = Mutex::new(()); + let _guard = OSC8_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + let prior = osc8::enabled(); + osc8::set_enabled(false); + let parsed = parse(source); + let result: String = render_parsed(&parsed, 80, Style::default()) + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + osc8::set_enabled(prior); + result + }; + assert_eq!(direct, two_step); } #[test] @@ -556,4 +731,33 @@ mod tests { ); assert!(joined.contains("https://example.com")); } + + #[test] + fn table_separator_row_is_dropped() { + // "|---|---|" must not appear in output + let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.85+ |\n"; + let parsed = parse(src); + let blocks: Vec<_> = parsed.blocks.iter().collect(); + // Should have 2 TableRow blocks (header + data), no separator + let table_rows: Vec<_> = blocks.iter().filter(|b| matches!(b, Block::TableRow(_))).collect(); + assert_eq!(table_rows.len(), 2, "expected 2 table rows, got {}: {blocks:?}", table_rows.len()); + } + + #[test] + fn bold_markers_stripped_in_render() { + let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n"; + let lines = render_markdown(src, 80, Style::default()); + let text: String = lines.iter().flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())).collect(); + assert!(!text.contains("**"), "bold markers leaked into output: {text:?}"); + assert!(text.contains("Rust"), "bold content missing: {text:?}"); + } + + #[test] + fn table_renders_with_pipe_separator() { + let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n"; + let lines = render_markdown(src, 60, Style::default()); + let text: String = lines.iter().flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())).collect(); + assert!(text.contains('│'), "table pipe separator missing: {text:?}"); + assert!(!text.contains("|---|"), "separator row leaked: {text:?}"); + } } From 08a3a8f5f5762db669c2a1b8619c263a57c54c82 Mon Sep 17 00:00:00 2001 From: Wu Yuxin Date: Mon, 4 May 2026 21:19:08 +0800 Subject: [PATCH 8/9] Update crates/tui/src/tui/markdown_render.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- crates/tui/src/tui/markdown_render.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 7c53a457..eb826657 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -353,7 +353,7 @@ fn render_line_with_links( if !first { // The space consumed by split — attach as a plain space word // so the wrap loop can decide whether to keep or break it. - words.push((" ".to_string(), base_style)); + words.push((" ".to_string(), style)); } if !part.is_empty() { words.push((part.to_string(), style)); From 6bcf07a479977b4ad64142834565c2ded965eb76 Mon Sep 17 00:00:00 2001 From: Wu Yuxin Date: Mon, 4 May 2026 21:19:18 +0800 Subject: [PATCH 9/9] Update crates/tui/src/tui/markdown_render.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- crates/tui/src/tui/markdown_render.rs | 107 +++++++++++++++++--------- 1 file changed, 69 insertions(+), 38 deletions(-) diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index eb826657..1dcff136 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -143,7 +143,10 @@ pub fn parse(content: &str) -> ParsedMarkdown { } match parse_table_row(trimmed) { - Some(cells) => { blocks.push(Block::TableRow(cells)); continue; } + Some(cells) => { + blocks.push(Block::TableRow(cells)); + continue; + } None if trimmed.starts_with('|') => continue, // separator row — drop it None => {} } @@ -371,7 +374,7 @@ fn render_line_with_links( if word == " " { // Space: emit only if we're mid-line and it fits; otherwise drop // (it's a potential wrap point, not content). - if !current_spans.is_empty() && current_width + 1 <= width { + if !current_spans.is_empty() && current_width < width { current_spans.push(Span::raw(" ")); current_width += 1; } @@ -380,10 +383,10 @@ fn render_line_with_links( // Wrap before this word if it doesn't fit. if current_width > 0 && current_width + ww > width { // Trim trailing space span before breaking. - if let Some(last) = current_spans.last() { - if last.content.as_ref() == " " { - current_spans.pop(); - } + if let Some(last) = current_spans.last() + && last.content.as_ref() == " " + { + current_spans.pop(); } lines.push(Line::from(current_spans)); current_spans = Vec::new(); @@ -426,28 +429,34 @@ fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<( continue; } // *italic* - if rest.starts_with('*') && !rest.starts_with("**") { - if let Some(end) = rest[1..].find('*') { - let inner = &rest[1..1 + end]; - out.push((inner.to_string(), italic_style)); - rest = &rest[1 + end + 1..]; - continue; - } + if rest.starts_with('*') + && !rest.starts_with("**") + && let Some(end) = rest[1..].find('*') + { + let inner = &rest[1..1 + end]; + out.push((inner.to_string(), italic_style)); + rest = &rest[1 + end + 1..]; + continue; } // _italic_ - if rest.starts_with('_') && !rest.starts_with("__") { - if let Some(end) = rest[1..].find('_') { - let inner = &rest[1..1 + end]; - out.push((inner.to_string(), italic_style)); - rest = &rest[1 + end + 1..]; - continue; - } + if rest.starts_with('_') + && !rest.starts_with("__") + && let Some(end) = rest[1..].find('_') + { + let inner = &rest[1..1 + end]; + out.push((inner.to_string(), italic_style)); + rest = &rest[1 + end + 1..]; + continue; } // URL: consume until whitespace if rest.starts_with("http://") || rest.starts_with("https://") { let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let url = &rest[..end]; - let content = if osc8::enabled() { osc8::wrap_link(url, url) } else { url.to_string() }; + let content = if osc8::enabled() { + osc8::wrap_link(url, url) + } else { + url.to_string() + }; out.push((content, link_style)); rest = &rest[end..]; continue; @@ -468,7 +477,8 @@ fn find_next_marker(s: &str) -> usize { while i < bytes.len() { let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8()); let slice = &s[i..]; - if slice.starts_with("**") || slice.starts_with("__") + if slice.starts_with("**") + || slice.starts_with("__") || (slice.starts_with('*') && !slice.starts_with("**")) || (slice.starts_with('_') && !slice.starts_with("__")) || slice.starts_with("http://") @@ -481,10 +491,11 @@ fn find_next_marker(s: &str) -> usize { s.len() } - fn is_horizontal_rule(line: &str) -> bool { let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect(); - (stripped.chars().all(|c| c == '-') || stripped.chars().all(|c| c == '*') || stripped.chars().all(|c| c == '_')) + (stripped.chars().all(|c| c == '-') + || stripped.chars().all(|c| c == '*') + || stripped.chars().all(|c| c == '_')) && stripped.len() >= 3 } @@ -495,12 +506,12 @@ fn parse_table_row(line: &str) -> Option> { return None; } let inner = line.trim_matches('|'); - let cells: Vec = inner - .split('|') - .map(|c| c.trim().to_string()) - .collect(); + let cells: Vec = inner.split('|').map(|c| c.trim().to_string()).collect(); // Separator row: every non-empty cell is only dashes/colons/spaces - if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ')) { + if cells + .iter() + .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ')) + { return None; } Some(cells) @@ -510,7 +521,7 @@ fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec
  • = vec![Span::styled("│ ".to_string(), sep_style)]; @@ -520,7 +531,10 @@ fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec
  • col_width { s.push('…'); break; } + if w + cw + 1 > col_width { + s.push('…'); + break; + } s.push(ch); w += cw; } @@ -528,7 +542,8 @@ fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec
  • = parse_inline_spans(&truncated, base_style, link_style()); + let cell_spans: Vec<(String, Style)> = + parse_inline_spans(&truncated, base_style, link_style()); let cell_width: usize = cell_spans.iter().map(|(t, _)| t.width()).sum(); let pad = col_width.saturating_sub(cell_width); for (text, style) in cell_spans { @@ -593,7 +608,6 @@ mod tests { use super::*; use ratatui::style::Style; - #[test] fn render_markdown_matches_parse_then_render() { // Both calls run in the same thread under the same OSC8 lock so the @@ -739,16 +753,30 @@ mod tests { let parsed = parse(src); let blocks: Vec<_> = parsed.blocks.iter().collect(); // Should have 2 TableRow blocks (header + data), no separator - let table_rows: Vec<_> = blocks.iter().filter(|b| matches!(b, Block::TableRow(_))).collect(); - assert_eq!(table_rows.len(), 2, "expected 2 table rows, got {}: {blocks:?}", table_rows.len()); + let table_rows: Vec<_> = blocks + .iter() + .filter(|b| matches!(b, Block::TableRow(_))) + .collect(); + assert_eq!( + table_rows.len(), + 2, + "expected 2 table rows, got {}: {blocks:?}", + table_rows.len() + ); } #[test] fn bold_markers_stripped_in_render() { let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n"; let lines = render_markdown(src, 80, Style::default()); - let text: String = lines.iter().flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())).collect(); - assert!(!text.contains("**"), "bold markers leaked into output: {text:?}"); + let text: String = lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert!( + !text.contains("**"), + "bold markers leaked into output: {text:?}" + ); assert!(text.contains("Rust"), "bold content missing: {text:?}"); } @@ -756,7 +784,10 @@ mod tests { fn table_renders_with_pipe_separator() { let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n"; let lines = render_markdown(src, 60, Style::default()); - let text: String = lines.iter().flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())).collect(); + let text: String = lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); assert!(text.contains('│'), "table pipe separator missing: {text:?}"); assert!(!text.contains("|---|"), "separator row leaked: {text:?}"); }