Skip to content

Commit ffd4252

Browse files
committed
fix: agent stop works, command approval UI, session cleanup on window close
- Emit session_id from backend so frontend stop button actually works - Add kill_all_agent_sessions() called on window close (belt+suspenders) - Add agent_approve_command Tauri command for run_command approval flow - Add COMMAND_APPROVALS static map for per-session approval oneshots - Frontend: listen for agent_session_id event and store it - Frontend: listen for agent_command_approval event, show modal overlay - Modal shows the exact command with Allow/Block buttons - Approval waits with cancel-race so Stop also unblocks pending approvals - Add agentApproveCommand IPC wrapper in tauri.ts Made-with: Cursor
1 parent 819eb51 commit ffd4252

File tree

6 files changed

+182
-9
lines changed

6 files changed

+182
-9
lines changed

clif-pad-ide/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clif-pad-ide/src-tauri/src/commands/agent.rs

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ use uuid::Uuid;
77
static AGENT_SESSIONS: std::sync::LazyLock<Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<()>>>>> =
88
std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
99

10+
// Pending command approvals: session_id -> oneshot sender with bool (true=approved)
11+
static COMMAND_APPROVALS: std::sync::LazyLock<Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<bool>>>>> =
12+
std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
13+
14+
/// Frontend calls this to approve or deny a pending run_command
15+
#[tauri::command]
16+
pub async fn agent_approve_command(session_id: String, approved: bool) -> Result<(), String> {
17+
let tx = {
18+
let mut map = COMMAND_APPROVALS.lock().map_err(|e| e.to_string())?;
19+
map.remove(&session_id)
20+
};
21+
if let Some(tx) = tx {
22+
let _ = tx.send(approved);
23+
}
24+
Ok(())
25+
}
26+
27+
/// Kill all active agent sessions (called on window close)
28+
pub fn kill_all_agent_sessions() {
29+
if let Ok(mut sessions) = AGENT_SESSIONS.lock() {
30+
for (_, tx) in sessions.drain() {
31+
let _ = tx.send(());
32+
}
33+
}
34+
}
35+
1036
/// Tool definitions for OpenAI function-calling format
1137
fn tool_definitions() -> Vec<serde_json::Value> {
1238
vec![
@@ -537,6 +563,9 @@ pub async fn agent_chat(
537563
sessions.insert(session_id.clone(), cancel_tx);
538564
}
539565

566+
// Emit session ID to frontend so it can call agent_stop
567+
let _ = window.emit("agent_session_id", &session_id);
568+
540569
let sid = session_id.clone();
541570

542571
tokio::spawn(async move {
@@ -571,7 +600,7 @@ pub async fn agent_chat(
571600
async fn run_agent_loop(
572601
app: tauri::AppHandle,
573602
label: &str,
574-
_session_id: &str,
603+
session_id: &str,
575604
initial_messages: Vec<super::ai::ChatMessage>,
576605
model: String,
577606
api_key: Option<String>,
@@ -889,13 +918,27 @@ async fn run_agent_loop(
889918
json!({ "id": id, "name": name, "arguments": args_str }),
890919
);
891920

892-
// Execute the tool — for run_command, race against cancel so Stop
893-
// works even while a subprocess is still running.
921+
// For run_command: request user approval before executing.
922+
// Emit approval request, wait for frontend response (or cancel).
894923
let result = if name == "run_command" {
895-
tokio::select! {
896-
res = execute_tool(name, &args, &workspace_dir) => res,
924+
let command_preview = args.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
925+
926+
let (approval_tx, approval_rx) = tokio::sync::oneshot::channel::<bool>();
927+
{
928+
let mut approvals = COMMAND_APPROVALS.lock().unwrap_or_else(|e| e.into_inner());
929+
approvals.insert(session_id.to_string(), approval_tx);
930+
}
931+
932+
let _ = app.emit_to(label, "agent_command_approval", json!({
933+
"session_id": session_id,
934+
"command": command_preview,
935+
"tool_call_id": id,
936+
}));
937+
938+
// Wait for approval or cancel
939+
let approved = tokio::select! {
940+
result = approval_rx => result.unwrap_or(false),
897941
_ = async {
898-
// Poll cancel channel every 250ms while command runs
899942
loop {
900943
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
901944
if cancel_rx.try_recv().is_ok() { break; }
@@ -905,6 +948,25 @@ async fn run_agent_loop(
905948
let _ = app.emit_to(label, "agent_stream", "[DONE]");
906949
return Ok(());
907950
}
951+
};
952+
953+
if !approved {
954+
"Command blocked by user.".to_string()
955+
} else {
956+
// Execute with cancel-race
957+
tokio::select! {
958+
res = execute_tool(name, &args, &workspace_dir) => res,
959+
_ = async {
960+
loop {
961+
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
962+
if cancel_rx.try_recv().is_ok() { break; }
963+
}
964+
} => {
965+
let _ = app.emit_to(label, "agent_stream", "\n*[Stopped by user]*\n");
966+
let _ = app.emit_to(label, "agent_stream", "[DONE]");
967+
return Ok(());
968+
}
969+
}
908970
}
909971
} else {
910972
execute_tool(name, &args, &workspace_dir).await

clif-pad-ide/src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ pub fn run() {
9292
if let Some(pty_state) = app.try_state::<PtyState>() {
9393
pty_state.kill_all_for_window(&label);
9494
}
95+
// Kill all active agent sessions
96+
commands::agent::kill_all_agent_sessions();
9597

9698
// Clean up file watchers for this window
9799
if let Some(watcher_state) = app.try_state::<WatcherState>() {
@@ -145,6 +147,7 @@ pub fn run() {
145147
commands::window::create_window,
146148
commands::agent::agent_chat,
147149
commands::agent::agent_stop,
150+
commands::agent::agent_approve_command,
148151
commands::security::scan_files_security,
149152
commands::security::scan_repo_security,
150153
])

clif-pad-ide/src/components/agent/AgentChatPanel.tsx

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, For, Show, createSignal, createEffect, onMount, onCleanup } from "solid-js";
1+
import { Component, For, Show, createSignal, createEffect, onMount, onCleanup, type Accessor } from "solid-js";
22
import {
33
agentMessages,
44
agentStreaming,
@@ -16,7 +16,7 @@ import { activeFile, projectRoot } from "../../stores/fileStore";
1616
import { currentBranch } from "../../stores/gitStore";
1717
import { settings, updateSettings } from "../../stores/settingsStore";
1818
import { fontSize } from "../../stores/uiStore";
19-
import { getApiKey, setApiKey as saveApiKey } from "../../lib/tauri";
19+
import { getApiKey, setApiKey as saveApiKey, agentApproveCommand } from "../../lib/tauri";
2020
import ChatMessage from "./ChatMessage";
2121
import ContextChip from "./ContextChip";
2222
import type { AgentContext } from "../../types/agent";
@@ -122,6 +122,7 @@ const AgentChatPanel: Component = () => {
122122
const [modelSort, setModelSort] = createSignal<"name" | "price-asc" | "price-desc" | "ctx">("name");
123123
const [modelProviderFilter, setModelProviderFilter] = createSignal("all");
124124
const [hoveredModel, setHoveredModel] = createSignal<string | null>(null);
125+
const [pendingCommand, setPendingCommand] = createSignal<{ sessionId: string; command: string; toolCallId: string } | null>(null);
125126

126127
async function fetchOpenRouterModels() {
127128
if (openRouterModels().length > 0) return;
@@ -168,6 +169,20 @@ const AgentChatPanel: Component = () => {
168169
await initAgentListeners();
169170
setInitialized(true);
170171
await checkApiKey();
172+
173+
// Listen for run_command approval requests from the agent
174+
const { listen } = await import("@tauri-apps/api/event");
175+
const unlisten = await listen<{ session_id: string; command: string; tool_call_id: string }>(
176+
"agent_command_approval",
177+
(event) => {
178+
setPendingCommand({
179+
sessionId: event.payload.session_id,
180+
command: event.payload.command,
181+
toolCallId: event.payload.tool_call_id,
182+
});
183+
}
184+
);
185+
onCleanup(() => unlisten());
171186
});
172187

173188
async function checkApiKey() {
@@ -1057,6 +1072,89 @@ const AgentChatPanel: Component = () => {
10571072
</div>
10581073
</Show>
10591074

1075+
{/* Command approval prompt */}
1076+
<Show when={pendingCommand()}>
1077+
{(cmd) => (
1078+
<div
1079+
style={{
1080+
position: "absolute", inset: "0", "z-index": "60",
1081+
background: "rgba(0,0,0,0.7)", "backdrop-filter": "blur(4px)",
1082+
display: "flex", "align-items": "center", "justify-content": "center",
1083+
padding: "16px",
1084+
}}
1085+
>
1086+
<div
1087+
style={{
1088+
background: "var(--bg-surface)", "border-radius": "12px",
1089+
border: "1px solid var(--border-default)",
1090+
"box-shadow": "0 16px 48px rgba(0,0,0,0.5)",
1091+
padding: "20px", width: "100%",
1092+
}}
1093+
>
1094+
<div class="flex items-center gap-2 mb-3">
1095+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--accent-yellow)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1096+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
1097+
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
1098+
</svg>
1099+
<span style={{ "font-size": "13px", "font-weight": "700", color: "var(--text-primary)" }}>
1100+
Agent wants to run a command
1101+
</span>
1102+
</div>
1103+
1104+
<div
1105+
style={{
1106+
background: "var(--bg-base)", "border-radius": "8px",
1107+
padding: "10px 12px", "margin-bottom": "14px",
1108+
"font-family": "var(--font-mono, monospace)", "font-size": "12px",
1109+
color: "var(--accent-yellow)", "word-break": "break-all",
1110+
border: "1px solid var(--border-muted)",
1111+
}}
1112+
>
1113+
{cmd().command}
1114+
</div>
1115+
1116+
<div class="flex gap-2">
1117+
<button
1118+
class="flex-1 rounded-lg py-2 text-sm font-semibold transition-colors"
1119+
style={{
1120+
background: "var(--accent-primary)", color: "#fff", border: "none", cursor: "pointer",
1121+
"font-size": "13px", "font-weight": "700",
1122+
}}
1123+
onClick={async () => {
1124+
const c = pendingCommand();
1125+
if (!c) return;
1126+
setPendingCommand(null);
1127+
await agentApproveCommand(c.sessionId, true);
1128+
}}
1129+
>
1130+
Allow
1131+
</button>
1132+
<button
1133+
class="flex-1 rounded-lg py-2 text-sm font-semibold transition-colors"
1134+
style={{
1135+
background: "color-mix(in srgb, var(--accent-red) 12%, transparent)",
1136+
color: "var(--accent-red)",
1137+
border: "1px solid color-mix(in srgb, var(--accent-red) 25%, transparent)",
1138+
cursor: "pointer", "font-size": "13px", "font-weight": "700",
1139+
}}
1140+
onClick={async () => {
1141+
const c = pendingCommand();
1142+
if (!c) return;
1143+
setPendingCommand(null);
1144+
await agentApproveCommand(c.sessionId, false);
1145+
}}
1146+
>
1147+
Block
1148+
</button>
1149+
</div>
1150+
<p style={{ "font-size": "10px", color: "var(--text-muted)", "margin-top": "8px", "text-align": "center" }}>
1151+
The agent will continue with the result either way
1152+
</p>
1153+
</div>
1154+
</div>
1155+
)}
1156+
</Show>
1157+
10601158
{/* Agent status bar — visible when agent loop is active */}
10611159
<Show when={agentStreaming()}>
10621160
<div

clif-pad-ide/src/lib/tauri.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ export async function agentStop(sessionId: string): Promise<void> {
247247
return invoke("agent_stop", { sessionId });
248248
}
249249

250+
export async function agentApproveCommand(sessionId: string, approved: boolean): Promise<void> {
251+
return invoke("agent_approve_command", { sessionId, approved });
252+
}
253+
250254
// Agent event listeners
251255
export function onAgentStream(callback: (chunk: string) => void): Promise<UnlistenFn> {
252256
return listen<string>("agent_stream", (event) => callback(event.payload));

clif-pad-ide/src/stores/agentStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ async function initAgentListeners() {
156156
})
157157
);
158158

159+
unlisteners.push(
160+
await listen<string>("agent_session_id", (event) => {
161+
setAgentSessionId(event.payload);
162+
})
163+
);
164+
159165
unlisteners.push(
160166
await listen<void>("agent_done", () => {
161167
setAgentStreaming(false);

0 commit comments

Comments
 (0)