diff --git a/README.md b/README.md
index fe5ae27..f4eb088 100644
--- a/README.md
+++ b/README.md
@@ -28,13 +28,13 @@ Or search for **Patchloom** in the Extensions view (`Ctrl+Shift+X` / `Cmd+Shift+
1. Install the [Patchloom CLI](https://github.com/patchloom/patchloom) (or run **Patchloom: Install Patchloom** from the command palette; recommended: tracks GitHub Releases with checksum verification)
```sh
brew install patchloom/tap/patchloom # macOS / Linux (Homebrew)
- npm install -g patchloom # npm (Node.js)
+ npm install -g patchloom # npm (Node.js; on Windows this is a patchloom.cmd shim)
curl -LsSf https://github.com/patchloom/patchloom/releases/latest/download/patchloom-installer.sh | sh # shell script
cargo install patchloom # from source
scoop bucket add patchloom https://github.com/patchloom/scoop-bucket
scoop install patchloom # Windows (Scoop; preferred PATH channel)
```
- On Windows, prefer the extension managed installer or Scoop. Avoid winget and Chocolatey for install or upgrade: both lag GitHub Releases and often leave you on an old CLI.
+ On Windows, prefer the extension managed installer or Scoop. `npm install -g patchloom` is supported: it puts `patchloom.cmd` on `PATH`, and the extension launches that shim through `cmd.exe`. If Show Status says `spawn EINVAL`, the extension build is one that does not launch `.cmd` shims. Avoid winget and Chocolatey for install or upgrade: both lag GitHub Releases and often leave you on an old CLI. The extension activates when the window finishes starting, including in a folder that has no `AGENTS.md` yet. Hiding the status bar does not hide the command palette entries.
2. Open a project and run **Patchloom: Setup Workspace**
@@ -49,7 +49,7 @@ The extension finds the CLI automatically. If it's not on `PATH`, point `patchlo
### One-click workspace setup
-Run `Patchloom: Setup Workspace` to walk through everything your project needs: binary detection, `AGENTS.md` generation, and MCP server configuration.
+Run `Patchloom: Setup Workspace` to walk through everything your project needs: binary detection, `AGENTS.md` generation, and MCP server configuration. In a multi-root window it asks for the folder once and uses that folder for both steps. Skipping a step still ends with a summary of what was already present, what ran, and what was skipped.
### Agent rules generation
@@ -64,7 +64,7 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs:
- **Cursor** (`.cursor/mcp.json`)
- **Windsurf** (`~/.codeium/windsurf/mcp_config.json`)
-When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error. If an existing file cannot be read, the command reports that error and does not replace it. A missing file is still created.
+When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Workspace files (VS Code, portable, and Cursor) store `command` as `patchloom`, not a machine path. A stale absolute path is rewritten. The Windsurf user file may still store the resolved binary path. VS Code also starts a native Patchloom server. Writing `.vscode/mcp.json` adds a second server next to that provider, and the command asks before it writes that file. The native server follows the `patchloom.mcpSurface` setting (`full` or `core`). Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error. If an existing file cannot be read, the command reports that error and does not replace it. A missing file is still created.
CLI **0.37.5** exposes **66** MCP tools by default (including `explain_plan`, `tidy_check`, `list_files`, `apply_fragment`, `notebook_edit`, and `operation_schema`). The core pack is 12 tools on CLI 0.37+: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `operation_schema`, `server_info`. `doc` accepts `.jsonc` (comments stay on set), plus `.env`, `.ini`, and `.properties`. `doc_set` accepts `if_exists` to skip a missing file or selector. `apply_patch` accepts `apply=false` for a check-only preview. CLI `list-files` inventories ignore-aware paths the same way as MCP `list_files`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). `doc_query` can list object keys and count array or object length (CLI 0.32+). Search, replace, and tidy dests accept cwd-only globs such as `*.txt` (CLI 0.33+; use `**/*.txt` or `--glob` for nested files). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels.
@@ -76,11 +76,11 @@ The status bar shows MCP and binary readiness at a glance:
- **$(check) Patchloom**, with the detected version when known, when the binary is ready and MCP is not configured
- **$(warning) Patchloom** when the binary is missing or needs an upgrade
-Click it to see full diagnostics, including per-file MCP configuration status (VS Code, portable workspace, Cursor, Windsurf).
+Click it to run **Show Status**. That command writes the full report to the Output channel and shows one short line, plus a button when a fix is available. Install does not download until you pick that button. The plug icon means a workspace MCP file is configured. A Windsurf user file alone does not turn on the plug icon, but Show Status still lists it. Saving `AGENTS.md`, `.mcp.json`, `.vscode/mcp.json`, or `.cursor/mcp.json`, focusing the window, or running **Refresh Status** updates the item.
### Verify MCP Server
-`Patchloom: Verify MCP Server` spawns `patchloom mcp-server`, sends a JSON-RPC `initialize` handshake, and confirms the server responds correctly. Reports the server name and version on success, or a diagnostic error on failure.
+`Patchloom: Verify MCP Server` runs in the workspace folder. If a workspace MCP file names a command, that command is the one probed. With no config file, it probes the resolved CLI. It sends a JSON-RPC `initialize` handshake and reports the server name and version on success, or a diagnostic error on failure. A bad configured command fails the check.
### Quick actions
@@ -118,13 +118,15 @@ Click it to see full diagnostics, including per-file MCP configuration status (V
| **Replace markdown section** | Replace content under a markdown heading |
| **Apply patch (unified / Begin Patch / SEARCH-REPLACE)** | Apply a unified diff, Codex Begin Patch, or Aider SEARCH/REPLACE (`patch apply`, CLI 0.30+) |
| **Merge patch (three-way)** | Apply a stale patch using three-way merge (v0.2.0+) |
-| **Undo last change** | Restore files from the latest Patchloom backup session |
+| **Undo last change** | Restore the latest backup, one session id, or one workspace path |
-Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Containment is relative to the effective working directory (the workspace folder). Patch apply and patch merge copy a patch file that lives outside the workspace into a temporary directory inside it, then still pass `--contain`. A path inside that patch that leaves the workspace is rejected.
+Content fields open a scratch file so newlines stay newlines. Pattern and anchor fields start with the current selection when it is a single line. Preview copies the file under the workspace and runs the CLI with the workspace as its working directory, so `.patchloom.toml` format commands and EditorConfig match Apply. The preview copy is removed afterward. The diff uses a `patchloom-preview` document, which is not an untitled buffer and is not saved. A preview that finds no pattern says so. A command that exits cleanly and changes nothing says the command made no edit. Search still writes its text to the Output channel, and then offers the hits in a list that opens the file at the matching line. Files without a match open at line 1.
+
+Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Containment is relative to the effective working directory (the workspace folder). Patch apply and patch merge ask for a file or the clipboard, stage that text inside the workspace, show a dry run, and write only after a second confirmation. A dry run that would change files exits 2 with `ok: true` and `applied: false`. That result still reaches the write confirmation. A bad patch or a stale hunk stops before that confirmation. A three-way merge that conflicts, when conflict markers were not allowed, also stops. Nothing is written. Run it again and choose Allow conflict markers to write them. An empty clipboard is an error and does not write. Cancel leaves the files unchanged. A path inside that patch that leaves the workspace is rejected.
### Batch operations
-`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, fuzzy replace, `doc.set`, JSONC `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, directory `file.rename`, markdown section inserts, tidy). The extension pipes the plan to `patchloom batch --json --apply`. A hard error rolls the plan back. A formatter failure (`format_failed`) does not: that write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported, other successful writes stay, and the completion message names those operations. A line whose first non-whitespace character is `#` is a comment. Blank lines and comments are not operations. A plan that contains only those lines is not applied.
+`Patchloom: Batch Apply` opens a line-oriented plan whose example lines are comments, including replace, fuzzy replace, `doc.set`, JSONC `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, directory `file.rename`, markdown section inserts, and tidy. Remove the leading `#` on the lines you want. Apply first runs the plan without `--apply` and shows that result. A preview that would change files exits 2 with `ok: true` and `applied: false`, including when another replace in the same plan is refused. That dry run still offers the write confirmation. A parse error or a plan that matches nothing does not. Confirming the write runs the plan with `--apply`. A hard error rolls the plan back. A formatter failure (`format_failed`) does not: that write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported, other successful writes stay, and the completion message names those operations. A line whose first non-whitespace character is `#` is a comment. Blank lines and comments are not operations. A plan that contains only those lines is not applied.
### Output channel
@@ -146,10 +148,11 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
| `Patchloom: Quick Action` | Build a Patchloom CLI command from an interactive picker |
| `Patchloom: Batch Apply` | Open a batch plan, apply it, and report refused operations |
| `Patchloom: Show Output` | Open the Patchloom output channel for CLI logs and diagnostics |
-| `Patchloom: Show Status` | Display binary readiness, version, compatibility, and workspace state |
-| `Patchloom: Verify MCP Server` | Spawn the MCP server and verify it responds to a JSON-RPC initialize request |
+| `Patchloom: Show Status` | Write the full readiness report to the Output channel and show one short line |
+| `Patchloom: Refresh Status` | Re-check the CLI and workspace files and update the status bar |
+| `Patchloom: Verify MCP Server` | Probe the configured MCP command, or the resolved CLI, with a JSON-RPC initialize request |
| `Patchloom: Install Patchloom` | Download and install the Patchloom CLI with checksum verification |
-| `Patchloom: Update Patchloom` | Update a managed Patchloom install to the latest release |
+| `Patchloom: Update Patchloom` | Check for a managed update, ask before download, then install |
| `Patchloom: Reinstall Patchloom` | Re-download and reinstall the managed Patchloom binary |
| `Patchloom: Open Settings` | Jump to Patchloom extension settings |
| `Patchloom: Open Documentation` | Open the Patchloom documentation site in a browser |
@@ -160,8 +163,9 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
| Setting | Default | Description |
|---------|---------|-------------|
| `patchloom.path` | `""` | Absolute path to the Patchloom binary. When empty, the extension searches `PATH` and then the managed install location. |
-| `patchloom.showStatusBar` | `true` | Show a status bar item reporting whether Patchloom is available. |
-| `patchloom.enable` | `true` | Enable the extension. When disabled, the status bar is hidden and background checks are skipped. |
+| `patchloom.showStatusBar` | `true` | Show the status bar item. Hiding it does not hide command palette entries. Click runs Show Status. |
+| `patchloom.enable` | `true` | Enable the extension. When disabled, the status bar is hidden and automatic update checks are skipped. Command palette entries stay available. |
+| `patchloom.mcpSurface` | `"full"` | Tool pack for the native MCP server (`full` or `core`). |
| `patchloom.trace.server` | `"off"` | Diagnostic CLI trace level (`off`, `messages`, `verbose`). User-facing search, undo, patch-merge, and batch-apply streams always go to Output. |
| `patchloom.env` | `{}` | Extra `PATCHLOOM_*` variables for the CLI (for example `{"PATCHLOOM_LOG": "debug"}`). Workspace values apply only in a trusted folder. |
| `patchloom.managedInstall.autoUpdate` | `true` | Automatically check for CLI updates on activation. |
diff --git a/package.json b/package.json
index 488cbc8..0eb9ff3 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
],
"main": "./out/extension.js",
"activationEvents": [
+ "onStartupFinished",
"workspaceContains:**/AGENTS.md",
"workspaceContains:**/.patchloom.toml"
],
@@ -81,6 +82,11 @@
"title": "Show Status",
"category": "Patchloom"
},
+ {
+ "command": "patchloom.refreshStatus",
+ "title": "Refresh Status",
+ "category": "Patchloom"
+ },
{
"command": "patchloom.batchApply",
"title": "Batch Apply",
@@ -207,12 +213,21 @@
"patchloom.showStatusBar": {
"type": "boolean",
"default": true,
- "description": "Show a status bar item reporting whether Patchloom is available for the current workspace."
+ "markdownDescription": "Show the status bar item. Hiding it does not hide command palette entries. The extension still detects the CLI so those entries can appear. Click the item to open Show Status."
},
"patchloom.enable": {
"type": "boolean",
"default": true,
- "markdownDescription": "Enable the Patchloom extension. When disabled, the status bar is hidden and background checks are skipped. Commands remain available for manual invocation."
+ "markdownDescription": "Enable the Patchloom extension. When disabled, the status bar is hidden and automatic update checks are skipped. Command palette entries stay available. The extension still detects the CLI so those entries can appear."
+ },
+ "patchloom.mcpSurface": {
+ "type": "string",
+ "default": "full",
+ "enum": [
+ "full",
+ "core"
+ ],
+ "markdownDescription": "Tool pack for the native Patchloom MCP server that VS Code starts. `core` sets `PATCHLOOM_MCP_SURFACE=core`. `full` is the default inventory. A `patchloom` entry written into `.vscode/mcp.json` is a second server next to this one. Change this setting, then run Refresh Status, to update the native server."
},
"patchloom.trace.server": {
"type": "string",
diff --git a/src/binary/launch.ts b/src/binary/launch.ts
new file mode 100644
index 0000000..67ee238
--- /dev/null
+++ b/src/binary/launch.ts
@@ -0,0 +1,105 @@
+import { execFile, type ExecFileOptions } from "node:child_process";
+import * as path from "node:path";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+export interface PatchloomLaunchPlan {
+ readonly file: string;
+ readonly args: string[];
+ /** Always false. `.cmd` shims go through `cmd.exe /d /s /c`, not `shell: true`. */
+ readonly shell: false;
+ readonly windowsVerbatimArguments: boolean;
+}
+
+/**
+ * How to spawn a Patchloom binary.
+ * Windows `npm install -g` exposes `patchloom.cmd`. Node `execFile` on that
+ * shim fails with `spawn EINVAL` unless `cmd.exe` runs it.
+ */
+export function patchloomLaunch(
+ binaryPath: string,
+ args: readonly string[],
+ platform: NodeJS.Platform = process.platform,
+ comSpec?: string,
+ verbatim = true
+): PatchloomLaunchPlan {
+ if (needsWindowsCommandProcessor(binaryPath, platform)) {
+ const script = [binaryPath, ...args].map(quoteCmdArgument).join(" ");
+ // `/s` strips one leading quote and the last quote. Callers that set
+ // windowsVerbatimArguments must supply that pair. A host that quotes
+ // argv itself (native MCP) must not get a second pair.
+ const commandLine = verbatim ? `"${script}"` : script;
+ return {
+ file: resolveComSpec(platform, comSpec),
+ args: ["/d", "/s", "/c", commandLine],
+ shell: false,
+ windowsVerbatimArguments: verbatim
+ };
+ }
+
+ return {
+ file: binaryPath,
+ args: [...args],
+ shell: false,
+ windowsVerbatimArguments: false
+ };
+}
+
+export async function execPatchloomFile(
+ binaryPath: string,
+ args: readonly string[],
+ options: ExecFileOptions
+): Promise<{ stdout: string; stderr: string }> {
+ const launch = patchloomLaunch(binaryPath, args);
+ const result = await execFileAsync(launch.file, launch.args, {
+ ...options,
+ encoding: "utf8",
+ shell: launch.shell,
+ windowsVerbatimArguments: launch.windowsVerbatimArguments
+ });
+ return {
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
+ stderr: typeof result.stderr === "string" ? result.stderr : ""
+ };
+}
+
+function needsWindowsCommandProcessor(binaryPath: string, platform: NodeJS.Platform): boolean {
+ if (platform !== "win32") {
+ return false;
+ }
+ const extension = path.win32.extname(binaryPath).toLowerCase();
+ if (extension === ".cmd" || extension === ".bat") {
+ return true;
+ }
+ const bareName = !binaryPath.includes("\\")
+ && !binaryPath.includes("/")
+ && !path.win32.isAbsolute(binaryPath);
+ return bareName && extension.length === 0;
+}
+
+function resolveComSpec(platform: NodeJS.Platform, comSpec?: string): string {
+ if (comSpec && comSpec.trim().length > 0) {
+ return comSpec;
+ }
+ if (platform === "win32") {
+ const fromEnv = process.env.ComSpec;
+ if (fromEnv && fromEnv.trim().length > 0) {
+ return fromEnv;
+ }
+ }
+ return "cmd.exe";
+}
+
+function quoteCmdArgument(value: string): string {
+ if (/[\r\n]/.test(value)) {
+ throw new Error(
+ "Patchloom cannot pass a newline through the Windows npm shim (patchloom.cmd). Use the managed patchloom.exe, or keep this value on one line."
+ );
+ }
+ const escaped = value.replaceAll("%", "%%");
+ if (escaped.length === 0 || /[\s"&|<>^()]/.test(escaped)) {
+ return `"${escaped.replaceAll("\"", "\"\"")}"`;
+ }
+ return escaped;
+}
diff --git a/src/binary/patchloom.ts b/src/binary/patchloom.ts
index 00556fe..ca0739a 100644
--- a/src/binary/patchloom.ts
+++ b/src/binary/patchloom.ts
@@ -1,8 +1,8 @@
-import { execFile } from "node:child_process";
+import { execPatchloomFile } from "./launch.js";
import { constants as fsConstants } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
-import { promisify } from "node:util";
+
import {
detectManagedInstallTarget,
getManagedInstallRoot,
@@ -11,8 +11,6 @@ import {
} from "../install/managed.js";
import { formatError } from "../util.js";
-const execFileAsync = promisify(execFile);
-
export const MINIMUM_SUPPORTED_PATCHLOOM_VERSION = "0.3.0";
export const PATCHLOOM_RELEASES_URL = "https://github.com/patchloom/patchloom/releases";
export const PATCHLOOM_DOCS_URL = "https://patchloom.github.io/patchloom/";
@@ -441,7 +439,7 @@ async function isExecutable(binaryPath: string): Promise {
}
async function readVersion(binaryPath: string): Promise {
- const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], {
+ const { stdout, stderr } = await execPatchloomFile(binaryPath, ["--version"], {
timeout: 5_000,
windowsHide: true
});
diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts
index 6f9de33..0ce5852 100644
--- a/src/commands/batchApply.ts
+++ b/src/commands/batchApply.ts
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import type * as VSCode from "vscode";
+import { patchloomLaunch } from "../binary/launch.js";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { formatCliOutput, mergePatchloomEnv, PATCHLOOM_CLI_TIMEOUT_MS } from "../util.js";
import {
@@ -10,13 +11,14 @@ import {
presentCliResultInOutput
} from "../logging/outputChannel.js";
import { activeWorkspaceFolder } from "../workspace/readiness.js";
+import { previewDryRunMayContinue } from "./previewExit.js";
import { serializePatchloomArgs } from "./quickActions.js";
// Batch replace is PATH OLD NEW (not CLI `replace OLD --new NEW path`). See CLI 0.18+ batch --help.
// --insert-after is not a batch flag. The CLI stores that token as the new text.
// doc.update / doc.delete_where are the multi-match siblings of doc.set / doc.delete
// (CLI 0.27+ suggested_op hints this).
-export const BATCH_TEMPLATE = [
+export const BATCH_EXAMPLE_LINES = [
"replace src/example.ts \"old text\" \"new text\"",
"replace src/example.ts \"typo_here\" \"fixed\" --fuzzy --min-fuzzy-score 0.80",
"file.prepend src/example.ts \"header line\"",
@@ -28,16 +30,17 @@ export const BATCH_TEMPLATE = [
"file.append src/example.ts \"new appended line\"",
"file.rename src/old_pkg src/new_pkg",
"md.insert_after_section README.md \"## Config\" \"## FAQ\"",
- "tidy.fix src/example.ts",
- ""
-].join("\n");
+ "tidy.fix src/example.ts"
+] as const;
+
+export const BATCH_TEMPLATE = `${BATCH_EXAMPLE_LINES.map((line) => `# ${line}`).join("\n")}\n`;
export function buildBatchTemplate(): string {
return BATCH_TEMPLATE;
}
export const BATCH_APPLY_PROMPT =
- "Edit the batch plan, then click Apply. A hard error rolls the plan back. A formatter failure (format_failed) does not: the write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported and does not undo other writes. Lines whose first non-whitespace character is # are comments and are not applied. Multi-match lines use dotted batch ops: doc.update PATH SELECTOR VALUE and doc.delete_where PATH SELECTOR PREDICATE.";
+ "The template lines are comments. Remove the leading # on the lines you want, then click Dry run. A second confirmation writes the plan. A hard error rolls the plan back. A formatter failure (format_failed) does not: the write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported and does not undo other writes. Lines whose first non-whitespace character is # are comments and are not applied. Multi-match lines use dotted batch ops: doc.update PATH SELECTOR VALUE and doc.delete_where PATH SELECTOR PREDICATE.";
/** Count operations `patchloom batch` will run. A leading BOM, blank lines, and `#` comments are ignored. */
export function parseBatchOperationCount(plan: string): number {
@@ -54,8 +57,13 @@ export function isEmptyBatchPlan(plan: string): boolean {
}
/** CLI argv for Batch Apply. `--json` is how a refused replace is visible on exit 0. */
-export function buildBatchApplyArgs(): string[] {
- return serializePatchloomArgs({ args: ["batch", "--json"], apply: true, contain: true });
+export function buildBatchApplyArgs(apply = true): string[] {
+ return serializePatchloomArgs({ args: ["batch", "--json"], apply, contain: true });
+}
+
+/** Dry run: same plan, without `--apply`. */
+export function buildBatchDryRunArgs(): string[] {
+ return buildBatchApplyArgs(false);
}
export interface BatchRefusedOperation {
@@ -239,9 +247,9 @@ export async function batchApply(): Promise {
const choice = await vscode.window.showInformationMessage(
BATCH_APPLY_PROMPT,
- "Apply"
+ "Dry run"
);
- if (choice !== "Apply") {
+ if (choice !== "Dry run") {
return;
}
@@ -256,7 +264,27 @@ export async function batchApply(): Promise {
const log = getPatchloomLog();
const runtime = await getPatchloomRuntimeConfig();
const env = mergePatchloomEnv(process.env, runtime.extraEnv);
- const args = buildBatchApplyArgs();
+ const dryArgs = buildBatchDryRunArgs();
+ logCliCommand(log, runtime.trace, binaryPath, dryArgs, folder.uri.fsPath);
+ const dryRun = await executePatchloomWithStdin(binaryPath, dryArgs, folder.uri.fsPath, plan, env);
+ logCliResult(log, runtime.trace, dryRun.exitCode, dryRun.stdout, dryRun.stderr);
+ presentCliResultInOutput(log, dryRun);
+ if (!previewDryRunMayContinue(dryRun)) {
+ await vscode.window.showErrorMessage(
+ `Batch dry run failed: ${formatCliOutput(dryRun)}`
+ );
+ return;
+ }
+
+ const write = await vscode.window.showInformationMessage(
+ "Dry run finished. The plan has not been written. Apply it now?",
+ "Apply"
+ );
+ if (write !== "Apply") {
+ return;
+ }
+
+ const args = buildBatchApplyArgs(true);
logCliCommand(log, runtime.trace, binaryPath, args, folder.uri.fsPath);
const result = await executePatchloomWithStdin(binaryPath, args, folder.uri.fsPath, plan, env);
@@ -294,12 +322,25 @@ function executePatchloomWithStdin(
env: NodeJS.ProcessEnv
): Promise {
return new Promise((resolve) => {
- const child = execFile(binaryPath, [...args], {
+ let launch: ReturnType;
+ try {
+ launch = patchloomLaunch(binaryPath, args);
+ } catch (error) {
+ resolve({
+ exitCode: 1,
+ stdout: "",
+ stderr: error instanceof Error ? error.message : String(error)
+ });
+ return;
+ }
+ const child = execFile(launch.file, launch.args, {
cwd,
env,
timeout: PATCHLOOM_CLI_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
- windowsHide: true
+ windowsHide: true,
+ shell: launch.shell,
+ windowsVerbatimArguments: launch.windowsVerbatimArguments
}, (error, stdout, stderr) => {
if (error) {
resolve({
diff --git a/src/commands/configureMcp.ts b/src/commands/configureMcp.ts
index 5f02bfe..013a567 100644
--- a/src/commands/configureMcp.ts
+++ b/src/commands/configureMcp.ts
@@ -13,13 +13,13 @@ import {
import { refreshStatusBar } from "../status/statusBar.js";
import { activeWorkspaceFolder, describeWorkspaceEnvironment } from "../workspace/readiness.js";
-export async function configureMcp(): Promise {
+export async function configureMcp(preset?: vscode.WorkspaceFolder): Promise {
const binaryPath = await ensurePatchloomReadyOrNotify("Patchloom needs a working binary before MCP setup can continue.");
if (!binaryPath) {
- return;
+ return false;
}
- const folder = await activeWorkspaceFolder({
+ const folder = preset ?? await activeWorkspaceFolder({
promptIfMany: true,
placeHolder: "Select the workspace folder to configure for Patchloom MCP"
});
@@ -39,7 +39,7 @@ export async function configureMcp(): Promise {
if (selectable.length === 0) {
const detail = environment.note ? ` ${environment.note}` : "";
await vscode.window.showWarningMessage(`No supported MCP config targets were found for this environment.${detail}`);
- return;
+ return false;
}
const selections = await vscode.window.showQuickPick(selectable, {
@@ -47,7 +47,7 @@ export async function configureMcp(): Promise {
placeHolder: "Select editor config targets for Patchloom MCP setup"
});
if (!selections || selections.length === 0) {
- return;
+ return false;
}
const surfacePick = await vscode.window.showQuickPick(
@@ -66,10 +66,25 @@ export async function configureMcp(): Promise {
{ placeHolder: "Which MCP tool surface should the server expose?" }
);
if (!surfacePick) {
- return;
+ return false;
}
- const selectedKinds = selections.map((selection) => selection.target.kind);
+ let chosen = selections;
+ if (chosen.some((selection) => selection.target.kind === "vscode-workspace")) {
+ const proceed = await vscode.window.showWarningMessage(
+ "VS Code already starts the native Patchloom MCP server. Writing .vscode/mcp.json adds a second server next to that provider.",
+ "Write file",
+ "Skip that file"
+ );
+ if (proceed !== "Write file") {
+ chosen = chosen.filter((selection) => selection.target.kind !== "vscode-workspace");
+ if (chosen.length === 0) {
+ return false;
+ }
+ }
+ }
+
+ const selectedKinds = chosen.map((selection) => selection.target.kind);
let results;
try {
results = await configureMcpTargets({
@@ -93,7 +108,7 @@ export async function configureMcp(): Promise {
if (error instanceof McpConfigureError && error.completed.some((result) => result.changed)) {
await refreshStatusBar();
}
- return;
+ return false;
}
const applied = results;
@@ -106,6 +121,7 @@ export async function configureMcp(): Promise {
await refreshStatusBar();
await vscode.window.showInformationMessage(summary || "Patchloom MCP setup completed.");
+ return true;
}
function targetDetail(target: { kind: string; configured: boolean; exists: boolean }): string {
@@ -118,7 +134,7 @@ function targetDetail(target: { kind: string; configured: boolean; exists: boole
return `${state}. Preferred for current VS Code and Copilot`;
}
if (target.kind === "vscode-workspace") {
- return `${state}. Older VS Code location`;
+ return `${state}. Older VS Code location. This file is a second server next to the native provider`;
}
return state;
}
diff --git a/src/commands/initializeProject.ts b/src/commands/initializeProject.ts
index 123c977..2058fb8 100644
--- a/src/commands/initializeProject.ts
+++ b/src/commands/initializeProject.ts
@@ -1,32 +1,30 @@
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
import type * as VSCode from "vscode";
+import { execPatchloomFile } from "../binary/launch.js";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand, logCliResult, writeUserVisibleOutput } from "../logging/outputChannel.js";
import { formatCliOutput, formatError, mergePatchloomEnv } from "../util.js";
import { activeWorkspaceFolder } from "../workspace/readiness.js";
-const execFileAsync = promisify(execFile);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export type AgentsFileState = "missing" | "up_to_date" | "different";
-export async function initializeProject(): Promise {
+export async function initializeProject(preset?: VSCode.WorkspaceFolder): Promise {
const vscode = await import("vscode");
const { refreshStatusBar } = await import("../status/statusBar.js");
- const folder = await activeWorkspaceFolder({
+ const folder = preset ?? await activeWorkspaceFolder({
promptIfMany: true,
placeHolder: "Select the workspace folder to initialize for Patchloom"
});
if (!folder) {
await vscode.window.showWarningMessage("Open a workspace folder before running Patchloom: Initialize Project.");
- return;
+ return false;
}
const binaryPath = await ensurePatchloomReadyOrNotify("Upgrade Patchloom before initializing this workspace.");
if (!binaryPath) {
- return;
+ return false;
}
const modePick = await vscode.window.showQuickPick(
@@ -38,7 +36,7 @@ export async function initializeProject(): Promise {
{ placeHolder: "Which agent-rules integration mode?" }
);
if (!modePick) {
- return;
+ return false;
}
const platformPick = await vscode.window.showQuickPick(
@@ -50,7 +48,7 @@ export async function initializeProject(): Promise {
{ placeHolder: "Which platform for shell examples?" }
);
if (!platformPick) {
- return;
+ return false;
}
const surfacePick = await vscode.window.showQuickPick(
@@ -69,7 +67,7 @@ export async function initializeProject(): Promise {
{ placeHolder: "Which agent-rules surface?" }
);
if (!surfacePick) {
- return;
+ return false;
}
const runtime = await getPatchloomRuntimeConfig();
@@ -89,12 +87,12 @@ export async function initializeProject(): Promise {
writeUserVisibleOutput(log, message);
log?.show();
await vscode.window.showErrorMessage(`Failed to run patchloom agent-rules in ${folder.name}: ${message}`);
- return;
+ return false;
}
if (!rules.trim()) {
await vscode.window.showErrorMessage("patchloom agent-rules returned no output. Verify the CLI is working by running `patchloom agent-rules` in a terminal.");
- return;
+ return false;
}
const agentsUri = vscode.Uri.joinPath(folder.uri, "AGENTS.md");
@@ -105,7 +103,7 @@ export async function initializeProject(): Promise {
state = classifyAgentsFile(existingContent, rules);
} catch (error) {
await vscode.window.showErrorMessage(`Could not read AGENTS.md: ${formatError(error)}`);
- return;
+ return false;
}
if (state === "missing") {
@@ -114,7 +112,7 @@ export async function initializeProject(): Promise {
await vscode.window.showTextDocument(doc, { preview: false });
await vscode.window.showInformationMessage(`Created AGENTS.md in ${folder.name}.`);
await refreshStatusBar();
- return;
+ return true;
}
if (state === "up_to_date") {
@@ -122,7 +120,7 @@ export async function initializeProject(): Promise {
await vscode.window.showTextDocument(doc, { preview: false });
await vscode.window.showInformationMessage(`AGENTS.md in ${folder.name} is already up to date.`);
await refreshStatusBar();
- return;
+ return true;
}
const generatedDoc = await vscode.workspace.openTextDocument({
@@ -137,6 +135,7 @@ export async function initializeProject(): Promise {
);
await vscode.window.showWarningMessage("AGENTS.md already exists and differs. Opened a diff against newly generated rules.");
await refreshStatusBar();
+ return true;
}
export function classifyAgentsFile(existingContent: string | undefined, generatedRules: string): AgentsFileState {
@@ -185,6 +184,8 @@ export interface GenerateAgentRulesDeps {
maxBuffer?: number;
windowsHide?: boolean;
env?: NodeJS.ProcessEnv;
+ shell?: boolean;
+ windowsVerbatimArguments?: boolean;
}
) => Promise<{ stdout: string; stderr: string }>;
readonly extraEnv?: Record;
@@ -203,7 +204,7 @@ export async function generateAgentRules(
const env = mergePatchloomEnv(deps.processEnv ?? process.env, deps.extraEnv);
const trace = deps.trace ?? "off";
logCliCommand(log, trace, binaryPath, args, cwd);
- const run = deps.execFile ?? execFileAsync;
+ const run = deps.execFile ?? execPatchloomFile;
try {
const { stdout, stderr } = await run(binaryPath, args, {
cwd,
diff --git a/src/commands/managedInstall.ts b/src/commands/managedInstall.ts
index f978a6f..2aa8dad 100644
--- a/src/commands/managedInstall.ts
+++ b/src/commands/managedInstall.ts
@@ -1,6 +1,5 @@
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
import * as vscode from "vscode";
+import { execPatchloomFile } from "../binary/launch.js";
import { resolvePatchloomStatus, PATCHLOOM_RELEASES_URL, clearPatchloomStatusInflight } from "../binary/patchloom.js";
import { decideManagedUpdate, resolveManagedBinaryVersion } from "../binary/managedUpdate.js";
import {
@@ -14,8 +13,7 @@ import { getPatchloomLog } from "../logging/outputChannel.js";
import { refreshMcpServerBinary } from "../mcp/register.js";
import { refreshStatusBar } from "../status/statusBar.js";
import { formatError } from "../util.js";
-
-const execFileAsync = promisify(execFile);
+import { runCheckedInstall } from "./managedUpdateFlow.js";
const MANAGED_INSTALL_UNAVAILABLE =
"Managed install is not available: extension storage path is not set.";
@@ -27,7 +25,7 @@ const PROGRESS_OPTIONS: vscode.ProgressOptions = {
};
async function readManagedBinaryVersionText(binaryPath: string): Promise {
- const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], {
+ const { stdout, stderr } = await execPatchloomFile(binaryPath, ["--version"], {
timeout: 5_000,
windowsHide: true
});
@@ -116,44 +114,40 @@ export async function updatePatchloom(): Promise {
const log = getPatchloomLog();
- await vscode.window.withProgress(
- PROGRESS_OPTIONS,
- async (progress) => {
- try {
+ try {
+ await runCheckedInstall({
+ check: () => vscode.window.withProgress(PROGRESS_OPTIONS, async (progress) => {
progress.report({ message: "Checking for updates..." });
const latestVersion = await fetchLatestReleaseVersion();
-
const status = await resolvePatchloomStatus();
const managedVersion = await resolveManagedBinaryVersion(status, readManagedBinaryVersionText);
- const decision = decideManagedUpdate(latestVersion, managedVersion);
+ return { latestVersion, decision: decideManagedUpdate(latestVersion, managedVersion) };
+ }),
+ confirm: async ({ latestVersion, decision }) => {
if (decision.kind === "current") {
await vscode.window.showInformationMessage(
`Managed Patchloom ${decision.version} is already up to date.`
);
- return;
+ return false;
}
const upgradeLabel = decision.from
? `${decision.from} to ${decision.to}`
: decision.to;
-
const choice = await vscode.window.showInformationMessage(
`Managed Patchloom ${upgradeLabel} is available. Install now?`,
"Install",
"View Release"
);
-
if (choice === "View Release") {
await vscode.env.openExternal(
vscode.Uri.parse(`${PATCHLOOM_RELEASES_URL}/tag/patchloom-v${latestVersion}`)
);
- return;
- }
-
- if (choice !== "Install") {
- return;
+ return false;
}
-
+ return choice === "Install";
+ },
+ install: ({ latestVersion }) => vscode.window.withProgress(PROGRESS_OPTIONS, async (progress) => {
const result = await performManagedInstall({
installRoot,
version: latestVersion,
@@ -161,7 +155,6 @@ export async function updatePatchloom(): Promise {
progress.report({ message: stageLabel(stage) });
}
});
-
log?.log(`Managed update complete: Patchloom ${result.version} at ${result.binaryPath}`);
clearPatchloomStatusInflight();
await refreshStatusBar();
@@ -169,14 +162,14 @@ export async function updatePatchloom(): Promise {
await vscode.window.showInformationMessage(
`Patchloom updated to ${result.version}.`
);
- } catch (error) {
- const message = formatError(error);
- log?.log(`Managed update failed: ${message}`);
- log?.show();
- await vscode.window.showErrorMessage(`Failed to update Patchloom: ${message}`);
- }
- }
- );
+ })
+ });
+ } catch (error) {
+ const message = formatError(error);
+ log?.log(`Managed update failed: ${message}`);
+ log?.show();
+ await vscode.window.showErrorMessage(`Failed to update Patchloom: ${message}`);
+ }
}
export async function reinstallPatchloom(): Promise {
diff --git a/src/commands/managedUpdateFlow.ts b/src/commands/managedUpdateFlow.ts
new file mode 100644
index 0000000..d4f30b1
--- /dev/null
+++ b/src/commands/managedUpdateFlow.ts
@@ -0,0 +1,22 @@
+export type ManagedUpdatePhase = "check" | "confirm" | "install";
+
+/**
+ * Check finishes before the confirm question. Install runs only after confirm
+ * returns true, so a progress toast is not held open over the question.
+ */
+export async function runCheckedInstall(input: {
+ readonly check: () => PromiseLike;
+ readonly confirm: (value: T) => PromiseLike;
+ readonly install: (value: T) => PromiseLike;
+ readonly onPhase?: (phase: ManagedUpdatePhase) => void;
+}): Promise {
+ input.onPhase?.("check");
+ const value = await input.check();
+ input.onPhase?.("confirm");
+ const accepted = await input.confirm(value);
+ if (!accepted) {
+ return;
+ }
+ input.onPhase?.("install");
+ await input.install(value);
+}
diff --git a/src/commands/previewExit.ts b/src/commands/previewExit.ts
new file mode 100644
index 0000000..2f645f7
--- /dev/null
+++ b/src/commands/previewExit.ts
@@ -0,0 +1,53 @@
+/**
+ * Preview without `--apply` exits 2 when it would write (`ok: true`,
+ * `applied: false`). Patch apply and merge omit `status`, so that field
+ * is not required. Call this only on the dry run. `--apply` succeeds on exit 0.
+ */
+export function previewDryRunMayContinue(result: {
+ readonly exitCode: number;
+ readonly stdout: string;
+}): boolean {
+ if (result.exitCode === 0) {
+ return true;
+ }
+ if (result.exitCode !== 2) {
+ return false;
+ }
+ const report = parsePreviewObject(result.stdout);
+ return report !== undefined && report.ok === true && report.applied !== true;
+}
+
+export type DryRunFollowUp = "apply" | "stop" | "conflicts";
+
+/**
+ * Exit 8 is a merge conflict. `--apply` exits 8 again and does not write.
+ * `--allow-conflicts` previews as exit 2, so that choice still reaches Apply.
+ */
+export function dryRunFollowUp(
+ noun: "apply" | "merge",
+ result: { readonly exitCode: number; readonly stdout: string }
+): DryRunFollowUp {
+ if (previewDryRunMayContinue(result)) {
+ return "apply";
+ }
+ if (noun === "merge" && result.exitCode === 8) {
+ return "conflicts";
+ }
+ return "stop";
+}
+
+function parsePreviewObject(stdout: string): { ok?: unknown; applied?: unknown } | undefined {
+ const trimmed = stdout.trim();
+ if (!trimmed.startsWith("{")) {
+ return undefined;
+ }
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (!parsed || typeof parsed !== "object") {
+ return undefined;
+ }
+ return parsed as { ok?: unknown; applied?: unknown };
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts
index f8cff64..2c767eb 100644
--- a/src/commands/quickActions.ts
+++ b/src/commands/quickActions.ts
@@ -1,9 +1,7 @@
-import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
-import * as os from "node:os";
import * as path from "node:path";
-import { promisify } from "node:util";
import type * as VSCode from "vscode";
+import { execPatchloomFile } from "../binary/launch.js";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import {
getPatchloomLog,
@@ -20,6 +18,7 @@ import {
resolveWorkspaceRelativePath,
sameRealFilePath
} from "../workspace/pathContainment.js";
+import { dryRunFollowUp } from "./previewExit.js";
export {
isPathInsideWorkspace,
@@ -28,7 +27,6 @@ export {
sameRealFilePath
} from "../workspace/pathContainment.js";
-const execFileAsync = promisify(execFile);
const STRUCTURED_FILE_EXTENSIONS = new Set([
".json",
".jsonc",
@@ -102,6 +100,177 @@ export function withOption(flag: string, value: string): string {
return `${flag}=${value}`;
}
+export const PATCHLOOM_PREVIEW_SCHEME = "patchloom-preview";
+
+const previewBodies = new Map();
+let previewSequence = 0;
+
+export function rememberPreviewBody(body: string): string {
+ previewSequence += 1;
+ const id = String(previewSequence);
+ previewBodies.set(id, body);
+ return id;
+}
+
+export function previewBodyForId(id: string): string {
+ return previewBodies.get(id) ?? "";
+}
+
+export function createPreviewContentProvider(): VSCode.TextDocumentContentProvider {
+ return {
+ provideTextDocumentContent(uri: VSCode.Uri): string {
+ return previewBodyForId(uri.path.replace(/^\//, ""));
+ }
+ };
+}
+
+/** First line of the editor selection, used to seed pattern and anchor fields. */
+export function seedFromSelection(selection: string | undefined): string {
+ if (!selection) {
+ return "";
+ }
+ return selection.split(/\r?\n/, 1)[0] ?? "";
+}
+
+/** Scratch-file text. Real newlines stay newlines. */
+export function contentFromScratch(text: string): string {
+ return text.replace(/\r\n/g, "\n");
+}
+
+export function previewWorkspaceFile(
+ workspaceRoot: string,
+ baseName: string,
+ nonce: string
+): { readonly directory: string; readonly filePath: string } {
+ const directory = path.join(workspaceRoot, ".patchloom", `qa-preview-${nonce}`);
+ return {
+ directory,
+ filePath: path.join(directory, path.basename(baseName))
+ };
+}
+
+/** Preview runs with this cwd so workspace format config and EditorConfig apply. */
+export function previewCwd(workspaceRoot: string): string {
+ return workspaceRoot;
+}
+
+/** Backup directory names that appeared while a preview was running. */
+export function newBackupSessionNames(before: readonly string[], after: readonly string[]): string[] {
+ const known = new Set(before);
+ return after.filter((name) => name.length > 0 && !known.has(name));
+}
+
+function isEnoent(error: unknown): boolean {
+ return typeof error === "object"
+ && error !== null
+ && "code" in error
+ && (error as { code?: unknown }).code === "ENOENT";
+}
+
+async function backupSessionNames(workspaceRoot: string): Promise {
+ try {
+ return await fs.readdir(path.join(workspaceRoot, ".patchloom", "backups"));
+ } catch (error) {
+ if (isEnoent(error)) {
+ return [];
+ }
+ return undefined;
+ }
+}
+
+async function removePreviewBackupSessions(
+ workspaceRoot: string,
+ before: readonly string[] | undefined,
+ nonce: string
+): Promise {
+ if (!before) {
+ return;
+ }
+ const after = await backupSessionNames(workspaceRoot);
+ if (!after) {
+ return;
+ }
+ for (const name of newBackupSessionNames(before, after)) {
+ const sessionDir = path.join(workspaceRoot, ".patchloom", "backups", name);
+ let manifest = "";
+ try {
+ manifest = await fs.readFile(path.join(sessionDir, "manifest.json"), "utf8");
+ } catch (error) {
+ if (!isEnoent(error)) {
+ continue;
+ }
+ }
+ if (!manifest.includes(`qa-preview-${nonce}`)) {
+ continue;
+ }
+ try {
+ await fs.rm(sessionDir, { recursive: true, force: true });
+ } catch {
+ // A locked session must not hide a preview that already succeeded.
+ }
+ }
+}
+
+export interface SearchHitLocation {
+ readonly path: string;
+ readonly line: number;
+}
+
+/** `path:line:` hits. A Windows drive letter is not treated as the line number. */
+export function parseSearchHitLines(stdout: string): SearchHitLocation[] {
+ const hits: SearchHitLocation[] = [];
+ for (const raw of stdout.split(/\r?\n/)) {
+ if (raw.trim().length === 0) {
+ continue;
+ }
+ const match = /^(.*?):(\d+):/.exec(raw);
+ if (!match || match[1].length === 0) {
+ continue;
+ }
+ const line = Number(match[2]);
+ if (!Number.isInteger(line) || line < 1) {
+ continue;
+ }
+ hits.push({ path: match[1], line });
+ }
+ return hits;
+}
+
+/** One path per line. Opening a hit uses line 1. */
+export function parseFilesWithoutMatchLines(stdout: string): SearchHitLocation[] {
+ const hits: SearchHitLocation[] = [];
+ for (const raw of stdout.split(/\r?\n/)) {
+ const trimmed = raw.trim();
+ if (trimmed.length === 0 || trimmed.startsWith("{")) {
+ continue;
+ }
+ hits.push({ path: trimmed, line: 1 });
+ }
+ return hits;
+}
+
+export type UndoSelection =
+ | { readonly kind: "latest" }
+ | { readonly kind: "session"; readonly sessionId: string }
+ | { readonly kind: "path"; readonly relativePath: string };
+
+export function buildUndoArgs(selection: UndoSelection = { kind: "latest" }): string[] {
+ if (selection.kind === "session") {
+ return ["undo", "--session", selection.sessionId];
+ }
+ if (selection.kind === "path") {
+ return ["undo", "--path", selection.relativePath];
+ }
+ return ["undo"];
+}
+
+export function emptyClipboardPatchMessage(text: string): string | undefined {
+ if (text.trim().length === 0) {
+ return "The clipboard has no patch text.";
+ }
+ return undefined;
+}
+
export function presentSearchOutcome(
log: PatchloomLog | undefined,
result: { exitCode: number; stdout: string; stderr: string }
@@ -190,6 +359,214 @@ interface PatchloomCommandResult {
readonly stderr: string;
}
+function activeSelectionSeed(vscode: typeof VSCode): string | undefined {
+ const editor = vscode.window.activeTextEditor;
+ if (!editor || editor.selection.isEmpty) {
+ return undefined;
+ }
+ const seed = seedFromSelection(editor.document.getText(editor.selection));
+ return seed.length > 0 ? seed : undefined;
+}
+
+async function promptScratchText(
+ vscode: typeof VSCode,
+ workspaceRoot: string,
+ prompt: string,
+ required: boolean
+): Promise {
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ const directory = path.join(workspaceRoot, ".patchloom", `qa-input-${nonce}`);
+ const filePath = path.join(directory, "content.txt");
+ await fs.mkdir(directory, { recursive: true });
+ await fs.writeFile(filePath, "", "utf8");
+ try {
+ const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath));
+ await vscode.window.showTextDocument(document, { preview: false });
+ const choice = await vscode.window.showInformationMessage(
+ `${prompt} Edit the scratch file (newlines are kept), then continue.`,
+ "Use this text"
+ );
+ if (choice !== "Use this text") {
+ return undefined;
+ }
+ if (document.isDirty && !(await document.save())) {
+ return undefined;
+ }
+ const text = contentFromScratch(document.getText());
+ if (required && text.length === 0) {
+ await vscode.window.showWarningMessage("Content is required.");
+ return undefined;
+ }
+ return text;
+ } finally {
+ try {
+ await fs.rm(directory, { recursive: true, force: true });
+ } catch {
+ // The confirmed text is already captured. A locked scratch file must not drop it.
+ }
+ }
+}
+
+async function openSearchHit(
+ vscode: typeof VSCode,
+ workspaceRoot: string,
+ hit: SearchHitLocation
+): Promise {
+ const absolutePath = path.resolve(workspaceRoot, hit.path);
+ if (!isRealPathInsideWorkspace(workspaceRoot, absolutePath)) {
+ await vscode.window.showWarningMessage(`Refusing to open ${hit.path}: it is outside the workspace.`);
+ return;
+ }
+ const document = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath));
+ const line = Math.max(0, hit.line - 1);
+ await vscode.window.showTextDocument(document, {
+ preview: false,
+ selection: new vscode.Range(line, 0, line, 0)
+ });
+}
+
+async function pickSearchHit(
+ vscode: typeof VSCode,
+ workspaceRoot: string,
+ hits: readonly SearchHitLocation[],
+ placeHolder: string
+): Promise {
+ if (hits.length === 0) {
+ return;
+ }
+ const selection = await vscode.window.showQuickPick(
+ hits.map((hit, index) => ({
+ label: hit.path,
+ description: `line ${hit.line}`,
+ hit,
+ index
+ })),
+ { placeHolder }
+ );
+ if (!selection) {
+ return;
+ }
+ await openSearchHit(vscode, workspaceRoot, selection.hit);
+}
+
+async function stageClipboardPatch(workspaceRoot: string, text: string): Promise {
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ const directory = path.join(workspaceRoot, ".patchloom", `qa-patch-${nonce}`);
+ await fs.mkdir(directory, { recursive: true });
+ const patchPath = path.join(directory, "clipboard.patch");
+ try {
+ await fs.writeFile(patchPath, text, "utf8");
+ } catch (error) {
+ await fs.rm(directory, { recursive: true, force: true });
+ throw error;
+ }
+ return {
+ patchPath,
+ cleanup: async () => {
+ await fs.rm(directory, { recursive: true, force: true });
+ }
+ };
+}
+
+async function choosePatchSource(
+ vscode: typeof VSCode,
+ folder: VSCode.WorkspaceFolder
+): Promise {
+ const source = await vscode.window.showQuickPick(
+ [
+ { label: "File", description: "Choose a patch file", source: "file" as const },
+ { label: "Clipboard", description: "Use the patch text on the clipboard", source: "clipboard" as const }
+ ],
+ { placeHolder: "Where is the patch?" }
+ );
+ if (!source) {
+ return undefined;
+ }
+ if (source.source === "clipboard") {
+ const text = await vscode.env.clipboard.readText();
+ const empty = emptyClipboardPatchMessage(text);
+ if (empty) {
+ await vscode.window.showErrorMessage(empty);
+ return undefined;
+ }
+ return stageClipboardPatch(folder.uri.fsPath, text);
+ }
+
+ const patchUri = await vscode.window.showOpenDialog({
+ canSelectMany: false,
+ defaultUri: folder.uri,
+ filters: { "Patch files": ["patch", "diff"], "All files": ["*"] },
+ openLabel: "Select Patch"
+ });
+ if (!patchUri || patchUri.length === 0) {
+ return undefined;
+ }
+ return stageExternalPatchInWorkspace(folder.uri.fsPath, patchUri[0].fsPath);
+}
+
+async function previewThenApplyPatch(
+ vscode: typeof VSCode,
+ binaryPath: string,
+ workspaceRoot: string,
+ action: PlannedQuickAction,
+ noun: "apply" | "merge"
+): Promise {
+ const dry = await executePatchloom(binaryPath, { ...action, apply: false }, workspaceRoot);
+ const log = getPatchloomLog();
+ const followUp = dryRunFollowUp(noun, dry);
+ if (noun === "merge" && followUp !== "apply") {
+ presentPatchMergeOutcome(log, dry);
+ } else {
+ presentCliResultInOutput(log, dry);
+ }
+ if (followUp === "conflicts") {
+ await vscode.window.showWarningMessage(
+ "Patch merge found unresolved conflicts. Nothing was written. Run Merge patch again and choose Allow conflict markers to write them."
+ );
+ return;
+ }
+ if (followUp === "stop") {
+ const label = noun === "merge" ? "Patch merge preview failed" : "Patch apply preview failed";
+ await vscode.window.showErrorMessage(`${label}: ${formatCliOutput(dry)}`);
+ return;
+ }
+
+ const choice = await vscode.window.showInformationMessage(
+ "Patch preview finished. Nothing has been written. Apply it now?",
+ "Apply"
+ );
+ if (choice !== "Apply") {
+ return;
+ }
+
+ const result = await executePatchloom(binaryPath, { ...action, apply: true }, workspaceRoot);
+ if (noun === "merge") {
+ const outcome = presentPatchMergeOutcome(log, result);
+ if (outcome === "conflicts") {
+ const message = patchCommandMadeNoChanges(result.stdout)
+ ? "Patch merge found unresolved conflicts. Nothing was written."
+ : "Patch merge completed with unresolved conflicts. Check the output for details.";
+ await vscode.window.showWarningMessage(message);
+ } else if (outcome === "error") {
+ await vscode.window.showErrorMessage(`Patch merge failed: ${formatCliOutput(result)}`);
+ } else if (patchCommandMadeNoChanges(result.stdout)) {
+ await vscode.window.showWarningMessage("Patch merge made no changes.");
+ } else {
+ await vscode.window.showInformationMessage("Patch merged successfully.");
+ }
+ return;
+ }
+
+ presentCliResultInOutput(log, result);
+ if (result.exitCode !== 0) {
+ await vscode.window.showErrorMessage(`Patch apply failed: ${formatCliOutput(result)}`);
+ } else if (patchCommandMadeNoChanges(result.stdout)) {
+ await vscode.window.showWarningMessage("Patch made no changes.");
+ } else {
+ await vscode.window.showInformationMessage("Patch applied successfully.");
+ }
+}
+
export async function runQuickAction(): Promise {
const vscode = await import("vscode");
const binaryPath = await ensurePatchloomReadyOrNotify("Upgrade Patchloom before running quick actions.");
@@ -211,16 +588,19 @@ export async function runQuickAction(): Promise {
const from = await vscode.window.showInputBox({
prompt: "Text to find",
placeHolder: "old_name",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Search text is required."
});
if (from === undefined) {
return;
}
- const to = await vscode.window.showInputBox({
- prompt: "Replacement text",
- placeHolder: "new_name"
- });
+ const to = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Replacement text",
+ false
+ );
if (to === undefined) {
return;
}
@@ -241,16 +621,19 @@ export async function runQuickAction(): Promise {
const pattern = await vscode.window.showInputBox({
prompt: "Text to match (anchor for the insert)",
placeHolder: "existing_line_or_token",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Match text is required."
});
if (pattern === undefined) {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Text to insert after each match",
- placeHolder: "new_line_or_token"
- });
+ const content = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Text to insert after each match",
+ false
+ );
if (content === undefined) {
return;
}
@@ -275,16 +658,19 @@ export async function runQuickAction(): Promise {
const pattern = await vscode.window.showInputBox({
prompt: "Text to match (anchor for the insert)",
placeHolder: "existing_line_or_token",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Match text is required."
});
if (pattern === undefined) {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Text to insert before each match",
- placeHolder: "new_line_or_token"
- });
+ const content = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Text to insert before each match",
+ false
+ );
if (content === undefined) {
return;
}
@@ -332,16 +718,19 @@ export async function runQuickAction(): Promise {
const anchor = await vscode.window.showInputBox({
prompt: "Unique anchor text (exactly one match required by default)",
placeHolder: placementPick.placement === "old" ? "span to replace" : "fn foo() {",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Anchor text is required."
});
if (anchor === undefined) {
return;
}
- const fragment = await vscode.window.showInputBox({
- prompt: "Fragment text (Morph-style // ... existing code ... markers are stripped)",
- placeHolder: " let x = 1;"
- });
+ const fragment = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Fragment text (Morph-style // ... existing code ... markers are stripped)",
+ false
+ );
if (fragment === undefined) {
return;
}
@@ -415,12 +804,7 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Value",
- placeHolder: "true, 42, hello, or {\"key\":\"value\"}",
- value: "",
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Value", true);
if (value === undefined) {
return;
}
@@ -447,12 +831,7 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Value",
- placeHolder: "true, 42, hello, or {\"key\":\"value\"}",
- value: "",
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Value", true);
if (value === undefined) {
return;
}
@@ -481,6 +860,7 @@ export async function runQuickAction(): Promise {
const pattern = await vscode.window.showInputBox({
prompt: "Search pattern",
placeHolder: "TODO|FIXME",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Pattern is required."
});
if (pattern === undefined) {
@@ -506,6 +886,12 @@ export async function runQuickAction(): Promise {
await vscode.window.showErrorMessage(`Patchloom search failed: ${formatCliOutput(result)}`);
} else {
await vscode.window.showInformationMessage("Search results displayed in the Patchloom output channel.");
+ await pickSearchHit(
+ vscode,
+ folder.uri.fsPath,
+ parseSearchHitLines(result.stdout),
+ "Open a search hit"
+ );
}
}
},
@@ -526,6 +912,7 @@ export async function runQuickAction(): Promise {
const pattern = await vscode.window.showInputBox({
prompt: "Pattern that listed files must not contain",
placeHolder: "TODO|FIXME",
+ value: activeSelectionSeed(vscode),
validateInput: (value) => value.length > 0 ? undefined : "Pattern is required."
});
if (pattern === undefined) {
@@ -555,6 +942,12 @@ export async function runQuickAction(): Promise {
await vscode.window.showInformationMessage(
"Files without matches are listed in the Patchloom output channel."
);
+ await pickSearchHit(
+ vscode,
+ folder.uri.fsPath,
+ parseFilesWithoutMatchLines(result.stdout),
+ "Open a file without a match"
+ );
}
}
},
@@ -590,10 +983,12 @@ export async function runQuickAction(): Promise {
}
// Empty content is allowed; CLI requires --content or --stdin and --apply to write.
- const content = await vscode.window.showInputBox({
- prompt: "Initial file content (leave empty for an empty file)",
- placeHolder: "// new file"
- });
+ const content = await promptScratchText(
+ vscode,
+ folder.uri.fsPath,
+ "Initial file content (leave empty for an empty file)",
+ false
+ );
if (content === undefined) {
return;
}
@@ -623,11 +1018,12 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Content to append",
- placeHolder: "new line of text",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Content to append",
+ true
+ );
if (content === undefined) {
return;
}
@@ -645,11 +1041,12 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Content to prepend",
- placeHolder: "// header comment",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Content to prepend",
+ true
+ );
if (content === undefined) {
return;
}
@@ -834,11 +1231,12 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Partial JSON object to merge",
- placeHolder: '{"debug": true, "logLevel": "verbose"}',
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Partial JSON object to merge",
+ true
+ );
if (value === undefined) {
return;
}
@@ -869,11 +1267,7 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Value to append",
- placeHolder: '"new-item"',
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Value to append", true);
if (value === undefined) {
return;
}
@@ -900,11 +1294,7 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Value to prepend",
- placeHolder: '"new-item"',
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Value to prepend", true);
if (value === undefined) {
return;
}
@@ -931,11 +1321,12 @@ export async function runQuickAction(): Promise {
return;
}
- const value = await vscode.window.showInputBox({
- prompt: "Default value (set only if missing)",
- placeHolder: "8080",
- validateInput: (input) => input.length > 0 ? undefined : "Value is required."
- });
+ const value = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Default value (set only if missing)",
+ true
+ );
if (value === undefined) {
return;
}
@@ -1000,11 +1391,7 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Content to insert",
- placeHolder: "New paragraph text",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Content to insert", true);
if (content === undefined) {
return;
}
@@ -1038,11 +1425,12 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Sibling content to insert after the section body",
- placeHolder: "## FAQ\n\nCommon questions.",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Sibling content to insert after the section body",
+ true
+ );
if (content === undefined) {
return;
}
@@ -1076,11 +1464,7 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "Content to insert",
- placeHolder: "New section text",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "Content to insert", true);
if (content === undefined) {
return;
}
@@ -1114,11 +1498,12 @@ export async function runQuickAction(): Promise {
return;
}
- const row = await vscode.window.showInputBox({
- prompt: "Table row to append (pipe-delimited)",
- placeHolder: "| /users | List users | GET |",
- validateInput: (value) => value.length > 0 ? undefined : "Row is required."
- });
+ const row = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Table row to append (pipe-delimited)",
+ true
+ );
if (row === undefined) {
return;
}
@@ -1152,11 +1537,12 @@ export async function runQuickAction(): Promise {
return;
}
- const bullet = await vscode.window.showInputBox({
- prompt: "Bullet text (without leading dash)",
- placeHolder: "Run make check before committing",
- validateInput: (value) => value.length > 0 ? undefined : "Bullet text is required."
- });
+ const bullet = await promptScratchText(
+ vscode,
+ target.workspaceFolder.uri.fsPath,
+ "Bullet text (without leading dash)",
+ true
+ );
if (bullet === undefined) {
return;
}
@@ -1190,11 +1576,7 @@ export async function runQuickAction(): Promise {
return;
}
- const content = await vscode.window.showInputBox({
- prompt: "New section content",
- placeHolder: "- New feature added",
- validateInput: (value) => value.length > 0 ? undefined : "Content is required."
- });
+ const content = await promptScratchText(vscode, target.workspaceFolder.uri.fsPath, "New section content", true);
if (content === undefined) {
return;
}
@@ -1216,31 +1598,13 @@ export async function runQuickAction(): Promise {
return;
}
- const patchUri = await vscode.window.showOpenDialog({
- canSelectMany: false,
- defaultUri: folder.uri,
- filters: { "Patch files": ["patch", "diff"], "All files": ["*"] },
- openLabel: "Select Patch"
- });
- if (!patchUri || patchUri.length === 0) {
+ const staged = await choosePatchSource(vscode, folder);
+ if (!staged) {
return;
}
-
- const action = buildPatchApplyQuickAction(patchUri[0].fsPath);
- const staged = await stageExternalPatchInWorkspace(folder.uri.fsPath, patchUri[0].fsPath);
try {
- const planned = retargetQuickAction(action, staged.patchPath);
- const result = await executePatchloom(binaryPath, planned, folder.uri.fsPath);
- const log = getPatchloomLog();
- presentCliResultInOutput(log, result);
-
- if (result.exitCode !== 0) {
- await vscode.window.showErrorMessage(`Patch apply failed: ${formatCliOutput(result)}`);
- } else if (patchCommandMadeNoChanges(result.stdout)) {
- await vscode.window.showWarningMessage("Patch made no changes.");
- } else {
- await vscode.window.showInformationMessage("Patch applied successfully.");
- }
+ const action = buildPatchApplyQuickAction(staged.patchPath);
+ await previewThenApplyPatch(vscode, binaryPath, folder.uri.fsPath, action, "apply");
} finally {
await staged.cleanup();
}
@@ -1260,13 +1624,8 @@ export async function runQuickAction(): Promise {
return;
}
- const patchUri = await vscode.window.showOpenDialog({
- canSelectMany: false,
- defaultUri: folder.uri,
- filters: { "Patch files": ["patch", "diff"], "All files": ["*"] },
- openLabel: "Select Patch"
- });
- if (!patchUri || patchUri.length === 0) {
+ const staged = await choosePatchSource(vscode, folder);
+ if (!staged) {
return;
}
@@ -1275,26 +1634,13 @@ export async function runQuickAction(): Promise {
{ label: "Allow conflict markers", description: "Write <<<<<<< / >>>>>>> markers into files", allow: true }
], { placeHolder: "How should unresolved conflicts be handled?" });
if (!allowConflicts) {
+ await staged.cleanup();
return;
}
- const action = buildPatchMergeQuickAction(patchUri[0].fsPath, allowConflicts.allow);
- const staged = await stageExternalPatchInWorkspace(folder.uri.fsPath, patchUri[0].fsPath);
try {
- const planned = retargetQuickAction(action, staged.patchPath);
- const result = await executePatchloom(binaryPath, planned, folder.uri.fsPath);
- const log = getPatchloomLog();
- const outcome = presentPatchMergeOutcome(log, result);
-
- if (outcome === "conflicts") {
- await vscode.window.showWarningMessage("Patch merge completed with unresolved conflicts. Check the output for details.");
- } else if (outcome === "error") {
- await vscode.window.showErrorMessage(`Patch merge failed: ${formatCliOutput(result)}`);
- } else if (patchCommandMadeNoChanges(result.stdout)) {
- await vscode.window.showWarningMessage("Patch merge made no changes.");
- } else {
- await vscode.window.showInformationMessage("Patch merged successfully.");
- }
+ const action = buildPatchMergeQuickAction(staged.patchPath, allowConflicts.allow);
+ await previewThenApplyPatch(vscode, binaryPath, folder.uri.fsPath, action, "merge");
} finally {
await staged.cleanup();
}
@@ -1314,8 +1660,45 @@ export async function runQuickAction(): Promise {
return;
}
+ const targetPick = await vscode.window.showQuickPick(
+ [
+ { label: "Latest backup", description: "Default", picked: true, selection: { kind: "latest" as const } },
+ { label: "Backup session", description: "undo --session ID", selection: { kind: "session" as const } },
+ { label: "One path", description: "undo --path REL", selection: { kind: "path" as const } }
+ ],
+ { placeHolder: "Which backup should undo restore?" }
+ );
+ if (!targetPick) {
+ return;
+ }
+
+ let undoSelection: UndoSelection = { kind: "latest" };
+ if (targetPick.selection.kind === "session") {
+ const sessionId = await vscode.window.showInputBox({
+ prompt: "Backup session id",
+ placeHolder: "session id from patchloom undo --list",
+ validateInput: (value) => value.trim().length > 0 ? undefined : "Session id is required."
+ });
+ if (sessionId === undefined) {
+ return;
+ }
+ undoSelection = { kind: "session", sessionId: sessionId.trim() };
+ } else if (targetPick.selection.kind === "path") {
+ const relativePath = await vscode.window.showInputBox({
+ prompt: "Workspace-relative path to restore",
+ placeHolder: "src/app.ts",
+ validateInput: (value) => value.trim().length > 0 ? undefined : "Path is required."
+ });
+ if (relativePath === undefined) {
+ return;
+ }
+ undoSelection = { kind: "path", relativePath: relativePath.trim() };
+ }
+
const confirm = await vscode.window.showWarningMessage(
- "Undo the last patchloom edit? This restores files from backup.",
+ undoSelection.kind === "latest"
+ ? "Undo the last patchloom edit? This restores files from backup."
+ : `Undo ${undoSelection.kind === "session" ? `session ${undoSelection.sessionId}` : undoSelection.relativePath}? This restores files from backup.`,
{ modal: true },
"Undo"
);
@@ -1323,7 +1706,7 @@ export async function runQuickAction(): Promise {
return;
}
- const action = buildUndoQuickAction(folder.uri.fsPath);
+ const action = buildUndoQuickAction(folder.uri.fsPath, undoSelection);
const result = await executePatchloom(binaryPath, action, folder.uri.fsPath);
if (result.exitCode !== 0) {
@@ -1730,12 +2113,20 @@ export function buildPatchMergeQuickAction(patchPath: string, allowConflicts: bo
};
}
-export function buildUndoQuickAction(workspacePath: string): PlannedQuickAction {
+export function buildUndoQuickAction(
+ workspacePath: string,
+ selection: UndoSelection = { kind: "latest" }
+): PlannedQuickAction {
+ const title = selection.kind === "session"
+ ? `Undo session ${selection.sessionId}`
+ : selection.kind === "path"
+ ? `Undo ${selection.relativePath}`
+ : "Undo last patchloom change";
return {
- title: "Undo last patchloom change",
+ title,
targetPath: workspacePath,
targetArgIndices: [],
- args: ["undo"],
+ args: buildUndoArgs(selection),
apply: true
};
}
@@ -1785,11 +2176,22 @@ export function isAllowedPreviewMiss(action: PlannedQuickAction, exitCode: numbe
return true;
}
-export function previewMissMessage(action: PlannedQuickAction, relativePath: string): string {
+export type PreviewMissReason = "pattern-not-found" | "unchanged";
+
+export function previewMissMessage(
+ action: PlannedQuickAction,
+ relativePath: string,
+ reason: PreviewMissReason = "pattern-not-found"
+): string {
if (action.args[0] === "doc" && action.args[1] === "delete-where") {
return `No items matched that predicate in ${relativePath}. Check the array path (for example items) versus the key=value predicate (for example name=stale).`;
}
- return `No changes to preview for ${relativePath}.`;
+ const patternSearch = action.args[0] === "replace"
+ || action.args.some((arg) => arg.startsWith("--insert-before=") || arg.startsWith("--insert-after="));
+ if (reason === "pattern-not-found" && patternSearch) {
+ return `Pattern not found in ${relativePath}.`;
+ }
+ return `Command made no edit to ${relativePath}.`;
}
export function retargetQuickAction(action: PlannedQuickAction, nextTargetPath: string): PlannedQuickAction {
@@ -1818,7 +2220,20 @@ async function previewAndMaybeApply(
const originalContent = await fs.readFile(target.absolutePath, "utf8");
let preview: VSCode.TextDocument | undefined;
try {
- preview = await buildPreviewDocument(binaryPath, action, originalContent, originalDocument.languageId);
+ const built = await buildPreviewDocument(
+ binaryPath,
+ action,
+ originalContent,
+ originalDocument.languageId,
+ target.workspaceFolder.uri.fsPath
+ );
+ if (built.kind === "miss") {
+ await vscode.window.showInformationMessage(
+ previewMissMessage(action, target.relativePath, built.reason)
+ );
+ return;
+ }
+ preview = built.document;
} catch (error) {
getPatchloomLog()?.show();
await vscode.window.showErrorMessage(
@@ -1826,10 +2241,6 @@ async function previewAndMaybeApply(
);
return;
}
- if (!preview) {
- await vscode.window.showInformationMessage(previewMissMessage(action, target.relativePath));
- return;
- }
await vscode.commands.executeCommand(
"vscode.diff",
@@ -1870,32 +2281,58 @@ async function buildPreviewDocument(
binaryPath: string,
action: PlannedQuickAction,
originalContent: string,
- languageId: string
-): Promise {
+ languageId: string,
+ workspaceRoot: string
+): Promise<
+ | { readonly kind: "document"; readonly document: VSCode.TextDocument }
+ | { readonly kind: "miss"; readonly reason: PreviewMissReason }
+> {
const vscode = await import("vscode");
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-preview-"));
- const tempPath = path.join(tempDir, path.basename(action.targetPath));
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ const location = previewWorkspaceFile(workspaceRoot, action.targetPath, nonce);
+ const cwd = previewCwd(workspaceRoot);
+ const backupsBefore = await backupSessionNames(workspaceRoot);
try {
- await fs.writeFile(tempPath, originalContent, "utf8");
- const previewAction = retargetQuickAction(action, tempPath);
- const result = await executePatchloom(binaryPath, { args: previewAction.args, apply: true }, tempDir);
+ await fs.mkdir(location.directory, { recursive: true });
+ await fs.writeFile(location.filePath, originalContent, "utf8");
+ const previewAction = retargetQuickAction(action, location.filePath);
+ const result = await executePatchloom(binaryPath, { args: previewAction.args, apply: true }, cwd);
if (result.exitCode !== 0 && !isAllowedPreviewMiss(action, result.exitCode)) {
presentCliResultInOutput(getPatchloomLog(), result);
throw new Error(formatQuickActionCliOutput(result));
}
- const previewContent = await fs.readFile(tempPath, "utf8");
+ const previewContent = await fs.readFile(location.filePath, "utf8");
+ if (result.exitCode === 3 && isAllowedPreviewMiss(action, result.exitCode)) {
+ return { kind: "miss", reason: "pattern-not-found" };
+ }
if (previewContent === originalContent) {
- return undefined;
+ return { kind: "miss", reason: "unchanged" };
}
- return vscode.workspace.openTextDocument({
- language: languageId,
- content: previewContent
- });
+ const id = rememberPreviewBody(previewContent);
+ const uri = vscode.Uri.from({ scheme: PATCHLOOM_PREVIEW_SCHEME, path: `/${id}` });
+ const document = await vscode.workspace.openTextDocument(uri);
+ if (languageId.length > 0) {
+ try {
+ await vscode.languages.setTextDocumentLanguage(document, languageId);
+ } catch {
+ // The preview body is already stored. A missing language id should not drop it.
+ }
+ }
+ return { kind: "document", document };
} finally {
- await fs.rm(tempDir, { recursive: true, force: true });
+ try {
+ await fs.rm(location.directory, { recursive: true, force: true });
+ } catch {
+ // The preview body is already stored.
+ }
+ try {
+ await removePreviewBackupSessions(workspaceRoot, backupsBefore, nonce);
+ } catch {
+ // Leave sessions we could not prove belong to this preview.
+ }
}
}
@@ -2097,7 +2534,7 @@ async function executePatchloom(
logCliCommand(log, runtime.trace, binaryPath, finalArgs, cwd);
try {
- const { stdout, stderr } = await execFileAsync(binaryPath, finalArgs, {
+ const { stdout, stderr } = await execPatchloomFile(binaryPath, finalArgs, {
cwd,
env,
timeout: PATCHLOOM_CLI_TIMEOUT_MS,
diff --git a/src/commands/setupSummary.ts b/src/commands/setupSummary.ts
new file mode 100644
index 0000000..f2f9374
--- /dev/null
+++ b/src/commands/setupSummary.ts
@@ -0,0 +1,22 @@
+export type SetupStepState = "present" | "ran" | "skipped";
+
+export function formatSetupSummary(
+ workspaceName: string | undefined,
+ agents: SetupStepState,
+ mcp: SetupStepState,
+ environmentNote?: string
+): string {
+ const where = workspaceName ? ` for ${workspaceName}` : "";
+ const agentsText = agents === "present"
+ ? "AGENTS.md was already present."
+ : agents === "ran"
+ ? "Initialize Project ran."
+ : "AGENTS.md was skipped.";
+ const mcpText = mcp === "present"
+ ? "MCP config was already present."
+ : mcp === "ran"
+ ? "Configure MCP ran."
+ : "MCP config was skipped.";
+ const note = environmentNote ? ` ${environmentNote}` : "";
+ return `Patchloom workspace setup${where} ${agentsText} ${mcpText}${note}`;
+}
diff --git a/src/commands/setupWorkspace.ts b/src/commands/setupWorkspace.ts
index e002282..5ed4962 100644
--- a/src/commands/setupWorkspace.ts
+++ b/src/commands/setupWorkspace.ts
@@ -1,6 +1,9 @@
import * as vscode from "vscode";
import { ensurePatchloomReadyOrNotify, PATCHLOOM_DOCS_URL, PATCHLOOM_RELEASES_URL } from "../binary/patchloom.js";
-import { describeWorkspaceEnvironment, inspectWorkspaceReadiness } from "../workspace/readiness.js";
+import { formatSetupSummary, type SetupStepState } from "./setupSummary.js";
+import { activeWorkspaceFolder, describeWorkspaceEnvironment, inspectWorkspaceReadiness } from "../workspace/readiness.js";
+
+export { formatSetupSummary, type SetupStepState } from "./setupSummary.js";
export async function setupWorkspace(): Promise {
const binaryPath = await ensurePatchloomReadyOrNotify("Patchloom needs a working binary before workspace setup can continue.");
@@ -8,46 +11,56 @@ export async function setupWorkspace(): Promise {
return;
}
- const readiness = await inspectWorkspaceReadiness({
+ const folder = await activeWorkspaceFolder({
promptIfMany: true,
placeHolder: "Select the workspace folder to inspect for Patchloom setup"
});
+ if (!folder) {
+ await vscode.window.showWarningMessage("Open a workspace folder before running Patchloom: Setup Workspace.");
+ return;
+ }
+ const readiness = await inspectWorkspaceReadiness({ folder });
if (!readiness.hasWorkspace) {
await vscode.window.showWarningMessage("Open a workspace folder before running Patchloom: Setup Workspace.");
return;
}
- if (readiness.hasAgentsFile === false) {
+ const agentsMissing = readiness.hasAgentsFile === false;
+ let agents: SetupStepState = agentsMissing ? "skipped" : "present";
+ if (agentsMissing) {
const choice = await vscode.window.showInformationMessage(
"Step 1/2: AGENTS.md is missing for this workspace. Create it now from patchloom agent-rules?",
"Initialize Project",
"Skip"
);
if (choice === "Initialize Project") {
- await vscode.commands.executeCommand("patchloom.initializeProject");
+ const wrote = await vscode.commands.executeCommand("patchloom.initializeProject", folder);
+ if (wrote === true) {
+ agents = "ran";
+ }
}
}
+ let mcp: SetupStepState = readiness.hasMcpConfig === false ? "skipped" : "present";
if (readiness.hasMcpConfig === false) {
const choice = await vscode.window.showInformationMessage(
- `${readiness.hasAgentsFile === false ? "Step 2/2: " : ""}Patchloom MCP config is missing. Configure supported editors now?`,
+ `${agentsMissing ? "Step 2/2: " : ""}Patchloom MCP config is missing. Configure supported editors now?`,
"Configure MCP",
"Skip"
);
if (choice === "Configure MCP") {
- await vscode.commands.executeCommand("patchloom.configureMcp");
+ const wrote = await vscode.commands.executeCommand("patchloom.configureMcp", folder);
+ if (wrote === true) {
+ mcp = "ran";
+ }
}
}
- if (readiness.hasAgentsFile !== false && readiness.hasMcpConfig !== false) {
- const environment = describeWorkspaceEnvironment(vscode.env.remoteName);
- const environmentSuffix = environment.note ? ` ${environment.note}` : "";
- const workspaceTarget = readiness.workspaceName ? ` for ${readiness.workspaceName}` : "";
- await vscode.window.showInformationMessage(
- `Patchloom workspace setup looks good${workspaceTarget}. Binary, AGENTS.md, and MCP config are already in place.${environmentSuffix}`
- );
- }
+ const environment = describeWorkspaceEnvironment(vscode.env.remoteName);
+ await vscode.window.showInformationMessage(
+ formatSetupSummary(readiness.workspaceName, agents, mcp, environment.note)
+ );
}
export async function openPatchloomSettings(): Promise {
diff --git a/src/commands/showStatus.ts b/src/commands/showStatus.ts
index a827bea..3308301 100644
--- a/src/commands/showStatus.ts
+++ b/src/commands/showStatus.ts
@@ -1,8 +1,9 @@
import { patchloomNeedsUpgrade, resolvePatchloomStatus } from "../binary/patchloom.js";
-import { buildStatusDetails, preferredStatusAction } from "../status/details.js";
+import { getPatchloomLog } from "../logging/outputChannel.js";
+import { buildStatusDetails, preferredStatusAction, statusNotificationSummary } from "../status/details.js";
import { inspectWorkspaceReadiness } from "../workspace/readiness.js";
-export { buildStatusDetails, preferredStatusAction, type SetupAction } from "../status/details.js";
+export { buildStatusDetails, preferredStatusAction, statusNotificationSummary, type SetupAction } from "../status/details.js";
export async function showStatus(): Promise {
const vscode = await import("vscode");
@@ -12,18 +13,20 @@ export async function showStatus(): Promise {
placeHolder: "Select the workspace folder to inspect for Patchloom status"
});
const details = buildStatusDetails(status, workspaceReadiness);
- const action = preferredStatusAction(status, workspaceReadiness);
-
- if (!action) {
- await vscode.window.showInformationMessage(details);
- return;
- }
+ const log = getPatchloomLog();
+ log?.log(details);
+ log?.show();
- const messageFn = (!status.ready || patchloomNeedsUpgrade(status))
+ const needsUpgrade = patchloomNeedsUpgrade(status);
+ const summary = statusNotificationSummary(status.ready, needsUpgrade);
+ const action = preferredStatusAction(status, workspaceReadiness);
+ const messageFn = (!status.ready || needsUpgrade)
? vscode.window.showWarningMessage
: vscode.window.showInformationMessage;
- const choice = await messageFn(details, action.title);
- if (choice === action.title) {
+ const choice = action
+ ? await messageFn(summary, action.title)
+ : await messageFn(summary);
+ if (action && choice === action.title) {
await vscode.commands.executeCommand(action.command);
}
}
diff --git a/src/commands/verifyMcp.ts b/src/commands/verifyMcp.ts
index f039d1f..bf08206 100644
--- a/src/commands/verifyMcp.ts
+++ b/src/commands/verifyMcp.ts
@@ -1,11 +1,41 @@
import { spawn } from "node:child_process";
+import { patchloomLaunch } from "../binary/launch.js";
import { ensurePatchloomReadyOrNotify } from "../binary/patchloom.js";
import { getPatchloomLog, getPatchloomRuntimeConfig, logCliCommand } from "../logging/outputChannel.js";
+import { inspectMcpTargets, readMcpConfigText, readPatchloomMcpCommand } from "../mcp/config.js";
import { mergePatchloomEnv } from "../util.js";
+import { activeWorkspaceFolder } from "../workspace/readiness.js";
+
+export function selectVerifyMcpCommand(configuredCommand: string | undefined, fallbackBinary: string): string {
+ const trimmed = configuredCommand?.trim() ?? "";
+ return trimmed.length > 0 ? trimmed : fallbackBinary;
+}
+
+const VERIFY_MCP_KIND_ORDER = ["portable-workspace", "cursor-workspace", "vscode-workspace"] as const;
+
+/** Portable workspace wins over the older VS Code file when both name a command. */
+export function preferredVerifyMcpCommand(
+ entries: readonly { readonly kind: string; readonly command?: string }[]
+): string | undefined {
+ for (const kind of VERIFY_MCP_KIND_ORDER) {
+ const command = entries.find((entry) => entry.kind === kind)?.command?.trim();
+ if (command) {
+ return command;
+ }
+ }
+ return undefined;
+}
+
+export function verifyMcpWorkingDirectory(workspacePath: string | undefined): string | undefined {
+ const trimmed = workspacePath?.trim() ?? "";
+ return trimmed.length > 0 ? trimmed : undefined;
+}
export interface VerifyMcpInputs {
readonly binaryPath: string;
- readonly spawnProcess?: typeof spawnMcpServer;
+ readonly configuredCommand?: string;
+ readonly cwd?: string;
+ readonly spawnProcess?: (command: string, cwd?: string) => Promise;
}
export interface VerifyMcpResult {
@@ -22,6 +52,27 @@ export async function verifyMcp(): Promise {
return;
}
+ const folderCount = vscode.workspace.workspaceFolders?.length ?? 0;
+ const folder = await activeWorkspaceFolder({
+ promptIfMany: true,
+ placeHolder: "Select the workspace folder whose MCP server to verify"
+ });
+ if (!folder && folderCount > 1) {
+ return;
+ }
+ const targets = await inspectMcpTargets({
+ workspaceFolderPath: folder?.uri.fsPath,
+ includeUserTarget: false
+ });
+ const entries = [];
+ for (const target of targets) {
+ entries.push({
+ kind: target.kind,
+ command: readPatchloomMcpCommand(target.kind, await readMcpConfigText(target.filePath))
+ });
+ }
+ const configuredCommand = preferredVerifyMcpCommand(entries);
+
const result = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
@@ -30,7 +81,11 @@ export async function verifyMcp(): Promise {
},
async (progress) => {
progress.report({ message: "Verifying MCP server..." });
- return verifyMcpServer({ binaryPath });
+ return verifyMcpServer({
+ binaryPath,
+ configuredCommand,
+ cwd: verifyMcpWorkingDirectory(folder?.uri.fsPath)
+ });
}
);
@@ -47,8 +102,9 @@ export async function verifyMcp(): Promise {
export async function verifyMcpServer(inputs: VerifyMcpInputs): Promise {
const spawnFn = inputs.spawnProcess ?? spawnMcpServer;
+ const command = selectVerifyMcpCommand(inputs.configuredCommand, inputs.binaryPath);
try {
- return await spawnFn(inputs.binaryPath);
+ return await spawnFn(command, inputs.cwd);
} catch (error) {
return {
ok: false,
@@ -57,16 +113,20 @@ export async function verifyMcpServer(inputs: VerifyMcpInputs): Promise {
+async function spawnMcpServer(binaryPath: string, cwd?: string): Promise {
const runtime = await getPatchloomRuntimeConfig();
const env = mergePatchloomEnv(process.env, runtime.extraEnv);
- logCliCommand(getPatchloomLog(), runtime.trace, binaryPath, ["mcp-server"], process.cwd());
+ const launch = patchloomLaunch(binaryPath, ["mcp-server"]);
+ logCliCommand(getPatchloomLog(), runtime.trace, launch.file, launch.args, cwd ?? process.cwd());
return new Promise((resolve) => {
- const child = spawn(binaryPath, ["mcp-server"], {
+ const child = spawn(launch.file, launch.args, {
+ cwd,
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
windowsHide: true,
+ shell: launch.shell,
+ windowsVerbatimArguments: launch.windowsVerbatimArguments,
env
});
diff --git a/src/extension.ts b/src/extension.ts
index 16079cb..f8774a6 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -12,8 +12,11 @@ import { clearPatchloomStatusInflight } from "./binary/patchloom.js";
import { setManagedInstallRoot } from "./install/managed.js";
import { createPatchloomLog, getPatchloomLog, setPatchloomLog } from "./logging/outputChannel.js";
import { refreshMcpServerBinary, registerMcpServerProviderWithBinary } from "./mcp/register.js";
+import { createPreviewContentProvider, PATCHLOOM_PREVIEW_SCHEME } from "./commands/quickActions.js";
+import { shouldRunBackgroundChecks } from "./status/contextKeys.js";
import { refreshAfterPatchloomInputChange } from "./status/refreshAfterInputChange.js";
import { disposeStatusBar, refreshStatusBar } from "./status/statusBar.js";
+import { isWorkspaceReadinessPath } from "./workspace/readiness.js";
export function activate(context: vscode.ExtensionContext): void {
setManagedInstallRoot(context.globalStorageUri.fsPath);
@@ -31,6 +34,7 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.commands.registerCommand("patchloom.openPatchloomSettings", openPatchloomSettings),
vscode.commands.registerCommand("patchloom.openPatchloomReleases", openPatchloomReleases),
vscode.commands.registerCommand("patchloom.showStatus", showStatus),
+ vscode.commands.registerCommand("patchloom.refreshStatus", () => refreshStatusBar()),
vscode.commands.registerCommand("patchloom.installBinary", installPatchloom),
vscode.commands.registerCommand("patchloom.updateBinary", updatePatchloom),
vscode.commands.registerCommand("patchloom.reinstallBinary", reinstallPatchloom),
@@ -59,11 +63,40 @@ export function activate(context: vscode.ExtensionContext): void {
refreshStatusBar,
refreshMcpServerBinary
);
+ }),
+ vscode.workspace.registerTextDocumentContentProvider(
+ PATCHLOOM_PREVIEW_SCHEME,
+ createPreviewContentProvider()
+ ),
+ vscode.workspace.onDidSaveTextDocument((document) => {
+ if (isWorkspaceReadinessPath(document.uri.fsPath)) {
+ void refreshStatusBar();
+ }
+ }),
+ vscode.window.onDidChangeWindowState((state) => {
+ if (state.focused) {
+ void refreshStatusBar();
+ }
})
);
+ const refreshFromWatcher = (uri: vscode.Uri): void => {
+ if (isWorkspaceReadinessPath(uri.fsPath)) {
+ void refreshStatusBar();
+ }
+ };
+ for (const pattern of ["**/AGENTS.md", "**/.mcp.json", "**/.vscode/mcp.json", "**/.cursor/mcp.json"]) {
+ const readinessWatcher = vscode.workspace.createFileSystemWatcher(pattern);
+ readinessWatcher.onDidCreate(refreshFromWatcher);
+ readinessWatcher.onDidChange(refreshFromWatcher);
+ readinessWatcher.onDidDelete(refreshFromWatcher);
+ context.subscriptions.push(readinessWatcher);
+ }
+
void refreshStatusBar();
- void checkForUpdates();
+ if (shouldRunBackgroundChecks(vscode.workspace.getConfiguration("patchloom").get("enable", true))) {
+ void checkForUpdates();
+ }
void registerMcpServerProviderWithBinary(context);
}
diff --git a/src/logging/outputChannel.ts b/src/logging/outputChannel.ts
index 6b15546..2441713 100644
--- a/src/logging/outputChannel.ts
+++ b/src/logging/outputChannel.ts
@@ -3,6 +3,7 @@ import { resolvePatchloomEnvFromInspect, shouldLogCliCommands, shouldLogCliStrea
export interface PatchloomRuntimeConfig {
readonly extraEnv: Record | undefined;
readonly trace: string;
+ readonly mcpSurface: "full" | "core";
}
export async function getPatchloomRuntimeConfig(): Promise {
@@ -15,7 +16,8 @@ export async function getPatchloomRuntimeConfig(): Promise("trace.server", "off") ?? "off"
+ trace: config.get("trace.server", "off") ?? "off",
+ mcpSurface: config.get("mcpSurface") === "core" ? "core" : "full"
};
}
diff --git a/src/mcp/config.ts b/src/mcp/config.ts
index d364fcf..b8ecd60 100644
--- a/src/mcp/config.ts
+++ b/src/mcp/config.ts
@@ -103,7 +103,7 @@ export async function inspectMcpTargets(inputs: McpInspectionInputs): Promise {
const readFile = inputs.readFile ?? readMcpConfigText;
- const patchloomCommand = configuredBinaryPathFromSetting(inputs.patchloomPathSetting) ?? "patchloom";
+ const resolvedCommand = configuredBinaryPathFromSetting(inputs.patchloomPathSetting) ?? "patchloom";
const includeKinds = inputs.includeKinds ? new Set(inputs.includeKinds) : undefined;
const targets = resolveMcpTargets(inputs.workspaceFolderPath, inputs.homeDir, inputs.includeUserTarget)
.filter((target) => !includeKinds || includeKinds.has(target.kind));
@@ -115,7 +115,11 @@ export async function configureMcpTargets(inputs: McpApplyInputs): Promise 0
? applyPatchloomEntry(content, key, entry)
- : `${JSON.stringify(withPatchloomEntry(target.kind, original, patchloomCommand, mcpSurface), null, 2)}\n`;
+ : `${JSON.stringify(withPatchloomEntry(target.kind, original, mcpCommandForTarget(target.kind, resolvedCommand), mcpSurface), null, 2)}\n`;
await inputs.writeFile(target.filePath, serialized);
results.push({
@@ -197,6 +201,53 @@ export function resolveMcpTargets(
return targets;
}
+/**
+ * Workspace files store `command: "patchloom"` so the config is portable.
+ * Windsurf's user file may keep the resolved binary path.
+ * An absolute path whose basename is `patchloom` is not the portable command.
+ */
+export function mcpCommandForTarget(kind: McpTargetKind, resolvedPath: string | undefined): string {
+ if (kind === "windsurf-user") {
+ const trimmed = resolvedPath?.trim() ?? "";
+ return trimmed.length > 0 ? trimmed : "patchloom";
+ }
+ return "patchloom";
+}
+
+/** Workspace entries are current only when command is exactly `patchloom`. */
+export function patchloomEntryIsCurrent(kind: McpTargetKind, entry: unknown): boolean {
+ if (!isPlainObject(entry)) {
+ return false;
+ }
+ const command = entry.command;
+ if (typeof command !== "string" || command.trim().length === 0) {
+ return false;
+ }
+ if (kind === "windsurf-user") {
+ return true;
+ }
+ return command === "patchloom";
+}
+
+export function readPatchloomMcpCommand(kind: McpTargetKind, content: string | undefined): string | undefined {
+ if (!content || !content.trim()) {
+ return undefined;
+ }
+ let parsed: Record;
+ try {
+ parsed = parseJsonObject(content, "mcp.json");
+ } catch {
+ return undefined;
+ }
+ const key = usesMcpServersKey(kind) ? "mcpServers" : "servers";
+ const root = parsed[key];
+ if (!isPlainObject(root) || !isPlainObject(root.patchloom)) {
+ return undefined;
+ }
+ const command = root.patchloom.command;
+ return typeof command === "string" && command.trim().length > 0 ? command : undefined;
+}
+
export function buildPatchloomMcpEntry(
commandPath: string,
mcpSurface: McpSurface = "full"
@@ -290,7 +341,7 @@ function withPatchloomEntry(
function hasPatchloomEntry(kind: McpTargetKind, config: Record): boolean {
const key = usesMcpServersKey(kind) ? "mcpServers" : "servers";
const root = objectValue(config[key]);
- return typeof root.patchloom === "object" && root.patchloom !== null;
+ return patchloomEntryIsCurrent(kind, root.patchloom);
}
function objectValue(value: unknown): Record {
diff --git a/src/mcp/register.ts b/src/mcp/register.ts
index ea60348..e76223c 100644
--- a/src/mcp/register.ts
+++ b/src/mcp/register.ts
@@ -1,4 +1,5 @@
import type * as VSCode from "vscode";
+import { patchloomLaunch } from "../binary/launch.js";
import { resolvePatchloomStatus } from "../binary/patchloom.js";
import { getPatchloomLog, getPatchloomRuntimeConfig } from "../logging/outputChannel.js";
import { isAllowedPatchloomEnvKey } from "../util.js";
@@ -27,17 +28,27 @@ function patchloomOnlyEnv(extra: Record | undefined): Record
+ env?: Record,
+ surface?: "full" | "core",
+ platform: NodeJS.Platform = process.platform,
+ comSpec?: string
): readonly McpServerBinaryDescriptor[] {
if (!binaryPath) {
return [];
}
+ const filtered = patchloomOnlyEnv(env);
+ if (surface === "core") {
+ filtered.PATCHLOOM_MCP_SURFACE = "core";
+ } else if (surface === "full") {
+ delete filtered.PATCHLOOM_MCP_SURFACE;
+ }
+ const launch = patchloomLaunch(binaryPath, ["mcp-server"], platform, comSpec, false);
return [
{
label: "Patchloom MCP",
- command: binaryPath,
- args: ["mcp-server"],
- env: patchloomOnlyEnv(env)
+ command: launch.file,
+ args: launch.args,
+ env: filtered
}
];
}
@@ -127,7 +138,11 @@ export async function registerMcpServerProviderWithBinary(context: VSCode.Extens
const runtime = await getPatchloomRuntimeConfig();
const workspaceCwd = vscode.workspace.workspaceFolders?.[0]?.uri;
const definitions: unknown[] = [];
- for (const descriptor of mcpServerDefinitionsForBinary(resolvedBinaryPath, runtime.extraEnv)) {
+ for (const descriptor of mcpServerDefinitionsForBinary(
+ resolvedBinaryPath,
+ runtime.extraEnv,
+ runtime.mcpSurface
+ )) {
const definition = createMcpStdioServerDefinition(Ctor, descriptor, workspaceCwd);
if (definition !== undefined) {
definitions.push(definition);
diff --git a/src/status/contextKeys.ts b/src/status/contextKeys.ts
new file mode 100644
index 0000000..c377137
--- /dev/null
+++ b/src/status/contextKeys.ts
@@ -0,0 +1,48 @@
+export interface StatusPresentationInput {
+ readonly extensionEnabled: boolean;
+ readonly statusBarEnabled: boolean;
+ readonly cliReady: boolean;
+ readonly managedInstallExists: boolean;
+ readonly projectInitialized: boolean;
+}
+
+export interface StatusContextKeys {
+ readonly cliAvailable: boolean;
+ readonly managedInstallExists: boolean;
+ readonly projectInitialized: boolean;
+}
+
+export interface StatusPresentation {
+ readonly showStatusBar: boolean;
+ readonly runBackgroundChecks: boolean;
+ readonly context: StatusContextKeys;
+ readonly statusBarCommand: "patchloom.showStatus";
+}
+
+/**
+ * Context keys are set whenever the CLI check finishes, including when the
+ * status bar is hidden and when the extension is disabled. Palette `when`
+ * clauses treat an unset key as false.
+ */
+export function decideStatusPresentation(input: StatusPresentationInput): StatusPresentation {
+ return {
+ showStatusBar: input.extensionEnabled && input.statusBarEnabled,
+ runBackgroundChecks: input.extensionEnabled,
+ context: {
+ cliAvailable: input.cliReady,
+ managedInstallExists: input.managedInstallExists,
+ projectInitialized: input.projectInitialized
+ },
+ statusBarCommand: "patchloom.showStatus"
+ };
+}
+
+export function shouldRunBackgroundChecks(extensionEnabled: boolean): boolean {
+ return decideStatusPresentation({
+ extensionEnabled,
+ statusBarEnabled: true,
+ cliReady: false,
+ managedInstallExists: false,
+ projectInitialized: false
+ }).runBackgroundChecks;
+}
diff --git a/src/status/details.ts b/src/status/details.ts
index ffb4145..1597b64 100644
--- a/src/status/details.ts
+++ b/src/status/details.ts
@@ -43,6 +43,17 @@ export function buildStatusDetails(status: PatchloomStatus, workspaceReadiness?:
].filter((line): line is string => Boolean(line)).join("\n");
}
+/** One line for the notification. The full report stays in the Output channel. */
+export function statusNotificationSummary(ready: boolean, needsUpgrade: boolean): string {
+ if (!ready) {
+ return "Patchloom is not ready. The full report is in the Output channel.";
+ }
+ if (needsUpgrade) {
+ return "Patchloom CLI is older than this extension expects. The full report is in the Output channel.";
+ }
+ return "Patchloom is ready. The full report is in the Output channel.";
+}
+
export function preferredStatusAction(status: PatchloomStatus, workspaceReadiness?: WorkspaceReadiness): SetupAction | undefined {
// Missing or outdated CLI: shared remediation (managed install preferred).
if (!status.ready || patchloomNeedsUpgrade(status)) {
diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts
index cc550f7..6654fdd 100644
--- a/src/status/statusBar.ts
+++ b/src/status/statusBar.ts
@@ -1,15 +1,28 @@
import * as vscode from "vscode";
import { patchloomNeedsUpgrade, resolvePatchloomStatus } from "../binary/patchloom.js";
-import { buildStatusDetails, preferredStatusAction } from "./details.js";
+import { decideStatusPresentation } from "./contextKeys.js";
+import { buildStatusDetails } from "./details.js";
import { inspectWorkspaceReadiness } from "../workspace/readiness.js";
let statusBarItem: vscode.StatusBarItem | undefined;
export async function refreshStatusBar(): Promise {
const config = vscode.workspace.getConfiguration("patchloom");
- const extensionEnabled = config.get("enable", true);
- const statusBarEnabled = config.get("showStatusBar", true);
- if (!extensionEnabled || !statusBarEnabled) {
+ const status = await resolvePatchloomStatus();
+ const workspaceReadiness = await inspectWorkspaceReadiness();
+ const presentation = decideStatusPresentation({
+ extensionEnabled: config.get("enable", true),
+ statusBarEnabled: config.get("showStatusBar", true),
+ cliReady: status.ready,
+ managedInstallExists: status.managedInstall?.exists === true,
+ projectInitialized: workspaceReadiness?.hasAgentsFile === true
+ });
+
+ void vscode.commands.executeCommand("setContext", "patchloom.cliAvailable", presentation.context.cliAvailable);
+ void vscode.commands.executeCommand("setContext", "patchloom.managedInstallExists", presentation.context.managedInstallExists);
+ void vscode.commands.executeCommand("setContext", "patchloom.projectInitialized", presentation.context.projectInitialized);
+
+ if (!presentation.showStatusBar) {
statusBarItem?.hide();
return;
}
@@ -19,21 +32,13 @@ export async function refreshStatusBar(): Promise {
statusBarItem.name = "Patchloom Status";
}
- const status = await resolvePatchloomStatus();
- const workspaceReadiness = await inspectWorkspaceReadiness();
- const action = preferredStatusAction(status, workspaceReadiness);
-
- void vscode.commands.executeCommand("setContext", "patchloom.cliAvailable", status.ready);
- void vscode.commands.executeCommand("setContext", "patchloom.managedInstallExists", status.managedInstall?.exists === true);
- void vscode.commands.executeCommand("setContext", "patchloom.projectInitialized", workspaceReadiness?.hasAgentsFile === true);
-
const versionSuffix = status.detectedVersion ? ` v${status.detectedVersion}` : "";
statusBarItem.text = !status.ready || patchloomNeedsUpgrade(status)
? "$(warning) Patchloom"
: workspaceReadiness?.hasMcpConfig
? `$(plug) Patchloom${versionSuffix}`
: `$(check) Patchloom${versionSuffix}`;
- statusBarItem.command = action?.command ?? "patchloom.showStatus";
+ statusBarItem.command = presentation.statusBarCommand;
statusBarItem.tooltip = buildStatusDetails(status, workspaceReadiness);
statusBarItem.show();
}
diff --git a/src/workspace/readiness.ts b/src/workspace/readiness.ts
index 821d6d6..d59096c 100644
--- a/src/workspace/readiness.ts
+++ b/src/workspace/readiness.ts
@@ -46,7 +46,7 @@ export async function inspectWorkspaceReadiness(options: WorkspaceReadinessOptio
});
return {
hasWorkspace: false,
- hasMcpConfig: targets.some((target) => target.configured),
+ hasMcpConfig: workspaceHasConfiguredMcp(targets),
mcpTargets: targets,
workspaceCount,
environmentLabel: environment.label,
@@ -64,7 +64,7 @@ export async function inspectWorkspaceReadiness(options: WorkspaceReadinessOptio
workspaceName: folder.name,
hasWorkspace: true,
hasAgentsFile: await fileExists(vscode.Uri.joinPath(folder.uri, "AGENTS.md")),
- hasMcpConfig: targets.some((target) => target.configured),
+ hasMcpConfig: workspaceHasConfiguredMcp(targets),
mcpTargets: targets,
workspaceCount,
environmentLabel: environment.label,
@@ -124,6 +124,26 @@ export function describeWorkspaceEnvironment(remoteName?: string): WorkspaceEnvi
}
}
+/** Plug icon and Setup Workspace ignore the Windsurf user file. Show Status still lists it. */
+export function workspaceHasConfiguredMcp(
+ targets: readonly { readonly kind: string; readonly configured: boolean }[]
+): boolean {
+ return targets.some((target) => target.kind !== "windsurf-user" && target.configured);
+}
+
+/**
+ * Files whose save should refresh the status bar.
+ * Windsurf's user config is outside the workspace and is not included.
+ */
+export function isWorkspaceReadinessPath(filePath: string): boolean {
+ const normalized = filePath.replaceAll("\\", "/");
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
+ if (base === "AGENTS.md" || base === ".mcp.json") {
+ return true;
+ }
+ return normalized.endsWith("/.vscode/mcp.json") || normalized.endsWith("/.cursor/mcp.json");
+}
+
export function defaultWorkspaceFolderIndex(workspaceCount: number, activeWorkspaceIndex?: number): number | undefined {
if (activeWorkspaceIndex !== undefined && activeWorkspaceIndex >= 0 && activeWorkspaceIndex < workspaceCount) {
return activeWorkspaceIndex;
diff --git a/test/suite/index.ts b/test/suite/index.ts
index 505a790..67fc61c 100644
--- a/test/suite/index.ts
+++ b/test/suite/index.ts
@@ -11,6 +11,7 @@ const EXPECTED_COMMANDS = [
"patchloom.openPatchloomSettings",
"patchloom.openPatchloomReleases",
"patchloom.showStatus",
+ "patchloom.refreshStatus",
"patchloom.installBinary",
"patchloom.updateBinary",
"patchloom.reinstallBinary",
@@ -65,6 +66,8 @@ export async function run(): Promise {
// Activation events include content-based triggers
const activationEvents = packageJson.activationEvents as string[];
+ assert.ok(activationEvents.includes("onStartupFinished"),
+ "should activate when the workbench has finished starting");
assert.ok(activationEvents.includes("workspaceContains:**/AGENTS.md"),
"should activate on AGENTS.md presence");
assert.ok(activationEvents.includes("workspaceContains:**/.patchloom.toml"),
@@ -90,6 +93,7 @@ export async function run(): Promise {
// New settings contributed
assert.ok(properties["patchloom.enable"], "should contribute patchloom.enable setting");
+ assert.ok(properties["patchloom.mcpSurface"], "should contribute patchloom.mcpSurface setting");
assert.ok(properties["patchloom.trace.server"], "should contribute patchloom.trace.server setting");
assert.ok(properties["patchloom.env"], "should contribute patchloom.env setting");
assert.ok(properties["patchloom.managedInstall.autoUpdate"], "should contribute patchloom.managedInstall.autoUpdate setting");
diff --git a/test/unit/batchApply.test.ts b/test/unit/batchApply.test.ts
index 58a0cfc..b0e8aee 100644
--- a/test/unit/batchApply.test.ts
+++ b/test/unit/batchApply.test.ts
@@ -3,15 +3,27 @@ import test from "node:test";
import {
BATCH_APPLY_PROMPT,
buildBatchApplyArgs,
+ buildBatchDryRunArgs,
buildBatchTemplate,
formatBatchApplyCompletion,
isEmptyBatchPlan,
parseBatchOperationCount
} from "../../src/commands/batchApply.js";
+function exampleLines(): string[] {
+ return buildBatchTemplate()
+ .split("\n")
+ .filter((line) => line.trim().length > 0)
+ .map((line) => line.startsWith("# ") ? line.slice(2) : line);
+}
+
test("buildBatchTemplate returns line-oriented format with twelve operations", () => {
const template = buildBatchTemplate();
- const lines = template.split("\n").filter((line) => line.trim().length > 0);
+ const commented = template.split("\n").filter((line) => line.trim().length > 0);
+ assert.ok(commented.every((line) => line.startsWith("# ")), "opened template lines are comments");
+ assert.equal(isEmptyBatchPlan(template), true);
+ assert.equal(parseBatchOperationCount(exampleLines().join("\n")), 12);
+ const lines = exampleLines();
assert.equal(lines.length, 12);
assert.ok(lines[0].startsWith("replace "), "first line should be a replace operation");
@@ -86,35 +98,35 @@ test("isEmptyBatchPlan is false when at least one operation is present", () => {
// --- #34: snapshot-style template tests ---
test("buildBatchTemplate replace line has file and quoted arguments", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const replaceLine = lines.find((l) => l.startsWith("replace "));
assert.ok(replaceLine, "template should contain a replace line");
assert.match(replaceLine, /replace \S+ ".+" ".+"/, "replace should have file and two quoted args");
});
test("buildBatchTemplate doc.set line has file, selector, and quoted value", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const docSetLine = lines.find((l) => l.startsWith("doc.set "));
assert.ok(docSetLine, "template should contain a doc.set line");
assert.match(docSetLine, /doc\.set \S+ \S+ ".+"/, "doc.set should have file, selector, and quoted value");
});
test("buildBatchTemplate tidy.fix line has a file path", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const tidyLine = lines.find((l) => l.startsWith("tidy.fix "));
assert.ok(tidyLine, "template should contain a tidy.fix line");
assert.match(tidyLine, /tidy\.fix \S+/, "tidy.fix should have a file path");
});
test("buildBatchTemplate file.append line has file and quoted content", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const appendLine = lines.find((l) => l.startsWith("file.append "));
assert.ok(appendLine, "template should contain a file.append line");
assert.match(appendLine, /file\.append \S+ ".+"/, "file.append should have file and quoted content");
});
test("buildBatchTemplate includes fuzzy replace and md.insert_after_section examples", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const fuzzyLine = lines.find((l) => l.includes("--fuzzy"));
const sectionLine = lines.find((l) => l.startsWith("md.insert_after_section "));
assert.ok(fuzzyLine, "template should contain a fuzzy replace example");
@@ -128,7 +140,7 @@ test("buildBatchTemplate includes fuzzy replace and md.insert_after_section exam
});
test("buildBatchTemplate doc.merge line uses path selector value (CLI 0.16 multi-doc)", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const mergeLine = lines.find((l) => l.startsWith("doc.merge "));
assert.ok(mergeLine, "template should contain a doc.merge line");
assert.match(
@@ -140,14 +152,14 @@ test("buildBatchTemplate doc.merge line uses path selector value (CLI 0.16 multi
});
test("buildBatchTemplate uses file.prepend instead of a batch --insert-after token", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const prependLine = lines.find((line) => line.startsWith("file.prepend "));
assert.equal(prependLine, "file.prepend src/example.ts \"header line\"");
assert.equal(lines.some((line) => line.includes("--insert-after")), false);
});
test("buildBatchTemplate includes JSONC doc.set and directory file.rename (CLI 0.35+)", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const jsoncLine = lines.find((l) => l.includes("tsconfig.jsonc"));
const renameLine = lines.find((l) => l.startsWith("file.rename "));
assert.ok(jsoncLine, "template should contain a JSONC doc.set example");
@@ -157,7 +169,7 @@ test("buildBatchTemplate includes JSONC doc.set and directory file.rename (CLI 0
});
test("buildBatchTemplate includes doc.update multi-match example (CLI 0.27+ suggested_op sibling)", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const updateLine = lines.find((l) => l.startsWith("doc.update "));
assert.ok(updateLine, "template should contain a doc.update line");
assert.match(
@@ -169,7 +181,7 @@ test("buildBatchTemplate includes doc.update multi-match example (CLI 0.27+ sugg
});
test("buildBatchTemplate includes doc.delete_where multi-match example (CLI 0.27+ suggested_op sibling)", () => {
- const lines = buildBatchTemplate().split("\n");
+ const lines = exampleLines();
const deleteWhereLine = lines.find((l) => l.startsWith("doc.delete_where "));
assert.ok(deleteWhereLine, "template should contain a doc.delete_where line");
assert.match(
@@ -181,6 +193,8 @@ test("buildBatchTemplate includes doc.delete_where multi-match example (CLI 0.27
test("buildBatchApplyArgs prefixes global --contain before batch --json --apply", () => {
assert.deepEqual(buildBatchApplyArgs(), ["--contain", "batch", "--json", "--apply"]);
+ assert.deepEqual(buildBatchDryRunArgs(), ["--contain", "batch", "--json"]);
+ assert.deepEqual(buildBatchApplyArgs(false), ["--contain", "batch", "--json"]);
});
test("formatBatchApplyCompletion names a refused replace instead of claiming every line applied", () => {
diff --git a/test/unit/binary.test.ts b/test/unit/binary.test.ts
index 97bb1e7..81f90c9 100644
--- a/test/unit/binary.test.ts
+++ b/test/unit/binary.test.ts
@@ -49,7 +49,12 @@ import {
verifyManagedInstallArchiveChecksum
} from "../../src/install/managed.js";
import { resolveMcpTargets } from "../../src/mcp/config.js";
-import { defaultWorkspaceFolderIndex, describeWorkspaceEnvironment } from "../../src/workspace/readiness.js";
+import {
+ defaultWorkspaceFolderIndex,
+ describeWorkspaceEnvironment,
+ isWorkspaceReadinessPath,
+ workspaceHasConfiguredMcp
+} from "../../src/workspace/readiness.js";
test("resolvePatchloomStatusWithInputs prefers patchloom.path over PATH", async () => {
const status = await resolvePatchloomStatusWithInputs({
@@ -339,6 +344,21 @@ test("preferredBinaryRemediationAction is undefined when ready and supported", (
// Note: ensurePatchloomReadyOrNotify UI branches call vscode.window.show* which is
// not unit-testable in pure node. Remediation selection is covered above.
+test("workspace MCP readiness ignores the Windsurf user file", () => {
+ assert.equal(workspaceHasConfiguredMcp([
+ { kind: "windsurf-user", configured: true },
+ { kind: "vscode-workspace", configured: false }
+ ]), false);
+ assert.equal(workspaceHasConfiguredMcp([
+ { kind: "portable-workspace", configured: true }
+ ]), true);
+ assert.equal(isWorkspaceReadinessPath("/work/AGENTS.md"), true);
+ assert.equal(isWorkspaceReadinessPath("/work/.mcp.json"), true);
+ assert.equal(isWorkspaceReadinessPath("/work/.vscode/mcp.json"), true);
+ assert.equal(isWorkspaceReadinessPath("/work/.cursor/mcp.json"), true);
+ assert.equal(isWorkspaceReadinessPath("/home/.codeium/windsurf/mcp_config.json"), false);
+});
+
test("defaultWorkspaceFolderIndex prefers active folders and only auto-selects single roots", () => {
assert.equal(defaultWorkspaceFolderIndex(3, 2), 2);
assert.equal(defaultWorkspaceFolderIndex(1, undefined), 0);
diff --git a/test/unit/contextKeys.test.ts b/test/unit/contextKeys.test.ts
new file mode 100644
index 0000000..347ba2f
--- /dev/null
+++ b/test/unit/contextKeys.test.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { decideStatusPresentation, shouldRunBackgroundChecks } from "../../src/status/contextKeys.js";
+
+test("context keys stay set when the status bar is hidden", () => {
+ const hidden = decideStatusPresentation({
+ extensionEnabled: true,
+ statusBarEnabled: false,
+ cliReady: true,
+ managedInstallExists: false,
+ projectInitialized: false
+ });
+ assert.equal(hidden.showStatusBar, false);
+ assert.equal(hidden.runBackgroundChecks, true);
+ assert.equal(hidden.context.cliAvailable, true);
+ assert.equal(hidden.statusBarCommand, "patchloom.showStatus");
+});
+
+test("disabling the extension hides the bar and skips background checks but still records the CLI", () => {
+ const disabled = decideStatusPresentation({
+ extensionEnabled: false,
+ statusBarEnabled: true,
+ cliReady: true,
+ managedInstallExists: true,
+ projectInitialized: true
+ });
+ assert.equal(disabled.showStatusBar, false);
+ assert.equal(disabled.runBackgroundChecks, false);
+ assert.deepEqual(disabled.context, {
+ cliAvailable: true,
+ managedInstallExists: true,
+ projectInitialized: true
+ });
+ assert.equal(shouldRunBackgroundChecks(false), false);
+ assert.equal(shouldRunBackgroundChecks(true), true);
+});
diff --git a/test/unit/launch.test.ts b/test/unit/launch.test.ts
new file mode 100644
index 0000000..c4fccd3
--- /dev/null
+++ b/test/unit/launch.test.ts
@@ -0,0 +1,78 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { execPatchloomFile, patchloomLaunch } from "../../src/binary/launch.js";
+
+test("patchloomLaunch runs exe and unix binaries directly", () => {
+ const exe = patchloomLaunch("C:\\tools\\patchloom.exe", ["--version"], "win32", "C:\\Windows\\System32\\cmd.exe");
+ assert.equal(exe.file, "C:\\tools\\patchloom.exe");
+ assert.deepEqual(exe.args, ["--version"]);
+ assert.equal(exe.shell, false);
+ assert.equal(exe.windowsVerbatimArguments, false);
+
+ const unix = patchloomLaunch("/usr/local/bin/patchloom", ["mcp-server"], "darwin");
+ assert.equal(unix.file, "/usr/local/bin/patchloom");
+ assert.deepEqual(unix.args, ["mcp-server"]);
+ assert.equal(unix.shell, false);
+});
+
+test("patchloomLaunch runs cmd and bat shims through ComSpec without shell", () => {
+ for (const name of ["patchloom.cmd", "patchloom.CMD", "patchloom.bat"]) {
+ const launch = patchloomLaunch(`C:\\npm\\${name}`, ["--version"], "win32", "C:\\Windows\\System32\\cmd.exe");
+ assert.equal(launch.file, "C:\\Windows\\System32\\cmd.exe");
+ assert.equal(launch.shell, false);
+ assert.equal(launch.windowsVerbatimArguments, true);
+ assert.deepEqual(launch.args.slice(0, 3), ["/d", "/s", "/c"]);
+ assert.match(launch.args[3] ?? "", /patchloom\.(cmd|bat)/i);
+ assert.match(launch.args[3] ?? "", /--version/);
+ }
+
+ const fallback = patchloomLaunch("C:\\npm\\patchloom.cmd", ["mcp-server"], "win32", " ");
+ const comSpecFromEnv = process.env.ComSpec?.trim();
+ assert.equal(fallback.file, comSpecFromEnv && comSpecFromEnv.length > 0 ? comSpecFromEnv : "cmd.exe");
+ assert.equal(fallback.shell, false);
+
+ const spaced = patchloomLaunch(
+ "C:\\Users\\Ada Lovelace\\npm\\patchloom.cmd",
+ ["--version"],
+ "win32",
+ "C:\\Windows\\System32\\cmd.exe"
+ );
+ assert.equal(spaced.args[3]?.startsWith("\""), true);
+ assert.equal(spaced.args[3]?.endsWith("\""), true);
+ const inner = spaced.args[3]?.slice(1, -1) ?? "";
+ assert.match(inner, /^"C:\\Users\\Ada Lovelace\\npm\\patchloom.cmd"/);
+
+ const percent = patchloomLaunch("C:\\npm\\patchloom.cmd", ["--new=%PATH%"], "win32", "cmd.exe");
+ assert.match(percent.args[3] ?? "", /%%PATH%%/);
+ assert.throws(
+ () => patchloomLaunch("C:\\npm\\patchloom.cmd", ["a\nb"], "win32", "cmd.exe"),
+ /newline/
+ );
+
+ const bare = patchloomLaunch("patchloom", ["mcp-server"], "win32", "cmd.exe");
+ assert.equal(bare.file, "cmd.exe");
+ assert.equal(bare.shell, false);
+ assert.deepEqual(bare.args.slice(0, 3), ["/d", "/s", "/c"]);
+
+ const hostQuoted = patchloomLaunch(
+ "C:\\Users\\Ada Lovelace\\npm\\patchloom.cmd",
+ ["mcp-server"],
+ "win32",
+ "cmd.exe",
+ false
+ );
+ assert.equal(hostQuoted.windowsVerbatimArguments, false);
+ assert.equal(
+ hostQuoted.args[3],
+ "\"C:\\Users\\Ada Lovelace\\npm\\patchloom.cmd\" mcp-server"
+ );
+});
+
+test("execPatchloomFile runs a direct binary and keeps stdout text", async () => {
+ const result = await execPatchloomFile(process.execPath, ["-e", "process.stdout.write('ok')"], {
+ timeout: 5_000,
+ windowsHide: true
+ });
+ assert.equal(result.stdout, "ok");
+ assert.equal(result.stderr, "");
+});
diff --git a/test/unit/managedUpdateFlow.test.ts b/test/unit/managedUpdateFlow.test.ts
new file mode 100644
index 0000000..87df68c
--- /dev/null
+++ b/test/unit/managedUpdateFlow.test.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { runCheckedInstall } from "../../src/commands/managedUpdateFlow.js";
+
+test("confirm runs after check ends and install starts only after confirm", async () => {
+ const phases: string[] = [];
+ await runCheckedInstall({
+ onPhase: (phase) => phases.push(phase),
+ check: async () => {
+ phases.push("check-body");
+ return "1.2.3";
+ },
+ confirm: async (version) => {
+ assert.equal(version, "1.2.3");
+ assert.deepEqual(phases, ["check", "check-body", "confirm"]);
+ return true;
+ },
+ install: async () => {
+ phases.push("install-body");
+ }
+ });
+ assert.deepEqual(phases, ["check", "check-body", "confirm", "install", "install-body"]);
+});
+
+test("a declined confirm does not start install", async () => {
+ let installed = false;
+ await runCheckedInstall({
+ check: async () => "current",
+ confirm: async () => false,
+ install: async () => {
+ installed = true;
+ }
+ });
+ assert.equal(installed, false);
+});
diff --git a/test/unit/mcpConfig.test.ts b/test/unit/mcpConfig.test.ts
index 08348fd..66a93bb 100644
--- a/test/unit/mcpConfig.test.ts
+++ b/test/unit/mcpConfig.test.ts
@@ -9,7 +9,10 @@ import {
configureMcpTargets,
formatMcpConfigureFailureMessage,
inspectMcpTargets,
+ mcpCommandForTarget,
McpConfigureError,
+ patchloomEntryIsCurrent,
+ readPatchloomMcpCommand,
resolveMcpTargets
} from "../../src/mcp/config.js";
@@ -61,7 +64,7 @@ test("configureMcpTargets writes VS Code mcp.json to a real temp workspace", asy
const servers = written.servers as Record;
assert.ok(servers.patchloom);
const entry = servers.patchloom as Record;
- assert.equal(entry.command, "/usr/local/bin/patchloom");
+ assert.equal(entry.command, "patchloom");
assert.deepEqual(entry.args, ["mcp-server"]);
assert.equal(entry.env, undefined, "full surface should not inject env");
});
@@ -294,7 +297,7 @@ test("configureMcpTargets writes portable .mcp.json with mcpServers and stdio ty
assert.equal(servers.other.command, "other-server");
assert.deepEqual(servers.patchloom, {
type: "stdio",
- command: "/opt/patchloom",
+ command: "patchloom",
args: ["mcp-server"],
env: { PATCHLOOM_MCP_SURFACE: "core" }
});
@@ -684,6 +687,63 @@ test("configureMcpTargets leaves an unreadable config file unchanged", async (t)
});
});
+test("mcpCommandForTarget keeps workspace configs portable and windsurf absolute", () => {
+ assert.equal(mcpCommandForTarget("vscode-workspace", "/usr/local/bin/patchloom"), "patchloom");
+ assert.equal(mcpCommandForTarget("portable-workspace", "/opt/patchloom"), "patchloom");
+ assert.equal(mcpCommandForTarget("cursor-workspace", "/opt/patchloom"), "patchloom");
+ assert.equal(mcpCommandForTarget("windsurf-user", "/opt/patchloom"), "/opt/patchloom");
+ assert.equal(patchloomEntryIsCurrent("vscode-workspace", { command: "patchloom" }), true);
+ assert.equal(patchloomEntryIsCurrent("vscode-workspace", { command: "/usr/bin/patchloom" }), false);
+ assert.equal(patchloomEntryIsCurrent("windsurf-user", { command: "/opt/patchloom" }), true);
+ assert.equal(patchloomEntryIsCurrent("windsurf-user", { command: "" }), false);
+});
+
+test("configureMcpTargets rewrites a stale workspace command and keeps a windsurf path", async () => {
+ await withTempDir(async (workspace) => {
+ const vscodeDir = path.join(workspace, ".vscode");
+ await fs.mkdir(vscodeDir, { recursive: true });
+ const filePath = path.join(vscodeDir, "mcp.json");
+ await fs.writeFile(
+ filePath,
+ JSON.stringify({ servers: { patchloom: { command: "/old/patchloom", args: ["mcp-server"] } } }),
+ "utf8"
+ );
+ const readFile = async (targetPath: string) => {
+ try { return await fs.readFile(targetPath, "utf8"); } catch { return undefined; }
+ };
+ const writeFile = async (targetPath: string, content: string) => {
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
+ await fs.writeFile(targetPath, content, "utf8");
+ };
+
+ const before = await inspectMcpTargets({ workspaceFolderPath: workspace, homeDir: workspace, readFile });
+ assert.equal(before.find((target) => target.kind === "vscode-workspace")?.configured, false);
+
+ const results = await configureMcpTargets({
+ workspaceFolderPath: workspace,
+ homeDir: workspace,
+ includeKinds: ["vscode-workspace", "windsurf-user"],
+ patchloomPathSetting: "/opt/patchloom",
+ readFile,
+ writeFile
+ });
+ assert.equal(results.find((result) => result.kind === "vscode-workspace")?.changed, true);
+ assert.equal(results.find((result) => result.kind === "windsurf-user")?.changed, true);
+
+ const vscodeWritten = await readJson(filePath);
+ const servers = vscodeWritten.servers as Record;
+ assert.equal(servers.patchloom?.command, "patchloom");
+
+ const windsurf = await readJson(path.join(workspace, ".codeium", "windsurf", "mcp_config.json"));
+ const mcpServers = windsurf.mcpServers as Record;
+ assert.equal(mcpServers.patchloom?.command, "/opt/patchloom");
+ assert.equal(
+ readPatchloomMcpCommand("vscode-workspace", await fs.readFile(filePath, "utf8")),
+ "patchloom"
+ );
+ });
+});
+
function isEnoent(error: unknown): boolean {
return typeof error === "object"
&& error !== null
diff --git a/test/unit/mcpRegister.test.ts b/test/unit/mcpRegister.test.ts
index de2096f..6865fc7 100644
--- a/test/unit/mcpRegister.test.ts
+++ b/test/unit/mcpRegister.test.ts
@@ -16,6 +16,19 @@ class FakeStdio {
) {}
}
+test("mcpServerDefinitionsForBinary launches a Windows cmd shim through cmd.exe", () => {
+ const defs = mcpServerDefinitionsForBinary(
+ "C:\\npm\\patchloom.cmd",
+ undefined,
+ "full",
+ "win32",
+ "C:\\Windows\\System32\\cmd.exe"
+ );
+ assert.equal(defs[0]?.command, "C:\\Windows\\System32\\cmd.exe");
+ assert.deepEqual(defs[0]?.args.slice(0, 3), ["/d", "/s", "/c"]);
+ assert.equal(defs[0]?.args[3], "C:\\npm\\patchloom.cmd mcp-server");
+});
+
test("mcpServerDefinitionsForBinary returns empty when binary is undefined", () => {
assert.deepEqual(mcpServerDefinitionsForBinary(undefined), []);
});
@@ -31,6 +44,21 @@ test("mcpServerDefinitionsForBinary returns one stdio definition for a path", ()
});
});
+test("mcpServerDefinitionsForBinary follows the core pack setting", () => {
+ const defs = mcpServerDefinitionsForBinary("/opt/patchloom", {
+ PATCHLOOM_LOG: "debug",
+ PATH: "/tmp/evil"
+ }, "core");
+ assert.deepEqual(defs[0]?.env, {
+ PATCHLOOM_LOG: "debug",
+ PATCHLOOM_MCP_SURFACE: "core"
+ });
+ const full = mcpServerDefinitionsForBinary("/opt/patchloom", {
+ PATCHLOOM_MCP_SURFACE: "core"
+ }, "full");
+ assert.deepEqual(full[0]?.env, {});
+});
+
test("mcpServerDefinitionsForBinary includes filtered PATCHLOOM env", () => {
const defs = mcpServerDefinitionsForBinary("/opt/patchloom", {
PATCHLOOM_MCP_SURFACE: "core",
diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts
index df21b58..4e71f6d 100644
--- a/test/unit/patchloomCli.test.ts
+++ b/test/unit/patchloomCli.test.ts
@@ -1021,7 +1021,7 @@ describe("patchloom CLI integration", async () => {
const config = JSON.parse(await fs.readFile(configPath, "utf8")) as Record;
const servers = config.servers as Record>;
assert.ok(servers.patchloom, "should have a patchloom server entry");
- assert.equal(servers.patchloom.command, binaryPath, "command should point to real binary");
+ assert.equal(servers.patchloom.command, "patchloom", "workspace command stays portable");
assert.deepEqual(servers.patchloom.args, ["mcp-server"], "args should be mcp-server");
// Verify the config is re-readable by inspectMcpTargets
diff --git a/test/unit/previewExit.test.ts b/test/unit/previewExit.test.ts
new file mode 100644
index 0000000..59279df
--- /dev/null
+++ b/test/unit/previewExit.test.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { dryRunFollowUp, previewDryRunMayContinue } from "../../src/commands/previewExit.js";
+
+const batchWouldWrite = `{
+ "ok": true,
+ "status": "changes_detected",
+ "applied": false,
+ "files_changed": 1,
+ "refused": [{ "path": "other.txt", "reason": "no_matches" }]
+}`;
+
+const patchWouldWrite = `{
+ "ok": true,
+ "files": [{ "path": "note.txt", "status": "would_change" }],
+ "applied": false
+}`;
+
+const mergeWouldWrite = `{
+ "ok": true,
+ "files": [{ "path": "note.txt", "status": "clean" }],
+ "applied": false
+}`;
+
+test("preview dry run continues on exit 0 and on exit 2 that would write", () => {
+ assert.equal(previewDryRunMayContinue({ exitCode: 0, stdout: '{"ok":true,"applied":false,"files":[]}' }), true);
+ assert.equal(previewDryRunMayContinue({ exitCode: 2, stdout: batchWouldWrite }), true);
+ assert.equal(previewDryRunMayContinue({ exitCode: 2, stdout: `\n${patchWouldWrite}\n` }), true);
+ assert.equal(previewDryRunMayContinue({ exitCode: 2, stdout: mergeWouldWrite }), true);
+});
+
+test("preview dry run stops on failures and on exit 2 that is not a pending write", () => {
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 3,
+ stdout: '{"ok":false,"status":"no_matches","applied":false}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 4,
+ stdout: '{"ok":false,"error_kind":"parse_error","applied":false}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 8,
+ stdout: '{"ok":false,"error_kind":"conflicts","applied":false}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 2,
+ stdout: '{"ok":false,"applied":false}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 2,
+ stdout: '{"ok":true,"applied":true}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({
+ exitCode: 2,
+ stdout: '{"ok":"true","applied":false}'
+ }), false);
+ assert.equal(previewDryRunMayContinue({ exitCode: 2, stdout: "would change note.txt" }), false);
+ assert.equal(previewDryRunMayContinue({ exitCode: 2, stdout: "{not json" }), false);
+ assert.equal(previewDryRunMayContinue({ exitCode: 1, stdout: batchWouldWrite }), false);
+});
+
+test("dry run follow-up offers apply only when the preview would write or change nothing", () => {
+ const wouldWrite = { exitCode: 2, stdout: patchWouldWrite };
+ const conflict = { exitCode: 8, stdout: '{"ok":false,"error_kind":"conflicts","applied":false}' };
+ assert.equal(dryRunFollowUp("apply", wouldWrite), "apply");
+ assert.equal(dryRunFollowUp("merge", wouldWrite), "apply");
+ assert.equal(dryRunFollowUp("merge", { exitCode: 0, stdout: '{"ok":true,"applied":false}' }), "apply");
+ assert.equal(dryRunFollowUp("merge", conflict), "conflicts");
+ assert.equal(dryRunFollowUp("apply", conflict), "stop");
+ assert.equal(dryRunFollowUp("merge", { exitCode: 4, stdout: '{"ok":false,"applied":false}' }), "stop");
+ assert.equal(dryRunFollowUp("apply", { exitCode: 5, stdout: '{"ok":false,"applied":false}' }), "stop");
+});
diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts
index 4fb1c67..a7ad45e 100644
--- a/test/unit/quickActions.test.ts
+++ b/test/unit/quickActions.test.ts
@@ -35,8 +35,19 @@ import {
buildSearchQuickAction,
buildTidyQuickAction,
buildUndoQuickAction,
+ contentFromScratch,
+ emptyClipboardPatchMessage,
isAllowedPreviewMiss,
+ PATCHLOOM_PREVIEW_SCHEME,
+ parseFilesWithoutMatchLines,
+ parseSearchHitLines,
+ previewBodyForId,
+ newBackupSessionNames,
+ previewCwd,
previewMissMessage,
+ previewWorkspaceFile,
+ rememberPreviewBody,
+ seedFromSelection,
DOC_SET_SELECTOR_PROMPT,
DOC_SET_SELECTOR_PLACEHOLDER,
DOC_UPDATE_SELECTOR_PROMPT,
@@ -290,9 +301,14 @@ test("previewMissMessage explains delete-where predicate vs array path", () => {
assert.doesNotMatch(message, /No changes to preview/);
});
-test("previewMissMessage keeps replace-style no-changes copy", () => {
+test("previewMissMessage distinguishes a missing pattern from an unchanged file", () => {
const action = buildReplaceQuickAction("/workspace/demo/README.md", "old", "new");
- assert.equal(previewMissMessage(action, "README.md"), "No changes to preview for README.md.");
+ assert.equal(previewMissMessage(action, "README.md", "pattern-not-found"), "Pattern not found in README.md.");
+ assert.equal(previewMissMessage(action, "README.md", "unchanged"), "Command made no edit to README.md.");
+ const tidy = buildTidyQuickAction("/workspace/demo/README.md", ["ensure-final-newline"]);
+ assert.equal(previewMissMessage(tidy, "README.md", "pattern-not-found"), "Command made no edit to README.md.");
+ const deleteWhere = buildDocDeleteWhereQuickAction("/workspace/demo/data.json", "items", "name=stale");
+ assert.match(previewMissMessage(deleteWhere, "data.json", "unchanged"), /No items matched that predicate/);
});
test("retargetQuickAction swaps only the target path arguments", () => {
@@ -1050,6 +1066,17 @@ test("presentPatchMergeOutcome exit 0 writes streams + show, returns ok", () =>
assert.deepEqual(messages, ["merged 2 hunks", "SHOW"]);
});
+test("presentPatchMergeOutcome exit 2 stays an error so an applied merge is not treated as success", () => {
+ const { log, messages } = fakeLog();
+ const kind = presentPatchMergeOutcome(log, {
+ exitCode: 2,
+ stdout: '{"ok":true,"applied":false}',
+ stderr: ""
+ });
+ assert.equal(kind, "error");
+ assert.deepEqual(messages, ['{"ok":true,"applied":false}', "SHOW"]);
+});
+
test("presentPatchMergeOutcome exit 1 writes streams + show, returns error", () => {
const { log, messages } = fakeLog();
const kind = presentPatchMergeOutcome(log, {
@@ -1208,6 +1235,51 @@ test("stageExternalPatchInWorkspace leaves an inside patch in place", async () =
}
});
+test("preview file stays under the workspace and the cwd is that workspace", () => {
+ const workspaceRoot = path.join("/workspace", "demo");
+ const location = previewWorkspaceFile(workspaceRoot, "src/app.ts", "abc");
+ assert.equal(previewCwd(workspaceRoot), workspaceRoot);
+ assert.ok(location.filePath.startsWith(path.join(workspaceRoot, ".patchloom")));
+ assert.equal(location.filePath.includes(`${path.sep}tmp${path.sep}`), false);
+ assert.equal(path.basename(location.filePath), "app.ts");
+ assert.deepEqual(newBackupSessionNames(["old"], ["old", "1792_preview"]), ["1792_preview"]);
+ assert.deepEqual(newBackupSessionNames([], []), []);
+});
+
+test("seedFromSelection keeps the first line and scratch text keeps newlines", () => {
+ assert.equal(seedFromSelection("anchor line\nsecond"), "anchor line");
+ assert.equal(seedFromSelection(undefined), "");
+ assert.equal(contentFromScratch("one\r\ntwo\n"), "one\ntwo\n");
+});
+
+test("search hit parser opens path:line and files-without-match at line 1", () => {
+ assert.deepEqual(parseSearchHitLines("src/app.ts:12:TODO\nC:\\work\\app.ts:4:hit\n"), [
+ { path: "src/app.ts", line: 12 },
+ { path: "C:\\work\\app.ts", line: 4 }
+ ]);
+ assert.deepEqual(parseFilesWithoutMatchLines("src/clean.ts\n\n"), [
+ { path: "src/clean.ts", line: 1 }
+ ]);
+});
+
+test("undo args cover latest, session, and path", () => {
+ assert.deepEqual(buildUndoQuickAction("/workspace/demo").args, ["undo"]);
+ assert.deepEqual(buildUndoQuickAction("/workspace/demo", { kind: "session", sessionId: "abc" }).args, [
+ "undo", "--session", "abc"
+ ]);
+ assert.deepEqual(buildUndoQuickAction("/workspace/demo", { kind: "path", relativePath: "src/app.ts" }).args, [
+ "undo", "--path", "src/app.ts"
+ ]);
+});
+
+test("empty clipboard patch text is an error and preview documents use patchloom-preview", () => {
+ assert.equal(emptyClipboardPatchMessage(" "), "The clipboard has no patch text.");
+ assert.equal(emptyClipboardPatchMessage("diff --git a/x b/x\n"), undefined);
+ const id = rememberPreviewBody("preview body\n");
+ assert.equal(previewBodyForId(id), "preview body\n");
+ assert.equal(PATCHLOOM_PREVIEW_SCHEME, "patchloom-preview");
+});
+
test("stageExternalPatchInWorkspace copies a workspace symlink to an outside patch", async (t) => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-stage-symlink-ws-"));
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "patchloom-stage-symlink-out-"));
diff --git a/test/unit/setupSummary.test.ts b/test/unit/setupSummary.test.ts
new file mode 100644
index 0000000..483dbf7
--- /dev/null
+++ b/test/unit/setupSummary.test.ts
@@ -0,0 +1,12 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { formatSetupSummary } from "../../src/commands/setupSummary.js";
+
+test("setup summary names skipped steps and a folder", () => {
+ const summary = formatSetupSummary("demo", "skipped", "skipped");
+ assert.match(summary, /demo/);
+ assert.match(summary, /AGENTS.md was skipped/);
+ assert.match(summary, /MCP config was skipped/);
+ assert.match(formatSetupSummary(undefined, "present", "ran"), /already present/);
+ assert.match(formatSetupSummary(undefined, "present", "ran"), /Configure MCP ran/);
+});
diff --git a/test/unit/verifyMcp.test.ts b/test/unit/verifyMcp.test.ts
index 207180c..775961f 100644
--- a/test/unit/verifyMcp.test.ts
+++ b/test/unit/verifyMcp.test.ts
@@ -1,7 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { parseInitializeResponse, verifyMcpServer } from "../../src/commands/verifyMcp.js";
-import { buildStatusDetails } from "../../src/status/details.js";
+import { parseInitializeResponse, preferredVerifyMcpCommand, selectVerifyMcpCommand, verifyMcpServer, verifyMcpWorkingDirectory } from "../../src/commands/verifyMcp.js";
+import { buildStatusDetails, statusNotificationSummary } from "../../src/status/details.js";
+
+test("selectVerifyMcpCommand prefers a configured command and otherwise the resolved CLI", () => {
+ assert.equal(selectVerifyMcpCommand("bad-command", "/opt/patchloom"), "bad-command");
+ assert.equal(selectVerifyMcpCommand(" ", "/opt/patchloom"), "/opt/patchloom");
+ assert.equal(selectVerifyMcpCommand(undefined, "/opt/patchloom"), "/opt/patchloom");
+ assert.equal(verifyMcpWorkingDirectory("/workspace/demo"), "/workspace/demo");
+ assert.equal(verifyMcpWorkingDirectory(" "), undefined);
+ assert.equal(preferredVerifyMcpCommand([
+ { kind: "vscode-workspace", command: "stale" },
+ { kind: "portable-workspace", command: "patchloom" }
+ ]), "patchloom");
+ assert.equal(preferredVerifyMcpCommand([
+ { kind: "vscode-workspace", command: "from-vscode" }
+ ]), "from-vscode");
+});
+
+test("statusNotificationSummary is one line and names the Output channel", () => {
+ assert.match(statusNotificationSummary(false, false), /not ready/);
+ assert.match(statusNotificationSummary(false, false), /Output channel/);
+ assert.match(statusNotificationSummary(true, true), /older/);
+ assert.match(statusNotificationSummary(true, false), /ready/);
+ assert.equal(statusNotificationSummary(true, false).includes("\n"), false);
+});
// --- parseInitializeResponse ---
diff --git a/walkthrough/configure-mcp.md b/walkthrough/configure-mcp.md
index 0a709c5..564bf69 100644
--- a/walkthrough/configure-mcp.md
+++ b/walkthrough/configure-mcp.md
@@ -17,8 +17,10 @@ for your editor. Choose the **full** tool inventory or the **core** pack
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
Prefer **Portable workspace** for current VS Code and Copilot. That file
-lives at the workspace root and sets `"type": "stdio"`. The older VS Code
-file remains available. You pick one or more targets, then Full vs Core.
-The command does not write every editor on its own.
+lives at the workspace root and sets `"type": "stdio"`. Workspace entries
+use `command` `patchloom`. The older VS Code file remains available. Writing
+it adds a second server next to the native Patchloom provider, which follows
+the `patchloom.mcpSurface` setting. You pick one or more targets, then Full
+vs Core. The command does not write every editor on its own.
See the [MCP setup guide](https://patchloom.github.io/patchloom/getting-started/mcp-setup.html) for advanced configuration.
diff --git a/walkthrough/install.md b/walkthrough/install.md
index 98ac298..77e0183 100644
--- a/walkthrough/install.md
+++ b/walkthrough/install.md
@@ -52,6 +52,7 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/patchloom/patchloom/rel
After installation, the status bar shows `$(check) Patchloom` when the
CLI is detected and MCP is not configured yet. It shows `$(plug) Patchloom`
-when an MCP config file is configured, and `$(warning) Patchloom` when
-the CLI is missing or too old. A detected version is appended on the
-check and plug states.
+when a workspace MCP config file is configured, and `$(warning) Patchloom`
+when the CLI is missing or too old. A detected version is appended on the
+check and plug states. Click the item to open Show Status. The full report
+is in the Output channel. Update Patchloom asks before it downloads.