Add worktrees, per-hunk review, effort per message, regenerate, voice input
- Isolated git worktree per session (create, status, merge, discard) - Permission card: accept file-edit hunks individually; agent learns what was rejected - Reasoning effort per message; regenerate the last answer with another model/effort - Voice input via the Web Speech API - Folder-up button in the file browser; ssh, rsync and mc in the image Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df1924b0a1
commit
fca1c41087
22 files changed
+821
-32
No files matched your search
+49
-7
@@ -90,10 +90,13 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
||||
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 });
|
||||
maybe_generate_title(app.clone(), &session_id, &text_only);
|
||||
// Empty parts: continue from the existing history (regenerate).
|
||||
if !parts.is_empty() {
|
||||
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 });
|
||||
maybe_generate_title(app.clone(), &session_id, &text_only);
|
||||
}
|
||||
run_session(app.clone(), &session_id, cancel.clone(), 0).await
|
||||
}
|
||||
.await;
|
||||
@@ -105,6 +108,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
||||
// stopped the agent: then they stay queued until sent explicitly.
|
||||
let leftover = app.with_runtime(&session_id, |r| {
|
||||
r.cancel = None;
|
||||
r.effort = None;
|
||||
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
||||
});
|
||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||
@@ -271,7 +275,10 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
agent.prompt = prompts::craft_prompt(clean_room);
|
||||
}
|
||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||
let (provider, model) = app.registry().resolve(&model_spec)?;
|
||||
let (provider, mut model) = app.registry().resolve(&model_spec)?;
|
||||
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
||||
model.effort = Some(effort);
|
||||
}
|
||||
|
||||
// Auto-compaction when the last request used most of the context window.
|
||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||
@@ -464,6 +471,8 @@ async fn execute_call(
|
||||
};
|
||||
let name = name.as_str();
|
||||
|
||||
// Hunks of a file edit the user accepted in the review (None = whole change).
|
||||
let mut partial: Option<Vec<usize>> = None;
|
||||
// Permission check. Approvals are tracked on the top-level session.
|
||||
let root = app.root_session_id(&session.id);
|
||||
let cfg = app.config();
|
||||
@@ -476,7 +485,7 @@ async fn execute_call(
|
||||
}
|
||||
Decision::Ask => {
|
||||
let diff = tool.preview(&input, &ctx).await;
|
||||
let (reply, message) = app
|
||||
let (reply, message, hunks) = app
|
||||
.request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone())
|
||||
.await;
|
||||
match reply {
|
||||
@@ -499,6 +508,12 @@ async fn execute_call(
|
||||
}),
|
||||
PermissionReply::Once => {}
|
||||
}
|
||||
if let Some(sel) = hunks {
|
||||
if sel.is_empty() {
|
||||
return tool_result(call_id, call_name, "The user rejected all parts of this change.".into(), true, None);
|
||||
}
|
||||
partial = Some(sel);
|
||||
}
|
||||
}
|
||||
Decision::Allow => {}
|
||||
}
|
||||
@@ -528,7 +543,9 @@ async fn execute_call(
|
||||
}
|
||||
let input_path = input["path"].as_str().map(String::from);
|
||||
let input_for_phase = if base.agent == "craft" { input.clone() } else { Value::Null };
|
||||
let res = if name == "task" {
|
||||
let res = if let (Some(sel), true) = (&partial, matches!(name, "write" | "edit")) {
|
||||
apply_partial(&ctx, name, &input, sel).await
|
||||
} else if name == "task" {
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
@@ -669,6 +686,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
last_usage: None,
|
||||
clean_room: parent.clean_room,
|
||||
phase: None,
|
||||
worktree: None,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||
@@ -997,6 +1015,30 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
|
||||
Ok(title.chars().take(80).collect())
|
||||
}
|
||||
|
||||
/// Write only the hunks of a proposed write/edit that the user accepted.
|
||||
async fn apply_partial(ctx: &ToolCtx, name: &str, input: &Value, selected: &[usize]) -> Result<tools::ToolOutput> {
|
||||
let path = ctx.resolve(tools::arg_str(input, "path")?)?;
|
||||
let old = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let proposed = if name == "write" { tools::arg_str(input, "content")?.to_string() } else { tools::fs::apply_edit(&old, input)? };
|
||||
let (result, total) = tools::fs::apply_hunks(&old, &proposed, selected);
|
||||
let accepted = selected.iter().filter(|i| **i < total).count();
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
tokio::fs::write(&path, &result).await?;
|
||||
ctx.app.mark_read(&ctx.session_id, &path);
|
||||
let rel = path.strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.display().to_string());
|
||||
let applied = tools::fs::unified_diff(&rel, &old, &result);
|
||||
let rejected = tools::fs::unified_diff(&rel, &result, &proposed);
|
||||
Ok(tools::ToolOutput::text(format!(
|
||||
"The user reviewed your change to {rel} and applied only {accepted} of {total} hunks. \
|
||||
The file now contains the accepted parts; the rejected parts were NOT applied – do not re-apply them unless the user asks.\n\n\
|
||||
Rejected (what is still missing compared to your proposal):\n{}",
|
||||
clip(&rejected, 8000)
|
||||
))
|
||||
.with_meta(serde_json::json!({"diff": applied, "path": path, "partial": {"accepted": accepted, "total": total}})))
|
||||
}
|
||||
|
||||
const PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
|
||||
|
||||
/// Craft phase implied by a tool call (fallback when the agent does not report it).
|
||||
|
||||
Reference in new issue
Block a user