debugger: Allow users to include PickProcessId in debug tasks and resolve Pid (#42913)

Anthony Eid and Remco Smits created

Closes #33286

This PR adds support for Zed's `$ZED_PICK_PID` command in debug
configurations, which allows users to select a process to attach to at
debug time. When this variable is present in a debug configuration, Zed
automatically opens a process picker modal.

Follow up for this will be integrating this variable in the task system
instead of just the debug configuration system.

Release Notes:

- Added `$ZED_PICK_PID` variable for debug configurations, allowing
users to select which process to attach the debugger to at runtime

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>

Change summary

crates/debugger_ui/src/attach_modal.rs       | 169 +++++++++++++--------
crates/debugger_ui/src/new_process_modal.rs  |  27 ++
crates/debugger_ui/src/session/running.rs    |  73 +++++++++
crates/debugger_ui/src/tests/attach_modal.rs | 160 +++++++++++++++++++-
crates/task/src/task.rs                      |  25 +++
crates/task/src/vscode_debug_format.rs       |  43 +++++
6 files changed, 410 insertions(+), 87 deletions(-)

Detailed changes

crates/debugger_ui/src/attach_modal.rs 🔗

@@ -1,4 +1,5 @@
 use dap::{DapRegistry, DebugRequest};
+use futures::channel::oneshot;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task};
 use gpui::{Subscription, WeakEntity};
@@ -9,6 +10,7 @@ use task::ZedDebugConfig;
 use util::debug_panic;
 
 use std::sync::Arc;
+
 use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
 use ui::{Context, Tooltip, prelude::*};
 use ui::{ListItem, ListItemSpacing};
@@ -23,11 +25,16 @@ pub(super) struct Candidate {
     pub(super) command: Vec<String>,
 }
 
+pub(crate) enum ModalIntent {
+    ResolveProcessId(Option<oneshot::Sender<Option<i32>>>),
+    AttachToProcess(ZedDebugConfig),
+}
+
 pub(crate) struct AttachModalDelegate {
     selected_index: usize,
     matches: Vec<StringMatch>,
     placeholder_text: Arc<str>,
-    pub(crate) definition: ZedDebugConfig,
+    pub(crate) intent: ModalIntent,
     workspace: WeakEntity<Workspace>,
     candidates: Arc<[Candidate]>,
 }
@@ -35,13 +42,13 @@ pub(crate) struct AttachModalDelegate {
 impl AttachModalDelegate {
     fn new(
         workspace: WeakEntity<Workspace>,
-        definition: ZedDebugConfig,
+        intent: ModalIntent,
         candidates: Arc<[Candidate]>,
     ) -> Self {
         Self {
             workspace,
-            definition,
             candidates,
+            intent,
             selected_index: 0,
             matches: Vec::default(),
             placeholder_text: Arc::from("Select the process you want to attach the debugger to"),
@@ -55,8 +62,8 @@ pub struct AttachModal {
 }
 
 impl AttachModal {
-    pub fn new(
-        definition: ZedDebugConfig,
+    pub(crate) fn new(
+        intent: ModalIntent,
         workspace: WeakEntity<Workspace>,
         project: Entity<Project>,
         modal: bool,
@@ -65,7 +72,7 @@ impl AttachModal {
     ) -> Self {
         let processes_task = get_processes_for_project(&project, cx);
 
-        let modal = Self::with_processes(workspace, definition, Arc::new([]), modal, window, cx);
+        let modal = Self::with_processes(workspace, Arc::new([]), modal, intent, window, cx);
 
         cx.spawn_in(window, async move |this, cx| {
             let processes = processes_task.await;
@@ -84,15 +91,15 @@ impl AttachModal {
 
     pub(super) fn with_processes(
         workspace: WeakEntity<Workspace>,
-        definition: ZedDebugConfig,
         processes: Arc<[Candidate]>,
         modal: bool,
+        intent: ModalIntent,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
         let picker = cx.new(|cx| {
             Picker::uniform_list(
-                AttachModalDelegate::new(workspace, definition, processes),
+                AttachModalDelegate::new(workspace, intent, processes),
                 window,
                 cx,
             )
@@ -207,7 +214,7 @@ impl PickerDelegate for AttachModalDelegate {
         })
     }
 
-    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         let candidate = self
             .matches
             .get(self.selected_index())
@@ -216,69 +223,86 @@ impl PickerDelegate for AttachModalDelegate {
                 self.candidates.get(ix)
             });
 
-        let Some(candidate) = candidate else {
-            return cx.emit(DismissEvent);
-        };
+        match &mut self.intent {
+            ModalIntent::ResolveProcessId(sender) => {
+                cx.emit(DismissEvent);
 
-        match &mut self.definition.request {
-            DebugRequest::Attach(config) => {
-                config.process_id = Some(candidate.pid);
-            }
-            DebugRequest::Launch(_) => {
-                debug_panic!("Debugger attach modal used on launch debug config");
-                return;
+                if let Some(sender) = sender.take() {
+                    sender
+                        .send(candidate.map(|candidate| candidate.pid as i32))
+                        .ok();
+                }
             }
-        }
-
-        let workspace = self.workspace.clone();
-        let Some(panel) = workspace
-            .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
-            .ok()
-            .flatten()
-        else {
-            return;
-        };
-
-        if secondary {
-            // let Some(id) = worktree_id else { return };
-            // cx.spawn_in(window, async move |_, cx| {
-            //     panel
-            //         .update_in(cx, |debug_panel, window, cx| {
-            //             debug_panel.save_scenario(&debug_scenario, id, window, cx)
-            //         })?
-            //         .await?;
-            //     anyhow::Ok(())
-            // })
-            // .detach_and_log_err(cx);
-        }
-        let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
-            registry.adapter(&self.definition.adapter)
-        }) else {
-            return;
-        };
-
-        let definition = self.definition.clone();
-        cx.spawn_in(window, async move |this, cx| {
-            let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
-                return;
-            };
+            ModalIntent::AttachToProcess(definition) => {
+                let Some(candidate) = candidate else {
+                    return cx.emit(DismissEvent);
+                };
+
+                match &mut definition.request {
+                    DebugRequest::Attach(config) => {
+                        config.process_id = Some(candidate.pid);
+                    }
+                    DebugRequest::Launch(_) => {
+                        debug_panic!("Debugger attach modal used on launch debug config");
+                        return;
+                    }
+                }
 
-            panel
-                .update_in(cx, |panel, window, cx| {
-                    panel.start_session(scenario, Default::default(), None, None, window, cx);
+                let workspace = self.workspace.clone();
+                let Some(panel) = workspace
+                    .update(cx, |workspace, cx| workspace.panel::<DebugPanel>(cx))
+                    .ok()
+                    .flatten()
+                else {
+                    return;
+                };
+
+                let Some(adapter) = cx.read_global::<DapRegistry, _>(|registry, _| {
+                    registry.adapter(&definition.adapter)
+                }) else {
+                    return;
+                };
+
+                let definition = definition.clone();
+                cx.spawn_in(window, async move |this, cx| {
+                    let Ok(scenario) = adapter.config_from_zed_format(definition).await else {
+                        return;
+                    };
+
+                    panel
+                        .update_in(cx, |panel, window, cx| {
+                            panel.start_session(
+                                scenario,
+                                Default::default(),
+                                None,
+                                None,
+                                window,
+                                cx,
+                            );
+                        })
+                        .ok();
+                    this.update(cx, |_, cx| {
+                        cx.emit(DismissEvent);
+                    })
+                    .ok();
                 })
-                .ok();
-            this.update(cx, |_, cx| {
-                cx.emit(DismissEvent);
-            })
-            .ok();
-        })
-        .detach();
+                .detach();
+            }
+        }
     }
 
     fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
         self.selected_index = 0;
 
+        match &mut self.intent {
+            ModalIntent::ResolveProcessId(sender) => {
+                if let Some(sender) = sender.take() {
+                    sender.send(None).ok();
+                }
+            }
+            ModalIntent::AttachToProcess(_) => {}
+        }
+
         cx.emit(DismissEvent);
     }
 
@@ -338,7 +362,7 @@ fn get_processes_for_project(project: &Entity<Project>, cx: &mut App) -> Task<Ar
 
     if let Some(remote_client) = project.remote_client() {
         let proto_client = remote_client.read(cx).proto_client();
-        cx.spawn(async move |_cx| {
+        cx.background_spawn(async move {
             let response = proto_client
                 .request(proto::GetProcesses {
                     project_id: proto::REMOTE_SERVER_PROJECT_ID,
@@ -389,8 +413,21 @@ fn get_processes_for_project(project: &Entity<Project>, cx: &mut App) -> Task<Ar
     }
 }
 
-#[cfg(any(test, feature = "test-support"))]
-pub(crate) fn _process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
+#[cfg(test)]
+pub(crate) fn set_candidates(
+    modal: &AttachModal,
+    candidates: Arc<[Candidate]>,
+    window: &mut Window,
+    cx: &mut Context<AttachModal>,
+) {
+    modal.picker.update(cx, |picker, cx| {
+        picker.delegate.candidates = candidates;
+        picker.refresh(window, cx);
+    });
+}
+
+#[cfg(test)]
+pub(crate) fn process_names(modal: &AttachModal, cx: &mut Context<AttachModal>) -> Vec<String> {
     modal.picker.read_with(cx, |picker, _| {
         picker
             .delegate

crates/debugger_ui/src/new_process_modal.rs 🔗

@@ -29,10 +29,13 @@ use ui::{
     KeyBinding, ListItem, ListItemSpacing, ToggleButtonGroup, ToggleButtonSimple, ToggleState,
     Tooltip, prelude::*,
 };
-use util::{ResultExt, rel_path::RelPath, shell::ShellKind};
+use util::{ResultExt, debug_panic, rel_path::RelPath, shell::ShellKind};
 use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr, pane};
 
-use crate::{attach_modal::AttachModal, debugger_panel::DebugPanel};
+use crate::{
+    attach_modal::{AttachModal, ModalIntent},
+    debugger_panel::DebugPanel,
+};
 
 pub(super) struct NewProcessModal {
     workspace: WeakEntity<Workspace>,
@@ -395,8 +398,15 @@ impl NewProcessModal {
 
                 this.attach_picker.update(cx, |this, cx| {
                     this.picker.update(cx, |this, cx| {
-                        this.delegate.definition.adapter = adapter.0.clone();
-                        this.focus(window, cx);
+                        match &mut this.delegate.intent {
+                            ModalIntent::AttachToProcess(definition) => {
+                                definition.adapter = adapter.0.clone();
+                                this.focus(window, cx);
+                            },
+                            ModalIntent::ResolveProcessId(_) => {
+                                debug_panic!("Attach picker attempted to update config when in resolve Process ID mode");
+                            }
+                        }
                     })
                 });
             }
@@ -942,7 +952,14 @@ impl AttachMode {
             stop_on_entry: Some(false),
         };
         let attach_picker = cx.new(|cx| {
-            let modal = AttachModal::new(definition.clone(), workspace, project, false, window, cx);
+            let modal = AttachModal::new(
+                ModalIntent::AttachToProcess(definition.clone()),
+                workspace,
+                project,
+                false,
+                window,
+                cx,
+            );
             window.focus(&modal.focus_handle(cx));
 
             modal

crates/debugger_ui/src/session/running.rs 🔗

@@ -5,16 +5,23 @@ pub(crate) mod memory_view;
 pub(crate) mod module_list;
 pub mod stack_frame_list;
 pub mod variable_list;
-use std::{any::Any, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration};
+use std::{
+    any::Any,
+    ops::ControlFlow,
+    path::PathBuf,
+    sync::{Arc, LazyLock},
+    time::Duration,
+};
 
 use crate::{
     ToggleExpandItem,
+    attach_modal::{AttachModal, ModalIntent},
     new_process_modal::resolve_path,
     persistence::{self, DebuggerPaneItem, SerializedLayout},
     session::running::memory_view::MemoryView,
 };
 
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result, anyhow, bail};
 use breakpoint_list::BreakpointList;
 use collections::{HashMap, IndexMap};
 use console::Console;
@@ -56,6 +63,9 @@ use workspace::{
     Workspace, item::TabContentParams, move_item, pane::Event,
 };
 
+static PROCESS_ID_PLACEHOLDER: LazyLock<String> =
+    LazyLock::new(|| task::VariableName::PickProcessId.template_value());
+
 pub struct RunningState {
     session: Entity<Session>,
     thread_id: Option<ThreadId>,
@@ -653,6 +663,40 @@ impl RunningState {
         }
     }
 
+    pub(crate) fn contains_substring(config: &serde_json::Value, substring: &str) -> bool {
+        match config {
+            serde_json::Value::Object(obj) => obj
+                .values()
+                .any(|value| Self::contains_substring(value, substring)),
+            serde_json::Value::Array(array) => array
+                .iter()
+                .any(|value| Self::contains_substring(value, substring)),
+            serde_json::Value::String(s) => s.contains(substring),
+            _ => false,
+        }
+    }
+
+    pub(crate) fn substitute_process_id_in_config(config: &mut serde_json::Value, process_id: i32) {
+        match config {
+            serde_json::Value::Object(obj) => {
+                obj.values_mut().for_each(|value| {
+                    Self::substitute_process_id_in_config(value, process_id);
+                });
+            }
+            serde_json::Value::Array(array) => {
+                array.iter_mut().for_each(|value| {
+                    Self::substitute_process_id_in_config(value, process_id);
+                });
+            }
+            serde_json::Value::String(s) => {
+                if s.contains(PROCESS_ID_PLACEHOLDER.as_str()) {
+                    *s = s.replace(PROCESS_ID_PLACEHOLDER.as_str(), &process_id.to_string());
+                }
+            }
+            _ => {}
+        }
+    }
+
     pub(crate) fn relativize_paths(
         key: Option<&str>,
         config: &mut serde_json::Value,
@@ -955,6 +999,31 @@ impl RunningState {
             Self::relativize_paths(None, &mut config, &task_context);
             Self::substitute_variables_in_config(&mut config, &task_context);
 
+            if Self::contains_substring(&config, PROCESS_ID_PLACEHOLDER.as_str()) || label.as_ref().contains(PROCESS_ID_PLACEHOLDER.as_str()) {
+                let (tx, rx) = futures::channel::oneshot::channel::<Option<i32>>();
+
+                let weak_workspace_clone = weak_workspace.clone();
+                weak_workspace.update_in(cx, |workspace, window, cx| {
+                    let project = workspace.project().clone();
+                    workspace.toggle_modal(window, cx, |window, cx| {
+                        AttachModal::new(
+                            ModalIntent::ResolveProcessId(Some(tx)),
+                            weak_workspace_clone,
+                            project,
+                            true,
+                            window,
+                            cx,
+                        )
+                    });
+                }).ok();
+
+                let Some(process_id) = rx.await.ok().flatten() else {
+                    bail!("No process selected with config that contains {}", PROCESS_ID_PLACEHOLDER.as_str())
+                };
+
+                Self::substitute_process_id_in_config(&mut config, process_id);
+            }
+
             let request_type = match dap_registry
                 .adapter(&adapter)
                 .with_context(|| format!("{}: is not a valid adapter name", &adapter)) {

crates/debugger_ui/src/tests/attach_modal.rs 🔗

@@ -1,4 +1,8 @@
-use crate::{attach_modal::Candidate, tests::start_debug_session_with, *};
+use crate::{
+    attach_modal::{Candidate, ModalIntent},
+    tests::start_debug_session_with,
+    *,
+};
 use attach_modal::AttachModal;
 use dap::{FakeAdapter, adapters::DebugTaskDefinition};
 use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
@@ -98,12 +102,6 @@ async fn test_show_attach_modal_and_select_process(
             workspace.toggle_modal(window, cx, |window, cx| {
                 AttachModal::with_processes(
                     workspace_handle,
-                    task::ZedDebugConfig {
-                        adapter: FakeAdapter::ADAPTER_NAME.into(),
-                        request: dap::DebugRequest::Attach(AttachRequest::default()),
-                        label: "attach example".into(),
-                        stop_on_entry: None,
-                    },
                     vec![
                         Candidate {
                             pid: 0,
@@ -124,6 +122,12 @@ async fn test_show_attach_modal_and_select_process(
                     .into_iter()
                     .collect(),
                     true,
+                    ModalIntent::AttachToProcess(task::ZedDebugConfig {
+                        adapter: FakeAdapter::ADAPTER_NAME.into(),
+                        request: dap::DebugRequest::Attach(AttachRequest::default()),
+                        label: "attach example".into(),
+                        stop_on_entry: None,
+                    }),
                     window,
                     cx,
                 )
@@ -138,8 +142,7 @@ async fn test_show_attach_modal_and_select_process(
     // assert we got the expected processes
     workspace
         .update(cx, |_, window, cx| {
-            let names =
-                attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
+            let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
             // Initially all processes are visible.
             assert_eq!(3, names.len());
             attach_modal.update(cx, |this, cx| {
@@ -153,8 +156,7 @@ async fn test_show_attach_modal_and_select_process(
     // assert we got the expected processes
     workspace
         .update(cx, |_, _, cx| {
-            let names =
-                attach_modal.update(cx, |modal, cx| attach_modal::_process_names(modal, cx));
+            let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
             // Initially all processes are visible.
             assert_eq!(2, names.len());
         })
@@ -171,3 +173,139 @@ async fn test_show_attach_modal_and_select_process(
         })
         .unwrap();
 }
+
+#[gpui::test]
+async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let _initialize_subscription =
+        project::debugger::test::intercept_debug_sessions(cx, |client| {
+            client.on_request::<dap::requests::Attach, _>(move |_, args| {
+                let raw = &args.raw;
+                assert_eq!(raw["request"], "attach");
+                assert_eq!(
+                    raw["process_id"], "42",
+                    "verify process id has been replaced"
+                );
+
+                Ok(())
+            });
+        });
+
+    let pick_pid_placeholder = task::VariableName::PickProcessId.template_value();
+    workspace
+        .update(cx, |workspace, window, cx| {
+            workspace.start_debug_session(
+                DebugTaskDefinition {
+                    adapter: FakeAdapter::ADAPTER_NAME.into(),
+                    label: "attach with picker".into(),
+                    config: json!({
+                        "request": "attach",
+                        "process_id": pick_pid_placeholder,
+                    }),
+                    tcp_connection: None,
+                }
+                .to_scenario(),
+                task::TaskContext::default(),
+                None,
+                None,
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+
+    cx.run_until_parked();
+
+    let attach_modal = workspace
+        .update(cx, |workspace, _window, cx| {
+            workspace.active_modal::<AttachModal>(cx)
+        })
+        .unwrap();
+
+    assert!(
+        attach_modal.is_some(),
+        "Attach modal should open when config contains ZED_PICK_PID"
+    );
+
+    let attach_modal = attach_modal.unwrap();
+
+    workspace
+        .update(cx, |_, window, cx| {
+            attach_modal.update(cx, |modal, cx| {
+                attach_modal::set_candidates(
+                    modal,
+                    vec![
+                        Candidate {
+                            pid: 10,
+                            name: "process-1".into(),
+                            command: vec![],
+                        },
+                        Candidate {
+                            pid: 42,
+                            name: "target-process".into(),
+                            command: vec![],
+                        },
+                        Candidate {
+                            pid: 99,
+                            name: "process-3".into(),
+                            command: vec![],
+                        },
+                    ]
+                    .into_iter()
+                    .collect(),
+                    window,
+                    cx,
+                )
+            })
+        })
+        .unwrap();
+
+    cx.run_until_parked();
+
+    workspace
+        .update(cx, |_, window, cx| {
+            attach_modal.update(cx, |modal, cx| {
+                modal.picker.update(cx, |picker, cx| {
+                    picker.set_query("target", window, cx);
+                })
+            })
+        })
+        .unwrap();
+
+    cx.run_until_parked();
+
+    workspace
+        .update(cx, |_, _, cx| {
+            let names = attach_modal.update(cx, |modal, cx| attach_modal::process_names(modal, cx));
+            assert_eq!(names.len(), 1);
+            assert_eq!(names[0], " 42 target-process");
+        })
+        .unwrap();
+
+    cx.dispatch_action(Confirm);
+    cx.run_until_parked();
+
+    workspace
+        .update(cx, |workspace, _window, cx| {
+            assert!(
+                workspace.active_modal::<AttachModal>(cx).is_none(),
+                "Attach modal should be dismissed after selection"
+            );
+        })
+        .unwrap();
+}

crates/task/src/task.rs 🔗

@@ -173,6 +173,9 @@ pub enum VariableName {
     SelectedText,
     /// The symbol selected by the symbol tagging system, specifically the @run capture in a runnables.scm
     RunnableSymbol,
+    /// Open a Picker to select a process ID to use in place
+    /// Can only be used to debug configurations
+    PickProcessId,
     /// Custom variable, provided by the plugin or other external source.
     /// Will be printed with `CUSTOM_` prefix to avoid potential conflicts with other variables.
     Custom(Cow<'static, str>),
@@ -240,6 +243,7 @@ impl std::fmt::Display for VariableName {
             Self::Column => write!(f, "{ZED_VARIABLE_NAME_PREFIX}COLUMN"),
             Self::SelectedText => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SELECTED_TEXT"),
             Self::RunnableSymbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RUNNABLE_SYMBOL"),
+            Self::PickProcessId => write!(f, "{ZED_VARIABLE_NAME_PREFIX}PICK_PID"),
             Self::Custom(s) => write!(
                 f,
                 "{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}"
@@ -346,15 +350,28 @@ pub fn shell_to_proto(shell: Shell) -> proto::Shell {
 }
 
 type VsCodeEnvVariable = String;
+type VsCodeCommand = String;
 type ZedEnvVariable = String;
 
 struct EnvVariableReplacer {
     variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>,
+    commands: HashMap<VsCodeCommand, ZedEnvVariable>,
 }
 
 impl EnvVariableReplacer {
     fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> Self {
-        Self { variables }
+        Self {
+            variables,
+            commands: HashMap::default(),
+        }
+    }
+
+    fn with_commands(
+        mut self,
+        commands: impl IntoIterator<Item = (VsCodeCommand, ZedEnvVariable)>,
+    ) -> Self {
+        self.commands = commands.into_iter().collect();
+        self
     }
 
     fn replace_value(&self, input: serde_json::Value) -> serde_json::Value {
@@ -380,7 +397,13 @@ impl EnvVariableReplacer {
             if left == "env" && !right.is_empty() {
                 let variable_name = &right[1..];
                 return Some(format!("${{{variable_name}}}"));
+            } else if left == "command" && !right.is_empty() {
+                let command_name = &right[1..];
+                if let Some(replacement_command) = self.commands.get(command_name) {
+                    return Some(format!("${{{replacement_command}}}"));
+                }
             }
+
             let (variable_name, default) = (left, right);
             let append_previous_default = |ret: &mut String| {
                 if !default.is_empty() {

crates/task/src/vscode_debug_format.rs 🔗

@@ -68,7 +68,11 @@ impl TryFrom<VsCodeDebugTaskFile> for DebugTaskFile {
                 VariableName::RelativeFile.to_string(),
             ),
             ("file".to_owned(), VariableName::File.to_string()),
-        ]));
+        ]))
+        .with_commands([(
+            "pickMyProcess".to_owned(),
+            VariableName::PickProcessId.to_string(),
+        )]);
         let templates = file
             .configurations
             .into_iter()
@@ -96,7 +100,7 @@ fn task_type_to_adapter_name(task_type: &str) -> String {
 mod tests {
     use serde_json::json;
 
-    use crate::{DebugScenario, DebugTaskFile};
+    use crate::{DebugScenario, DebugTaskFile, VariableName};
 
     use super::VsCodeDebugTaskFile;
 
@@ -152,4 +156,39 @@ mod tests {
             }])
         );
     }
+
+    #[test]
+    fn test_command_pickmyprocess_replacement() {
+        let raw = r#"
+            {
+                "version": "0.2.0",
+                "configurations": [
+                    {
+                        "name": "Attach to Process",
+                        "request": "attach",
+                        "type": "cppdbg",
+                        "processId": "${command:pickMyProcess}"
+                    }
+                ]
+            }
+        "#;
+        let parsed: VsCodeDebugTaskFile =
+            serde_json_lenient::from_str(raw).expect("deserializing launch.json");
+        let zed = DebugTaskFile::try_from(parsed).expect("converting to Zed debug templates");
+
+        let expected_placeholder = format!("${{{}}}", VariableName::PickProcessId);
+        pretty_assertions::assert_eq!(
+            zed,
+            DebugTaskFile(vec![DebugScenario {
+                label: "Attach to Process".into(),
+                adapter: "CodeLLDB".into(),
+                config: json!({
+                    "request": "attach",
+                    "processId": expected_placeholder,
+                }),
+                tcp_connection: None,
+                build: None
+            }])
+        );
+    }
 }