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
+56
-1
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user