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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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![];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user