- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
85 lines
3.0 KiB
Rust
85 lines
3.0 KiB
Rust
//! `@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),
|
||
owner_id: None,
|
||
};
|
||
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/"]);
|
||
}
|
||
}
|