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:
1 parent
ec1b9b4925
commit
013695722a
17 files changed
+911
-41
No files matched your search
@@ -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/"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user