Add hooks, @file mentions, session search/fork/export, follow-scroll

- Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks
- @file mentions with autocomplete; mentioned files/folders are attached to the prompt
- Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON
- Chat follows the newest message until the user scrolls up; jump-to-latest button
- SVG activity spark and thinking icon instead of Unicode/emoji glyphs
- File search ranks name matches first and returns relative paths

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 20:17:12 +01:00
1 parent ec1b9b4925
commit 013695722a
17 files changed
+911 -41

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
//! `@path` mentions in user prompts: the mentioned files (or folder listings)
//! are attached to the prompt, read with the regular read/list tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx};
use crate::types::Part;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
const MAX_MENTIONS: usize = 10;
const MAX_CHARS: usize = 60_000;
/// `@token`s at the start of the text or after whitespace (not e-mail addresses).
pub fn find(text: &str) -> Vec<String> {
let re = regex::Regex::new(r"(?:^|\s)@([\w./~+\-]+)").unwrap();
let mut out: Vec<String> = Vec::new();
for c in re.captures_iter(text) {
let token = c[1].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']).to_string();
if !token.is_empty() && !out.contains(&token) {
out.push(token);
}
}
out
}
/// Parts to append to a user prompt for its mentions.
pub async fn expand(app: &Arc<App>, session_id: &str, text: &str) -> Vec<Part> {
let tokens = find(text);
if tokens.is_empty() {
return vec![];
}
let Ok(session) = app.session(session_id) else { return vec![] };
let cwd = PathBuf::from(&session.cwd);
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.to_string(),
call_id: String::new(),
cwd: cwd.clone(),
cancel: CancellationToken::new(),
agent: session.agent.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room),
};
let mut parts = Vec::new();
for token in tokens.into_iter().take(MAX_MENTIONS) {
let Ok(path) = ctx.resolve_read(&token) else { continue };
if !path.exists() {
continue;
}
let res = if path.is_dir() {
crate::tools::fs::List.run(json!({"path": token}), &ctx).await
} else {
crate::tools::fs::Read.run(json!({"path": token}), &ctx).await
};
let Ok(out) = res else { continue };
let mut content = out.content;
if content.len() > MAX_CHARS {
let mut end = MAX_CHARS;
while !content.is_char_boundary(end) {
end -= 1;
}
content.truncate(end);
content.push_str("\n[… truncated – read the rest with the read tool]");
}
let kind = if path.is_dir() { "folder" } else { "file" };
parts.push(Part::Text { text: format!("<mentioned-{kind} path=\"{token}\">\n{content}\n</mentioned-{kind}>") });
parts.extend(out.images.into_iter().map(|image| Part::Image { image }));
}
parts
}
#[cfg(test)]
mod tests {
#[test]
fn finds_mentions() {
assert_eq!(super::find("look at @src/main.rs, and @README.md."), vec!["src/main.rs", "README.md"]);
assert!(super::find("mail me at a@b.de").is_empty());
assert_eq!(super::find("@docs/ please"), vec!["docs/"]);
}
}