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/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/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/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/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/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/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/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 0a42d09f..1dcff136 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,20 @@ 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 +189,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 +347,216 @@ 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(), 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 < 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() + && 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("**") + && 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("__") + && 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(3 * 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 +608,27 @@ 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 +745,50 @@ 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:?}"); + } } 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; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b9976723..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) @@ -372,6 +373,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; @@ -1718,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 => { @@ -1729,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; @@ -2378,6 +2384,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; @@ -4662,6 +4681,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 @@ -5462,6 +5485,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; @@ -7166,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(); 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.