Merge pull request #572 from Hmbown/feat/v0.8.10-features

feat(v0.8.10): shell_env hook + toast stack + @-mention frecency + keybindings audit (#456 #439 #441 #559)
This commit is contained in:
Hunter Bown
2026-05-04 10:10:57 -05:00
committed by GitHub
16 changed files with 1167 additions and 88 deletions
+21
View File
@@ -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)
+1
View File
@@ -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",
}
}
+125 -27
View File
@@ -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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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<Message> = vec![];
+137
View File
@@ -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<String, String> {
let mut merged: HashMap<String, String> = 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::<Vec<_>>(),
}),
);
// 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<HookResult> {
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<String, String> {
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");
+8 -1
View File
@@ -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."
);
}
+1
View File
@@ -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(),
+71 -5
View File
@@ -627,6 +627,34 @@ impl ShellManager {
stdin_data: Option<&str>,
tty: bool,
policy_override: Option<ExecutionSandboxPolicy>,
) -> Result<ShellResult> {
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<ExecutionSandboxPolicy>,
extra_env: HashMap<String, String>,
) -> Result<ShellResult> {
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<ExecutionSandboxPolicy>,
) -> Result<ShellResult> {
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<ExecutionSandboxPolicy>,
extra_env: HashMap<String, String>,
) -> Result<ShellResult> {
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<ExecutionSandboxPolicy>,
extra_env: HashMap<String, String>,
) -> Result<ShellResult> {
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
};
+5
View File
@@ -35,6 +35,10 @@ pub struct RuntimeToolServices {
pub task_data_dir: Option<PathBuf>,
pub active_task_id: Option<String>,
pub active_thread_id: Option<String>,
/// 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<std::sync::Arc<crate::hooks::HookExecutor>>,
}
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()
}
}
+45
View File
@@ -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<StatusToast> {
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<StatusToast> = 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<StatusToast> = 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<StatusToast> {
self.sync_status_message_to_toasts();
let now = Instant::now();
+261
View File
@@ -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<String, FrecencyEntry>` 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<String, FrecencyRecord>,
persisted_path: Option<PathBuf>,
loaded: bool,
}
fn store() -> &'static Mutex<Store> {
static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(Store::default()))
}
fn default_path() -> Option<PathBuf> {
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::<FrecencyRecord>(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<String>) -> Vec<String> {
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)
);
}
}
+8
View File
@@ -148,6 +148,9 @@ pub fn find_file_mention_completions(
limit: usize,
) -> Vec<String> {
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;
+286 -51
View File
@@ -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<String>),
/// 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<Span> = 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<Vec<String>> {
if !line.starts_with('|') {
return None;
}
let inner = line.trim_matches('|');
let cells: Vec<String> = 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<Line<'static>> {
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<Span> = 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<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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:?}");
}
}
+1
View File
@@ -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;
+81 -4
View File
@@ -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)
+10
View File
@@ -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();
+106
View File
@@ -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 `@<partial>` 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.