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

+56 -1
View File
@@ -11,7 +11,7 @@ use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail};
use futures::future::join_all;
use serde_json::Value;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
@@ -70,9 +70,26 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.collect::<Vec<_>>()
.join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string();
tokio::spawn(async move {
let res = async {
let mut parts = parts;
if source == "user" {
let session = app.session(&session_id)?;
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "prompt": text_only});
let o = crate::hooks::run(&app, "user_prompt", None, payload, Path::new(&session.cwd), sandbox.as_ref()).await;
if let Some(reason) = o.blocked {
anyhow::bail!("Prompt blocked by a hook: {}", reason.trim());
}
if !o.context.trim().is_empty() {
parts.push(Part::Text { text: format!("<hook-context event=\"user_prompt\">\n{}\n</hook-context>", o.context.trim()) });
}
}
if source == "user" {
parts.extend(crate::mentions::expand(&app, &session_id, &text_only).await);
}
let msg = Message::new(Role::User, parts);
app.store.add_message(&session_id, &msg)?;
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
@@ -236,6 +253,7 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
let mut final_text = String::new();
let mut stop_hooks = 0;
for _ in 0..MAX_STEPS {
if cancel.is_cancelled() {
break;
@@ -303,6 +321,16 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
}
break;
}
if calls.is_empty() && depth == 0 && stop_hooks < 3 && matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "") {
let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "last_message": final_text});
let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref());
let o = crate::hooks::run(&app, "stop", None, payload, &cwd, sandbox.as_ref()).await;
if let Some(reason) = o.blocked {
stop_hooks += 1;
save_user_parts(&app, session_id, vec![Part::Text { text: format!("<hook-feedback event=\"stop\">\n{}\n</hook-feedback>", reason.trim()) }])?;
continue;
}
}
if calls.is_empty() {
match step.stop_reason.as_str() {
"max_tokens" | "length" => app.emit(Event::Error {
@@ -453,6 +481,20 @@ async fn execute_call(
Decision::Allow => {}
}
// Hooks see the tool input (cloned only when hooks are configured).
let hook_input = if app.config().hooks.is_empty() { Value::Null } else { input.clone() };
let hook_payload = |extra: Option<(&str, Value)>| {
let mut v = serde_json::json!({"session_id": session.id, "cwd": session.cwd, "tool": name, "input": hook_input});
if let Some((k, x)) = extra {
v[k] = x;
}
v
};
let pre = crate::hooks::run(app, "pre_tool", Some(name), hook_payload(None), &ctx.cwd, ctx.sandbox.as_ref()).await;
if let Some(reason) = pre.blocked {
return tool_result(call_id, call_name, format!("Blocked by a hook: {}", reason.trim()), true, None);
}
// Checkpoint: remember files before the agent changes them (rewind can restore them).
if tool.is_edit() {
if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) {
@@ -482,6 +524,19 @@ async fn execute_call(
}
other => other,
};
let res = match res {
Ok(mut out) => {
let result = serde_json::json!({"content": out.content.chars().take(20_000).collect::<String>(), "is_error": out.is_error});
let post = crate::hooks::run(app, "post_tool", Some(name), hook_payload(Some(("result", result))), &ctx.cwd, ctx.sandbox.as_ref()).await;
if let Some(reason) = post.blocked {
out.content.push_str(&format!("\n\n<hook-feedback event=\"post_tool\">\n{}\n</hook-feedback>", reason.trim()));
} else if !post.context.trim().is_empty() {
out.content.push_str(&format!("\n\n<hook-context event=\"post_tool\">\n{}\n</hook-context>", post.context.trim()));
}
Ok(out)
}
e => e,
};
match res {
Ok(out) => Part::ToolResult {
call_id: call_id.into(),