diff --git a/default_config.toml b/default_config.toml index 262197ad..2762a369 100644 --- a/default_config.toml +++ b/default_config.toml @@ -571,6 +571,9 @@ process = ["rg"] [plugin_permissions.git] process = ["git"] +[plugin_permissions.agent] +process = ["git"] + # Git signs use the same glyphs as the default Red/Neovim setup. Each value # must occupy one or two terminal cells. [plugin_config.git.signs] diff --git a/plugins/agent.hk b/plugins/agent.hk index 09e4f118..3b5cdef7 100644 --- a/plugins/agent.hk +++ b/plugins/agent.hk @@ -7,6 +7,7 @@ struct AgentSessionEvent { session_id: String, + cwd: Option, } struct AgentTextEvent { @@ -61,8 +62,42 @@ struct AgentConversationEvent { cwd: String, model_info: Json, items: [AgentConversationItem], + live: Option, + status: Option, +} + +struct AgentThread { + thread_id: String, + cwd: String, + mode: String, + title: String, + branch: Option, + base_cwd: Option, + model_info: Json, + items: [AgentConversationItem], + selected: bool, + live: bool, + status: String, + status_detail: Option, } +struct AgentThreadsResult { threads: [AgentThread] } +struct AgentThreadRow { + id: String, + selectable: bool, + depth: i32, + segments: [PanelSegment], + right_segments: [PanelSegment], + data: Option, +} +struct AgentThreadWorkspaceRow { data: AgentThread } +struct AgentThreadWorkspaceEvent { + action: String, + row: AgentThreadWorkspaceRow, +} + +struct AgentDelegateFailedEvent { message: String, cwd: String } + struct AgentStringValueEvent { value: String, } @@ -263,6 +298,14 @@ struct AgentState { activity_inspector_detail: [[PanelSegment]], activity_block_id: String, thought: String, + threads_open: bool, + thread_detail: [[PanelSegment]], + delegate_task: String, + delegate_base_cwd: String, + delegate_branch: String, + delegate_worktree: String, + delegate_process: String, + delegate_error: String, } #[red::state] @@ -319,6 +362,14 @@ fn initial_state() -> AgentState { activity_inspector_detail: [], activity_block_id: "", thought: "", + threads_open: false, + thread_detail: [[PanelSegment { text: "Select a thread", style: muted_style() }]], + delegate_task: "", + delegate_base_cwd: "", + delegate_branch: "", + delegate_worktree: "", + delegate_process: "", + delegate_error: "", }; } @@ -627,6 +678,18 @@ fn cwd_loaded(result: AgentStringValueEvent) { #[red::on("agent:session_created")] fn session_created(event: AgentSessionEvent) { red::state_patch(AgentState { session_starting: false }); + let event_cwd = ""; + if let Some(cwd) = event.cwd { event_cwd = cwd; } + if state().delegate_worktree != "" && event_cwd == state().delegate_worktree { + let task = state().delegate_task; + red::state_patch(AgentState { + delegate_task: "", delegate_base_cwd: "", delegate_branch: "", delegate_worktree: "", + }); + red::execute("AgentPrompt", event.session_id, task); + red::execute("Print", "Delegated thread started · open Threads to follow it"); + refresh_threads(); + return; + } let previous_session_id = red::string(state().session_id, ""); let pending = red::string(state().pending_prompt, ""); let pending_block_id = red::string(state().pending_prompt_block_id, ""); @@ -755,6 +818,7 @@ fn ensure_conversation_panel() { title: "Agent", composer: TextPanelComposerConfig { placeholder: "Ask a follow-up…", rows: 3 }, header_actions: [ + TextPanelHeaderAction { id: "threads", label: "Threads", compact_label: "T" }, TextPanelHeaderAction { id: "activity", label: "Activity", compact_label: "A" }, TextPanelHeaderAction { id: "clear", label: "Clear", compact_label: "C" }, TextPanelHeaderAction { id: "new", label: "New", compact_label: "N" }, @@ -1387,6 +1451,8 @@ fn panel_event(event: AgentPanelEvent) { choose_model(); } else if event.action == "activity" { toggle_activity(); + } else if event.action == "threads" { + threads(); } else if event.action == "activate_block" { activate_activity_block(event.text); } else if event.action == "clear" { @@ -1539,17 +1605,46 @@ fn close_conversation() { scope = "global", )] fn new_conversation() { + if state().session_starting || state().delegate_task != "" || state().delegate_process != "" { + red::execute("Print", "An agent conversation is still starting"); + return; + } + red::execute("OpenPicker", "New agent conversation", [ + PickerItem { + id: "pair", label: "Pair in this workspace", + detail: "Work interactively with the active buffer and workspace", kind: "Conversation", + }, + PickerItem { + id: "delegate", label: "Delegate in a worktree", + detail: "Run isolated work in the background and review it later", kind: "Conversation", + }, + ], PickerOptions { + placeholder: "Choose how to work…", status: "Pair stays here · Delegate creates an isolated worktree", + presentation: "compact", item_layout: "label_first", + }, PickerHandlers { selected: new_mode_selected }); +} + +fn new_mode_selected(item: PickerItem) { + if item.id == "delegate" { + if state().delegate_task != "" || state().delegate_process != "" { + red::execute("Print", "A delegated thread is still starting"); + return; + } + red::execute("OpenComposer", "Delegate a task", "", state().prompt_history, ComposerHandlers { + submitted: delegate_task_submitted, + cancelled: delegate_task_cancelled, + }); + } else if item.id == "pair" { + start_pair_conversation(); + } +} + +fn start_pair_conversation() { if state().session_starting && state().session_id == "" { open_conversation(); return; } let session_id = red::string(state().session_id, ""); - if session_id != "" { - if !state().restoring { - red::execute("AgentCloseSession", session_id); - } - } - red::execute("AgentForgetSession", session_id); red::state_patch(AgentState { session_id: "" }); reset_model_state(); red::state_patch(AgentState { restoring: false }); @@ -1573,6 +1668,148 @@ fn new_conversation() { start(); } +fn delegate_task_cancelled(event: ComposerCancelled) {} + +fn delegate_task_submitted(text: String) { + let task = red::trim(text); + if task == "" { return; } + red::state_patch(AgentState { delegate_task: task }); + red::request("GetConfig", delegate_cwd_loaded, "cwd"); +} + +fn delegate_cwd_loaded(result: AgentStringValueEvent) { + let base = red::replace_all(red::string(result.value, "."), "\\", "/"); + let parts = red::split(base, "/"); + if red::len(parts) == 0 { + red::execute("Print", "Could not determine the workspace path"); + return; + } + let repository = parts[red::len(parts) - 1]; + let parent_parts = []; + let index = 0; + while index + 1 < red::len(parts) { + parent_parts = red::push(parent_parts, parts[index]); + index = index + 1; + } + let parent = red::join(parent_parts, "/"); + if parent == "" { parent = "."; } + let slug = delegate_slug(state().delegate_task); + let branch = "red/delegate/" + slug; + let worktree = parent + "/" + repository + ".delegate-" + slug; + red::state_patch(AgentState { + delegate_base_cwd: base, + delegate_branch: branch, + delegate_worktree: worktree, + }); + red::execute("OpenConfirm", "Start delegated work", + "Create an isolated worktree and start this task in the background.", + PickerHandlers { selected: delegate_confirmed, cancelled: delegate_confirm_cancelled }, + Json { + accept_label: "Delegate", + cancel_label: "Cancel", + rows: [ + [Json { text: "Branch ", style: muted_style() }, Json { text: branch, style: normal_style() }], + [Json { text: "Worktree ", style: muted_style() }, Json { text: worktree, style: normal_style() }], + ], + }); +} + +fn delegate_slug(task: String) -> String { + let slug = red::lower(red::trim(red::split(task, "\n")[0])); + for separator in [ + " ", "_", "/", "\\", ":", ".", ",", "(", ")", "[", "]", "?", "*", "^", "~", "{", "}", + "'", "\"", "|", "<", ">", "=", "+", ";", + ] { + slug = red::replace_all(slug, separator, "-"); + } + while red::len(red::split(slug, "--")) > 1 { + slug = red::replace_all(slug, "--", "-"); + } + if red::len(slug) > 40 { slug = red::slice(slug, 0, 40); } + if slug == "" { slug = "task"; } + return slug; +} + +fn delegate_confirm_cancelled(event: PickerCancelled) { + clear_delegate_start(); +} + +fn clear_delegate_start() { + red::state_patch(AgentState { + delegate_task: "", delegate_base_cwd: "", delegate_branch: "", delegate_worktree: "", + }); +} + +fn delegate_confirmed(item: PickerItem) { + if item.id != "accept" { + clear_delegate_start(); + return; + } + let process_id = red::execute("SpawnProcess", Process { + command: "git", + args: ["worktree", "add", "-b", state().delegate_branch, state().delegate_worktree, "HEAD"], + cwd: state().delegate_base_cwd, + env: Json { LC_ALL: "C" }, + }); + red::state_patch(AgentState { delegate_process: process_id, delegate_error: "" }); + red::on("process:" + process_id, delegate_worktree_event); + red::execute("Print", "Creating delegated worktree…"); +} + +fn delegate_worktree_event(event: ProcessEvent) { + match event { + ProcessEvent::Stdout { process_id, line, plugin_name } => { + if process_id == state().delegate_process { + red::state_patch(AgentState { delegate_error: state().delegate_error + line + "\n" }); + } + } + ProcessEvent::Stderr { process_id, line, plugin_name } => { + if process_id == state().delegate_process { + red::state_patch(AgentState { delegate_error: state().delegate_error + line + "\n" }); + } + } + ProcessEvent::Error { process_id, message, plugin_name } => { + if process_id == state().delegate_process { delegate_worktree_finished(false, message); } + } + ProcessEvent::Exit { process_id, code, plugin_name } => { + if process_id != state().delegate_process { return; } + let exit_code = 1; + if let Some(value) = code { exit_code = value; } + delegate_worktree_finished(exit_code == 0, "git exited with code " + exit_code); + } + } +} + +fn delegate_worktree_finished(succeeded: bool, fallback: String) { + let details = red::trim(state().delegate_error); + red::state_patch(AgentState { delegate_process: "", delegate_error: "" }); + if !succeeded { + if details == "" { details = fallback; } + red::state_patch(AgentState { + delegate_task: "", delegate_base_cwd: "", delegate_branch: "", delegate_worktree: "", + }); + red::execute("Print", "Could not create delegated worktree: " + details); + return; + } + let title = red::split(state().delegate_task, "\n")[0]; + red::execute( + "AgentNewDelegateSession", + state().delegate_worktree, + title, + state().delegate_branch, + state().delegate_base_cwd + ); + red::execute("Print", "Delegated work is starting in " + state().delegate_worktree); +} + +#[red::on("agent:delegate_failed")] +fn delegate_failed(event: AgentDelegateFailedEvent) { + red::state_patch(AgentState { + delegate_task: "", delegate_base_cwd: "", delegate_branch: "", delegate_worktree: "", + }); + red::execute("Print", "Could not start delegated work: " + event.message); +} + fn prompt_cancelled(event: ComposerCancelled) {} #[red::command( @@ -1711,6 +1948,7 @@ fn flat_transcript(blocks: [AgentTextBlock]) -> String { #[red::on("agent:completed")] fn completed(event: AgentCompletedEvent) { + refresh_threads(); if !is_current_session(event.session_id) { return; } @@ -1792,6 +2030,7 @@ fn cancelled(event: AgentSessionEvent) { #[red::on("agent:error")] fn failed(event: AgentErrorEvent) { + refresh_threads(); if !is_current_session(red::string(event.session_id, "")) { return; } @@ -2262,6 +2501,167 @@ fn legacy_transcript_blocks(transcript: String) -> [AgentTextBlock] { return blocks; } +#[red::command( + name = "AgentThreads", + title = "Show agent threads", + category = "Agent", + description = "Inspect pair and delegated conversations", + aliases = ["agent sessions", "delegated work"], + scope = "global", +)] +fn threads() { + red::state_patch(AgentState { threads_open: true }); + red::execute("OpenWorkspace", "agent-threads", WorkspaceConfig { + title: "Agent threads", + rows_title: "Threads", + detail_ratio: 62, + min_two_pane_width: 88, + }); + refresh_threads(); +} + +fn refresh_threads() { + if state().threads_open { red::request("AgentListThreads", render_threads); } +} + +fn append_thread_section(rows: [AgentThreadRow], threads: [AgentThread], status: String, title: String) -> [AgentThreadRow] { + let count = 0; + for thread in threads { + if thread.status == status { count = count + 1; } + } + if count == 0 { return rows; } + rows = red::push(rows, AgentThreadRow { + id: "section:" + status, + selectable: false, + segments: [PanelSegment { text: title + " " + count, style: heading_style() }], + }); + for thread in threads { + if thread.status != status { continue; } + let title = red::string(thread.title, "Untitled conversation"); + let marker = " "; + if thread.selected { marker = "● "; } + let mode = "Pair"; + if thread.mode == "delegate" { mode = "Delegate"; } + rows = red::push(rows, AgentThreadRow { + id: "thread:" + thread.thread_id, + selectable: true, + depth: 1, + segments: [ + PanelSegment { text: marker, style: success_style() }, + PanelSegment { text: title, style: normal_style() }, + ], + right_segments: [PanelSegment { text: mode, style: muted_style() }], + data: Some(thread), + }); + } + return rows; +} + +fn render_threads(result: AgentThreadsResult) { + if !state().threads_open { return; } + let rows: [AgentThreadRow] = []; + rows = append_thread_section(rows, result.threads, "Needs you", "Needs you"); + rows = append_thread_section(rows, result.threads, "Failed", "Failed"); + rows = append_thread_section(rows, result.threads, "Running", "Running"); + rows = append_thread_section(rows, result.threads, "Ready to review", "Ready to review"); + rows = append_thread_section(rows, result.threads, "Current", "Current"); + rows = append_thread_section(rows, result.threads, "History", "History"); + if red::len(rows) == 0 { + rows = red::push(rows, AgentThreadRow { + id: "empty", selectable: false, + segments: [PanelSegment { text: "No agent conversations yet", style: muted_style() }], + }); + } + red::execute("UpdateWorkspace", "agent-threads", WorkspaceModel { + rows: rows, + detail: state().thread_detail, + actions: [ + AgentWorkspaceAction { hint: AgentWorkspaceActionHint { id: "activate", key: "enter", label: "open", priority: "essential" } }, + AgentWorkspaceAction { hint: AgentWorkspaceActionHint { id: "n", key: "n", label: "new…", priority: "secondary" } }, + AgentWorkspaceAction { hint: AgentWorkspaceActionHint { id: "r", key: "r", label: "refresh", priority: "secondary" } }, + ], + }); +} + +fn thread_detail(thread: AgentThread) -> [[PanelSegment]] { + let title = red::string(thread.title, "Untitled conversation"); + let detail = [ + [PanelSegment { text: title, style: heading_style() }], + [PanelSegment { text: thread.status + " · " + thread.mode, style: muted_style() }], + ]; + let status_detail = red::string(thread.status_detail, ""); + if status_detail != "" { + detail = red::push(detail, [PanelSegment { text: status_detail, style: warning_style() }]); + } + let branch = red::string(thread.branch, ""); + if branch != "" { + detail = red::push(detail, [PanelSegment { text: "Branch " + branch, style: normal_style() }]); + } + detail = red::push(detail, [PanelSegment { text: "Worktree " + thread.cwd, style: muted_style() }]); + detail = red::push(detail, []); + for item in thread.items { + let label = "Agent"; + let style = normal_style(); + if item.role == "user" { label = "You"; style = heading_style(); } + detail = red::push(detail, [PanelSegment { text: label + ": " + item.text, style: style }]); + } + if red::len(thread.items) == 0 { + detail = red::push(detail, [PanelSegment { text: "Waiting for the first message…", style: muted_style() }]); + } + return detail; +} + +#[red::on("workspace:event:agent-threads")] +fn threads_event(event: AgentThreadWorkspaceEvent) { + if event.action == "q" || event.action == "escape" { + red::state_patch(AgentState { threads_open: false }); + red::execute("CloseWorkspace", "agent-threads"); + return; + } + if event.action == "n" { new_conversation(); return; } + if event.action == "r" { refresh_threads(); return; } + let row = event.row; + if row == red::null() || row.data.thread_id == red::null() { return; } + red::state_patch(AgentState { thread_detail: thread_detail(row.data) }); + if event.action == "activate" { + red::execute("AgentSelectThread", row.data.thread_id); + return; + } + refresh_threads(); +} + +#[red::on("agent:thread_selected")] +fn thread_selected(event: AgentConversationEvent) { + load_conversation(event); + red::state_patch(AgentState { + session_id: event.thread_id, + model_info: event.model_info, + accepted_model: Json {}, + turn_model_info: Json {}, + prompt_queue: [], + cancelled_session_id: "", + }); + let live = false; + if let Some(value) = event.live { live = value; } + let status = "History"; + if let Some(value) = event.status { status = value; } + red::state_patch(AgentState { turn_active: status == "Running", restoring: !live }); + red::state_patch(AgentState { threads_open: false }); + red::execute("CloseWorkspace", "agent-threads"); + ensure_conversation_panel(); + red::execute("SetPanelVisible", "agent-conversation", true); + if live { + set_phase(if status == "Running" { "waiting" } else { "idle" }, if status == "Running" { "Working in delegated worktree…" } else { "" }); + red::execute("SetTextPanelComposerState", "agent-conversation", true, ""); + } else { + set_phase("restoring", "Restoring session…"); + red::execute("SetTextPanelComposerState", "agent-conversation", false, "Restoring agent session…"); + red::execute("AgentResumeSession", event.cwd, event.thread_id); + } + refresh_model_header(); + red::execute("FocusTextPanelComposer", "agent-conversation"); +} + #[red::command( name = "AgentHistory", title = "Show agent history", @@ -2361,6 +2761,7 @@ fn history_event(event: AgentHistoryEvent) { #[red::on("agent:permission_requested")] fn permission_requested(event: AgentPermissionEvent) { + refresh_threads(); if !is_current_session(event.session_id) { return; } diff --git a/src/agent_conversation.rs b/src/agent_conversation.rs index e21a8661..2d7d1773 100644 --- a/src/agent_conversation.rs +++ b/src/agent_conversation.rs @@ -10,6 +10,14 @@ const EDITOR_CONTEXT_MARKER: &str = "\n\nActive editor context from "; /// Maximum source annotations retained with one Agent conversation. pub const MAX_AGENT_ANNOTATIONS: usize = 512; +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentThreadMode { + #[default] + Pair, + Delegate, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentTranscriptRole { @@ -45,6 +53,14 @@ pub struct AgentAnnotationRecord { pub struct AgentConversationSnapshot { pub thread_id: String, pub cwd: String, + #[serde(default)] + pub mode: AgentThreadMode, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_info: Option, #[serde(default)] @@ -94,6 +110,10 @@ impl AgentConversationSnapshot { Self { thread_id: thread_id.into(), cwd: cwd.into(), + mode: AgentThreadMode::Pair, + title: String::new(), + branch: None, + base_cwd: None, model_info: None, items: Vec::new(), annotations: Vec::new(), @@ -102,11 +122,15 @@ impl AgentConversationSnapshot { pub fn append_user(&mut self, turn_id: impl Into, text: impl Into) { let turn_id = turn_id.into(); + let text = text.into(); + if self.title.is_empty() { + self.title = concise_thread_title(&text); + } self.items.push(AgentTranscriptItem { id: format!("red-user-{turn_id}"), turn_id: Some(turn_id), role: AgentTranscriptRole::User, - text: text.into(), + text, }); self.enforce_limits(); } @@ -184,6 +208,21 @@ impl AgentConversationSnapshot { } } +fn concise_thread_title(text: &str) -> String { + const MAX_TITLE_CHARS: usize = 72; + let title = text.lines().next().unwrap_or_default().trim(); + let mut characters = title.chars(); + let compact = characters + .by_ref() + .take(MAX_TITLE_CHARS) + .collect::(); + if characters.next().is_some() { + format!("{}…", compact.trim_end()) + } else { + compact + } +} + fn transcript_items_from_thread(thread: &Value) -> Vec { let mut transcript = Vec::new(); let Some(turns) = thread.get("turns").and_then(Value::as_array) else { @@ -327,6 +366,22 @@ mod tests { assert_eq!(restored.items[1].text, "First\n\nSecond"); } + #[test] + fn first_user_message_supplies_a_bounded_thread_title() { + let mut conversation = AgentConversationSnapshot::new("thread-1", "/workspace"); + conversation.append_user( + "turn-1", + "Implement delegated conversations with a deliberately long first line that exceeds the navigation title limit\nMore detail", + ); + + assert!(conversation.title.ends_with('…')); + assert!(conversation.title.chars().count() <= 73); + conversation.append_user("turn-2", "This must not replace the title"); + assert!(conversation + .title + .starts_with("Implement delegated conversations")); + } + #[test] fn transcript_limits_count_characters_instead_of_utf8_bytes() { let mut conversation = AgentConversationSnapshot::new("thread", "/workspace"); diff --git a/src/codex/mod.rs b/src/codex/mod.rs index 9409c7a5..696daab1 100644 --- a/src/codex/mod.rs +++ b/src/codex/mod.rs @@ -274,6 +274,8 @@ pub enum CodexEvent { SessionCreated { /// Red session identifier. session_id: String, + /// Workspace root supplied when the thread was started. + cwd: PathBuf, }, /// A hidden commit-message generation request finished. CommitMessageGenerated { @@ -1653,7 +1655,7 @@ async fn handle_response( session_id.clone(), Session { model_info: AgentModelInfo::from_response(&message["result"]), - cwd, + cwd: cwd.clone(), active_turn: None, pending_interrupt_turn_id: None, cancelled: Arc::new(AtomicBool::new(false)), @@ -1672,7 +1674,7 @@ async fn handle_response( match launch { Some(SessionLaunch::New) => { events - .send(CodexEvent::SessionCreated { session_id }) + .send(CodexEvent::SessionCreated { session_id, cwd }) .await .ok(); } diff --git a/src/editor.rs b/src/editor.rs index 743627d0..4cbd71b1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1380,9 +1380,9 @@ fn agent_event_payload(event: CodexEvent) -> (&'static str, Value) { "agent:model_changed", json!({ "session_id": session_id, "model_info": model_info }), ), - CodexEvent::SessionCreated { session_id } => ( + CodexEvent::SessionCreated { session_id, cwd } => ( "agent:session_created", - json!({ "session_id": session_id.to_string() }), + json!({ "session_id": session_id.to_string(), "cwd": cwd }), ), CodexEvent::CommitMessageGenerated { request_id, result } => ( "agent:error", @@ -1742,6 +1742,18 @@ pub enum PluginRequest { AgentNewSession { cwd: PathBuf, }, + AgentNewDelegateSession { + cwd: PathBuf, + title: String, + branch: String, + base_cwd: PathBuf, + }, + AgentListThreads { + request_id: RequestId, + }, + AgentSelectThread { + session_id: String, + }, AgentResumeSession { cwd: PathBuf, session_id: String, @@ -2194,6 +2206,9 @@ impl PluginRequest { Self::AgentModelRequest { .. } => "AgentModelRequest", Self::SetTextPanelHeaderDetail { .. } => "SetTextPanelHeaderDetail", Self::AgentNewSession { .. } => "AgentNewSession", + Self::AgentNewDelegateSession { .. } => "AgentNewDelegateSession", + Self::AgentListThreads { .. } => "AgentListThreads", + Self::AgentSelectThread { .. } => "AgentSelectThread", Self::AgentResumeSession { .. } => "AgentResumeSession", Self::AgentPrompt { .. } => "AgentPrompt", Self::AgentPromptWithContext { .. } => "AgentPromptWithContext", @@ -3409,7 +3424,7 @@ pub struct Editor { lsp_coordinator: lsp_coordinator::LspCoordinator, /// Domain sub-controller managing background AI agent state and tool channels - agent_manager: agent_manager::AgentManager, + agent_manager: Box, /// One editor-owned bounded inline edit, including stale-response guards. inline_assist: Option, @@ -5131,7 +5146,7 @@ impl Editor { let lsp_coordinator = lsp_coordinator::LspCoordinator::with_buffers(&buffers); let buffer_manager = buffer_manager::BufferManager::with_buffers(buffers); let session_manager = session_manager::SessionManager::new(); - let agent_manager = agent_manager::AgentManager::new(); + let agent_manager = Box::new(agent_manager::AgentManager::new()); let whats_new_startup_pending = preferences.is_persistent() && config.show_whats_new.unwrap_or(true) && preferences.last_seen_version() != Some(env!("CARGO_PKG_VERSION")); @@ -8592,16 +8607,21 @@ impl Editor { } fn agent_context_payload(&self) -> Value { + let root = self + .agent_manager + .conversation_snapshot() + .map(|conversation| PathBuf::from(conversation.cwd)) + .or_else(|| self.agent_manager.root().map(Path::to_path_buf)) + .unwrap_or_else(get_workspace_path); + self.agent_context_payload_for_root(&root) + } + + fn agent_context_payload_for_root(&self, root: &Path) -> Value { const CONTEXT_LINES: usize = 40; const MAX_CONTEXT_CHARS: usize = 40_000; const MAX_DIAGNOSTICS: usize = 20; let buffer = self.current_buffer(); - let root = self - .agent_manager - .root() - .map(Path::to_path_buf) - .unwrap_or_else(get_workspace_path); let path = buffer.file.as_deref().and_then(|file| { Path::new(file) .absolutize() @@ -8615,7 +8635,7 @@ impl Editor { .unwrap_or_else(|| "red-buffer://active".to_string()); let file = path .as_ref() - .and_then(|path| path.strip_prefix(&root).ok()) + .and_then(|path| path.strip_prefix(root).ok()) .unwrap_or_else(|| path.as_deref().unwrap_or_else(|| Path::new("[No Name]"))) .to_string_lossy() .into_owned(); @@ -8637,16 +8657,16 @@ impl Editor { let unsafe_reason = path.as_ref().and_then(|path| { let physical_path = fs::canonicalize(path).ok(); - let physical_root = fs::canonicalize(&root).ok(); + let physical_root = fs::canonicalize(root).ok(); let escapes_root = physical_path .as_ref() .zip(physical_root.as_ref()) .is_some_and(|(path, root)| !path.starts_with(root)); - if !path.starts_with(&root) || escapes_root { + if !path.starts_with(root) || escapes_root { Some("outside the workspace") } else if agent_context_path_is_sensitive(path) { Some("a sensitive file") - } else if agent_context_path_is_ignored(path, &root, /*is_dir*/ false) { + } else if agent_context_path_is_ignored(path, root, /*is_dir*/ false) { Some("an ignored file") } else { None @@ -9204,8 +9224,8 @@ impl Editor { Ok(()) } - fn agent_editor_state(&self) -> Value { - let context = self.agent_context_payload(); + fn agent_editor_state(&self, root: &Path) -> Value { + let context = self.agent_context_payload_for_root(root); let included = context .get("included") .and_then(Value::as_bool) @@ -9240,15 +9260,13 @@ impl Editor { .as_array() .cloned() .unwrap_or_default(); - if let Some(root) = self.agent_manager.root() { - windows.retain(|window| { - window - .get("file") - .and_then(Value::as_str) - .and_then(|path| resolve_agent_tool_path(root, path).ok()) - .is_some() - }); - } + windows.retain(|window| { + window + .get("file") + .and_then(Value::as_str) + .and_then(|path| resolve_agent_tool_path(root, path).ok()) + .is_some() + }); json!({ "ok": true, "file": included.then(|| context.get("file").cloned()).flatten(), @@ -9520,7 +9538,7 @@ impl Editor { .map(|transaction| transaction.id.clone()); let root = self .agent_manager - .root() + .root_for_session(session_id) .ok_or_else(|| anyhow::anyhow!("no agent workspace is active"))? .to_path_buf(); let notification_error = self.notify_change(runtime).await.err(); @@ -9567,7 +9585,7 @@ impl Editor { ); let root = self .agent_manager - .root() + .root_for_session(&request.session_id) .ok_or_else(|| anyhow::anyhow!("no agent workspace is active"))? .to_path_buf(); @@ -9696,7 +9714,7 @@ impl Editor { ) .await } - EditorToolCall::GetEditorState {} => Ok(self.agent_editor_state()), + EditorToolCall::GetEditorState {} => Ok(self.agent_editor_state(&root)), EditorToolCall::OpenFile { path, line, @@ -9723,7 +9741,7 @@ impl Editor { runtime, ) .await?; - Ok(self.agent_editor_state()) + Ok(self.agent_editor_state(&root)) } EditorToolCall::SelectText { path, @@ -9784,7 +9802,7 @@ impl Editor { runtime, ) .await?; - return Ok(self.agent_editor_state()); + return Ok(self.agent_editor_state(&root)); } self.mode = match kind { EditorSelectionKind::Character => Mode::Visual, @@ -9799,7 +9817,7 @@ impl Editor { runtime, ) .await?; - Ok(self.agent_editor_state()) + Ok(self.agent_editor_state(&root)) } EditorToolCall::ApplyEdits { path, @@ -9845,7 +9863,7 @@ impl Editor { } }; self.execute(&action, render_buffer, runtime).await?; - Ok(self.agent_editor_state()) + Ok(self.agent_editor_state(&root)) } } } @@ -9856,7 +9874,11 @@ impl Editor { render_buffer: &mut RenderBuffer, runtime: &mut Runtime, ) -> anyhow::Result { - let Some(root) = self.agent_manager.root().map(Path::to_path_buf) else { + let Some(root) = self + .agent_manager + .root_for_session(&request.session_id) + .map(Path::to_path_buf) + else { anyhow::bail!("no agent workspace is active"); }; let (path, position, create, delay) = match &request.call { @@ -10305,6 +10327,7 @@ impl Editor { task.abort(); } self.agent_manager.clear_active_sessions(); + self.agent_manager.clear_live_sessions(); self.agent_manager.clear_turns(); self.agent_manager.clear_tool_requests(); self.agent_manager.set_root(None); @@ -10315,14 +10338,6 @@ impl Editor { anyhow::bail!("agent support is disabled by `disable_ai = true`"); } let cwd = cwd.absolutize()?.into_owned(); - if let Some(root) = self.agent_manager.root() { - anyhow::ensure!( - root == cwd, - "Codex session root `{}` does not match the active agent workspace `{}`", - cwd.display(), - root.display() - ); - } if self.agent_manager.has_bridge() { return Ok(()); } @@ -10418,6 +10433,7 @@ impl Editor { self.stop_inline_agent_outcomes(fallback); drop(self.agent_manager.take_bridge()); self.agent_manager.clear_active_sessions(); + self.agent_manager.clear_live_sessions(); self.agent_manager.clear_turns(); self.agent_manager.clear_tool_requests(); self.agent_manager.set_root(None); @@ -10471,7 +10487,9 @@ impl Editor { self.dispatch_inline_context_request(pending); } else if pending.request.call.is_lsp() { self.dispatch_agent_lsp(pending, buffer, runtime).await; - } else if self.config.agent.follow_tool_calls { + } else if self.config.agent.follow_tool_calls + && !self.agent_manager.is_delegate(&pending.request.session_id) + { match self .prepare_agent_follow_step(&pending.request, buffer, runtime) .await @@ -10490,19 +10508,21 @@ impl Editor { } } if let Some(pending) = self.agent_manager.take_ready_playback_tool(Instant::now()) { - let post_delay = - if self.config.agent.follow_tool_calls && pending.request.call.is_edit() { - Duration::from_millis(700) - } else { - Duration::ZERO - }; - let restore_buffer = (!self.config.agent.follow_tool_calls - && !matches!( - &pending.request.call, - EditorToolCall::OpenFile { .. } - | EditorToolCall::SelectText { .. } - | EditorToolCall::RunEditorAction { .. } - )) + let delegated = self.agent_manager.is_delegate(&pending.request.session_id); + let follows_tool = self.config.agent.follow_tool_calls && !delegated; + let post_delay = if follows_tool && pending.request.call.is_edit() { + Duration::from_millis(700) + } else { + Duration::ZERO + }; + let restore_buffer = (!follows_tool + && (delegated + || !matches!( + &pending.request.call, + EditorToolCall::OpenFile { .. } + | EditorToolCall::SelectText { .. } + | EditorToolCall::RunEditorAction { .. } + ))) .then(|| self.buffer_manager.active_index()); let mut result = self .dispatch_agent_editor_tool(pending.request, buffer, runtime) @@ -10776,14 +10796,9 @@ impl Editor { self.agent_manager .set_conversation_model(session_id, model_info.clone()); } - CodexEvent::SessionCreated { session_id } => { + CodexEvent::SessionCreated { session_id, cwd } => { self.agent_manager.take_next_model(); - let root = self - .agent_manager - .root() - .map(Path::to_path_buf) - .unwrap_or_default(); - self.agent_manager.begin_conversation(session_id, &root); + self.agent_manager.begin_conversation(session_id, cwd); } CodexEvent::SessionRestored { session_id, thread } => { if self.agent_manager.take_forgotten_conversation(session_id) { @@ -10796,13 +10811,14 @@ impl Editor { } let root = self .agent_manager - .root() + .root_for_session(session_id) .map(Path::to_path_buf) .unwrap_or_default(); let conversation = self .agent_manager .reconcile_conversation(session_id, &root, thread) .cloned(); + self.agent_manager.mark_session_live(session_id.clone()); if let Some(conversation) = conversation { self.plugin_registry .notify( @@ -10856,6 +10872,26 @@ impl Editor { { self.agent_manager.mark_session_inactive(session_id); } + match &event { + CodexEvent::PermissionRequested { session_id, .. } => { + self.agent_manager + .mark_session_attention(session_id.clone()); + } + CodexEvent::Update { session_id, .. } => { + self.agent_manager.clear_session_attention(session_id); + } + CodexEvent::Completed { session_id, .. } => { + self.agent_manager.mark_session_finished(session_id); + } + CodexEvent::Failed { + session_id: Some(session_id), + message, + } => { + self.agent_manager + .mark_session_failed(session_id, message.clone()); + } + _ => {} + } match &event { CodexEvent::Update { session_id, .. } | CodexEvent::MessageCompleted { session_id, .. } @@ -11152,6 +11188,49 @@ impl Editor { self.handle_agent_model_request(runtime, request_id, request) .await?; } + PluginRequest::AgentListThreads { request_id } => { + let selected = self.agent_manager.selected_conversation_id(); + let threads = self + .agent_manager + .conversation_snapshots() + .into_iter() + .map(|conversation| { + let thread_id = conversation.thread_id.clone(); + let (status, detail) = self.agent_manager.thread_status(&thread_id); + json!({ + "thread_id": thread_id.clone(), + "cwd": conversation.cwd, + "mode": conversation.mode, + "title": conversation.title, + "branch": conversation.branch, + "base_cwd": conversation.base_cwd, + "model_info": conversation.model_info, + "items": conversation.items, + "selected": selected == Some(thread_id.as_str()), + "live": self.agent_manager.is_session_live(&thread_id), + "status": status, + "status_detail": detail, + }) + }) + .collect::>(); + self.plugin_registry + .resolve_request(runtime, request_id, json!({ "threads": threads })) + .await?; + } + PluginRequest::AgentSelectThread { session_id } => { + if let Some(conversation) = self.agent_manager.select_conversation(&session_id) + { + let live = self.agent_manager.is_session_live(&session_id); + let (status, status_detail) = self.agent_manager.thread_status(&session_id); + let mut payload = serde_json::to_value(conversation)?; + payload["live"] = json!(live); + payload["status"] = json!(status); + payload["status_detail"] = json!(status_detail); + self.plugin_registry + .notify(runtime, "agent:thread_selected", plugin_json(payload)) + .await?; + } + } PluginRequest::SetTextPanelHeaderDetail { id, detail } => { needs_render |= self.panel_manager.set_text_panel_header_detail(&id, detail); } @@ -11204,6 +11283,56 @@ impl Editor { .await?; } } + PluginRequest::AgentNewDelegateSession { + cwd, + title, + branch, + base_cwd, + } => { + if self.agent_manager.is_task_finished() { + let _ = self + .finish_agent_bridge( + runtime, + "Codex app-server stopped before starting delegated work", + ) + .await?; + } + self.agent_manager.mark_conversation_requested(); + self.agent_manager + .register_delegate(cwd.clone(), title, branch, base_cwd); + if let Err(error) = self.ensure_agent_bridge(&cwd) { + self.plugin_registry + .notify( + runtime, + "agent:delegate_failed", + json!({ "message": error.to_string(), "cwd": cwd }), + ) + .await?; + continue; + } + let Some(bridge) = self.agent_manager.bridge() else { + continue; + }; + if bridge + .send(CodexCommand::NewSession { cwd: cwd.clone() }) + .await + .is_err() + { + let message = self + .finish_agent_bridge( + runtime, + "Codex app-server stopped while starting delegated work", + ) + .await?; + self.plugin_registry + .notify( + runtime, + "agent:delegate_failed", + json!({ "message": message, "cwd": cwd }), + ) + .await?; + } + } PluginRequest::AgentResumeSession { cwd, session_id } => { self.agent_manager.mark_conversation_requested(); if self.agent_manager.is_task_finished() { @@ -11254,14 +11383,22 @@ impl Editor { } } PluginRequest::AgentPrompt { session_id, text } => { - let context = self.agent_context_payload(); - let uri = context["uri"] - .as_str() - .unwrap_or("red-buffer://active") - .to_string(); - let context = context["text"].as_str().unwrap_or_default().to_string(); + let context = (!self.agent_manager.is_delegate(&session_id)).then(|| { + let root = self + .agent_manager + .root_for_session(&session_id) + .map(Path::to_path_buf) + .unwrap_or_else(get_workspace_path); + let context = self.agent_context_payload_for_root(&root); + let uri = context["uri"] + .as_str() + .unwrap_or("red-buffer://active") + .to_string(); + let text = context["text"].as_str().unwrap_or_default().to_string(); + (uri, text) + }); needs_render |= self - .dispatch_agent_prompt(runtime, session_id, text, Some((uri, context))) + .dispatch_agent_prompt(runtime, session_id, text, context) .await?; } PluginRequest::AgentPromptWithContext { @@ -27479,8 +27616,18 @@ impl Editor { } self.agent_manager .set_root(Some(PathBuf::from(snapshot.cwd.clone()))); - if let Some(conversation) = snapshot.agent_conversation.clone() { - self.agent_manager.restore_conversation(conversation); + if snapshot.agent_threads.is_empty() { + if let Some(conversation) = snapshot.agent_conversation.clone() { + self.agent_manager.restore_conversation(conversation); + } + } else { + self.agent_manager.restore_conversations( + snapshot.agent_threads.clone(), + snapshot + .agent_conversation + .as_ref() + .map(|conversation| conversation.thread_id.as_str()), + ); } if self.config.persist_inline_history.unwrap_or(true) { self.inline_history = snapshot.inline_history.clone(); @@ -27810,7 +27957,8 @@ impl Editor { .and_then(Value::as_str) .map(str::to_string); let agent_conversation = self.agent_manager.conversation_snapshot(); - let agent_session_resumable = agent_conversation.is_some(); + let agent_threads = self.agent_manager.conversation_snapshots(); + let agent_session_resumable = !agent_threads.is_empty(); ( SessionSnapshot { @@ -27832,6 +27980,7 @@ impl Editor { last_visual_selections, agent_transcript, agent_conversation, + agent_threads, inline_history: if self.config.persist_inline_history.unwrap_or(true) { self.inline_history.clone() } else { diff --git a/src/editor/agent_annotations/tests.rs b/src/editor/agent_annotations/tests.rs index 7fe156ac..8bbf582b 100644 --- a/src/editor/agent_annotations/tests.rs +++ b/src/editor/agent_annotations/tests.rs @@ -234,7 +234,7 @@ async fn excluded_agent_context_redacts_annotation_messages() { .current_buffer_mut() .save_as(&sensitive.to_string_lossy()) .unwrap(); - let state = editor.agent_editor_state(); + let state = editor.agent_editor_state(root.path()); assert_eq!(state["context"]["included"], false); assert_eq!(state["annotations"]["visible_count"], 0); diff --git a/src/editor/agent_lsp.rs b/src/editor/agent_lsp.rs index c2c69807..6db76bf5 100644 --- a/src/editor/agent_lsp.rs +++ b/src/editor/agent_lsp.rs @@ -206,7 +206,7 @@ impl Editor { ); let root = self .agent_manager - .root() + .root_for_session(&request.session_id) .ok_or_else(|| anyhow::anyhow!("no agent workspace is active"))? .to_path_buf(); anyhow::ensure!( @@ -545,7 +545,7 @@ impl Editor { "agent turn changed" ); anyhow::ensure!( - self.agent_manager.root() == Some(context.root.as_path()), + self.agent_manager.root_for_session(&context.session) == Some(context.root.as_path()), "agent workspace changed" ); anyhow::ensure!( diff --git a/src/editor/agent_manager.rs b/src/editor/agent_manager.rs index f5a5e72f..934961c6 100644 --- a/src/editor/agent_manager.rs +++ b/src/editor/agent_manager.rs @@ -7,7 +7,7 @@ use std::{ }; use crate::{ - agent_conversation::{AgentAnnotationRecord, AgentConversationSnapshot}, + agent_conversation::{AgentAnnotationRecord, AgentConversationSnapshot, AgentThreadMode}, agent_tools::{PendingEditorTool, PendingEditorToolResponse}, codex::CodexBridge, }; @@ -30,10 +30,25 @@ pub struct AgentManager { pending_model_requests: HashSet, model_only_bridge: bool, next_model: Option, - conversation: Option, + conversations: HashMap, + conversation_order: Vec, + selected_conversation: Option, + live_sessions: HashSet, + attention_sessions: HashSet, + review_ready_sessions: HashSet, + failed_sessions: HashMap, + pending_delegate: Option, forgotten_conversations: HashSet, } +#[derive(Debug, Clone)] +pub struct PendingDelegate { + pub cwd: PathBuf, + pub title: String, + pub branch: String, + pub base_cwd: PathBuf, +} + impl AgentManager { /// Creates a new, empty AgentManager instance. pub fn new() -> Self { @@ -253,11 +268,7 @@ impl AgentManager { session_id: &str, model_info: crate::codex::AgentModelInfo, ) { - if let Some(conversation) = self - .conversation - .as_mut() - .filter(|conversation| conversation.thread_id == session_id) - { + if let Some(conversation) = self.conversations.get_mut(session_id) { conversation.model_info = Some(model_info); } } @@ -265,15 +276,55 @@ impl AgentManager { pub fn begin_conversation(&mut self, thread_id: impl Into, cwd: &Path) { let thread_id = thread_id.into(); self.forgotten_conversations.remove(&thread_id); - self.conversation = Some(AgentConversationSnapshot::new( - thread_id, - cwd.to_string_lossy(), - )); + let mut conversation = + AgentConversationSnapshot::new(thread_id.clone(), cwd.to_string_lossy()); + let delegate = if self + .pending_delegate + .as_ref() + .is_some_and(|delegate| delegate.cwd == cwd) + { + self.pending_delegate.take() + } else { + None + }; + let select = delegate.is_none(); + if let Some(delegate) = delegate { + conversation.mode = AgentThreadMode::Delegate; + conversation.title = delegate.title; + conversation.branch = Some(delegate.branch); + conversation.base_cwd = Some(delegate.base_cwd.to_string_lossy().into_owned()); + } + self.insert_conversation(conversation, select); + self.live_sessions.insert(thread_id); } pub fn restore_conversation(&mut self, conversation: AgentConversationSnapshot) { self.root = Some(PathBuf::from(&conversation.cwd)); - self.conversation = Some(conversation); + self.insert_conversation(conversation, /*select*/ true); + } + + pub fn restore_conversations( + &mut self, + conversations: Vec, + selected: Option<&str>, + ) { + self.conversations.clear(); + self.conversation_order.clear(); + self.selected_conversation = None; + for conversation in conversations { + self.insert_conversation(conversation, /*select*/ false); + } + if let Some(selected) = selected.filter(|id| self.conversations.contains_key(*id)) { + self.selected_conversation = Some(selected.to_string()); + } else { + self.selected_conversation = self.conversation_order.last().cloned(); + } + if let Some(cwd) = self + .conversation_snapshot() + .map(|conversation| PathBuf::from(conversation.cwd)) + { + self.root = Some(cwd); + } } pub fn reconcile_conversation( @@ -283,33 +334,154 @@ impl AgentManager { thread: &serde_json::Value, ) -> Option<&AgentConversationSnapshot> { let cached = self - .conversation - .take() - .filter(|conversation| conversation.thread_id == thread_id) + .conversations + .remove(thread_id) .unwrap_or_else(|| AgentConversationSnapshot::new(thread_id, cwd.to_string_lossy())); - self.conversation = Some(cached.reconciled_with_thread(thread)); - self.conversation.as_ref() + self.conversations + .insert(thread_id.to_string(), cached.reconciled_with_thread(thread)); + self.conversations.get(thread_id) } pub fn conversation_snapshot(&self) -> Option { - self.conversation.clone() + self.selected_conversation + .as_deref() + .and_then(|id| self.conversations.get(id)) + .cloned() + } + + pub fn conversation_snapshots(&self) -> Vec { + self.conversation_order + .iter() + .filter_map(|id| self.conversations.get(id).cloned()) + .collect() + } + + pub fn select_conversation(&mut self, session_id: &str) -> Option { + let conversation = self.conversations.get(session_id)?.clone(); + self.selected_conversation = Some(session_id.to_string()); + self.review_ready_sessions.remove(session_id); + Some(conversation) + } + + pub fn selected_conversation_id(&self) -> Option<&str> { + self.selected_conversation.as_deref() + } + + pub fn register_delegate( + &mut self, + cwd: PathBuf, + title: String, + branch: String, + base_cwd: PathBuf, + ) { + self.pending_delegate = Some(PendingDelegate { + cwd, + title, + branch, + base_cwd, + }); + } + + pub fn root_for_session(&self, session_id: &str) -> Option<&Path> { + self.conversations + .get(session_id) + .map(|conversation| Path::new(&conversation.cwd)) + .or_else(|| self.root()) + } + + pub fn is_session_live(&self, session_id: &str) -> bool { + self.live_sessions.contains(session_id) + } + + pub fn mark_session_live(&mut self, session_id: impl Into) { + self.live_sessions.insert(session_id.into()); + } + + pub fn clear_live_sessions(&mut self) { + self.live_sessions.clear(); + } + + pub fn is_delegate(&self, session_id: &str) -> bool { + self.conversations + .get(session_id) + .is_some_and(|conversation| conversation.mode == AgentThreadMode::Delegate) + } + + pub fn mark_session_attention(&mut self, session_id: impl Into) { + let session_id = session_id.into(); + self.attention_sessions.insert(session_id.clone()); + self.review_ready_sessions.remove(&session_id); + } + + pub fn clear_session_attention(&mut self, session_id: &str) { + self.attention_sessions.remove(session_id); + } + + pub fn mark_session_finished(&mut self, session_id: &str) { + self.attention_sessions.remove(session_id); + self.failed_sessions.remove(session_id); + if self + .conversations + .get(session_id) + .is_some_and(|conversation| conversation.mode == AgentThreadMode::Delegate) + { + self.review_ready_sessions.insert(session_id.to_string()); + } + } + + pub fn mark_session_failed(&mut self, session_id: &str, message: impl Into) { + self.attention_sessions.remove(session_id); + self.review_ready_sessions.remove(session_id); + self.failed_sessions + .insert(session_id.to_string(), message.into()); + } + + pub fn thread_status(&self, session_id: &str) -> (&'static str, Option<&str>) { + if let Some(message) = self.failed_sessions.get(session_id) { + return ("Failed", Some(message)); + } + if self.attention_sessions.contains(session_id) { + return ("Needs you", None); + } + if self.active_sessions.contains(session_id) { + return ("Running", None); + } + if self.review_ready_sessions.contains(session_id) { + return ("Ready to review", None); + } + if self.selected_conversation.as_deref() == Some(session_id) { + return ("Current", None); + } + ("History", None) } pub fn replace_annotation_records(&mut self, annotations: Vec) { - if let Some(conversation) = self.conversation.as_mut() { - conversation.annotations = annotations; + for conversation in self.conversations.values_mut() { + conversation.annotations.clear(); + } + for annotation in annotations { + let session_id = if annotation.session_id.is_empty() { + self.selected_conversation.as_deref() + } else { + Some(annotation.session_id.as_str()) + }; + if let Some(conversation) = session_id.and_then(|id| self.conversations.get_mut(id)) { + conversation.annotations.push(annotation); + } } } pub fn forget_conversation(&mut self, session_id: &str) { self.next_model = None; self.forgotten_conversations.insert(session_id.to_string()); - if self - .conversation - .as_ref() - .is_some_and(|conversation| conversation.thread_id == session_id) - { - self.conversation = None; + self.conversations.remove(session_id); + self.conversation_order.retain(|id| id != session_id); + self.live_sessions.remove(session_id); + self.attention_sessions.remove(session_id); + self.review_ready_sessions.remove(session_id); + self.failed_sessions.remove(session_id); + if self.selected_conversation.as_deref() == Some(session_id) { + self.selected_conversation = self.conversation_order.last().cloned(); } } @@ -318,11 +490,7 @@ impl AgentManager { } pub fn record_user_message(&mut self, session_id: &str, turn_id: &str, text: &str) { - if let Some(conversation) = self - .conversation - .as_mut() - .filter(|conversation| conversation.thread_id == session_id) - { + if let Some(conversation) = self.conversations.get_mut(session_id) { conversation.append_user(turn_id, text); } } @@ -331,11 +499,7 @@ impl AgentManager { let Some(turn_id) = self.active_turn_ids.get(session_id) else { return; }; - if let Some(conversation) = self - .conversation - .as_mut() - .filter(|conversation| conversation.thread_id == session_id) - { + if let Some(conversation) = self.conversations.get_mut(session_id) { conversation.append_agent_delta(turn_id, text); } } @@ -344,11 +508,7 @@ impl AgentManager { let Some(turn_id) = self.active_turn_ids.get(session_id) else { return; }; - let Some(conversation) = self - .conversation - .as_mut() - .filter(|conversation| conversation.thread_id == session_id) - else { + let Some(conversation) = self.conversations.get_mut(session_id) else { return; }; if let Some(item) = conversation.items.iter_mut().rev().find(|item| { @@ -360,11 +520,22 @@ impl AgentManager { conversation.append_agent_delta(turn_id, text); } } + + fn insert_conversation(&mut self, conversation: AgentConversationSnapshot, select: bool) { + let thread_id = conversation.thread_id.clone(); + self.conversation_order.retain(|id| id != &thread_id); + self.conversation_order.push(thread_id.clone()); + self.conversations.insert(thread_id.clone(), conversation); + if select || self.selected_conversation.is_none() { + self.selected_conversation = Some(thread_id); + } + } } #[cfg(test)] mod tests { use super::AgentManager; + use crate::agent_conversation::AgentThreadMode; use crate::agent_tools::PendingEditorToolResponse; use serde_json::json; use std::{ @@ -397,6 +568,44 @@ mod tests { assert!(!manager.take_forgotten_conversation("session-1")); } + #[test] + fn delegate_threads_keep_the_pair_selected_and_use_their_own_root() { + let mut manager = AgentManager::new(); + manager.begin_conversation("pair", Path::new("/workspace")); + manager.register_delegate( + "/workspace.delegate-task".into(), + "Implement task".to_string(), + "red/delegate/task".to_string(), + "/workspace".into(), + ); + manager.begin_conversation("delegate", Path::new("/workspace.delegate-task")); + + assert_eq!(manager.selected_conversation_id(), Some("pair")); + assert_eq!( + manager.root_for_session("delegate"), + Some(Path::new("/workspace.delegate-task")) + ); + let delegate = manager + .conversation_snapshots() + .into_iter() + .find(|conversation| conversation.thread_id == "delegate") + .unwrap(); + assert_eq!(delegate.mode, AgentThreadMode::Delegate); + assert_eq!(delegate.branch.as_deref(), Some("red/delegate/task")); + + manager.mark_session_active("delegate"); + assert_eq!(manager.thread_status("delegate").0, "Running"); + manager.mark_session_inactive("delegate"); + manager.mark_session_finished("delegate"); + assert_eq!(manager.thread_status("delegate").0, "Ready to review"); + + assert_eq!( + manager.select_conversation("delegate").unwrap().thread_id, + "delegate" + ); + assert_eq!(manager.selected_conversation_id(), Some("delegate")); + } + #[tokio::test] async fn holds_completed_edits_until_the_follow_deadline() { let mut manager = AgentManager::new(); diff --git a/src/plugin/host_api.json b/src/plugin/host_api.json index 9ddfa419..d4625ee2 100644 --- a/src/plugin/host_api.json +++ b/src/plugin/host_api.json @@ -21,6 +21,9 @@ { "name": "AgentSetModel", "kind": "request", "signature": "(callback: fn(Json), session_id: String, selection: Json)", "introduced": "0.14.0" }, { "name": "SetTextPanelHeaderDetail", "kind": "execute", "signature": "(id: String, detail?: Json)", "introduced": "0.14.0" }, { "name": "AgentNewSession", "kind": "execute", "signature": "(cwd: String)", "introduced": "0.1.0" }, + { "name": "AgentNewDelegateSession", "kind": "execute", "signature": "(cwd: String, title: String, branch: String, base_cwd: String)", "introduced": "0.16.0" }, + { "name": "AgentListThreads", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.16.0" }, + { "name": "AgentSelectThread", "kind": "execute", "signature": "(session_id: String)", "introduced": "0.16.0" }, { "name": "AgentResumeSession", "kind": "execute", "signature": "(cwd: String, session_id: String)", "introduced": "0.9.0" }, { "name": "AgentPrompt", "kind": "execute", "signature": "(session_id: String, text: String)", "introduced": "0.1.0" }, { "name": "AgentPromptWithContext", "kind": "execute", "signature": "(session_id: String, text: String, context: Json)", "introduced": "0.2.0" }, diff --git a/src/plugin/runtime.rs b/src/plugin/runtime.rs index 7568e3f2..d2183629 100644 --- a/src/plugin/runtime.rs +++ b/src/plugin/runtime.rs @@ -1193,6 +1193,32 @@ impl RedHost { .map_or_else(|| PathBuf::from("."), PathBuf::from); self.send_request(PluginRequest::AgentNewSession { cwd }); } + "AgentNewDelegateSession" => { + let cwd = args + .first() + .and_then(Value::as_str) + .map_or_else(|| PathBuf::from("."), PathBuf::from); + let title = args.get(1).map(value_to_string).unwrap_or_default(); + let branch = args.get(2).map(value_to_string).unwrap_or_default(); + let base_cwd = args + .get(3) + .and_then(Value::as_str) + .map_or_else(|| PathBuf::from("."), PathBuf::from); + self.send_request(PluginRequest::AgentNewDelegateSession { + cwd, + title, + branch, + base_cwd, + }); + } + "AgentSelectThread" => { + let session_id = args + .first() + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("AgentSelectThread requires a session id"))? + .to_string(); + self.send_request(PluginRequest::AgentSelectThread { session_id }); + } "AgentResumeSession" => { let cwd = args .first() @@ -2098,6 +2124,7 @@ impl RedHost { } "GetEditorInfo" => PluginRequest::EditorInfo(request_id), "EditHistory" => PluginRequest::EditHistory { request_id }, + "AgentListThreads" => PluginRequest::AgentListThreads { request_id }, "AgentReadDefaultModel" => PluginRequest::AgentModelRequest { request_id, request: crate::codex::ModelRequest::ReadDefault { @@ -7999,7 +8026,7 @@ mod tests { && config.side == crate::plugin::PanelSide::Right && config.width == 62 && config.title.as_deref() == Some("Agent") - && config.header_actions.iter().map(|action| action.id.as_str()).eq(["activity", "clear", "new", "close"]) + && config.header_actions.iter().map(|action| action.id.as_str()).eq(["threads", "activity", "clear", "new", "close"]) )); resolve_prompt_history(&mut runtime, serde_json::json!([])).await; expect_agent_model_header(); @@ -8987,7 +9014,7 @@ mod tests { } #[tokio::test] - async fn bundled_agent_close_reopens_without_recreating_and_new_resets_the_session() { + async fn bundled_agent_close_reopens_and_new_pair_preserves_the_previous_session() { drain_requests(); let mut runtime = Runtime::new(); runtime @@ -9048,6 +9075,17 @@ mod tests { assert!(ACTION_DISPATCHER.try_recv_request().is_none()); runtime.execute_command("AgentNew").await.unwrap(); + let (mode_picker, modes) = recv_agent_picker("New agent conversation"); + assert_eq!( + modes + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["pair", "delegate"] + ); + runtime + .notify_picker(mode_picker, PickerCallback::Selected(modes[0].clone())) + .unwrap(); let mut closed = false; let mut cleared = false; let mut reset_storage = false; @@ -9086,7 +9124,10 @@ mod tests { _ => {} } } - assert!(closed); + assert!( + !closed, + "starting a pair thread keeps the previous thread available" + ); assert!(cleared); assert!(reset_storage); assert!(reset_draft); @@ -9143,6 +9184,67 @@ mod tests { assert!(ACTION_DISPATCHER.try_recv_request().is_none()); } + #[tokio::test] + async fn bundled_agent_delegate_previews_an_isolated_worktree() { + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + + runtime.execute_command("AgentNew").await.unwrap(); + let (mode_picker, modes) = recv_agent_picker("New agent conversation"); + runtime + .notify_picker(mode_picker, PickerCallback::Selected(modes[1].clone())) + .unwrap(); + let (composer, title, _, _) = recv_agent_composer(); + assert_eq!(title.as_deref(), Some("Delegate a task")); + runtime + .notify_composer( + composer, + ComposerCallback::Submitted("Implement thread navigation".to_string()), + ) + .unwrap(); + let cwd_request = match ACTION_DISPATCHER.recv_request() { + PluginRequest::GetConfig { request_id, key } => { + assert_eq!(key.as_deref(), Some("cwd")); + request_id + } + _ => panic!("expected delegate cwd request"), + }; + runtime + .resolve_request( + cwd_request, + serde_json::json!({ "value": "/workspace/red" }), + ) + .await + .unwrap(); + + match ACTION_DISPATCHER.recv_request() { + PluginRequest::OpenCallbackConfirmation { + owner, + title, + message, + options, + .. + } => { + assert_eq!(owner, "agent"); + assert_eq!(title, "Start delegated work"); + assert!(message.contains("isolated worktree")); + let preview = options + .rows + .iter() + .flatten() + .map(|segment| segment.text.as_str()) + .collect::(); + assert!(preview.contains("red/delegate/implement-thread-navigation")); + assert!(preview.contains("/workspace/red.delegate-implement-thread-navigation")); + } + _ => panic!("expected delegate confirmation"), + } + } + #[tokio::test] async fn host_accepts_explicit_agent_context_and_exposes_context_requests() { drain_requests(); diff --git a/src/plugin/workspace.rs b/src/plugin/workspace.rs index b4c7c917..f360a008 100644 --- a/src/plugin/workspace.rs +++ b/src/plugin/workspace.rs @@ -51,6 +51,9 @@ impl DiffHighlightMode { pub struct WorkspaceConfig { #[serde(default)] pub title: String, + /// Label for the selectable row pane. + #[serde(default = "default_rows_title")] + pub rows_title: String, #[serde(default = "default_detail_ratio")] pub detail_ratio: u8, #[serde(default = "default_min_two_pane_width")] @@ -75,6 +78,10 @@ fn default_detail_ratio() -> u8 { 55 } +fn default_rows_title() -> String { + "Changes".to_string() +} + fn default_min_two_pane_width() -> usize { 100 } @@ -95,6 +102,7 @@ impl Default for WorkspaceConfig { fn default() -> Self { Self { title: String::new(), + rows_title: default_rows_title(), detail_ratio: default_detail_ratio(), min_two_pane_width: default_min_two_pane_width(), min_stacked_height: default_min_stacked_height(), @@ -1727,12 +1735,17 @@ fn render_row_pane( title_style.bold = active; let title = if workspace.filtering || !workspace.filter.is_empty() { format!( - "{} Changes /{}", + "{} {} /{}", if active { "›" } else { " " }, + workspace.config.rows_title, workspace.filter ) } else { - format!("{} Changes", if active { "›" } else { " " }) + format!( + "{} {}", + if active { "›" } else { " " }, + workspace.config.rows_title + ) }; buffer.set_text( x + 1, diff --git a/src/session.rs b/src/session.rs index 833571ea..be88c24a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -119,6 +119,9 @@ pub struct SessionSnapshot { /// Persisted Codex thread binding and Red's clean model-visible projection. #[serde(default)] pub agent_conversation: Option, + /// All known pair and delegate conversations, in navigation order. + #[serde(default)] + pub agent_threads: Vec, /// Source-linked inline questions and results, independent of provider threads. #[serde(default)] pub inline_history: crate::inline_history::InlineHistory, @@ -2328,6 +2331,7 @@ mod tests { last_visual_selections: Vec::new(), agent_transcript: None, agent_conversation: None, + agent_threads: Vec::new(), inline_history: Default::default(), legacy_agent_workspace: None, agent_session_resumable: false, @@ -2396,6 +2400,25 @@ mod tests { assert!(restored.agent_session_resumable); } + #[test] + fn persisted_agent_threads_keep_pair_and_delegate_metadata() { + let mut snapshot = snapshot("source"); + let pair = AgentConversationSnapshot::new("pair", "/workspace"); + let mut delegate = AgentConversationSnapshot::new("delegate", "/workspace.delegate-task"); + delegate.mode = crate::agent_conversation::AgentThreadMode::Delegate; + delegate.title = "Implement task".to_string(); + delegate.branch = Some("red/delegate/task".to_string()); + delegate.base_cwd = Some("/workspace".to_string()); + snapshot.agent_conversation = Some(pair.clone()); + snapshot.agent_threads = vec![pair, delegate.clone()]; + + let encoded = serde_json::to_vec(&snapshot).unwrap(); + let restored: SessionSnapshot = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(restored.agent_threads.len(), 2); + assert_eq!(restored.agent_threads[1], delegate); + } + #[test] fn crash_during_snapshot_keeps_a_loadable_generation() { let directory = tempfile::tempdir().unwrap(); diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index 28fff61d..c95c12db 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -419,7 +419,10 @@ async fn direct_app_server_streams_and_routes_writes_to_the_host() { .await .unwrap(); let session_id = match next_event(&mut bridge, &mut task).await { - CodexEvent::SessionCreated { session_id } => session_id, + CodexEvent::SessionCreated { session_id, cwd } => { + assert_eq!(cwd, directory.path()); + session_id + } other => panic!("expected created session, got {other:?}"), }; assert_eq!(session_id, "thread-red"); @@ -588,7 +591,7 @@ async fn direct_app_server_starts_without_managed_feature_requirements() { let event = next_event(&mut bridge, &mut task).await; assert!( - matches!(event, CodexEvent::SessionCreated { session_id } if session_id == "thread-red") + matches!(event, CodexEvent::SessionCreated { session_id, .. } if session_id == "thread-red") ); drop(bridge); task.await.unwrap().unwrap(); @@ -626,7 +629,7 @@ async fn direct_app_server_starts_with_required_hooks() { let event = next_event(&mut bridge, &mut task).await; assert!( - matches!(event, CodexEvent::SessionCreated { session_id } if session_id == "thread-red") + matches!(event, CodexEvent::SessionCreated { session_id, .. } if session_id == "thread-red") ); drop(bridge); task.await.unwrap().unwrap(); @@ -780,7 +783,7 @@ async fn direct_app_server_lists_and_changes_conversation_models() { .await .unwrap(); assert!( - matches!(next_event(&mut bridge, &mut task).await, CodexEvent::SessionCreated { session_id } if session_id == "model-thread") + matches!(next_event(&mut bridge, &mut task).await, CodexEvent::SessionCreated { session_id, .. } if session_id == "model-thread") ); assert!( matches!(next_event(&mut bridge, &mut task).await, CodexEvent::SessionModelChanged { model_info, .. } if model_info.model == "first" && model_info.effort.as_deref() == Some("high"))