diff --git a/.github/prompts/endgame.prompt.md b/.github/prompts/endgame.prompt.md deleted file mode 100644 index a4faae37..00000000 --- a/.github/prompts/endgame.prompt.md +++ /dev/null @@ -1,127 +0,0 @@ -# Endgame Verification Prompt - -## Instructions - -**⚠️ CRITICAL - YOU MUST FOLLOW THESE RULES:** -1. **Do NOT run `gh pr view` or `gh issue view` for individual tasks** - only run it once for the main endgame issue -2. **Do NOT research or analyze any task yourself** -3. **Do NOT create any verification files yourself** -4. **IMMEDIATELY call `runSubagent` for each task** after parsing the issue - no delays, no research - -Your workflow is ONLY: -1. Create initial todo: "Fetch endgame issue and parse tasks" -2. Fetch the endgame issue (ONE `gh issue view` call) -3. Parse to get task list, then ADD a todo item for each task found -4. Create output directory -5. Call `runSubagent` for each task - mark todo complete when subagent returns - -### Steps - -1. **Create initial todo list**: - Use `manage_todo_list` to create the first todo: - - Todo 1: "Fetch endgame issue" (mark as in-progress) - -2. **Ask the user** for the following information: - - The GitHub endgame issue link (e.g., `https://github.com/microsoft/copilot-for-eclipse/issues/XXXX`) - - The user's GitHub account name - -3. **Fetch the endgame issue** (this is the ONLY `gh issue view` you should run): - ```shell - gh issue view --repo microsoft/copilot-for-eclipse - ``` - Parse the issue body to find all tasks (checkboxes) assigned to the specified user. - Extract the task title and any linked PR/issue URL as plain text. - **STOP - do NOT fetch any of the linked PRs or issues.** - -4. **Update todo list with all tasks found**: - Use `manage_todo_list` to: - - Mark "Fetch endgame issue" as completed - - ADD a new todo for each task found (e.g., "Task 1: ", "Task 2: <title>", etc.) - - All new task todos should be "not-started" - -5. **Create the output directory**: - ```shell - mkdir -p .github/endgame/<issue_number> - ``` - -6. **For each task, mark todo as in-progress, then call `runSubagent`**: - - For each task: - 1. Update todo list - mark that task as "in-progress" - 2. Call `runSubagent` tool with: - - **description**: "Endgame: <short_task_title>" - - **prompt**: The template below filled in with only the info you extracted - 3. When subagent returns, mark that task's todo as "completed" - - **Subagent prompt template**: - - --- - ## Task Details - - Task Number: <N> - - Task Title: <task_title> - - Assignee: <username> - - Related Issue/PR: <link_if_available> (NOT YET FETCHED - you must fetch this) - - ## Your Mission - YOU (the subagent) must research this task AND create the verification file. - - ### Step 1: Research the Task - - If there's a linked PR/issue, fetch it using `gh pr view` or `gh issue view` - - Understand what feature/fix needs to be verified - - Identify the affected areas of the codebase - - ### Step 2: Create Verification File (YOU must create this file) - Create the file: `.github/endgame/<issue_number>/<N>_<task_slug>.md` - - Use this format: - - ### Task: <Task Title> - **Assignee:** <Name> - **Issue/PR:** <Link> - - #### Context - [Brief description of what this task involves] - - #### Prerequisites - - [ ] [Any setup needed before testing] - - #### Steps to Verify - 1. [ ] [Detailed step 1] - 2. [ ] [Detailed step 2] - 3. [ ] [Detailed step 3] - - #### Expected Results - - [Expected outcome 1] - - [Expected outcome 2] - - #### Edge Cases to Test - - [Edge case 1] - - [Edge case 2] - - #### Status - - [ ] Not Started - - [ ] In Progress - - [ ] Completed - - [ ] Blocked (reason: ___) - - ### Step 3: Return Summary - Return ONLY: - - File path created - - One-line summary of what needs to be verified - --- - -7. **After all subagents complete**, provide: - - Summary table of all generated verification files - - Total tasks processed - - Any tasks that couldn't be processed (with reasons) - ---- - -## Notes - -- **NEVER run `gh pr view` or `gh issue view` on task links** - only on the main endgame issue -- **NEVER analyze or research tasks yourself** - immediately delegate to subagents -- Each subagent runs independently -- Subagents should be concise - create the file and return a brief summary -- If a task is unclear, the subagent should note this in the verification file -- Use slugified task titles for filenames (lowercase, hyphens, no special chars) diff --git a/.github/skills/endgame/SKILL.md b/.github/skills/endgame/SKILL.md new file mode 100644 index 00000000..53794bac --- /dev/null +++ b/.github/skills/endgame/SKILL.md @@ -0,0 +1,173 @@ +--- +name: endgame +description: Orchestrate endgame verification for a GitHub milestone issue. Fetches the issue, parses assigned tasks, delegates each task to a subagent that researches the linked PR/issue and writes a test plan, and saves every plan to com.microsoft.copilot.eclipse.swtbot.test/test-plans/ following the project's standard test-plan format. +--- + +# Endgame Verification Skill + +Use this skill to run an endgame verification pass for a GitHub milestone issue. +Each task in the issue is delegated to a subagent that researches the linked +PR/issue and writes a test plan under `com.microsoft.copilot.eclipse.swtbot.test/test-plans/`. + +## Workflow + +**⚠️ CRITICAL — YOU MUST FOLLOW THESE RULES:** + +1. **Do NOT run `gh pr view` or `gh issue view` for individual tasks** — only + run it once for the main endgame issue. +2. **Do NOT research or analyse any task yourself.** +3. **Do NOT create any test-plan files yourself.** +4. **IMMEDIATELY call `runSubagent` for each task** after parsing the issue — + no delays, no research. + +--- + +### Step 1 — Ask the user for inputs + +Ask for: +- The GitHub endgame issue URL + (e.g. `https://github.com/microsoft/copilot-for-eclipse/issues/XXXX`) +- The user's GitHub account name + +--- + +### Step 2 — Fetch the endgame issue (ONE call only) + +```shell +gh issue view <issue_number> --repo microsoft/copilot-for-eclipse --json body --jq '.body' +``` + +Parse the body to find **all tasks (checkboxes) assigned to the specified +user**. Extract each task's title and any linked PR/issue URL as plain text. + +**STOP — do NOT fetch any of the linked PRs or issues.** + +--- + +### Step 3 — Process each task via subagent + +For each task, call `runSubagent` with: +- **description**: `"Endgame: <short_task_title>"` +- **prompt**: the template below, filled in with only the info you extracted. + +#### Subagent prompt template + +--- +## Task Details +- Task Number: \<N\> +- Task Title: \<task_title\> +- Assignee: \<username\> +- Related Issue/PR: \<link_if_available\> (NOT YET FETCHED — you must fetch this) + +## Your Mission + +YOU (the subagent) must research this task AND create the test-plan file. + +### Step 1: Research the task + +- Fetch the linked PR or issue with `gh pr view` / `gh issue view`. +- Understand what feature or fix needs to be verified. +- **If anything is ambiguous or unclear after reading the PR/issue, ask the + user for clarification before proceeding. Keep asking until you have enough + information to write concrete, accurate test steps.** + +### Step 2: Create the test-plan file + +Determine a short `<feature-slug>` (lowercase, hyphens, no special chars) +derived from the task title (e.g. `chat-history-restore`). + +Create the directory and file: + +``` +com.microsoft.copilot.eclipse.swtbot.test/test-plans/<feature-slug>/<feature-slug>.md +``` + +Use **exactly** this format, matching the project's existing test plans +(see `com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-persistence/thinking-persistence.md` for a live +example): + +```markdown +# <Feature / Task Title> + +## Overview +<1–3 sentences: what is being verified and why it matters.> + +--- + +## Test Cases + +### TC-001: <Specific test case title> + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- <Specific state required before starting these steps> + +#### Steps +1. <Detailed, concrete step> +2. <Next step> +3. <Continue as needed> + +#### Expected Result +- <Observable outcome that proves the feature works> + +#### 📸 Key Screenshots +- [ ] **<Label>** — <What to capture> + +--- + +### TC-002: <Next scenario if needed> +<!-- Repeat TC block for each distinct scenario. Use TC-003, TC-004, … --> + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` <Label> +- [ ] `TC-002` <Label> +``` + +Guidelines: +- **Overview**: 1–3 sentences max. Do not repeat what is already covered by + Preconditions or Steps. +- **Type** values: `Happy Path`, `Negative`, `Edge Case`, `Regression`. +- **Priority** values: `P0` (must-pass), `P1` (high), `P2` (medium). +- Use 3-digit zero-padded TC numbers: TC-001, TC-002, TC-003, … +- Omit `📸 Key Screenshots` within a TC block if there are no meaningful + screenshots to capture for that case. +- Keep the `## Screenshots Checklist` section at the end — list every + screenshot across all TC blocks. +- If you are still unsure about any step after researching, **ask the user** + before writing that step — do not guess. + +### Step 3: Return a brief summary + +Return ONLY: +- File path created (e.g. `com.microsoft.copilot.eclipse.swtbot.test/test-plans/chat-history-restore/chat-history-restore.md`) +- One-line summary of what needs to be verified + +--- + +### Step 4 — Final summary + +After all subagents complete, provide: +- A table of all generated test-plan files +- Total tasks processed +- Any tasks that could not be processed (with reasons) + +--- + +## Notes + +- **NEVER run `gh pr view` or `gh issue view` on task links yourself** — only + on the main endgame issue. Subagents handle the individual links. +- **NEVER analyse or research tasks yourself** — immediately delegate to + subagents. +- Each subagent runs independently. +- Subagents should be concise — create the file and return a brief summary. +- If a task is still unclear after the user is asked, note the outstanding + question inside the test-plan file. +- Use slugified task titles for the feature slug and directory name + (lowercase, hyphens, no special characters). diff --git a/.github/skills/ui-action/REFERENCE.md b/.github/skills/ui-action/REFERENCE.md new file mode 100644 index 00000000..65d4fb3e --- /dev/null +++ b/.github/skills/ui-action/REFERENCE.md @@ -0,0 +1,169 @@ +# SWTBot Probe Reference + +## Runner behavior + +`ProbeRunner` is a JUnit 4 test launched by Tycho under a real Eclipse workbench +with `useUIHarness=true` and `useUIThread=false`. It loads the JSON array from +`-Dprobe.script`, executes each `ProbeStep` through `StepExecutor`, writes result +artifacts, and fails the Maven build if any failed step has `failFast` enabled. + +Before the bot starts, the runner pre-populates configuration-scope preferences +so Quick Start, What's New, Welcome, and "Terminal Support Unavailable" dialogs +do not block normal probes. + +`screenshot` and failure screenshots capture the active workbench shell with +`java.awt.Robot`, then fall back to SWTBot's full-display capture if needed. +Screenshots are diagnostic artifacts only; JSON assertions drive pass/fail. + +## Running probes + +Default command from the repository root: + + # macOS / Linux + ./mvnw clean verify -Dprobe.script=probe-scripts/<name>.json + # Windows (PowerShell) + .\mvnw.cmd clean verify -Dprobe.script=probe-scripts/<name>.json + +The narrower module command is useful only after a green root build: + +```bash +./mvnw -pl com.microsoft.copilot.eclipse.swtbot.test -am -Dprobe.script=probe-scripts/<name>.json verify +``` + +The module-only shortcut can fail dependency resolution or reuse stale bundles, +especially after source edits. If widget markers or other code changes appear to +be ignored, run the root `clean verify` command. + +Platform notes: + +- Linux headless: prefix the command with `xvfb-run -a`. +- macOS: keep `${swtbot.platformArgLine}` in + `com.microsoft.copilot.eclipse.swtbot.test/pom.xml` so the `swtbot-osx` + profile can inject `-XstartOnFirstThread`. +- macOS screenshots: the JVM needs Screen Recording permission. Blank or + wallpaper-only PNGs usually mean permission is missing. +- Do not set `-Djava.awt.headless=true`; `Robot` cannot capture screenshots in + headless AWT mode. +- Windows HiDPI: workbench shell bounds are DIP coordinates while `Robot` + expects raw pixels, so captures can clip at scaling above 100%. + +Windows stale cache recovery: + +```powershell +Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository\com\microsoft\copilot\eclipse +``` + +## Results + +Artifacts are written under +`com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/`: + +```text +results.json # pass/fail summary +workspace.log # sandbox Eclipse .metadata/.log +screenshots/ + <id>.png # from screenshot steps + FAILED-stepNN-<action>.png # auto-captured on step failure +ui-dumps/<id>.xml # from dumpUi steps +``` + +Quick pass/fail checks: + +```bash +jq '{passed, failed, failed_steps: [.steps[] | select(.status=="failed") | {index, action, message}]}' \ + com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json +``` + +```powershell +Get-Content com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json | + ConvertFrom-Json | Select-Object passed, failed, durationMs +``` + +Maven exit code `0` and `results.json` `.failed == 0` means pass. Otherwise, +open the failed step message, `FAILED-stepNN-*.png`, nearest `ui-dumps/*.xml`, +and `workspace.log`. Many "widget not found" failures are downstream of a +Copilot language server startup or authentication failure. + +## Authentication + +The Copilot JS agent reads its GitHub token from the host's standard Copilot +store (`%USERPROFILE%\AppData\Local\github-copilot\apps.json` on Windows; +`~/.config/github-copilot/apps.json` elsewhere). The Tycho JVM inherits it. + +- Signed-in host required for probes that exercise chat completion. +- To probe unauthenticated state, assert the "Sign in to GitHub" UI instead of + polling internal status managers. +- CI-side auth bootstrap is out of scope for this skill. + +## Actions + +Set `"failFast": false` on a step to record a failure without aborting the probe. + +| `action` | Required | Optional | Notes | +|---|---|---|---| +| `screenshot` | - | `id` | PNG of the active workbench window written to `screenshots/`. | +| `sleep` | - | `timeoutSec` default `1` | Plain `Thread.sleep`. Prefer waits over sleeps. | +| `waitForIdle` | - | - | Flushes the SWT event queue. | +| `pressKey` | `key` | `locator` | Supports `ENTER`, `RETURN`, `CR`, `ESC`, `ESCAPE`, `TAB`, `SPACE`, `BS`, `BACKSPACE`, `DELETE`. Uses SWTBot `pressShortcut`; with a text/styled-text locator it targets that widget, otherwise the active shell. | +| `showView` | `idRef` | - | Opens an Eclipse view via `IWorkbenchPage#showView`. | +| `closeView` | `idRef` | - | Hides the view if present. | +| `invokeCommand` | `idRef` | - | Runs a registered Eclipse command via `IHandlerService#executeCommand`. | +| `click` | `locator` | - | Focuses text/styled-text widgets; otherwise reflectively invokes `click()` on the matched widget. | +| `typeIn` | `locator`, `text` | - | Sets text on text/styled-text widgets. | +| `clearElement` | `locator` | - | Clears text/styled-text widgets. | +| `waitFor` | `locator` | `timeoutSec` default `30` | Polls until the locator resolves. | +| `waitForMethod` | `locator`, `method` | `timeoutSec` default `30`, `expectedValue` | Polls a no-arg getter on the located widget until it returns non-null/non-empty, or equals `expectedValue`. | +| `assertExists` | `locator` | `shouldExist` default `true` | Asserts presence or absence. | +| `dumpUi` | - | `id` | Writes shell widget hierarchy XML with class names and SWTBot widget IDs. | +| `newSession` | - | - | Copilot-specific shortcut for the `newChatSession` command. | + +## Locators + +Locators are JSON objects with a `by` discriminator. SWTBot is not XPath-based; +extend `Locator` and `StepExecutor` when the existing vocabulary is not enough. + +| `by` | Other fields | What it finds | +|---|---|---| +| `viewId` | `id` | Eclipse view by ID (`bot.viewById`). | +| `label` | `text` | First label with the given text. | +| `button` | `text` | First button with the given text. | +| `buttonWithTooltip` | `tooltip` | First button whose tooltip matches; useful for icon-only buttons like Send. | +| `text` | `index` default `0` | Nth text field. | +| `styledText` | - | First `StyledText`. | +| `tree` | `labels` array | Tree path under the active tree. | +| `cssId` | `value` | Widget whose `CssConstants.CSS_ID_KEY` data equals `value`. | +| `cssClass` | `value` | Widget whose `CssConstants.CSS_CLASS_NAME_KEY` contains `value` as a whitespace-separated token. | +| `widgetId` | `value` | Widget tagged with `setData("org.eclipse.swtbot.widget.key", value)`. Preferred for widgets you own. | +| `widgetClass` | `value` | First widget whose `getClass().getSimpleName()` equals `value`. Use only when you cannot tag the widget. | + +Stable IDs and signals currently used by probes: + +- `widgetId`: `user-turn`, `copilot-turn`, `model-picker`. +- `cssClass`: `model-info-label` appears only after a Copilot turn has completed. +- `cssId`: `chat-container`, `chat-content-wrapper`, `chat-content-viewer`, + `chat-action-bar-wrapper`, `chat-action-bar`, `chat-history-viewer`. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| `IllegalArgumentException: Missing required field: ...` | Step JSON is missing a field required by that action. Check the action table. | +| `AssertionError: waitFor timed out: locator ...` | Widget did not appear. Add `dumpUi` before the failing step, confirm class/id, or raise `timeoutSec`. | +| `assertExists failed: ... shouldExist=true` | Locator did not match. Confirm the `by` type matches the widget family. | +| `click not supported on <Type>` | Wrapper has no `click()` method. Use a more specific locator, such as `button` instead of `label`. | +| Tests skipped: "No probe script specified" | Pass `-Dprobe.script=probe-scripts/<name>.json`. | +| `model-info-label` wait times out | Usually authentication or language-server startup. Inspect `workspace.log`. | +| All screenshots are blank or tiny identical PNGs | `Robot` capture failed. On macOS, grant Screen Recording permission to the JVM and re-run. | +| Widget-id markers missing from `dumpUi` | Stale bundle cache or stale module jar. Run root `./mvnw clean verify`. | + +## Extending the vocabulary + +Probe support lives in `com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`: + +- `ProbeStep.java` and `Locator.java`: JSON shape. +- `StepExecutor.java`: action dispatch, locator resolution, screenshots, dumps. +- `ProbeRunner.java`: runner loop, reporting, and workbench setup. + +Add UI-level primitives that mimic user behavior. Do not add actions that call +private production methods, reach into OSGi services, or mutate app state behind +the UI; those hide real UX bugs and break on internal refactors. diff --git a/.github/skills/ui-action/SKILL.md b/.github/skills/ui-action/SKILL.md index 6ddf7ba8..5b4740cc 100644 --- a/.github/skills/ui-action/SKILL.md +++ b/.github/skills/ui-action/SKILL.md @@ -1,19 +1,18 @@ --- name: ui-action -description: Write or update a SWTBot JSON probe script (e.g. from a test plan), then validate it locally by running the `ProbeRunner` Tycho test against the Copilot for Eclipse plugin. Use this whenever you need end-to-end UI validation against a real Eclipse workbench. +description: Authors and validates SWTBot JSON probe scripts for GitHub Copilot for Eclipse UI flows against a real Eclipse workbench. Use when creating or updating probe-scripts, converting test plans to UI probes, validating end-to-end Eclipse UI behavior, or troubleshooting ProbeRunner failures. --- -# Authoring and Validating SWTBot Probe Scripts (Eclipse) +# SWTBot Probe Scripts -Use this skill to write or update a JSON probe script and validate it by -invoking the `com.microsoft.copilot.eclipse.swtbot.test` Tycho bundle locally. -Probe scripts are JSON, so no Java is compiled per test case. +Use this skill to create or update JSON probes for `com.microsoft.copilot.eclipse.swtbot.test`. +Probes drive the workbench through SWTBot actions and run as Tycho tests; no Java is compiled per test case. ## Quick start -1. Drop a probe at `com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/<plan-slug>-<tc-id>.json` - (e.g. `chat-send-receive-001.json`). Start every probe with this preamble that - settles the workbench and opens the Copilot Chat view: +1. Create one focused JSON script at + `com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/<plan-slug>-<tc-id>.json`. +2. Start by settling the workbench and opening the view under test: ```json [ @@ -25,275 +24,63 @@ Probe scripts are JSON, so no Java is compiled per test case. ] ``` -2. Run it (cross-platform command; use `./mvnw` on macOS/Linux): +3. Add user-level steps only, such as `click`, `typeIn`, `clearElement`, `pressKey`, + `invokeCommand`, `waitFor`, `waitForMethod`, `assertExists`, `screenshot`, or + `dumpUi`. Prefer `widgetId` locators for widgets the plugin owns. +4. Run from the repository root: - ```powershell + ```bash ./mvnw clean verify -Dprobe.script=probe-scripts/<name>.json ``` - Root `clean verify` is the recommended default. Tycho prefers each - bundle's freshly-packaged jar in `<module>/target/` and silently falls - back to the stale Maven cache if the jar is missing — making `setData` - markers and other source edits appear to be ignored. + Use root `clean verify` as the default. Tycho can reuse stale bundle jars + when only the SWTBot module is run, making source edits appear ignored. Use + `xvfb-run -a` on Linux headless. +5. Read `com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json`, + `workspace.log`, `screenshots/`, and `ui-dumps/`. - The narrower `./mvnw -pl com.microsoft.copilot.eclipse.swtbot.test -am - -Dprobe.script=... verify` shortcut is **experimental**: in practice - it can fail dependency resolution (observed: `Missing requirement: - com.microsoft.copilot.eclipse.core 0.0.0`, with Tycho choosing the - wrong `osgi.arch` on aarch64). Use only as a follow-up, after a green - root `clean verify`. +If `-Dprobe.script` is unset, `ProbeRunner` skips itself so ordinary `./mvnw verify` runs are unaffected. -3. Read results under `com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/`: +## Authoring workflow - ``` - results.json # pass/fail summary - workspace.log # sandbox Eclipse .metadata/.log - screenshots/ - <id>.png # from `screenshot` steps - FAILED-stepNN-<action>.png # auto-captured on step failure - ui-dumps/<id>.xml # from `dumpUi` steps - ``` - -If `-Dprobe.script` is unset the test is skipped, so ordinary `./mvnw verify` -runs are unaffected. - -> **Linux headless:** prefix with `xvfb-run -a`. **macOS:** the swtbot test -> pom carries a `swtbot-osx` profile that injects `-XstartOnFirstThread` -> into the test JVM via the `swtbot.platformArgLine` placeholder -> interpolated into `<argLine>`. If you ever edit -> `com.microsoft.copilot.eclipse.swtbot.test/pom.xml`, **keep the -> `${swtbot.platformArgLine}` token in `<argLine>`** — replacing it with a -> plain literal silently drops the macOS arg and the workbench fails with -> `SWTException: Invalid thread access`. **Stale cache recovery:** -> `Remove-Item -Recurse -Force $env:USERPROFILE\.m2\repository\com\microsoft\copilot\eclipse` -> then re-run `clean verify`. - -## How the runner works - -`ProbeRunner` is a JUnit 4 test Tycho launches under a real Eclipse workbench -(`useUIHarness=true`, `useUIThread=false`). It: - -1. Loads the JSON probe pointed at by `-Dprobe.script`. -2. Walks each step via `SWTWorkbenchBot`, wrapping every step in a uniform - try/catch so one failure doesn't crash the run. -3. Writes `results.json`, screenshots (PNG — `SWTBotPreferences.SCREENSHOT_FORMAT` - is pinned), and widget-tree dumps. -4. Fails the Maven build if any `failFast` step failed. - -Before the bot starts, the runner pre-populates configuration-scope -preferences so Quick Start, What's New, Welcome, and "Terminal Support -Unavailable" dialogs don't pop during a probe — probes see a clean workbench. - -`screenshot` and `dumpUi` capture from the **on-screen workbench shell**: -`StepExecutor#captureWorkbenchShell` resolves the active workbench -`Shell`'s bounds, then snapshots those screen pixels with -`java.awt.Robot.createScreenCapture` (with SWTBot's full-display -`bot.captureScreenshot` as a final fallback). SWT's own GC-based capture -(`new GC(display).copyArea(...)` and `Shell#print(GC)`) returns blank -images on macOS Cocoa for workbench shells — Robot is the workaround. -Caveats: - -- **macOS:** the JVM running the tests needs **Screen Recording** - permission (System Settings → Privacy & Security → Screen Recording). - Without it Robot returns blank or wallpaper-only frames. -- **Headless AWT:** `-Djava.awt.headless=true` makes `new Robot()` throw, - and the SWTBot fallback is also blank on Cocoa. Don't set it. -- **Windows HiDPI:** `Shell#getBounds()` returns DIP coordinates; Robot - expects raw screen pixels. At display scaling > 100% the captured - region is undersized / clipped. (At 100% — typical CI runners — it's - a pixel-perfect match.) - -## Authoring rules - -### Drive the UI, don't reach into services - -Probes **must** interact with the workbench the way a user does — SWTBot -clicks, typing, menu navigation, keyboard shortcuts, or `invokeCommand` -against a registered Eclipse command id (the same `IHandlerService#executeCommand` -path that menus and key bindings trigger — useful when an entry point is -buried in a popup like the Copilot status-bar menu, which is awkward to -drive with SWTBot). Do not add actions that reflectively invoke private -methods, call OSGi services to flip state, or read private fields; those -hide real UX bugs and break on every internal refactor. If a scenario needs -a new primitive (e.g. a chat-mode dropdown picker), extend the -action/locator vocabulary in `StepExecutor` with a UI-level primitive, -not a service shortcut. +- Convert one scenario/test case into one probe. Split unrelated behaviors so each failure screenshot has one meaning. +- Drive the UI the way a user does. `invokeCommand` is allowed for registered Eclipse command IDs; do not call + OSGi services, private methods, or test-only shortcuts. +- Pair screenshots with machine-checked assertions. Screenshots never fail a step; `assertExists`, `waitFor`, and + `waitForMethod` decide pass/fail. +- Wait for asynchronous UI state before acting. For chat, wait for the `model-picker` selected model before Send, + then wait for `user-turn` and `model-info-label`. +- When a locator fails, insert `dumpUi` before the failure and inspect `ui-dumps/*.xml` for widget class names and + SWTBot widget IDs. +- For unauthenticated chat probes, assert the "Sign in to GitHub" UI. Do not poll internal auth managers. -### Tag widgets for `widgetId` locators - -SWTBot's blessed identification convention is -`widget.setData("org.eclipse.swtbot.widget.key", "<stable-id>")` at widget -construction, located from tests with the `widgetId` locator. Zero runtime -cost, far more robust than class-name or creation-order lookup. Prefer -`widgetId` over `widgetClass` whenever you control the widget's construction; -use `widgetClass` only for platform / third-party widgets you can't tag. - -Current tags: `user-turn` (`UserTurnWidget`), `copilot-turn` -(`CopilotTurnWidget`), `model-picker` (chat-model `DropdownButton`). - -### Screenshots are artifacts; assertions catch regressions - -`screenshot` never fails a step. Validation has two layers: - -- **Structural** (machine-checked): `assertExists`, `waitFor`, specific - locators. These failures land in `results.json` and drive the build exit - code — the agent judges pass/fail from here. -- **Visual** (agent-checked): after the run, open the PNGs under - `screenshots/` with your vision tool and describe what you see. No - baseline-image comparison in JSON (cross-platform font rendering makes - pixel diffs flaky). - -Pair screenshots with assertions around every non-trivial interaction: +## Chat send skeleton ```json -{ "action": "screenshot", "id": "before-send" }, -{ "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, -{ "action": "waitForIdle" }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "user-turn" } }, -{ "action": "screenshot", "id": "after-send" } +[ + { "action": "waitForIdle" }, + { "action": "showView", "idRef": "com.microsoft.copilot.eclipse.ui.chat.ChatView" }, + { "action": "waitFor", "locator": { "by": "styledText" }, "timeoutSec": 30 }, + { "action": "clearElement", "locator": { "by": "styledText" } }, + { "action": "typeIn", "locator": { "by": "styledText" }, "text": "hello" }, + { "action": "waitForMethod", "locator": { "by": "widgetId", "value": "model-picker" }, + "method": "getSelectedItemId", "timeoutSec": 60 }, + { "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, + { "action": "waitFor", "locator": { "by": "widgetId", "value": "user-turn" }, "timeoutSec": 30 }, + { "action": "waitFor", "locator": { "by": "cssClass", "value": "model-info-label" }, "timeoutSec": 120 }, + { "action": "assertExists", "locator": { "by": "widgetId", "value": "copilot-turn" } }, + { "action": "screenshot", "id": "agent-response" } +] ``` -When a locator is wrong, insert a `dumpUi` step and inspect -`ui-dumps/*.xml` to find the real widget class / id. - -### Authentication prerequisite - -The Copilot JS agent reads its GitHub token from the host's standard Copilot -store (`%USERPROFILE%\AppData\Local\github-copilot\apps.json` on Windows; -`~/.config/github-copilot/apps.json` elsewhere) — the Tycho JVM inherits it. - -- **Signed-in host required** for any probe that exercises chat. Sign in via - any Copilot client (Eclipse plugin, VS Code, `gh auth login`). -- **Probing unauthed state:** assert on the "Sign in to GitHub" button that - replaces the chat input; don't poll internal status managers. -- CI-side auth bootstrap is out of scope for this skill. - -## Reading results & interpreting failures - -Maven exit code `0` **and** `results.json` `.failed == 0` → overall pass. -Otherwise open the failed step's `message`, its `FAILED-stepNN-*.png`, the -nearest `ui-dumps/*.xml`, **and** `workspace.log` — many "widget not found" -failures are downstream of a Copilot LS that never started or authenticated. -Look for `!ENTRY com.microsoft.copilot.eclipse` entries and NPEs on -`CopilotLanguageServer.*`. - -| Symptom | What's wrong / fix | -|---|---| -| `IllegalArgumentException: Missing required field: …` | Step JSON missing a field required by that action (see action table). | -| `AssertionError: waitFor timed out: locator …` | Widget not appearing. Add a `dumpUi` before the failing step, check class/id, or raise `timeoutSec`. | -| `assertExists failed: … shouldExist=true` | Locator didn't match. Confirm `by` matches the widget family. | -| `click not supported on <Type>` | Wrapper has no `click()`; use the right `by` (e.g. `button`, not `label`). | -| Tests skipped: "No probe script specified" | Pass `-Dprobe.script=probe-scripts/<name>.json`. | -| `model-info-label` wait times out | Usually auth; open `workspace.log`. | -| All screenshots are blank / identical ~4KB PNGs across every step | Robot capture failed. macOS: grant Screen Recording permission to the JVM and re-run. Verify by file size — real workbench frames are 50KB+; a uniform 4KB family means the on-screen capture path didn't run or returned empty pixels. | -| Widget-id markers missing from `dumpUi` | Stale Maven cache — run full `clean verify` (see Quick start). | - -Quick pass/fail check: - -```powershell -Get-Content com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json | - ConvertFrom-Json | Select-Object passed, failed, durationMs -``` - -```bash -jq '{passed, failed, failed_steps: [.steps[] | select(.status=="failed") | {index, action, message}]}' \ - com.microsoft.copilot.eclipse.swtbot.test/target/probe-results/results.json -``` - -## Action reference - -Set `"failFast": false` on a step to record a failure without aborting the probe. - -| `action` | Required | Optional | Notes | -|---|---|---|---| -| `screenshot` | — | `id` | PNG of the active workbench window written to `screenshots/`. | -| `sleep` | — | `timeoutSec` (default 1) | Plain `Thread.sleep`. | -| `waitForIdle` | — | — | Flushes the SWT event queue. | -| `pressKey` | `key` | `locator` | Accepts `ENTER`/`CR`, `ESC`/`ESCAPE`, `TAB`, `SPACE`, `BS`/`BACKSPACE`, `DELETE`. Without `locator`, presses on the active shell; with a `locator` targeting a text / styledText widget, sends the key directly (needed for chat input ENTER-to-send). | -| `showView` | `idRef` (Eclipse view id) | — | Opens via `IWorkbenchPage#showView`. | -| `closeView` | `idRef` | — | Hides the view if present. | -| `invokeCommand` | `idRef` (command id) | — | Runs via `IHandlerService#executeCommand`. | -| `click` | `locator` | — | Reflective `click()` on matched widget. | -| `typeIn` | `locator`, `text` | — | Works on text / styled-text widgets. | -| `clearElement` | `locator` | — | Sets text to empty. | -| `waitFor` | `locator` | `timeoutSec` (default 30) | Polls until locator resolves. | -| `waitForMethod` | `locator`, `method` | `timeoutSec` (default 30), `expectedValue` | Polls until a no-arg getter on the located widget returns non-null (and non-empty for `String`), or — if `expectedValue` is set — until `toString()` equals it. Invoked reflectively via the class hierarchy. Use for UI-exposed state not reachable via finders, e.g. `DropdownButton.getSelectedItemId()`. | -| `assertExists` | `locator` | `shouldExist` (default true) | Asserts presence / absence. | -| `dumpUi` | — | `id` | Writes widget hierarchy XML. | -| `newSession` | — | — | Copilot-specific: triggers `newChatSession` command. | - -## Locator reference - -Locators are JSON objects with a `by` discriminator. SWTBot is **not -XPath-based** — stick to this vocabulary; extend `Locator` / `StepExecutor` -if you need a new finder. - -| `by` | Other fields | What it finds | -|---|---|---| -| `viewId` | `id` | Eclipse view by id (`bot.viewById`). | -| `label` | `text` | First label with the given text. | -| `button` | `text` | First button with the given text. | -| `buttonWithTooltip` | `tooltip` | First button whose tooltip matches (use for icon-only buttons like **Send**). | -| `text` | `index` (default 0) | Nth text field. | -| `styledText` | — | First StyledText (editors / chat input). | -| `tree` | `labels` (array) | Tree path under the active tree. | -| `cssId` | `value` | Widget whose `CssConstants.CSS_ID_KEY` equals `value` (e.g. `chat-content-viewer`, `chat-action-bar`). Walks the full shell tree. | -| `cssClass` | `value` | Widget whose `CssConstants.CSS_CLASS_NAME_KEY` contains `value` as a token. `model-info-label` is only set on a **completed** Copilot turn, so waiting on it is a reliable "response received" signal. | -| `widgetId` | `value` | Widget tagged with `setData("org.eclipse.swtbot.widget.key", value)`. **Preferred** identifier for widgets you own. | -| `widgetClass` | `value` | First widget whose `getClass().getSimpleName()` equals `value` (walks the full shell tree). Fallback for widgets you can't tag. | - -## Canonical example: send a prompt and verify a response - -```json -{ "action": "clearElement", "locator": { "by": "styledText" } }, -{ "action": "typeIn", "locator": { "by": "styledText" }, "text": "your prompt" }, -{ "action": "screenshot", "id": "typed" }, - -{ "action": "waitForMethod", - "locator": { "by": "widgetId", "value": "model-picker" }, - "method": "getSelectedItemId", - "timeoutSec": 60 }, - -{ "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, - -{ "action": "waitFor", "locator": { "by": "widgetId", "value": "user-turn" }, - "timeoutSec": 30 }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "user-turn" } }, - -{ "action": "waitFor", "locator": { "by": "cssClass", "value": "model-info-label" }, - "timeoutSec": 120 }, -{ "action": "screenshot", "id": "agent-response" }, -{ "action": "assertExists", "locator": { "by": "widgetId", "value": "copilot-turn" } } -``` - -Key signals: - -- `model-picker.getSelectedItemId()` non-null — the workbench has resolved an - active chat model. Sending before this NPEs in `onSendInternal` with - "activeModel is null" because auth + model fetch complete asynchronously. -- `user-turn` — the user turn has rendered (Send button dispatched the prompt). -- `model-info-label` — only appears once a Copilot turn has **completed** - (rendered in the turn's footer at end of streaming); the reliable "response - fully received" signal. -- `copilot-turn` — the assistant turn container; good secondary assertion. - -Without a signed-in host the language server can't complete a turn, so -`model-info-label` never appears and the step times out — itself a useful -diagnostic (check `workspace.log`). - -## Extending the vocabulary - -Everything the probe understands lives in -`com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`: +## Pass/fail judgment -- `ProbeStep.java` / `Locator.java` — JSON shape. -- `StepExecutor.java` — action dispatch + locator resolution. -- `ProbeRunner.java` — runner loop, reporting, screenshots. +A probe passes only when Maven exits `0` and `results.json` has `"failed": 0`. +On failure, inspect the failed step message, `FAILED-stepNN-*.png`, nearest `ui-dumps/*.xml`, and `workspace.log`. -Add a case to the `switch` in `StepExecutor#execute` (or `resolve`) and the new -action is usable from every probe. +## Extending probes -### Keep one probe focused +The vocabulary lives in `com.microsoft.copilot.eclipse.swtbot.test/src/.../probe/`. +Add UI-level primitives in `ProbeStep`, `Locator`, and `StepExecutor`, then update [REFERENCE.md](REFERENCE.md). -Each JSON script represents one test case. Split unrelated behaviours into -separate probes so a single `FAILED-step…` screenshot tells you what broke. +See [REFERENCE.md](REFERENCE.md) for action/locator tables, platform notes, result queries, troubleshooting, and current stable widget IDs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820eefb3..a77cc802 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "main" ] +permissions: + contents: read + jobs: build: @@ -38,8 +41,3 @@ jobs: with: run: >- ./mvnw clean verify --batch-mode - - # Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive - - name: Update dependency graph - uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 - continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 04fc4f02..bdd3076e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,83 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.20.0 +### Added +- Support organization-enabled custom (BYOK) models automatically in the model picker. [PR#333](https://github.com/microsoft/copilot-for-eclipse/pull/333) + +### Changed +- Move Copilot Menu to the left of the Help menu. [#287](https://github.com/microsoft/copilot-for-eclipse/issues/287) + +### Fixed +- Chat cannot be scrolled down to see the newest messages in long conversations. [#63](https://github.com/microsoft/copilot-for-eclipse/issues/63) +- Copilot asks read permission for a global skill file. [#318](https://github.com/microsoft/copilot-for-eclipse/issues/318) +- Detailed model information on dropdown hover is cropped on Linux. [#113](https://github.com/microsoft/copilot-for-eclipse/issues/113), contributed by [@travkin79](https://github.com/travkin79) +- Automatically scroll the chat view to prompts requiring user action (e.g. "Continue") in Agent mode. [#120](https://github.com/microsoft/copilot-for-eclipse/issues/120), contributed by [@raghucssit](https://github.com/raghucssit) +- Agents become extremely slow due to expensive chat view re-layout on every streamed chunk. [#259](https://github.com/microsoft/copilot-for-eclipse/issues/259) +- UI freezes on editor switch when the Chat view has a long conversation. [#335](https://github.com/microsoft/copilot-for-eclipse/issues/335) + +## 0.19.0 +### Added +- Improve terminal command execution across Windows and Linux. [PR#247](https://github.com/microsoft/copilot-for-eclipse/pull/247) +- Support editing/creating local files outside the workspace. [PR#248](https://github.com/microsoft/copilot-for-eclipse/pull/248) +- Support automatic chat context compression. [PR#250](https://github.com/microsoft/copilot-for-eclipse/pull/250) +- Support tool auto-approve controls. [PR#255](https://github.com/microsoft/copilot-for-eclipse/pull/255) + +### Fixed +- Copilot reports failure when extension-point contributed MCP server has changed. [#153](https://github.com/microsoft/copilot-for-eclipse/issues/153) +- Subagent panel size is not updated if conversation is canceled. [#169](https://github.com/microsoft/copilot-for-eclipse/issues/169), contributed by [@rsd-darshan](https://github.com/rsd-darshan) +- Thinking effort descriptions are truncated in the model picker hover card. [#233](https://github.com/microsoft/copilot-for-eclipse/issues/233) +- Canceled terminal tool calling status will become ongoing again after chat history restoration. [#239](https://github.com/microsoft/copilot-for-eclipse/issues/239) +- Cannot navigate to the file out of workspace. [#262](https://github.com/microsoft/copilot-for-eclipse/issues/262) +- Read the default text editor preferences from platform instead of defaulting to spaces. [PR#267](https://github.com/microsoft/copilot-for-eclipse/pull/267), contributed by [@jomillerOpen](https://github.com/jomillerOpen) + +### Engineering +- Remove unused message keys and properties across various modules. [PR#208](https://github.com/microsoft/copilot-for-eclipse/pull/208) +- Add endgame skill. [PR#230](https://github.com/microsoft/copilot-for-eclipse/pull/230) +- Fix typo in README upgrade recommendation. [PR#251](https://github.com/microsoft/copilot-for-eclipse/pull/251), contributed by [@evanclan](https://github.com/evanclan) + +## 0.18.0 +### Added +- ℹ️ Prepare for the [upcoming usage-based billing](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/). We strongly recommend upgrading to this version as soon as possible. [#203](https://github.com/microsoft/copilot-for-eclipse/issues/203) +- Add Copilot preference for a chat's custom instructions loading. [#62](https://github.com/microsoft/copilot-for-eclipse/issues/62), contributed by [@travkin79](https://github.com/travkin79) +- Support skills and prompt files. [PR#133](https://github.com/microsoft/copilot-for-eclipse/pull/133) +- Support displaying thinking blocks in chat view. [#202](https://github.com/microsoft/copilot-for-eclipse/issues/202) +- Support selecting thinking effort for model. [#204](https://github.com/microsoft/copilot-for-eclipse/issues/204) + +### Fixed +- Cannot fall back to JS-based CLS when native binary fails to start. [#116](https://github.com/microsoft/copilot-for-eclipse/issues/116) +- 400 Bad Request when restoring conversation from persistence. [#131](https://github.com/microsoft/copilot-for-eclipse/issues/131) +- Tool call errors failed to render in chat view. [PR#145](https://github.com/microsoft/copilot-for-eclipse/pull/145) +- BYOK display name label should be optional. [PR#158](https://github.com/microsoft/copilot-for-eclipse/pull/158) +- Subagent progress events leak into unrelated conversation UI when switching sessions. [#160](https://github.com/microsoft/copilot-for-eclipse/issues/160) +- Integrate CLS session persistence and restoration for conversation history. [PR#161](https://github.com/microsoft/copilot-for-eclipse/pull/161) +- Subagent turns appear as separate assistant messages after restoration. [#163](https://github.com/microsoft/copilot-for-eclipse/issues/163) +- UI freeze: caused by deadlock in EditorsManager. [#175](https://github.com/microsoft/copilot-for-eclipse/issues/175) +- UI freeze: deadlock between main thread and LSP listener on quota fallback. [#179](https://github.com/microsoft/copilot-for-eclipse/issues/179) +- The mode picker will be blank in preference page when workspace contains 'remote' FS project. [#180](https://github.com/microsoft/copilot-for-eclipse/issues/180) +- Prevent focusing the Terminal view after executing a CLI command in Chat view. [#188](https://github.com/microsoft/copilot-for-eclipse/issues/188), contributed by [@rsd-darshan](https://github.com/rsd-darshan) + +### Engineering +- Extend CONTRIBUTING.md and adapt some Eclipse project settings to simplify getting started. [PR#176](https://github.com/microsoft/copilot-for-eclipse/pull/176), contributed by [@travkin79](https://github.com/travkin79) +- Add explicit least-privilege permissions to CI workflow. [PR#185](https://github.com/microsoft/copilot-for-eclipse/pull/185), contributed by [@arpitjain099](https://github.com/arpitjain099) + + +## 0.17.0 +### Added +- Add context size donut and popup for visualizing token usage. +- Add rate limit warning banner in chat view. [PR#17](https://github.com/microsoft/copilot-for-eclipse/pull/17) +- Support delegating read directory to IDE. [PR#18](https://github.com/microsoft/copilot-for-eclipse/pull/18) +- Support delegating file and text search to IDE. [PR#22](https://github.com/microsoft/copilot-for-eclipse/pull/22) +- Support custom models (BYOK) for Copilot Business and Enterprise users. [PR#140](https://github.com/microsoft/copilot-for-eclipse/pull/140) + +### Changed +- Update the combos rendering and add context button style in chat view. + +### Fixed +- Fix ConcurrentModificationException in CompletionProvider listener iteration. [PR#13](https://github.com/microsoft/copilot-for-eclipse/pull/13) +- Fix capitalization of "GitHub" in signin description. [PR#10](https://github.com/microsoft/copilot-for-eclipse/pull/10) +- Fix NES annotation type mapping and foreign text marker registration. [#23](https://github.com/microsoft/copilot-for-eclipse/issues/23) + ## 0.16.0 ### Added - Support tool calling in Ask Mode. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37e54c50..e78e21d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,18 +16,28 @@ When you submit a pull request, a CLA bot will automatically determine whether y ### Prerequisites -- **Java 21** or later +- **Java 17** or later (CI uses Temurin 17; use a newer JDK if required by your Eclipse IDE) - **Maven 3.8+** (or use the provided Maven wrapper `./mvnw`) +- **Node.js 22.13** or later, with npm - **Eclipse IDE for Eclipse Committers 2024-03** or later (for development) +- Recommended: **Eclipse Checkstyle plugin** for code style compliance + (e.g. install from update site: https://checkstyle.org/eclipse-cs-update-site/) ### Building the Project -Clone the repository and build with the Maven wrapper: +Clone the repository, install the Copilot agent dependencies, and build with the Maven wrapper: ```shell +cd com.microsoft.copilot.eclipse.core/copilot-agent +npm i -f +cd ../.. ./mvnw clean package ``` +The `npm i -f` step runs the Copilot agent `postinstall` script, which copies the language server files into +`com.microsoft.copilot.eclipse.core/copilot-agent/dist/` and the platform-specific agent bundles required by the +Tycho build. The `-f` flag matches CI and lets npm proceed if the agent dependency tree has conflicts. + ### Running Tests ```shell @@ -45,7 +55,24 @@ The installable P2 repository is generated in `com.microsoft.copilot.eclipse.rep ### Running in Eclipse 1. Import all modules into your Eclipse workspace. -2. Use the launch configurations in the `launch/` directory. + * Select *File > Import... > General > Existing projects into Workspace*, + select the root directory of the copilot-for-eclipse git repo, + activate the check box *Search for nested projects*, and finish the wizard. + * Do also import the agent bundle for your OS (e.g., `com.microsoft.copilot.eclipse.core.agent.win32`) + after building the project with npm and maven or import all OS-specific agent bundles. +2. Activate one of the target platforms, i.e. open one of the target definition files and select `Set As Active Target Platform`. + * target-terminal.target (Eclipse 4.37+) + * target-tm-terminal.target (Eclipse 4.36 and earlier) +3. For using the Checkstyle configuration (assuming you have installed the Eclipse Checkstyle plugin, see prerequisites), + add a new named Checkstyle configuration. + * Select *Window > Preferences > Checkstyle* and press the *New...* button. + * Select Type="Project Relative Configuration", **name="copilot4eclipse"**, and choose the location using the *Browse...* button. + The `checkstyle.xml` file is in the git repository root folder in the project "github-copilot-for-eclipse". +4. Use the launch configurations in the `launch/` directory, e.g. for launching a new Eclipse IDE with Copilot plug-ins. + * Check the selected plug-ins in your launch configuration (in *Plug-ins* tab) and remove any OS-specific agent bundle that does not fit + to your OS and remove all test bundles from the selected plug-ins from your Eclipse workspace. + * Validate your config and ensure that all dependencies are resolved. + Try *Select Required* button if something is missing. ## How to Contribute @@ -61,6 +88,9 @@ The installable P2 repository is generated in `com.microsoft.copilot.eclipse.rep 2. Make your changes following the code style and architecture guidelines below. 3. Ensure all checks pass before submitting: ```shell + cd com.microsoft.copilot.eclipse.core/copilot-agent + npm i -f + cd ../.. ./mvnw checkstyle:check # Code style compliance ./mvnw clean verify # Compilation and packaging ./mvnw test # Unit tests diff --git a/README.md b/README.md index 9cca642c..31cddede 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ GitHub Copilot for Eclipse brings AI-assisted coding to the Eclipse IDE with the - **Model Context Protocol (MCP)** integration to connect Copilot with external tools and services. - **Advanced Agentic Capabilities** include Custom Agents, Isolated Subagents, and Plan Agent, with more agentic capabilities coming soon. +## Usage-based billing support + +Starting from version **0.18.0**, we have added internal support for the upcoming [usage-based billing experience](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including experience updates to the usage panel, usage notifications, and model picker. These changes will become visible once usage-based billing is rolled out. + +To ensure compatibility with the new billing experience, we strongly recommend upgrading the plugin to **0.18.0 or later** as soon as possible. + +Clients using older plugin versions will continue to function. However, the billing and usage experience may not be optimal and may not accurately reflect the latest usage-based billing experience. + ## Getting access to GitHub Copilot @@ -61,6 +69,18 @@ MCP support enables integrating external tools and services into Copilot workflo - **Custom Agents** allow users to create personalized agents with specific instructions and behaviors. - **Isolated Subagents** can be spawned by the main agent to handle specific tasks or contexts independently. - **Plan Agent** can generate multi-step plans to accomplish complex tasks, breaking them down into manageable actions. +- **Skills** are reusable, specialized AI assistant templates that enrich chat context in Agent Mode. Skills are defined as `SKILL.md` files and can be scoped to a workspace or shared globally. + + - Creating Skills + + Place a `SKILL.md` file in any of these directories: + + - **Project-scoped:** `.github/skills/<skill-name>/`, `.claude/skills/<skill-name>/`, `.agents/skills/<skill-name>/` + - **User-scoped (global):** `~/.copilot/skills/<skill-name>/`, `~/.claude/skills/<skill-name>/`, `~/.agents/skills/<skill-name>/` + + Each `SKILL.md` file can include YAML front matter with metadata (name, description) followed by Markdown content that provides domain knowledge, workflows, or instructions for the AI assistant. + + Skills are automatically discovered and available in Agent Mode. You can enable or disable skills in **Window → Preferences → Copilot → Chat → Enable Skills**. For other available features in Eclipse, see the [Copilot feature matrix](https://docs.github.com/en/copilot/reference/copilot-feature-matrix?tool=eclipse). diff --git a/com.microsoft.copilot.eclipse.branding/.project b/com.microsoft.copilot.eclipse.branding/.project new file mode 100644 index 00000000..f25cf55b --- /dev/null +++ b/com.microsoft.copilot.eclipse.branding/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.branding</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.branding/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.branding/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.branding/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF index f5109b6c..7a539765 100644 --- a/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.branding/META-INF/MANIFEST.MF @@ -2,6 +2,6 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Bundle-SymbolicName: com.microsoft.copilot.eclipse.branding;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Automatic-Module-Name: com.microsoft.copilot.eclipse.branding diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.project b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.project new file mode 100644 index 00000000..bcd9d4b9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.core.agent.linux.aarch64</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF index f900928c..e13e44f3 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.aarch64/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 -Automatic-Module-NAME: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 -Bundle-Version: 0.16.0.qualifier +Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.aarch64 +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/.project b/com.microsoft.copilot.eclipse.core.agent.linux.x64/.project new file mode 100644 index 00000000..caf59c07 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.core.agent.linux.x64</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.core.agent.linux.x64/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF index f5e2b7b0..b8a762ac 100644 --- a/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.linux.x64/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.linux.x64 -Automatic-Module-NAME: com.microsoft.copilot.eclipse.core.agent.linux.x64 -Bundle-Version: 0.16.0.qualifier +Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.linux.x64 +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.project b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.project new file mode 100644 index 00000000..076b9a7c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.core.agent.macosx.aarch64</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF index f7a860e2..d2a33578 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.aarch64/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 -Automatic-Module-NAME: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 -Bundle-Version: 0.16.0.qualifier +Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.aarch64 +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.project b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.project new file mode 100644 index 00000000..eeb7516c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.core.agent.macosx.x64</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF index a865163b..a6a5e998 100644 --- a/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.macosx.x64/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.macosx.x64 -Automatic-Module-NAME: com.microsoft.copilot.eclipse.core.agent.macosx.x64 -Bundle-Version: 0.16.0.qualifier +Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.macosx.x64 +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/.project b/com.microsoft.copilot.eclipse.core.agent.win32/.project new file mode 100644 index 00000000..50cac2a8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.win32/.project @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.core.agent.win32</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.ManifestBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.pde.SchemaBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + <nature>org.eclipse.pde.PluginNature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.core.agent.win32/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.agent.win32/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF index 865b5114..b9c752e5 100644 --- a/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.agent.win32/META-INF/MANIFEST.MF @@ -2,8 +2,8 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.agent.win32 Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.agent.win32 -Automatic-Module-NAME: com.microsoft.copilot.eclipse.core.agent.win32 -Bundle-Version: 0.16.0.qualifier +Automatic-Module-Name: com.microsoft.copilot.eclipse.core.agent.win32 +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Fragment-Host: com.microsoft.copilot.eclipse.core Bundle-RequiredExecutionEnvironment: JavaSE-17 diff --git a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF index d9b05e0f..efcd8dba 100644 --- a/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core.test/META-INF/MANIFEST.MF @@ -2,14 +2,14 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.core.test;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Fragment-Host: com.microsoft.copilot.eclipse.core Automatic-Module-Name: com.microsoft.copilot.eclipse.core.test Import-Package: org.objenesis;version="[3.4.0,4.0.0)", org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.jdt.annotation;resolution:=optional, diff --git a/com.microsoft.copilot.eclipse.core.test/pom.xml b/com.microsoft.copilot.eclipse.core.test/pom.xml index 3231acca..dcc3fca9 100644 --- a/com.microsoft.copilot.eclipse.core.test/pom.xml +++ b/com.microsoft.copilot.eclipse.core.test/pom.xml @@ -14,4 +14,27 @@ <properties> <checkstyle.skip>true</checkstyle.skip> </properties> -</project> \ No newline at end of file + + <profiles> + <profile> + <id>skip-tests-during-ui-probe</id> + <activation> + <property> + <name>probe.script</name> + </property> + </activation> + <build> + <plugins> + <plugin> + <groupId>org.eclipse.tycho</groupId> + <artifactId>tycho-surefire-plugin</artifactId> + <version>${tycho-version}</version> + <configuration> + <skipTests>true</skipTests> + </configuration> + </plugin> + </plugins> + </build> + </profile> + </profiles> +</project> diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java index 0514c259..64481d68 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/AuthStatusManagerTests.java @@ -55,7 +55,7 @@ void testSignInConfirm() throws InterruptedException, ExecutionException { when(mockResult.getUser()).thenReturn(mockedUser); when(mockResult.getStatus()).thenReturn(CopilotStatusResult.OK); when(mockConnection.signInConfirm(userCode)).thenReturn(CompletableFuture.completedFuture(mockResult)); - when(mockConnection.checkQuota()).thenReturn(CompletableFuture.completedFuture(new CheckQuotaResult())); + when(mockConnection.checkQuota()).thenReturn(CompletableFuture.completedFuture(CheckQuotaResult.empty())); CopilotStatusResult result = authStatusManager.signInConfirm(userCode); diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java new file mode 100644 index 00000000..aee51a1f --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/CustomInstructionsChatLoadScopeTest.java @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; + +class CustomInstructionsChatLoadScopeTest { + + @ParameterizedTest + @EnumSource(CustomInstructionsChatLoadScope.class) + void testStringToEnumEntryConversion(CustomInstructionsChatLoadScope enumEntry) { + String inputValue = enumEntry.getValue(); + + CustomInstructionsChatLoadScope actualResult = CustomInstructionsChatLoadScope.fromValue(inputValue); + + assertEquals(enumEntry, actualResult); + } + + @ParameterizedTest + @ValueSource(strings = { "wrongValue" }) + @NullSource + void testStringToEnumEntryConversionThrowsExceptionForWrongValues(String value) { + assertThrows(IllegalArgumentException.class, () -> CustomInstructionsChatLoadScope.fromValue(value)); + } + +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java index c87449dc..eeeb756d 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/completion/FormatOptionProviderTests.java @@ -10,10 +10,15 @@ import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; import org.eclipse.lsp4j.FormattingOptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.junit.jupiter.MockitoExtension; import com.microsoft.copilot.eclipse.core.format.FormatOptionProvider; @@ -25,7 +30,9 @@ class FormatOptionProviderTests { private IFile mockFile; private IProject mockProject; - private static final int PREFERENCE_DEFAULT_TAB_SIZE = 4; + private static final String EDITOR_PREF_NODE = "org.eclipse.ui.editors"; + private static final String TAB_WIDTH_KEY = "tabWidth"; + private static final String SPACES_FOR_TABS_KEY = "spacesForTabs"; @BeforeEach void setUp() { @@ -52,20 +59,20 @@ void testGetEclipseDefaultJavaTabCharAndSize() { assertEquals(tabSize, formatOptionProvider.getTabSize(mockFile)); } - @Test - void testGetCopilotDefaultTabCharAndSizeForUnknownLanguage() { - when(mockFile.getFileExtension()).thenReturn("js"); - - assertTrue(formatOptionProvider.useSpace(mockFile)); - assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); - } + @ParameterizedTest @NullSource @ValueSource(strings = { "js" }) + void testUsesEclipseTextEditorFormattingOptionsForUnknownOrNoExtension(String extension) { + when(mockFile.getFileExtension()).thenReturn(extension); - @Test - void testGetCopilotDefaultTabCharAndSizeForNoExtensionFile() { - when(mockFile.getFileExtension()).thenReturn(null); + // Set the Eclipse preferences to be something other than the default (false, 4) + setEditorFormattingPreferences(true, 2); assertTrue(formatOptionProvider.useSpace(mockFile)); - assertEquals(PREFERENCE_DEFAULT_TAB_SIZE, formatOptionProvider.getTabSize(mockFile)); + assertEquals(2, formatOptionProvider.getTabSize(mockFile)); } + private void setEditorFormattingPreferences(boolean useSpaces, int tabSize) { + IEclipsePreferences prefs = InstanceScope.INSTANCE.getNode(EDITOR_PREF_NODE); + prefs.putBoolean(SPACES_FOR_TABS_KEY, useSpaces); + prefs.putInt(TAB_WIDTH_KEY, tabSize); + } } diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 0ebad63b..52e9f3c7 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -131,4 +131,4 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { verify(mockFeatureFlags).setByokEnabled(true); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java new file mode 100644 index 00000000..37f7b85d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModelTests.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; + +/** + * Tests for {@link CopilotModel}, focusing on the {@code customModel} metadata that carries organization- and + * enterprise-contributed custom (BYOK) models exposed through {@code copilot/models}. + */ +class CopilotModelTests { + + private final Gson gson = new Gson(); + + @Test + void testDeserialize_populatesCustomModelMetadata() { + String json = """ + { + "modelFamily": "custom", + "modelName": "Sonnet (Org)", + "id": "claude-sonnet-org", + "scopes": ["chat-panel", "agent-panel"], + "customModel": { + "keyName": "Contoso Azure Key", + "ownerName": "Contoso", + "ownerType": "organization", + "provider": "azure" + } + } + """; + + CopilotModel model = gson.fromJson(json, CopilotModel.class); + + assertNotNull(model.getCustomModel()); + assertEquals("Contoso Azure Key", model.getCustomModel().keyName()); + assertEquals("Contoso", model.getCustomModel().ownerName()); + assertEquals("organization", model.getCustomModel().ownerType()); + assertEquals("azure", model.getCustomModel().provider()); + } + + @Test + void testDeserialize_customModelAbsentIsNull() { + String json = """ + { + "modelFamily": "gpt-4o", + "modelName": "GPT-4o", + "id": "gpt-4o", + "scopes": ["chat-panel"] + } + """; + + CopilotModel model = gson.fromJson(json, CopilotModel.class); + + assertNull(model.getCustomModel()); + } + + @Test + void testEqualsAndHashCode_accountForCustomModel() { + CopilotModel base = new CopilotModel(); + base.setId("claude-sonnet-org"); + base.setModelName("Sonnet (Org)"); + base.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure")); + + CopilotModel same = new CopilotModel(); + same.setId("claude-sonnet-org"); + same.setModelName("Sonnet (Org)"); + same.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure")); + + CopilotModel differentOwner = new CopilotModel(); + differentOwner.setId("claude-sonnet-org"); + differentOwner.setModelName("Sonnet (Org)"); + differentOwner.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Fabrikam", "organization", + "azure")); + + assertEquals(base, same); + assertEquals(base.hashCode(), same.hashCode()); + assertNotEquals(base, differentOwner); + } +} diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManagerTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManagerTests.java index f7b60937..ff213bcc 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManagerTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManagerTests.java @@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -35,10 +36,17 @@ import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.logger.CopilotForEclipseLogger; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepStatus; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; @ExtendWith(MockitoExtension.class) @@ -187,7 +195,7 @@ void testCacheConversationProgress_Success() throws Exception { when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); - CompletableFuture<Void> result = persistenceManager.cacheConversationProgress(conversationId, progress); + CompletableFuture<Void> result = persistenceManager.cacheConversationProgress(conversationId, progress, null); result.get(); // Wait for completion @@ -198,6 +206,98 @@ void testCacheConversationProgress_Success() throws Exception { assertTrue(cache.containsKey(conversationId)); } + @Test + void testUpdateThinkingBlockTitle_updatesCachedThinkingBlockById() throws Exception { + ConversationPersistenceManager manager = createManagerWithRealDataFactory(); + String conversationId = "00000000-0000-0000-0000-000000000000"; + String turnId = "00000000-0000-0000-0000-000000000002"; + String thinkingBlockId = "thinking-block-1"; + ConversationData conversationData = createTestConversationData(conversationId); + when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); + + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "thinking content"), thinkingBlockId).get(); + manager.updateThinkingBlockTitle(conversationId, turnId, thinkingBlockId, "Generated title").get(); + + ThinkingBlockData block = getCachedCopilotTurn(manager, conversationId, turnId).getReply() + .getEditAgentRounds().get(0).getThinkingBlock(); + assertNotNull(block); + assertEquals(thinkingBlockId, block.getId()); + assertEquals("Generated title", block.getTitle()); + } + + @Test + void testCancelThinkingBlock_updatesCachedThinkingBlockById() throws Exception { + ConversationPersistenceManager manager = createManagerWithRealDataFactory(); + String conversationId = "00000000-0000-0000-0000-000000000000"; + String turnId = "00000000-0000-0000-0000-000000000002"; + String thinkingBlockId = "thinking-block-1"; + ConversationData conversationData = createTestConversationData(conversationId); + when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); + + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "thinking content"), thinkingBlockId).get(); + manager.cancelThinkingBlock(conversationId, turnId, thinkingBlockId).get(); + + ThinkingBlockData block = getCachedCopilotTurn(manager, conversationId, turnId).getReply() + .getEditAgentRounds().get(0).getThinkingBlock(); + assertNotNull(block); + assertEquals(thinkingBlockId, block.getId()); + assertEquals(ThinkingBlockState.CANCELLED, block.getState()); + } + + @Test + void testCacheConversationProgress_withThinkingBlockId_updatesMatchingPlaceholderRound() throws Exception { + ConversationPersistenceManager manager = createManagerWithRealDataFactory(); + String conversationId = "00000000-0000-0000-0000-000000000000"; + String turnId = "00000000-0000-0000-0000-000000000002"; + String firstThinkingBlockId = "thinking-block-1"; + String secondThinkingBlockId = "thinking-block-2"; + ConversationData conversationData = createTestConversationData(conversationId); + when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); + + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "first"), firstThinkingBlockId).get(); + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "second"), secondThinkingBlockId).get(); + manager.cacheConversationProgress(conversationId, + createAgentRoundProgressValue(conversationId, turnId, 1, "round reply"), secondThinkingBlockId).get(); + + ReplyData reply = getCachedCopilotTurn(manager, conversationId, turnId).getReply(); + List<EditAgentRoundData> rounds = reply.getEditAgentRounds(); + assertEquals(2, rounds.size()); + EditAgentRoundData updatedRound = rounds.stream() + .filter(round -> round.getRoundId() == 1) + .findFirst() + .orElseThrow(); + assertEquals(secondThinkingBlockId, updatedRound.getThinkingBlock().getId()); + assertEquals("round reply", updatedRound.getReply()); + assertTrue(rounds.stream() + .anyMatch(round -> firstThinkingBlockId.equals(round.getThinkingBlock().getId()))); + } + + @Test + void testCacheConversationProgress_preservesWhitespaceOnlyThinkingFragments() throws Exception { + ConversationPersistenceManager manager = createManagerWithRealDataFactory(); + String conversationId = "00000000-0000-0000-0000-000000000000"; + String turnId = "00000000-0000-0000-0000-000000000002"; + String thinkingBlockId = "thinking-block-1"; + ConversationData conversationData = createTestConversationData(conversationId); + when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); + + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "before title."), thinkingBlockId).get(); + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "\n"), thinkingBlockId).get(); + manager.cacheConversationProgress(conversationId, + createThinkingProgressValue(conversationId, turnId, "**Next title**\n\nbody"), thinkingBlockId).get(); + + ThinkingBlockData block = getCachedCopilotTurn(manager, conversationId, turnId).getReply() + .getEditAgentRounds().get(0).getThinkingBlock(); + assertNotNull(block); + assertEquals("before title.\n**Next title**\n\nbody", block.getContent()); + } + @Test void testPersistConversationProgress_Success() throws Exception { String conversationId = "00000000-0000-0000-0000-000000000000"; @@ -206,13 +306,55 @@ void testPersistConversationProgress_Success() throws Exception { when(mockPersistenceService.loadConversationFromPersistedJsonFile(conversationId)).thenReturn(conversationData); - CompletableFuture<Void> result = persistenceManager.persistConversationProgress(conversationId, progress); + CompletableFuture<Void> result = persistenceManager.persistConversationProgress(conversationId, progress, null); result.get(); // Wait for completion verify(mockPersistenceService).saveConversation(any(ConversationData.class)); } + @Test + void testMarkRunningToolCallsCancelledAndPersist_UpdatesOnlyRunningToolCalls() throws Exception { + String conversationId = "00000000-0000-0000-0000-000000000000"; + ConversationData conversationData = createTestConversationData(conversationId); + CopilotTurnData copilotTurnData = (CopilotTurnData) conversationData.getTurns().get(1); + ToolCallData runningToolCall = createTestToolCallData("tool-1", ChatStepStatus.RUNNING); + ToolCallData completedToolCall = createTestToolCallData("tool-2", ChatStepStatus.COMPLETED); + EditAgentRoundData roundData = new EditAgentRoundData(); + roundData.setRoundId(1); + roundData.setToolCalls(List.of(runningToolCall, completedToolCall)); + copilotTurnData.getReply().setEditAgentRounds(List.of(roundData)); + + Map<String, ConversationData> cache = getConversationCache(); + cache.put(conversationId, conversationData); + + persistenceManager.markRunningToolCallsCancelledAndPersist(conversationId).get(); + + assertEquals(ChatStepStatus.CANCELLED, runningToolCall.getStatus()); + assertEquals(ChatStepStatus.COMPLETED, completedToolCall.getStatus()); + verify(mockPersistenceService).saveConversation(conversationData); + } + + @Test + void testMarkRunningToolCallsCancelledAndPersist_PersistsWhenNoRunningToolCalls() throws Exception { + String conversationId = "00000000-0000-0000-0000-000000000000"; + ConversationData conversationData = createTestConversationData(conversationId); + CopilotTurnData copilotTurnData = (CopilotTurnData) conversationData.getTurns().get(1); + ToolCallData completedToolCall = createTestToolCallData("tool-1", ChatStepStatus.COMPLETED); + EditAgentRoundData roundData = new EditAgentRoundData(); + roundData.setRoundId(1); + roundData.setToolCalls(List.of(completedToolCall)); + copilotTurnData.getReply().setEditAgentRounds(List.of(roundData)); + + Map<String, ConversationData> cache = getConversationCache(); + cache.put(conversationId, conversationData); + + persistenceManager.markRunningToolCallsCancelledAndPersist(conversationId).get(); + + assertEquals(ChatStepStatus.COMPLETED, completedToolCall.getStatus()); + verify(mockPersistenceService).saveConversation(conversationData); + } + @Test void testUpdateConversationProgress_NewConversation() throws Exception { String conversationId = "00000000-0000-0000-0000-000000000001"; @@ -229,7 +371,7 @@ void testUpdateConversationProgress_NewConversation() throws Exception { assertNotNull(result); assertEquals(conversationId, result.getConversationId()); verify(mockDataFactory).updateConversationMetadata(newConversationData, progress); - verify(mockDataFactory).updateReplyFromProgress(any(), eq(progress)); + verify(mockDataFactory).updateReplyFromProgress(any(), eq(progress), isNull()); } // Helper methods to create test data @@ -294,4 +436,65 @@ private ChatProgressValue createTestChatProgressValue() { progress.setSuggestedTitle("Test Suggested Title"); return progress; } + + private ConversationPersistenceManager createManagerWithRealDataFactory() throws Exception { + ConversationPersistenceManager manager = new ConversationPersistenceManager(mockAuthStatusManager); + setPrivateField(manager, "persistenceService", mockPersistenceService); + return manager; + } + + private CopilotTurnData getCachedCopilotTurn(ConversationPersistenceManager manager, String conversationId, + String turnId) throws Exception { + ConversationData conversationData = manager.loadConversation(conversationId).get(); + assertNotNull(conversationData); + return conversationData.getTurns().stream() + .filter(CopilotTurnData.class::isInstance) + .map(CopilotTurnData.class::cast) + .filter(turn -> turnId.equals(turn.getTurnId())) + .findFirst() + .orElseThrow(); + } + + private ChatProgressValue createThinkingProgressValue(String conversationId, String turnId, String thinkingText) { + ChatProgressValue progress = new ChatProgressValue(); + progress.setKind(WorkDoneProgressKind.report); + progress.setConversationId(conversationId); + progress.setTurnId(turnId); + progress.setThinking(new Thinking("server-thinking-id", thinkingText, null)); + return progress; + } + + private ChatProgressValue createAgentRoundProgressValue(String conversationId, String turnId, int roundId, + String reply) throws Exception { + ChatProgressValue progress = new ChatProgressValue(); + progress.setKind(WorkDoneProgressKind.report); + progress.setConversationId(conversationId); + progress.setTurnId(turnId); + AgentRound round = new AgentRound(); + setPrivateField(round, "roundId", roundId); + setPrivateField(round, "reply", reply); + setPrivateField(progress, "editAgentRounds", List.of(round)); + return progress; + } + + private void setPrivateField(Object target, String fieldName, Object value) throws Exception { + var field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private ToolCallData createTestToolCallData(String id, String status) { + ToolCallData toolCallData = new ToolCallData(); + toolCallData.setId(id); + toolCallData.setName("run_in_terminal"); + toolCallData.setProgressMessage("Running command"); + toolCallData.setStatus(status); + return toolCallData; + } + + private Map<String, ConversationData> getConversationCache() throws Exception { + var cacheField = ConversationPersistenceManager.class.getDeclaredField("conversationCache"); + cacheField.setAccessible(true); + return (Map<String, ConversationData>) cacheField.get(persistenceManager); + } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java new file mode 100644 index 00000000..cbbf6d55 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/FileUtilsTests.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileUtilsTests { + + @Test + void testGetLocalFilePath_absolutePath_returnsNormalizedPath(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toString())); + } + + @Test + void testGetLocalFilePath_fileUriWithFragment_ignoresFragment(@TempDir Path tempDir) { + Path expected = tempDir.resolve("external-file.txt").toAbsolutePath().normalize(); + + assertEquals(expected, FileUtils.getLocalFilePath(expected.toUri() + "#L10")); + } + + @Test + void testGetLocalFilePath_relativePath_returnsNull() { + assertNull(FileUtils.getLocalFilePath("src/main/java/File.java")); + } + + @Test + void testGetLocalFilePath_nonFileUri_returnsNull() { + assertNull(FileUtils.getLocalFilePath("https://example.com/file.java")); + } +} diff --git a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF index 05f7823c..9b5eb2d0 100644 --- a/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.core/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.core Bundle-SymbolicName: com.microsoft.copilot.eclipse.core;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.core, com.microsoft.copilot.eclipse.core.chat, @@ -43,6 +43,7 @@ Require-Bundle: org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.wildwebdeveloper.embedder.node;bundle-version="1.0.3";resolution:=optional, org.eclipse.core.net;bundle-version="1.5.200", org.eclipse.core.resources;bundle-version="3.20.0", + org.eclipse.core.filesystem;bundle-version="1.10.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.apache.httpcomponents.client5.httpclient5;bundle-version="5.2.1", org.apache.httpcomponents.core5.httpcore5;bundle-version="5.2.3", diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json index 76d8060f..eda43152 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package-lock.json @@ -9,18 +9,18 @@ "version": "0.0.1", "hasInstallScript": true, "dependencies": { - "@github/copilot-language-server": "1.474.0", - "@github/copilot-language-server-darwin-arm64": "1.474.0", - "@github/copilot-language-server-darwin-x64": "1.474.0", - "@github/copilot-language-server-linux-arm64": "1.474.0", - "@github/copilot-language-server-linux-x64": "1.474.0", - "@github/copilot-language-server-win32-x64": "1.474.0" + "@github/copilot-language-server": "1.509.5", + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5" } }, "node_modules/@github/copilot-language-server": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.474.0.tgz", - "integrity": "sha512-vcYgSypghHYflAyFzMkzciV4FtYSW/2JmKS0LQUS92K/wBJrmC/H19Od02ye8gLG625BayKla9fHvVfiXSs/2A==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server/-/copilot-language-server-1.509.5.tgz", + "integrity": "sha512-cAZVbN5UHrm+ILqroO2NY2gbI7mYojwmiOClCWj8yPD0svXJKSVmmmDonkVrvNZ1VxW3tIbCbE1LRTL6HVN/9g==", "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "^3.17.5" @@ -29,18 +29,18 @@ "copilot-language-server": "dist/language-server.js" }, "optionalDependencies": { - "@github/copilot-language-server-darwin-arm64": "1.474.0", - "@github/copilot-language-server-darwin-x64": "1.474.0", - "@github/copilot-language-server-linux-arm64": "1.474.0", - "@github/copilot-language-server-linux-x64": "1.474.0", - "@github/copilot-language-server-win32-arm64": "1.474.0", - "@github/copilot-language-server-win32-x64": "1.474.0" + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-win32-arm64": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5" } }, "node_modules/@github/copilot-language-server-darwin-arm64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.474.0.tgz", - "integrity": "sha512-hoY6MXQEqk6tKn9F9IxTGFm2o9IqbuzCH7TvVsg3tCCCIynu5HrnUUXRjvMiQg3nOiX6qM94jePbg8tumI8vFw==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-arm64/-/copilot-language-server-darwin-arm64-1.509.5.tgz", + "integrity": "sha512-eGb5bHN1OVUEnQYCop1+Hd4z6CR2fZK0iqFRu0pdGeQ3/y370trt2KR+omJv354ibsCUmpDUIqZXb6hOiIZk/Q==", "cpu": [ "arm64" ], @@ -50,9 +50,9 @@ ] }, "node_modules/@github/copilot-language-server-darwin-x64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.474.0.tgz", - "integrity": "sha512-AWYiYQyo8CdBkObaYdcFGrwJQtm9zp6g+HoB0DT1uKxJ1J/Lc60BaHe4wM+06PqbFNUmIS7GJ1pmIXsGFVcgSg==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-darwin-x64/-/copilot-language-server-darwin-x64-1.509.5.tgz", + "integrity": "sha512-EonG1QHCx7qmiPc0EsVYSk9oBeWtwHXw0h3fMFOWKN37HOKImMiDeC7JBjv1kG/q9XzHGGhkYjkvReebMKZoOg==", "cpu": [ "x64" ], @@ -62,9 +62,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-arm64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.474.0.tgz", - "integrity": "sha512-9r2IF/Xo2WImvlEMF9L50VbEtXeKajgJFh56DR8yf9B2bQJ62Eb1DEsOQpsc525O5uFgiC1xjNRSkQbsLZCA5g==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-arm64/-/copilot-language-server-linux-arm64-1.509.5.tgz", + "integrity": "sha512-f1SYaxh8drXeZ2Sf5A/gjFC5gg0qnUdwYUWXA2vpJrMZ2bMbylacGS9hwhkwk9jDaHkeOmjOWWh+g2csdLgVTw==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ ] }, "node_modules/@github/copilot-language-server-linux-x64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.474.0.tgz", - "integrity": "sha512-3Kc0Nc3glu8z7Rjgkquk5F1BdaKUJ8/wyzRpT5ejEmzLtLKdGX3/csew5N8BROXXVZqc9Vv6Uc1GYUe6AsXGIw==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-linux-x64/-/copilot-language-server-linux-x64-1.509.5.tgz", + "integrity": "sha512-T0ihY6w2H7sdKMaF0k7pHBQV3wTj4Uzq38kx8pGKAFTupUIgivMUHOWysiLdsBgQ9rafByIMzHR3slnpYcH1SQ==", "cpu": [ "x64" ], @@ -86,9 +86,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-arm64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.474.0.tgz", - "integrity": "sha512-swVdlBpBBLLDk4w+cIy5ELn2Wz8EaHR8qjnLCaBi8frXA8yKGpqnud3ELcckKN3THfNa3CwT7lnLcHxgJUcGtQ==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-arm64/-/copilot-language-server-win32-arm64-1.509.5.tgz", + "integrity": "sha512-T4fyhZIJCU+ETyp3vcIw0Y+sIvcsnq8C59s2l7RR9jX6lwHacwsU2PUcRYQqETfWOkM5YX7wzKiThLxvsOAc9g==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ ] }, "node_modules/@github/copilot-language-server-win32-x64": { - "version": "1.474.0", - "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.474.0.tgz", - "integrity": "sha512-QqMthwUQNeVj3S3lA1q2lSvoB1I9eLPRN1fDk+I+C35FpiO950xQ2/Zf7tOXeJ3Miuo0cx+a4EAlN6RQPxnCMg==", + "version": "1.509.5", + "resolved": "https://registry.npmjs.org/@github/copilot-language-server-win32-x64/-/copilot-language-server-win32-x64-1.509.5.tgz", + "integrity": "sha512-tv12/MayELMseV7V+ghdC/AY5GgKh/lyncAU/A7eEpPt8ACNEErBZ51mriAT1F/EqSfB1iEUKVtgZ4/+eTe9Fw==", "cpu": [ "x64" ], diff --git a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json index 2afae347..d6c12fb7 100644 --- a/com.microsoft.copilot.eclipse.core/copilot-agent/package.json +++ b/com.microsoft.copilot.eclipse.core/copilot-agent/package.json @@ -12,11 +12,11 @@ "postinstall": "node copy-binaries.js" }, "dependencies": { - "@github/copilot-language-server": "1.474.0", - "@github/copilot-language-server-win32-x64": "1.474.0", - "@github/copilot-language-server-darwin-x64": "1.474.0", - "@github/copilot-language-server-darwin-arm64": "1.474.0", - "@github/copilot-language-server-linux-x64": "1.474.0", - "@github/copilot-language-server-linux-arm64": "1.474.0" + "@github/copilot-language-server": "1.509.5", + "@github/copilot-language-server-win32-x64": "1.509.5", + "@github/copilot-language-server-darwin-x64": "1.509.5", + "@github/copilot-language-server-darwin-arm64": "1.509.5", + "@github/copilot-language-server-linux-x64": "1.509.5", + "@github/copilot-language-server-linux-arm64": "1.509.5" } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java index b6e598c8..21284a15 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/AuthStatusManager.java @@ -182,7 +182,7 @@ public void setQuotaStatus(CheckQuotaResult checkQuotaResult) { */ public CheckQuotaResult getQuotaStatus() { if (this.checkQuotaResult == null) { - this.checkQuotaResult = new CheckQuotaResult(); + this.checkQuotaResult = CheckQuotaResult.empty(); } return this.checkQuotaResult; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 7e2ae6ca..176bc245 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -28,6 +28,8 @@ private Constants() { public static final String WORKSPACE_CONTEXT_ENABLED = "workspaceContextEnabled"; public static final String SUB_AGENT_ENABLED = "subAgentEnabled"; public static final String AGENT_MAX_REQUESTS = "agentMaxRequests"; + public static final String ENABLE_SKILLS = "enableSkills"; + public static final String TRANSCRIPT_SUBDIR = ".copilot/eclipse"; public static final String MCP = "mcp"; public static final String MCP_REGISTRY_URL = "mcpRegistryUrl"; public static final String MCP_REGISTRY_VERSION = "v0.1"; @@ -37,6 +39,9 @@ private Constants() { public static final String CUSTOM_INSTRUCTIONS_WORKSPACE = "customInstructionsWorkspace"; public static final String CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED = "customInstructionsWorkspaceEnabled"; public static final String CUSTOM_INSTRUCTIONS_GIT_COMMIT = "customInstructionsGitCommit"; + public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE = "customInstructionsChatLoadScope"; + public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL = "allProjects"; + public static final String CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED = "referencedProjects"; public static final String GITHUB_COPILOT_URL = "http://github.com"; @Deprecated public static final String QUICK_START_VERSION = "quickStartVersion"; @@ -49,6 +54,16 @@ private Constants() { public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView"; public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog"; + // Auto-Approve settings + public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; + public static final String AUTO_APPROVE_UNMATCHED_TERMINAL = "autoApproveUnmatchedTerminal"; + public static final String AUTO_APPROVE_FILE_OP_RULES = "autoApproveEditRules"; + public static final String AUTO_APPROVE_UNMATCHED_FILE_OP = "autoApproveUnmatchedFileOp"; + public static final String AUTO_APPROVE_MCP_SERVERS = "autoApproveMcpServers"; + public static final String AUTO_APPROVE_MCP_TOOLS = "autoApproveMcpTools"; + public static final String AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS = "autoApproveTrustToolAnnotations"; + public static final String AUTO_APPROVE_YOLO_MODE = "autoApproveYoloMode"; + // Base excluded file types shared by both // Copied from InelliJ, excluded file extension list // https://github.com/microsoft/copilot-intellij/blob/main/core/src/main/kotlin/com/github/copilot/chat/references/FileSearchService.kt diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java index 1e9a6bf0..f54e0bfa 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/FeatureFlags.java @@ -25,6 +25,10 @@ public class FeatureFlags { private boolean customAgentPolicyEnabled = true; + private boolean autoApprovalTokenEnabled = true; + + private boolean autoApprovalPolicyEnabled = true; + public boolean isAgentModeEnabled() { return agentModeEnabled; } @@ -84,6 +88,25 @@ public void setCustomAgentPolicyEnabled(boolean customAgentPolicyEnabled) { this.customAgentPolicyEnabled = customAgentPolicyEnabled; } + /** + * Returns true if the auto-approval feature is available. + * Requires both the server token ({@code agent_mode_auto_approval}) and + * the organization policy ({@code agentMode.autoApproval.enabled}) to permit it. + * + * @return true if auto-approval is permitted + */ + public boolean isAutoApprovalEnabled() { + return autoApprovalTokenEnabled && autoApprovalPolicyEnabled; + } + + public void setAutoApprovalTokenEnabled(boolean autoApprovalTokenEnabled) { + this.autoApprovalTokenEnabled = autoApprovalTokenEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + public boolean isClientPreviewFeatureEnabled() { return clientPreviewFeatureEnabled; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java new file mode 100644 index 00000000..07f10d92 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationAction.java @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Represents a button action in the confirmation UI. Each action has a label, + * a decision (accept or dismiss), a persistence scope, and optional metadata + * for the handler to know what to persist. + */ +public class ConfirmationAction { + + /** Metadata key for the action type enum name. */ + public static final String META_ACTION = "action"; + + private final String label; + private final boolean accept; + private final ConfirmationActionScope scope; + private final Map<String, String> metadata; + private final boolean primary; + + /** + * Creates a new confirmation action. + * + * @param label the button label + * @param accept true for accept, false for dismiss + * @param scope the persistence scope (null for dismiss actions) + * @param metadata extra data for the handler (e.g., command names, server name) + * @param primary whether this is the primary/default button + */ + public ConfirmationAction(String label, boolean accept, + ConfirmationActionScope scope, Map<String, String> metadata, + boolean primary) { + this.label = label; + this.accept = accept; + this.scope = scope; + this.metadata = metadata != null ? metadata : Map.of(); + this.primary = primary; + } + + public String getLabel() { + return label; + } + + public boolean isAccept() { + return accept; + } + + public ConfirmationActionScope getScope() { + return scope; + } + + public Map<String, String> getMetadata() { + return metadata; + } + + public boolean isPrimary() { + return primary; + } + + /** Creates a primary accept action (scope = ONCE). */ + public static ConfirmationAction allowOnce(String label) { + return new ConfirmationAction(label, true, + ConfirmationActionScope.ONCE, null, true); + } + + /** Creates a dismiss action. */ + public static ConfirmationAction skip(String label) { + return new ConfirmationAction(label, false, null, null, false); + } + + @Override + public int hashCode() { + return Objects.hash(accept, label, metadata, primary, scope); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationAction other = (ConfirmationAction) obj; + return accept == other.accept + && Objects.equals(label, other.label) + && Objects.equals(metadata, other.metadata) + && primary == other.primary && scope == other.scope; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("label", label) + .append("accept", accept) + .append("scope", scope) + .append("metadata", metadata) + .append("primary", primary) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java new file mode 100644 index 00000000..37fc6d88 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationActionScope.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +/** + * Scope of how a confirmation decision should be persisted. + */ +public enum ConfirmationActionScope { + /** One-time acceptance for this single invocation. */ + ONCE, + /** Remember for the current conversation session (in-memory). */ + SESSION, + /** Remember globally (application-level persistent, synced to CLS). */ + GLOBAL +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java new file mode 100644 index 00000000..abc588fc --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationContent.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Complete content for rendering a confirmation UI. Returned by handlers when a tool call + * needs user confirmation. Contains the display text and the list of action buttons. + */ +public class ConfirmationContent { + + private final String title; + private final String message; + private final List<ConfirmationAction> actions; + + /** + * Creates a new confirmation content. + * + * @param title bold title text displayed at the top + * @param message description text (may be null) + * @param actions list of button actions for the confirmation UI + */ + public ConfirmationContent(String title, String message, + List<ConfirmationAction> actions) { + this.title = title; + this.message = message; + this.actions = actions; + } + + public String getTitle() { + return title; + } + + public String getMessage() { + return message; + } + + public List<ConfirmationAction> getActions() { + return actions; + } + + @Override + public int hashCode() { + return Objects.hash(actions, message, title); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationContent other = (ConfirmationContent) obj; + return Objects.equals(actions, other.actions) + && Objects.equals(message, other.message) + && Objects.equals(title, other.title); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("title", title) + .append("message", message) + .append("actions", actions) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java new file mode 100644 index 00000000..b8553435 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationHandler.java @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** + * Evaluates whether the given confirmation request should be auto-approved. + * + * @param params the confirmation request parameters from CLS + * @return ConfirmationResult.AUTO_APPROVED if the tool call can proceed without user + * confirmation, or ConfirmationResult.NEEDS_CONFIRMATION if the user must approve + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params); +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java new file mode 100644 index 00000000..a496ab10 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/ConfirmationResult.java @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of evaluating an auto-approve confirmation request. + * Either AUTO_APPROVED (no UI needed) or NEEDS_CONFIRMATION with content for the dialog. + */ +public class ConfirmationResult { + + /** Auto-approved, no user confirmation needed. */ + public static final ConfirmationResult AUTO_APPROVED = new ConfirmationResult(true, false, null); + + /** Dismissed — malformed or unhandleable request; CLS should be told to skip the tool. */ + public static final ConfirmationResult DISMISSED = new ConfirmationResult(false, true, null); + + private final boolean autoApproved; + private final boolean dismissed; + private final ConfirmationContent content; + + private ConfirmationResult(boolean autoApproved, boolean dismissed, ConfirmationContent content) { + this.autoApproved = autoApproved; + this.dismissed = dismissed; + this.content = content; + } + + /** Creates a result that requires user confirmation with the given content. */ + public static ConfirmationResult needsConfirmation( + ConfirmationContent content) { + return new ConfirmationResult(false, false, content); + } + + public boolean isAutoApproved() { + return autoApproved; + } + + /** Returns true if the request should be dismissed without showing UI. */ + public boolean isDismissed() { + return dismissed; + } + + /** Returns the confirmation content, or null if auto-approved or using defaults. */ + public ConfirmationContent getContent() { + return content; + } + + @Override + public int hashCode() { + return Objects.hash(autoApproved, dismissed, content); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ConfirmationResult other = (ConfirmationResult) obj; + return autoApproved == other.autoApproved + && dismissed == other.dismissed + && Objects.equals(content, other.content); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApproved", autoApproved) + .append("dismissed", dismissed) + .append("content", content) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java new file mode 100644 index 00000000..4ca1cff3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/CustomInstructionsChatLoadScope.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Scope loading modes for custom instructions in GitHub Copilot chat. + * <ul> + * <li><b>ALL_PROJECTS</b>: Load custom instructions from all projects in the Eclipse workspace + * <li><b>REFERENCED_PROJECTS</b>: Load custom instructions only from parent projects of files/folders referenced in the + * Copilot chat + * </ul> + */ +public enum CustomInstructionsChatLoadScope { + ALL_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_ALL), + REFERENCED_PROJECTS(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE_REFERENCED); + + public static final CustomInstructionsChatLoadScope DEFAULT_VALUE = CustomInstructionsChatLoadScope.ALL_PROJECTS; + + private final String value; + + CustomInstructionsChatLoadScope(String value) { + this.value = value; + } + + /** + * Returns the string value representing this enum entry for preference serialization. + * + * @return the string value for this preference setting + */ + public String getValue() { + return value; + } + + /** + * Retrieves the enum constant corresponding to the given string value if available, otherwise an + * {@link IllegalArgumentException} is thrown. + * + * @param value the string value (preference) representing an enum entry + * @return the enum entry representing the given value + * @throws IllegalArgumentException if the value does not correspond to any enum entry + */ + public static CustomInstructionsChatLoadScope fromValue(String value) { + for (CustomInstructionsChatLoadScope scope : values()) { + if (scope.getValue().equals(value)) { + return scope; + } + } + throw new IllegalArgumentException("Unknown CustomInstructionsLoadScope value: " + value); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java new file mode 100644 index 00000000..9f5c819d --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/FileOperationAutoApproveRule.java @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single file-operation auto-approve rule mapping a glob pattern to an allow/deny decision. + */ +public class FileOperationAutoApproveRule { + private String pattern; + private String description; + private boolean autoApprove; + private transient boolean isDefault; + + /** + * Creates a new rule. + * + * @param pattern the glob pattern (e.g., "**\/.github/instructions/*") + * @param description human-readable description of what this pattern matches + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public FileOperationAutoApproveRule(String pattern, String description, boolean autoApprove) { + this(pattern, description, autoApprove, false); + } + + /** + * Creates a new rule. + * + * @param pattern the glob pattern + * @param description human-readable description + * @param autoApprove true to auto-approve, false to always require confirmation + * @param isDefault true if this is a CLS default rule (non-removable) + */ + public FileOperationAutoApproveRule(String pattern, String description, + boolean autoApprove, boolean isDefault) { + this.pattern = pattern; + this.description = description; + this.autoApprove = autoApprove; + this.isDefault = isDefault; + } + + /** Default constructor for Gson deserialization. */ + public FileOperationAutoApproveRule() { + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + @Override + public int hashCode() { + return Objects.hash(pattern, description, autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileOperationAutoApproveRule other = (FileOperationAutoApproveRule) obj; + return Objects.equals(pattern, other.pattern) + && Objects.equals(description, other.description) + && autoApprove == other.autoApprove; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("description", description) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java new file mode 100644 index 00000000..b70a2797 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/TerminalAutoApproveRule.java @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A single terminal auto-approve rule mapping a command name or regex pattern to an + * allow/deny decision. + */ +public class TerminalAutoApproveRule { + private String command; + private boolean autoApprove; + + /** + * Creates a new rule. + * + * @param command the command name or regex pattern + * @param autoApprove true to auto-approve, false to always require confirmation + */ + public TerminalAutoApproveRule(String command, boolean autoApprove) { + this.command = command; + this.autoApprove = autoApprove; + } + + /** Default constructor for Gson deserialization. */ + public TerminalAutoApproveRule() { + } + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public boolean isAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(boolean autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove, command); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalAutoApproveRule other = (TerminalAutoApproveRule) obj; + return autoApprove == other.autoApprove + && Objects.equals(command, other.command); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("command", command) + .append("autoApprove", autoApprove) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java index 2be7f6d2..5398fec3 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java @@ -3,11 +3,14 @@ package com.microsoft.copilot.eclipse.core.chat; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import org.apache.commons.lang3.builder.ToStringBuilder; + /** * Preferences per GitHub user. All the getters and setters are synchronized due to that the ChatBaseService holds a * shared (single) reference to the user preference. synchronized modifies makes sure the update to the instance are @@ -19,6 +22,10 @@ public class UserPreference { private String chatModeName; private List<String> userInputs; private boolean skipGitHubJobConfirmDialog; + /** + * User-selected reasoning effort keyed by the composite model key (matching the {@link #chatModel} format). + */ + private volatile Map<String, String> reasoningEffortByModel = Map.of(); /** * Gets the id of the Chat model. @@ -73,9 +80,95 @@ public synchronized void setSkipGitHubJobConfirmDialog(boolean skipGitHubJobConf this.skipGitHubJobConfirmDialog = skipGitHubJobConfirmDialog; } + /** + * Returns the user-selected reasoning effort for the given model key, or {@code null} when the user has not made an + * explicit selection. + * + * @param modelKey the composite model key (matching the {@link #getChatModel()} format) + * @return the previously selected reasoning effort, or {@code null} + */ + public String getReasoningEffort(String modelKey) { + if (modelKey == null) { + return null; + } + return reasoningEffortByModel.get(modelKey); + } + + /** + * Returns an immutable snapshot of the model-key to reasoning-effort map. Useful as an observable value that + * changes by equality whenever any individual entry is added, updated, or removed. + * + * @return an immutable snapshot of the current reasoning-effort map (never {@code null}) + */ + public Map<String, String> getReasoningEffortSnapshot() { + return reasoningEffortByModel; + } + + /** + * Stores the user-selected reasoning effort for the given model key. Passing a {@code null} effort clears any + * previously stored value for that key. + * + * <p>The underlying map is replaced atomically with a fresh immutable snapshot via {@link Map#copyOf}, so concurrent + * readers (including Gson reflectively serializing this preference on a background thread) always observe either + * the old or the new snapshot, never a partially mutated map. + * + * @param modelKey the composite model key (matching the {@link #getChatModel()} format) + * @param reasoningEffort the reasoning effort to store, or {@code null} to clear + */ + public synchronized void setReasoningEffort(String modelKey, String reasoningEffort) { + if (modelKey == null) { + return; + } + Map<String, String> current = reasoningEffortByModel; + if (reasoningEffort == null) { + if (!current.containsKey(modelKey)) { + return; + } + Map<String, String> next = new HashMap<>(current); + next.remove(modelKey); + reasoningEffortByModel = Map.copyOf(next); + return; + } + if (reasoningEffort.equals(current.get(modelKey))) { + return; + } + Map<String, String> next = new HashMap<>(current); + next.put(modelKey, reasoningEffort); + reasoningEffortByModel = Map.copyOf(next); + } + + /** + * Atomically replaces the entire reasoning-effort map with the given snapshot, dropping any keys not present in + * {@code reasoningEffortsByModel}. {@code null} entries in the input map are ignored. Returns {@code true} when + * the new snapshot differs from the previous one (so callers can decide whether to persist or notify observers). + * + * @param reasoningEffortsByModel the new reasoning-effort map keyed by composite model key (matching the + * {@link #getChatModel()} format); may be {@code null} or empty to clear all entries + * @return {@code true} when the stored map changed, {@code false} otherwise + */ + public synchronized boolean setReasoningEfforts(Map<String, String> reasoningEffortsByModel) { + Map<String, String> updatedReasoningEfforts; + if (reasoningEffortsByModel == null || reasoningEffortsByModel.isEmpty()) { + updatedReasoningEfforts = Map.of(); + } else { + Map<String, String> copy = new HashMap<>(); + for (Map.Entry<String, String> entry : reasoningEffortsByModel.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), entry.getValue()); + } + } + updatedReasoningEfforts = Map.copyOf(copy); + } + if (updatedReasoningEfforts.equals(this.reasoningEffortByModel)) { + return false; + } + this.reasoningEffortByModel = updatedReasoningEfforts; + return true; + } + @Override public int hashCode() { - return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog); + return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog, reasoningEffortByModel); } @Override @@ -92,7 +185,8 @@ public boolean equals(Object obj) { UserPreference other = (UserPreference) obj; return Objects.equals(chatModeName, other.chatModeName) && Objects.equals(chatModel, other.chatModel) && Objects.equals(userInputs, other.userInputs) - && skipGitHubJobConfirmDialog == other.skipGitHubJobConfirmDialog; + && skipGitHubJobConfirmDialog == other.skipGitHubJobConfirmDialog + && Objects.equals(reasoningEffortByModel, other.reasoningEffortByModel); } @Override @@ -102,6 +196,7 @@ public String toString() { builder.append("chatModeName", chatModeName); builder.append("userInputs", userInputs); builder.append("skipGitHubJobConfirmDialog", skipGitHubJobConfirmDialog); + builder.append("reasoningEffortByModel", reasoningEffortByModel); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java new file mode 100644 index 00000000..c94c14f7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/CustomizationFileService.java @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat.service; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.e4.core.contexts.EclipseContextFactory; +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.osgi.framework.FrameworkUtil; +import org.osgi.service.event.EventHandler; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; + +/** + * Default {@link ICustomizationFileService} backed by the language server's {@code copilot/custom*} + * list requests. Each customization type is fetched and snapshotted independently. + */ +public class CustomizationFileService implements ICustomizationFileService { + + private final CopilotLanguageServerConnection lsConnection; + private final IEventBroker eventBroker; + private final EventHandler customizationFilesChangedHandler; + + private volatile Set<Path> promptFiles = Set.of(); + private volatile Set<Path> instructionFiles = Set.of(); + private volatile Set<Path> agentFiles = Set.of(); + private volatile Set<Path> skillFolders = Set.of(); + + private volatile Set<Path> customizationFiles = Set.of(); + + /** + * Creates the service and subscribes to customization-file change events. + * + * @param lsConnection the language server connection used to issue the list requests + */ + public CustomizationFileService(CopilotLanguageServerConnection lsConnection) { + this.lsConnection = lsConnection; + this.eventBroker = EclipseContextFactory + .getServiceContext(FrameworkUtil.getBundle(getClass()).getBundleContext()).get(IEventBroker.class); + this.customizationFilesChangedHandler = event -> { + if (event.getProperty(IEventBroker.DATA) instanceof CustomizationType type) { + CompletableFuture.runAsync(() -> refreshType(type, WorkspaceUtils.listWorkspaceFolders())); + } + }; + if (eventBroker != null) { + eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, + customizationFilesChangedHandler); + } + } + + /** Unsubscribes from customization-file change events. */ + public void dispose() { + if (eventBroker != null) { + eventBroker.unsubscribe(customizationFilesChangedHandler); + } + } + + @Override + public Set<Path> getCustomizationFiles() { + return customizationFiles; + } + + @Override + public Set<Path> getSkillFolders() { + return skillFolders; + } + + @Override + public void refreshAllAsync() { + CompletableFuture.runAsync(() -> { + List<WorkspaceFolder> workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); + for (CustomizationType type : CustomizationType.values()) { + refreshType(type, workspaceFolders); + } + }); + } + + private void refreshType(CustomizationType type, List<WorkspaceFolder> workspaceFolders) { + switch (type) { + case SKILL -> toPaths(lsConnection.listCustomSkills(workspaceFolders)).thenAccept(paths -> { + Set<Path> folders = new HashSet<>(); + for (Path skillFile : paths) { + Path parent = skillFile.getParent(); + if (parent != null) { + folders.add(parent); + } + } + this.skillFolders = Set.copyOf(folders); + }); + case PROMPT -> toPaths(lsConnection.listCustomPrompts(workspaceFolders)).thenAccept(paths -> { + this.promptFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + case INSTRUCTION -> toPaths(lsConnection.listCustomInstructions(workspaceFolders)).thenAccept(paths -> { + this.instructionFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + case AGENT -> toPaths(lsConnection.listCustomAgents(workspaceFolders)).thenAccept(paths -> { + this.agentFiles = Set.copyOf(paths); + rebuildCustomizationFiles(); + }); + default -> { + // No other customization types. + } + } + } + + private synchronized void rebuildCustomizationFiles() { + Set<Path> all = new HashSet<>(promptFiles); + all.addAll(instructionFiles); + all.addAll(agentFiles); + this.customizationFiles = Set.copyOf(all); + } + + private CompletableFuture<List<Path>> toPaths(CompletableFuture<CustomizationFileInfo[]> future) { + return future.thenApply(infos -> { + List<Path> paths = new ArrayList<>(); + if (infos != null) { + for (CustomizationFileInfo info : infos) { + Path path = FileUtils.getLocalFilePath(info.uri()); + if (path != null) { + paths.add(path.toAbsolutePath().normalize()); + } + } + } + return paths; + }).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to list customization files", ex); + return List.of(); + }); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java index 65aa911e..d6b0784f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/IChatServiceManager.java @@ -17,4 +17,9 @@ public interface IChatServiceManager { * Get the MCP config service. */ IMcpConfigService getMcpConfigService(); + + /** + * Get the customization file service tracking skill/prompt/instruction/agent file locations. + */ + ICustomizationFileService getCustomizationFileService(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java new file mode 100644 index 00000000..736df418 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/service/ICustomizationFileService.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat.service; + +import java.nio.file.Path; +import java.util.Set; + +/** + * Maintains an up-to-date view of the Copilot customization files (skills, prompts, instructions, + * and agents) reported by the language server, keyed by their on-disk location. + * + */ +public interface ICustomizationFileService { + + /** The customization kinds, each backed by a distinct language-server list request. */ + enum CustomizationType { + SKILL, PROMPT, INSTRUCTION, AGENT + } + + /** + * Returns the absolute paths of single-file customization files (prompts, instructions, agents). + * The returned set is an immutable snapshot. + */ + Set<Path> getCustomizationFiles(); + + /** + * Returns the absolute paths of skill folders (the directory containing each {@code SKILL.md}). + * A read of any file within one of these folders is a skill read. The returned set is an immutable + * snapshot. + */ + Set<Path> getSkillFolders(); + + /** + * Refreshes every customization type from the language server. Used for the initial load. + * Fire-and-forget; the work completes asynchronously off the caller's thread. + */ + void refreshAllAsync(); +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java index abd9f57f..1a2d3c9e 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java @@ -30,6 +30,11 @@ public class CopilotEventConstants { */ private static final String TOPIC_MCP = TOPIC_BASE + "MCP/"; + /** + * Topic for quota events. + */ + private static final String TOPIC_QUOTA = TOPIC_BASE + "QUOTA/"; + /** * Topic for Next Edit Suggestion (NES) events. */ @@ -146,6 +151,17 @@ public class CopilotEventConstants { */ public static final String TOPIC_MCP_SERVER_STATE_CHANGE = TOPIC_MCP + "SERVER_STATE_CHANGE"; + /** + * Event when the MCP registration extension-point manager has finished detecting and verifying + * the contributed servers (either at startup or after a policy toggle / user approval) and the + * approved-servers JSON for the language server is ready to be pushed. + * + * <p>Payload (via {@code IEventBroker.DATA}): the approved-servers JSON {@code String}, or + * {@code null} when no extension-contributed servers are approved. + */ + public static final String TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED = TOPIC_MCP + + "EXTENSION_POINT_REGISTRATION_COMPLETED"; + /** * Event when NES suggestion is accepted. */ @@ -160,4 +176,25 @@ public class CopilotEventConstants { * Event when a rate limit warning is received from the language server. */ public static final String TOPIC_RATE_LIMIT_WARNING = TOPIC_CHAT + "RATE_LIMIT_WARNING"; + + /** + * Event when a quota warning notification is received from the language server. + */ + public static final String TOPIC_QUOTA_WARNING = TOPIC_QUOTA + "WARNING"; + + /** + * Event when custom prompts, skills, agents, or instructions change on the language server. The event data is the + * {@link com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType} that changed. + */ + public static final String TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES = TOPIC_CHAT + "DID_CHANGE_CUSTOMIZATION_FILES"; + + /** + * Event when automatic conversation compression starts. + */ + public static final String TOPIC_CHAT_COMPRESSION_STARTED = TOPIC_CHAT + "COMPRESSION_STARTED"; + + /** + * Event when automatic conversation compression completes. + */ + public static final String TOPIC_CHAT_COMPRESSION_COMPLETED = TOPIC_CHAT + "COMPRESSION_COMPLETED"; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java index 8b06a164..b738929f 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/format/FormatOptionProvider.java @@ -9,6 +9,8 @@ import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.preferences.IPreferencesService; import org.eclipse.lsp4j.FormattingOptions; import com.microsoft.copilot.eclipse.core.CopilotCore; @@ -26,6 +28,9 @@ public class FormatOptionProvider { private static final String CPP_LANGUAGE_ID = "cpp"; private static final String[] CPP_LANGUAGE_EXTENSIONS = new String[] { "cpp", "c++", "cc", "cp", "cxx", "h", "h++", "hh", ".hpp", ".hxx", ".inc", ".inl", ".ipp", ".tcc", ".tpp" }; + private static final String EDITOR_PREF_NODE = "org.eclipse.ui.editors"; + private static final String TAB_WIDTH_KEY = "tabWidth"; + private static final String SPACES_FOR_TABS_KEY = "spacesForTabs"; private static final boolean DEFAULT_USE_SPACE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_CHAR.equals("space"); private static final int DEFAULT_TAB_SIZE = LanguageFormatReader.PREFERENCE_DEFAULT_TAB_SIZE; @@ -85,13 +90,13 @@ private FormattingOptions getLanguageFormat(IFile file) { IProject project = file.getProject(); if (project == null) { CopilotCore.LOGGER.info("Project is null for file: " + file.getName() + "default format will be applied."); - return null; + return getEclipseTextEditorFormattingOptions(); } String fileExtension = file.getFileExtension(); if (StringUtils.isEmpty(fileExtension)) { CopilotCore.LOGGER.info("File extension is null or empty for file: " + file.getName()); - return null; + return getEclipseTextEditorFormattingOptions(); } else { fileExtension = fileExtension.toLowerCase(); } @@ -110,7 +115,7 @@ private FormattingOptions getLanguageFormat(IFile file) { if (languageFormatReaderForProject == null) { languageFormatReaderForProject = loadFormatReaderForTheProject(languageId, project); if (languageFormatReaderForProject == null) { - return new FormattingOptions(DEFAULT_TAB_SIZE, DEFAULT_USE_SPACE); + return getEclipseTextEditorFormattingOptions(); } projectToLanguageFormatReaderMap.put(project, languageFormatReaderForProject); } @@ -118,6 +123,38 @@ private FormattingOptions getLanguageFormat(IFile file) { return languageFormatReaderForProject.getFormattingOptions(); } + /** + * Attempts to read the Eclipse default text editor preferences for tab size and spaces-for-tabs. + * Falls back to hardcoded defaults if the preferences cannot be read. + */ + private FormattingOptions getEclipseTextEditorFormattingOptions() { + try { + IPreferencesService service = Platform.getPreferencesService(); + + boolean useSpaces = service.getBoolean( + EDITOR_PREF_NODE, + SPACES_FOR_TABS_KEY, + DEFAULT_USE_SPACE, + null + ); + + int tabSize = service.getInt( + EDITOR_PREF_NODE, + TAB_WIDTH_KEY, + DEFAULT_TAB_SIZE, + null + ); + + return new FormattingOptions(tabSize, useSpaces); + } catch (Exception e) { + CopilotCore.LOGGER.info( + "Failed to read Eclipse text editor preferences, using defaults"); + return new FormattingOptions(DEFAULT_TAB_SIZE, DEFAULT_USE_SPACE); + } + } + + + private LanguageFormatReader loadFormatReaderForTheProject(String languageId, IProject project) { switch (languageId) { case JAVA_LANGUAGE_ID: @@ -129,4 +166,4 @@ private LanguageFormatReader loadFormatReaderForTheProject(String languageId, IP return null; } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index 4320c072..cf0e81ad 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -37,16 +37,23 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType; import com.microsoft.copilot.eclipse.core.chat.service.IMcpConfigService; import com.microsoft.copilot.eclipse.core.chat.service.IReferencedFileService; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpOauthRequest; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpRuntimeLog; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCapabilities; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationContextParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CurrentEditorContext; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeFeatureFlagsParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.GetWatchedFilesRequest; import com.microsoft.copilot.eclipse.core.lsp.protocol.GetWatchedFilesResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -60,6 +67,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -136,11 +144,11 @@ public CompletableFuture<Object[]> getConversationContext(ConversationContextPar public CompletableFuture<Object> invokeClientTool(InvokeClientToolParams params) { return CompletableFuture.supplyAsync(() -> { try { - CompletableFuture<LanguageModelToolResult[]> toolFuture = - CopilotCore.getPlugin().getChatEventsManager().invokeAgentTool(params); + CompletableFuture<LanguageModelToolResult[]> toolFuture = CopilotCore.getPlugin().getChatEventsManager() + .invokeAgentTool(params); if (toolFuture == null) { - CopilotCore.LOGGER.error( - new IllegalStateException("invokeAgentTool returned null for tool: " + params.getName())); + CopilotCore.LOGGER + .error(new IllegalStateException("invokeAgentTool returned null for tool: " + params.getName())); LanguageModelToolResult errorResult = new LanguageModelToolResult(); errorResult.addContent("Failed to invoke the tool: tool invocation returned null"); errorResult.setStatus(ToolInvocationStatus.error); @@ -178,6 +186,8 @@ public CompletableFuture<Object[]> confirmClientTool(InvokeClientToolConfirmatio }); } + // TODO: Should remove workspace-root folder as the projects are not directly under it in Eclipse, and can cause + // confusion in CLS. @Override public CompletableFuture<List<WorkspaceFolder>> workspaceFolders() { // Ideally, we should return each IProject as a workspace folder, but given that when @@ -255,9 +265,48 @@ public void onRateLimitWarning(RateLimitWarningParams params) { } /** - * Handles the Dynamic OAuth request for MCP. - * Shows a dialog with multiple input fields and returns the user's input values. - * Returns null if the user cancels the request. + * Notify when custom skills change (global or workspace). + */ + @JsonNotification("copilot/customSkill/didChange") + public void onDidChangeCustomSkill(Object params) { + postCustomizationFilesChanged(CustomizationType.SKILL); + } + + /** + * Notify when custom prompts change (global or workspace). + */ + @JsonNotification("copilot/customPrompt/didChange") + public void onDidChangeCustomPrompt(Object params) { + postCustomizationFilesChanged(CustomizationType.PROMPT); + } + + /** + * Notify when custom instructions change (global or workspace). + */ + @JsonNotification("copilot/customInstruction/didChange") + public void onDidChangeCustomInstruction(Object params) { + postCustomizationFilesChanged(CustomizationType.INSTRUCTION); + } + + /** + * Notify when custom agents change (global or workspace). + */ + @JsonNotification("copilot/customAgent/didChange") + public void onDidChangeCustomAgent(Object params) { + postCustomizationFilesChanged(CustomizationType.AGENT); + } + + // Broadcasts the change on the event bus; subscribers (the customization-file service and the + // slash-command service) react without this class depending on them directly. + private void postCustomizationFilesChanged(CustomizationType type) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, type); + } + } + + /** + * Handles the Dynamic OAuth request for MCP. Shows a dialog with multiple input fields and returns the user's input + * values. Returns null if the user cancels the request. */ @JsonRequest("copilot/dynamicOAuth") public CompletableFuture<Map<String, String>> mcpOauth(McpOauthRequest request) { @@ -282,6 +331,7 @@ public void onDidChangeFeatureFlags(DidChangeFeatureFlagsParams params) { flags.setMcpEnabled(params.isMcpEnabled()); flags.setByokEnabled(params.isByokEnabled()); flags.setClientPreviewFeatureEnabled(params.isClientPreviewFeaturesEnabled()); + flags.setAutoApprovalTokenEnabled(params.isAutoApprovalEnabled()); } if (eventBroker != null) { @@ -303,14 +353,13 @@ public void onDidChangePolicy(DidChangePolicyParams params) { } if (flags.isSubAgentPolicyEnabled() != params.isSubAgentEnabled()) { flags.setSubAgentPolicyEnabled(params.isSubAgentEnabled()); - eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_SUB_AGENT_POLICY, - params.isSubAgentEnabled()); + eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_SUB_AGENT_POLICY, params.isSubAgentEnabled()); } if (flags.isCustomAgentPolicyEnabled() != params.isCustomAgentEnabled()) { flags.setCustomAgentPolicyEnabled(params.isCustomAgentEnabled()); - eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, - params.isCustomAgentEnabled()); + eventBroker.post(CopilotEventConstants.TOPIC_DID_CHANGE_CUSTOM_AGENT_POLICY, params.isCustomAgentEnabled()); } + flags.setAutoApprovalPolicyEnabled(params.isAutoApprovalPolicyEnabled()); } } @@ -328,6 +377,36 @@ public CompletableFuture<CodingAgentMessageResult> onCodingAgentMessage(CodingAg return CompletableFuture.completedFuture(result); } + /** + * Notify when a quota warning is received from the language server. + */ + @JsonNotification("copilot/quotaWarning") + public void onQuotaWarning(QuotaWarningParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, params); + } + } + + /** + * Notify when automatic conversation compression starts. + */ + @JsonNotification("$/copilot/compressionStarted") + public void onCompressionStarted(CompressionStartedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, params); + } + } + + /** + * Notify when automatic conversation compression completes. + */ + @JsonNotification("$/copilot/compressionCompleted") + public void onCompressionCompleted(CompressionCompletedParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, params); + } + } + /** * Reads the contents and stats of a file given its URI. */ @@ -345,6 +424,22 @@ public CompletableFuture<ReadDirectoryResult> readDirectory(String uri) { return CompletableFuture.supplyAsync(() -> FileUtils.readDirectoryEntries(uri)); } + /** + * Searches for files matching a glob pattern under the given base URI. + */ + @JsonRequest("workspace/findFiles") + public CompletableFuture<FindFilesResult> findFiles(FindFilesParams params) { + return CompletableFuture.supplyAsync(() -> FileUtils.findFiles(params)); + } + + /** + * Searches for text (or a regex) in files under the given base URI. + */ + @JsonRequest("workspace/findTextInFiles") + public CompletableFuture<FindTextInFilesResult> findTextInFiles(FindTextInFilesParams params) { + return CompletableFuture.supplyAsync(() -> FileUtils.findTextInFiles(params)); + } + /** * Handles the progress notification for chat replies. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java index 8065520a..f238d933 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServer.java @@ -25,13 +25,18 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCodeCopyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCreateParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationDestroyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTurnParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; @@ -46,6 +51,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TelemetryExceptionParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateConversationToolsStatusParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateMcpToolsStatusParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.WorkspaceFoldersParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokApiKey; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListApiKeyResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListModelParams; @@ -137,9 +143,43 @@ public interface CopilotLanguageServer extends LanguageServer { /** * List conversation templates. + * + * @param params includes workspace folders for discovering workspace-specific prompt files and skills */ @JsonRequest("conversation/templates") - CompletableFuture<ConversationTemplate[]> listTemplates(NullParams param); + CompletableFuture<ConversationTemplate[]> listTemplates(WorkspaceFoldersParams params); + + /** + * List custom skill files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customSkill/list") + CompletableFuture<CustomizationFileInfo[]> listCustomSkills(WorkspaceFoldersParams params); + + /** + * List custom prompt files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customPrompt/list") + CompletableFuture<CustomizationFileInfo[]> listCustomPrompts(WorkspaceFoldersParams params); + + /** + * List custom instruction files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customInstruction/list") + CompletableFuture<CustomizationFileInfo[]> listCustomInstructions(WorkspaceFoldersParams params); + + /** + * List custom agent files (each carries its on-disk {@code uri}). + * + * @param params includes the workspace folders to scan + */ + @JsonRequest("copilot/customAgent/list") + CompletableFuture<CustomizationFileInfo[]> listCustomAgents(WorkspaceFoldersParams params); /** * List conversation modes. @@ -159,6 +199,12 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("conversation/persistence") CompletableFuture<ChatPersistence> persistence(NullParams param); + /** + * Destroy a conversation, stopping any in-progress processing. + */ + @JsonRequest("conversation/destroy") + CompletableFuture<String> destroy(ConversationDestroyParams param); + /** * Register agent tools to the language server. */ @@ -195,6 +241,12 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("git/commitGenerate") CompletableFuture<GenerateCommitMessageResult> generateCommitMessage(GenerateCommitMessageParams params); + /** + * Generate a short title summarizing a thinking block. + */ + @JsonRequest("thinking/generateTitle") + CompletableFuture<GenerateThinkingTitleResponse> generateThinkingTitle(GenerateThinkingTitleParams params); + /** * List BYOK models. */ @@ -267,6 +319,13 @@ public interface CopilotLanguageServer extends LanguageServer { @JsonRequest("githubApi/searchPR") CompletableFuture<SearchPrResponse> searchPr(SearchPrParams params); + /** + * Get the default file safety rules from CLS. + */ + @JsonRequest("getDefaultFileSafetyRules") + CompletableFuture<GetDefaultFileSafetyRulesResult> getDefaultFileSafetyRules( + NullParams params); + /** * Notify that an inline edit was shown. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index 8aaa7f6d..5844c4f7 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -12,7 +12,6 @@ import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.jface.text.IDocument; -import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4j.Command; import org.eclipse.lsp4j.DidChangeConfigurationParams; @@ -20,6 +19,7 @@ import org.eclipse.lsp4j.ProgressParams; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.jsonrpc.Endpoint; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.services.LanguageServer; @@ -42,15 +42,21 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCodeCopyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationCreateParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationDestroyParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationModesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTurnParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CustomizationFileInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidChangeCopilotWatchedFilesParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.DidShowInlineEditParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleResponse; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GetDefaultFileSafetyRulesResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ModelInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.NextEditSuggestionsResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.NotifyAcceptedParams; @@ -66,6 +72,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateConversationToolsStatusParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateMcpToolsStatusParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.WorkspaceFoldersParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokApiKey; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListApiKeyResponse; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokListModelParams; @@ -148,6 +155,16 @@ public CompletableFuture<CheckQuotaResult> checkQuota() { return this.languageServerWrapper.execute(fn); } + /** + * Get the default file safety rules from CLS. + */ + public CompletableFuture<GetDefaultFileSafetyRulesResult> getDefaultFileSafetyRules() { + Function<LanguageServer, CompletableFuture<GetDefaultFileSafetyRulesResult>> fn = + server -> ((CopilotLanguageServer) server) + .getDefaultFileSafetyRules(new NullParams()); + return this.languageServerWrapper.execute(fn); + } + /** * Get single completion for the given parameters. */ @@ -249,32 +266,15 @@ public CompletableFuture<Object> sendExceptionTelemetry(Throwable ex) { } /** - * Create a conversation with the given parameters. - */ - public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message, - List<IResource> files, IFile currentFile, List<Turn> turns, CopilotModel activeModel, String chatModeName, - String customChatModeId, List<TodoItem> todos) { - return createConversation(workDoneToken, message, files, currentFile, null, turns, activeModel, chatModeName, - customChatModeId, todos, null, null); - } - - /** - * Create a conversation with the given parameters and optional agentSlug, agentJobWorkspaceFolder. - */ - public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message, - List<IResource> files, IFile currentFile, List<Turn> turns, CopilotModel activeModel, String chatModeName, - String customChatModeId, List<TodoItem> todos, String agentSlug, String agentJobWorkspaceFolder) { - return createConversation(workDoneToken, message, files, currentFile, null, turns, activeModel, chatModeName, - customChatModeId, todos, agentSlug, agentJobWorkspaceFolder); - } - - /** - * Create a conversation with the given parameters, including optional currentSelection from the editor. + * Create a conversation with the given parameters, including an optional reasoning effort to forward to the server + * via {@code modelInfo}. */ public CompletableFuture<ChatCreateResult> createConversation(String workDoneToken, String message, List<IResource> files, IFile currentFile, Range currentSelection, List<Turn> turns, CopilotModel activeModel, - String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug, - String agentJobWorkspaceFolder) { + String reasoningEffort, String chatModeName, String customChatModeId, List<TodoItem> todos, String agentSlug, + String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId, + List<WorkspaceFolder> workspaceFolders) { + boolean supportVision = activeModel.getCapabilities().supports().vision(); Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils .createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision); @@ -283,28 +283,36 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok param.setReferences(FileUtils.convertToChatReferences(files)); param.setModel(getModelName(activeModel)); param.setModelProviderName(activeModel.getProviderName()); + param.setModelInfo(buildModelInfo(activeModel, reasoningEffort)); param.setChatMode(chatModeName); param.setCustomChatModeId(customChatModeId); + // Set historical turns if provided, inserting them before the current user message. + if (turns != null && turns.size() > 0) { + param.getTurns().addAll(0, turns); + } + if (StringUtils.isBlank(agentSlug)) { param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri()); - param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders()); + param.setWorkspaceFolders(workspaceFolders == null ? List.of() : workspaceFolders); param.setTodoList(todos); } else { - // Set agentSlug if provided - this will modify the first turn's agentSlug + // Set agentSlug on the last turn (current user message) after history insertion if (param.getTurns() != null && !param.getTurns().isEmpty()) { - param.getTurns().get(0).setAgentSlug(agentSlug); + param.getTurns().get(param.getTurns().size() - 1).setAgentSlug(agentSlug); } param.setWorkspaceFolder(agentJobWorkspaceFolder); } - // Set historical turns if provided. - if (turns != null && turns.size() > 0) { - param.getTurns().addAll(turns); - } - // TODO: remove needToolCallConfirmation when CLS fully supports it across all IDEs. param.setNeedToolCallConfirmation(true); + + // Set conversationId and restoreToTurnId for session restoration from history + if (conversationId != null) { + param.setConversationId(conversationId); + param.setRestoreToTurnId(restoreToTurnId); + } + if (currentFile != null) { param.setTextDocument(new TextDocumentIdentifier(FileUtils.getResourceUri(currentFile))); if (currentSelection != null) { @@ -317,22 +325,14 @@ public CompletableFuture<ChatCreateResult> createConversation(String workDoneTok } /** - * Create a conversation turn with the given parameters. - */ - public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToken, String conversationId, - String message, List<IResource> files, IFile currentFile, CopilotModel activeModel, String chatModeName, - String customChatModeId, List<TodoItem> todoList, String agentSlug, String agentJobWorkspaceFolder) { - return addConversationTurn(workDoneToken, conversationId, message, files, currentFile, null, activeModel, - chatModeName, customChatModeId, todoList, agentSlug, agentJobWorkspaceFolder); - } - - /** - * Create a conversation turn with the given parameters, including optional currentSelection from the editor. + * Create a conversation turn with the given parameters, including an optional reasoning effort to forward to the + * server via {@code modelInfo}. */ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToken, String conversationId, String message, List<IResource> files, IFile currentFile, Range currentSelection, CopilotModel activeModel, - String chatModeName, String customChatModeId, List<TodoItem> todoList, String agentSlug, - String agentJobWorkspaceFolder) { + String reasoningEffort, String chatModeName, String customChatModeId, List<TodoItem> todoList, String agentSlug, + String agentJobWorkspaceFolder, List<WorkspaceFolder> workspaceFolders) { + boolean supportVision = activeModel.getCapabilities().supports().vision(); Either<String, List<ChatCompletionContentPart>> messageWithImages = ChatMessageUtils .createMessageWithImages(message, FileUtils.filterFilesFrom(files), supportVision); @@ -341,12 +341,13 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke param.setReferences(FileUtils.convertToChatReferences(files)); param.setModel(getModelName(activeModel)); param.setModelProviderName(activeModel.getProviderName()); + param.setModelInfo(buildModelInfo(activeModel, reasoningEffort)); param.setChatMode(chatModeName); param.setCustomChatModeId(customChatModeId); if (StringUtils.isBlank(agentSlug)) { param.setWorkspaceFolder(PlatformUtils.getWorkspaceRootUri()); - param.setWorkspaceFolders(LSPEclipseUtils.getWorkspaceFolders()); + param.setWorkspaceFolders(workspaceFolders == null ? List.of() : workspaceFolders); param.setTodoList(todoList); } else { param.setAgentSlug(agentSlug); @@ -369,14 +370,56 @@ public CompletableFuture<ChatTurnResult> addConversationTurn(String workDoneToke /** * List the conversation templates. + * + * @param workspaceFolders workspace folders for discovering workspace-specific prompt files and skills */ - public CompletableFuture<ConversationTemplate[]> listConversationTemplates() { + public CompletableFuture<ConversationTemplate[]> listConversationTemplates(List<WorkspaceFolder> workspaceFolders) { Function<LanguageServer, CompletableFuture<ConversationTemplate[]>> fn = server -> { - return ((CopilotLanguageServer) server).listTemplates(new NullParams()); + return ((CopilotLanguageServer) server).listTemplates(new WorkspaceFoldersParams(workspaceFolders)); }; return this.languageServerWrapper.execute(fn); } + /** + * List custom skill files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture<CustomizationFileInfo[]> listCustomSkills(List<WorkspaceFolder> workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomSkills(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom prompt files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture<CustomizationFileInfo[]> listCustomPrompts(List<WorkspaceFolder> workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomPrompts(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom instruction files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture<CustomizationFileInfo[]> listCustomInstructions(List<WorkspaceFolder> workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomInstructions(new WorkspaceFoldersParams(workspaceFolders))); + } + + /** + * List custom agent files, each carrying its on-disk {@code uri}. + * + * @param workspaceFolders the workspace folders to scan + */ + public CompletableFuture<CustomizationFileInfo[]> listCustomAgents(List<WorkspaceFolder> workspaceFolders) { + return this.languageServerWrapper.execute(server -> + ((CopilotLanguageServer) server).listCustomAgents(new WorkspaceFoldersParams(workspaceFolders))); + } + /** * List the conversation modes. */ @@ -429,6 +472,21 @@ public CompletableFuture<ChatPersistence> persistence() { }); } + /** + * Destroy a conversation, stopping any in-progress processing on the server. + */ + public void destroyConversation(String conversationId) { + if (StringUtils.isBlank(conversationId)) { + return; + } + Function<LanguageServer, CompletableFuture<String>> fn = server -> ((CopilotLanguageServer) server) + .destroy(new ConversationDestroyParams(conversationId)); + this.languageServerWrapper.execute(fn).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to destroy conversation: " + conversationId, ex); + return null; + }); + } + /** * Used to register the tools for the language server. */ @@ -526,6 +584,19 @@ public CompletableFuture<GenerateCommitMessageResult> generateCommitMessage(Gene }); } + /** + * Generate a short title summarizing a thinking block. + */ + public CompletableFuture<GenerateThinkingTitleResponse> generateThinkingTitle( + GenerateThinkingTitleParams params) { + Function<LanguageServer, CompletableFuture<GenerateThinkingTitleResponse>> fn = + server -> ((CopilotLanguageServer) server).generateThinkingTitle(params); + return this.languageServerWrapper.execute(fn).exceptionally(ex -> { + CopilotCore.LOGGER.error(ex); + return null; + }); + } + /** * List BYOK models. */ @@ -676,4 +747,17 @@ private String getModelName(CopilotModel activeModel) { return activeModel == null ? null : activeModel.isChatFallback() ? activeModel.getId() : activeModel.getModelFamily(); } + + /** + * Builds the {@link ModelInfo} payload for chat requests. Always sends the concrete model id (which the server + * prefers over the legacy {@code model} family field), since the family is not unique across models. Returns + * {@code null} when no model id is available. + */ + private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort) { + if (activeModel == null || StringUtils.isBlank(activeModel.getId())) { + return null; + } + String effort = StringUtils.isBlank(reasoningEffort) ? null : reasoningEffort; + return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, null); + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLauncherBuilder.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLauncherBuilder.java index 21cf458f..a36d6336 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLauncherBuilder.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLauncherBuilder.java @@ -8,6 +8,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatReferenceTypeAdapter; import com.microsoft.copilot.eclipse.core.lsp.protocol.ProgressParamsAdapter; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ThinkingTypeAdapter; /** * Builder for Copilot Language Server. @@ -19,7 +20,8 @@ public class CopilotLauncherBuilder<T extends LanguageServer> extends Launcher.B */ public CopilotLauncherBuilder() { this.configureGson(gsonBuilder -> gsonBuilder.registerTypeAdapterFactory(new ProgressParamsAdapter.Factory()) - .registerTypeAdapterFactory(new ChatReferenceTypeAdapter.Factory())); + .registerTypeAdapterFactory(new ChatReferenceTypeAdapter.Factory()) + .registerTypeAdapterFactory(new ThinkingTypeAdapter.Factory())); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProvider.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProvider.java index 5b3ca813..6f637137 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProvider.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/LsStreamConnectionProvider.java @@ -79,6 +79,8 @@ public void start() throws IOException { Thread.sleep(1000); CopilotCore.LOGGER.info("Retrying binary LSP agent start on Linux."); startBinaryLspAgent(); + } else { + throw e; } } } catch (Exception e) { diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java index 74321868..3929af0b 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java @@ -25,6 +25,7 @@ public class ChatProgressValue implements WorkDoneProgressNotification { private ChatReference[] references; private boolean hideText; private String[] notifications; + private Thinking thinking; private ChatStep[] steps; private String cancellationReason; private ConversationError error; @@ -80,6 +81,10 @@ public String[] getNotifications() { return notifications; } + public Thinking getThinking() { + return thinking; + } + public ChatStep[] getSteps() { return steps; } @@ -96,6 +101,15 @@ public String getErrorReason() { return error != null ? error.getReason() : null; } + /** + * Returns the BYOK model-provider name from the error payload, when present. + * + * @return the name of the BYOK model provider that produced the error, or {@code null} for built-in models. + */ + public String getErrorModelProviderName() { + return error != null ? error.getModelProviderName() : null; + } + public List<AgentRound> getAgentRounds() { return editAgentRounds; } @@ -140,6 +154,10 @@ public void setNotifications(String[] notifications) { this.notifications = notifications; } + public void setThinking(Thinking thinking) { + this.thinking = thinking; + } + public void setSteps(ChatStep[] steps) { this.steps = steps; } @@ -177,7 +195,7 @@ public int hashCode() { result = prime * result + Arrays.hashCode(references); result = prime * result + Arrays.hashCode(steps); result = prime * result + Objects.hash(editAgentRounds, cancellationReason, contextSize, conversationId, error, - hideText, kind, reply, title, turnId, parentTurnId, suggestedTitle); + hideText, kind, reply, thinking, title, turnId, parentTurnId, suggestedTitle); return result; } @@ -199,7 +217,8 @@ public boolean equals(Object obj) { && Objects.equals(conversationId, other.conversationId) && Objects.equals(error, other.error) && hideText == other.hideText && kind == other.kind && Arrays.equals(notifications, other.notifications) && Arrays.equals(references, other.references) && Objects.equals(reply, other.reply) - && Arrays.equals(steps, other.steps) && Objects.equals(title, other.title) + && Arrays.equals(steps, other.steps) && Objects.equals(thinking, other.thinking) + && Objects.equals(title, other.title) && Objects.equals(turnId, other.turnId) && Objects.equals(parentTurnId, other.parentTurnId) && Objects.equals(suggestedTitle, other.suggestedTitle); } @@ -217,6 +236,7 @@ public String toString() { builder.append("references", Arrays.toString(references)); builder.append("hideText", hideText); builder.append("notifications", Arrays.toString(notifications)); + builder.append("thinking", thinking); builder.append("steps", Arrays.toString(steps)); builder.append("cancellationReason", cancellationReason); builder.append("error", error); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java new file mode 100644 index 00000000..28869330 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionCompletedParams.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionCompleted} notification sent by the language server when automatic + * conversation compression finishes. The {@code contextInfo} field is optional and may be {@code null}. + */ +public record CompressionCompletedParams( + String conversationId, + int archivedPartitionId, + int newPartitionId, + int summaryLength, + int turnCount, + int durationMs, + ContextSizeInfo contextInfo) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java new file mode 100644 index 00000000..247826bd --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CompressionStartedParams.java @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code $/copilot/compressionStarted} notification sent by the language server when automatic + * conversation compression begins. + */ +public record CompressionStartedParams(String conversationId, int partitionId, String reason) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java index 0edd83a8..2331bab7 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ContextSizeInfo.java @@ -9,6 +9,7 @@ */ public record ContextSizeInfo( int totalTokenLimit, + int reservedOutputTokens, int systemPromptTokens, int toolDefinitionTokens, int userMessagesTokens, diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationCreateParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationCreateParams.java index 59670e88..bf401029 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationCreateParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationCreateParams.java @@ -33,12 +33,15 @@ public class ConversationCreateParams { private String userLanguage; private String model; private String modelProviderName; + private ModelInfo modelInfo; private String chatMode; private String customChatModeId; // TODO: remove needToolCallConfirmation when CLS fully supports it across all IDEs. private boolean needToolCallConfirmation; private List<TodoItem> todoList; + private String conversationId; + private String restoreToTurnId; /** * Creates a new ConversationCreateParams. @@ -198,6 +201,30 @@ public void setModelProviderName(String modelProviderName) { this.modelProviderName = modelProviderName; } + public ModelInfo getModelInfo() { + return modelInfo; + } + + public void setModelInfo(ModelInfo modelInfo) { + this.modelInfo = modelInfo; + } + + public String getConversationId() { + return conversationId; + } + + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + public String getRestoreToTurnId() { + return restoreToTurnId; + } + + public void setRestoreToTurnId(String restoreToTurnId) { + this.restoreToTurnId = restoreToTurnId; + } + @Override public int hashCode() { final int prime = 31; @@ -207,7 +234,7 @@ public int hashCode() { result = prime * result + Objects.hash(capabilities, chatMode, computeSuggestions, customChatModeId, model, needToolCallConfirmation, references, source, textDocument, userLanguage, workDoneToken, workspaceFolder, workspaceFolders, - modelProviderName, todoList); + modelProviderName, modelInfo, todoList, conversationId, restoreToTurnId); return result; } @@ -233,7 +260,10 @@ public boolean equals(Object obj) { && Objects.equals(workDoneToken, other.workDoneToken) && Objects.equals(workspaceFolder, other.workspaceFolder) && Objects.equals(workspaceFolders, other.workspaceFolders) && Objects.equals(modelProviderName, other.modelProviderName) - && Objects.equals(todoList, other.todoList); + && Objects.equals(modelInfo, other.modelInfo) + && Objects.equals(todoList, other.todoList) + && Objects.equals(conversationId, other.conversationId) + && Objects.equals(restoreToTurnId, other.restoreToTurnId); } @Override @@ -252,10 +282,13 @@ public String toString() { builder.append("userLanguage", userLanguage); builder.append("model", model); builder.append("modelProviderName", modelProviderName); + builder.append("modelInfo", modelInfo); builder.append("chatMode", chatMode); builder.append("customChatModeId", customChatModeId); builder.append("needToolCallConfirmation", needToolCallConfirmation); builder.append("todoList", todoList); + builder.append("conversationId", conversationId); + builder.append("restoreToTurnId", restoreToTurnId); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java new file mode 100644 index 00000000..87459397 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationDestroyParams.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Parameters for destroying a conversation. + */ +public class ConversationDestroyParams { + private String conversationId; + + /** + * Creates a new ConversationDestroyParams. + */ + public ConversationDestroyParams(String conversationId) { + this.conversationId = conversationId; + } + + public String getConversationId() { + return conversationId; + } + + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + @Override + public int hashCode() { + return Objects.hash(conversationId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ConversationDestroyParams other = (ConversationDestroyParams) obj; + return Objects.equals(conversationId, other.conversationId); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("conversationId", conversationId); + return builder.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java index d4c883bc..a8b52a77 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java @@ -19,6 +19,7 @@ public class ConversationError { private String reason; private boolean responseIsIncomplete; private boolean responseIsFiltered; + private String modelProviderName; public void setMessage(String message) { this.message = message; @@ -40,6 +41,10 @@ public void setResponseIsFiltered(boolean responseIsFiltered) { this.responseIsFiltered = responseIsFiltered; } + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public String getMessage() { return message; } @@ -60,9 +65,17 @@ public boolean getResponseIsFiltered() { return responseIsFiltered; } + /** + * The name of the model provider that produced the error, when the failing request was routed to a custom + * Bring-Your-Own-Key (BYOK) model. {@code null} or blank for built-in Copilot models. + */ + public String getModelProviderName() { + return modelProviderName; + } + @Override public int hashCode() { - return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered); + return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered, modelProviderName); } @Override @@ -75,7 +88,8 @@ public boolean equals(Object o) { } ConversationError that = (ConversationError) o; return Objects.equals(message, that.message) && code == that.code && Objects.equals(reason, that.reason) - && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered; + && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered + && Objects.equals(modelProviderName, that.modelProviderName); } @Override @@ -86,6 +100,7 @@ public String toString() { builder.append("reason", reason); builder.append("responseIsIncomplete", responseIsIncomplete); builder.append("responseIsFiltered", responseIsFiltered); + builder.append("modelProviderName", modelProviderName); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java index f643ac36..23303571 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTemplate.java @@ -4,78 +4,14 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; import java.util.List; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; /** - * Get the templates. + * Represents a conversation template returned by the language server. */ -public class ConversationTemplate { - - - private String id; - private String description; - private String shortDescription; - private List<String> scopes; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getShortDescription() { - return shortDescription; - } - - public void setShortDescription(String shortDescription) { - this.shortDescription = shortDescription; - } - - public List<String> getScopes() { - return scopes; - } - - public void setScopes(List<String> scopes) { - this.scopes = scopes; - } - - @Override - public int hashCode() { - return Objects.hash(id, description, shortDescription, scopes); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - ConversationTemplate that = (ConversationTemplate) obj; - return Objects.equals(id, that.id) && Objects.equals(description, that.description) - && Objects.equals(shortDescription, that.shortDescription) && Objects.equals(scopes, that.scopes); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("id", id); - builder.append("description", description); - builder.append("shortDescription", shortDescription); - builder.append("scopes", scopes); - return builder.toString(); - } +public record ConversationTemplate( + String id, + String description, + String shortDescription, + List<String> scopes, + TemplateSource source) { } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTurnParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTurnParams.java index 23d63c0b..11eaef2b 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTurnParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationTurnParams.java @@ -34,6 +34,7 @@ public class ConversationTurnParams { private String[] ignoredSkills; private String model; private String modelProviderName; + private ModelInfo modelInfo; private String chatMode; private String customChatModeId; @@ -174,6 +175,14 @@ public void setModelProviderName(String modelProviderName) { this.modelProviderName = modelProviderName; } + public ModelInfo getModelInfo() { + return modelInfo; + } + + public void setModelInfo(ModelInfo modelInfo) { + this.modelInfo = modelInfo; + } + public String getAgentSlug() { return agentSlug; } @@ -197,7 +206,7 @@ public int hashCode() { result = prime * result + Arrays.hashCode(ignoredSkills); result = prime * result + Objects.hash(agentSlug, chatMode, computeSuggestions, conversationId, customChatModeId, message, model, - modelProviderName, needToolCallConfirmation, references, textDocument, todoList, workDoneToken, + modelProviderName, modelInfo, needToolCallConfirmation, references, textDocument, todoList, workDoneToken, workspaceFolder, workspaceFolders); return result; } @@ -219,6 +228,7 @@ public boolean equals(Object obj) { && Objects.equals(customChatModeId, other.customChatModeId) && Arrays.equals(ignoredSkills, other.ignoredSkills) && Objects.equals(message, other.message) && Objects.equals(model, other.model) && Objects.equals(modelProviderName, other.modelProviderName) + && Objects.equals(modelInfo, other.modelInfo) && needToolCallConfirmation == other.needToolCallConfirmation && Objects.equals(references, other.references) && Objects.equals(textDocument, other.textDocument) && Objects.equals(todoList, other.todoList) && Objects.equals(workDoneToken, other.workDoneToken) @@ -243,6 +253,7 @@ public String toString() { builder.append("customChatModeId", customChatModeId); builder.append("needToolCallConfirmation", needToolCallConfirmation); builder.append("modelProviderName", modelProviderName); + builder.append("modelInfo", modelInfo); builder.append("agentSlug", agentSlug); builder.append("todoList", todoList); return builder.toString(); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java index 254a1266..818fbd36 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotAgentSettings.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol; +import java.util.Map; import java.util.Objects; import com.google.gson.annotations.SerializedName; @@ -15,6 +16,141 @@ public class CopilotAgentSettings { @SerializedName("maxToolCallingLoop") private int agentMaxRequests; + private boolean enableSkills; + private boolean autoCompress; + + private String transcriptDirectory; + + @SerializedName("autoApproveUnmatchedTerminal") + private boolean autoApproveUnmatchedTerminal; + + @SerializedName("autoApproveUnmatchedFileOp") + private boolean autoApproveUnmatchedFileOp; + + // Tells CLS to always send confirmation requests to the editor + @SerializedName("editorHandlesAllConfirmation") + private boolean editorHandlesAllConfirmation = true; + + private ToolsSettings tools; + + /** Nested tools settings matching CLS agent.tools structure. */ + public static class ToolsSettings { + private TerminalSettings terminal; + private EditSettings edit; + + /** Gets terminal settings, creating if needed. */ + public TerminalSettings getTerminal() { + if (terminal == null) { + terminal = new TerminalSettings(); + } + return terminal; + } + + /** Gets edit settings, creating if needed. */ + public EditSettings getEdit() { + if (edit == null) { + edit = new EditSettings(); + } + return edit; + } + + @Override + public int hashCode() { + return Objects.hash(terminal, edit); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolsSettings other = (ToolsSettings) obj; + return Objects.equals(terminal, other.terminal) && Objects.equals(edit, other.edit); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("terminal", terminal) + .append("edit", edit) + .toString(); + } + } + + /** Terminal auto-approve rules: command/pattern -> allow(true)/deny(false). */ + public static class TerminalSettings { + private Map<String, Boolean> autoApprove; + + public Map<String, Boolean> getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map<String, Boolean> autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((TerminalSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } + + /** Edit (file operation) auto-approve rules: pattern → allow(true)/deny(false). */ + public static class EditSettings { + private Map<String, Boolean> autoApprove; + + public Map<String, Boolean> getAutoApprove() { + return autoApprove; + } + + public void setAutoApprove(Map<String, Boolean> autoApprove) { + this.autoApprove = autoApprove; + } + + @Override + public int hashCode() { + return Objects.hash(autoApprove); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return Objects.equals(autoApprove, ((EditSettings) obj).autoApprove); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("autoApprove", autoApprove) + .toString(); + } + } public int getAgentMaxRequests() { return agentMaxRequests; @@ -24,9 +160,69 @@ public void setAgentMaxRequests(int agentMaxRequests) { this.agentMaxRequests = agentMaxRequests; } + public boolean isEnableSkills() { + return enableSkills; + } + + /** + * Sets whether skills are enabled. + * + * @param enableSkills whether skills should be enabled + * @return this settings instance, for chaining + */ + public CopilotAgentSettings setEnableSkills(boolean enableSkills) { + this.enableSkills = enableSkills; + return this; + } + + public String getTranscriptDirectory() { + return transcriptDirectory; + } + + public boolean isAutoCompress() { + return autoCompress; + } + + public void setAutoCompress(boolean autoCompress) { + this.autoCompress = autoCompress; + } + + public void setTranscriptDirectory(String transcriptDirectory) { + this.transcriptDirectory = transcriptDirectory; + } + + public boolean isEditorHandlesAllConfirmation() { + return editorHandlesAllConfirmation; + } + + public boolean isAutoApproveUnmatchedTerminal() { + return autoApproveUnmatchedTerminal; + } + + public void setAutoApproveUnmatchedTerminal(boolean autoApproveUnmatchedTerminal) { + this.autoApproveUnmatchedTerminal = autoApproveUnmatchedTerminal; + } + + public boolean isAutoApproveUnmatchedFileOp() { + return autoApproveUnmatchedFileOp; + } + + public void setAutoApproveUnmatchedFileOp(boolean autoApproveUnmatchedFileOp) { + this.autoApproveUnmatchedFileOp = autoApproveUnmatchedFileOp; + } + + /** Gets tools settings, creating if needed. */ + public ToolsSettings getTools() { + if (tools == null) { + tools = new ToolsSettings(); + } + return tools; + } + @Override public int hashCode() { - return Objects.hash(agentMaxRequests); + return Objects.hash(agentMaxRequests, enableSkills, autoCompress, transcriptDirectory, + editorHandlesAllConfirmation, autoApproveUnmatchedTerminal, autoApproveUnmatchedFileOp, tools); } @Override @@ -34,17 +230,34 @@ public boolean equals(Object obj) { if (this == obj) { return true; } - if (!(obj instanceof CopilotAgentSettings)) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { return false; } CopilotAgentSettings other = (CopilotAgentSettings) obj; - return agentMaxRequests == other.agentMaxRequests; + return agentMaxRequests == other.agentMaxRequests && enableSkills == other.enableSkills + && autoCompress == other.autoCompress + && Objects.equals(transcriptDirectory, other.transcriptDirectory) + && editorHandlesAllConfirmation == other.editorHandlesAllConfirmation + && autoApproveUnmatchedTerminal == other.autoApproveUnmatchedTerminal + && autoApproveUnmatchedFileOp == other.autoApproveUnmatchedFileOp + && Objects.equals(tools, other.tools); } @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("agentMaxRequests", agentMaxRequests); + builder.append("enableSkills", enableSkills); + builder.append("autoCompress", autoCompress); + builder.append("transcriptDirectory", transcriptDirectory); + builder.append("editorHandlesAllConfirmation", editorHandlesAllConfirmation); + builder.append("autoApproveUnmatchedTerminal", autoApproveUnmatchedTerminal); + builder.append("autoApproveUnmatchedFileOp", autoApproveUnmatchedFileOp); + builder.append("tools", tools); return builder.toString(); } + } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java index 30e5581a..b766f49c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CopilotModel.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Objects; +import com.google.gson.annotations.SerializedName; import org.apache.commons.lang3.builder.ToStringBuilder; /** @@ -16,6 +17,7 @@ public class CopilotModel { private String modelFamily; private String modelName; private String id; + private String vendor; private CopilotModelPolicy modelPolicy; private List<String> scopes; private boolean preview; @@ -23,9 +25,11 @@ public class CopilotModel { private boolean isChatFallback; private CopilotModelCapabilities capabilities; private CopilotModelBilling billing; + private CopilotModelCustomModel customModel; private String degradationReason; private String providerName; private String modelPickerCategory; + private String modelPickerPriceCategory; /** * Policy for the model. @@ -42,12 +46,40 @@ public String toString() { /** * Capabilities supports for the model. + * + * @param vision whether the model supports vision input + * @param reasoningEfforts the list of reasoning effort levels advertised by the model (e.g. {@code low}, {@code + * medium}, {@code high}); may be {@code null} or empty when the model does not expose this list + * @param supportsReasoningEffortLevel whether the model surfaces selectable reasoning effort levels to the user + * (the language server only reports {@code true} when the model has more than one effort level and is hosted on + * a compatible endpoint) */ - public record CopilotModelCapabilitiesSupports(boolean vision) { + public record CopilotModelCapabilitiesSupports(boolean vision, List<String> reasoningEfforts, + boolean supportsReasoningEffortLevel) { + @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("vision", vision); + builder.append("reasoningEfforts", reasoningEfforts); + builder.append("supportsReasoningEffortLevel", supportsReasoningEffortLevel); + return builder.toString(); + } + } + + /** + * Capabilities limits for the model. All components are optional ({@code null} when the server does not provide a + * value), mirroring the {@code number | undefined} fields in the language-server schema. + */ + public record CopilotModelCapabilitiesLimits(Integer maxContextWindowTokens, Integer maxOutputTokens, + Integer maxInputTokens, Integer maxNonStreamingOutputTokens) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("maxContextWindowTokens", maxContextWindowTokens); + builder.append("maxOutputTokens", maxOutputTokens); + builder.append("maxInputTokens", maxInputTokens); + builder.append("maxNonStreamingOutputTokens", maxNonStreamingOutputTokens); return builder.toString(); } } @@ -55,11 +87,58 @@ public String toString() { /** * Capabilities for the model. */ - public record CopilotModelCapabilities(CopilotModelCapabilitiesSupports supports) { + public record CopilotModelCapabilities(CopilotModelCapabilitiesSupports supports, + CopilotModelCapabilitiesLimits limits) { @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("supports", supports); + builder.append("limits", limits); + return builder.toString(); + } + } + + /** + * Per-tier token prices, quoted in USD per {@link CopilotModelBillingTokenPrices#batchSize} tokens and applying up + * to {@code maxContext} context tokens. Requests larger than the {@code default} tier's {@code maxContext} are + * billed at the {@code longContext} tier. All components are optional ({@code null} when the server does not provide + * a value). + * + * @param cachePrice the price for cached input tokens + * @param inputPrice the price for input tokens + * @param outputPrice the price for output tokens + * @param maxContext the maximum number of context (input) tokens this tier applies to + */ + public record CopilotModelTokenPriceTier(Double cachePrice, Double inputPrice, Double outputPrice, + Integer maxContext) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("cachePrice", cachePrice); + builder.append("inputPrice", inputPrice); + builder.append("outputPrice", outputPrice); + builder.append("maxContext", maxContext); + return builder.toString(); + } + } + + /** + * Per-tier token prices for the model. When token-based billing is enabled the server returns a {@code default} + * tier and, for models that support long context, a {@code longContext} tier. + * + * @param batchSize the number of tokens each tier price is quoted per + * @param defaultTier the {@code default} price tier (deserialized from the {@code default} JSON field) + * @param longContext the {@code longContext} price tier, or {@code null} when the model does not support long + * context + */ + public record CopilotModelBillingTokenPrices(Double batchSize, + @SerializedName("default") CopilotModelTokenPriceTier defaultTier, CopilotModelTokenPriceTier longContext) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("batchSize", batchSize); + builder.append("defaultTier", defaultTier); + builder.append("longContext", longContext); return builder.toString(); } } @@ -67,12 +146,37 @@ public String toString() { /** * Billing for the model. */ - public record CopilotModelBilling(boolean isPremium, double multiplier) { + public record CopilotModelBilling(boolean isPremium, double multiplier, boolean tokenBasedBillingEnabled, + CopilotModelBillingTokenPrices tokenPrices) { @Override public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("isPremium", isPremium); builder.append("multiplier", multiplier); + builder.append("tokenBasedBillingEnabled", tokenBasedBillingEnabled); + builder.append("tokenPrices", tokenPrices); + return builder.toString(); + } + } + + /** + * Metadata describing a custom (BYOK) model that is contributed to the user by an organization or enterprise. When + * present, the model is served through a key that the owner configured, so it is surfaced in the model picker + * automatically without the user having to register their own API key. + * + * @param keyName the display name of the API key the model is served through + * @param ownerName the name of the organization, enterprise, or user that contributed the model + * @param ownerType the type of the owner (e.g. {@code organization}, {@code enterprise}, {@code user}) + * @param provider the underlying model provider (e.g. {@code azure}, {@code openai}) + */ + public record CopilotModelCustomModel(String keyName, String ownerName, String ownerType, String provider) { + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("keyName", keyName); + builder.append("ownerName", ownerName); + builder.append("ownerType", ownerType); + builder.append("provider", provider); return builder.toString(); } } @@ -101,6 +205,14 @@ public void setId(String id) { this.id = id; } + public String getVendor() { + return vendor; + } + + public void setVendor(String vendor) { + this.vendor = vendor; + } + public CopilotModelPolicy getModelPolicy() { return modelPolicy; } @@ -157,6 +269,14 @@ public void setBilling(CopilotModelBilling billing) { this.billing = billing; } + public CopilotModelCustomModel getCustomModel() { + return customModel; + } + + public void setCustomModel(CopilotModelCustomModel customModel) { + this.customModel = customModel; + } + public String getDegradationReason() { return degradationReason; } @@ -181,6 +301,23 @@ public void setModelPickerCategory(String modelPickerCategory) { this.modelPickerCategory = modelPickerCategory; } + public String getModelPickerPriceCategory() { + return modelPickerPriceCategory; + } + + public void setModelPickerPriceCategory(String modelPickerPriceCategory) { + this.modelPickerPriceCategory = modelPickerPriceCategory; + } + + /** + * Builds the composite key used to identify this model in maps and user preferences. + * + * @return the composite key + */ + public String getModelKey() { + return providerName != null ? providerName + "_" + id : id; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -194,18 +331,22 @@ public boolean equals(Object obj) { } CopilotModel other = (CopilotModel) obj; return Objects.equals(billing, other.billing) && Objects.equals(capabilities, other.capabilities) + && Objects.equals(customModel, other.customModel) && Objects.equals(degradationReason, other.degradationReason) && Objects.equals(id, other.id) && isChatDefault == other.isChatDefault && isChatFallback == other.isChatFallback && Objects.equals(modelFamily, other.modelFamily) && Objects.equals(modelName, other.modelName) && Objects.equals(modelPickerCategory, other.modelPickerCategory) + && Objects.equals(modelPickerPriceCategory, other.modelPickerPriceCategory) && Objects.equals(modelPolicy, other.modelPolicy) && preview == other.preview - && Objects.equals(providerName, other.providerName) && Objects.equals(scopes, other.scopes); + && Objects.equals(providerName, other.providerName) && Objects.equals(scopes, other.scopes) + && Objects.equals(vendor, other.vendor); } @Override public int hashCode() { - return Objects.hash(billing, capabilities, degradationReason, id, isChatDefault, isChatFallback, modelFamily, - modelName, modelPickerCategory, modelPolicy, preview, providerName, scopes); + return Objects.hash(billing, capabilities, customModel, degradationReason, id, isChatDefault, isChatFallback, + modelFamily, modelName, modelPickerCategory, modelPickerPriceCategory, modelPolicy, preview, providerName, + scopes, vendor); } @Override @@ -214,6 +355,7 @@ public String toString() { builder.append("modelFamily", modelFamily); builder.append("modelName", modelName); builder.append("id", id); + builder.append("vendor", vendor); builder.append("modelPolicy", modelPolicy); builder.append("scopes", scopes); builder.append("preview", preview); @@ -221,9 +363,11 @@ public String toString() { builder.append("isChatFallback", isChatFallback); builder.append("capabilities", capabilities); builder.append("billing", billing); + builder.append("customModel", customModel); builder.append("degradationReason", degradationReason); builder.append("providerName", providerName); builder.append("modelPickerCategory", modelPickerCategory); + builder.append("modelPickerPriceCategory", modelPickerPriceCategory); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java new file mode 100644 index 00000000..c164b1dd --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/CustomizationFileInfo.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Metadata for a Copilot customization file (skill, prompt, instruction, or agent) as returned by + * the language server's customization list requests. + * + * @param id the stable identifier, when provided by the language server + * @param name the user-facing name + * @param uri the on-disk file URI, or {@code null} for entries without a backing file + * @param storage the storage scope reported by the language server (for example {@code local} or {@code user}) + */ +public record CustomizationFileInfo(String id, String name, String uri, String storage) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java index 44dd3e4c..cdd0e839 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/DidChangeFeatureFlagsParams.java @@ -76,6 +76,15 @@ public boolean isClientPreviewFeaturesEnabled() { return !disabled; } + /** + * Checks if the auto-approval feature is enabled. + * Disabled only when the feature flag "agent_mode_auto_approval" is set to "0". + */ + public boolean isAutoApprovalEnabled() { + boolean disabled = featureFlags != null && "0".equals(featureFlags.get("agent_mode_auto_approval")); + return !disabled; + } + @Override public int hashCode() { return Objects.hash(activeExps, featureFlags, envelope, byokEnabled); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java new file mode 100644 index 00000000..90348463 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FileSafetyRuleInfo.java @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * A file safety rule as returned by CLS {@code getDefaultFileSafetyRules}. + * + * <p>Field names match the CLS JSON-RPC response exactly.</p> + */ +public class FileSafetyRuleInfo { + + private String pattern; + private boolean requiresConfirmation; + private String description; + + /** Default constructor for Gson deserialization. */ + public FileSafetyRuleInfo() { + } + + /** + * Creates a new FileSafetyRuleInfo. + * + * @param pattern the glob pattern + * @param requiresConfirmation whether the file requires confirmation + * @param description description of the rule + */ + public FileSafetyRuleInfo(String pattern, boolean requiresConfirmation, + String description) { + this.pattern = pattern; + this.requiresConfirmation = requiresConfirmation; + this.description = description; + } + + public String getPattern() { + return pattern; + } + + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public boolean isRequiresConfirmation() { + return requiresConfirmation; + } + + public void setRequiresConfirmation(boolean requiresConfirmation) { + this.requiresConfirmation = requiresConfirmation; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public int hashCode() { + return Objects.hash(description, pattern, requiresConfirmation); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + FileSafetyRuleInfo other = (FileSafetyRuleInfo) obj; + return Objects.equals(description, other.description) + && Objects.equals(pattern, other.pattern) + && requiresConfirmation == other.requiresConfirmation; + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("pattern", pattern) + .append("requiresConfirmation", requiresConfirmation) + .append("description", description) + .toString(); + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java new file mode 100644 index 00000000..0e3f59ce --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesParams.java @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code workspace/findFiles} request. Used by the language server to ask the client to search for + * files matching a glob pattern under a given base URI (e.g. a semanticfs workspace folder). + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param pattern the glob pattern to match file paths against + * @param maxResults the maximum number of results to return (optional) + */ +public record FindFilesParams(String baseUri, String pattern, int maxResults) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java new file mode 100644 index 00000000..fc250a2e --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindFilesResult.java @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; + +/** + * Result of the {@code workspace/findFiles} request, containing URIs of files matching the glob pattern. + * + * @param uris the list of file URIs matching the glob pattern + */ +public record FindFilesResult(List<String> uris) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java new file mode 100644 index 00000000..84bb5832 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesParams.java @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code workspace/findTextInFiles} request. Used by the language server to ask the client to search + * for text (or a regex) in files under a given base URI. + * + * @param baseUri the base URI to search under (e.g. a semanticfs workspace folder) + * @param query the text or regex pattern to search for in files + * @param isRegexp whether the query is a regular expression + * @param includePattern an optional glob pattern to filter which files to search + * @param maxResults the maximum number of results to return (optional) + */ +public record FindTextInFilesParams(String baseUri, String query, Boolean isRegexp, String includePattern, + int maxResults) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java new file mode 100644 index 00000000..7e7bd0b6 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/FindTextInFilesResult.java @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; + +/** + * Result of the {@code workspace/findTextInFiles} request, containing the list of matches. + * + * @param matches the list of text search matches + */ +public record FindTextInFilesResult(List<TextSearchMatch> matches) { + + /** + * A single text search match. Field names mirror the CLS protocol. + * + * @param uri the URI of the file containing the match + * @param lineNumber the 1-based line number of the match within the file + * @param lineText the full text of the line containing the match + */ + public record TextSearchMatch(String uri, int lineNumber, String lineText) { + } + +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleParams.java new file mode 100644 index 00000000..1b42ab89 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleParams.java @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Parameters for the {@code thinking/generateTitle} request. + * + * <p>Either {@code thinkingContent} or {@code extractedTitles} should be provided depending on + * whether parsed section titles are available on the client side. + * + * @param thinkingContent the raw thinking content (used when no extracted titles are available) + * @param extractedTitles previously extracted section titles, may be {@code null} + */ +public record GenerateThinkingTitleParams(String thinkingContent, String[] extractedTitles) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleResponse.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleResponse.java new file mode 100644 index 00000000..9efe23e3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GenerateThinkingTitleResponse.java @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Response for the {@code thinking/generateTitle} request. + * + * @param title the title returned by the language server + */ +public record GenerateThinkingTitleResponse(String title) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java new file mode 100644 index 00000000..a88e2c6c --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/GetDefaultFileSafetyRulesResult.java @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Result of the {@code getDefaultFileSafetyRules} CLS request. + */ +public class GetDefaultFileSafetyRulesResult { + + private List<FileSafetyRuleInfo> defaultRules; + + /** Default constructor for Gson deserialization. */ + public GetDefaultFileSafetyRulesResult() { + } + + /** + * Creates a new result with the given default rules. + * + * @param defaultRules the list of default file safety rules + */ + public GetDefaultFileSafetyRulesResult( + List<FileSafetyRuleInfo> defaultRules) { + this.defaultRules = defaultRules; + } + + public List<FileSafetyRuleInfo> getDefaultRules() { + return defaultRules; + } + + public void setDefaultRules(List<FileSafetyRuleInfo> defaultRules) { + this.defaultRules = defaultRules; + } + + @Override + public int hashCode() { + return Objects.hash(defaultRules); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GetDefaultFileSafetyRulesResult other = (GetDefaultFileSafetyRulesResult) obj; + return Objects.equals(defaultRules, other.defaultRules); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("defaultRules", defaultRules) + .toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java index ba40b954..2066c74c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/InvokeClientToolConfirmationParams.java @@ -52,6 +52,16 @@ public class InvokeClientToolConfirmationParams { */ private String toolCallId; + /** + * The MCP tool annotations describing tool behavior hints. + */ + private ToolAnnotations annotations; + + /** + * Additional metadata associated with the tool invocation. + */ + private ToolMetadata toolMetadata; + public String getName() { return name; } @@ -116,9 +126,26 @@ public void setToolCallId(String toolCallId) { this.toolCallId = toolCallId; } + public ToolAnnotations getAnnotations() { + return annotations; + } + + public void setAnnotations(ToolAnnotations annotations) { + this.annotations = annotations; + } + + public ToolMetadata getToolMetadata() { + return toolMetadata; + } + + public void setToolMetadata(ToolMetadata toolMetadata) { + this.toolMetadata = toolMetadata; + } + @Override public int hashCode() { - return Objects.hash(conversationId, input, message, name, roundId, title, toolCallId, turnId); + return Objects.hash(annotations, conversationId, input, message, name, roundId, title, toolCallId, toolMetadata, + turnId); } @Override @@ -133,10 +160,11 @@ public boolean equals(Object obj) { return false; } InvokeClientToolConfirmationParams other = (InvokeClientToolConfirmationParams) obj; - return Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) + return Objects.equals(annotations, other.annotations) + && Objects.equals(conversationId, other.conversationId) && Objects.equals(input, other.input) && Objects.equals(message, other.message) && Objects.equals(name, other.name) && roundId == other.roundId && Objects.equals(title, other.title) && Objects.equals(toolCallId, other.toolCallId) - && Objects.equals(turnId, other.turnId); + && Objects.equals(toolMetadata, other.toolMetadata) && Objects.equals(turnId, other.turnId); } @Override @@ -150,6 +178,8 @@ public String toString() { builder.append("turnId", turnId); builder.append("roundId", roundId); builder.append("toolCallId", toolCallId); + builder.append("annotations", annotations); + builder.append("toolMetadata", toolMetadata); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java new file mode 100644 index 00000000..e7ff3e84 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Optional model metadata associated with a conversation turn. Mirrors the {@code modelInfo} field on the + * {@code conversation/create} and {@code conversation/turn} LSP requests and responses. + * + * <p>The language server resolves the turn's model from {@code id} (and {@code providerName} for BYOK models) in + * preference to the legacy {@code model} / {@code modelProviderName} request fields, so {@code id} should carry the + * concrete model id of the user-selected model. The {@code reasoningEffort} field carries the user-selected reasoning + * effort level (e.g. {@code low}, {@code medium}, {@code high}) when the model surfaces selectable effort levels. + * + * @param id model identifier (optional) + * @param providerName provider name (optional) + * @param reasoningEffort user-selected reasoning effort (optional) + * @param contextSize context size (optional) + */ +public record ModelInfo(String id, String providerName, String reasoningEffort, String contextSize) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java new file mode 100644 index 00000000..e5363a88 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/TemplateSource.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import com.google.gson.annotations.SerializedName; + +/** + * Source of a conversation template. + */ +public enum TemplateSource { + @SerializedName("builtin") + BUILTIN, + + @SerializedName("prompt") + PROMPT, + + @SerializedName("skill") + SKILL +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Thinking.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Thinking.java new file mode 100644 index 00000000..e325e8c2 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Thinking.java @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +/** + * Wire-level "thinking" payload streamed from the language server inside ChatProgressValue. Each report carries a + * delta; callers accumulate the deltas across reports. + * + * @param id the (optional) identifier of the thinking block + * @param text the delta text for this report; callers should treat blank text as "no content" + * @param encrypted the (optional) encrypted form of the thinking content + */ +public record Thinking(String id, String text, String encrypted) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ThinkingTypeAdapter.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ThinkingTypeAdapter.java new file mode 100644 index 00000000..95b43de9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ThinkingTypeAdapter.java @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.io.IOException; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +/** + * TypeAdapter for {@link Thinking} that tolerates the server's mixed wire shape for + * {@code text}: it may be a string delta (e.g. {@code "text":"The"}) or an array + * (e.g. {@code "text":[]}) when the report carries only an encrypted payload, or a + * multi-fragment array of deltas. The array form is concatenated into a single string + * (or {@code null} when empty) so the rest of the UI treats the report as scalar. + */ +public class ThinkingTypeAdapter extends TypeAdapter<Thinking> { + private final TypeAdapter<Thinking> delegate; + private final TypeAdapter<JsonElement> elementAdapter; + + /** + * Construct a new adapter that delegates to the given default adapter after the + * {@code text} field has been normalized. + * + * @param delegate the Gson-generated default adapter for {@link Thinking} + * @param elementAdapter the adapter used to read the JSON tree + */ + public ThinkingTypeAdapter(TypeAdapter<Thinking> delegate, TypeAdapter<JsonElement> elementAdapter) { + this.delegate = delegate; + this.elementAdapter = elementAdapter; + } + + /** TypeAdapterFactory for {@link Thinking}. */ + public static final class Factory implements TypeAdapterFactory { + @SuppressWarnings("unchecked") + @Override + public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) { + if (typeToken.getRawType() != Thinking.class) { + return null; + } + TypeAdapter<Thinking> defaultAdapter = gson.getDelegateAdapter(this, TypeToken.get(Thinking.class)); + TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class); + return (TypeAdapter<T>) new ThinkingTypeAdapter(defaultAdapter, elementAdapter); + } + } + + @Override + public Thinking read(JsonReader in) throws IOException { + JsonElement element = elementAdapter.read(in); + if (element != null && element.isJsonObject()) { + JsonObject obj = element.getAsJsonObject(); + JsonElement text = obj.get("text"); + if (text != null && text.isJsonArray()) { + obj.add("text", flattenTextArray(text.getAsJsonArray())); + } + } + return delegate.fromJsonTree(element); + } + + private static JsonElement flattenTextArray(JsonArray arr) { + StringBuilder sb = new StringBuilder(); + for (JsonElement e : arr) { + if (!e.isJsonNull() && e.isJsonPrimitive()) { + JsonPrimitive prim = e.getAsJsonPrimitive(); + if (prim.isString()) { + sb.append(prim.getAsString()); + } + } + } + return sb.length() == 0 ? JsonNull.INSTANCE : new JsonPrimitive(sb.toString()); + } + + @Override + public void write(JsonWriter out, Thinking value) throws IOException { + delegate.write(out, value); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java new file mode 100644 index 00000000..a59f3598 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolAnnotations.java @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * MCP tool annotations describing tool behavior hints (e.g., readOnly, destructive). + * Provided by CLS, originally sourced from the MCP server's tool definition. + */ +public class ToolAnnotations { + private boolean readOnlyHint; + private boolean destructiveHint; + private boolean idempotentHint; + private boolean openWorldHint; + + public boolean isReadOnlyHint() { + return readOnlyHint; + } + + public void setReadOnlyHint(boolean readOnlyHint) { + this.readOnlyHint = readOnlyHint; + } + + public boolean isDestructiveHint() { + return destructiveHint; + } + + public void setDestructiveHint(boolean destructiveHint) { + this.destructiveHint = destructiveHint; + } + + public boolean isIdempotentHint() { + return idempotentHint; + } + + public void setIdempotentHint(boolean idempotentHint) { + this.idempotentHint = idempotentHint; + } + + public boolean isOpenWorldHint() { + return openWorldHint; + } + + public void setOpenWorldHint(boolean openWorldHint) { + this.openWorldHint = openWorldHint; + } + + @Override + public int hashCode() { + return Objects.hash(readOnlyHint, destructiveHint, idempotentHint, openWorldHint); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ToolAnnotations other = (ToolAnnotations) obj; + return readOnlyHint == other.readOnlyHint + && destructiveHint == other.destructiveHint + && idempotentHint == other.idempotentHint + && openWorldHint == other.openWorldHint; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("readOnlyHint", readOnlyHint); + builder.append("destructiveHint", destructiveHint); + builder.append("idempotentHint", idempotentHint); + builder.append("openWorldHint", openWorldHint); + return builder.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java new file mode 100644 index 00000000..9883a504 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ToolMetadata.java @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.Arrays; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; + +/** + * Tool-specific metadata provided by CLS as part of the confirmation request, used by the + * auto-approve system to make confirmation decisions. + */ +public class ToolMetadata { + + private TerminalCommandData terminalCommandData; + private SensitiveFileData sensitiveFileData; + + public TerminalCommandData getTerminalCommandData() { + return terminalCommandData; + } + + public void setTerminalCommandData(TerminalCommandData terminalCommandData) { + this.terminalCommandData = terminalCommandData; + } + + public SensitiveFileData getSensitiveFileData() { + return sensitiveFileData; + } + + public void setSensitiveFileData(SensitiveFileData sensitiveFileData) { + this.sensitiveFileData = sensitiveFileData; + } + + @Override + public int hashCode() { + return Objects.hash(terminalCommandData, sensitiveFileData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ToolMetadata other = (ToolMetadata) obj; + return Objects.equals(terminalCommandData, other.terminalCommandData) + && Objects.equals(sensitiveFileData, other.sensitiveFileData); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("terminalCommandData", terminalCommandData); + builder.append("sensitiveFileData", sensitiveFileData); + return builder.toString(); + } + + /** + * Data describing a terminal command and its sub-commands. + */ + public static class TerminalCommandData { + private String[] subCommands; + private String[] commandNames; + + public String[] getSubCommands() { + return subCommands; + } + + public void setSubCommands(String[] subCommands) { + this.subCommands = subCommands; + } + + public String[] getCommandNames() { + return commandNames; + } + + public void setCommandNames(String[] commandNames) { + this.commandNames = commandNames; + } + + @Override + public int hashCode() { + return Objects.hash(Arrays.hashCode(subCommands), Arrays.hashCode(commandNames)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TerminalCommandData other = (TerminalCommandData) obj; + return Arrays.equals(subCommands, other.subCommands) + && Arrays.equals(commandNames, other.commandNames); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("subCommands", subCommands); + builder.append("commandNames", commandNames); + return builder.toString(); + } + } + + /** + * Data describing a sensitive file that requires confirmation. + */ + public static class SensitiveFileData { + private String filePath; + private String matchingRule; + private String ruleDescription; + private boolean isGlobal; + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public String getMatchingRule() { + return matchingRule; + } + + public void setMatchingRule(String matchingRule) { + this.matchingRule = matchingRule; + } + + public String getRuleDescription() { + return ruleDescription; + } + + public void setRuleDescription(String ruleDescription) { + this.ruleDescription = ruleDescription; + } + + public boolean isGlobal() { + return isGlobal; + } + + public void setGlobal(boolean isGlobal) { + this.isGlobal = isGlobal; + } + + @Override + public int hashCode() { + return Objects.hash(filePath, matchingRule, ruleDescription, isGlobal); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + SensitiveFileData other = (SensitiveFileData) obj; + return Objects.equals(filePath, other.filePath) + && Objects.equals(matchingRule, other.matchingRule) + && Objects.equals(ruleDescription, other.ruleDescription) + && isGlobal == other.isGlobal; + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("filePath", filePath); + builder.append("matchingRule", matchingRule); + builder.append("ruleDescription", ruleDescription); + builder.append("isGlobal", isGlobal); + return builder.toString(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Turn.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Turn.java index ead4b7f9..d1463ecb 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Turn.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/Turn.java @@ -18,6 +18,7 @@ public class Turn { Either<String, List<ChatCompletionContentPart>> request; String response; String agentSlug; + String turnId; /** * Creates a new Turn. @@ -28,6 +29,17 @@ public Turn(@NonNull Either<String, List<ChatCompletionContentPart>> request, St this.agentSlug = agentSlug; } + /** + * Creates a new Turn with turnId. + */ + public Turn(@NonNull Either<String, List<ChatCompletionContentPart>> request, String response, String agentSlug, + String turnId) { + this.request = request; + this.response = response; + this.agentSlug = agentSlug; + this.turnId = turnId; + } + public Either<String, List<ChatCompletionContentPart>> getRequest() { return request; } @@ -52,9 +64,17 @@ public void setAgentSlug(String agentSlug) { this.agentSlug = agentSlug; } + public String getTurnId() { + return turnId; + } + + public void setTurnId(String turnId) { + this.turnId = turnId; + } + @Override public int hashCode() { - return Objects.hash(request, response, agentSlug); + return Objects.hash(request, response, agentSlug, turnId); } @Override @@ -67,7 +87,7 @@ public boolean equals(Object o) { } Turn turn = (Turn) o; return Objects.equals(request, turn.request) && Objects.equals(response, turn.response) - && Objects.equals(agentSlug, turn.agentSlug); + && Objects.equals(agentSlug, turn.agentSlug) && Objects.equals(turnId, turn.turnId); } @Override @@ -76,6 +96,7 @@ public String toString() { builder.append("request", request); builder.append("response", response); builder.append("agentSlug", agentSlug); + builder.append("turnId", turnId); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java new file mode 100644 index 00000000..5f3e3574 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/WorkspaceFoldersParams.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol; + +import java.util.List; + +import org.eclipse.lsp4j.WorkspaceFolder; + +/** + * Generic parameters for language-server requests scoped to a set of workspace folders. + * + * @param workspaceFolders the workspace folders to scan + */ +public record WorkspaceFoldersParams(List<WorkspaceFolder> workspaceFolders) { + /** Compact constructor that defensively copies the folders, defaulting {@code null} to empty. */ + public WorkspaceFoldersParams { + workspaceFolders = workspaceFolders != null ? List.copyOf(workspaceFolders) : List.of(); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java index b5087205..3d21772a 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/policy/DidChangePolicyParams.java @@ -22,6 +22,9 @@ public class DidChangePolicyParams { @SerializedName("customAgent.enabled") private boolean customAgentEnabled = true; + @SerializedName("agentMode.autoApproval.enabled") + private boolean autoApprovalPolicyEnabled = true; + public boolean isMcpContributionPointEnabled() { return mcpContributionPointEnabled; } @@ -46,9 +49,18 @@ public void setCustomAgentEnabled(boolean customAgentEnabled) { this.customAgentEnabled = customAgentEnabled; } + public boolean isAutoApprovalPolicyEnabled() { + return autoApprovalPolicyEnabled; + } + + public void setAutoApprovalPolicyEnabled(boolean autoApprovalPolicyEnabled) { + this.autoApprovalPolicyEnabled = autoApprovalPolicyEnabled; + } + @Override public int hashCode() { - return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled); + return Objects.hash(mcpContributionPointEnabled, subAgentEnabled, customAgentEnabled, + autoApprovalPolicyEnabled); } @Override @@ -65,7 +77,8 @@ public boolean equals(Object obj) { DidChangePolicyParams other = (DidChangePolicyParams) obj; return mcpContributionPointEnabled == other.mcpContributionPointEnabled && subAgentEnabled == other.subAgentEnabled - && customAgentEnabled == other.customAgentEnabled; + && customAgentEnabled == other.customAgentEnabled + && autoApprovalPolicyEnabled == other.autoApprovalPolicyEnabled; } @Override @@ -74,6 +87,7 @@ public String toString() { builder.append("mcpContributionPointEnabled", mcpContributionPointEnabled); builder.append("subAgentEnabled", subAgentEnabled); builder.append("customAgentEnabled", customAgentEnabled); + builder.append("autoApprovalPolicyEnabled", autoApprovalPolicyEnabled); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java index 64f34dc4..5bdbda99 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CheckQuotaResult.java @@ -3,90 +3,37 @@ package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; -import java.util.Objects; - -import org.apache.commons.lang3.builder.ToStringBuilder; - /** - * Result of the checkQuota request. + * Result of the {@code checkQuota} request. + * + * @param chat chat quota snapshot + * @param completions completions quota snapshot + * @param premiumInteractions premium interactions quota snapshot + * @param resetDate ISO-8601 local date when the monthly allowance resets, or {@code null} + * @param resetDateUtc ISO-8601 instant when the monthly allowance resets in UTC, or {@code null} + * @param copilotPlan the user's Copilot plan + * @param tokenBasedBillingEnabled whether the user's billing is token-based + * @param canUpgradePlan whether the user is eligible to upgrade their Copilot plan; {@code null} when the language + * server has not supplied this field, in which case callers should fall back to plan-based defaults */ -public class CheckQuotaResult { - private Quota chat; - private Quota completions; - private Quota premiumInteractions; - private String resetDate; - private CopilotPlan copilotPlan; - - public Quota getChatQuota() { - return chat; - } - - public void setChatQuota(Quota chat) { - this.chat = chat; - } - - public Quota getCompletionsQuota() { - return completions; - } - - public void setCompletionsQuota(Quota completions) { - this.completions = completions; - } - - public Quota getPremiumInteractionsQuota() { - return premiumInteractions; - } - - public void setPremiumInteractionsQuota(Quota premiumInteractions) { - this.premiumInteractions = premiumInteractions; - } - - public String getResetDate() { - return resetDate; - } - - public void setResetDate(String resetDate) { - this.resetDate = resetDate; - } - - public CopilotPlan getCopilotPlan() { - return copilotPlan; - } - - public void setCopilotPlan(CopilotPlan copilotPlan) { - this.copilotPlan = copilotPlan; - } - - @Override - public int hashCode() { - return Objects.hash(chat, completions, copilotPlan, premiumInteractions, resetDate); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - CheckQuotaResult other = (CheckQuotaResult) obj; - return Objects.equals(chat, other.chat) && Objects.equals(completions, other.completions) - && copilotPlan == other.copilotPlan && Objects.equals(premiumInteractions, other.premiumInteractions) - && Objects.equals(resetDate, other.resetDate); - } - - @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("chat", chat); - builder.append("completions", completions); - builder.append("premiumInteractions", premiumInteractions); - builder.append("resetDate", resetDate); - builder.append("copilotPlan", copilotPlan); - return builder.toString(); +public record CheckQuotaResult( + Quota chat, + Quota completions, + Quota premiumInteractions, + String resetDate, + String resetDateUtc, + CopilotPlan copilotPlan, + boolean tokenBasedBillingEnabled, + Boolean canUpgradePlan) { + + private static final CheckQuotaResult EMPTY = + new CheckQuotaResult(null, null, null, null, null, null, false, null); + + /** + * Returns an empty {@link CheckQuotaResult} used as a placeholder before the language server + * supplies real quota data. + */ + public static CheckQuotaResult empty() { + return EMPTY; } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java index 674dc6a0..d0b2b222 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/CopilotPlan.java @@ -7,5 +7,5 @@ * Enum representing the different Copilot plans. */ public enum CopilotPlan { - free, individual, individual_pro, business, enterprise + free, individual, individual_pro, individual_max, business, enterprise } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java index 9d712884..75ec40c1 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/Quota.java @@ -5,60 +5,41 @@ import java.util.Objects; -import org.apache.commons.lang3.builder.ToStringBuilder; - /** - * Completions quota information. + * Quota information for a single tracked category (chat, completions, or premium interactions). + * + * <p>Equality intentionally excludes {@link #timeStamp} so that two snapshots with the same + * display-meaningful state compare equal even when the language server stamps a different + * production time on each refresh. + * + * @param percentRemaining percentage of the quota remaining; clamped into {@code [0.0, 100.0]} by + * the accessor since the language server may report drift slightly outside that range + * @param unlimited whether this category has no monthly limit + * @param overagePermitted whether the user has enabled additional paid usage beyond the allowance + * @param overageCount additional paid units already consumed, when reported + * @param entitlement total monthly allowance, when reported + * @param quotaRemaining absolute units remaining in the monthly allowance, when reported + * @param timeStamp ISO-8601 timestamp of when the snapshot was produced by the language server; + * not part of {@link #equals(Object)} / {@link #hashCode()} */ -public class Quota { - private double percentRemaining; - private boolean unlimited; - private boolean overagePermitted; - - /** - * Creates a new CompletionsQuota quota information with default values. - */ - public Quota() { - this.percentRemaining = 0.0; - this.unlimited = false; - this.overagePermitted = false; - } +public record Quota( + double percentRemaining, + boolean unlimited, + boolean overagePermitted, + double overageCount, + double entitlement, + double quotaRemaining, + String timeStamp) { /** - * Gets the percentage of the quota remaining within the range of 0.0 to 100.0. + * Returns the percentage of the quota remaining, clamped into the {@code [0.0, 100.0]} range. */ - public double getPercentRemaining() { + public Quota { if (percentRemaining < 0.0) { - return 0.0; + percentRemaining = 0.0; } else if (percentRemaining > 100.0) { - return 100.0; + percentRemaining = 100.0; } - return percentRemaining; - } - - public void setPercentRemaining(double percentRemaining) { - this.percentRemaining = percentRemaining; - } - - public boolean isUnlimited() { - return unlimited; - } - - public void setUnlimited(boolean unlimited) { - this.unlimited = unlimited; - } - - public boolean isOveragePermitted() { - return overagePermitted; - } - - public void setOveragePermitted(boolean overagePermitted) { - this.overagePermitted = overagePermitted; - } - - @Override - public int hashCode() { - return Objects.hash(overagePermitted, percentRemaining, unlimited); } @Override @@ -66,24 +47,19 @@ public boolean equals(Object obj) { if (this == obj) { return true; } - if (obj == null) { + if (!(obj instanceof Quota other)) { return false; } - if (getClass() != obj.getClass()) { - return false; - } - Quota other = (Quota) obj; - return overagePermitted == other.overagePermitted - && Double.doubleToLongBits(percentRemaining) == Double.doubleToLongBits(other.percentRemaining) - && unlimited == other.unlimited; + return Double.compare(percentRemaining, other.percentRemaining) == 0 + && unlimited == other.unlimited + && overagePermitted == other.overagePermitted + && Double.compare(overageCount, other.overageCount) == 0 + && Double.compare(entitlement, other.entitlement) == 0 + && Double.compare(quotaRemaining, other.quotaRemaining) == 0; } @Override - public String toString() { - ToStringBuilder builder = new ToStringBuilder(this); - builder.append("percentRemaining", percentRemaining); - builder.append("unlimited", unlimited); - builder.append("overagePermitted", overagePermitted); - return builder.toString(); + public int hashCode() { + return Objects.hash(percentRemaining, unlimited, overagePermitted, overageCount, entitlement, quotaRemaining); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java new file mode 100644 index 00000000..d4500ad1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaSnapshotParams.java @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +/** + * Snapshot of a single quota bucket (chat, completions, or premium interactions) shipped with + * {@code copilot/quotaChange} and {@code copilot/quotaWarning} notifications. + * + * @param quota total entitlement + * @param used computed amount used (entitlement * (1 - percentRemaining / 100)) + * @param percentRemaining percentage of the quota remaining (0-100) + * @param overageUsed overage amount consumed + * @param overageEnabled whether overages are permitted + * @param resetDate ISO 8601 timestamp when the quota resets, or empty when unknown + * @param unlimited true when the quota is unlimited + */ +public record QuotaSnapshotParams(double quota, double used, double percentRemaining, double overageUsed, + boolean overageEnabled, String resetDate, boolean unlimited) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java new file mode 100644 index 00000000..033e3396 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +import com.google.gson.annotations.SerializedName; + +/** + * Parameters for the {@code copilot/quotaWarning} notification, sent by the language server when + * the user crosses a quota usage threshold or starts consuming overages. + * + * @param title warning title (e.g. "Copilot Quota Usage Alert") + * @param message human-readable warning message + * @param severity severity level, either {@code "warning"} or {@code "info"} + * @param chat current chat quota snapshot, when available + * @param completions current completions quota snapshot, when available + * @param premiumInteractions current premium interactions quota snapshot, when available + * @param copilotPlan the user's Copilot plan + * @param canUpgradePlan whether the user is eligible to upgrade their Copilot plan; {@code null} when the language + * server has not supplied this field, in which case callers should fall back to plan-based defaults + */ +public record QuotaWarningParams(String title, String message, String severity, QuotaSnapshotParams chat, + QuotaSnapshotParams completions, + @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, CopilotPlan copilotPlan, + Boolean canUpgradePlan) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java index 8ba73a6e..bdf2c567 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.eclipse.lsp4j.jsonrpc.messages.Either; @@ -19,20 +20,24 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatCompletionContentPart; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationError; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; -import com.microsoft.copilot.eclipse.core.utils.ChatMessageUtils; /** * Factory for creating and transforming conversation data objects. Responsible only for pure data transformation with * no business logic. */ public class ConversationDataFactory { + private static final int SYNTHETIC_ROUND_ID = -1; + private final AuthStatusManager authStatusManager; /** @@ -96,12 +101,16 @@ public CopilotTurnData createCopilotTurnData(String turnId) { * * @param reply the reply data to update * @param progress the progress value to extract data from + * @param thinkingBlockId the UI-generated thinking block ID, when the progress belongs to a thinking round */ - public void updateReplyFromProgress(ReplyData reply, ChatProgressValue progress) { + public void updateReplyFromProgress(ReplyData reply, ChatProgressValue progress, String thinkingBlockId) { ensureReplyInitialized(reply); + // Accumulate thinking content + appendThinkingContent(reply, progress.getThinking(), thinkingBlockId); + // Merge agent rounds and append tool calls - mergeAgentRounds(reply, progress.getAgentRounds()); + mergeAgentRounds(reply, progress.getAgentRounds(), thinkingBlockId); // Handle plain reply streaming (no agentRounds) appendProgressReplyText(reply, progress.getReply()); @@ -118,27 +127,20 @@ public void updateReplyFromProgress(ReplyData reply, ChatProgressValue progress) reply.setHideText(progress.isHideText()); } - private void mergeAgentRounds(ReplyData reply, List<AgentRound> agentRounds) { + private void mergeAgentRounds(ReplyData reply, List<AgentRound> agentRounds, String thinkingBlockId) { if (agentRounds == null || agentRounds.isEmpty()) { return; } for (AgentRound round : agentRounds) { EditAgentRoundData existingRound = findRoundById(reply.getEditAgentRounds(), round.getRoundId()); + if (existingRound == null) { + existingRound = findThinkingPlaceholderRound(reply.getEditAgentRounds(), thinkingBlockId); + } if (existingRound == null) { EditAgentRoundData er = convertAgentRoundToEditAgentRoundData(round); reply.getEditAgentRounds().add(er); } else { - appendReplyToAgentRound(existingRound, round.getReply()); - if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { - for (AgentToolCall tc : round.getToolCalls()) { - ToolCallData existingToolCall = findToolCallById(existingRound.getToolCalls(), tc.getId()); - if (existingToolCall == null) { - existingRound.getToolCalls().add(convertAgentToolCallToToolCallData(tc)); - } else { - updateToolCallData(existingToolCall, tc); - } - } - } + updateAgentRoundData(existingRound, round); } } } @@ -173,6 +175,19 @@ private void appendReplyToAgentRound(EditAgentRoundData round, String addition) round.setReply(existingReply + addition); } + private void appendThinkingContent(ReplyData reply, Thinking thinking, String thinkingBlockId) { + // Preserve whitespace-only thinking fragments; they can carry markdown boundaries between sections. + if (thinking == null || StringUtils.isEmpty(thinking.text())) { + return; + } + if (StringUtils.isBlank(thinkingBlockId)) { + return; + } + EditAgentRoundData round = getOrCreateThinkingRound(reply, thinkingBlockId); + ThinkingBlockData block = round.getThinkingBlock(); + block.setContent(StringUtils.defaultString(block.getContent()) + thinking.text()); + } + private void applyConversationError(ReplyData reply, ConversationError error) { if (error == null) { return; @@ -180,6 +195,7 @@ private void applyConversationError(ReplyData reply, ConversationError error) { ErrorData errorData = new ErrorData(); errorData.setMessage(error.getMessage()); errorData.setCode(error.getCode()); + errorData.setModelProviderName(error.getModelProviderName()); ErrorMessageData em = new ErrorMessageData(); em.setError(errorData); reply.getErrorMessages().add(em); @@ -208,24 +224,43 @@ public List<Turn> convertToTurns(List<AbstractTurnData> turnDataList) { // Defensive copy to avoid ConcurrentModificationException if another thread mutates the list while iterating. List<AbstractTurnData> snapshot = new ArrayList<>(turnDataList); List<Turn> result = new ArrayList<>(snapshot.size()); + Turn unpairedUserTurn = null; + for (AbstractTurnData turnData : snapshot) { if (turnData == null) { continue; } + // Skip subagent turns - they are not part of the main conversation history + if (turnData instanceof CopilotTurnData copilotCheck + && copilotCheck.getParentTurnId() != null) { + continue; + } if (turnData instanceof UserTurnData userTurnData) { + // Flush any unpaired user turn without a response + if (unpairedUserTurn != null) { + result.add(unpairedUserTurn); + } String requestText = userTurnData.getMessage() != null ? userTurnData.getMessage().getText() : ""; Either<String, List<ChatCompletionContentPart>> request = Either .forLeft(requestText == null ? "" : requestText); - result.add(new Turn(request, null, null)); + unpairedUserTurn = new Turn(request, null, null, turnData.getTurnId()); } else if (turnData instanceof CopilotTurnData copilotTurnData) { - // TODO: We don't persist images for now, so hard code the modelSupportVersion to false. In the future, handle - // images in responses and pass the model support version here if needed. String responseText = extractResponseFromCopilotTurnData(copilotTurnData); - Either<String, List<ChatCompletionContentPart>> response = ChatMessageUtils - .createMessageWithImages(responseText, new ArrayList<>(), false); - result.add(new Turn(response, responseText, null)); + if (unpairedUserTurn != null) { + // Pair the response with the unpaired user turn + unpairedUserTurn.setResponse(responseText); + result.add(unpairedUserTurn); + unpairedUserTurn = null; + } else { + // Orphaned copilot turn (no preceding user turn), create a standalone turn + result.add(new Turn(Either.forLeft(""), responseText, null, turnData.getTurnId())); + } } } + // Flush any remaining unpaired user turn (user message without a response) + if (unpairedUserTurn != null) { + result.add(unpairedUserTurn); + } return result; } @@ -259,6 +294,69 @@ private String extractResponseFromCopilotTurnData(CopilotTurnData copilotTurnDat } // Private helper methods for data transformation + private EditAgentRoundData getOrCreateThinkingRound(ReplyData reply, String thinkingBlockId) { + EditAgentRoundData existingRound = findRoundByThinkingBlockId(reply.getEditAgentRounds(), thinkingBlockId); + if (existingRound != null) { + return existingRound; + } + EditAgentRoundData round = new EditAgentRoundData(); + round.setRoundId(SYNTHETIC_ROUND_ID); + round.setToolCalls(new ArrayList<>()); + round.setThinkingBlock(new ThinkingBlockData(thinkingBlockId, "")); + reply.getEditAgentRounds().add(round); + return round; + } + + private void updateAgentRoundData(EditAgentRoundData target, AgentRound source) { + target.setRoundId(source.getRoundId()); + appendReplyToAgentRound(target, source.getReply()); + ThinkingBlockData thinkingBlock = target.getThinkingBlock(); + if (thinkingBlock != null && !thinkingBlock.isFinalized()) { + thinkingBlock.setState(ThinkingBlockState.COMPLETED); + } + if (source.getToolCalls() == null || source.getToolCalls().isEmpty()) { + return; + } + if (target.getToolCalls() == null) { + target.setToolCalls(new ArrayList<>()); + } + for (AgentToolCall toolCall : source.getToolCalls()) { + ToolCallData existingToolCall = findToolCallById(target.getToolCalls(), toolCall.getId()); + if (existingToolCall == null) { + target.getToolCalls().add(convertAgentToolCallToToolCallData(toolCall)); + } else { + updateToolCallData(existingToolCall, toolCall); + } + } + } + + private EditAgentRoundData findRoundByThinkingBlockId(List<EditAgentRoundData> rounds, String thinkingBlockId) { + if (rounds == null || StringUtils.isBlank(thinkingBlockId)) { + return null; + } + for (EditAgentRoundData round : rounds) { + ThinkingBlockData thinkingBlock = round.getThinkingBlock(); + if (thinkingBlock != null && thinkingBlockId.equals(thinkingBlock.getId())) { + return round; + } + } + return null; + } + + private EditAgentRoundData findThinkingPlaceholderRound(List<EditAgentRoundData> rounds, String thinkingBlockId) { + if (rounds == null || StringUtils.isBlank(thinkingBlockId)) { + return null; + } + for (EditAgentRoundData round : rounds) { + ThinkingBlockData thinkingBlock = round.getThinkingBlock(); + if (Objects.equals(round.getRoundId(), SYNTHETIC_ROUND_ID) && thinkingBlock != null + && thinkingBlockId.equals(thinkingBlock.getId())) { + return round; + } + } + return null; + } + private EditAgentRoundData findRoundById(List<EditAgentRoundData> rounds, int roundId) { if (rounds == null) { return null; diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java index 304ae253..6916e15c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationPersistenceManager.java @@ -20,10 +20,16 @@ import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepStatus; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; /** @@ -201,12 +207,18 @@ public CompletableFuture<ConversationData> persistUserTurnInfo(String conversati /** * Updates a conversation with progress data and caches it only (no disk persistence). + * + * @param conversationId the conversation ID + * @param progress the progress value + * @param thinkingBlockId the UI-generated thinking block ID, when the progress belongs to a thinking round */ - public CompletableFuture<Void> cacheConversationProgress(String conversationId, ChatProgressValue progress) { + public CompletableFuture<Void> cacheConversationProgress(String conversationId, ChatProgressValue progress, + String thinkingBlockId) { return CompletableFuture.runAsync(() -> { lock.writeLock().lock(); try { - ConversationData conversationData = updateConversationProgressInternal(conversationId, progress); + ConversationData conversationData = updateConversationProgressInternal(conversationId, progress, + thinkingBlockId); conversationCache.put(conversationId, conversationData); } catch (Exception e) { CopilotCore.LOGGER.error("Failed to cache conversation progress: " + conversationId, e); @@ -218,12 +230,18 @@ public CompletableFuture<Void> cacheConversationProgress(String conversationId, /** * Updates a conversation with progress data and persists it to disk. + * + * @param conversationId the conversation ID + * @param progress the progress value + * @param thinkingBlockId the UI-generated thinking block ID, when the progress belongs to a thinking round */ - public CompletableFuture<Void> persistConversationProgress(String conversationId, ChatProgressValue progress) { + public CompletableFuture<Void> persistConversationProgress(String conversationId, ChatProgressValue progress, + String thinkingBlockId) { return CompletableFuture.runAsync(() -> { lock.writeLock().lock(); try { - ConversationData conversationData = updateConversationProgressInternal(conversationId, progress); + ConversationData conversationData = updateConversationProgressInternal(conversationId, progress, + thinkingBlockId); persistAndCacheConversation(conversationData); } catch (IOException e) { CopilotCore.LOGGER.error("Failed to persist conversation progress: " + conversationId, e); @@ -254,6 +272,41 @@ public CompletableFuture<Void> persistCachedConversation(String conversationId) }); } + /** + * Marks cached running tool calls as cancelled, then persists the cached conversation to disk. + * + * @param conversationId the ID of the cached conversation to persist + * @return a future that completes when the cached conversation has been persisted + */ + public CompletableFuture<Void> markRunningToolCallsCancelledAndPersist(String conversationId) { + if (StringUtils.isBlank(conversationId)) { + return CompletableFuture.completedFuture(null); + } + + ConversationData conversationData; + lock.writeLock().lock(); + try { + conversationData = conversationCache.get(conversationId); + if (conversationData == null) { + return CompletableFuture.completedFuture(null); + } + markRunningToolCallsCancelled(conversationData); + } finally { + lock.writeLock().unlock(); + } + + return CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + persistAndCacheConversation(conversationData); + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to persist cancelled tool calls for conversation: " + conversationId, e); + } finally { + lock.writeLock().unlock(); + } + }); + } + /** * Updates a conversation with progress data. This method is synchronous and handles all IO operations internally. */ @@ -262,7 +315,7 @@ public CompletableFuture<ConversationData> updateConversationProgress(String con return CompletableFuture.supplyAsync(() -> { lock.writeLock().lock(); try { - return updateConversationProgressInternal(conversationId, progress); + return updateConversationProgressInternal(conversationId, progress, null); } catch (IOException e) { CopilotCore.LOGGER.error("Failed to update conversation progress: " + conversationId, e); throw new RuntimeException("Failed to update conversation progress", e); @@ -275,8 +328,8 @@ public CompletableFuture<ConversationData> updateConversationProgress(String con /** * Internal method to update conversation progress without locking (caller must hold write lock). */ - private ConversationData updateConversationProgressInternal(String conversationId, ChatProgressValue progress) - throws IOException { + private ConversationData updateConversationProgressInternal(String conversationId, ChatProgressValue progress, + String thinkingBlockId) throws IOException { ConversationData conversationData = getOrCreateNewConversationById(conversationId); // Update conversation metadata using factory @@ -284,7 +337,13 @@ private ConversationData updateConversationProgressInternal(String conversationI // Find or create turn and update it CopilotTurnData copilotTurnData = findOrCreateCopilotTurn(conversationData, progress.getTurnId()); - dataFactory.updateReplyFromProgress(copilotTurnData.getReply(), progress); + ReplyData reply = copilotTurnData.getReply(); + dataFactory.updateReplyFromProgress(reply, progress, thinkingBlockId); + + // Mark subagent turns with their parent turn ID + if (StringUtils.isNotBlank(progress.getParentTurnId())) { + copilotTurnData.setParentTurnId(progress.getParentTurnId()); + } // Update suggested title in CopilotTurnData if present if (StringUtils.isNotBlank(progress.getSuggestedTitle())) { @@ -294,47 +353,121 @@ private ConversationData updateConversationProgressInternal(String conversationI return conversationData; } - private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) { - if (turnId != null) { - AbstractTurnData existingTurn = findTurn(conversation, turnId); - if (existingTurn != null && existingTurn instanceof UserTurnData userTurnData) { - return userTurnData; + /** + * Sets the title on the thinking block with the given ID. + * + * @param conversationId the conversation ID + * @param turnId the turn ID + * @param thinkingBlockId the thinking block ID + * @param title the generated title + */ + public CompletableFuture<Void> updateThinkingBlockTitle(String conversationId, String turnId, String thinkingBlockId, + String title) { + if (StringUtils.isAnyBlank(conversationId, turnId, thinkingBlockId, title)) { + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + ThinkingBlockData block = findThinkingBlock(conversationId, turnId, thinkingBlockId); + if (block != null) { + block.setTitle(title); + } + } finally { + lock.writeLock().unlock(); } + }); + } + + /** + * Cancels the thinking block with the given ID. + * + * @param conversationId the conversation ID + * @param turnId the turn ID + * @param thinkingBlockId the thinking block ID + */ + public CompletableFuture<Void> cancelThinkingBlock(String conversationId, String turnId, String thinkingBlockId) { + if (StringUtils.isAnyBlank(conversationId, turnId, thinkingBlockId)) { + return CompletableFuture.completedFuture(null); } + return CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + ThinkingBlockData block = findThinkingBlock(conversationId, turnId, thinkingBlockId); + if (block != null) { + block.setState(ThinkingBlockState.CANCELLED); + } + } finally { + lock.writeLock().unlock(); + } + }); + } - UserTurnData turn = dataFactory.createUserTurnData(conversation.getConversationId(), turnId, "", null, null, null); - conversation.getTurns().add(turn); - return turn; + private ThinkingBlockData findThinkingBlock(String conversationId, String turnId, String thinkingBlockId) { + ConversationData conversationData = conversationCache.get(conversationId); + if (conversationData == null) { + return null; + } + CopilotTurnData turn = findTurn(conversationData, turnId, CopilotTurnData.class); + if (turn == null || turn.getReply() == null) { + return null; + } + return findThinkingBlock(turn.getReply(), thinkingBlockId); } - private CopilotTurnData findOrCreateCopilotTurn(ConversationData conversation, String turnId) { - if (turnId != null) { - AbstractTurnData existingTurn = findTurn(conversation, turnId); - if (existingTurn != null && existingTurn instanceof CopilotTurnData copilotTurnData) { - return copilotTurnData; + private ThinkingBlockData findThinkingBlock(ReplyData reply, String thinkingBlockId) { + if (reply == null || StringUtils.isBlank(thinkingBlockId)) { + return null; + } + List<EditAgentRoundData> rounds = reply.getEditAgentRounds(); + if (rounds == null) { + return null; + } + for (EditAgentRoundData round : rounds) { + ThinkingBlockData block = round.getThinkingBlock(); + if (block != null && thinkingBlockId.equals(block.getId())) { + return block; } } - - CopilotTurnData turn = dataFactory.createCopilotTurnData(turnId); - conversation.getTurns().add(turn); - return turn; + return null; } /** - * Finds a turn by ID in the conversation. + * Finds a turn by ID and type in the conversation. */ - private AbstractTurnData findTurn(ConversationData conversation, String turnId) { - if (conversation == null || turnId == null) { + @SuppressWarnings("unchecked") + private <T extends AbstractTurnData> T findTurn(ConversationData conversation, String turnId, Class<T> type) { + if (turnId == null) { return null; } for (AbstractTurnData t : conversation.getTurns()) { - if (turnId.equals(t.getTurnId())) { - return t; + if (turnId.equals(t.getTurnId()) && type.isInstance(t)) { + return (T) t; } } return null; } + private UserTurnData findOrCreateUserTurn(ConversationData conversation, String turnId) { + UserTurnData existing = findTurn(conversation, turnId, UserTurnData.class); + if (existing != null) { + return existing; + } + UserTurnData turn = dataFactory.createUserTurnData(conversation.getConversationId(), turnId, "", null, null, null); + conversation.getTurns().add(turn); + return turn; + } + + private CopilotTurnData findOrCreateCopilotTurn(ConversationData conversation, String turnId) { + CopilotTurnData existing = findTurn(conversation, turnId, CopilotTurnData.class); + if (existing != null) { + return existing; + } + CopilotTurnData turn = dataFactory.createCopilotTurnData(turnId); + conversation.getTurns().add(turn); + return turn; + } + private ConversationData getOrCreateNewConversationById(String conversationId) throws IOException { try { ConversationData existedConversation = getConversationFromCacheOrLoadFromDisk(conversationId); @@ -402,6 +535,28 @@ private void persistAndCacheConversation(ConversationData conversation) throws I conversationCache.put(conversation.getConversationId(), conversation); } + private void markRunningToolCallsCancelled(ConversationData conversationData) { + if (conversationData.getTurns() == null) { + return; + } + for (AbstractTurnData turn : conversationData.getTurns()) { + if (!(turn instanceof CopilotTurnData copilotTurnData) || copilotTurnData.getReply() == null + || copilotTurnData.getReply().getEditAgentRounds() == null) { + continue; + } + for (EditAgentRoundData round : copilotTurnData.getReply().getEditAgentRounds()) { + if (round.getToolCalls() == null) { + continue; + } + for (ToolCallData toolCall : round.getToolCalls()) { + if (toolCall != null && ChatStepStatus.RUNNING.equalsIgnoreCase(toolCall.getStatus())) { + toolCall.setStatus(ChatStepStatus.CANCELLED); + } + } + } + } + } + /** * Removes a conversation by ID from both disk and in-memory cache. * @@ -504,15 +659,17 @@ public CompletableFuture<Void> addCodingAgentMessage(CodingAgentMessageRequestPa } /** - * Persists model information (model name and billing multiplier) for a Copilot turn. + * Persists model information (model name, billing multiplier, reasoning effort) for a Copilot turn. * * @param conversationId the ID of the conversation * @param turnId the ID of the turn * @param modelName the name of the model used * @param billingMultiplier the billing multiplier for the model + * @param reasoningEffort the reasoning effort sent for this turn, or {@code null} when the model does not support + * reasoning effort */ public CompletableFuture<Void> persistModelInfo(String conversationId, String turnId, String modelName, - double billingMultiplier) { + double billingMultiplier, String reasoningEffort) { return CompletableFuture.runAsync(() -> { lock.writeLock().lock(); try { @@ -523,6 +680,7 @@ public CompletableFuture<Void> persistModelInfo(String conversationId, String tu if (copilotTurn.getReply() != null) { copilotTurn.getReply().setModelName(modelName); copilotTurn.getReply().setBillingMultiplier(billingMultiplier); + copilotTurn.getReply().setReasoningEffort(reasoningEffort); } // Persist the updated conversation @@ -543,4 +701,73 @@ public CompletableFuture<Void> persistModelInfo(String conversationId, String tu public ConversationDataFactory getDataFactory() { return dataFactory; } + + /** + * Sets the CLS-assigned turnId on the last user turn that doesn't have a turnId yet. User turns are initially + * persisted without a turnId (null), and the server-assigned turnId is set when the CLS progress begin event arrives. + * + * @param conversationId the conversation ID + * @param turnId the server-assigned turnId from CLS + */ + public void setUserTurnId(String conversationId, String turnId) { + if (turnId == null) { + return; + } + CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId); + if (conversation == null) { + return; + } + // Find the last UserTurnData with null turnId and set it + List<AbstractTurnData> turns = conversation.getTurns(); + for (int i = turns.size() - 1; i >= 0; i--) { + AbstractTurnData t = turns.get(i); + if (t instanceof UserTurnData && t.getTurnId() == null) { + t.setTurnId(turnId); + persistAndCacheConversation(conversation); + break; + } + } + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to set user turn ID for conversation: " + conversationId, e); + } finally { + lock.writeLock().unlock(); + } + }); + } + + /** + * Sets the subagentToolCallId on a subagent's CopilotTurnData to associate it with the parent turn's run_subagent + * tool call. This enables precise positioning of subagent content during conversation restoration. + * + * @param conversationId the conversation ID + * @param subagentTurnId the subagent's turn ID + * @param toolCallId the run_subagent tool call ID from the parent turn + * @return a future that completes when the tool call ID has been set + */ + public CompletableFuture<Void> setSubagentToolCallId(String conversationId, String subagentTurnId, + String toolCallId) { + if (toolCallId == null || subagentTurnId == null) { + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.runAsync(() -> { + lock.writeLock().lock(); + try { + ConversationData conversation = getConversationFromCacheOrLoadFromDisk(conversationId); + if (conversation == null) { + return; + } + CopilotTurnData turnData = findTurn(conversation, subagentTurnId, CopilotTurnData.class); + if (turnData != null && turnData.getSubagentToolCallId() == null) { + turnData.setSubagentToolCallId(toolCallId); + } + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to set subagent tool call ID: " + conversationId, e); + } finally { + lock.writeLock().unlock(); + } + }); + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java index 91e8818f..bf8971c5 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java @@ -19,6 +19,8 @@ public class CopilotTurnData extends AbstractTurnData { private ReplyData reply; private String suggestedTitle; + private String parentTurnId; + private String subagentToolCallId; /** * Default constructor initializing default values. @@ -43,11 +45,27 @@ public void setSuggestedTitle(String suggestedTitle) { this.suggestedTitle = suggestedTitle; } + public String getParentTurnId() { + return parentTurnId; + } + + public void setParentTurnId(String parentTurnId) { + this.parentTurnId = parentTurnId; + } + + public String getSubagentToolCallId() { + return subagentToolCallId; + } + + public void setSubagentToolCallId(String subagentToolCallId) { + this.subagentToolCallId = subagentToolCallId; + } + @Override public int hashCode() { final int prime = 31; int result = super.hashCode(); - result = prime * result + Objects.hash(reply, suggestedTitle); + result = prime * result + Objects.hash(reply, suggestedTitle, parentTurnId, subagentToolCallId); return result; } @@ -63,7 +81,9 @@ public boolean equals(Object obj) { return false; } CopilotTurnData other = (CopilotTurnData) obj; - return Objects.equals(reply, other.reply) && Objects.equals(suggestedTitle, other.suggestedTitle); + return Objects.equals(reply, other.reply) && Objects.equals(suggestedTitle, other.suggestedTitle) + && Objects.equals(parentTurnId, other.parentTurnId) + && Objects.equals(subagentToolCallId, other.subagentToolCallId); } @Override @@ -77,6 +97,8 @@ public String toString() { // Include CopilotTurnData specific properties builder.append("reply", reply); builder.append("suggestedTitle", suggestedTitle); + builder.append("parentTurnId", parentTurnId); + builder.append("subagentToolCallId", subagentToolCallId); return builder.toString(); } @@ -172,6 +194,7 @@ public static class ReplyData { private Map<String, Object> data; private String modelName; private double billingMultiplier; + private String reasoningEffort; /** * Default constructor initializing lists and data maps. @@ -309,10 +332,19 @@ public void setBillingMultiplier(double billingMultiplier) { this.billingMultiplier = billingMultiplier; } + public String getReasoningEffort() { + return reasoningEffort; + } + + public void setReasoningEffort(String reasoningEffort) { + this.reasoningEffort = reasoningEffort; + } + @Override public int hashCode() { return Objects.hash(annotations, data, editAgentRounds, errorMessages, followups, hideText, notifications, - panelMessages, rating, references, steps, agentMessages, text, modelName, billingMultiplier); + panelMessages, rating, references, steps, agentMessages, text, modelName, billingMultiplier, + reasoningEffort); } @Override @@ -333,9 +365,11 @@ public boolean equals(Object obj) { && hideText == other.hideText && Objects.equals(notifications, other.notifications) && Objects.equals(panelMessages, other.panelMessages) && Objects.equals(rating, other.rating) && Objects.equals(references, other.references) && Objects.equals(steps, other.steps) - && Objects.equals(agentMessages, other.agentMessages) && Objects.equals(text, other.text) + && Objects.equals(agentMessages, other.agentMessages) + && Objects.equals(text, other.text) && Objects.equals(modelName, other.modelName) - && Double.compare(billingMultiplier, other.billingMultiplier) == 0; + && Double.compare(billingMultiplier, other.billingMultiplier) == 0 + && Objects.equals(reasoningEffort, other.reasoningEffort); } @Override @@ -356,6 +390,7 @@ public String toString() { builder.append("data", data); builder.append("modelName", modelName); builder.append("billingMultiplier", billingMultiplier); + builder.append("reasoningEffort", reasoningEffort); return builder.toString(); } } @@ -418,6 +453,7 @@ public String toString() { public static class ErrorData { private String message; private int code; + private String modelProviderName; private Map<String, Object> data; public String getMessage() { @@ -436,6 +472,18 @@ public void setCode(int code) { this.code = code; } + /** + * The BYOK model provider responsible for the error, or {@code null} when the failing model was a + * built-in Copilot model. + */ + public String getModelProviderName() { + return modelProviderName; + } + + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public Map<String, Object> getData() { return data; } @@ -446,7 +494,7 @@ public void setData(Map<String, Object> data) { @Override public int hashCode() { - return Objects.hash(code, data, message); + return Objects.hash(code, data, message, modelProviderName); } @Override @@ -461,7 +509,8 @@ public boolean equals(Object obj) { return false; } ErrorData other = (ErrorData) obj; - return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message); + return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message) + && Objects.equals(modelProviderName, other.modelProviderName); } @Override @@ -469,6 +518,7 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("message", message); builder.append("code", code); + builder.append("modelProviderName", modelProviderName); builder.append("data", data); return builder.toString(); } @@ -482,6 +532,7 @@ public static class EditAgentRoundData { private String reply; private List<ToolCallData> toolCalls; private Map<String, Object> data; + private ThinkingBlockData thinkingBlock; public int getRoundId() { return roundId; @@ -515,9 +566,17 @@ public void setData(Map<String, Object> data) { this.data = data; } + public ThinkingBlockData getThinkingBlock() { + return thinkingBlock; + } + + public void setThinkingBlock(ThinkingBlockData thinkingBlock) { + this.thinkingBlock = thinkingBlock; + } + @Override public int hashCode() { - return Objects.hash(data, reply, roundId, toolCalls); + return Objects.hash(data, reply, roundId, toolCalls, thinkingBlock); } @Override @@ -533,7 +592,7 @@ public boolean equals(Object obj) { } EditAgentRoundData other = (EditAgentRoundData) obj; return Objects.equals(data, other.data) && Objects.equals(reply, other.reply) && roundId == other.roundId - && Objects.equals(toolCalls, other.toolCalls); + && Objects.equals(toolCalls, other.toolCalls) && Objects.equals(thinkingBlock, other.thinkingBlock); } @Override @@ -543,6 +602,7 @@ public String toString() { builder.append("reply", reply); builder.append("toolCalls", toolCalls); builder.append("data", data); + builder.append("thinkingBlock", thinkingBlock); return builder.toString(); } } @@ -724,4 +784,102 @@ public String toString() { return builder.toString(); } } -} \ No newline at end of file + + /** Possible final states for a thinking block. */ + public enum ThinkingBlockState { + /** The thinking block completed normally. */ + COMPLETED, + /** The thinking block was cancelled by the user. */ + CANCELLED + } + + /** Data class representing a persisted thinking block. */ + public static class ThinkingBlockData { + private ThinkingBlockState state; + private String id; + private String content; + private String title; + + /** Default constructor. */ + public ThinkingBlockData() { + } + + /** Construct with id and content. */ + public ThinkingBlockData(String id, String content) { + this.id = id; + this.content = content; + } + + public ThinkingBlockState getState() { + return state; + } + + public void setState(ThinkingBlockState state) { + this.state = state; + } + + public boolean isCompleted() { + return state == ThinkingBlockState.COMPLETED; + } + + public boolean isCancelled() { + return state == ThinkingBlockState.CANCELLED; + } + + public boolean isFinalized() { + return state != null; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + @Override + public int hashCode() { + return Objects.hash(id, content, title, state); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ThinkingBlockData other = (ThinkingBlockData) obj; + return Objects.equals(id, other.id) && Objects.equals(content, other.content) + && Objects.equals(title, other.title) && Objects.equals(state, other.state); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("id", id); + builder.append("content", content); + builder.append("title", title); + builder.append("state", state); + return builder.toString(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java index fd4814d9..06bbb611 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/FileUtils.java @@ -3,23 +3,30 @@ package com.microsoft.copilot.eclipse.core.utils; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.filesystem.EFS; import org.eclipse.core.resources.IContainer; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -28,6 +35,7 @@ import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; @@ -37,6 +45,11 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.DirectoryChatReference; import com.microsoft.copilot.eclipse.core.lsp.protocol.FileChatReference; import com.microsoft.copilot.eclipse.core.lsp.protocol.FileStat; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FindTextInFilesResult.TextSearchMatch; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadDirectoryResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadDirectoryResult.DirectoryEntry; import com.microsoft.copilot.eclipse.core.lsp.protocol.ReadFileResult; @@ -47,6 +60,12 @@ public class FileUtils { private static final Pattern URI_SCHEME_PATTERN = Pattern.compile("^\\w[\\w\\d+.-]*:/"); + /** + * Upper bound on the number of results returned by {@link #findFiles} and {@link #findTextInFiles} when the caller + * does not specify {@code maxResults} or specifies a value exceeding this limit. This value aligns with the CLS. + */ + private static final int MAX_SEARCH_RESULTS = 20; + private FileUtils() { } @@ -162,6 +181,53 @@ public static IFile getFileFromUri(String fileUri) { return null; } + /** + * Resolves an absolute local filesystem path from a path or file URI. + * + * @param filePath the path or URI to resolve + * @return the local filesystem path, or null if the input is not an absolute local path + */ + @Nullable + public static Path getLocalFilePath(String filePath) { + if (StringUtils.isBlank(filePath)) { + return null; + } + + try { + // For file URIs, '#' is always a fragment delimiter (literal '#' in filenames is encoded as %23). + if (filePath.startsWith("file:")) { + String uriWithoutFragment = stripFragment(filePath); + return Paths.get(new URI(uriWithoutFragment)).toAbsolutePath().normalize(); + } + + String pathWithoutFragment = stripFragment(filePath); + if (URI_SCHEME_PATTERN.matcher(pathWithoutFragment).find() && !hasDriveLetter(pathWithoutFragment)) { + return null; + } + + // For raw paths, try the full string first since '#' is a valid filename character on Unix/Linux. + // Only fall back to stripping the fragment if the full path doesn't exist. + Path fullPath = Paths.get(filePath); + if (fullPath.isAbsolute()) { + if (Files.exists(fullPath)) { + return fullPath.toAbsolutePath().normalize(); + } + // Fall back: treat '#...' as a line-number fragment + Path strippedPath = Paths.get(pathWithoutFragment); + return strippedPath.isAbsolute() ? strippedPath.toAbsolutePath().normalize() : null; + } + return null; + } catch (IllegalArgumentException | URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid local file path: " + filePath, e); + return null; + } + } + + private static String stripFragment(String pathOrUri) { + int fragmentIndex = pathOrUri.indexOf('#'); + return fragmentIndex > 0 ? pathOrUri.substring(0, fragmentIndex) : pathOrUri; + } + /** * Normalizes a file path or URI string to a proper file URI string. Handles Windows absolute paths, POSIX absolute * paths, and existing URI strings. Line number fragments (e.g., #L123) are preserved. @@ -203,39 +269,6 @@ public static String normalizeToUri(String pathOrUri) { return uri.toString(); } - /** - * Resolves a file path to a URI. Handles Windows absolute paths, POSIX absolute paths, and existing URI strings. - * - * @param filepath the file path to resolve - * @return the resolved URI, or null if the path is invalid - */ - private static URI resolvePathToUri(String filepath) { - // Check for POSIX-like absolute paths or Windows-like absolute paths - if (filepath.startsWith("/") - || hasDriveLetter(filepath) - || (PlatformUtils.isWindows() && filepath.startsWith("\\"))) { - try { - return Paths.get(filepath).toUri(); - } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to convert path to URI: " + filepath, e); - return null; - } - } - - // Check if the filepath starts with a URI scheme (e.g., file:, http:) - // Verify the character after colon is "/" to distinguish from Windows drive letters - if (URI_SCHEME_PATTERN.matcher(filepath).find()) { - try { - return new URI(filepath); - } catch (URISyntaxException e) { - CopilotCore.LOGGER.error("Failed to parse URI: " + filepath, e); - return null; - } - } - - return null; - } - /** * Get an IFile from a file path string. This method tries multiple approaches to locate the file in the workspace: 1. * First tries getFileForLocation for absolute filesystem paths 2. Falls back to getFile for workspace-relative paths @@ -253,6 +286,30 @@ public static IFile getFileFromPath(String filePath, boolean checkExistence) { return null; } + // Try URI-based resolution first for non-filesystem URI schemes (e.g., semanticfs://). + // Exclude drive-letter paths (e.g., C:/project/file.txt) — they match the URI_SCHEME_PATTERN + // but must be handled as filesystem paths below. + if (URI_SCHEME_PATTERN.matcher(filePath).find() && !filePath.startsWith("file:") && !hasDriveLetter(filePath)) { + IResource resource = getResourceFromUri(filePath); + if (resource instanceof IFile file) { + return file; + } + // getResourceFromUri only returns existing resources. When checkExistence=false, try to + // obtain an IFile handle without requiring the resource to already exist on disk. + if (!checkExistence) { + try { + URI uri = new URI(filePath); + IFile[] files = ResourcesPlugin.getWorkspace().getRoot().findFilesForLocationURI(uri); + if (files != null && files.length > 0 && files[0] != null) { + return files[0]; + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid URI in getFileFromPath: " + filePath, e); + } + } + return null; + } + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); IPath eclipsePath = org.eclipse.core.runtime.Path.fromOSString(filePath); @@ -274,16 +331,6 @@ public static IFile getFileFromPath(String filePath, boolean checkExistence) { return null; } - /** - * Checks if the filepath starts with a Windows drive letter (e.g., C:). - * - * @param filepath the file path to check - * @return true if the path starts with a drive letter, false otherwise - */ - private static boolean hasDriveLetter(String filepath) { - return filepath.length() > 1 && Character.isLetter(filepath.charAt(0)) && filepath.charAt(1) == ':'; - } - /** * Reads the contents and stats of a file given its URI. Used by workspace/readFile API to read file content along * with file stats using uri. @@ -322,34 +369,7 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { } try { - URI parsedUri = new URI(uri); - IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); - - IContainer container = null; - if ("platform".equals(parsedUri.getScheme())) { - // Handle platform:/resource/... URIs by resolving via workspace path - String path = parsedUri.getPath(); - String prefix = "/resource"; - if (path != null && path.startsWith(prefix)) { - String workspacePath = path.substring(prefix.length()); - IResource resource = root.findMember(workspacePath); - if (resource instanceof IContainer c && c.isAccessible()) { - container = c; - } - } - } else { - // For file://, semanticfs://, and other URIs, use location URI lookup - IContainer[] containers = root.findContainersForLocationURI(parsedUri); - if (containers != null) { - for (IContainer c : containers) { - if (c.isAccessible()) { - container = c; - break; - } - } - } - } - + IContainer container = findContainerForUri(uri); if (container == null) { return new ReadDirectoryResult(Collections.emptyList()); } @@ -373,17 +393,182 @@ public static ReadDirectoryResult readDirectoryEntries(String uri) { entries.add(new DirectoryEntry(member.getName(), type)); } return new ReadDirectoryResult(entries); - } catch (URISyntaxException e) { - CopilotCore.LOGGER.error("Invalid directory URI: " + uri, e); - return new ReadDirectoryResult(Collections.emptyList()); } catch (CoreException e) { CopilotCore.LOGGER.error("Failed to read directory: " + uri, e); return new ReadDirectoryResult(Collections.emptyList()); } } + /** + * Finds files under the given base URI whose path (relative to the base container) matches the provided glob pattern. + * Used by the {@code workspace/findFiles} request so the language server can perform file search over custom URI + * schemes such as {@code semanticfs}. + * + * @param params the search parameters + * @return a {@link FindFilesResult} containing the matching file URIs + */ + public static FindFilesResult findFiles(FindFilesParams params) { + if (params == null || StringUtils.isBlank(params.baseUri()) || StringUtils.isBlank(params.pattern())) { + return new FindFilesResult(List.of()); + } + + int maxResults = resolveMaxResults(params.maxResults()); + + try { + IContainer container = findContainerForUri(params.baseUri()); + if (container == null) { + CopilotCore.LOGGER.info("findFiles: base URI not found in workspace: " + params.baseUri()); + return new FindFilesResult(List.of()); + } + + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + params.pattern()); + List<String> uris = new ArrayList<>(); + IPath basePath = container.getFullPath(); + + // Narrow the starting container to the literal prefix of the glob to skip unrelated + // subtrees (e.g. node_modules/, .git/, target/). + IContainer startContainer = narrowToLiteralPrefix(container, params.pattern()); + if (startContainer == null) { + return new FindFilesResult(List.of()); + } + + walkFiles(startContainer, basePath, matcher, file -> { + String uri = getResourceUri(file); + if (uri != null) { + uris.add(uri); + } + }, uris, maxResults); + return new FindFilesResult(uris); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Failed to find files under: " + params.baseUri(), e); + return new FindFilesResult(List.of()); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Invalid glob pattern for findFiles: " + params.pattern(), e); + return new FindFilesResult(List.of()); + } + } + + /** + * Searches for text (or a regex) in files under the given base URI. Used by the {@code workspace/findTextInFiles} + * request. + * + * @param params the search parameters + * @return a {@link FindTextInFilesResult} containing the matches + */ + public static FindTextInFilesResult findTextInFiles(FindTextInFilesParams params) { + if (params == null || StringUtils.isBlank(params.baseUri()) || StringUtils.isBlank(params.query())) { + return new FindTextInFilesResult(List.of()); + } + + int maxResults = resolveMaxResults(params.maxResults()); + boolean isRegexp = Boolean.TRUE.equals(params.isRegexp()); + + Pattern pattern; + try { + pattern = isRegexp ? Pattern.compile(params.query(), Pattern.CASE_INSENSITIVE) + : Pattern.compile(Pattern.quote(params.query()), Pattern.CASE_INSENSITIVE); + } catch (PatternSyntaxException e) { + CopilotCore.LOGGER.error("Invalid regex for findTextInFiles: " + params.query(), e); + return new FindTextInFilesResult(List.of()); + } + + // Compile the optional include glob pattern to filter which files are searched + PathMatcher includeMatcher = null; + if (params.includePattern() != null && !params.includePattern().isEmpty()) { + try { + includeMatcher = FileSystems.getDefault().getPathMatcher("glob:" + params.includePattern()); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Invalid glob for findTextInFiles includePattern: " + params.includePattern(), e); + return new FindTextInFilesResult(List.of()); + } + } + + // Resolve the base URI to a workspace container and recursively search for text matches + try { + IContainer container = findContainerForUri(params.baseUri()); + if (container == null) { + CopilotCore.LOGGER.info("findTextInFiles: base URI not found in workspace: " + params.baseUri()); + return new FindTextInFilesResult(List.of()); + } + + // Narrow the starting container using the include glob's literal prefix when available. + IContainer startContainer = container; + if (includeMatcher != null) { + IContainer narrowed = narrowToLiteralPrefix(container, params.includePattern()); + if (narrowed == null) { + return new FindTextInFilesResult(List.of()); + } + startContainer = narrowed; + } + + List<TextSearchMatch> matches = new ArrayList<>(); + walkFiles(startContainer, container.getFullPath(), includeMatcher, file -> { + searchTextInFile(file, pattern, matches, maxResults); + }, matches, maxResults); + return new FindTextInFilesResult(matches); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Failed to search text under: " + params.baseUri(), e); + return new FindTextInFilesResult(List.of()); + } + } + + /** + * Resolves a file path to a URI. Handles Windows absolute paths, POSIX absolute paths, and existing URI strings. + * + * @param filepath the file path to resolve + * @return the resolved URI, or null if the path is invalid + */ + private static URI resolvePathToUri(String filepath) { + // Check for POSIX-like absolute paths or Windows-like absolute paths + if (filepath.startsWith("/") + || hasDriveLetter(filepath) + || (PlatformUtils.isWindows() && filepath.startsWith("\\"))) { + try { + return Paths.get(filepath).toUri(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to convert path to URI: " + filepath, e); + return null; + } + } + + // Check if the filepath starts with a URI scheme (e.g., file:, http:) + // Verify the character after colon is "/" to distinguish from Windows drive letters + if (URI_SCHEME_PATTERN.matcher(filepath).find()) { + try { + return new URI(filepath); + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Failed to parse URI: " + filepath, e); + return null; + } + } + + return null; + } + + /** + * Checks if the filepath starts with a Windows drive letter (e.g., C:). + * + * @param filepath the file path to check + * @return true if the path starts with a drive letter, false otherwise + */ + private static boolean hasDriveLetter(String filepath) { + return filepath.length() > 1 && Character.isLetter(filepath.charAt(0)) && filepath.charAt(1) == ':'; + } + private static String readFileContent(IFile file) throws CoreException, IOException { - try (InputStream is = file.getContents()) { + URI locationUri = file.getLocationURI(); + if (locationUri == null) { + // IResource#getLocationURI() can be null for resources without a defined location. + // Fall back to IFile.getContents() which works for any local resource. + try (InputStream is = file.getContents()) { + return new String(is.readAllBytes(), file.getCharset()); + } + } + // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the + // Eclipse workspace resource-tree lock during the I/O. For virtual URI schemes (e.g. + // semanticfs://) IFile.getContents() would hold the lock across a synchronous network request, + // potentially stalling the UI thread. + try (InputStream is = EFS.getStore(locationUri).openInputStream(EFS.NONE, new NullProgressMonitor())) { return new String(is.readAllBytes(), file.getCharset()); } } @@ -413,12 +598,233 @@ private static FileStat getFileStatFromEclipseResource(IFile file) { FileStat stat = new FileStat(); if (file.getLocationURI() != null) { - try (InputStream is = file.getContents(true)) { - stat.setSize(is.readAllBytes().length); - } catch (IOException | CoreException e) { + // Use EFS to query the file size without acquiring the workspace resource-tree lock. + try { + stat.setSize(EFS.getStore(file.getLocationURI()).fetchInfo().getLength()); + } catch (CoreException e) { // Ignore; size stays 0. } } return stat; } + + private static void searchTextInFile(IFile file, Pattern pattern, List<TextSearchMatch> results, int maxResults) { + String uri = getResourceUri(file); + if (uri == null) { + return; + } + URI locationUri = file.getLocationURI(); + if (locationUri == null) { + // IResource#getLocationURI() can be null for resources without a defined location; skip them. + CopilotCore.LOGGER.info("findTextInFiles: skipping file without location URI: " + uri); + return; + } + // Use EFS.getStore().openInputStream() instead of IFile.getContents() to avoid holding the + // Eclipse workspace resource-tree lock during the I/O. IFile.getContents() acquires that lock + // for the lifetime of the call; for virtual URI schemes (e.g. semanticfs://) the underlying + // EFS provider may issue a synchronous network request, which would stall the UI thread and + // any background Jobs waiting to acquire the same lock. + try (InputStream is = EFS.getStore(locationUri).openInputStream(EFS.NONE, new NullProgressMonitor()); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, file.getCharset()))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + if (results.size() >= maxResults) { + return; + } + lineNumber++; + Matcher m = pattern.matcher(line); + if (m.find()) { + results.add(new TextSearchMatch(uri, lineNumber, line)); + } + } + } catch (CoreException | IOException e) { + CopilotCore.LOGGER.info("findTextInFiles: skipping unreadable file " + uri + ": " + e.getMessage()); + } + } + + /** + * Narrows a base container to the subcontainer matching the literal directory prefix of a glob pattern. Returns the + * narrowed container, or the original container if no literal prefix exists, or {@code null} if the prefix path does + * not exist in the workspace (meaning no files can match). + */ + @Nullable + private static IContainer narrowToLiteralPrefix(IContainer base, String glob) { + String prefix = extractGlobLiteralPrefix(glob); + if (prefix.isEmpty()) { + return base; + } + IResource member = base.findMember(prefix); + if (member instanceof IContainer c && c.isAccessible()) { + return c; + } + // If member exists but is not a container (e.g. a file) — fall back to the base. + // Else if member is null — the prefix path does not exist so no files can match. + return member != null ? base : null; + } + + /** + * Extracts the literal directory prefix from a glob pattern — the longest sequence of complete path segments that + * contain no wildcard characters ({@code *}, {@code ?}, <code>{</code>, {@code [}). For example, + * {@code src/main/java/**\/*.java} yields {@code src/main/java}. + */ + private static String extractGlobLiteralPrefix(String glob) { + StringBuilder prefix = new StringBuilder(); + for (String segment : glob.split("/")) { + if (segment.contains("*") || segment.contains("?") || segment.contains("{") || segment.contains("[")) { + break; + } + if (prefix.length() > 0) { + prefix.append('/'); + } + prefix.append(segment); + } + return prefix.toString(); + } + + /** + * Resolves the effective maximum number of search results, capping at {@link #MAX_SEARCH_RESULTS}. + */ + private static int resolveMaxResults(int requested) { + return requested > 0 ? Math.min(requested, MAX_SEARCH_RESULTS) : MAX_SEARCH_RESULTS; + } + + /** + * Callback interface for {@link #walkFiles}. Invoked for each file whose path matches the glob filter. + */ + @FunctionalInterface + private interface FileVisitor { + void visit(IFile file) throws CoreException; + } + + /** + * Recursively walks files under {@code container}, skipping derived/hidden/team-private resources. For each file + * whose relative path matches {@code fileMatcher} (when non-null), the {@code visitor} is invoked. The walk stops + * once {@code results.size() >= maxResults}. + */ + private static void walkFiles(IContainer container, IPath basePath, @Nullable PathMatcher fileMatcher, + FileVisitor visitor, List<?> results, int maxResults) throws CoreException { + if (results.size() >= maxResults) { + return; + } + for (IResource member : container.members()) { + if (results.size() >= maxResults) { + return; + } + if (shouldSkipResource(member)) { + continue; + } + if (member.getType() == IResource.FILE) { + IPath relative = member.getFullPath().makeRelativeTo(basePath); + // Glob patterns use '/'; match against the portable (forward-slash) form. + // Paths.get normalizes separators appropriately for the default FileSystem. + if (fileMatcher != null && !fileMatcher.matches(Paths.get(relative.toPortableString()))) { + continue; + } + visitor.visit((IFile) member); + } else if (member instanceof IContainer c) { + walkFiles(c, basePath, fileMatcher, visitor, results, maxResults); + } + } + } + + /** + * Returns {@code true} if the resource should be excluded from search traversal. Skips build output (derived), + * version-control internals (team-private), and hidden resources. + */ + private static boolean shouldSkipResource(IResource resource) { + return resource.isDerived(IResource.CHECK_ANCESTORS) || resource.isTeamPrivateMember(IResource.CHECK_ANCESTORS) + || resource.isHidden(IResource.CHECK_ANCESTORS); + } + + /** + * Resolves a workspace container (folder/project/root) for the given URI, or {@code null} if none exists. Used by + * findFiles / findTextInFiles. + */ + @Nullable + private static IContainer findContainerForUri(String uri) { + if (StringUtils.isBlank(uri)) { + return null; + } + try { + URI parsedUri = new URI(uri); + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + + if ("platform".equals(parsedUri.getScheme())) { + String path = parsedUri.getPath(); + String prefix = "/resource"; + if (path != null && path.startsWith(prefix)) { + IResource resource = root.findMember(path.substring(prefix.length())); + if (resource instanceof IContainer c && c.isAccessible()) { + return c; + } + } + } + + IContainer[] containers = root.findContainersForLocationURI(parsedUri); + if (containers != null) { + for (IContainer c : containers) { + if (c != null && c.isAccessible()) { + return c; + } + } + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid container URI: " + uri, e); + } + return null; + } + + /** + * Resolves a workspace resource from a URI. Supports file URIs, platform resource URIs, and Eclipse-managed virtual + * URIs such as semanticfs. + * + * @param resourceUri the resource URI + * @return the matching workspace resource, or null if not found + */ + @Nullable + private static IResource getResourceFromUri(String resourceUri) { + if (StringUtils.isBlank(resourceUri)) { + return null; + } + + try { + URI uri = new URI(resourceUri); + IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot(); + + IFile[] files = root.findFilesForLocationURI(uri); + if (files != null) { + for (IFile file : files) { + if (file != null && file.exists()) { + return file; + } + } + } + + // Handle platform:/resource/... URIs by resolving via workspace path + if ("platform".equals(uri.getScheme())) { + String path = uri.getPath(); + String prefix = "/resource"; + if (path != null && path.startsWith(prefix)) { + IResource resource = root.findMember(path.substring(prefix.length())); + if (resource != null && resource.exists()) { + return resource; + } + } + } + + // For file://, semanticfs://, and other URIs, use location URI lookup + IContainer[] containers = root.findContainersForLocationURI(uri); + if (containers != null) { + for (IContainer container : containers) { + if (container != null && container.exists()) { + return container; + } + } + } + } catch (URISyntaxException e) { + CopilotCore.LOGGER.error("Invalid resource URI: " + resourceUri, e); + } + return null; + } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/PlatformUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/PlatformUtils.java index b6b0ca37..277ed92c 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/PlatformUtils.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/PlatformUtils.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.core.utils; +import java.io.File; import java.io.IOException; import java.lang.reflect.Field; import java.net.URI; @@ -25,6 +26,7 @@ import org.osgi.framework.Bundle; import org.osgi.framework.Version; +import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; /** @@ -150,6 +152,15 @@ public static boolean isArm64() { return Platform.getOSArch().equals(Platform.ARCH_AARCH64); } + /** + * Returns the transcript directory for CLS session persistence, following the same convention as the IntelliJ + * Copilot plugin ({@code ~/.copilot/eclipse}). + */ + public static String getTranscriptDirectory() { + String userHome = System.getProperty("user.home"); + return new File(userHome, Constants.TRANSCRIPT_SUBDIR).getAbsolutePath(); + } + /** * get the property value of the object with reflection. * diff --git a/com.microsoft.copilot.eclipse.feature/.project b/com.microsoft.copilot.eclipse.feature/.project new file mode 100644 index 00000000..a1bbaa35 --- /dev/null +++ b/com.microsoft.copilot.eclipse.feature/.project @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.feature</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.pde.FeatureBuilder</name> + <arguments> + </arguments> + </buildCommand> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.pde.FeatureNature</nature> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.feature/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.feature/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.feature/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.feature/feature.xml b/com.microsoft.copilot.eclipse.feature/feature.xml index 990eace0..f53843fa 100644 --- a/com.microsoft.copilot.eclipse.feature/feature.xml +++ b/com.microsoft.copilot.eclipse.feature/feature.xml @@ -2,7 +2,7 @@ <feature id="com.microsoft.copilot.eclipse.feature" label="GitHub Copilot" - version="0.16.0.qualifier" + version="0.20.0.qualifier" provider-name="GitHub Copilot" plugin="com.microsoft.copilot.eclipse.branding"> diff --git a/com.microsoft.copilot.eclipse.repository/.project b/com.microsoft.copilot.eclipse.repository/.project new file mode 100644 index 00000000..5a4829c3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.repository/.project @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>com.microsoft.copilot.eclipse.repository</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.m2e.core.maven2Builder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.m2e.core.maven2Nature</nature> + </natures> +</projectDescription> diff --git a/com.microsoft.copilot.eclipse.repository/.settings/org.eclipse.m2e.core.prefs b/com.microsoft.copilot.eclipse.repository/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..14b697b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.repository/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/com.microsoft.copilot.eclipse.repository/category.xml b/com.microsoft.copilot.eclipse.repository/category.xml index 4181ec91..394c11c3 100644 --- a/com.microsoft.copilot.eclipse.repository/category.xml +++ b/com.microsoft.copilot.eclipse.repository/category.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <site> - <feature id="com.microsoft.copilot.eclipse.feature" version="0.16.0.qualifier"> + <feature id="com.microsoft.copilot.eclipse.feature" version="0.20.0.qualifier"> <category name="com.microsoft.copilot.eclipse.category"/> </feature> <category-def name="com.microsoft.copilot.eclipse.category" label="GitHub Copilot"/> diff --git a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF index 677e33d2..8fbc5c1a 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.swtbot.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.swtbot.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.swtbot.test;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.swtbot.test diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/add-context/add-context.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/add-context/add-context.md new file mode 100644 index 00000000..05d214ca --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/add-context/add-context.md @@ -0,0 +1,120 @@ +# Chat: Add Context (Attach Files) + +## Overview + +Tests the **Add Context** button in the Chat view, which lets users attach +workspace files to a chat prompt. Attached files provide additional context to +Copilot so it can give more relevant answers. The button is an icon-only flat +button (tooltip: **Add Context...**) in the action bar's file reference area. + +After attaching, files appear as **chips** below the input area showing the +file name and a close (×) button. Users can click a chip to open the file, or +click × to remove it. + +Entry points exercised: +- **Ctrl+Alt+I** (or status bar → **Open Chat**), then click the Add Context + button in the action bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account. +- A Java project is open in the workspace with at least two files. + +--- + +## 1. Attach files, verify chips, remove, and send with context + +### TC-001: Attach files via the Add Context button, verify file chips, remove a file, and send a prompt with attached context + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions + +- The Chat view is open. +- The workspace contains at least two Java files. + +#### Steps + +1. Locate the **Add Context** button in the action bar (icon-only button with + tooltip **Add Context...**). +2. Verify the button appears as a flat icon without a visible rectangular + border. +3. Click the **Add Context** button. +4. Verify a file picker dialog opens (title: **Search attachments**). +5. Select two files from the workspace and click **OK**. +6. Verify the dialog closes and two **file chips** appear below the chat input + area, each showing the file name and a close (×) button. +7. Click the × button on one of the file chips. +8. Verify that chip is removed and only one file chip remains. +9. Click on the remaining file chip's file name. +10. Verify the corresponding file opens (or is revealed) in the editor. +11. Type a prompt (e.g. `explain this file`) in the chat input and click + **Send**. +12. Wait for the Copilot turn to complete. +13. Verify the response references or uses content from the attached file. + +#### Expected Result + +- The Add Context button opens the file picker dialog. +- Selected files appear as removable chips below the input area. +- Clicking × removes the chip. +- Clicking the file name opens the file in the editor. +- Sending a prompt with an attached file includes that file as context in the + conversation. + +#### 📸 Key Screenshots + +- [ ] **Add Context button** — flat icon button in the action bar, no border. +- [ ] **File picker dialog** — dialog showing workspace files to select. +- [ ] **File chips** — two file chips visible below the chat input area. +- [ ] **After removing one chip** — only one chip remaining. +- [ ] **Response with context** — Copilot turn that references the attached + file content. + +--- + +## 2. Currently active file is shown automatically + +### TC-002: The currently open file appears as a reference in the action bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions + +- The Chat view is open. +- A Java file is open and active in the editor. + +#### Steps + +1. Open a Java file in the editor (click on it in the Package Explorer or + switch to an already-open tab). +2. Switch focus to the Chat view. +3. Observe the file reference area in the action bar. + +#### Expected Result + +- The currently active editor file is automatically shown as a reference in + the action bar (displaying the file name). +- Switching to a different file in the editor updates the displayed reference. + +#### 📸 Key Screenshots + +- [ ] **Current file reference** — the action bar showing the active file name + as a context reference. + +--- + +## Notes on failure modes + +- File picker dialog does not open → the Add Context button's selection + listener may not be wired; check the Eclipse error log. +- File chips do not appear after selection → the ReferencedFileService may not + have received the files; verify the file selection result is non-empty. +- Clicking × does not remove the chip → the close button's mouse listener + may not be attached; check the ReferencedFile widget. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/byok/byok.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/byok/byok.md new file mode 100644 index 00000000..f1657160 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/byok/byok.md @@ -0,0 +1,554 @@ +# BYOK (Bring Your Own Key) Model Management + +## Overview +Tests the **Model Management** preference page (`ByokPreferencePage`), which lets +users register their own AI model providers and models alongside the GitHub +Copilot defaults. The page is the user-facing surface for the BYOK flow: +storing per-provider API keys, fetching available models, adding custom +deployments, and toggling which models are exposed to the chat model picker. + +The page renders three distinct states driven by auth + feature-flag status: + +1. **Signed-out** — a sign-in prompt label is shown. +2. **Signed-in but BYOK disabled by org policy** — a "disabled by your + organization's GitHub settings" tip is shown. +3. **Signed-in + BYOK enabled** — the full provider tree, action buttons, and + loading overlay. + +Entry points exercised: +- **Window → Preferences → GitHub Copilot → Model Management**. +- Equivalently, the probe's `invokeCommand` of + `com.microsoft.copilot.eclipse.commands.openPreferences` with parameter + `com.microsoft.copilot.eclipse.commands.openPreferences.activePageId = + com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage`. + +Providers covered (`ByokModelProvider`): `Azure`, `OpenAI`, `Gemini`, `Groq`, +`OpenRouter`, `Anthropic`. Azure is special-cased: it has no top-level API +key, so the **Change API…** / **Delete API…** buttons stay disabled for it +even when models are configured. + +Not exercised in this plan (separate scenarios): +- Actually issuing chat completions through a registered BYOK model — that's + covered by the chat-send-receive plan once a BYOK model is selected in the + picker. +- Network-failure paths against real provider endpoints (out of scope for a + workbench probe). + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- **A signed-in Copilot account on the host machine** for every TC except + TC-010 (which deliberately probes the signed-out empty state). The Copilot + JS agent reads its token from the OS-standard Copilot store. +- The signed-in account must have BYOK enabled by GitHub policy. If the org + disables custom models, only TC-011 (disabled tip) is observable; the rest + will short-circuit to the disabled view. +- Valid API keys for at least one non-Azure provider (e.g. OpenAI) for the + add-key / add-model / change-key / delete-key TCs. Use a throwaway key + scoped to a sandbox account — the probe persists the key into the Copilot + language server's secure store as part of the TC. +- For Azure-specific TCs: a deployment URL and API key for an Azure OpenAI + deployment (or skip the Azure cases). +- No previously opened Preferences dialog. The probe runner pre-suppresses + Quick Start, What's New, Welcome, and "Terminal Support Unavailable" + pop-ups — keep that contract when authoring follow-up plans. +- Each TC starts from a freshly launched workbench (the probe sandbox + satisfies this automatically). When running manually, close the + Preferences dialog between cases so the page re-runs its `init` / + `refreshPageData` lifecycle. + +--- + +## 1. Page lifecycle + +### TC-001: Open Model Management page and view providers + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- The user is signed in to Copilot with BYOK enabled by org policy. + +#### Steps +1. Wait for the workbench to settle (`waitForIdle`). +2. Open the Preferences dialog and navigate to the BYOK page (`Window → + Preferences → GitHub Copilot → Model Management`, or the probe's + `invokeCommand` with `activePageId = + com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage`). +3. Wait for the loading overlay to finish (the "Loading..." label is + replaced by the provider tree). +4. Verify the page header shows the title **Model Management** and the + description **Configure your own custom models.** +5. Verify the **Provider** group is visible with the description + **Select a provider before adding models.** +6. Verify the tree has two columns — **Custom Models** and **Status** — + and contains exactly the six providers `Azure`, `OpenAI`, `Gemini`, + `Groq`, `OpenRouter`, `Anthropic`. +7. Verify the action buttons are present on the right side: + **Add Model...**, **Remove Model**, **Enable** / **Disable**, **Reload**, + **Change API...**, **Delete API...**. With no selection, **Add Model...**, + **Remove Model**, **Enable**, **Change API...**, and **Delete API...** are + disabled; only **Reload** is enabled. + +#### Expected Result +- The page opens without an error dialog. +- All six providers render as collapsible tree nodes. +- Button enablement matches the no-selection state described above. +- `workspace.log` contains no `ERROR` entries from + `com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage` or + `com.microsoft.copilot.eclipse.ui.chat.services.ByokService` during the + open. + +#### 📸 Key Screenshots +- [ ] **Loading state** — overlay shown immediately after the page opens. +- [ ] **Loaded state** — provider tree visible with the six providers and + the action buttons on the right. + +#### Notes on failure modes +- Page never leaves loading → `ByokService.refreshData()` failed; check + `workspace.log` for the LS bind error. +- Sign-in label or org-disabled tip is shown instead of the tree → the + preconditions don't match; fall through to TC-010 / TC-011. + +--- + +## 2. API key management (non-Azure provider) + +### TC-002: Add an API key for a provider with no key configured + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 preconditions hold. +- The signed-in account has **no** API key stored for the target provider + (e.g. OpenAI). Run TC-007 first against this provider to guarantee the + state, or use a fresh sandbox account. +- A valid OpenAI (or equivalent non-Azure) API key is available to the + tester. + +#### Steps +1. Open the BYOK page (steps 1–3 of TC-001). +2. Select the **OpenAI** provider node in the tree. +3. Verify **Add Model…** becomes enabled and **Change API…** / + **Delete API…** stay disabled (no key registered yet). +4. Click **Add Model…**. +5. Verify the API-key dialog opens with shell title **Add OpenAI Models** + (formatted from `preferences_page_byok_addModel_dialog_title`). The + dialog body contains a single password-masked **API Key** text field + with an eye-icon toggle button to reveal/mask the value, and the + button bar shows the platform-default **OK** and **Cancel** buttons. + **OK** is enabled on open (the dialog defers validation until the + button is pressed). +6. Verify that pressing **OK** while the field is empty does **not** + close the dialog: focus returns to the API Key field and no save is + dispatched. (Equivalent of the empty-input guard in `okPressed`.) +7. Type a valid OpenAI API key into the field and click **OK**. +8. Wait for the per-provider loading indicator to clear (the "(Loading...)" + suffix on the OpenAI node disappears). +9. Expand the OpenAI node. +10. Verify a list of models is fetched from the provider and rendered as + children, each with a **Status** column entry of either **Enable** or + **Disable** (matching their default registration state) and a status + icon. +11. Verify the OpenAI node label now includes the registered/total count + suffix, e.g. `OpenAI ( 0 of N Enabled )`. +12. Re-select the OpenAI provider node and verify **Change API…** and + **Delete API…** are now enabled. + +#### Expected Result +- The API key is persisted (subsequent reopens of the Preferences dialog + show the OpenAI node already populated and the API-key buttons enabled). +- No error dialog is shown and no `ByokService` errors are logged. + +#### 📸 Key Screenshots +- [ ] **Empty OpenAI node** — selected before clicking Add Model. +- [ ] **API key dialog** — shell title **Add OpenAI Models**, masked + API Key field with eye toggle, **OK** / **Cancel** buttons. +- [ ] **OpenAI populated** — node expanded with models + count suffix. + +--- + +### TC-003: Change the API key for a provider that already has one + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- TC-002 has succeeded (OpenAI has a stored API key and at least one + fetched model). +- A second valid OpenAI API key is available. + +#### Steps +1. Open the BYOK page and select the **OpenAI** provider node. +2. Click **Change API…**. +3. Verify a confirmation dialog appears with title **Change OpenAI API + Key?** and warning text about the change potentially breaking models. + Click **Yes**. +4. Verify the API-key dialog reopens. In the change flow its shell title + is just the provider name (**OpenAI**, not the `Add %s Models` + formatted title used for the add flow), and the masked API Key field + is pre-populated with the current key. **OK** / **Cancel** are the + button-bar labels; **OK** is enabled on open. +5. Replace the field with the new API key and click **OK**. +6. Wait for the per-provider loading indicator to clear. +7. Verify the OpenAI node still shows its model list (refreshed against + the new key) and no error dialog is shown. + +#### Expected Result +- The new key replaces the previous one in the secure store. +- The provider's model list is reloaded with the new credentials. + +#### 📸 Key Screenshots +- [ ] **Change API confirmation dialog**. +- [ ] **API key dialog (change flow)** — shell title is just **OpenAI**, + field pre-populated with the masked existing key. + +--- + +### TC-004: Delete an API key (cascades to associated custom models) + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- TC-002 has succeeded for the chosen provider, and at least one custom + model has been added through TC-005. + +#### Steps +1. Open the BYOK page and select the **OpenAI** provider node. +2. Click **Delete API…**. +3. Verify a confirmation dialog appears with title **Delete OpenAI API + Key?** and body warning that removing the key permanently deletes + associated models. Click **Delete**. +4. Wait for the per-provider loading indicator to clear. +5. Verify the OpenAI node returns to the empty state — no children, no + `(N of N Enabled)` count suffix. +6. Re-select the OpenAI node and verify **Change API…** / **Delete API…** + are disabled again. + +#### Expected Result +- The API key and any custom models attached to it are removed. +- Default (non-custom) models for the provider are also gone, since the + provider has no credentials to fetch them. +- No error dialog is shown. + +#### 📸 Key Screenshots +- [ ] **Delete API confirmation dialog**. +- [ ] **Empty OpenAI node after delete**. + +--- + +### TC-005: Azure provider does not expose Change/Delete API actions + +**Type:** `Negative / Edge Case` +**Priority:** `P1` + +#### Preconditions +- TC-001 preconditions hold. +- No requirement for Azure deployments to be registered. + +#### Steps +1. Open the BYOK page and select the **Azure** provider node. +2. Verify **Change API…** and **Delete API…** stay **disabled** even + though Azure is selected (Azure has per-deployment credentials, not a + provider-level key). +3. Verify **Add Model…** is **enabled** for Azure. +4. Click **Add Model…** and verify the dialog opened is the + **Add Azure Models** dialog (model id, display name, deployment URL, + API key, vision and tool-calling checkboxes) — **not** the simpler + Add API Key dialog used by other providers. +5. Cancel the dialog. + +#### Expected Result +- Azure is treated as a per-deployment provider: no provider-wide key, + no Change/Delete API affordance, and the Add Model dialog includes + deployment URL and per-model API key fields. + +#### 📸 Key Screenshots +- [ ] **Azure selected** — Change/Delete API buttons greyed out. +- [ ] **Add Azure Models dialog** with deployment-URL and API-key fields. + +--- + +## 3. Custom model management + +### TC-006: Add a custom model under a provider with an API key + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-002 has succeeded for OpenAI (API key registered). + +#### Steps +1. Open the BYOK page, select the **OpenAI** provider node, and click + **Add Model…**. +2. Verify the **Add OpenAI Models** dialog opens with fields **Model + ID** (required), **Display Name** (optional), **Support Vision** + checkbox, and **Support Tool Calling** checkbox. There is no + deployment-URL or API-key field for OpenAI (those are Azure-only). + The **Add** button stays disabled until **Model ID** is non-blank; + leaving **Display Name** empty does **not** block the **Add** button + (validation only requires Model ID for non-Azure providers, and + additionally Deployment URL + API Key for Azure). +3. Enter a model id (e.g. `gpt-4o-mini-test`), a display name (e.g. + `My Custom Model`), leave the capability checkboxes at their defaults, + and click **Add**. +4. Wait for the per-provider loading indicator to clear. +5. Expand the OpenAI node and verify the new model appears in the tree + with the chosen display name (no `(Default)` suffix — that is reserved + for fetched non-custom models). Its **Status** is **Enable** by + default. +6. Select the new custom model and verify **Remove Model** is **enabled** + for it (custom models can be removed). +7. Select any non-custom model in the same provider and verify **Remove + Model** is **disabled** (default models cannot be removed). +8. Re-open **Add Model…**, enter only a model id (e.g. + `gpt-4o-mini-blank-name`), leave **Display Name** empty, and click + **Add**. Verify the model is created and rendered in the tree using + the **model id** as its display name (the dialog falls back to the + trimmed model id when display name is blank — see + `AddByokModelDialog#buildModel`). + +#### Expected Result +- The custom model is persisted, appears in the tree, and is selectable + from the chat model picker after this point. +- The OpenAI node count suffix updates to reflect the new model count. +- When **Display Name** is left blank, the saved model's displayed name + equals the trimmed **Model ID**. + +#### 📸 Key Screenshots +- [ ] **Add OpenAI Models dialog** with required fields filled. +- [ ] **Custom model in tree** showing the new display name. +- [ ] **Custom model in tree (blank display name)** — node label equals + the model id. + +--- + +### TC-007: Toggle a model between Enable and Disable + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- At least one model is registered under any provider (custom or default). + +#### Steps +1. Open the BYOK page and expand a provider with at least one model. +2. Select a model whose **Status** is **Enable**. +3. Verify the right-hand toggle button reads **Disable**. +4. Click the toggle button. +5. Verify the model's **Status** column flips to **Disable** with the + matching disabled icon, and the toggle button text now reads + **Enable**. +6. Click the toggle button again to flip the model back to **Enable**; + verify both the column and the button text revert. +7. Repeat with a model whose initial status is **Disable** to confirm the + inverse path. + +#### Expected Result +- Status flips on each toggle and persists across re-opening the + Preferences dialog. +- The provider's `(N of M Enabled)` count suffix updates to reflect the + new enabled count. + +#### 📸 Key Screenshots +- [ ] **Model enabled** — green keep icon and "Enable" status. +- [ ] **Model disabled** — delete icon and "Disable" status, button now + reads "Enable". + +--- + +### TC-008: Remove a custom model + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- A custom model exists under some provider (e.g. created in TC-006). + +#### Steps +1. Open the BYOK page, expand the provider, and select the custom model. +2. Verify **Remove Model** is enabled. +3. Click **Remove Model**. +4. Verify a confirmation dialog appears with title **Remove Model** and + body **Do you want to remove this model?**, with **Remove** and + **Cancel** buttons. +5. Click **Remove**. +6. Verify the model is removed from the tree and the provider's count + suffix decreases by one. +7. Repeat steps 1–4, but click **Cancel** instead. Verify the model is + still present (cancel does not delete). + +#### Expected Result +- The custom model is removed from the secure store and the tree. +- Cancelling the confirmation dialog leaves state unchanged. + +#### 📸 Key Screenshots +- [ ] **Remove Model confirmation dialog**. +- [ ] **Tree after removal** — model gone, count suffix decremented. + +--- + +## 4. Reload + +### TC-009: Reload a single provider vs. all providers + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Preconditions +- TC-002 has succeeded for at least one non-Azure provider (so reload has + something to fetch). + +#### Steps +1. Open the BYOK page. +2. Select the **OpenAI** provider node and click **Reload**. Verify the + OpenAI node briefly shows a `(Loading...)` suffix and that **only** + OpenAI flips to loading — sibling provider nodes keep their normal + labels. +3. Wait for the per-provider loading indicator to clear and verify the + OpenAI model list is still rendered (refreshed). +4. Click on empty space inside the tree to clear the selection, then + click **Reload**. +5. Verify the **page-level** loading overlay is shown (the entire tree is + replaced with the centered "Loading..." label) and all action buttons + are disabled. +6. Wait for the overlay to clear and verify every provider with a key + shows its refreshed model list. + +#### Expected Result +- Per-provider reload affects only the selected provider's row. +- Reload-all triggers the page-level overlay and disables all buttons + while in flight. +- No error dialogs are shown for providers with valid keys. + +#### 📸 Key Screenshots +- [ ] **Per-provider loading** — OpenAI shows `(Loading...)` while + siblings render normally. +- [ ] **Page-level overlay** — full "Loading..." view during reload-all. + +--- + +## 5. Conditional page states + +### TC-010: Signed-out empty state + +**Type:** `Negative / Edge Case` +**Priority:** `P1` + +#### Preconditions +- The user is **not** signed in to Copilot on the host machine. Sign out + via the Copilot status-bar menu, or run on a host with no + `apps.json` token, before launching the workbench. + +#### Steps +1. Open the BYOK page (`Window → Preferences → GitHub Copilot → Model + Management`). +2. Verify the page contents are replaced by a single sign-in description + label whose text is **Sign in to GitHub to access custom models.** — + the provider tree, the loading overlay, and all action buttons are + absent. + +#### Expected Result +- The page renders the signed-out empty state and no LS calls are made + (no `ByokService.refreshData()` activity in `workspace.log`). + +#### 📸 Key Screenshots +- [ ] **Signed-out state** — sign-in description visible, no provider + tree. + +--- + +### TC-011: BYOK disabled by org policy / feature flag + +**Type:** `Negative / Edge Case` +**Priority:** `P1` + +#### Preconditions +- The user is signed in to Copilot, but the account's org has BYOK + disabled (`FeatureFlags.isByokEnabled()` returns `false`). Reproduce + by signing in with an org-managed account that has the policy off, or + by overriding the feature flag in a test build. + +#### Steps +1. Open the BYOK page. +2. Verify the page shows the **disabled tip** view: an information icon + and the text **Custom models are disabled by your organization's + GitHub settings. Please contact your organization's administrator for + more information.** — the provider tree and action buttons are + absent. + +#### Expected Result +- The page renders the org-disabled state and no models are fetched. + +#### 📸 Key Screenshots +- [ ] **Org-disabled state** — info icon and disabled-tip label. + +--- + +## 6. Selection-driven button enablement + +### TC-012: Action button enablement matches selection + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Preconditions +- TC-001 preconditions hold. +- TC-002 has succeeded for OpenAI. +- TC-006 has succeeded so that OpenAI has at least one custom model and + one default (fetched, non-custom) model. + +#### Steps +1. Open the BYOK page, expand OpenAI, and clear any selection by clicking + inside the tree's empty area. +2. Verify only **Reload** is enabled. +3. Select the **OpenAI** provider node (no model selection). Verify: + **Add Model…** enabled; **Remove Model**, **Enable** disabled (no + model selected); **Change API…**, **Delete API…** enabled (non-Azure + provider with a stored key); **Reload** enabled. +4. Select the **Azure** provider node. Verify the same as step 3 but with + **Change API…** and **Delete API…** disabled (Azure exception). +5. Select a **default** (non-custom) model under OpenAI. Verify + **Remove Model** is disabled, **Enable**/**Disable** toggle is + enabled, **Add Model…** enabled, API-key buttons enabled. +6. Select a **custom** model under OpenAI. Verify **Remove Model** is + enabled, **Enable**/**Disable** toggle is enabled, the rest as in + step 5. + +#### Expected Result +- Button enablement strictly follows the rules in + `ByokPreferencePage#refreshButtonsEnabled`. + +#### 📸 Key Screenshots +- [ ] **No selection** — only Reload enabled. +- [ ] **Custom model selected** — Remove Model and toggle enabled. +- [ ] **Azure node selected** — API-key buttons disabled. + +--- + +## Cross-cutting failure modes + +If multiple TCs fail in similar ways, suspect a single root cause: + +- **Page never leaves the loading overlay** → `ByokService.refreshData()` + rejected. Inspect `workspace.log` for the language-server bind error and + confirm the LS process started (`!ENTRY com.microsoft.copilot.eclipse.core` + with no fatal stack). +- **Add API Key / Add Model dialogs fail to open** → the Preferences dialog + may have closed before the click landed. `invokeCommand` for + `org.eclipse.ui.window.preferences` is `asyncExec`-dispatched; follow it + with `waitForIdle` and a short `sleep` before the next step. +- **`(Loading...)` suffix never clears for one provider** → that provider's + `reloadProvider` future failed; check `handleError` toasts in screenshots + and `workspace.log` for the upstream HTTP failure. +- **Buttons enablement looks wrong even when state is correct** → the + selection listener may have raced with a `setInput` refresh; reproduce by + opening Preferences twice in quick succession and capture a `dumpUi` of + the page during the failure. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md new file mode 100644 index 00000000..813bffab --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/cls-session-persistence/cls-session-persistence.md @@ -0,0 +1,227 @@ +# CLS Session Persistence and Restoration for Conversation History + +## Overview +This feature integrates CLS (Copilot Language Server) server-side session persistence so that when a conversation is restored the CLS receives the original `conversationId` and `restoreToTurnId`, enabling full context continuation rather than relying solely on IDE-side turn history. It also configures `<user home>/.copilot/eclipse` as the transcript directory (matching IntelliJ's convention). + +> **Path note:** On macOS/Linux this directory is `~/.copilot/eclipse`; on Windows it is `%USERPROFILE%\.copilot\eclipse`. + +--- + +## Test Cases + +### TC-001: Conversation history displays correctly after session switch + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Copilot Chat is open +- At least one conversation with multiple turns exists in history + +#### Steps +1. Create a conversation with at least 3 user–Copilot turn pairs +2. Open chat history and switch to a different conversation +3. Open chat history again and select the original conversation + +#### Expected Result +- All user and Copilot turns are displayed in the correct order +- Each turn shows its full content (no missing or truncated messages) +- Turn pairing is correct: each user message is followed by its corresponding Copilot response + +#### 📸 Key Screenshots +- [ ] **Before switch** — Full conversation with all turns visible +- [ ] **After restore** — Same conversation restored with all turns in correct order + +--- + +### TC-002: Conversation history persists across Eclipse restarts + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Eclipse is open with Copilot Chat functional +- The `<user home>/.copilot/eclipse` directory does not exist (or is empty) before the test + +#### Steps +1. Open Copilot Chat and send at least 2 messages; wait for responses +2. Close Eclipse completely (File → Exit or equivalent) +3. Verify that the `<user home>/.copilot/eclipse` directory exists and contains transcript files +4. Reopen Eclipse and open Copilot Chat +5. Open chat history and select the conversation from before the restart + +#### Expected Result +- The conversation appears in chat history with all turns intact +- Continuing the conversation produces contextually aware responses (CLS uses transcript for context) + +#### 📸 Key Screenshots +- [ ] **Transcript directory** — `<user home>/.copilot/eclipse` folder visible with transcript files +- [ ] **After restart** — Conversation restored in chat history after Eclipse reopen + +--- + +### TC-003: New conversation starts without interference from restored state + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- At least one existing conversation in chat history + +#### Steps +1. Open chat history and restore an existing conversation +2. Click "New Chat" to start a fresh conversation +3. Send a message unrelated to any prior conversation +4. Verify Copilot responds without referencing prior conversations + +#### Expected Result +- New conversation has a clean context; no cross-contamination from the restored conversation +- CLS receives a new `conversationId` (no `restoreToTurnId` for a brand-new conversation) + +--- + +### TC-004: Transcript directory created on first startup + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- `<user home>/.copilot/eclipse` directory does not exist + +#### Steps +1. Start Eclipse with the Copilot plugin installed +2. Wait for the Copilot language server to finish initializing (status bar shows Copilot ready) +3. Check the file system for `<user home>/.copilot/eclipse` + +#### Expected Result +- `<user home>/.copilot/eclipse` directory is created automatically on startup +- The path mirrors IntelliJ's `<user home>/.copilot/jb` convention (different subdirectory, same parent) + +#### 📸 Key Screenshots +- [ ] **Directory exists** — File explorer showing `<user home>/.copilot/eclipse` after startup + +--- + +### TC-005: Partially completed conversation restores to the last completed turn + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Copilot Chat is open in Agent mode + +#### Steps +1. Send a message and wait for Copilot to begin responding +2. Click Cancel while the response is still streaming +3. Open chat history, switch to a different conversation +4. Open chat history again and select the original conversation +5. Send a new follow-up message + +#### Expected Result +- The restored conversation shows the partial/cancelled turn in the UI +- The `restoreToTurnId` sent to CLS corresponds to the last *fully completed* turn, not the cancelled one +- Copilot's next response is coherent with the conversation up to the last completed turn + +#### 📸 Key Screenshots +- [ ] **After cancel** — Conversation with cancelled/partial turn visible +- [ ] **After restore + new message** — New response coherent with completed context + +--- + +### TC-006: No 400 Bad Request after restoring a tool-call conversation + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Copilot Chat is open in Agent mode +- A workspace with at least one source file is open + +#### Steps +1. Send a message that triggers at least one tool call and gets a full assistant response (e.g. "list the files in this project and summarise what each one does") +2. Wait for the full multi-turn exchange to complete (tool calls executed, final assistant reply shown) +3. Restart Eclipse IDE +4. Open Copilot Chat and restore the previous conversation from history +5. Send a new follow-up message in the restored conversation + +#### Expected Result +- The follow-up message is sent without any 400 Bad Request error +- The assistant replies successfully on the first attempt (no error banner) + +#### 📸 Key Screenshots +- [ ] **Completed original turn** — Chat showing the tool-call exchange and final assistant reply before restart +- [ ] **After restore + reply** — Restored history with successful follow-up reply and no error + +--- + +### TC-007: Restored history shows no duplicated user messages + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Copilot Chat is open in Agent mode +- A workspace with at least one source file is open + +#### Steps +1. Send a message that triggers at least one tool call (e.g. "read the contents of README.md and explain it") +2. Wait for the full response including the assistant's explanation +3. Note the exact assistant response text +4. Restart Eclipse IDE +5. Open Copilot Chat and restore the conversation from history +6. Inspect the restored conversation turns + +#### Expected Result +- The assistant's previous response text does not appear as a user message in the restored conversation +- Each turn shows the correct role (user prompt → tool call → assistant reply), with no duplicated content + +#### 📸 Key Screenshots +- [ ] **Original conversation** — Chat showing the original tool-call turn and assistant reply +- [ ] **Restored conversation** — Restored history with correctly attributed messages and no duplicates + +--- + +### TC-008: Cancelled terminal tool call stays cancelled after restoration + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Copilot Chat is open in Agent mode +- A workspace is open so the `run_in_terminal` tool can execute +- A model that supports tool calls is selected + +#### Steps +1. Send a message that makes Copilot use the `run_in_terminal` tool to perform a longer-running task (e.g. "run a command that takes a while, such as `ping -n 30 127.0.0.1`") +2. While the terminal tool call is still running (spinner active on the tool status), click Cancel on the chat response +3. Verify the terminal tool call status updates to the **cancelled** icon (no longer spinning) +4. Click "New Chat" to start a fresh conversation +5. Open chat history and select the original conversation to restore it + +#### Expected Result +- After restoration, the terminal tool call still shows the **cancelled** icon +- The tool call status does **not** revert to running/spinning (no rotating/ongoing indicator reappears) +- The cancelled status is consistent between the live cancel and the restored view + +#### 📸 Key Screenshots +- [ ] **After cancel** — Terminal tool call showing the cancelled icon (not spinning) +- [ ] **After restore** — Same terminal tool call restored with the cancelled icon, not reverted to running + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Full conversation before session switch +- [ ] `TC-001` Same conversation after restore, all turns in correct order +- [ ] `TC-002` `<user home>/.copilot/eclipse` directory with transcript files +- [ ] `TC-002` Conversation restored in history after Eclipse restart +- [ ] `TC-004` `<user home>/.copilot/eclipse` directory created on first startup +- [ ] `TC-005` Conversation with cancelled turn visible +- [ ] `TC-005` New response coherent with completed context after restore +- [ ] `TC-006` Completed tool-call turn before restart +- [ ] `TC-006` Restored history with successful follow-up and no 400 error +- [ ] `TC-007` Original conversation showing assistant reply +- [ ] `TC-007` Restored conversation with correctly attributed messages and no duplicates +- [ ] `TC-008` Cancelled terminal tool call showing cancelled icon after cancel +- [ ] `TC-008` Restored terminal tool call still cancelled, not reverted to running diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md new file mode 100644 index 00000000..e3b52987 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-compress/context-compress.md @@ -0,0 +1,163 @@ +# Auto Context Compression + +## Overview +Verifies the **Auto Compress** feature that automatically compresses long +conversations to keep context usage within the model's limit. Auto Compress +is always enabled (no user-facing preference). While compression is in +progress, the chat view shows a "Compacting conversation..." spinner below +the latest Copilot turn, and the context size donut updates once it +completes. + +Entry points: +- **Copilot Chat view** → latest Copilot turn (spinner banner appears here). +- **Copilot Chat view** → control bar **Context Size Donut** (updates after + compression completes). + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed (built from + the branch containing the staged Auto Compress changes). +- A valid GitHub Copilot subscription is active (authentication completed). +- A model that supports a finite context window is selected (so the donut and + compression can be exercised — e.g. Claude Sonnet 4.6 or GPT-4.1). +- The Copilot Chat view is open and visible. + +--- + +## Test Cases + +### TC-001: Compacting banner appears when compression starts + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Copilot Chat view is open with a new conversation. + +#### Steps +1. Start a conversation and drive the context usage toward the model limit — + for example, attach several large files and/or run multiple tool-heavy + turns until the **Context Size Donut** approaches its warning threshold + (≥90 %). +2. Continue sending messages until the conversation goes over the threshold + so the server initiates automatic compression. +3. Observe the latest Copilot turn while the server processes the request. + +#### Expected Result +- A small banner appears **below the latest Copilot turn** containing: + - An animated spinner. + - The status text **"Compacting conversation..."**. +- The chat view layout refreshes so the banner is fully visible (not clipped). +- No error dialogs are shown. + +#### 📸 Key Screenshots +- [ ] **Compacting banner** — spinner + "Compacting conversation..." text + rendered under the latest Copilot turn. + +--- + +### TC-002: Compacting banner is dismissed and context donut updates on completion + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 has been executed and the "Compacting conversation..." banner is + currently visible. + +#### Steps +1. Wait for the server to finish compression (typically a few seconds). +2. Observe the latest Copilot turn after compression completes. +3. Hover the **Context Size Donut** in the chat view control bar. + +#### Expected Result +- The "Compacting conversation..." banner is removed from the Copilot turn. +- The chat view scroller relayouts cleanly (no leftover blank space, no + clipping). +- The Context Size Donut updates to reflect the new, smaller token usage + (the ring's filled portion shrinks). +- The **Context Window** popup shows the post-compression token breakdown + consistent with the new total. +- The subsequent reply continues to stream normally on top of the freshly + compressed history. + +#### 📸 Key Screenshots +- [ ] **After completion** — Copilot turn without the banner. +- [ ] **Donut after compression** — Context Size Donut showing reduced usage. +- [ ] **Context Window popup** — Token breakdown after compression. + +--- + +### TC-003: Cancelling a chat hides the compacting banner + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- A conversation is set up so the next send will trigger compression + (as in TC-001). + +#### Steps +1. Send the message that triggers compression and wait for the + "Compacting conversation..." banner to appear. +2. While the banner is showing, click the **Cancel** (stop) button in the + chat input action bar. + +#### Expected Result +- The send button is restored from its stop/cancel state back to its normal + send state. +- The "Compacting conversation..." banner is removed from the latest Copilot + turn. +- Any buffered reply text that arrived just before cancellation is rendered + (no missing trailing line). +- The chat view relayouts cleanly so the flushed reply is fully visible. +- The user can immediately send a new message in the same conversation. + +#### 📸 Key Screenshots +- [ ] **After cancel** — banner gone, send button reset, any buffered reply + visible. + +--- + +### TC-004: Compacting banner only updates the matching conversation + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Preconditions +- Two conversations exist in chat history: *Conversation A* (about to + trigger compression) and *Conversation B* (short, well under the limit). + +#### Steps +1. In *Conversation A*, send a message that triggers compression and wait + for the "Compacting conversation..." banner to appear. +2. Without waiting for completion, open chat history and switch to + *Conversation B*. +3. Inspect *Conversation B* for any compaction banner. +4. Switch back to *Conversation A*. + +#### Expected Result +- *Conversation B* never shows a "Compacting conversation..." banner — the + compaction status is scoped to *Conversation A* only. +- When you return to *Conversation A*, its state is consistent with the + compression outcome (banner cleared if it completed in the meantime; new + reply continues to stream if still in progress). +- No errors or stale spinners are left behind in either conversation. + +#### 📸 Key Screenshots +- [ ] **Conversation B during A's compaction** — no banner shown. + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Compacting banner under latest Copilot turn. +- [ ] `TC-002` Copilot turn after compaction completes (banner gone). +- [ ] `TC-002` Context Size Donut after compaction (reduced usage). +- [ ] `TC-002` Context Window popup with post-compaction token breakdown. +- [ ] `TC-003` State after cancel — banner gone, send button reset, buffered + reply visible. +- [ ] `TC-004` Conversation B during Conversation A's compaction (no banner). diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-size-donut/context-size-donut.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-size-donut/context-size-donut.md new file mode 100644 index 00000000..e17963b3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/context-size-donut/context-size-donut.md @@ -0,0 +1,101 @@ +# Context Size Donut and Popup + +## Overview +Tests the context size donut chart widget and its hover popup in the GitHub Copilot +for Eclipse chat view. A `ContextSizeDonut` widget is rendered in the chat view's +`ActionBar` control bar. It displays a ring that fills proportionally based on the +token utilization percentage received via the LSP `ContextSizeInfo` payload. At +≥90 % utilization the ring switches to a warning color. Hovering over the donut +opens a `ContextWindowPopup` that shows a breakdown of token usage by category. + +Entry points: +- **Copilot Chat view** → bottom control bar (the donut appears after the first + response is received that includes `ContextSizeInfo`). +- Hover the donut to open the **Context Window** popup. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- A valid GitHub Copilot subscription is active (authentication completed). +- The Copilot Chat view is open and visible in the workbench. + +--- + +## 1. Donut Widget Visibility + +### TC-001: Donut appears after first response + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The Copilot Chat view is open. +- No previous conversation is loaded (fresh session or new conversation). + +#### Steps +1. Open the Copilot Chat view. +2. Confirm the donut widget is **not** visible in the bottom control bar (no + `ContextSizeInfo` has been received yet). +3. Send a short chat message (e.g. "Hello") and wait for a response. +4. Inspect the bottom control bar of the chat view. + +#### Expected Result +- Before the first response the donut is not visible (or shows an empty/zero state). +- After the first response arrives the donut widget appears in the control bar. +- No error dialog or exception is logged. + +#### 📸 Key Screenshots +- [ ] **Before response** — chat view control bar with no donut. +- [ ] **After response** — chat view control bar showing the donut widget. + +--- + +## 2. Context Window Popup + +### TC-002: Hovering the donut opens the Context Window popup + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The donut widget is visible (at least one response received). + +#### Steps +1. Hover the mouse cursor over the donut widget in the control bar. +2. Wait for the popup to appear. +3. Inspect the popup header. +4. Inspect the popup body for: total token count, utilization percentage, progress + bar, and per-category rows. +5. Move the mouse away from the donut. + +#### Expected Result +- The popup opens with the header **"Context Window"**. +- Total usage is displayed in the format `X / Y tokens`. +- A utilization percentage is shown. +- A progress bar reflects the utilization level. +- Per-category rows are shown: **System Instructions**, **Tool Definitions**, + **Messages**, **Attached Files**, **Tool Results**. +- Moving the mouse away closes the popup. + +#### 📸 Key Screenshots +- [ ] **Popup open** — the Context Window popup showing all fields. + +--- + +## 3. Edge Cases + +### TC-003: Donut resets when a new conversation is started + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Steps +1. Start a conversation and let the donut show some utilization. +2. Start a new conversation (clear or new session). +3. Observe the donut state before any new response. + +#### Expected Result +- The donut resets (hidden or zero state) for the new conversation until a + response with `ContextSizeInfo` is received. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/custom-instructions/custom-instructions.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/custom-instructions/custom-instructions.md new file mode 100644 index 00000000..a0bbad1e --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/custom-instructions/custom-instructions.md @@ -0,0 +1,212 @@ +# Custom Instructions + +## Overview + +Tests the new **Custom Instructions** preference setting introduced in +[PR #136](https://github.com/microsoft/copilot-for-eclipse/pull/136). + +The setting controls which projects' `.github/copilot-instructions.md` files +(and other custom instruction sources) are loaded into the Copilot chat. It is +exposed as a drop-down combo in **Window → Preferences → GitHub Copilot → +Custom Instructions**, inside the existing _Project Custom Instructions_ group. + +| Option | Behaviour | +|--------|-----------| +| **all projects in workspace** (`ALL_PROJECTS`, default) | Custom instructions are loaded from every project in the Eclipse workspace — same as the behaviour before this change. | +| **projects inferred from chat-attached files** (`REFERENCED_PROJECTS`) | Custom instructions are loaded only from the parent projects of files/folders that are currently attached to the chat window. If nothing is attached, no custom instructions are loaded. | + +The selected value is persisted in the Eclipse preference store under the key +`customInstructionsChatLoadScope` and is read by `ChatView#deriveWorkspaceFolders(...)`, +which uses it to compute the set of workspace folders sent to the language server on +each chat request. + +Entry points: +- **Window → Preferences → GitHub Copilot → Custom Instructions** → _Load custom instructions from_ combo. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- A GitHub account signed in with an active Copilot subscription. +- At least **two Java (or any language) projects** in the workspace, each + containing a `.github/copilot-instructions.md` with a distinct, observable + style rule: + - **Project A**: `.github/copilot-instructions.md` containing `Always start every response with "[SRC-A]".` + - **Project B**: `.github/copilot-instructions.md` containing `Always end every response with "[SRC-B]".` + + Using a prefix marker for one project and a suffix marker for the other makes it + unambiguous whether one or both instruction sources are active at the same time. +- The Copilot Chat view is open and visible in the workbench. +- The Custom Instructions preference page is accessible via + **Window → Preferences → GitHub Copilot → Custom Instructions**. + +--- + +## 1. Preference Page UI + +### TC-001: "Restore Defaults" resets the combo to the default value + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Preconditions +- The _Load custom instructions from_ preference is currently set to + **`projects inferred from chat-attached files`**. + +#### Steps +1. Open **Window → Preferences → GitHub Copilot → Custom Instructions**. +2. Click **Restore Defaults**. +3. Observe the combo value (do **not** click Apply yet). +4. Click **Apply and Close**. +5. Re-open the preference page and observe the value. + +#### Expected Result +- After **Restore Defaults** the combo switches back to + **`all projects in workspace`** without closing the page. +- After **Apply and Close** and reopening, the combo still shows + **`all projects in workspace`**. + +--- + +## 2. Behaviour — "All Projects in Workspace" (default) + +### TC-002: Custom instructions from all workspace projects are loaded in chat + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The workspace contains **Project A** and **Project B**, each with a + `.github/copilot-instructions.md` that has identifiable content. +- The _Load custom instructions from_ preference is set to + **`all projects in workspace`**. +- No files are attached to the chat window. + +#### Steps +1. Open the Copilot Chat view. +2. Send: _"What are your response style requirements?"_ +3. Observe the response. + +#### Expected Result +- The response mentions both the `[A]` and `[B]` rules (or the reply itself + ends sentences with both markers), confirming instructions from both projects + are active. + +--- + +## 3. Behaviour — "Projects Inferred from Chat-Attached Files" + +### TC-003: Only the parent project of attached file is used for custom instructions + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The workspace contains **Project A** and **Project B**, each with + `.github/copilot-instructions.md`. +- The _Load custom instructions from_ preference is set to + **`projects inferred from chat-attached files`**. +- A file from **Project A only** is attached to the chat window (e.g. via + the paperclip / context button). + +#### Steps +1. Open the Copilot Chat view. +2. Attach a file from **Project A** to the chat. +3. Send: _"What are your response style requirements?"_ +4. Observe the response. + +#### Expected Result +- The response mentions only the `[A]` rule. +- The `[B]` rule is **not** mentioned — Project B's instructions are not loaded. + +--- + +### TC-004: No attached files — no custom instructions loaded (referenced-projects mode) + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Same two-project workspace. +- Preference is set to **`projects inferred from chat-attached files`**. +- No files are attached to the chat input. + +#### Steps +1. Open the Copilot Chat view and verify the chat input has no attachments. +2. Send: _"What are your response style requirements?"_ +3. Observe the response. + +#### Expected Result +- The response mentions neither `[A]` nor `[B]` — no custom instructions are + active. + +--- + +### TC-005: Attaching files from multiple projects loads instructions from all their parent projects + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Preconditions +- Workspace has Project A and Project B. +- Preference is **`projects inferred from chat-attached files`**. +- Files from **both** projects are attached to the chat. + +#### Steps +1. Attach one file from Project A and one file from Project B to the chat. +2. Send: _"What are your response style requirements?"_ +3. Observe the response. + +#### Expected Result +- The response mentions both the `[A]` and `[B]` rules. + +--- + +## 4. Switching Between Modes + +### TC-006: Switching from "all projects" to "referenced projects" takes effect immediately for the next request + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Preference is currently **`all projects in workspace`**. +- One file from Project A is attached to the chat. + +#### Steps +1. Send: _"What are your response style requirements?"_ and confirm both `[A]` + and `[B]` rules appear in the response. +2. Open **Window → Preferences → GitHub Copilot → Custom Instructions**, + switch to **`projects inferred from chat-attached files`**, click + **Apply and Close**. +3. Send the same prompt again (same attachment — one file from Project A). +4. Observe the response. + +#### Expected Result +- In step 1, both `[A]` and `[B]` rules are mentioned. +- In step 4, only the `[A]` rule appears — the preference change takes effect + immediately without restarting Eclipse. + +--- + +### TC-007: Switching from "referenced projects" back to "all projects" restores full workspace folder set + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Preconditions +- Preference is currently **`projects inferred from chat-attached files`**. +- Only a file from Project A is attached. + +#### Steps +1. Send: _"What are your response style requirements?"_ and confirm only `[A]` + appears. +2. Change preference back to **`all projects in workspace`** and apply. +3. Send the same prompt again. +4. Observe the response. + +#### Expected Result +- In step 4, both `[A]` and `[B]` rules are mentioned again. + + diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md new file mode 100644 index 00000000..eab55438 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/editor-indentation-preferences/editor-indentation-preferences.md @@ -0,0 +1,168 @@ +# Editor Indentation Preferences for Inline Completions + +## Overview +When Copilot produces an **inline completion (ghost text)**, the indentation it +suggests (tab character vs. spaces and the tab width) should follow the +platform's **default text editor preferences** instead of always defaulting to +spaces with a tab size of 4. + +The formatting options are resolved by `FormatOptionProvider` and sent on each +completion request as `CompletionDocument.insertSpaces` / `tabSize` by +`CompletionProvider`. For Java and C/C++ projects the language-specific +formatter settings continue to apply. For every other case — an unknown +language, a file with no extension, or a file with no project — the provider +now reads the Eclipse text editor preferences +`org.eclipse.ui.editors/spacesForTabs` and `org.eclipse.ui.editors/tabWidth`. +If those preferences cannot be read it falls back to the previous hardcoded +defaults (spaces, tab width 4). + +> **Important:** these options affect **inline completions only**, not the +> Agent-mode file create/edit tools. The verification must therefore be done +> by triggering ghost-text completions in an editor, not by asking Agent mode +> to write a file. + +These preferences are configured under +**Preferences → General → Editors → Text Editors**: +- **"Insert spaces for tabs"** maps to `spacesForTabs`. +- **"Displayed tab width"** maps to `tabWidth`. + +Entry points: +- Window → Preferences → General → Editors → Text Editors +- Typing in a text editor to trigger an inline completion (ghost text) + +Not exercised: +- Direct unit-level invocation of `FormatOptionProvider` (covered by + `FormatOptionProviderTests`). +- Agent-mode file create/edit tools (they do not consult these options). +- Java/C-specific formatter configuration screens. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- The user is signed in to GitHub Copilot and inline completions are enabled. +- A workspace with at least one open project that is **neither a Java nor a + C/C++ project** (a General/empty project), so the editor text preferences + are the deciding factor. +- Knowledge of the current values of **Preferences → General → Editors → + Text Editors → "Insert spaces for tabs"** and **"Displayed tab width"** so + they can be restored after testing. +- Because completion content is model-influenced, prefer a prompt/context that + reliably forces an **indented multi-line suggestion** (e.g. an opening + brace/block), and inspect the **raw whitespace** of the accepted text rather + than relying on the displayed tab width. + +--- + +## 1. Spaces configuration + +### TC-001: Inline completion uses spaces when "insert spaces for tabs" is on + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A non-Java/non-C project is open in the workspace. +- A file whose language is unknown / has no extension (e.g. a file named + `sample` with code-like content) is open in the editor. + +#### Steps +1. Open **Preferences → General → Editors → Text Editors**, check + **"Insert spaces for tabs"**, set **"Displayed tab width"** to `2`, then + click **Apply and Close**. +2. In the editor, type content that forces an indented multi-line + continuation, for example an opening block and a newline: + `function greet() {` then press Enter so Copilot suggests the indented + body as ghost text. +3. Accept the inline completion (Tab). +4. Reveal whitespace ("Show whitespace characters") or inspect the raw bytes + of the inserted lines. + +#### Expected Result +- The accepted completion indents the body with **spaces** (not tabs), at + **2 spaces** per level, matching the configured tab width. +- No error dialog appears and the Eclipse error log has no formatting + exception. + +#### 📸 Key Screenshots +- [ ] **Editor preferences** — "Insert spaces for tabs" checked, tab width 2. +- [ ] **Accepted completion whitespace** — Indentation shown as 2-space groups. + +--- + +## 2. Tabs configuration + +### TC-002: Inline completion uses tabs, and switching the preference updates the next request + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A non-Java/non-C project is open in the workspace. +- An unknown-language / extensionless file is open in the editor. + +#### Steps +1. Open **Preferences → General → Editors → Text Editors**, **uncheck** + "Insert spaces for tabs", set **"Displayed tab width"** to `4`, then click + **Apply and Close**. +2. In the editor, trigger an indented multi-line completion (as in TC-001) and + accept it. Reveal whitespace / inspect raw bytes. +3. Change editor preferences to **spaces**, tab width `2`. Apply and close. +4. Trigger another indented completion in the same or a new unknown-language + file and accept it. Reveal whitespace. + +#### Expected Result +- The first accepted completion indents with **tab characters** (`\t`). +- After switching the preference, the next completion indents with **2 + spaces**. +- The provider reads the current editor preference value on **each completion + request** rather than caching the first observed value. +- No error dialog appears and the Eclipse error log has no formatting + exception. + +#### 📸 Key Screenshots +- [ ] **Tabs completion** — Tab indentation under the tabs preference. +- [ ] **Spaces completion** — 2-space indentation after switching the preference. + +--- + +## 3. Regression — language-specific projects are unaffected + +### TC-003: Java completion still uses the Java formatter settings, not editor preferences + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- A **Java** project is open in the workspace with its own + formatter/indentation settings (e.g., tabs for Java). +- Editor preferences (General → Text Editors) are set to a **different** + value than the Java formatter (e.g., spaces, tab width 2). +- A `.java` source file is open in the editor. + +#### Steps +1. In the Java file, trigger an indented multi-line inline completion (e.g. + inside a method body) and accept it. +2. Reveal whitespace / inspect raw bytes of the inserted lines. + +#### Expected Result +- The Java completion follows the **Java formatter** indentation settings, not + the generic text editor preferences. +- Only unknown/extensionless/projectless cases consult the editor + preferences; Java (and C/C++) behavior is unchanged. + +#### 📸 Key Screenshots +- [ ] **Java completion whitespace** — Indentation matches the Java formatter, + independent of the General text-editor preference. + +--- + +## Screenshots Checklist + +- [ ] TC-001 — Editor preferences (spaces, width 2) + accepted completion with + 2-space indentation. +- [ ] TC-002 — Tabs completion vs. spaces completion after a preference switch. +- [ ] TC-003 — Java completion indentation unchanged by the General + text-editor preference. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md new file mode 100644 index 00000000..e5ddf4e8 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-operation-auto-approve/file-operation-auto-approve.md @@ -0,0 +1,415 @@ +# File Operation Auto Approve + +## Overview +Tests the file-operation (read/write) auto-approve feature end-to-end: +configuring glob-pattern rules in the preference page, attaching context +files, then triggering Agent Mode tool calls and observing whether the +confirmation dialog appears or the operation runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`FileOperationConfirmationHandler` → dialog (or auto-approve) → file +operation execution. This mirrors the real user workflow: tweak settings, +attach files, chat with Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the file + operation rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `copilot.read_file`, + `copilot.editFile`, `copilot.createFile`, or `copilot.deleteFile` tool + calls. +- **Confirmation dialog** — the button with session/global allow actions. +- **Attached files** — the context panel for user-attached files. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- A workspace with at least one Java project open (e.g., `demo`). +- No previous file-operation auto-approve rules beyond the defaults + (reset via "Reset to Defaults" before each scenario). +- "Auto approve file operations not covered by rules" is **unchecked** + unless the test specifies otherwise. + +--- + +## 1. Default behavior and dialog UI + +### TC-001: File read in workspace triggers confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "File Operation Auto Approve" section is visible with a + table showing default deny rules (e.g., `.github/instructions/*`, + `github-copilot/**/*`). +3. **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). Close preferences. +4. Open the **Copilot Chat** view, select **Agent** mode. +5. Type: `read the file src/demo/App.java and summarize it`. +6. Wait for the agent to invoke the `copilot.read_file` tool. +7. Observe the confirmation dialog. Verify it shows: + - Title: **"Read file"**, message mentioning the file name. + - **"Allow Once"** button with dropdown, and a **"Skip"** button. +8. Click the dropdown arrow. Verify the menu contains: + - "Allow this file in this Session" + - "Always Allow" +9. Click **"Skip"**. +10. Verify the agent receives a dismiss result — no file content. + +#### Expected Result +- No matching allow rule → confirmation dialog appears. +- Dialog renders correctly with session/global actions. +- Skip prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules. +- [ ] Confirmation dialog with dropdown expanded. +- [ ] Agent turn after skip. + +--- + +## 2. Attached file auto-approval + +### TC-002: Attached file auto-approves; deny rule does not block attached files + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add a deny rule `**/*.java` → **Deny**. + Apply and close. +2. Open `src/demo/App.java` in the editor. +3. Open the **Copilot Chat** view, select **Agent** mode. +4. Attach `App.java` via the context panel (paperclip / "Add Context"). +5. Type: `read App.java and explain what it does`. +6. Observe **no confirmation dialog** — the file is auto-approved + because it is attached, even though the deny rule matches. +7. Verify the agent reads and summarizes the file content. +8. In the **same conversation**, type: `now read Helper.java`. +9. Observe **confirmation dialog appears** — `Helper.java` is not + attached and the deny rule matches. + +#### Expected Result +- Attached file auto-approval takes precedence over deny rules. +- Non-attached files still respect rules normally. + +#### 📸 Key Screenshots +- [ ] Context panel showing attached `App.java`. +- [ ] Agent auto-approved read without dialog. +- [ ] Dialog appears for non-attached `Helper.java`. + +--- + +## 3. Session-level file approval + +### TC-003: Session approval — read, then write, then new conversation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it so the initial read triggers a dialog). + +#### Steps +1. Ensure no custom rules exist for the test file. +2. In Agent Mode, type: `read src/demo/App.java`. +3. Confirmation dialog appears. +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read successfully. +6. In the **same conversation**, type: `read src/demo/App.java again`. +7. Observe **auto-approved** — session cache hit. +8. In the **same conversation**, type: `add a comment "// test" to + the top of src/demo/App.java`. +9. The agent invokes `copilot.editFile` for `App.java`. +10. Observe **auto-approved** — session approval is path-based, covers + both reads and writes. +11. Start a **new conversation** (click "New Chat"). +12. Type: `read src/demo/App.java`. +13. Observe **confirmation dialog appears** — session approvals do not + carry to new conversations. + +#### Expected Result +- Session approval: same file re-read auto-approves. +- Session approval: write to same file auto-approves. +- New conversation: resets session state. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this file in this Session". +- [ ] Write auto-approved in same conversation. +- [ ] New conversation: dialog reappears. + +--- + +## 4. Global rules — allow, deny, and "Always Allow" from dialog + +### TC-004: Glob allow and deny rules with unmatched toggle + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Add rule: `**/*.java` → **Allow**. Click OK. +3. Add rule: `**/secret/**` → **Deny**. Click OK. +4. Enable **"Auto approve file operations not covered by rules"**. +5. Click **"Apply and Close"**. +6. In Agent Mode, type: `read src/demo/App.java`. +7. Observe **auto-approved** — matches `**/*.java` allow rule. +8. Type: `read src/secret/config.properties`. +9. Observe **confirmation dialog** — matches `**/secret/**` deny rule, + even though unmatched is enabled. +10. Click **"Skip"**. +11. Type: `read README.md`. +12. Observe **auto-approved** — no rule matches, unmatched fallback. +13. Open preferences, **uncheck** "Auto approve file operations not + covered by rules". Apply and close. +14. Type: `read README.md`. +15. Observe **confirmation dialog** — unmatched now disabled. + +#### Expected Result +- Allow glob rule auto-approves matching files. +- Deny glob rule blocks matching files even with unmatched enabled. +- Unmatched toggle controls fallback for non-matching files. + +#### 📸 Key Screenshots +- [ ] Preference page with both rules. +- [ ] `.java` auto-approved, `secret/**` denied, `README.md` varies. + +--- + +### TC-005: "Always Allow" persists as global rule and overrides deny + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- **Manually uncheck** "Auto approve file operations not covered by rules" + (system default is checked; uncheck it for this test). + +#### Steps +1. Open preferences, add deny rule for the file's absolute path + (e.g., `C:\<your-workspace-path>\demo\src\demo\App.java`) → **Deny**. Apply and close. +2. In Agent Mode, trigger a file read for `App.java`. +3. Confirmation dialog appears (deny rule matches). +4. Click dropdown → **"Always Allow"**. +5. The file is read. +6. Open **Preferences → Tool Auto Approve**. +7. Verify the rule changed from **Deny → Allow** (no duplicate). +8. Close preferences. +9. Start a **new conversation**. +10. Type: `read src/demo/App.java`. +11. Observe **auto-approved** — the updated global rule persists. + +#### Expected Result +- "Always Allow" writes/updates the file path as a global allow rule. +- Overrides existing deny rule (case-insensitive match, no duplicates). +- Persists across conversations. + +#### 📸 Key Screenshots +- [ ] Preference page: deny → allow transition. +- [ ] New conversation: auto-approved. + +--- + +## 5. Outside-workspace files and folder-level approval + +### TC-006: Outside-workspace file requires confirmation with folder +approval + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Enable "Auto approve file operations not covered by rules". +2. Add allow rule `**/*`. Apply and close. +3. In Agent Mode, type: `read C:\temp\test\file1.txt` + (path outside workspace). +4. Observe **confirmation dialog** — outside-workspace files always + require confirmation regardless of rules. +5. Verify the dialog offers folder-level approval: + - "Allow Once" + - "Allow files in 'test' folder in this Session" + - "Skip" +6. Click dropdown → **"Allow files in 'test' folder in this Session"**. +7. The file is read. +8. In the same conversation, trigger read for `C:\temp\test\file2.txt`. +9. Observe **auto-approved** — same folder. +10. Trigger read for `C:\temp\other\file3.txt`. +11. Observe **confirmation dialog** — different folder. + +#### Expected Result +- Outside-workspace files bypass rules, always show dialog. +- Folder-level session approval covers sibling files. +- Different folders still require confirmation. + +#### 📸 Key Screenshots +- [ ] Dialog with folder-level action. +- [ ] Same-folder auto-approved. +- [ ] Different-folder dialog. + +--- + +## 6. Session approval overrides deny rule + +### TC-007: Session approval overrides deny rule within conversation + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Add deny rule `**/App.java` in preferences. Apply and close. +2. In Agent Mode, trigger a read for `src/demo/App.java`. +3. Confirmation dialog appears (deny rule). +4. Click dropdown → **"Allow this file in this Session"**. +5. The file is read. +6. In the same conversation, trigger another read for `App.java`. +7. Observe **auto-approved** — session approval checked before rules. + +#### Expected Result +- Session-level approval overrides global deny rules. + +--- + +## 7. Subagent inherits parent session approvals + +### TC-008: Session file approval applies to subagent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom rules for the test file. +- "Auto approve file operations not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger a read for `App.java`. +2. Confirmation dialog appears. +3. Select **"Allow this file in this Session"**. +4. The file is read. +5. In the **same conversation**, send a prompt that spawns a subagent + (e.g., `use a subagent to analyze App.java`). +6. The subagent invokes `copilot.read_file` for `App.java`. +7. Observe **auto-approved** — session approval carries to subagent. + +#### Expected Result +- Subagent shares parent conversation's session scope. + +--- + +## 8. Reset to Defaults + +### TC-009: Reset clears custom rules and reverts behavior + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Add custom rules: `**/*.java` → Allow, `**/secret/*` → Deny. + Apply and close. +2. Verify in Agent Mode: `.java` files auto-approve. +3. Open preferences, click **"Reset to Defaults"** and confirm. +4. **Manually uncheck** "Auto approve file operations not covered by rules" + (Reset to Defaults only clears rules, it does not reset this checkbox). + Apply and close. +5. Verify only default deny rules remain (`.github/instructions/*`, + `github-copilot/**/*`). +5. Apply and close. +6. In Agent Mode, trigger a `.java` file read. +7. Observe **confirmation dialog** — custom allow rule removed. + +#### Expected Result +- Reset removes all custom rules and restores defaults. + +#### 📸 Key Screenshots +- [ ] Preference page after reset — only defaults. +- [ ] `.java` file shows confirmation dialog post-reset. + +--- + +## 9. Customization file reads (skills, instructions, prompts, agents) + +Reading a Copilot customization file is always auto-approved, even with +"Auto approve file operations not covered by rules" **unchecked** and even +when a deny rule would otherwise match (e.g. the default `.github/instructions/*` +rule). The exemption is read-only and covers both workspace and user-global +(`~/.copilot`, `~/.claude`, `~/.agents`) locations. Recognized files: +`<skills-dir>/<name>/**` (the whole skill folder, including helper files), +`*.instructions.md`, `*.prompt.md`, `*.agent.md`, and the fixed names +`copilot-instructions.md`, `git-commit-instructions.md`, `AGENTS.md`, `CLAUDE.md`. + +### TC-010: Customization reads auto-approve; edits and ordinary files still prompt + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- "Auto approve file operations not covered by rules" is **unchecked**. +- The project contains `.github/skills/demo/SKILL.md` (whose body also tells + Copilot to read a sibling `notes.md`), `.github/instructions/coding.instructions.md`, + and `.github/copilot-instructions.md`. + +#### Steps +1. In Agent Mode, invoke the demo skill (or ask Copilot to read + `.github/skills/demo/SKILL.md`). +2. Observe **no confirmation** for `SKILL.md` **or** the referenced `notes.md`. +3. Type: `read the project instructions and summarize them`. +4. Observe **no confirmation** for `coding.instructions.md` — even though the + default `.github/instructions/*` deny rule matches. +5. Type: `read .github/copilot-instructions.md`. +6. Observe **no confirmation**. +7. Type: `add a comment line to .github/skills/demo/SKILL.md`. +8. Observe **confirmation dialog appears** — editing is not exempt. +9. Type: `read src/demo/App.java`. +10. Observe **confirmation dialog appears** — ordinary files are unaffected. + +#### Expected Result +- Reads of skill files (and their helpers), instruction/prompt/agent files, and + the fixed well-known files auto-approve with no dialog. +- Editing a customization file still prompts. +- Ordinary (non-customization) reads still prompt. + +#### 📸 Key Screenshots +- [ ] Skill `SKILL.md` + `notes.md` read with no confirmation. +- [ ] Instruction file read with no confirmation despite the deny rule. +- [ ] Edit to `SKILL.md` shows a confirmation dialog. + +--- + +### TC-011: User-global customization files auto-approve + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- "Auto approve file operations not covered by rules" is **unchecked**. +- A user-global skill exists, e.g. `~/.copilot/skills/demo-global/SKILL.md`. + +#### Steps +1. In Agent Mode, invoke the global demo skill (or ask Copilot to read its + `SKILL.md`). +2. Observe **no confirmation** — global customization files are exempt even + though they live outside the workspace (an ordinary outside-workspace file + would still prompt, per TC-006). + +#### Expected Result +- User-global customization reads auto-approve without a dialog. + +#### 📸 Key Screenshots +- [ ] Global skill read with no confirmation. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-file-and-text-search-to-ide.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-file-and-text-search-to-ide.md new file mode 100644 index 00000000..29350633 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-file-and-text-search-to-ide.md @@ -0,0 +1,111 @@ +# Support Delegating File and Text Search to IDE + +## Overview +Verify that Copilot Agent mode can satisfy language-server `workspace/findFiles` and `workspace/findTextInFiles` +requests by delegating file-name and text-content search to Eclipse. This matters for ABAP support because ADT resources +may use Eclipse-managed or virtual URIs that the Copilot language server cannot search directly from the local file system. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- Manual unit-level invocation of `workspace/findFiles` or `workspace/findTextInFiles`; this plan verifies the + user-visible Agent flow. +- Generic local-file search outside ABAP ADT resources. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- ABAP Development Tools is installed and connected to an ABAP system. +- The workspace contains an imported ABAP project with a known package that has at least one ABAP development object + visible in Project Explorer. +- A test ABAP development object is available that can be opened in ADT and contains, or can safely be updated to + contain, a unique search token such as `ZCOPILOT_SEARCH_ABAP_144` in a comment or string literal. + +--- + +## 1. ABAP ADT file and text search delegation + +### TC-001: Agent finds a known token inside ABAP source through Eclipse text search + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open with the ABAP project loaded and connected. +- A test ABAP development object in the target package contains the unique token `ZCOPILOT_SEARCH_ABAP_144` in a comment + or string literal. +- The object containing the token is visible in Project Explorer and can be opened from ADT. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Open the ABAP development object that contains `ZCOPILOT_SEARCH_ABAP_144` and verify the token is present in the + editor. +2. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat` if it is not already open. +3. Switch the chat mode selector to **Agent**. +4. Send a prompt that asks Agent mode to search the ABAP project or package for the token, for example: `Search ABAP + project <project name> for the text ZCOPILOT_SEARCH_ABAP_144. Return the matching object path + and line text.` +5. If Copilot asks for tool confirmation, approve the requested workspace text-search operation. +6. Wait for the Copilot response to complete. +7. Compare the returned object and line text with the ABAP editor content. + +#### Expected Result +- Copilot completes the request without reporting that the ABAP source, ADT resource, `semanticfs` URI, or virtual file + cannot be searched or read. +- The response includes the ABAP development object that contains `ZCOPILOT_SEARCH_ABAP_144`. +- The response includes the matching line text, or enough surrounding context to prove that the token was found inside + the ABAP source object. +- The Eclipse error log has no `workspace/findTextInFiles`, `Invalid regex`, `Invalid glob`, or `Failed to search text` + error for the selected ABAP resource. + +#### Key Screenshots +- [ ] **ABAP token in editor** -- The ABAP development object open in ADT with `ZCOPILOT_SEARCH_ABAP_144` visible. +- [ ] **Agent text-search prompt** -- Copilot Chat in Agent mode with the token-search prompt visible. +- [ ] **Completed text-search response** -- The completed response showing the matching ABAP object and line text. + +#### Notes on failure modes +- Copilot returns file-name matches but cannot find the token -- `workspace/findFiles` may work while + `workspace/findTextInFiles` is not reading ABAP resources through Eclipse EFS. +- The match is missing when the token differs only by case -- text search should be case-insensitive, so check the + regex/text-search handling path. +- The chat turn hangs after approval -- the text search may be blocked while reading an ADT-backed resource. + +### TC-002: Agent handles no-match ABAP searches without surfacing errors + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- The Eclipse workbench is open with the ABAP project loaded and connected. +- Copilot Chat is open in Agent mode. +- A deliberately nonexistent ABAP object name or token is chosen, for example `ZCOPILOT_SEARCH_ABAP_144_DOES_NOT_EXIST`. + +#### Steps +1. Ask Agent mode to search the target ABAP package for files or development objects matching the nonexistent name. +2. If Copilot asks for tool confirmation, approve the requested workspace search operation. +3. Wait for the response to complete. +4. Ask Agent mode to search the same ABAP package for the nonexistent text token. +5. If Copilot asks for tool confirmation, approve the requested workspace text-search operation. +6. Wait for the response to complete. +7. Open the Eclipse error log and check for errors emitted during both searches. + +#### Expected Result +- Copilot completes both turns and explains that no matching ABAP files, objects, or text results were found. +- No error dialog is shown to the user. +- The Eclipse error log has no uncaught exception or stack trace from `workspace/findFiles`, `workspace/findTextInFiles`, + glob matching, regex matching, or URI parsing. + +#### Key Screenshots +- [ ] **No-match file-search response** -- Copilot Chat showing a completed response for the nonexistent ABAP object + name. +- [ ] **No-match text-search response** -- Copilot Chat showing a completed response for the nonexistent ABAP token. + +#### Notes on failure modes +- An invalid-pattern or URI error appears for an ordinary no-match search -- the delegated search should return an empty + result rather than surfacing an exception. +- The response includes unrelated local files or source lines -- the search may not be scoped to the selected ABAP + project or package. \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-read-directory-to-ide.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-read-directory-to-ide.md new file mode 100644 index 00000000..96e24748 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/delegating-read-directory-to-ide.md @@ -0,0 +1,115 @@ +# Support Delegating Read Directory to IDE + +## Overview +Verify that Copilot Agent mode can satisfy language-server `workspace/readDirectory` requests by delegating +directory listing to Eclipse. This matters for ABAP support because ADT resources may use Eclipse-managed or virtual +URIs that the Copilot language server cannot list directly from the local file system. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- General file and text search delegation, which is covered by the separate search delegation task. +- Manual unit-level invocation of `workspace/readDirectory`; this plan verifies the user-visible Agent flow. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- ABAP Development Tools is installed and connected to an ABAP system. +- The workspace contains an imported ABAP project that has previously cached at least one ABAP development object + locally (i.e., the object has been opened from Project Explorer at least once so the `.adt/...` workspace folder + contains a corresponding `IFolder`/`IFile` for it). +- A folder under the project's locally cached ADT structure (e.g., `<project>/.adt/classlib/classes/<class_name>/`) is + visible in Project Explorer with the **Show Hidden Resources** filter disabled, and has at least one child entry on + disk. + +--- + +## 1. ABAP ADT directory delegation + +### TC-001: Agent lists a cached ADT directory through Eclipse + +**Type:** `Happy Path` +**Priority:** `P0` + +> Note: Virtual ABAP packages shown in Project Explorer are not Eclipse `IResource`s, and ADT only materializes +> development objects under `<project>/.adt/...` after they are opened. The delegated `workspace/readDirectory` +> implementation lists workspace resources, so this test targets a *cached* ADT folder where children are guaranteed to +> exist on disk. + +#### Preconditions +- The Eclipse workbench is open with the ABAP project loaded and connected. +- A locally cached ADT folder (e.g., `<project>/.adt/classlib/classes/<class_name>/`) is visible in Project Explorer + and contains at least one cached child entry (a sub-folder or a development object file such as `<name>.aclass`). +- Copilot Chat is open in a fresh or cleared conversation. + +#### Steps +1. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat`. +2. Switch the chat mode selector to **Agent**. +3. In Project Explorer, expand the target ABAP project and create a folder under it. +4. Attach that folder to the chat context (drag-and-drop into the input area or use **Add Context...**) so Copilot + sends its workspace URI to the language server. +5. Send a prompt that asks Agent mode to list its immediate children, for example: + `List the immediate children of the attached folder. Do not search recursively.` +6. If Copilot asks for tool confirmation, approve the requested workspace read operation. +7. Wait for the Copilot response to complete. +8. Compare the listed entries in the response with the immediate children visible under the same folder in Project + Explorer. + +#### Expected Result +- Copilot completes the request without reporting that the folder URI, ADT resource, `platform:/resource` URI, or + `semanticfs:` URI cannot be read. +- The response includes the immediate child entries that are visible on disk for the attached cached ADT folder. +- The response does not include recursive grandchildren when the prompt requested immediate children only. +- The Eclipse error log has no `workspace/readDirectory`, `Invalid container URI`, or `Failed to read directory` error + for the selected ABAP resource. + +#### Key Screenshots +- [ ] **Cached ADT folder in Project Explorer** -- The selected folder expanded to show the child entries used as the + expected result. +- [ ] **Agent prompt** -- Copilot Chat in Agent mode with the folder attached and the listing prompt visible. +- [ ] **Completed Agent response** -- The completed response showing the matching child entries. + +#### Notes on failure modes +- Copilot says the directory cannot be read or is unsupported -- Eclipse may not be resolving the ADT, + `platform:/resource`, or `semanticfs:` URI through the delegated `workspace/readDirectory` path. +- The response is empty while Project Explorer shows children -- the IDE may have returned no accessible `IContainer` + for the resource; verify the URI scheme is among the supported schemes (`PlatformUtils.getSupportedUriSchemes()`), + the ABAP project is connected, and the target folder is genuinely cached on disk (a virtual ABAP *package* will + legitimately return empty because it is not an `IResource`). +- The response includes unrelated workspace files -- the language server may have fallen back to local filesystem + listing instead of the Eclipse workspace resource. + +### TC-002: Agent handles an empty or inaccessible ABAP package without crashing + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- The Eclipse workbench is open with the ABAP project loaded. +- An empty ABAP package, a package with no visible child resources, or a package that can be temporarily + disconnected/unloaded is available. +- Copilot Chat is open in Agent mode. + +#### Steps +1. In Project Explorer, locate the empty, unloaded, or inaccessible ABAP package and note its displayed name. +2. Ask Agent mode to list the immediate children of that ABAP package. +3. If Copilot asks for tool confirmation, approve the requested workspace read operation. +4. Wait for the response to complete. +5. Open the Eclipse error log and check for errors emitted during the request. + +#### Expected Result +- Copilot completes the turn and explains that no child entries are available, or that the resource could not be accessed, without hanging or crashing the chat view. +- No error dialog is shown to the user. +- The Eclipse error log has no uncaught exception or stack trace from `workspace/readDirectory` or directory URI parsing. + +#### Key Screenshots +- [ ] **Empty or inaccessible ABAP package** -- Project Explorer showing the package state before the prompt. +- [ ] **Graceful Agent response** -- Copilot Chat showing a completed response without a crash or endless spinner. + +#### Notes on failure modes +- The chat turn remains in progress indefinitely -- the `workspace/readDirectory` request may not be completing for unresolved ADT resources. +- An error dialog or stack trace appears -- URI handling should return an empty directory result for unresolved resources instead of surfacing an exception. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md new file mode 100644 index 00000000..0e55246e --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/file-system/local-file-edit-and-create-tools.md @@ -0,0 +1,286 @@ +# Support Editing and Creating Local Files Outside the Workspace + +## Overview +Verify that Copilot Agent mode can edit and create local filesystem files that are outside the Eclipse workspace, and +that those changes are surfaced through the file change summary bar with the same review actions users expect for +workspace files. + +This covers the user-visible flow for the `insert_edit_into_file` and `create_file` tools when the target is an +absolute local path rather than an Eclipse `IFile`. + +Entry points: +- Window -> Show View -> Other... -> Copilot -> Copilot Chat -> Agent mode + +Not exercised: +- Direct unit-level invocation of the file tools. +- Workspace-file edit coverage. +- Low-level compare editor APIs; this plan verifies the Compare UI through the summary bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot and Agent mode is available in the Copilot Chat view. +- A writable local directory outside the Eclipse workspace is available, for example: + - Windows: `%TEMP%\\copilot-eclipse-local-file-tools` + - macOS/Linux: `/tmp/copilot-eclipse-local-file-tools` +- The local directory contains an existing text file named `existing-local-file.txt` with this content: + `before local edit` +- The local directory does not contain `created-local-file.txt` before the create-file test starts. + +--- + +## 1. Edit an existing local file outside the workspace + +### TC-001: Agent edits a local file and exposes the change in the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. + +#### Steps +1. Open **Copilot Chat** from `Window -> Show View -> Other... -> Copilot -> Copilot Chat`. +2. Switch the chat mode selector to **Agent**. +3. Send a prompt that asks Agent mode to edit the external local file by absolute path, for example: + `Edit <absolute path to existing-local-file.txt> so its entire content is exactly "after local edit".` +4. If Copilot asks for tool confirmation, approve the file edit operation. +5. Wait for the Agent turn to complete. +6. Verify the file change summary bar appears in the Chat view. +7. Verify the summary bar includes `existing-local-file.txt` and displays a local filesystem path for that file. +8. Click **View Diff** for `existing-local-file.txt`. +9. Verify the Compare editor opens and shows the original content `before local edit` against the modified content + `after local edit`. +10. Close the Compare editor. + +#### Expected Result +- Copilot completes the edit without reporting that the file is outside the workspace or cannot be edited. +- The local file on disk contains `after local edit`. +- The summary bar lists `existing-local-file.txt` even though it is not an Eclipse workspace file. +- The Compare editor opens from **View Diff** and shows the correct before/after content. +- No error dialog is shown. The Eclipse error log has no uncaught exception from `insert_edit_into_file`, local file + path handling, or compare editor creation. + +#### Key Screenshots +- [ ] **Agent edit prompt** -- Copilot Chat in Agent mode with the absolute local file path visible. +- [ ] **Summary bar after local edit** -- The changed local file appears in the file change summary bar. +- [ ] **Local file Compare editor** -- The Compare editor shows `before local edit` vs. `after local edit`. + +#### Notes on failure modes +- The edit succeeds on disk but the file is missing from the summary bar -- the local `Path` change may not be tracked + by the summary bar model. +- **View Diff** does nothing or throws an error -- local files may not be routed through the local Compare input path. +- The diff baseline shows the modified content on both sides -- the original content may not have been cached before + applying the edit. + +### TC-002: Keep clears the local file change and later edits use a new baseline + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Keep** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Send another Agent prompt to edit the same absolute file path so its entire content is exactly `second local edit`. +4. Approve the edit if prompted and wait for the turn to complete. +5. Click **View Diff** for `existing-local-file.txt`. +6. Verify the Compare editor shows `after local edit` as the original content and `second local edit` as the modified + content. + +#### Expected Result +- **Keep** accepts the current local file content and clears the tracked change. +- The next edit of the same local file starts a new diff baseline from the kept content. +- The file remains accessible through the summary bar and Compare editor after the second edit. + +#### Key Screenshots +- [ ] **After Keep** -- The summary bar no longer lists the local file. +- [ ] **Second local diff** -- The Compare editor shows the kept content as the new baseline. + +### TC-003: Undo restores the original local file content + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in a fresh or cleared conversation. +- `existing-local-file.txt` exists outside the workspace and contains `before local edit`. +- Agent mode has edited `existing-local-file.txt` so it contains `after local edit`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `existing-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Open `existing-local-file.txt` from the local filesystem and inspect its content. + +#### Expected Result +- **Undo** restores the file to the original content captured before the tracked edit. +- The file is removed from the summary bar after undo completes. +- No error dialog is shown and the Eclipse error log has no local file undo exception. + +#### Key Screenshots +- [ ] **Before Undo** -- The summary bar lists the edited local file. +- [ ] **After Undo** -- The summary bar no longer lists the local file and the file content is restored. + +--- + +## 2. Create a new local file outside the workspace + +### TC-004: Agent creates a local file and opens it from the summary bar + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- `created-local-file.txt` does not exist in the local test directory. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that asks Agent mode to create the external local file by absolute path, for example: + `Create <absolute path to created-local-file.txt> with the exact content "created local content".` +2. If Copilot asks for tool confirmation, approve the file create operation. +3. Wait for the Agent turn to complete. +4. Verify `created-local-file.txt` exists on disk and contains `created local content`. +5. Verify the file change summary bar lists `created-local-file.txt`. +6. Click **View Diff** for `created-local-file.txt`. +7. Verify Eclipse opens `created-local-file.txt` in an editor and shows `created local content`. + +#### Expected Result +- Copilot creates the local file without requiring it to be inside an Eclipse workspace project. +- The created file is listed in the summary bar. +- The created local file can be opened from the summary bar. +- No error dialog is shown and the Eclipse error log has no local file create or editor-open exception. + +#### Key Screenshots +- [ ] **Agent create prompt** -- Copilot Chat in Agent mode with the absolute create path visible. +- [ ] **Summary bar after local create** -- The created local file appears in the file change summary bar. +- [ ] **Created local file editor** -- The external local file opens in an editor with the created content. + +### TC-005: Undo removes a created local file + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- `created-local-file.txt` does not exist in the local test directory. +- Agent mode has created `created-local-file.txt` with content `created local content`, and the file is listed in the + summary bar. + +#### Steps +1. Click **Undo** for `created-local-file.txt` in the file change summary bar. +2. Verify the file is removed from the summary bar. +3. Verify `created-local-file.txt` no longer exists on disk. + +#### Expected Result +- **Undo** for a created local file deletes the file, matching the create-file semantics. +- The summary bar no longer lists the created file after undo completes. +- No error dialog is shown and the Eclipse error log has no local file deletion exception. + +#### Key Screenshots +- [ ] **Before created-file Undo** -- The summary bar lists `created-local-file.txt`. +- [ ] **After created-file Undo** -- The summary bar is clear and the file is absent from disk. + +--- + +## 3. Navigate to local files from tool links + +### TC-006: Tool result links open local files outside the workspace + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- The local test directory outside the workspace exists and contains `existing-local-file.txt`. + +#### Steps +1. Send a prompt that causes Agent mode to reference or edit `existing-local-file.txt` by absolute path. +2. When the tool call appears in the Chat view, click the file path link for `existing-local-file.txt`. +3. Verify Eclipse opens `existing-local-file.txt` in an editor. + +#### Expected Result +- File links for paths outside the Eclipse workspace open the local file in an Eclipse editor. +- No error dialog is shown and the Eclipse error log has no local file navigation exception. + +#### Key Screenshots +- [ ] **Local file tool link** -- The tool result shows a clickable absolute path outside the workspace. +- [ ] **External local file editor** -- The external local file opens in an Eclipse editor. + +### TC-007: Workspace directory and project links reveal in the Project Explorer + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The Eclipse workbench is open with at least one project (e.g., `demo`) that contains a sub-folder (e.g., `src`). +- The **Project Explorer** view is open. +- Copilot Chat is open in Agent mode. + +#### Steps +1. Send a prompt that causes Agent mode to reference the workspace sub-folder by path (for example, ask it to list the + contents of the `src` folder so the tool result renders a directory link). +2. When the tool call appears in the Chat view, click the directory link for the `src` folder. +3. Verify the `src` folder is selected and revealed in the **Project Explorer** (no external browser opens). +4. Send a prompt that causes Agent mode to reference the project root, then click the project link in the tool result. +5. Verify the project is selected and revealed in the **Project Explorer**. + +#### Expected Result +- Clicking a workspace folder or project link reveals and selects that resource in the Project Explorer. +- No external browser or web page is opened for directory/project links. +- No error dialog is shown and the Eclipse error log has no navigation exception. + +#### Key Screenshots +- [ ] **Directory tool link** -- The tool result shows a clickable workspace folder link. +- [ ] **Folder revealed** -- The `src` folder is selected and revealed in the Project Explorer. +- [ ] **Project revealed** -- The project root is selected and revealed in the Project Explorer. + +#### Notes on failure modes +- Clicking the directory link opens a browser or does nothing -- the folder/project branch may not route through + `UiUtils.revealInExplorer`. +- The resource is opened in an editor instead of being revealed -- folder/project types may be incorrectly treated as + files. + +### TC-008: File URI link with a line-number fragment opens the external local file + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Preconditions +- The Eclipse workbench is open. +- Copilot Chat is open in Agent mode. +- The local test directory outside the workspace exists and contains `existing-local-file.txt` with multiple lines. + +#### Steps +1. Send a prompt that causes Agent mode to reference `existing-local-file.txt` by absolute path with a line-number + fragment (for example, a link ending in `#L10` or a `file:` URI with a `#L10` fragment). +2. When the tool call appears in the Chat view, click the file path link. +3. Verify Eclipse opens `existing-local-file.txt` in an editor (the trailing line-number fragment is treated as a + fragment, not part of the file name). + +#### Expected Result +- The line-number fragment is stripped when resolving the local path, so the correct external file opens. +- Eclipse does not report a missing file named `existing-local-file.txt#L10` or a path-resolution error. +- No error dialog is shown and the Eclipse error log has no local file navigation exception. + +#### Key Screenshots +- [ ] **Fragment file link** -- The tool result shows a clickable local path with a `#L10` fragment. +- [ ] **External file opened** -- The external local file opens in an Eclipse editor despite the fragment. + +#### Notes on failure modes +- Eclipse reports the file cannot be found -- the `#` fragment may not be stripped before resolving the local path. +- A relative-path or `https:` link is unexpectedly opened as a local file -- the URI-scheme guard in + `FileUtils.getLocalFilePath` may be bypassed. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md new file mode 100644 index 00000000..09cf4171 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/global-auto-approve/global-auto-approve.md @@ -0,0 +1,125 @@ +# Global Auto-Approve + +## Overview + +Tests the Global Auto-Approve (YOLO) feature: enabling/disabling it via the +preference page and verifying that all tool confirmations are bypassed when +active. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → Global Auto-Approve** — + the "Automatically approve ALL tool invocations" checkbox with its + confirmation dialog. +- **Agent Mode chat** — any tool call (terminal, file operation, MCP) to + observe confirmation bypass. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- Global Auto-Approve is **disabled** at the start of each scenario. + +--- + +## 1. Enable Global Auto-Approve — confirmation dialog required + +### TC-001: Enable Global Auto-Approve → confirmation dialog required +→ all tools skip confirmation + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox. +3. Observe that a **confirmation dialog immediately appears** asking the user + to confirm this dangerous setting. +4. Verify the dialog title and message warn about the risk. +5. Click **Cancel** — verify the checkbox remains **unchecked**. +6. Click the checkbox again, then click **OK** in the confirmation dialog. +7. Verify the checkbox is now **checked**. +8. Click **"Apply and Close"**. +9. In Agent Mode, send a prompt that would normally trigger a confirmation + (e.g., an MCP tool call or a terminal command). +10. Observe that **no confirmation dialog appears** — all tools auto-approve. + +#### Expected Result +- Enabling YOLO mode requires an explicit confirmation dialog. +- Cancelling the dialog keeps the checkbox unchecked. +- When enabled, all tool confirmations (terminal, file operations, MCP) + are bypassed. + +#### 📸 Key Screenshots +- [ ] Confirmation dialog when enabling YOLO mode. +- [ ] Checkbox unchecked after Cancel. +- [ ] Checkbox checked after OK. +- [ ] Agent Mode: tool runs without any confirmation dialog. + +--- + +## 2. Disable Global Auto-Approve — no confirmation needed + +### TC-002: Disable Global Auto-Approve → no dialog → tools require +confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. + +#### Steps +1. Open **Preferences → Tool Auto Approve → Global Auto-Approve** section. +2. Click the **"Automatically approve ALL tool invocations"** checkbox to + uncheck it. +3. Observe that **no confirmation dialog appears** — turning it off is safe + and does not require confirmation. +4. Verify the checkbox is now **unchecked**. +5. Click **"Apply and Close"**. +6. In Agent Mode, trigger any tool. +7. Observe that the **confirmation dialog appears** — YOLO mode is off. + +#### Expected Result +- Disabling YOLO mode does not require a confirmation dialog. +- Tools require confirmation again after disabling. + +#### 📸 Key Screenshots +- [ ] Checkbox unchecked without any dialog. +- [ ] Tool shows confirmation dialog again. + +--- + +## 3. Global Auto-Approve overrides all tool categories + +### TC-003: Global Auto-Approve bypasses terminal deny rules, MCP +rules, and file operation rules + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Global Auto-Approve is **enabled**. +- Terminal has a custom **Deny** rule for `curl`. + +#### Steps +1. In Agent Mode, trigger a `curl` terminal command (normally blocked by the + deny rule). +2. Observe **auto-approved** — YOLO mode bypasses the deny rule. +3. Trigger an MCP tool call with no prior MCP approval. +4. Observe **auto-approved** — YOLO mode bypasses MCP confirmation. +5. Trigger a file operation on a file not in the attached context. +6. Observe **auto-approved** — YOLO mode bypasses file operation confirmation. + +#### Expected Result +- Global Auto-Approve bypasses ALL tool categories regardless of individual + rules or approval lists. + +#### 📸 Key Screenshots +- [ ] `curl` auto-approved despite deny rule. +- [ ] MCP tool auto-approved without prior approval. +- [ ] File operation auto-approved. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md new file mode 100644 index 00000000..0659ec51 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/mcp-auto-approve/mcp-auto-approve.md @@ -0,0 +1,265 @@ +# MCP Auto-Approve + +## Overview + +Tests the MCP tool auto-approve feature end-to-end: configuring rules in the +preference page, then triggering Agent Mode tool calls and observing whether +the confirmation dialog appears or the tool runs automatically. + +Each test case exercises the full stack: preference store → +`McpConfirmationHandler` → dialog (or auto-approve) → tool execution. This +mirrors the real user workflow: tweak settings, chat with Copilot via an MCP +tool, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve → MCP Configuration** — + the "Trust MCP tool annotations" checkbox and the server/tool tree. +- **Agent Mode chat** — sending prompts that trigger MCP tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions for tool and server scope. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **At least one MCP server configured** in the Copilot MCP settings, with + at least one tool available. Note the server name and a tool name for use + in the prompts below. +- **Agent Mode** selected in the chat mode dropdown. +- All MCP auto-approve preferences at their defaults before each scenario: + no globally approved servers/tools, "Trust MCP tool annotations" unchecked. + +--- + +## 1. Default behavior: confirmation dialog appears for MCP tools + +### TC-001: MCP tool call with no rules → confirmation dialog + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Navigate to the **MCP Configuration** section. +3. Verify the server/tool tree shows no tools checked. +4. Confirm "Trust MCP tool annotations" is unchecked. +5. Close preferences. +6. Open the **Copilot Chat** view and select **Agent** mode. +7. Send a prompt that triggers the known MCP tool (e.g., `use <toolName> to + <action>`). +8. Wait for the Copilot turn — the agent should invoke the MCP tool. +9. Observe the **confirmation dialog** that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: `Run '<toolName>' tool from '<serverName>' MCP server`. + - A description of the MCP tool call. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown contains: + - "Allow '<toolName>' in this Session" + - "Always Allow '<toolName>'" + - "Allow tools from '<serverName>' in this Session" + - "Always Allow tools from '<serverName>'" +13. Click **"Skip"**. +14. Verify the tool was **NOT** executed. + +#### Expected Result +- Confirmation dialog appears for MCP tools with no auto-approve rules. +- Dropdown shows tool-level and server-level scoped actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] MCP preference section with no checked tools. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no tool output. + +--- + +## 2. Session allow for a specific tool + +### TC-002: "Allow tool in Session" → same tool auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown arrow and select **"Allow '<toolName>' in this + Session"**. +4. The tool executes. +5. In the **same conversation**, send another prompt that triggers the same + tool. +6. Observe that **no confirmation dialog appears** — the tool is + session-approved. +7. Start a **new conversation** (click "New Chat" or equivalent). +8. Send a prompt that triggers the same MCP tool. +9. Observe that the **confirmation dialog appears again** — session approvals + do not carry over. + +#### Expected Result +- Session approval auto-approves the same tool within the conversation. +- New conversation resets session approvals. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow '<toolName>' in this Session". +- [ ] Second invocation (same conversation): auto-approved, no dialog. +- [ ] New conversation: dialog reappears. + +--- + +## 3. Session allow for an entire server + +### TC-003: "Allow all tools from server in Session" → all tools from +that server auto-approve + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers any tool from the target + MCP server. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Allow all tools from '<serverName>' + in this Session"**. +4. The tool executes. +5. In the **same conversation**, send prompts that trigger **different tools** + from the same server. +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any tool from the same server. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- Server-level session approval covers all tools from that server. +- New conversation resets the session approval. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Allow tools from '<serverName>' in this Session". +- [ ] Second tool from same server: auto-approved. +- [ ] New conversation: dialog reappears. + +--- + +## 4. "Always Allow" for a specific tool — global persistence + +### TC-004: "Always Allow '<toolName>'" → persists across conversations → +visible in preferences tree + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- No global approved servers or tools. +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger the MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow '<toolName>'"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the specific tool is **checked** in the server/tool tree. +7. Close preferences. +8. Start a **new conversation**. +9. Send a prompt that triggers the same MCP tool. +10. Observe that **no confirmation dialog appears** — the global rule persists. + +#### Expected Result +- "Always Allow" writes the tool key to the global preference store. +- The tool appears checked in the preference tree. +- The approval persists across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: selecting "Always Allow '<toolName>'". +- [ ] Preference tree: tool checked. +- [ ] New conversation: tool auto-approved without dialog. + +--- + +## 5. "Always Allow" for an entire server — global persistence + +### TC-005: "Always Allow all tools from '<serverName>'" → server shown +checked in preferences + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- No global approved servers or tools (clear any rules written by TC-004 + if running in sequence). +- "Trust MCP tool annotations" is **unchecked**. + +#### Steps +1. In Agent Mode, trigger any MCP tool. +2. Confirmation dialog appears. +3. Click the dropdown and select **"Always Allow all tools from + '<serverName>'"**. +4. The tool executes. +5. Open **Preferences → Tool Auto Approve → MCP Configuration**. +6. Verify the server node is **checked** in the tree. +7. Close preferences. +8. Start a **new conversation** and trigger different tools from the same + server. +9. Observe all tools **auto-approve without dialog**. + +#### Expected Result +- "Always Allow" for server writes to the global servers list. +- The server row appears checked in the preference tree. +- All tools from the server auto-approve in new conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown: "Always Allow all tools from '<serverName>'". +- [ ] Preference tree: server node checked. +- [ ] New conversation: all server tools auto-approved. + +--- + +## 6. Trust MCP tool annotations — read-only tools auto-approve + +### TC-006: Enable "Trust MCP tool annotations" → tools with +readOnlyHint=true auto-approve + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- An MCP tool is available with `readOnlyHint=true` and `openWorldHint=false` + in its annotations. + +#### Steps +1. Open **Preferences → Tool Auto Approve → MCP Configuration**. +2. Check **"Trust MCP tool annotations"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, send a prompt that triggers the read-only MCP tool. +5. Observe that **no confirmation dialog appears** — the tool auto-approves + because it is annotated as read-only and closed-world. +6. Send a prompt that triggers an MCP tool that does NOT have + `readOnlyHint=true` (or has `openWorldHint=true`). +7. Observe that the **confirmation dialog appears** — only strictly + read-only + closed-world tools bypass confirmation. + +#### Expected Result +- Tools with `readOnlyHint=true` AND `openWorldHint=false` auto-approve. +- All other tools still show the confirmation dialog. + +#### 📸 Key Screenshots +- [ ] Preference: "Trust MCP tool annotations" checked. +- [ ] Read-only tool: auto-approved. +- [ ] Non-read-only tool: confirmation dialog shown. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/model-picker/model-picker.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/model-picker/model-picker.md new file mode 100644 index 00000000..1d18ab35 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/model-picker/model-picker.md @@ -0,0 +1,177 @@ +# Chat: Model Picker + +## Overview + +Tests the **model picker** dropdown in the Chat view action bar. The model +picker lets users choose which AI model powers their chat conversations. It +displays available models grouped by tier (Standard, Premium, Custom), shows +per-model details on hover, and includes a **Manage Models...** shortcut for +users with BYOK enabled. + +Entry points exercised: +- **Ctrl+Alt+I** (or status bar → **Open Chat**), then interact with the model + picker button in the action bar. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account. Without a valid account the model list cannot + be fetched and the picker will show no items. + +--- + +## 1. Model picker loads, opens, selects, and routes a message + +### TC-001: Open model picker, browse grouped models, switch model, and verify a response uses it + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions + +- The Eclipse workbench is open. +- The user is signed in to Copilot. +- No previous Chat view is open. + +#### Steps + +1. Open the **Copilot Chat** view via the keyboard shortcut **Ctrl+Alt+I** + (Windows/Linux) or **Ctrl+Cmd+I** (macOS), or click the Copilot status bar + icon and select **Open Chat**. +2. Wait for the Chat view to fully load (the input area is editable, the model + picker shows a resolved model name instead of "Loading..."). +3. Verify the model picker button on the right side of the action bar displays + a model name (e.g. `Claude Sonnet 4.6`) with a dropdown arrow. +4. Hover the mouse over the model picker button and verify a tooltip appears + (e.g. `Pick Model`). +5. Click the model picker button to open the dropdown popup. +6. Verify the dropdown lists models grouped under labelled headers (e.g. + **Standard Models**, **Premium Models**), with each model showing a cost + multiplier on the right (e.g. `0x`, `1x`, `3x`). +7. Verify the currently selected model is marked with a **checkmark** (✓) + in the list. +8. Hover over a model item in the dropdown and verify a **detail card** + appears showing the model's **family** (e.g. `Family: claude-sonnet-4.5`), + **cost** (e.g. `Cost: 1x premium`), and **category tag** (e.g. + `Versatile`). +9. Click a model different from the currently selected one. +10. Verify the dropdown closes and the model picker button label updates to + show the newly selected model name. +11. Type a prompt (e.g. `hello`) in the chat input and click **Send**. +12. Wait for the Copilot turn to complete (the model-info-label footer + appears). +13. Verify the model info label at the bottom of the completed turn matches + the model selected in step 9. + +#### Expected Result + +- The model picker loads and shows the default model. +- The dropdown opens with grouped models and cost multipliers; the selected + model has a checkmark. +- Hovering a model shows a detail card with family, cost, and category. +- Selecting a different model updates the picker label immediately. +- The response is served by the newly selected model, confirmed by the turn + footer. + +#### 📸 Key Screenshots + +- [ ] **Model picker loaded** — action bar showing the resolved model name. +- [ ] **Dropdown open** — popup showing grouped model list with the active + model marked with a checkmark. +- [ ] **Model hover card** — detail card showing family, cost, and category + tag for a model item. +- [ ] **Model switched** — picker button showing the new model name. +- [ ] **Response with new model** — completed Copilot turn whose footer shows + the new model name. + +--- + +## 2. Chat mode interaction + +### TC-002: Switching chat mode updates the model picker + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions + +- The Chat view is open. +- Both **Ask** and **Agent** modes are available in the mode picker. + +#### Steps + +1. Note the model currently shown in the model picker. +2. Switch the chat mode from **Agent** to **Ask** (or vice versa) using the + mode picker on the left side of the action bar. +3. Observe the model picker button — it should still show a valid model name + (which may differ from the previous one if the old model is not available + in the new mode). +4. Open the model picker dropdown and verify the model list has loaded (it may + contain different models than before). +5. Close the dropdown (click outside or press Escape). + +#### Expected Result + +- The model picker updates automatically when the chat mode changes. +- No crash, blank picker, or stale model name occurs. + +#### 📸 Key Screenshots + +- [ ] **After mode switch** — action bar showing the updated mode label and the + model picker with a valid model name. + +--- + +## 3. BYOK custom models and Manage Models shortcut + +### TC-003: Custom models appear in the picker and "Manage Models..." opens the preference page + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions + +- The user is signed in with an account that has BYOK enabled. +- At least one custom model has been added and enabled via **Window → + Preferences → GitHub Copilot → Model Management**. + +#### Steps + +1. Open the model picker dropdown. +2. Verify a **Custom Models** group is present at the bottom of the list, + containing the configured custom model(s). +3. Select a custom model and verify the picker label updates. +4. Open the model picker dropdown again. +5. Click the **Manage Models...** item at the bottom of the dropdown. +6. Verify the **Preferences** dialog opens directly on the **Model Management** + page. +7. Close the Preferences dialog. + +#### Expected Result + +- Custom models are listed under a dedicated **Custom Models** group. +- Selecting a custom model works the same as selecting a built-in model. +- **Manage Models...** navigates directly to the BYOK preference page. + +#### 📸 Key Screenshots + +- [ ] **Custom Models group** — dropdown showing the Custom Models section with + the user's configured models. +- [ ] **Manage Models... action** — Preferences dialog opened to the Model + Management page after clicking the shortcut. + +--- + +## Notes on failure modes + +- Picker button stays blank / never shows a model name → the model list fetch + failed; check that the account is signed in and network access is available. +- Dropdown is empty → auth is valid but the server returned no models; try + signing out and back in to refresh the token. +- Selected model is not reflected in the completed turn's footer → the model + switch event did not propagate; re-select the model and resend. +- Custom Models group does not appear → BYOK is disabled by org policy, or no + custom model has been added; verify in Model Management preferences. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/nes/nes.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/nes/nes.md new file mode 100644 index 00000000..dc4bc348 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/nes/nes.md @@ -0,0 +1,311 @@ +# Next Edit Suggestions (NES) + +## Overview + +Tests the **Next Edit Suggestions** feature, which proactively shows AI-powered +code changes in the editor as the user types. When Copilot detects that a +recent edit implies a follow-up change elsewhere in the file, it highlights the +affected range with a background color, shows a diff popup with the proposed +change in green ghost text, and renders a Copilot icon in the editor gutter. + +Users can: +- **Tab** to accept the suggestion (or jump to it if off-screen). +- **Esc** to dismiss the suggestion. +- **Click the gutter icon** to open an Accept / Reject action menu. +- **Hover the gutter icon** to see a tooltip with keyboard shortcuts. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account. +- **Enable Next Edit Suggestions** is checked in **Window → Preferences → + GitHub Copilot → Completions** (this is the default). +- A Java project is open in the workspace with at least one Java source file. + +--- + +## 1. Trigger a suggestion, inspect it, and accept via Tab + +### TC-001: NES appears after related edits, shows diff and gutter icon, and is accepted with Tab + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions + +- A Java file with multiple references to the same variable or method is open. +- NES is enabled in Preferences. + +#### Steps + +1. In the editor, make a series of related edits (e.g. rename a variable on + one line, then move the cursor to another line where the same variable is + used — or add a parameter to a method declaration, then navigate to a call + site of that method). +2. After each edit, pause briefly (~1–3 seconds) and observe the editor. +3. Once a NES suggestion appears, verify: + - A **background highlight** is shown on the text range that would be + changed. + - A **diff popup** appears next to the highlighted range showing the + proposed replacement in **green ghost text**. + - A **Copilot icon** appears in the editor gutter on the suggestion line. +4. Hover the mouse over the Copilot gutter icon and verify a tooltip appears + showing keyboard shortcuts: **Tab: Accept suggestion** and + **Esc: Dismiss suggestion**. +5. Press **Tab** on the keyboard. +6. Verify the suggestion is applied: the highlighted text is replaced with the + proposed change, the diff popup disappears, the gutter icon disappears, and + the file is modified (unsaved asterisk in the tab title). + +#### Expected Result + +- NES suggestion appears with highlight, diff popup, and gutter icon. +- Gutter icon tooltip shows Tab / Esc hints. +- Pressing Tab applies the change and clears all NES decorations. + +#### 📸 Key Screenshots + +- [ ] **NES suggestion visible** — editor showing the highlighted range, the + diff popup with green ghost text, and the Copilot gutter icon. +- [ ] **Gutter icon tooltip** — tooltip showing "Tab: Accept / Esc: Dismiss". +- [ ] **After Tab accept** — editor showing the applied change with no + remaining NES decorations. + +--- + +## 2. Reject via Esc and use the gutter icon action menu + +### TC-002: Dismiss a suggestion with Esc, trigger another, and use gutter icon Accept / Reject + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions + +- A Java file is open in the editor. +- NES is enabled. + +#### Steps + +1. Make a series of related edits to trigger a NES suggestion (same approach + as TC-001 steps 1–2). +2. Once the suggestion appears, press **Esc**. +3. Verify the diff popup, background highlight, and gutter icon all disappear. +4. Verify the file content is unchanged — no text was inserted or deleted. +5. Make another series of related edits to trigger a new suggestion. +6. Once the new suggestion appears, **click the Copilot icon** in the gutter. +7. Verify an action menu appears with two options: **Accept** and **Reject**. +8. Click **Reject** in the action menu. +9. Verify the suggestion is dismissed (same as pressing Esc: all decorations + disappear, file unchanged). +10. Trigger yet another suggestion. +11. Click the gutter icon again and this time click **Accept**. +12. Verify the suggestion is applied (same result as pressing Tab in TC-001). + +#### Expected Result + +- Esc dismisses without modifying the file. +- Gutter icon click opens an Accept / Reject menu. +- Reject via menu dismisses the suggestion. +- Accept via menu applies the suggestion. + +#### 📸 Key Screenshots + +- [ ] **After Esc dismiss** — editor with no NES decorations, file unchanged. +- [ ] **Gutter icon menu** — the Accept / Reject action menu next to the + gutter icon. +- [ ] **After menu Accept** — editor showing the applied change. + +--- + +## 3. Out-of-viewport navigation + +### TC-003: Tab jumps to an off-screen suggestion, then a second Tab accepts it + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions + +- A long Java file (200+ lines) is open. +- NES is enabled. + +#### Steps + +1. Make a series of related edits to trigger a NES suggestion (same approach + as TC-001). +2. Once the suggestion is visible (highlight, diff popup, gutter icon), + **scroll away** from the suggestion line so it is no longer in the viewport. +3. Verify a **notification bar** (pill) appears at the bottom of the editor + with a message like **"Press Tab to jump to Next Edit Suggestion"**. +4. Press **Tab**. +5. Verify the editor scrolls back to reveal the suggestion line, the diff + popup and gutter icon become visible again, and the notification bar + disappears. +6. Verify the suggestion is **not** accepted yet — the highlight and diff + popup are still showing. +7. Press **Tab** again. +8. Verify the suggestion is now accepted and all NES decorations disappear. + +#### Expected Result + +- First Tab: jumps to the suggestion without accepting it. +- Second Tab: accepts the suggestion. + +#### 📸 Key Screenshots + +- [ ] **Bottom bar visible** — notification pill at the bottom of the editor. +- [ ] **After first Tab** — editor scrolled to show the suggestion with diff + popup and gutter icon. +- [ ] **After second Tab** — suggestion accepted, all decorations cleared. + +--- + +## 4. Suggestion auto-dismiss on further edits + +### TC-004: Editing the file while a suggestion is active causes it to disappear + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions + +- A Java file is open in the editor. +- NES is enabled. + +#### Steps + +1. Trigger a NES suggestion via a series of related edits (same approach as + TC-001). +2. Once the suggestion is visible (highlight, diff popup, gutter icon), do + **not** accept or reject it. +3. Instead, type additional text anywhere in the file (e.g. add a comment on a + different line, or directly edit the text in the suggested range). +4. Observe whether the NES suggestion disappears immediately after the edit. + +#### Expected Result + +- The NES suggestion (highlight, diff popup, gutter icon) disappears as soon + as the file content is modified by the user. +- The newly typed text is applied normally — no conflict with the old + suggestion. + +#### 📸 Key Screenshots + +- [ ] **Suggestion visible before edit** — NES decorations present. +- [ ] **After typing** — all NES decorations gone, user's new edit applied. + +--- + +## 5. Enable / disable toggle + +### TC-005: Disabling and re-enabling NES in Preferences + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions + +- NES is currently enabled. +- A Java file is open. + +#### Steps + +1. Open **Window → Preferences → GitHub Copilot → Completions**. +2. Uncheck **Enable Next Edit Suggestions**. +3. Click **Apply and Close**. +4. Make a series of related edits in the Java file (same approach as TC-001). +5. Verify no NES suggestion appears (no highlight, no diff popup, no gutter + icon). +6. Open **Window → Preferences → GitHub Copilot → Completions** again. +7. Check **Enable Next Edit Suggestions**. +8. Click **Apply and Close**. +9. Make a series of related edits in the Java file again. +10. Verify a NES suggestion appears. + +#### Expected Result + +- Disabling: no NES decorations appear after edits. +- Re-enabling: NES suggestions resume without restarting Eclipse. +- Inline tab-completions are unaffected by either toggle. + +#### 📸 Key Screenshots + +- [ ] **NES disabled** — Preferences page with the checkbox unchecked. +- [ ] **No suggestion after edit** — editor with no NES decorations after + editing with NES disabled. +- [ ] **NES re-enabled** — suggestion appears again after re-enabling. + +--- + +## 6. No interference with other editor decorations + +### TC-006: No red rectangular border from Copilot appears on SonarQube issue locations + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions + +- Both **GitHub Copilot for Eclipse** and **SonarQube for Eclipse** are + installed. +- NES is enabled. +- A Java project is open with a file that contains a SonarQube issue. For + example, a file like: + ```java + import java.util.List; + + public class TestSonar { + boolean isEmpty(List l) { + return l == null || l.size() == 0; + } + } + ``` + where `l.size() == 0` triggers a SonarQube rule. +- SonarQube analysis has completed and its markers are visible in the editor. + +#### Steps + +1. Open the Java file that has SonarQube issue markers. +2. Do **not** make any edits — just observe the editor. +3. Check whether a **red rectangular border** (BOX style) with the label + `"Text to be Deleted"` appears around the SonarQube issue location. +4. Hover over the SonarQube-annotated area and verify the tooltip shows + SonarQube's own message — not `"Text to be Deleted"`. +5. If a SonarQube quick fix is available, apply it. +6. Verify the red border does not persist after the quick fix is applied. +7. Open a different Java file that has compiler warnings but no SonarQube + issues, and verify no Copilot NES decorations appear on unedited code. + +#### Expected Result + +- No red rectangular border (`"Text to be Deleted"` BOX annotation) from + Copilot appears on SonarQube issue locations. +- SonarQube markers display normally with their own styling. +- Hovering SonarQube markers shows SonarQube's message, not Copilot's. +- After applying a SonarQube quick fix, no Copilot annotation persists. +- On unedited files without SonarQube, no NES decorations appear either. + +#### 📸 Key Screenshots + +- [ ] **SonarQube file — no NES** — editor showing SonarQube markers with no + Copilot decorations overlapping. +- [ ] **SonarQube hover** — tooltip showing the SonarQube message, not a + Copilot annotation. + +--- + +## Notes on failure modes + +- No suggestion appears after editing → NES may be disabled in Preferences, or + the Copilot account is not signed in; verify both and retry. +- Red rectangular border appears on SonarQube markers → the NES annotation + type is still mapped to the root `textmarker`; check that `plugin.xml` does + not declare `markerType="org.eclipse.core.resources.textmarker"` on the NES + annotation types. +- Accepting a suggestion has no effect → the suggestion may have become stale + (the underlying code changed again before Tab was pressed); make a fresh + edit to trigger a new suggestion and accept immediately. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md new file mode 100644 index 00000000..08555695 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/preference-pages/preference-pages.md @@ -0,0 +1,81 @@ +# GitHub Copilot Preference Pages + +## Overview +Covers behavior shared by **all** GitHub Copilot preference pages +(**Preferences → GitHub Copilot → …**), independent of any single feature. +Every Copilot page is hosted in the same JFace `PreferenceDialog`, so concerns +like dialog sizing, layout stability, and navigation are cross-cutting. + +JFace grows the shared dialog to the **tallest** page visited and never shrinks +it again. To keep the dialog a stable size, each Copilot page keeps its content +within a common target height — `PreferencePageUtils.STANDARD_CONTENT_HEIGHT`. +Pages whose natural content is taller (e.g. **Tool Auto Approve**, which stacks +four rule sections) scroll within a height-capped `ScrolledComposite` instead of +ballooning the dialog. The same constant drives `McpPreferencePage`'s two groups, +so the pages settle at a consistent size. + +Entry points exercised: +- **Preferences → GitHub Copilot** — the root category page (only hyperlinks; no + sign-in, always valid). A stable baseline for size comparisons. +- **Preferences → GitHub Copilot → Tool Auto Approve** — the tallest page; the + regression magnet for dialog ballooning. +- Other child pages (MCP, Completions, Chat, …) — for navigation stability. + +--- + +## Prerequisites +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- No sign-in required: the pages below render independently of language-server + data. + +--- + +## 1. Dialog size stability + +### TC-001: Opening a tall page does not balloon the Preferences dialog + +**Type:** `Regression` +**Priority:** `P1` + +#### Steps +1. Open **Window → Preferences** and select **GitHub Copilot** (root page). Note + the dialog's size. +2. In the tree, select **GitHub Copilot → Tool Auto Approve**. +3. Observe the dialog does **not** grow toward screen height — it stays close to + the root page's size. +4. Confirm all four sections (Terminal, File Operation, MCP, Global) are + reachable by scrolling **within the page** using a single page-level vertical + scrollbar. +5. Navigate to a couple of sibling pages (e.g. **MCP**, **Completions**) and back + to Tool Auto Approve; the dialog size stays stable throughout. +6. Close Preferences. + +#### Expected Result +- The Preferences dialog stays a normal, stable size as the selection moves + between Copilot pages; Tool Auto Approve does not balloon toward screen height. +- Tool Auto Approve content is reachable via the page's own single vertical + scrollbar; no second (dialog-level) scrollbar appears. + +#### Reference measurements (Eclipse 2025-03, macOS) +- Root **GitHub Copilot** page dialog height: **~551px**. +- **Tool Auto Approve** dialog height (fixed by the height cap): **~656px** — + deterministic and screen-independent. +- Pre-fix, Tool Auto Approve ballooned to ~screen height (≈986px or + screen-clamped). + +#### 📸 Key Screenshots +- [ ] Dialog on the root **GitHub Copilot** page. +- [ ] Dialog on **Tool Auto Approve** — only slightly taller, content scrolling + within the page (single page scrollbar). + +--- + +## Notes +- Page height is **data-independent** (table/tree height hints are fixed), so MCP + servers loading asynchronously does not change the page height. +- The height cap is unified via `PreferencePageUtils.STANDARD_CONTENT_HEIGHT`, + shared by `AutoApprovePreferencePage` (scroller cap) and `McpPreferencePage` + (two stacked groups). Keep the pages within this height when adding content. +- Use the root **GitHub Copilot** page as the size baseline — it is always valid + and needs no sign-in, whereas the MCP page can enter an invalid state in an + unauthenticated sandbox and pop a JFace validation dialog. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/rate-limit-warning-banner/rate-limit-warning-banner.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/rate-limit-warning-banner/rate-limit-warning-banner.md new file mode 100644 index 00000000..ffb7cdce --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/rate-limit-warning-banner/rate-limit-warning-banner.md @@ -0,0 +1,101 @@ +# Rate Limit Warning Banner + +## Overview +Tests the rate limit warning banner in the GitHub Copilot for Eclipse chat view. +When the Copilot language server emits a `$/copilot/rateLimitWarning` LSP +notification, a `StaticBanner` widget is displayed above the action bar input +area. The banner shows the server-provided human-readable message, a +"Get more info" hyperlink to `https://aka.ms/github-copilot-rate-limit-error`, +and a "Dismiss" button. The banner is wired via an OSGi event topic +(`TOPIC_RATE_LIMIT_WARNING`) from `CopilotLanguageClient` → `ChatView` → +`ActionBar`. Navigating chat history hides/shows the banner appropriately. + +Entry points: +- Triggered automatically by a `$/copilot/rateLimitWarning` LSP notification. +- Dismissed manually via the "×" button on the banner. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- A GitHub account signed in with a Copilot subscription that has measurable + usage quota (so that rate limit notifications can be triggered or simulated). +- A way to trigger or mock a `$/copilot/rateLimitWarning` LSP notification — + options include: + - Exhausting the quota for the account. + - Injecting the notification via a debug breakpoint in `CopilotLanguageClient`. + - Using a test harness / mock language server. +- The Copilot Chat view is open and visible in the workbench. + +--- + +## 1. Banner Appearance + +### TC-001: Banner appears in non-handoff mode on rate limit warning + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The Copilot Chat view is open in a **non-handoff** (standard) chat mode. + +#### Steps +1. Trigger a `$/copilot/rateLimitWarning` notification (type: "weekly" or + "session") from the language server while in standard chat mode. +2. Observe the area above the chat input field in the Action Bar. + +#### Expected Result +- A warning banner appears above the action bar input area. +- The banner text matches the `message` field from the LSP notification. +- No error dialog or exception is logged. + +#### 📸 Key Screenshots +- [ ] **Banner visible** — chat view showing the rate limit warning banner. + +--- + +## 2. Banner Content + +### TC-002: Banner contains "Get more info" link and Dismiss button + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The rate limit warning banner is currently visible in the chat view. + +#### Steps +1. Locate the **"Get more info"** link in the banner. +2. Click the link. +3. Locate the **"×"** (Dismiss) button in the banner. + +#### Expected Result +- The "Get more info" link opens `https://aka.ms/github-copilot-rate-limit-error` + in the system default browser (or Eclipse's internal browser). +- The "×" button is visible and interactive. + +#### 📸 Key Screenshots +- [ ] **Banner with link and dismiss button** — close-up of the banner widget. + +--- + +### TC-003: Dismiss button closes the banner + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- The rate limit warning banner is currently visible. + +#### Steps +1. Click the **"×"** (Dismiss) button on the banner. +2. Observe the chat view layout. + +#### Expected Result +- The banner is removed from the chat view immediately. +- The chat input area expands to fill the space previously occupied by the banner. +- No exceptions or layout glitches occur. + +#### 📸 Key Screenshots +- [ ] **After dismiss** — chat view with banner removed. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md new file mode 100644 index 00000000..fb6de18c --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/run-in-terminal/run-in-terminal.md @@ -0,0 +1,112 @@ +# Run In Terminal Tool + +## Overview +Verify that Agent mode invokes `run_in_terminal` with commands that preserve multi-line shell syntax and execute +build commands from the correct imported project root. These cases guard against command flattening and wrong +working-directory selection in terminal tool calls. + +--- + +## Test Cases + +### TC-001: Multi-line PowerShell command executes correctly + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Eclipse IDE is open with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot. +- Copilot Chat is open in Agent mode. +- The terminal shell used by `run_in_terminal` is PowerShell. +- Terminal tool confirmation is enabled, or the tester can inspect the generated command before allowing execution. + +#### Steps +1. Start a new Copilot Chat conversation in Agent mode. +2. Send a prompt that asks the agent to run this exact multi-line PowerShell command in the terminal and report the + output: + ```powershell + 1..3 | ForEach-Object { + Write-Output "item=$_" + } + ``` +3. When the `run_in_terminal` confirmation appears, verify that the displayed command keeps the pipeline and block + body as a multi-line command, including the opening `{`, the `Write-Output` line, and the closing `}`. +4. Allow the command to run. +5. Wait for the terminal tool call to complete and inspect the terminal output returned to the agent. + +#### Expected Result +- The command executes successfully without PowerShell parser errors caused by dropped newlines, missing braces, or + truncated block content. +- The output contains exactly these item lines, in order: + ```text + item=1 + item=2 + item=3 + ``` +- The agent response reports the same three output lines and does not claim the command failed. + +#### 📸 Key Screenshots +- [ ] **Multi-line command confirmation** — `run_in_terminal` confirmation showing the full multi-line PowerShell + command. +- [ ] **Multi-line command output** — Terminal or agent output showing `item=1`, `item=2`, and `item=3`. + +--- + +### TC-002: Maven verify command runs from imported project root + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- Eclipse IDE is open with the GitHub Copilot for Eclipse plugin installed and activated. +- The user is signed in to GitHub Copilot. +- Copilot Chat is open in Agent mode. +- A Maven project is imported into the Eclipse workspace and visible in Project Explorer. +- The imported project root contains `pom.xml` and either a Maven wrapper (`mvnw` / `mvnw.cmd`) or uses a system + `mvn` installation. +- Terminal tool confirmation is enabled, or the tester can inspect the generated command before allowing execution. + +#### Steps +1. Import a Maven project into the Eclipse workspace if one is not already present. +2. Start a new Copilot Chat conversation in Agent mode. +3. Ask the agent to run Maven `verify` for the imported project. +4. When the `run_in_terminal` confirmation appears, inspect the generated command before allowing it. +5. Allow the command to run after confirming the tool will execute from the imported Maven project root. Valid evidence + includes either an explicit `cd "<imported-maven-project-root>"` in the command or a terminal prompt/working directory + that already points at the imported project root. +6. Wait for the terminal tool call to complete or reach the normal build result for that project. + +#### Expected Result +- The terminal executes Maven `verify` from the imported Maven project root path. +- The execution directory is the project directory that contains the imported project's `pom.xml`, not the Eclipse + workspace root, a parent folder, or an unrelated project. +- The tool satisfies this either by starting the terminal with the imported project root as its working directory or by + using an equivalent explicit directory change before running `verify`, such as: + ```powershell + cd "<imported-maven-project-root>" + .\mvnw.cmd verify + ``` + or: + ```powershell + cd "<imported-maven-project-root>" + mvn verify + ``` +- The terminal starts Maven from that project root, so Maven resolves the expected `pom.xml`. + +#### 📸 Key Screenshots +- [ ] **Imported Maven project** — Project Explorer showing the selected/imported Maven project root with `pom.xml`. +- [ ] **Verify execution location** — `run_in_terminal` confirmation or terminal prompt showing the command will execute + from the imported Maven project root. +- [ ] **Verify command output** — Terminal output showing Maven started from the selected project root. + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Multi-line command confirmation +- [ ] `TC-001` Multi-line command output +- [ ] `TC-002` Imported Maven project +- [ ] `TC-002` Verify execution location +- [ ] `TC-002` Verify command output diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/skills-prompt-files/skills-prompt-files.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/skills-prompt-files/skills-prompt-files.md new file mode 100644 index 00000000..b88cb673 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/skills-prompt-files/skills-prompt-files.md @@ -0,0 +1,234 @@ +# Support Skills and Prompt Files + +## Overview +This feature adds support for agent skills (`SKILL.md`) and custom prompt files (`.prompt.md`) in Agent Mode. Skills and prompts are discovered from workspace projects and user-global directories, exposed as slash commands in the chat input, and controlled by an **Enable Skills** toggle in `Window → Preferences → Copilot → Chat`. + +--- + +## Test Cases + +### TC-001: Enable Skills toggle appears in Chat preferences + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Eclipse is running with the Copilot plugin installed +- A valid GitHub Copilot subscription is active (client-preview feature flag must be on) + +#### Steps +1. Open **Window → Preferences → Copilot → Chat** +2. Observe the preference page content + +#### Expected Result +- An **Enable Skills** checkbox is visible +- A note below it reads "Controls whether agent skills can be used to enrich chat context." +- The checkbox is checked by default (enabled) + +#### 📸 Key Screenshots +- [ ] **Preferences page** — Chat preferences showing the Enable Skills toggle (checked) + +--- + +### TC-002: Workspace-scoped skill appears as slash command in Agent mode + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Eclipse is running with a project open in the workspace +- Copilot chat is open in **Agent** mode +- **Enable Skills** is checked in preferences + +#### Steps +1. Create the directory `.github/skills/my-skill/` inside any open project +2. Create `SKILL.md` inside that directory with the following content: + ```markdown + --- + name: My Skill + description: A test skill for verification + --- + This skill provides test context. + ``` +3. In the Copilot chat input (Agent mode), type `/` and observe the slash command popup + +#### Expected Result +- `my-skill` appears in the slash command list with description "A test skill for verification" +- The display name shown is `My Skill` (from `shortDescription`/name front matter) + +#### 📸 Key Screenshots +- [ ] **Slash command popup** — `/my-skill` visible in the Agent mode autocomplete list + +--- + +### TC-003: Prompt file appears as slash command in Agent mode + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Eclipse is running with a project open in the workspace +- Copilot chat is open in **Agent** mode +- **Enable Skills** is checked in preferences + +#### Steps +1. Create a `.github/` directory (or any supported location) inside the project +2. Create a file named `review.prompt.md` in the project with some markdown content +3. In the Copilot chat input (Agent mode), type `/` and observe the slash command popup + +#### Expected Result +- `review` (or the prompt file's ID) appears in the slash command list +- The template is listed alongside built-in slash commands + +#### 📸 Key Screenshots +- [ ] **Slash command popup** — Custom prompt file visible as slash command in Agent mode + +--- + +### TC-004: Skills do NOT appear in Ask mode + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A workspace-scoped `SKILL.md` exists (as created in TC-002) +- **Enable Skills** is checked in preferences + +#### Steps +1. Switch the Copilot chat to **Ask** mode +2. In the chat input, type `/` and observe the slash command popup + +#### Expected Result +- The skill (`my-skill`) does **not** appear in the Ask mode slash command list +- Only built-in `chat-panel`-scoped commands are shown + +#### 📸 Key Screenshots +- [ ] **Ask mode popup** — Skill absent from the slash command list in Ask mode + +--- + +### TC-005: Disabling Skills hides skills from slash command list + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- A workspace-scoped `SKILL.md` exists and was visible in Agent mode (TC-002 completed) + +#### Steps +1. Open **Window → Preferences → Copilot → Chat** +2. Uncheck **Enable Skills** and click **Apply and Close** +3. In the Copilot chat (Agent mode), type `/` and observe the slash command popup + +#### Expected Result +- The skill is no longer listed in the slash command popup +- Built-in slash commands (e.g. `/fix`, `/explain`) still appear if scoped for Agent mode + +#### 📸 Key Screenshots +- [ ] **Preferences page** — Enable Skills unchecked +- [ ] **Slash command popup** — Skill absent after disabling + +--- + +### TC-006: Auto-refresh when SKILL.md is added or removed + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Eclipse is running with a project open +- Copilot chat is open in Agent mode +- **Enable Skills** is checked + +#### Steps +1. In the chat input, type `/` — note that the skill does not yet exist +2. In Eclipse's Project Explorer, create `.github/skills/refresh-skill/SKILL.md` with a description +3. Without restarting, return to the chat input and type `/` again +4. Then delete the `SKILL.md` file and type `/` once more + +#### Expected Result +- After adding: `refresh-skill` appears in the slash command list without restarting Eclipse +- After deleting: `refresh-skill` disappears from the slash command list without restarting Eclipse + +#### 📸 Key Screenshots +- [ ] **Before add** — Slash command popup without the skill +- [ ] **After add** — Slash command popup with the new skill +- [ ] **After delete** — Slash command popup with skill removed + +--- + +### TC-007: User-global skill discovery + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Copilot chat is open in Agent mode +- **Enable Skills** is checked + +#### Steps +1. Create `~/.copilot/skills/global-skill/SKILL.md` with a name and description in YAML front matter +2. Restart Eclipse (or wait for auto-refresh) +3. In the Copilot chat input (Agent mode), type `/` + +#### Expected Result +- `global-skill` appears in the slash command list, available regardless of which workspace is open + +#### 📸 Key Screenshots +- [ ] **Slash command popup** — User-global skill visible in Agent mode + +--- + +### TC-008: Skill prefix filtering in slash command autocomplete + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- At least two skills are available in Agent mode (e.g., `my-skill` and `other-skill`) + +#### Steps +1. In the Copilot chat input (Agent mode), type `/my` +2. Observe the filtered slash command popup + +#### Expected Result +- Only commands matching "my" are shown (e.g., `my-skill` visible, `other-skill` absent) + +#### 📸 Key Screenshots +- [ ] **Filtered popup** — Autocomplete showing only matching skills for typed prefix + +--- + +### TC-009: Skills settings persist across Eclipse restarts + +**Type:** `Regression` +**Priority:** `P1` + +#### Preconditions +- **Enable Skills** preference is visible + +#### Steps +1. Open **Window → Preferences → Copilot → Chat** and uncheck **Enable Skills** +2. Click **Apply and Close** +3. Restart Eclipse +4. Open **Window → Preferences → Copilot → Chat** + +#### Expected Result +- **Enable Skills** remains unchecked after restart, confirming the preference is persisted + +--- + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Chat preferences showing the Enable Skills toggle (checked) +- [ ] `TC-002` Slash command popup with `/my-skill` visible in Agent mode +- [ ] `TC-003` Custom prompt file visible as slash command in Agent mode +- [ ] `TC-004` Ask mode popup without skill in slash command list +- [ ] `TC-005` Enable Skills unchecked in preferences +- [ ] `TC-005` Slash command popup without skill after disabling +- [ ] `TC-006` Slash command popup before skill is added +- [ ] `TC-006` Slash command popup after skill is added +- [ ] `TC-006` Slash command popup after skill is deleted +- [ ] `TC-007` User-global skill visible in Agent mode slash command popup +- [ ] `TC-008` Filtered autocomplete showing only matching skills for typed prefix diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md new file mode 100644 index 00000000..49b667ab --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md @@ -0,0 +1,269 @@ +# Subagent: Execution, Persistence, and Session Switching + +## Overview +Tests that subagent execution (`run_subagent` tool call) is correctly +displayed during live execution, persisted to conversation history, and +properly restored when switching between conversations via chat history. + +The observable signals include the `SubagentMessageBlock` card (border + +agent name header), individual tool-call status labels inside the card, +and the main agent's summary text appearing after the subagent block. +A failure in persistence, progress-event routing, or restoration logic +breaks these signals. + +Entry points exercised: +- Agent mode with a prompt that triggers `run_subagent`. +- Chat history panel: switching to a different conversation and back. +- Cancel (stop) button during subagent execution. +- Cancel button on a subagent tool-confirmation dialog. +- `conversation/destroy` LSP call on session switch. + +Not exercised: +- Subagent tool-call confirmation dialogs. +- Subagent error recovery / retry. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- **A signed-in Copilot account.** +- Network access to `api.githubcopilot.com`. +- Agent mode selected in the chat mode picker. +- At least one custom agent available (e.g., the built-in CVE Remediator + or a workspace `.github/agents/*.md` agent). + +--- + +## 1. Live subagent execution + +### TC-001: Subagent executes and renders nested card + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Chat view is open in Agent mode. +- No previous conversation is active (fresh session). + +#### Steps +1. Type a prompt that triggers subagent execution (e.g., `invoke a subagent` + or `scan for CVEs`). +2. Click **Send**. +3. Wait for the main agent to invoke `run_subagent` — a bordered + `SubagentMessageBlock` card should appear with the agent name header + (e.g., "CVE Remediator: Scan dependencies for CVEs"). +4. Wait for the subagent to finish executing — tool-call status labels + (checkmark icons) should appear inside the card. +5. Wait for the main agent to produce its summary response after the + subagent block. +6. The send button returns to the send state. + +#### Expected Result +- A `SubagentMessageBlock` card renders with agent name and description. +- Subagent tool calls (e.g., "Searched for files matching query", + "Ran MCP tool") appear inside the card with status icons. +- The main agent's summary text appears below the subagent block. +- No orphaned tool-call labels outside the card. + +#### Key Screenshots +- [ ] **Subagent running** — card header visible, tool calls streaming in. +- [ ] **Subagent completed** — all tool calls show checkmark, main agent + summary visible below. + +--- + +## 2. Persistence and restoration + +### TC-002: Subagent content restored after session switch (completed) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-001 completed successfully (subagent fully finished). + +#### Steps +1. Open chat history (clock icon in top banner). +2. Select a different conversation or click "New Chat". +3. Open chat history again. +4. Select the conversation from TC-001. + +#### Expected Result +- The subagent card header is visible, positioned between the + `run_subagent` tool-call status and the main agent's summary. +- Subagent tool calls are displayed inside the card (not as flat + labels under the main turn). +- The main agent's summary text appears after the subagent section. + +#### Key Screenshots +- [ ] **Restored conversation** — subagent card with tool calls visible + inside, main agent summary below. + +--- + +### TC-003: Subagent content restored after cancel mid-execution + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers subagent execution. +2. While the subagent is running (tool calls streaming), click **Cancel** + (stop button). +3. Open chat history, switch to another conversation, then switch back. + +#### Expected Result +- The subagent card header is visible with partial content. +- Tool calls executed before cancel are displayed inside the card. +- The send button is in the send state. + +#### Key Screenshots +- [ ] **After cancel** — partial subagent card visible, send button reset. +- [ ] **After restore** — same partial content restored correctly. + +--- + +## 3. Progress event isolation + +### TC-004: Subagent progress does not leak when switching mid-execution + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers subagent execution. +2. While the subagent is still running, open chat history and switch to + a different conversation. +3. Observe the newly opened conversation. + +#### Expected Result +- The newly opened conversation does not show any subagent output + (tool calls, card headers, or text) from the previous conversation. +- The send button is in the send state (not stuck on cancel). +- No error messages or orphaned widgets appear. +- `workspace.log` should show `conversation/destroy` being sent for + the previous conversation. + +#### Key Screenshots +- [ ] **Switched conversation** — clean UI, no leaked subagent output. + +#### Notes on failure modes +- Subagent output appearing in the wrong conversation → + `isProgressForCurrentConversation()` check failed; verify + `subagentConversationId` is being set on first subagent event and + cleared on cancel/switch. +- Send button stuck on cancel → `onCancel()` did not call + `actionBar.resetSendButton()`. + +--- + +## 4. Multiple subagents in one turn + +### TC-005: Two subagents in a single turn restore correctly + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a prompt that triggers multiple subagent invocations + (e.g., `invoke two different subagents` or a prompt where the agent + decides to call `run_subagent` twice). +2. Wait for both subagents to complete. +3. Switch to another conversation and back. + +#### Expected Result +- Each subagent has its own card header with the correct agent name. +- Tool calls for each subagent appear under their respective cards. +- After restoration, both subagent cards are correctly positioned and + filled with their respective tool calls. +- The main agent's summary (if any) appears after the last subagent block. + +#### Key Screenshots +- [ ] **Live execution** — two distinct subagent cards visible. +- [ ] **After restore** — both cards restored with correct content. + +#### Notes on failure modes +- Content from subagent A appearing in subagent B's card → + `subagentToolCallId` association is incorrect; check that + `lastRunSubagentToolCallId` is updated for each `run_subagent` + tool call. + +--- + +## 5. Non-subagent conversation unaffected + +### TC-006: Conversation with no subagent restores cleanly + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- A new conversation in Agent mode. + +#### Steps +1. Send a message that produces a normal (non-subagent) agent response. +2. Wait for the response to complete. +3. Switch to another conversation via chat history, then switch back. + +#### Expected Result +- The restored conversation looks identical to before the session switch. +- No duplicate or extra assistant messages appear. +- No orphaned subagent card headers or tool-call labels are shown. + +#### Key Screenshots +- [ ] **After restore** — conversation unchanged, no extra messages. + +--- + +## 6. Tool-confirmation dialog cancel layout + +### TC-007: Subagent panel collapses after confirmation dialog is cancelled + +**Type:** `Regression` +**Priority:** `P1` + +#### Preconditions +- The Chat view is open in Agent mode. +- A new conversation (fresh session). +- The selected agent triggers a tool that requires confirmation + (e.g., a command that needs to run in the terminal). + +#### Steps +1. Send a prompt that triggers subagent execution where the subagent + wants to run a command (e.g., `scan for CVEs`, or + `run ls in the terminal`). +2. Wait for the subagent's tool **confirmation dialog** to appear inside + the subagent card (Allow / Skip buttons). +3. Click **Skip** on the confirmation dialog. +4. Observe the subagent card area after the dialog disposes. + +#### Expected Result +- The subagent panel resizes/collapses to fit its remaining content. +- **No empty/blank gap** is left at the bottom of the subagent area where + the confirmation dialog used to be (the bug in #169). +- A skip label/message is shown where appropriate, and the scroller + minimum height is recomputed in a single layout pass. +- The send button returns to the send state. + +#### Key Screenshots +- [ ] **Confirmation dialog visible** — Allow / Skip buttons shown + inside the subagent card. +- [ ] **After skip** — subagent panel collapsed with no empty area below. + +#### Notes on failure modes +- Empty area left below the subagent card → `cancelConfirmation()` did not + trigger `requestRefreshScrollerLayout()`; the `ScrolledComposite` + `setMinHeight()` was never recomputed after the dialog disposal. +- Verify the Allow path is unaffected: clicking **Allow** on a + separate invocation should still lay out correctly (no regression). diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md new file mode 100644 index 00000000..180783a4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/terminal-auto-approve/terminal-auto-approve.md @@ -0,0 +1,551 @@ +# Terminal Auto Approve + +## Overview +Tests the terminal command auto-approve feature end-to-end: configuring rules +in the preference page, then triggering Agent Mode tool calls and observing +whether the confirmation dialog appears or the command runs automatically. + +Each test case exercises the full stack: preference store → CLS sync → +Agent Mode prompt → tool confirmation request → `ConfirmationService` → +`TerminalConfirmationHandler` → dialog (or auto-approve) → terminal +execution. This mirrors the real user workflow: tweak settings, chat with +Copilot, observe behavior. + +Entry points exercised: +- **Preferences → GitHub Copilot → Tool Auto Approve** — the terminal + rule table (add / remove / toggle / reset). +- **Agent Mode chat** — sending prompts that trigger `run_in_terminal` + tool calls. +- **Confirmation dialog** — the split-dropdown button with session/global + allow actions. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and + activated. +- A signed-in Copilot account on the host machine. +- Network access to `api.githubcopilot.com`. +- **Agent Mode** selected in the chat mode dropdown. +- No previous auto-approve rules beyond the defaults (reset via + "Reset to Defaults" before each scenario). + +--- + +## 1. Default deny rules block dangerous commands + +### TC-001: Default deny rules + Agent Mode terminal call + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Default rules are active (not modified). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Verify the "Terminal Auto Approve" section is visible with a table + showing default deny rules (rm, rmdir, del, kill, curl, wget, eval, + chmod, chown, and the regex rules for subshells / backticks / braces). +3. Confirm "Auto approve commands not covered by rules" is unchecked. +4. Close preferences. +5. Open the **Copilot Chat** view and select **Agent** mode. +6. Wait for the model picker to resolve. +7. Type the prompt: `please run curl https://example.com`. +8. Wait for a Copilot turn to stream — the agent should invoke the + `run_in_terminal` tool with a `curl` command. +9. Observe the confirmation dialog that appears in the chat panel. +10. Verify the dialog shows: + - Bold title: **"Run command in terminal"** + - Message: *"The tool is about to run the following command in the + terminal."* + - The command text in a scrollable panel with `bg-command-panel` + background. + - A blue **"Allow Once ▾"** split-dropdown button and a **"Skip"** + button. +11. Click the dropdown arrow on "Allow Once ▾". +12. Verify the dropdown menu contains: + - "Allow 'curl' in this Session" + - "Always Allow 'curl'" + - "Allow this exact command in this Session" (if the full command + differs from "curl") + - "Always Allow this exact command" + - "Allow all commands in this Session" +13. Click **"Skip"**. +14. Verify the command was NOT executed — the agent receives a dismiss + result and continues without terminal output. + +#### Expected Result +- Default deny rule for `curl` causes the confirmation dialog to appear. +- The dialog renders correctly with split-dropdown actions. +- Skipping prevents execution. + +#### 📸 Key Screenshots +- [ ] Preference page with default rules visible. +- [ ] Confirmation dialog with dropdown expanded showing all actions. +- [ ] Agent turn after skip — no terminal output. + +--- + +## 2. Custom allow rule auto-approves matching commands + +### TC-002: Add allow rule → Agent auto-approves → verify execution + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open **Preferences → GitHub Copilot → Tool Auto Approve**. +2. Click **"Add..."** in the Terminal Auto Approve section. +3. Enter `systeminfo` as command, select **"Allow"**, click OK. +4. Verify `systeminfo` appears in the table with "Allow" status. +5. Click **"Apply and Close"**. +6. In Agent Mode chat, type: `run systeminfo to show my computer info`. +7. Wait for the agent to invoke `run_in_terminal`. +8. Observe that **no confirmation dialog appears** — the command runs + directly. +9. Wait for the tool call to complete — the agent should report terminal + output containing system information. +10. Open preferences again and verify the rule is still present. + +#### Expected Result +- The custom allow rule causes the `systeminfo` command to auto-approve. +- No confirmation dialog is shown. +- The terminal runs the command and the agent receives output. + +#### 📸 Key Screenshots +- [ ] Preference page with `systeminfo` Allow rule added. +- [ ] Agent turn showing "✔ Ran run_in_terminal tool" without a + confirmation dialog in between. +- [ ] Agent response containing system information output. + +--- + +## 3. Session "Allow command name" persists within conversation + +### TC-003: Allow name in Session → same command auto-approves → new +conversation resets + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `echo` exist (only defaults). +2. In Agent Mode, type: `run echo hello world`. +3. Confirmation dialog appears (echo has no matching rule and unmatched + is disabled). +4. Click the dropdown arrow and select **"Allow 'echo' in this Session"**. +5. The command executes. +6. In the **same conversation**, type: `run echo second message`. +7. Observe that **no confirmation dialog appears** — the command + auto-approves because `echo` is session-approved. +8. Verify the agent shows terminal output for both commands. +9. Start a **new conversation** (click "New Chat" or equivalent). +10. Type: `run echo third message`. +11. Observe that the **confirmation dialog appears again** — session + approvals do not carry over to new conversations. + +#### Expected Result +- First invocation: dialog shown, user selects session allow. +- Second invocation (same conversation): auto-approved. +- Third invocation (new conversation): dialog shown again. + +#### 📸 Key Screenshots +- [ ] First dialog with dropdown showing "Allow 'echo' in this Session". +- [ ] Second invocation auto-approved — no dialog. +- [ ] New conversation — dialog reappears. + +--- + +## 4. "Always Allow" persists globally + +### TC-004: Always Allow command name → persists across conversations +and appears in preferences + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Ensure no custom rules for `dir` exist. +2. In Agent Mode, type: `list files in current directory`. +3. Confirmation dialog appears for the `dir` (or `ls`) command. +4. Click the dropdown and select **"Always Allow 'dir'"**. +5. The command executes. +6. Open **Preferences → Tool Auto Approve**. +7. Verify `dir` appears in the rules table with **"Allow"** status. +8. Close preferences. +9. Start a **new conversation**. +10. Type: `show me what files are here` (triggers `dir` again). +11. Observe that **no confirmation dialog appears** — the global rule + persists. + +#### Expected Result +- The "Always Allow" action writes the command name to the preference + store. +- The rule appears in the preference page UI. +- The rule survives across conversations. + +#### 📸 Key Screenshots +- [ ] Dropdown selection: "Always Allow 'dir'". +- [ ] Preference page showing `dir` as Allow rule. +- [ ] New conversation: `dir` auto-approved. + +--- + +## 5. Exact command vs. command name distinction + +### TC-005: Exact command Session approval only matches the same +command line + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, type: `run echo hello world`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow this exact command in this + Session"**. +4. The command executes. +5. In the same conversation, type: `run echo hello world` again. +6. Observe **auto-approved** — exact same command line. +7. In the same conversation, type: `run echo different text`. +8. Observe **confirmation dialog appears** — different command line, + even though command name `echo` is the same. + +#### Expected Result +- Exact command approval is strict: only the identical command line + is auto-approved. +- A different argument string requires separate confirmation. + +#### 📸 Key Screenshots +- [ ] First dialog: selecting "Allow this exact command in this Session". +- [ ] Same command: auto-approved. +- [ ] Different arguments: dialog appears again. + +--- + +## 6. Simple command hides redundant exact-command actions + +### TC-006: Single-word command shows minimal dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a single-word command like `ipconfig` + (or `hostname`). +2. Confirmation dialog appears. +3. Click the dropdown arrow. +4. Count the menu items. + +#### Expected Result +- "Allow 'ipconfig' in this Session" — present. +- "Always Allow 'ipconfig'" — present. +- "Allow this exact command in this Session" — **NOT present** + (redundant: exact command = command name for single-word commands). +- "Always Allow this exact command" — **NOT present**. +- "Allow all commands in this Session" — present. + +--- + +## 7. Unmatched auto-approve toggle + +### TC-007: Enable unmatched auto-approve → unknown commands pass +through → disable → they require confirmation again + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Check **"Auto approve commands not covered by rules"**. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `run hostname`. +5. Observe **auto-approved** — `hostname` has no matching rule but + unmatched auto-approve is enabled. +6. Open preferences again. +7. **Uncheck** "Auto approve commands not covered by rules". +8. Click **"Apply and Close"**. +9. In the same or new conversation, type: `run hostname`. +10. Observe **confirmation dialog appears**. + +#### Expected Result +- With unmatched enabled: unknown commands auto-approve. +- With unmatched disabled: unknown commands require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference: "Auto approve commands not covered by rules" checked. +- [ ] `hostname` auto-approved. +- [ ] Preference: unchecked. +- [ ] `hostname` shows confirmation dialog. + +--- + +## 8. Regex rule integration + +### TC-008: Add a case-insensitive regex allow rule → verify it +matches in Agent Mode + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `/^npm\b/i`, select **"Allow"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type: `install lodash using npm`. +5. Agent invokes `npm install lodash`. +6. Observe **auto-approved** — regex `/^npm\b/i` matches. +7. Type: `run NPM run build` (uppercase). +8. Observe **auto-approved** — case-insensitive flag `/i` matches. + +#### Expected Result +- The regex allow rule auto-approves matching commands. +- Case-insensitive flag works correctly. + +--- + +## 9. "Allow all commands in this Session" — blanket approval + +### TC-009: Allow all → every subsequent terminal call auto-approves +in this conversation + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow all commands in this Session"**. +4. The command executes. +5. In the same conversation, send multiple prompts that trigger + different terminal commands (e.g., "run dir", "run echo test", + "run ipconfig"). +6. Observe that **none of them** show a confirmation dialog. +7. Start a **new conversation**. +8. Trigger any terminal command. +9. Observe that the **confirmation dialog appears again**. + +#### Expected Result +- "Allow all" is a blanket session-scoped approval. +- Every terminal command in the same conversation auto-approves. +- New conversations start fresh. + +--- + +## 10. Reset to Defaults clears custom rules + +### TC-010: Add custom rules → Reset → verify Agent Mode behavior +reverts + +**Type:** `Happy Path` +**Priority:** `P2` + +#### Steps +1. Open preferences and add `echo` as Allow, `javac` as Allow. +2. Apply and close. +3. Verify in Agent Mode: `echo hello` auto-approves. +4. Open preferences again. +5. Click **"Reset to Defaults"** and confirm. +6. Verify only default deny rules remain — `echo` and `javac` are gone. +7. Apply and close. +8. In Agent Mode, trigger `echo hello`. +9. Observe **confirmation dialog appears** — the custom allow rule was + removed. + +#### Expected Result +- Reset removes all custom rules. +- Commands that were previously allowed now require confirmation. + +#### 📸 Key Screenshots +- [ ] Preference page after adding custom rules. +- [ ] Preference page after reset — only defaults. +- [ ] `echo hello` shows confirmation dialog post-reset. + +--- + +## 11. Already-approved names filtered from dropdown + +### TC-011: Session-approved command name hidden from dropdown +actions in multi-command scenario + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. In Agent Mode, trigger a command that includes `echo` + (e.g., "run echo hello"). +2. Confirmation dialog appears. +3. Select **"Allow 'echo' in this Session"** from the dropdown. +4. The command executes. +5. In the same conversation, send a prompt that triggers a + multi-command like `echo test && curl https://example.com`. +6. Confirmation dialog appears (because `curl` is a default deny + rule). +7. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'curl' in this Session"** and + **"Always Allow 'curl'"**. +- `echo` does **NOT** appear in the command-name actions — it was + already session-approved. +- "Allow all commands in this Session" is still shown. +- Exact command actions (if shown) refer to the full multi-command + string, not individual parts. + +#### 📸 Key Screenshots +- [ ] First dialog: approving `echo` in session. +- [ ] Second dialog: dropdown showing only `curl`, not `echo`. + +--- + +### TC-012: Global-approved command name hidden from dropdown + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Add `echo` as **Allow** rule. Apply and close. +3. In Agent Mode, trigger `echo hello && hostname`. +4. Confirmation dialog appears (because `hostname` has no matching + rule and unmatched is disabled). +5. Click the dropdown arrow. + +#### Expected Result +- The dropdown shows **"Allow 'hostname' in this Session"** and + **"Always Allow 'hostname'"**. +- `echo` does **NOT** appear — it is globally allowed. +- "Allow all commands in this Session" is still shown. + +--- + +## 13. Subagent inherits parent session approvals + +### TC-013: Session approval in main agent applies to subagent calls + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `echo` or `cat` (only defaults). +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a prompt that triggers `echo hello`. +2. Confirmation dialog appears. +3. Click dropdown and select **"Allow 'echo' in this Session"**. +4. The command executes. +5. In the **same conversation**, send a complex prompt that causes the + agent to spawn a **subagent** (e.g., `use a subagent to run echo + from subagent`). +6. The subagent invokes `run_in_terminal` with an `echo` command. +7. Observe that **no confirmation dialog appears** — the session + approval from the main agent carries over to the subagent. +8. Still in the subagent context, the subagent invokes a different + command (e.g., `cat somefile.txt`). +9. Observe that a **confirmation dialog appears** — `cat` was not + session-approved. + +#### Expected Result +- Subagent tool calls use the parent conversation's session rules. +- Commands approved in the main agent conversation auto-approve in + subagent context. +- Commands NOT approved still require confirmation in subagent context. + +#### 📸 Key Screenshots +- [ ] Main agent: approving `echo` in session. +- [ ] Subagent: `echo` auto-approved — no dialog. +- [ ] Subagent: `cat` shows confirmation dialog. + +--- + +### TC-014: Session approval in subagent carries back to main agent + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- No custom allow rules for `hostname`. +- "Auto approve commands not covered by rules" is **unchecked**. + +#### Steps +1. In Agent Mode, send a complex prompt that triggers a **subagent**. +2. The subagent invokes `run_in_terminal` with `hostname`. +3. Confirmation dialog appears. +4. Click dropdown and select **"Allow 'hostname' in this Session"**. +5. The command executes in the subagent context. +6. After the subagent completes, continue in the **same conversation** + with the main agent. +7. Send a prompt that triggers `hostname` again (e.g., `what is my + hostname?`). +8. Observe that **no confirmation dialog appears** — the session + approval made during the subagent call is shared with the main + conversation. + +#### Expected Result +- Session approvals made during subagent execution are stored under + the parent conversation's session scope. +- The main agent benefits from approvals granted in subagent context. + +#### 📸 Key Screenshots +- [ ] Subagent: approving `hostname` in session. +- [ ] Main agent: `hostname` auto-approved — no dialog. + +--- + +### TC-015: "Allow all commands in this Session" in main agent covers subagent + +**Type:** `Edge Case` +**Priority:** `P2` + +#### Steps +1. In Agent Mode, trigger any terminal command. +2. Confirmation dialog appears. +3. Select **"Allow all commands in this Session"** from the dropdown. +4. In the **same conversation**, send a prompt that spawns a subagent + which runs multiple different terminal commands. +5. Observe that **none of them** show a confirmation dialog — the + blanket session approval covers subagent calls too. + +#### Expected Result +- "Allow all commands in this Session" is a blanket approval that + applies to both main agent and subagent tool calls within the + same conversation. + +--- + +## 14. Edge Cases + +### TC-016: "Always Allow" overrides existing deny rule in preferences + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Steps +1. Open **Preferences → Tool Auto Approve**. +2. Click **"Add..."**, enter `curl`, select **"Deny"**, click OK. +3. Click **"Apply and Close"**. +4. In Agent Mode, type a prompt that triggers `curl` (e.g., `fetch + https://example.com using curl`). +5. Confirmation dialog appears (deny rule blocks it). +6. Click dropdown and select **"Always Allow curl"**. +7. The command executes. +8. Open **Preferences → Tool Auto Approve** again. +9. Verify the `curl` rule has been changed from **Deny → Allow**. +10. Start a **new conversation**, trigger `curl` again. +11. Observe **auto-approved** — the global allow rule now applies. + +#### Expected Result +- Clicking "Always Allow" in the dialog overrides an existing deny + rule in preferences, changing it from deny to allow. +- The updated rule persists across conversations. +- The preferences table reflects the updated rule. + +#### 📸 Key Screenshots +- [ ] Preferences: `curl → Deny` rule before override. +- [ ] Confirmation dialog: showing "Always Allow curl" action. +- [ ] Preferences: `curl → Allow` rule after override. +- [ ] New conversation: `curl` auto-approved without dialog. diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md new file mode 100644 index 00000000..3eacc8d1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-effort/thinking-effort.md @@ -0,0 +1,126 @@ +# Thinking Effort Selection + +## Overview + +Tests the **Thinking Effort** feature requested in +[issue #204](https://github.com/microsoft/copilot-for-eclipse/issues/204). + +Some models (e.g. Claude Sonnet) support multiple reasoning effort levels — +`low`, `medium`, `high`. The language server advertises available levels via +`capabilities.supports.reasoningEfforts` on each model. The plugin should allow +the user to select an effort level per model from the model picker, persist the +choice across sessions, reflect it in the model picker suffix, and pass it to the language server with each +conversation turn. + +Entry points: +- **Chat view → model picker** → hover card for a reasoning-capable model. + +--- + +## Prerequisites + +- Eclipse IDE with the GitHub Copilot for Eclipse plugin installed and activated. +- A GitHub account signed in with an active Copilot subscription. +- At least one **reasoning-capable** model (e.g. Claude Sonnet) and one + **non-reasoning** model (e.g. GPT-4o) available in the model picker. +- The reasoning-capable model advertises at least two effort levels + (`low`, `medium`, `high`). + +--- + +## TC-001: Model picker UI and effort selection end-to-end + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- No effort has been explicitly set for the reasoning model (fresh state or + reset). + +#### Steps +1. Open the Copilot Chat view and click the model picker to open the dropdown. +2. Locate a **reasoning-capable model** (e.g. Claude Sonnet). Read its suffix. +3. Hover over the model to open its hover card. Inspect the effort section. +4. Click **`High`** in the effort section. +5. Close the model picker and reopen it. Read the suffix again. +6. Hover over the same model again and check which effort is marked. + +#### Expected Result +- In step 2, the suffix contains the default effort level — **`Medium`** if + supported, otherwise the first advertised level. +- In step 3, the hover card shows a **"Thinking effort"** section listing all + available levels; the current default is marked. +- After clicking `High` (step 4), the suffix updates to show **`High`** and + the checkmark moves to `High` in the hover card. +- A **non-reasoning model** hovered in the same session shows no effort section. + +#### 📸 Key Screenshots +- [ ] **Model picker list** — Reasoning model with default effort in suffix. +- [ ] **Hover card** — Effort section with `High` selected and checkmark. +- [ ] **Model picker list** — Updated suffix after selecting `High`. + +--- + +## TC-002: Effort selection is persisted per model across sessions + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Model A (reasoning) effort set to `high`, Model B (reasoning) effort set to + `low` (from TC-001 or set manually). + +#### Steps +1. Restart Eclipse. +2. Open the model picker and check the suffixes and hover cards of Model A and + Model B. + +#### Expected Result +- Model A still shows **`High`**; Model B still shows **`Low`**. +- Each model's effort is independent — changing one did not affect the other. + +--- + +## TC-003: Thinking effort descriptions are fully readable in the hover card + +**Type:** `Regression` +**Priority:** `P1` + +Regression guard for +[issue #233](https://github.com/microsoft/copilot-for-eclipse/issues/233) / +[PR #234](https://github.com/microsoft/copilot-for-eclipse/pull/234). The hover +card previously clamped its width to a fixed maximum (`LONG_POPUP_WIDTH = 300`), +which truncated the longer per-level description text in the **Thinking effort** +section. The fix removes that cap so the hover shell uses its natural packed +width (only enforcing a `250` px minimum), letting every description wrap and +render in full. + +#### Preconditions +- A reasoning-capable model (e.g. Claude Sonnet 4.6) advertising multiple + effort levels, each with a descriptive sub-label (e.g. + `Low — Faster responses, less reasoning`, + `Medium — Balanced reasoning and speed`, + `High — Deeper reasoning, slower responses`). + +#### Steps +1. Open the Copilot Chat view and click the model picker to open the dropdown. +2. Hover over the reasoning-capable model to open its hover card. +3. Locate the **Thinking effort** section and read each effort level's + description line in full. +4. Visually compare the right edge of the longest description against the hover card border. + +#### Expected Result +- The hover card is wide enough to display the full description for every effort + level — no text is clipped, cut off, or replaced with an ellipsis at the right + edge. +- Each description ends with its natural last word (or wraps onto a second line) + rather than being truncated mid-word against the card border. +- The hover card width is never narrower than the `250` px minimum, so short + content still renders cleanly. + +#### 📸 Key Screenshots +- [ ] **Hover card — full descriptions** — Thinking effort section showing each + level's complete, untruncated description text. +- [ ] **Right-edge close-up** — the longest description's final word fully + visible inside the card border (no clipping/ellipsis). + diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-persistence/thinking-persistence.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-persistence/thinking-persistence.md new file mode 100644 index 00000000..d45db7b3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/thinking-persistence/thinking-persistence.md @@ -0,0 +1,141 @@ +# Thinking Block Persistence & Restoration + +## Overview +Thinking blocks (the collapsible "Thinking" banners shown during model reasoning) are persisted to conversation history so they survive session switches and history restoration. This covers content, state (completed/cancelled), and generated titles for both main agent and subagent thinking blocks. + +--- + +## Test Cases + +### TC-001: Completed thinking restores after session switch + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Copilot chat is open in Agent mode +- A model that supports thinking is selected (e.g. Claude Sonnet 4.6) + +#### Steps +1. Send a message that triggers thinking (e.g. "think about what improvements this file needs") +2. Wait for thinking to complete (spinner stops, title appears on the thinking block header) +3. Open chat history, select a different conversation (or "New Chat") +4. Open chat history again, select the original conversation + +#### Expected Result +The thinking block appears collapsed with its generated title, expandable to show full thinking content. + +#### 📸 Key Screenshots +- [ ] **Before switch** — Thinking block with title visible in completed state +- [ ] **After restore** — Same thinking block restored with title and expandable content + +--- + +### TC-002: Cancelled thinking restores after session switch + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Copilot chat is open in Agent mode +- A model that supports thinking is selected + +#### Steps +1. Send a message that triggers thinking +2. While thinking is streaming (spinner active), click Cancel +3. Verify the thinking block shows cancelled icon +4. Open chat history, select a different conversation +5. Open chat history again, select the original conversation + +#### Expected Result +The cancelled thinking block restores with cancelled state and partial content visible on expand. + +#### 📸 Key Screenshots +- [ ] **After cancel** — Thinking block with cancelled icon +- [ ] **After restore** — Same cancelled thinking block restored + +--- + +### TC-003: Multiple thinking blocks in one turn + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Copilot chat is open in Agent mode +- A model that supports thinking is selected + +#### Steps +1. Send a message that triggers multiple rounds of thinking interleaved with tool calls (e.g. a complex multi-step task) +2. Wait for the full response to complete +3. Verify multiple thinking blocks are shown, each with its own title +4. Open chat history, select a different conversation and switch back + +#### Expected Result +All thinking blocks restore in correct order, each with its own title and content, positioned before their corresponding tool calls. + +#### 📸 Key Screenshots +- [ ] **Before switch** — Multiple thinking blocks visible in the turn +- [ ] **After restore** — All thinking blocks restored in correct positions + +--- + +### TC-004: Subagent thinking restores after session switch + +**Type:** `Happy Path` +**Priority:** `P1` + +#### Preconditions +- Copilot chat is open in Agent mode +- A model that supports thinking and subagents is selected + +#### Steps +1. Send a message that triggers a subagent with thinking (e.g. "use the test agent to analyze this file step by step") +2. Wait for the subagent to complete with thinking +3. Verify the subagent's thinking block appears inside the subagent message block +4. Open chat history, select a different conversation and switch back + +#### Expected Result +The subagent's thinking block restores inside the subagent message block with title and content. + +#### 📸 Key Screenshots +- [ ] **Before switch** — Subagent thinking block visible inside subagent block +- [ ] **After restore** — Subagent thinking block restored correctly + +--- + +### TC-005: Cancelled subagent thinking persists + +**Type:** `Edge Case` +**Priority:** `P1` + +#### Preconditions +- Copilot chat is open in Agent mode +- A model that supports thinking and subagents is selected + +#### Steps +1. Send a message that triggers a subagent +2. While the subagent is thinking (spinner active inside subagent block), click Cancel +3. Verify the subagent thinking shows cancelled state +4. Open chat history, select a different conversation and switch back + +#### Expected Result +The cancelled subagent thinking block restores with cancelled state. + +#### 📸 Key Screenshots +- [ ] **After cancel** — Subagent thinking block with cancelled icon +- [ ] **After restore** — Cancelled subagent thinking block restored + +## Screenshots Checklist +> Consolidated list of all key screenshot moments. + +- [ ] `TC-001` Completed thinking before switch +- [ ] `TC-001` Completed thinking after restore +- [ ] `TC-002` Cancelled thinking after cancel +- [ ] `TC-002` Cancelled thinking after restore +- [ ] `TC-003` Multiple thinking blocks before switch +- [ ] `TC-003` Multiple thinking blocks after restore +- [ ] `TC-004` Subagent thinking before switch +- [ ] `TC-004` Subagent thinking after restore +- [ ] `TC-005` Cancelled subagent thinking after cancel +- [ ] `TC-005` Cancelled subagent thinking after restore diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/whats-new/whats-new.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/whats-new/whats-new.md index 21cd4251..f3e10c56 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/whats-new/whats-new.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/whats-new/whats-new.md @@ -55,10 +55,7 @@ Entry points: `# GitHub Copilot <version> Release Notes` heading). 6. Verify that this top-most section, and the first feature sub-heading beneath it, match the top of the bundled `intro/whatsnew/WHATISNEW.md` file shipped - with the installed plugin build. At the time of writing, the expected - latest entry is: - - Release heading: `GitHub Copilot 0.16.0 Release Notes` - - First feature: `Tool Calling in Ask Mode` + with the installed plugin build. #### Expected Result - Clicking **What's New** from the Copilot status-bar menu opens an editor tab diff --git a/com.microsoft.copilot.eclipse.terminal.api/.classpath b/com.microsoft.copilot.eclipse.terminal.api/.classpath index 81fe078c..8d861214 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/.classpath +++ b/com.microsoft.copilot.eclipse.terminal.api/.classpath @@ -3,5 +3,5 @@ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17"/> <classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/> <classpathentry kind="src" path="src"/> - <classpathentry kind="output" path="bin"/> + <classpathentry kind="output" path="target/classes"/> </classpath> diff --git a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF index 4e880b2c..75b5a994 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.terminal.api/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.terminal.api Bundle-SymbolicName: com.microsoft.copilot.eclipse.terminal.api;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Export-Package: com.microsoft.copilot.eclipse.terminal.api Automatic-Module-Name: com.microsoft.copilot.eclipse.terminal.api @@ -10,7 +10,8 @@ Bundle-ActivationPolicy: lazy Bundle-RequiredExecutionEnvironment: JavaSE-17 Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", - com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.jface, org.eclipse.swt, - org.eclipse.ui.workbench + org.eclipse.ui.workbench, + org.apache.commons.lang3;bundle-version="3.13.0" diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh new file mode 100644 index 00000000..b36076c7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-bash-integration.sh @@ -0,0 +1,45 @@ +# Copilot Shell Integration for Bash +# This script is loaded with bash --init-file when starting a terminal. + +__copilot_bash_integration_main() { + [ -n "${COPILOT_BASH_INTEGRATION:-}" ] && return + COPILOT_BASH_INTEGRATION=1 + + bind 'set enable-bracketed-paste on' 2>/dev/null || true + __copilot_prompt_initialized=0 + + __copilot_precmd() { + __copilot_status=$? + if [ "${__copilot_prompt_initialized:-0}" = "1" ]; then + printf '\033]7775;C;%s\007' "$__copilot_status" + else + __copilot_prompt_initialized=1 + fi + printf '\033]7775;A\007' + return "$__copilot_status" + } + + __copilot_prompt_end() { + printf '\033]7775;B\007' + } + + if [ -z "${__copilot_original_ps1:-}" ]; then + __copilot_original_ps1=${PS1:-'\$ '} + fi + + case "$(declare -p PROMPT_COMMAND 2>/dev/null)" in + declare\ -a*|declare\ -A*) + PROMPT_COMMAND=(__copilot_precmd "${PROMPT_COMMAND[@]}") + ;; + *) + if [ -n "${PROMPT_COMMAND:-}" ]; then + PROMPT_COMMAND="__copilot_precmd; ${PROMPT_COMMAND}" + else + PROMPT_COMMAND=__copilot_precmd + fi + ;; + esac + PS1="${__copilot_original_ps1}"'\[$(__copilot_prompt_end)\]' +} + +__copilot_bash_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 new file mode 100644 index 00000000..5cc0c77e --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-powershell-integration.ps1 @@ -0,0 +1,56 @@ +# Copilot Shell Integration for Windows PowerShell +# This script is loaded when starting a Copilot terminal. + +try { + $global:OutputEncoding = New-Object System.Text.UTF8Encoding $false + [Console]::InputEncoding = $global:OutputEncoding + [Console]::OutputEncoding = $global:OutputEncoding +} catch { + # Some hosts do not expose console encodings during startup. +} + +if (-not $global:COPILOT_SHELL_INTEGRATION) { + $global:COPILOT_SHELL_INTEGRATION = $true + $global:__copilot_original_prompt = (Get-Command prompt -CommandType Function).ScriptBlock + $global:__copilot_last_history_id = -1 + + function global:prompt { + $lastSuccess = $? + $lastExitCode = $LASTEXITCODE + $esc = [char]27 + $bel = [char]7 + $lastHistoryEntry = Get-History -Count 1 + $result = "" + + if ($global:__copilot_last_history_id -ne -1) { + if ($null -ne $lastHistoryEntry -and $lastHistoryEntry.Id -eq $global:__copilot_last_history_id) { + $result += "$esc]7775;C$bel" + } else { + if ($lastSuccess) { + $exitCode = 0 + } elseif ($null -ne $lastExitCode -and $lastExitCode -ne 0) { + $exitCode = $lastExitCode + } else { + $exitCode = 1 + } + $result += "$esc]7775;C;$exitCode$bel" + } + } + + $result += "$esc]7775;A$bel" + + if ($global:__copilot_original_prompt) { + $result += $global:__copilot_original_prompt.Invoke() + } else { + $result += "PS $($executionContext.SessionState.Path.CurrentLocation)> " + } + + $result += "$esc]7775;B$bel" + if ($null -ne $lastHistoryEntry) { + $global:__copilot_last_history_id = $lastHistoryEntry.Id + } else { + $global:__copilot_last_history_id = 0 + } + $result + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh b/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh deleted file mode 100644 index 4b365b14..00000000 --- a/com.microsoft.copilot.eclipse.terminal.api/scripts/copilot-sh-integration.sh +++ /dev/null @@ -1,32 +0,0 @@ -# Copilot Shell Integration for POSIX sh -# This script is sourced via ENV when starting a terminal. - -__copilot_sh_integration_main() { - # Avoid multiple initialization - [ -n "$COPILOT_SHELL_INTEGRATION" ] && return - COPILOT_SHELL_INTEGRATION=1 - - # OSC escape sequence: ESC ] 7775 ; C BEL - # This is invisible in terminal but detectable programmatically - __COPILOT_MARKER=$(printf '\033]7775;C\007') - - # The function that prints the marker - __copilot_precmd() { - printf '%s' "$__COPILOT_MARKER" - } - - # Save original PS1 only if PS1 is already set and not empty - if [ -z "$__copilot_original_ps1" ] && [ -n "$PS1" ]; then - __copilot_original_ps1=$PS1 - fi - - # Ensure PS1 has a value (POSIX shells may start without PS1) - : "${__copilot_original_ps1:=$ }" - - # Assemble PS1: - # <marker output> (invisible OSC sequence) - # <original prompt> - PS1="\$(__copilot_precmd)${__copilot_original_ps1}" -} - -__copilot_sh_integration_main \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java index b3773000..b91429e1 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/IRunInTerminalTool.java @@ -16,22 +16,25 @@ public interface IRunInTerminalTool { /** - * Executes a command in the terminal. + * Executes a command in the terminal with an initial working directory. * * @param command The command to execute. * @param isBackground Whether the command should run in the background. + * @param workingDirectory The terminal's initial working directory. * @return A CompletableFuture that resolves to the output of the command. */ - public CompletableFuture<String> executeCommand(String command, boolean isBackground); + public CompletableFuture<String> executeCommand(String command, boolean isBackground, String workingDirectory); /** - * Prepares terminal properties for the command execution. + * Prepares terminal properties for the command execution with an initial working directory. * * @param runInBackground Whether the command should run in the background. * @param executionId The unique identifier for the execution. + * @param workingDirectory The terminal's initial working directory. * @return A map containing terminal properties. */ - public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId); + public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory); /** * Retrieves the output of a background command execution. @@ -41,6 +44,11 @@ public interface IRunInTerminalTool { */ public StringBuilder getBackgroundCommandOutput(String executionId); + /** + * Cancels the foreground terminal command if one is currently running. + */ + public void cancelCurrentCommand(); + /** * Sets the terminal icon descriptor for the tool. */ diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java index cd527c3a..c2015f2c 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/ShellIntegrationScripts.java @@ -19,33 +19,62 @@ */ public final class ShellIntegrationScripts { - /** - * The marker string output by the shell integration script after each command completes. - * Uses OSC (Operating System Command) escape sequence format: ESC ] 7775 ; marker BEL - * This is invisible in terminal output but can be detected programmatically. - */ - public static final String SHELL_MARKER = "\u001b]7775;C\u0007"; + /** OSC namespace used by Copilot shell integration markers. */ + public static final String OSC_NAMESPACE = "7775"; + + /** Marker emitted when the prompt starts. */ + public static final String PROMPT_START_MARKER = "\u001b]" + OSC_NAMESPACE + ";A\u0007"; + + /** Marker emitted when the prompt ends. */ + public static final String PROMPT_END_MARKER = "\u001b]" + OSC_NAMESPACE + ";B\u0007"; + + /** Marker emitted when a command finishes without an exit code. */ + public static final String COMMAND_FINISH_MARKER = "\u001b]" + OSC_NAMESPACE + ";C\u0007"; + + /** Marker prefix emitted when a command finishes with an exit code. */ + public static final String COMMAND_FINISH_MARKER_PREFIX = "\u001b]" + OSC_NAMESPACE + ";C;"; + + /** Pattern matching Copilot OSC markers, including markers that lost ESC/BEL during terminal processing. */ + public static final String OSC_MARKER_PATTERN = buildOscMarkerPattern(); private static final String SCRIPTS_PATH = "scripts/"; - private static final String SH_SCRIPT = "copilot-sh-integration.sh"; + private static final String BASH_SCRIPT = "copilot-bash-integration.sh"; + private static final String POWERSHELL_SCRIPT = "copilot-powershell-integration.ps1"; private ShellIntegrationScripts() { // Utility class } + private static String buildOscMarkerPattern() { + return "(?:\u001B)?\\]" + OSC_NAMESPACE + ";[ABC](?:;[-]?\\d+)?(?:\u0007|\u001B\\\\)?"; + } + + /** + * Gets the absolute file path to the Bash integration script. + * + * @return the absolute path to the Bash script, or null if not found + */ + public static String getBashScriptPath() { + return getScriptPath(BASH_SCRIPT); + } + /** - * Gets the absolute file path to the POSIX sh integration script. + * Gets the absolute file path to the PowerShell integration script. * - * @return the absolute path to the sh script, or null if not found + * @return the absolute path to the PowerShell script, or null if not found */ - public static String getShScriptPath() { + public static String getPowerShellScriptPath() { + return getScriptPath(POWERSHELL_SCRIPT); + } + + private static String getScriptPath(String scriptName) { try { Bundle bundle = FrameworkUtil.getBundle(ShellIntegrationScripts.class); if (bundle == null) { return null; } - URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + SH_SCRIPT)); + URL scriptUrl = FileLocator.find(bundle, new Path(SCRIPTS_PATH + scriptName)); if (scriptUrl == null) { return null; } @@ -60,7 +89,7 @@ public static String getShScriptPath() { return scriptFile.getAbsolutePath(); } } catch (IOException e) { - CopilotCore.LOGGER.error("Failed to locate shell integration script: " + SH_SCRIPT, e); + CopilotCore.LOGGER.error("Failed to locate shell integration script: " + scriptName, e); } return null; } diff --git a/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java new file mode 100644 index 00000000..92dbb93b --- /dev/null +++ b/com.microsoft.copilot.eclipse.terminal.api/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessor.java @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +/** + * Processes terminal command input and output buffers. + */ +public final class TerminalCommandProcessor { + private static final int MAX_OUTPUT_LINE_COUNT = 1000; + private static final String BRACKETED_PASTE_START = "\u001b[200~"; + private static final String BRACKETED_PASTE_END = "\u001b[201~"; + private static final String ANSI_CSI_SEQUENCE_PATTERN = "\u001B\\[(\\?)?[\\d;]*[a-zA-Z]"; + private static final String OSC_SEQUENCE_PATTERN = "\u001B\\][^\u0007\u001B]*(?:\u0007|\u001B\\\\)"; + private static final Pattern PROMPT_START_MARKER_PATTERN = buildMarkerPattern("A", false); + private static final Pattern PROMPT_END_MARKER_PATTERN = buildMarkerPattern("B", false); + private static final Pattern COMMAND_FINISH_MARKER_PATTERN = buildMarkerPattern("C", true); + + private TerminalCommandProcessor() { + // Utility class + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command) { + return formatForExecution(command, true); + } + + /** + * Formats a command for immediate terminal execution. + * + * @param command the command to send + * @param useBracketedPaste whether multiline commands should be sent using bracketed paste + * @return command text formatted for terminal input + */ + public static String formatForExecution(String command, boolean useBracketedPaste) { + String normalizedCommand = StringUtils.stripEnd(normalizeLineEndings(command), "\n"); + String terminalInput = useBracketedPaste && isMultilineCommand(normalizedCommand) + ? BRACKETED_PASTE_START + normalizedCommand + BRACKETED_PASTE_END + : normalizedCommand; + terminalInput = terminalInput.replace('\n', '\r'); + if (!terminalInput.endsWith("\r")) { + terminalInput += "\r"; + } + return terminalInput; + } + + /** + * Truncates terminal output to the tail when it is too long for a tool result. + * + * @param output terminal output + * @return terminal output, truncated to the last lines when needed + */ + public static String truncateOutput(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + + String normalizedOutput = normalizeLineEndings(output); + int scanIndex = normalizedOutput.endsWith("\n") ? normalizedOutput.length() - 2 : normalizedOutput.length() - 1; + int keptLineCount = 1; + while (scanIndex >= 0) { + if (normalizedOutput.charAt(scanIndex) == '\n') { + if (keptLineCount == MAX_OUTPUT_LINE_COUNT) { + StringBuilder truncatedOutput = new StringBuilder(); + truncatedOutput.append("[Terminal output truncated: showing last ") + .append(MAX_OUTPUT_LINE_COUNT) + .append(" lines.]\n"); + truncatedOutput.append(normalizedOutput.substring(scanIndex + 1)); + return truncatedOutput.toString(); + } + keptLineCount++; + } + scanIndex--; + } + + return output; + } + + /** + * Cleans terminal output for model-visible tool results. + * + * @param output terminal output + * @return output with terminal control sequences removed and long output truncated + */ + public static String prepareOutputForModel(String output) { + return truncateOutput(cleanTerminalControlSequences(output)); + } + + /** + * Attempts to complete a command using shell integration markers. + * + * @param output terminal output buffer + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithMarker(StringBuilder output) { + MarkerRange commandFinishMarkerRange = findMarker(output, COMMAND_FINISH_MARKER_PATTERN, 0); + if (commandFinishMarkerRange == null) { + // Startup or idle prompts can arrive before a command runs. Keep the visible prompt text, but remove marker + // bytes so later command output cleanup does not have to handle stale prompt boundaries. + removePromptMarkers(output); + return CompletionCheckResult.incomplete(); + } + + // A complete marker command is the command finish marker followed by the next prompt end. Waiting for B keeps the + // prompt line in the returned output, which gives the language model the terminal's current working directory. + MarkerRange promptEndMarkerRange = findMarker(output, PROMPT_END_MARKER_PATTERN, commandFinishMarkerRange.endIndex); + if (promptEndMarkerRange == null) { + return CompletionCheckResult.incomplete(); + } + + // Keep terminal output plus the next prompt, but exclude command finish markers. + String completedOutput = output.substring(0, commandFinishMarkerRange.startIndex) + + output.substring(commandFinishMarkerRange.endIndex, promptEndMarkerRange.endIndex); + return CompletionCheckResult.completed(cleanCommandOutput(completedOutput)); + } + + /** + * Attempts to complete a command by detecting a shell prompt. + * + * @param output terminal output buffer + * @return the completion check result + */ + public static CompletionCheckResult tryCompleteWithPrompt(StringBuilder output) { + String terminalOutput = output.toString().trim(); + int lastNewLineIndex = terminalOutput.lastIndexOf('\n'); + if (lastNewLineIndex <= 0) { + return CompletionCheckResult.incomplete(); + } + + String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); + if (lastLine.isBlank() || lastLine.length() == 1) { + return CompletionCheckResult.incomplete(); + } + + char lastChar = lastLine.charAt(lastLine.length() - 1); + boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; + if (!isPromptChar) { + return CompletionCheckResult.incomplete(); + } + + String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); + int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); + if (promptStartIndex == -1) { + promptStartIndex = 0; + } else { + promptStartIndex += lastLine.length(); + } + + if (contentWithoutLastPrompt.isBlank()) { + return CompletionCheckResult.incomplete(); + } + return CompletionCheckResult.completed(contentWithoutLastPrompt.substring(promptStartIndex).trim()); + } + + private static String removeBracketedPasteMarkers(String output) { + return output.replace(BRACKETED_PASTE_START, "").replace(BRACKETED_PASTE_END, ""); + } + + private static boolean isMultilineCommand(String command) { + int newlineIndex = command.indexOf('\n'); + while (newlineIndex >= 0) { + if (newlineIndex == 0 || command.charAt(newlineIndex - 1) != '\\') { + return true; + } + newlineIndex = command.indexOf('\n', newlineIndex + 1); + } + return false; + } + + private static MarkerRange findMarker(StringBuilder output, Pattern markerPattern, int startIndex) { + Matcher matcher = markerPattern.matcher(output); + if (!matcher.find(startIndex)) { + return null; + } + return new MarkerRange(matcher.start(), matcher.end()); + } + + private static void removePromptMarkers(StringBuilder output) { + removeAll(output, PROMPT_START_MARKER_PATTERN); + removeAll(output, PROMPT_END_MARKER_PATTERN); + } + + private static void removeAll(StringBuilder output, Pattern markerPattern) { + Matcher matcher = markerPattern.matcher(output); + while (matcher.find()) { + output.delete(matcher.start(), matcher.end()); + matcher.reset(output); + } + } + + private static Pattern buildMarkerPattern(String markerKind, boolean includeExitCode) { + String exitCodePattern = includeExitCode ? "(?:;[-]?\\d+)?" : ""; + return Pattern.compile("(?:\u001B)?\\]" + ShellIntegrationScripts.OSC_NAMESPACE + ";" + markerKind + + exitCodePattern + "(?:\u0007|\u001B\\\\)?"); + } + + private static String cleanCommandOutput(String output) { + String normalizedOutput = normalizeLineEndings(output); + normalizedOutput = removeShellIntegrationMarkers(normalizedOutput); + normalizedOutput = removeBracketedPasteMarkers(normalizedOutput); + return normalizedOutput.trim(); + } + + private static String removeShellIntegrationMarkers(String output) { + return output.replaceAll(ShellIntegrationScripts.OSC_MARKER_PATTERN, ""); + } + + private static String cleanTerminalControlSequences(String output) { + if (output == null || output.isEmpty()) { + return output == null ? "" : output; + } + return removeShellIntegrationMarkers(output) + .replaceAll(ANSI_CSI_SEQUENCE_PATTERN, "") + .replaceAll(OSC_SEQUENCE_PATTERN, ""); + } + + private static String normalizeLineEndings(String value) { + return value.replace("\r\n", "\n").replace('\r', '\n'); + } + + private record MarkerRange(int startIndex, int endIndex) { + } + + /** + * Result of checking a terminal output buffer for command completion. + */ + public record CompletionCheckResult(CompletionCheckState state, String output) { + private static CompletionCheckResult incomplete() { + return new CompletionCheckResult(CompletionCheckState.INCOMPLETE, ""); + } + + private static CompletionCheckResult completed(String output) { + return new CompletionCheckResult(CompletionCheckState.COMPLETED, output); + } + } + + /** + * Terminal output completion states. + */ + public enum CompletionCheckState { + INCOMPLETE, + COMPLETED + } +} diff --git a/com.microsoft.copilot.eclipse.ui.jobs/.classpath b/com.microsoft.copilot.eclipse.ui.jobs/.classpath index a7bc7121..5508535a 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/.classpath +++ b/com.microsoft.copilot.eclipse.ui.jobs/.classpath @@ -3,5 +3,5 @@ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17"/> <classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/> <classpathentry kind="src" path="src/"/> - <classpathentry kind="output" path="bin"/> + <classpathentry kind="output" path="target/classes"/> </classpath> diff --git a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF index 1d4b3c5d..7c499bb2 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.jobs/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: GitHub Copilot Jobs Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.jobs;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Activator: com.microsoft.copilot.eclipse.ui.jobs.CopilotJobs Bundle-RequiredExecutionEnvironment: JavaSE-17 @@ -10,8 +10,8 @@ Bundle-Localization: plugin Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.jobs Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.20.0", jakarta.annotation-api, jakarta.inject.jakarta.inject-api, org.apache.commons.lang3;bundle-version="3.13.0", diff --git a/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/Messages.java index 58209d9d..f74d54f4 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/Messages.java @@ -11,9 +11,6 @@ public final class Messages extends NLS { private static final String BUNDLE_NAME = "com.microsoft.copilot.eclipse.ui.jobs.i18n.messages"; //$NON-NLS-1$ - public static String jobsView_toolTip_refreshAgentJobs; - public static String jobsView_toolTip_collapseAll; - public static String jobsView_toolTip_expandAll; public static String jobsView_toolTip_pullRequest; public static String jobsView_label_loadingAgentJobs; public static String jobsView_label_noOpenProjects; @@ -22,9 +19,7 @@ public final class Messages extends NLS { public static String jobsView_label_draftPrefix; public static String jobsView_job_loadingPullRequests; public static String jobsView_job_loadingPullRequestsForProjects; - public static String jobsView_job_loadingPRsForProject; public static String jobsView_error_loadingPullRequests; - public static String jobsView_error_searchingPRsForProject; public static String jobsView_error_loadingPRsForProject; public static String jobsView_error_languageServerNotAvailable; diff --git a/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/messages.properties index 9d72d429..535ea725 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui.jobs/src/com/microsoft/copilot/eclipse/ui/jobs/i18n/messages.properties @@ -1,6 +1,3 @@ -jobsView_toolTip_refreshAgentJobs=Refresh Agent Jobs -jobsView_toolTip_collapseAll=Collapse All -jobsView_toolTip_expandAll=Expand All jobsView_toolTip_pullRequest=Click to open pull request "{0}" in your default browser jobsView_label_loadingAgentJobs=Loading Agent Jobs... jobsView_label_noOpenProjects=No open projects @@ -9,8 +6,6 @@ jobsView_label_noAgentJobsFound=No Copilot coding agent jobs found jobsView_label_draftPrefix=[Draft] jobsView_job_loadingPullRequests=Loading Pull Requests jobsView_job_loadingPullRequestsForProjects=Loading pull requests for projects -jobsView_job_loadingPRsForProject=Loading PRs for {0} jobsView_error_loadingPullRequests=Failed to load pull requests -jobsView_error_searchingPRsForProject=Error searching PRs for project {0} jobsView_error_loadingPRsForProject=Error loading PRs for project {0} jobsView_error_languageServerNotAvailable=Language server not available for project after retries: {0} diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/.classpath b/com.microsoft.copilot.eclipse.ui.terminal.tm/.classpath index 81fe078c..8d861214 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/.classpath +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/.classpath @@ -3,5 +3,5 @@ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17"/> <classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/> <classpathentry kind="src" path="src"/> - <classpathentry kind="output" path="bin"/> + <classpathentry kind="output" path="target/classes"/> </classpath> diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF index 3eacc266..0dd06aa3 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal.tm Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal.tm;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal.tm @@ -10,7 +10,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.tm.terminal.view.core, org.eclipse.tm.terminal.view.ui, org.eclipse.tm.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java index 6844799f..28849b6e 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal.tm/src/com/microsoft/copilot/eclipse/ui/terminal/tm/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal.tm; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * terminal tool implementation for older Eclipse versions. @@ -46,6 +50,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map<String, StringBuilder> backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -59,9 +66,7 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; - private CompletableFuture<String> resultFuture; - private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile ForegroundCommand foregroundCommand; /** * Constructor for RunInTerminalTool. @@ -71,62 +76,71 @@ public RunInTerminalTool() { } @Override - public CompletableFuture<String> executeCommand(String command, boolean isBackground) { + public CompletableFuture<String> executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } - resultFuture = new CompletableFuture<>(); - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + if (!isBackground) { + closeRunningForegroundTerminal(COMMAND_INTERRUPTED_MESSAGE); + } - if (!useMarker) { - // Retain only the last line (prompt) in the output buffer - if (!sb.isEmpty()) { - int lastLineStart = sb.lastIndexOf(StringUtils.LF); - if (lastLineStart > 0) { - sb.delete(0, lastLineStart); + CompletableFuture<String> commandFuture = new CompletableFuture<>(); + ForegroundCommand commandState = isBackground ? null + : new ForegroundCommand(commandFuture, hasShellIntegrationMarker()); + + if (commandState != null) { + foregroundCommand = commandState; + if (!commandState.useMarker()) { + // Retain only the last line (prompt) in the output buffer + if (!sb.isEmpty()) { + int lastLineStart = sb.lastIndexOf(StringUtils.LF); + if (lastLineStart > 0) { + sb.delete(0, lastLineStart); + } } + } else { + // For marker-based detection, clear the buffer + sb.setLength(0); } - } else { - // For marker-based detection, clear the buffer - sb.setLength(0); } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { - bringTerminalViewAndCopilotConsoleToFront(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + revealTerminal(); + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = TerminalServiceFactory.getService(); if (service == null) { + clearForegroundCommand(commandState); return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId), status -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory), status -> { if (status.isOK()) { + if (commandFuture.isDone()) { + return; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + clearForegroundCommand(commandState); + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return; } if (!isBackground) { this.persistentTerminalViewControl = terminalViewControl; - bringTerminalViewAndCopilotConsoleToFront(); + revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + status.getException()); + clearForegroundCommand(commandState); + commandFuture.complete("Failed to open terminal console: " + status.getException()); } }); } @@ -135,25 +149,35 @@ public CompletableFuture<String> executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map<String, Object> properties = new HashMap<>(); properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -187,6 +211,69 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + closeRunningForegroundTerminal(COMMAND_CANCELLED_MESSAGE); + } + + private void closeRunningForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = foregroundCommand; + if (commandState != null && !commandState.future().isDone()) { + closeCurrentForegroundTerminal(completionMessage); + } + } + + private void closeCurrentForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = null; + CTabItem tabItem = null; + synchronized (lock) { + commandState = foregroundCommand; + foregroundCommand = null; + persistentTerminalViewControl = null; + tabItem = copilotTabItem; + copilotTabItem = null; + sb.setLength(0); + } + + if (tabItem != null) { + final CTabItem tabItemToDispose = tabItem; + // Keep this synchronous so a new foreground command cannot open before the old terminal tab is disposed. + Display.getDefault().syncExec(() -> { + if (!tabItemToDispose.isDisposed()) { + tabItemToDispose.dispose(); + } + }); + } + if (commandState != null && !commandState.future().isDone()) { + commandState.future().complete(completionMessage); + } + } + + private void clearForegroundCommand(ForegroundCommand commandState) { + if (commandState != null && foregroundCommand == commandState) { + foregroundCommand = null; + } + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -205,7 +292,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack try { IWorkbenchPage page = getActivePage(); if (page != null) { - IViewPart view = page.showView(IUIConstants.ID); + IViewPart view = page.showView(IUIConstants.ID, null, IWorkbenchPage.VIEW_VISIBLE); if (view != null) { tabFolder = view.getAdapter(CTabFolder.class); if (tabFolder != null) { @@ -246,12 +333,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to - // be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of // PlatformUtils @@ -265,80 +349,25 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList output.append(content); // Detect completion based on platform strategy - if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + ForegroundCommand commandState = foregroundCommand; + if (!isBackground && commandState != null && !commandState.future().isDone()) { + CompletionCheckResult completionResult = commandState.useMarker() + ? TerminalCommandProcessor.tryCompleteWithMarker(output) + : TerminalCommandProcessor.tryCompleteWithPrompt(output); + handleCompletionResult(commandState, completionResult); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { + private void handleCompletionResult(ForegroundCommand commandState, CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; + if (foregroundCommand == commandState) { + foregroundCommand = null; } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { - return; - } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { - return; - } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; - } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + if (!commandState.future().isDone()) { + commandState.future().complete(completionResult.output()); } } @@ -360,13 +389,13 @@ private DisposeListener buildDisposeListener(String executionId, boolean isBackg }; } - private void bringTerminalViewAndCopilotConsoleToFront() { + private void revealTerminal() { if (tabFolder != null && copilotTabItem != null) { Display.getDefault().syncExec(() -> { try { IWorkbenchPage page = getActivePage(); if (page != null) { - IViewPart view = page.showView(IUIConstants.ID); + IViewPart view = page.showView(IUIConstants.ID, null, IWorkbenchPage.VIEW_VISIBLE); if (tabFolder.isDisposed() && view != null) { tabFolder = view.getAdapter(CTabFolder.class); } @@ -385,6 +414,9 @@ private String buildBackgroundTerminalTitle(String executionId) { return BACKGROUND_TERMINAL_PREFIX + executionId; } + private record ForegroundCommand(CompletableFuture<String> future, boolean useMarker) { + } + /** * Get active workbench page without UiUtils dependency. */ diff --git a/com.microsoft.copilot.eclipse.ui.terminal/.classpath b/com.microsoft.copilot.eclipse.ui.terminal/.classpath index 31b01735..e0ce1f7e 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/.classpath +++ b/com.microsoft.copilot.eclipse.ui.terminal/.classpath @@ -3,6 +3,6 @@ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17"/> <classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/> <classpathentry kind="src" path="src"/> - <classpathentry kind="output" path="bin"/> + <classpathentry kind="output" path="target/classes"/> </classpath> diff --git a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF index eb6e4e34..5fdd2792 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.terminal/META-INF/MANIFEST.MF @@ -1,7 +1,7 @@ Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.terminal Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.terminal;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.terminal @@ -9,7 +9,7 @@ Bundle-ActivationPolicy: lazy Service-Component: OSGI-INF/component.xml Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)", org.osgi.service.component -Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.terminal.view.core, org.eclipse.terminal.view.ui, org.eclipse.terminal.control, diff --git a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java index 2edacc0b..dfe44701 100644 --- a/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java +++ b/com.microsoft.copilot.eclipse.ui.terminal/src/com/microsoft/copilot/eclipse/ui/terminal/RunInTerminalTool.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.terminal; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,9 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.ShellIntegrationScripts; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckResult; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; /** * Modern terminal tool implementation for newer Eclipse versions. @@ -46,6 +50,9 @@ public class RunInTerminalTool implements IRunInTerminalTool { private static final Object lock = new Object(); private static final Map<String, StringBuilder> backgroundCommandOutputs = new HashMap<>(); private static final String BACKGROUND_TERMINAL_PREFIX = "Copilot-"; + private static final String POWERSHELL_SCRIPT_ENV = "COPILOT_POWERSHELL_INTEGRATION_SCRIPT"; + private static final String COMMAND_CANCELLED_MESSAGE = "Terminal command cancelled."; + private static final String COMMAND_INTERRUPTED_MESSAGE = "Terminal command interrupted by a new command."; // Non-background terminal field private ITerminalViewControl persistentTerminalViewControl; @@ -59,9 +66,7 @@ public class RunInTerminalTool implements IRunInTerminalTool { // Output and command state private StringBuilder sb; - private CompletableFuture<String> resultFuture; - private volatile boolean useMarker; - private volatile boolean isInitialMarkerHandled; + private volatile ForegroundCommand foregroundCommand; /** * Constructor for RunInTerminalTool. @@ -71,64 +76,71 @@ public RunInTerminalTool() { } @Override - public CompletableFuture<String> executeCommand(String command, boolean isBackground) { + public CompletableFuture<String> executeCommand(String command, boolean isBackground, String workingDirectory) { if (StringUtils.isBlank(command)) { return CompletableFuture.completedFuture("The command is null or empty."); } - resultFuture = new CompletableFuture<>(); - - // Linux uses shell-integration marker lines; others rely on prompt detection - useMarker = Platform.getOS().equals(Platform.OS_LINUX); + if (!isBackground) { + closeRunningForegroundTerminal(COMMAND_INTERRUPTED_MESSAGE); + } - if (!useMarker) { - // Retain only the last line (prompt) in the output buffer - if (!sb.isEmpty()) { - int lastLineStart = sb.lastIndexOf(StringUtils.LF); - if (lastLineStart > 0) { - sb.delete(0, lastLineStart); + CompletableFuture<String> commandFuture = new CompletableFuture<>(); + ForegroundCommand commandState = isBackground ? null + : new ForegroundCommand(commandFuture, hasShellIntegrationMarker()); + + if (commandState != null) { + foregroundCommand = commandState; + if (!commandState.useMarker()) { + // Retain only the last line (prompt) in the output buffer + if (!sb.isEmpty()) { + int lastLineStart = sb.lastIndexOf(StringUtils.LF); + if (lastLineStart > 0) { + sb.delete(0, lastLineStart); + } } + } else { + // For marker-based detection, clear the buffer + sb.setLength(0); } - } else { - // For marker-based detection, clear the buffer - sb.setLength(0); } String executionId = UUID.randomUUID().toString(); - final String finalCommand = command + System.lineSeparator(); + final String finalCommand = TerminalCommandProcessor.formatForExecution(command, useBracketedPaste()); synchronized (lock) { if (!isBackground && this.persistentTerminalViewControl != null) { - bringTerminalViewAndCopilotConsoleToFront(); - this.persistentTerminalViewControl.pasteString(finalCommand); - return this.resultFuture; + revealTerminal(); + sendCommand(this.persistentTerminalViewControl, finalCommand); + return commandFuture; } ITerminalService service = CoreBundleActivator.getTerminalService(); if (service == null) { + clearForegroundCommand(commandState); return CompletableFuture.completedFuture("Failed to open terminal console due to terminal service is null."); } - // New non-background terminal will have an initial marker from shell startup; need to handle it - if (useMarker && !isBackground) { - isInitialMarkerHandled = false; - } - - service.openConsole(prepareTerminalProperties(isBackground, executionId)).handle((o, e) -> { + service.openConsole(prepareTerminalProperties(isBackground, executionId, workingDirectory)).handle((o, e) -> { if (e == null) { + if (commandFuture.isDone()) { + return null; + } ITerminalViewControl terminalViewControl = finalizeTerminalSetup(executionId, isBackground); if (terminalViewControl == null) { - resultFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); + clearForegroundCommand(commandState); + commandFuture.complete("Terminal view control cannot be setup for RunInTerminalTool."); return null; } if (!isBackground) { this.persistentTerminalViewControl = terminalViewControl; - bringTerminalViewAndCopilotConsoleToFront(); + revealTerminal(); } - terminalViewControl.pasteString(finalCommand); + sendCommand(terminalViewControl, finalCommand); } else { - resultFuture.complete("Failed to open terminal console: " + e.getMessage()); + clearForegroundCommand(commandState); + commandFuture.complete("Failed to open terminal console: " + e.getMessage()); } return null; }); @@ -138,25 +150,35 @@ public CompletableFuture<String> executeCommand(String command, boolean isBackgr return CompletableFuture.completedFuture("Command is running in terminal with ID=" + executionId); } - return resultFuture; + return commandFuture; } @Override - public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId) { + public Map<String, Object> prepareTerminalProperties(boolean runInBackground, String executionId, + String workingDirectory) { Map<String, Object> properties = new HashMap<>(); properties.put(ITerminalsConnectorConstants.PROP_ENCODING, "UTF-8"); properties.put(ITerminalsConnectorConstants.PROP_TITLE_DISABLE_ANSI_TITLE, true); + if (StringUtils.isNotBlank(workingDirectory)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_WORKING_DIR, workingDirectory); + } if (Platform.getOS().equals(Platform.OS_WIN32)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "cmd.exe"); - } else if (Platform.getOS().equals(Platform.OS_LINUX)) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/sh"); - // Use ENV to load shell integration script at startup - String scriptPath = ShellIntegrationScripts.getShScriptPath(); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "powershell.exe"); + String scriptPath = ShellIntegrationScripts.getPowerShellScriptPath(); if (scriptPath != null) { - properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, new String[] { "ENV=" + scriptPath }); + String[] environment = new String[] { POWERSHELL_SCRIPT_ENV + "=" + scriptPath }; + String args = "-NoExit -ExecutionPolicy Bypass -Command \". $env:" + POWERSHELL_SCRIPT_ENV + "\""; + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ENVIRONMENT, environment); properties.put(ITerminalsConnectorConstants.PROP_PROCESS_MERGE_ENVIRONMENT, true); + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, args); + } + } else if (Platform.getOS().equals(Platform.OS_LINUX)) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_PATH, "/bin/bash"); + String scriptPath = ShellIntegrationScripts.getBashScriptPath(); + if (scriptPath != null) { + properties.put(ITerminalsConnectorConstants.PROP_PROCESS_ARGS, "--init-file \"" + scriptPath + "\" -i"); } } else { // macOS or other Unix-like: keep existing behavior, only set args if empty @@ -190,6 +212,69 @@ public StringBuilder getBackgroundCommandOutput(String executionId) { return output; } + @Override + public void cancelCurrentCommand() { + closeRunningForegroundTerminal(COMMAND_CANCELLED_MESSAGE); + } + + private void closeRunningForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = foregroundCommand; + if (commandState != null && !commandState.future().isDone()) { + closeCurrentForegroundTerminal(completionMessage); + } + } + + private void closeCurrentForegroundTerminal(String completionMessage) { + ForegroundCommand commandState = null; + CTabItem tabItem = null; + synchronized (lock) { + commandState = foregroundCommand; + foregroundCommand = null; + persistentTerminalViewControl = null; + tabItem = copilotTabItem; + copilotTabItem = null; + sb.setLength(0); + } + + if (tabItem != null) { + final CTabItem tabItemToDispose = tabItem; + // Keep this synchronous so a new foreground command cannot open before the old terminal tab is disposed. + Display.getDefault().syncExec(() -> { + if (!tabItemToDispose.isDisposed()) { + tabItemToDispose.dispose(); + } + }); + } + if (commandState != null && !commandState.future().isDone()) { + commandState.future().complete(completionMessage); + } + } + + private void clearForegroundCommand(ForegroundCommand commandState) { + if (commandState != null && foregroundCommand == commandState) { + foregroundCommand = null; + } + } + + private void sendCommand(ITerminalViewControl terminalViewControl, String command) { + terminalViewControl.pasteString(command); + } + + private boolean hasShellIntegrationMarker() { + if (Platform.getOS().equals(Platform.OS_WIN32)) { + return ShellIntegrationScripts.getPowerShellScriptPath() != null; + } + if (Platform.getOS().equals(Platform.OS_LINUX)) { + return ShellIntegrationScripts.getBashScriptPath() != null; + } + return false; + } + + private boolean useBracketedPaste() { + // macOS terminal multiline handling differs from PowerShell/Bash integration, so keep its existing plain input. + return Platform.getOS().equals(Platform.OS_WIN32) || Platform.getOS().equals(Platform.OS_LINUX); + } + private ITerminalViewControl finalizeTerminalSetup(String executionId, boolean isBackground) { String title = isBackground ? buildBackgroundTerminalTitle(executionId) : "Copilot"; synchronized (lock) { @@ -208,7 +293,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack try { IWorkbenchPage page = getActivePage(); if (page != null) { - IViewPart view = page.showView(IUIConstants.ID); + IViewPart view = page.showView(IUIConstants.ID, null, IWorkbenchPage.VIEW_VISIBLE); if (view != null) { tabFolder = view.getAdapter(CTabFolder.class); if (tabFolder != null) { @@ -234,7 +319,7 @@ private ITerminalControl getTerminalControl(String terminalTitle, boolean isBack } } } catch (PartInitException e) { - //skip exception + // Skip exception } }); @@ -249,11 +334,9 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList } return (byteBuffer, bytesRead) -> { - String content = new String(byteBuffer, 0, bytesRead); - // Remove ANSI escape sequences - // Sometimes it also removes the linebreaks. But we need the last prompt line to be a separate line later. So we - // add line separator back to the content. - content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", StringUtils.LF); + String content = new String(byteBuffer, 0, bytesRead, StandardCharsets.UTF_8); + // Remove ANSI escape sequences while preserving only real line breaks from the terminal output. + content = content.replaceAll("\u001B\\[(\\?)?[\\d;]*[a-zA-Z]", ""); // Handle Windows terminal title sequences - using Platform instead of PlatformUtils if (Platform.getOS().equals(Platform.OS_WIN32)) { @@ -265,80 +348,25 @@ private ITerminalServiceOutputStreamMonitorListener buildOutputStreamMonitorList output.append(content); // Detect completion based on platform strategy - if (!isBackground && resultFuture != null && !resultFuture.isDone()) { - if (useMarker) { - tryCompleteWithMarker(output); - } else { - tryCompleteWithPrompt(output); - } + ForegroundCommand commandState = foregroundCommand; + if (!isBackground && commandState != null && !commandState.future().isDone()) { + CompletionCheckResult completionResult = commandState.useMarker() + ? TerminalCommandProcessor.tryCompleteWithMarker(output) + : TerminalCommandProcessor.tryCompleteWithPrompt(output); + handleCompletionResult(commandState, completionResult); } }; } - /** - * Attempts to complete the command by detecting the shell marker in output. - * Used on Linux where shell integration script outputs a marker after each command. - */ - private void tryCompleteWithMarker(StringBuilder output) { - int markerIndex = output.indexOf(ShellIntegrationScripts.SHELL_MARKER); - if (markerIndex < 0) { - return; - } - - // Remove marker from output - output.delete(markerIndex, markerIndex + ShellIntegrationScripts.SHELL_MARKER.length()); - - // Skip the initial marker that appears when terminal starts (before any command is run) - if (!isInitialMarkerHandled) { - isInitialMarkerHandled = true; - return; - } - - String cleaned = output.toString().trim(); - resultFuture.complete(cleaned); - } - - /** - * Attempts to complete the command by detecting a shell prompt in output. - * Used on Windows and macOS where prompt characters indicate command completion. - */ - private void tryCompleteWithPrompt(StringBuilder output) { - String terminalOutput = output.toString().trim(); - int lastNewLineIndex = terminalOutput.lastIndexOf(StringUtils.LF); - if (lastNewLineIndex <= 0) { - return; - } - - String lastLine = terminalOutput.substring(lastNewLineIndex).trim(); - - // Check if last line is a prompt line - // Mac always has single '%' as last line, that's not what we want. - if (StringUtils.isBlank(lastLine) || lastLine.length() == 1) { + private void handleCompletionResult(ForegroundCommand commandState, CompletionCheckResult completionResult) { + if (completionResult.state() == CompletionCheckState.INCOMPLETE) { return; } - - char lastChar = lastLine.charAt(lastLine.length() - 1); - boolean isPromptChar = lastChar == '>' || lastChar == '#' || lastChar == '$' || lastChar == '%'; - if (!isPromptChar) { - return; + if (foregroundCommand == commandState) { + foregroundCommand = null; } - - // Extract result text between prompts - String contentWithoutLastPrompt = terminalOutput.substring(0, lastNewLineIndex); - int promptStartIndex = contentWithoutLastPrompt.indexOf(lastLine); - // If the prompt line is not found, set start index to 0. Sometimes it starts - // with the commandResult. - if (promptStartIndex == -1) { - promptStartIndex = 0; - } else { - promptStartIndex += lastLine.length(); - } - - if (!contentWithoutLastPrompt.isBlank()) { - String commandResult = contentWithoutLastPrompt.substring(promptStartIndex).trim(); - if (resultFuture != null && !resultFuture.isDone()) { - resultFuture.complete(commandResult); - } + if (!commandState.future().isDone()) { + commandState.future().complete(completionResult.output()); } } @@ -360,13 +388,13 @@ private DisposeListener buildDisposeListener(String executionId, boolean isBackg }; } - private void bringTerminalViewAndCopilotConsoleToFront() { + private void revealTerminal() { if (tabFolder != null && copilotTabItem != null) { Display.getDefault().syncExec(() -> { try { IWorkbenchPage page = getActivePage(); if (page != null) { - IViewPart view = page.showView(IUIConstants.ID); + IViewPart view = page.showView(IUIConstants.ID, null, IWorkbenchPage.VIEW_VISIBLE); if (tabFolder.isDisposed() && view != null) { tabFolder = view.getAdapter(CTabFolder.class); } @@ -385,6 +413,9 @@ private String buildBackgroundTerminalTitle(String executionId) { return BACKGROUND_TERMINAL_PREFIX + executionId; } + private record ForegroundCommand(CompletableFuture<String> future, boolean useMarker) { + } + /** * Get active workbench page without UiUtils dependency. */ diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 9fc390ec..9d33676c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui.test Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui.test;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui.test @@ -12,9 +12,10 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.lsp4e;bundle-version="0.18.1", org.eclipse.jdt.annotation;resolution:=optional, junit-jupiter-api;bundle-version="5.10.1", + junit-jupiter-params;bundle-version="5.10.1", org.mockito.junit-jupiter;bundle-version="5.10.2", - com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.ui;bundle-version="0.15.0", + com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.ui;bundle-version="0.20.0", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.ide, org.eclipse.ui.workbench.texteditor, @@ -28,4 +29,4 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.e4.core.services;bundle-version="2.4.200", org.osgi.service.event, com.google.gson, - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0" + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0" diff --git a/com.microsoft.copilot.eclipse.ui.test/plugin.xml b/com.microsoft.copilot.eclipse.ui.test/plugin.xml index a12ac0c7..fd3fba17 100644 --- a/com.microsoft.copilot.eclipse.ui.test/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui.test/plugin.xml @@ -1,6 +1,18 @@ <?xml version="1.0" encoding="UTF-8"?> <?eclipse version="3.4"?> <plugin> + <extension + id="foreignTextMarker" + name="Foreign Text Marker" + point="org.eclipse.core.resources.markers"> + <super + type="org.eclipse.core.resources.textmarker"> + </super> + <persistent + value="false"> + </persistent> + </extension> + <extension point="org.eclipse.ui.editors"> <editor diff --git a/com.microsoft.copilot.eclipse.ui.test/pom.xml b/com.microsoft.copilot.eclipse.ui.test/pom.xml index 2514c9b6..d6296186 100644 --- a/com.microsoft.copilot.eclipse.ui.test/pom.xml +++ b/com.microsoft.copilot.eclipse.ui.test/pom.xml @@ -15,6 +15,29 @@ <checkstyle.skip>true</checkstyle.skip> </properties> + <profiles> + <profile> + <id>skip-tests-during-ui-probe</id> + <activation> + <property> + <name>probe.script</name> + </property> + </activation> + <build> + <plugins> + <plugin> + <groupId>org.eclipse.tycho</groupId> + <artifactId>tycho-surefire-plugin</artifactId> + <version>${tycho-version}</version> + <configuration> + <skipTests>true</skipTests> + </configuration> + </plugin> + </plugins> + </build> + </profile> + </profiles> + <build> <plugins> <plugin> diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java new file mode 100644 index 00000000..67e4607e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/terminal/api/TerminalCommandProcessorTest.java @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.terminal.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor.CompletionCheckState; + +class TerminalCommandProcessorTest { + @Test + void testFormatForExecution_singleLine_appendsCarriageReturn() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello")); + } + + @Test + void testFormatForExecution_multiline_wrapsWithBracketedPaste() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second")); + } + + @Test + void testFormatForExecution_multilineWithCrlf_normalizesLineEndings() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\r\necho second")); + } + + @Test + void testFormatForExecution_multilineWithTrailingNewline_doesNotSubmitEmptyCommand() { + assertEquals("\u001b[200~echo first\recho second\u001b[201~\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second\n")); + } + + @Test + void testFormatForExecution_multilineWithoutBracketedPaste_submitsPlainLines() { + assertEquals("echo first\recho second\r", + TerminalCommandProcessor.formatForExecution("echo first\necho second", false)); + } + + @Test + void testFormatForExecution_singleLineWithTrailingNewline_doesNotUseBracketedPaste() { + assertEquals("echo hello\r", TerminalCommandProcessor.formatForExecution("echo hello\n")); + } + + @Test + void testFormatForExecution_backslashContinuation_doesNotUseBracketedPaste() { + assertEquals("echo hello \\" + "\r world\r", + TerminalCommandProcessor.formatForExecution("echo hello \\\n world")); + } + + @Test + void testTryCompleteWithMarker_completed_keepsNextPrompt() { + StringBuilder output = new StringBuilder("echo hi\r\n") + .append("hi\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi\nPS C:\\projects\\copilot-eclipse>", result.output()); + } + + @Test + void testTryCompleteWithMarker_completedWithBareMarker() { + StringBuilder output = new StringBuilder("echo hi\r\nhi\r\n]7775;C;0]7775;A$ ]7775;B"); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi\n$", result.output()); + } + + @Test + void testTryCompleteWithMarker_incomplete_removesPromptMarkers() { + StringBuilder output = new StringBuilder() + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("PS C:\\projects\\copilot-eclipse> ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.INCOMPLETE, result.state()); + assertEquals("PS C:\\projects\\copilot-eclipse> ", output.toString()); + } + + @Test + void testTryCompleteWithMarker_completedPreservesOutputContainingCommandText() { + StringBuilder output = new StringBuilder("echo hi\r\n") + .append("before\r\necho hi\r\nafter\r\n") + .append(ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX).append("0\u0007") + .append(ShellIntegrationScripts.PROMPT_START_MARKER) + .append("$ ") + .append(ShellIntegrationScripts.PROMPT_END_MARKER); + + var result = TerminalCommandProcessor.tryCompleteWithMarker(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nbefore\necho hi\nafter\n$", result.output()); + } + + @Test + void testTryCompleteWithPrompt_completed_extractsOutputBetweenPrompts() { + StringBuilder output = new StringBuilder("PS C:\\repo> echo hi\nhi\nPS C:\\repo> "); + + var result = TerminalCommandProcessor.tryCompleteWithPrompt(output); + + assertEquals(CompletionCheckState.COMPLETED, result.state()); + assertEquals("echo hi\nhi", result.output()); + } + + @Test + void testTruncateOutput_shortOutput_returnsOriginalOutput() { + String output = "line 1\r\nline 2"; + + assertEquals(output, TerminalCommandProcessor.truncateOutput(output)); + } + + @Test + void testTruncateOutput_longOutput_keepsTailLines() { + StringBuilder output = new StringBuilder(); + for (int lineIndex = 1; lineIndex <= 1005; lineIndex++) { + output.append("line ").append(lineIndex).append('\n'); + } + + String result = TerminalCommandProcessor.truncateOutput(output.toString()); + + assertTrue(result.startsWith("[Terminal output truncated: showing last 1000 lines.]\n")); + assertFalse(result.contains("line 5\n")); + assertTrue(result.contains("line 6\n")); + assertTrue(result.endsWith("line 1005\n")); + } + + @Test + void testPrepareOutputForModel_removesCopilotShellMarkers() { + String output = "start\n" + ShellIntegrationScripts.PROMPT_START_MARKER + + "]7775;B\nbody\n]7775;C\nliteral 7775;A remains\n" + + ShellIntegrationScripts.COMMAND_FINISH_MARKER_PREFIX + "1\u0007end"; + + String result = TerminalCommandProcessor.prepareOutputForModel(output); + + assertEquals("start\n\nbody\n\nliteral 7775;A remains\nend", result); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java new file mode 100644 index 00000000..b2e0df9b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetPartialRenderTest.java @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Verifies that {@link BaseTurnWidget#appendMessage} eagerly renders trailing partial lines via + * {@code renderPartialBuffer}, while deferring rendering for code-fence prefixes and inside code + * blocks where fence detection requires a complete line. + */ +@ExtendWith(MockitoExtension.class) +class BaseTurnWidgetPartialRenderTest { + + private static final String TURN_ID = "turn-1"; + + private Shell shell; + private MockedStatic<CopilotUi> copilotUiMock; + private CopilotUi mockPlugin; + + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private AvatarService mockAvatarService; + @Mock + private ChatFontService mockChatFontService; + + @BeforeEach + void setUp() { + lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService); + lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService); + lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null); + + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + copilotUiMock = mockStatic(CopilotUi.class); + mockPlugin = mock(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager); + }); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (copilotUiMock != null) { + copilotUiMock.close(); + copilotUiMock = null; + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void appendMessage_partialLineWithoutNewline_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("Hello world"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Partial text without newline should be rendered eagerly to a text block"); + assertTrue(viewer.getTextWidget().getText().contains("Hello world"), + "Expected partial text to be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialAfterCompleteLine_rendersBothCommittedAndPartial() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First chunk: a complete line plus a trailing partial fragment without a newline. + widget.appendMessage("First line\nSecond "); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Text block should be created"); + String rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "Committed line should be rendered, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second"), + "Trailing partial fragment should be rendered eagerly, got: '" + rendered + "'"); + + // Append more text completing the partial line — final render must show full content. + widget.appendMessage("line\n"); + rendered = viewer.getTextWidget().getText(); + assertTrue(rendered.contains("First line"), + "First line should still be present after appending more text, got: '" + rendered + "'"); + assertTrue(rendered.contains("Second line"), + "Completed second line should be rendered, got: '" + rendered + "'"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickFence_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A confirmed code-fence prefix — must wait for the newline before deciding whether + // to render markup or open a code block. Otherwise the literal backticks would flash + // into the markup viewer. + widget.appendMessage("```"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for a confirmed fence prefix"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsTripleBacktickWithLanguage_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("```java"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created while a fence-with-language is still being streamed"); + assertNull(getField(widget, "currentCodeBlock"), + "Code block must not be created until the fence newline is received"); + }); + } + + @Test + void appendMessage_partialIsSingleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A lone backtick could grow into ``` on the next chunk — defer rendering. + widget.appendMessage("`"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous single backtick"); + }); + } + + @Test + void appendMessage_partialIsDoubleBacktick_defersRendering() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage("``"); + + assertNull(getField(widget, "currentTextBlock"), + "Text block must not be created for an ambiguous double backtick"); + }); + } + + @Test + void appendMessage_partialBacktickFollowedByText_rendersImmediately() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // A single backtick followed by non-backtick content is inline code, not a fence — + // render eagerly so the user sees the streamed token immediately. + widget.appendMessage("`code"); + + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer, "Inline-code partial should be rendered eagerly"); + assertTrue(viewer.getTextWidget().getText().contains("code"), + "Inline-code content should be visible, got: '" + viewer.getTextWidget().getText() + "'"); + }); + } + + @Test + void appendMessage_partialResolvesIntoFence_doesNotLeaveStaleText() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First send some markdown so a text block exists. + widget.appendMessage("intro text\n"); + ChatMarkupViewer viewer = getMarkupViewer(widget); + assertNotNull(viewer); + assertTrue(viewer.getTextWidget().getText().contains("intro text")); + + // Then start streaming a fence — must NOT append "```" into the markup viewer. + widget.appendMessage("```"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence prefix must not leak into the markup viewer, got: '" + viewer.getTextWidget().getText() + "'"); + + // Complete the fence: the code block should open and the markup viewer should not gain + // the fence characters. + widget.appendMessage("java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block must open once the fence newline is received"); + assertTrue(!viewer.getTextWidget().getText().contains("```"), + "Fence characters must never appear in the markup viewer"); + }); + } + + @Test + void appendMessage_partialInsideCodeBlock_isNotRenderedAsMarkup() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Open a code block. + widget.appendMessage("```java\n"); + assertNotNull(getField(widget, "currentCodeBlock"), + "Code block should be open after the opening fence line"); + assertNull(getField(widget, "currentTextBlock"), + "Text block should not exist while we are inside a code block"); + + // Stream a partial code line without a newline — the partial must NOT be rendered + // to the markup viewer, since partial code text has to go through the source viewer + // once a complete line arrives. + widget.appendMessage("int x = 1;"); + + assertNull(getField(widget, "currentTextBlock"), + "Partial text inside a code block must not create a markup text block"); + }); + } + + @Test + void appendMessage_emptyString_doesNotRender() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendMessage(""); + + assertNull(getField(widget, "currentTextBlock"), + "Empty message must be a no-op and must not create a text block"); + StringBuilder buffer = (StringBuilder) getField(widget, "messageBuffer"); + assertEquals(0, buffer.length(), "Empty message must not accumulate into the buffer"); + }); + } + + private static ChatMarkupViewer getMarkupViewer(BaseTurnWidget widget) { + Object textBlock = getField(widget, "currentTextBlock"); + return textBlock instanceof ChatMarkupViewer markup ? markup : null; + } + + private static Object getField(Object target, String name) { + Class<?> cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("Field '" + name + "' not found on " + target.getClass()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetToolCallStatusTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetToolCallStatusTest.java new file mode 100644 index 00000000..af37dce6 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidgetToolCallStatusTest.java @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.google.gson.Gson; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Verifies that {@link BaseTurnWidget#appendToolCallStatus} renders text for + * tool calls that finish with status {@code error}. + */ +@ExtendWith(MockitoExtension.class) +class BaseTurnWidgetToolCallStatusTest { + + private static final String TURN_ID = "turn-1"; + private static final String TOOL_CALL_ID = "tool-call-1"; + private static final String TOOL_NAME = "edit_file"; + private static final String SUBAGENT_TOOL_NAME = "run_subagent"; + private static final String ERROR_MESSAGE = "file not found"; + private static final String PROGRESS_MESSAGE = "Editing file.txt"; + + private Shell shell; + private MockedStatic<CopilotUi> copilotUiMock; + private CopilotUi mockPlugin; + + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private AvatarService mockAvatarService; + @Mock + private ChatFontService mockChatFontService; + + @BeforeEach + void setUp() { + lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService); + lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService); + lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null); + + // Mockito's static mocks are scoped to the registering thread. The widgets under + // test execute on the SWT Display thread (via SwtUtils.invokeOnDisplayThread), so + // the mock must be registered there as well; otherwise CopilotUi.getPlugin() calls + // from the display thread would not see it. + SwtUtils.invokeOnDisplayThread(() -> { + shell = new Shell(Display.getDefault()); + copilotUiMock = mockStatic(CopilotUi.class); + mockPlugin = mock(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager); + }); + } + + @AfterEach + void tearDown() { + SwtUtils.invokeOnDisplayThread(() -> { + if (copilotUiMock != null) { + copilotUiMock.close(); + copilotUiMock = null; + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void appendToolCallStatus_errorWithMessage_rendersErrorText() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", PROGRESS_MESSAGE, ERROR_MESSAGE)); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text, "Status label text must be created when an error event is delivered"); + assertTrue(text.getText().contains(ERROR_MESSAGE), + "Expected error message to be rendered next to the error icon, got: '" + text.getText() + "'"); + assertTrue(text.getText().contains(TOOL_NAME), + "Expected error text to be prefixed with the tool name, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_errorWithoutProgressMessage_isStillProcessed() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // No progressMessage (e.g., tool failed before the running event was dispatched server-side). + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", null, ERROR_MESSAGE)); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text, "Error event without progressMessage must still be processed"); + assertTrue(text.getText().contains(ERROR_MESSAGE), + "Expected error message to be rendered, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_runningThenError_replacesProgressTextWithErrorText() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // First the running event sets the progress text. + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "running", PROGRESS_MESSAGE, null)); + StyledText runningText = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(runningText, "Running event should have populated text"); + assertTrue(runningText.getText().contains(PROGRESS_MESSAGE), + "Running text should be visible before failure, got: '" + runningText.getText() + "'"); + + // Then the same tool call fails: the error text must overwrite the stale running text. + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", PROGRESS_MESSAGE, ERROR_MESSAGE)); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text); + assertTrue(text.getText().contains(ERROR_MESSAGE), + "Error text should replace stale running text, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_errorFallsBackToProgressMessage_whenErrorIsBlank() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Whitespace-only error must fall back to progressMessage rather than rendering empty text. + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", PROGRESS_MESSAGE, " ")); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text, "Status label text must be created on error even when only progressMessage is present"); + assertTrue(text.getText().contains(PROGRESS_MESSAGE), + "Expected progressMessage to be used as fallback, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_errorFallsBackToProgressMessage_whenErrorIsNull() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", PROGRESS_MESSAGE, null)); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text); + assertTrue(text.getText().contains(PROGRESS_MESSAGE), + "Expected progressMessage to be used as fallback, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_errorWithBothFieldsBlank_rendersGenericFallbackWithToolName() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "error", null, null)); + + StyledText text = getStatusLabelText(widget, TOOL_CALL_ID); + assertNotNull(text, "Error event with no fields must still surface a generic message"); + assertTrue(text.getText().contains(TOOL_NAME), + "Generic fallback should still tell the user which tool failed, got: '" + text.getText() + "'"); + }); + } + + @Test + void appendToolCallStatus_nonErrorStatusWithOnlyError_isIgnored() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Non-error events with only `error` populated must be dropped; otherwise + // setRunningStatus/setCompletedStatus/setText would call setMarkup(null). + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "running", null, ERROR_MESSAGE)); + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "completed", " ", ERROR_MESSAGE)); + widget.appendToolCallStatus(buildToolCall(TOOL_NAME, "cancelled", null, ERROR_MESSAGE)); + + @SuppressWarnings("unchecked") + Map<String, AgentStatusLabel> labels = (Map<String, AgentStatusLabel>) getField(widget, "statusLabels"); + assertTrue(labels.isEmpty(), + "Non-error events without progressMessage should be ignored, got: " + labels.keySet()); + }); + } + + /** + * Regression: a {@code run_subagent} terminal event with no progressMessage must still + * clear the subagent block state. Otherwise subsequent messages get routed to a ghost + * {@code currentSubagentBlock} (see review comment on PR #145). + */ + @Test + void appendToolCallStatus_subagentTerminalEventWithBlankMessage_clearsSubagentState() { + SwtUtils.invokeOnDisplayThread(() -> { + CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID); + + // Open a subagent block. + widget.appendToolCallStatus(buildToolCall(SUBAGENT_TOOL_NAME, "running", null, null)); + assertTrue((boolean) getField(widget, "inSubagentBlock"), + "Subagent block should be active after a 'running' event"); + assertNotNull(getField(widget, "currentSubagentBlock"), + "currentSubagentBlock should be populated after a 'running' event"); + + // Terminal event with no display message — the early-return guard for blank + // progressMessage must NOT short-circuit subagent state cleanup. + widget.appendToolCallStatus(buildToolCall(SUBAGENT_TOOL_NAME, "completed", null, null)); + + assertFalse((boolean) getField(widget, "inSubagentBlock"), + "inSubagentBlock should be cleared after a terminal subagent event"); + assertNull(getField(widget, "currentSubagentBlock"), + "currentSubagentBlock should be cleared after a terminal subagent event"); + }); + } + + private static AgentToolCall buildToolCall(String name, String status, String progressMessage, String error) { + Gson gson = new Gson(); + Map<String, Object> fields = new HashMap<>(); + fields.put("id", TOOL_CALL_ID); + fields.put("name", name); + fields.put("status", status); + if (progressMessage != null) { + fields.put("progressMessage", progressMessage); + } + if (error != null) { + fields.put("error", error); + } + return gson.fromJson(gson.toJson(fields), AgentToolCall.class); + } + + private static StyledText getStatusLabelText(BaseTurnWidget widget, String toolCallId) { + @SuppressWarnings("unchecked") + Map<String, AgentStatusLabel> labels = (Map<String, AgentStatusLabel>) getField(widget, "statusLabels"); + AgentStatusLabel label = labels.get(toolCallId); + assertNotNull(label, "AgentStatusLabel for tool call '" + toolCallId + "' should have been created"); + Object markupViewer = getField(label, "textLabel"); + if (markupViewer == null) { + return null; + } + try { + return (StyledText) markupViewer.getClass().getMethod("getTextWidget").invoke(markupViewer); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to obtain StyledText from textLabel", e); + } + } + + private static Object getField(Object target, String name) { + Class<?> cls = target.getClass(); + while (cls != null) { + try { + Field f = cls.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("Field '" + name + "' not found on " + target.getClass()); + } +} + diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java index b181bd9a..16595f17 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewerTest.java @@ -9,14 +9,17 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -104,6 +107,118 @@ private Map<String, BaseTurnWidget> getTurnsMap(ChatContentViewer viewer) { return (Map<String, BaseTurnWidget>) getFieldValue(viewer, "turns"); } + @Test + void testClampOffset_clampsToScrollableRange() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + int maxOffset = invokeIntMethod(viewer, "maxOffset"); + + Assertions.assertTrue(maxOffset > 0, "content taller than the viewport should be scrollable"); + Assertions.assertEquals(0, invokeIntMethod(viewer, "clampOffset", -50), + "negative offset should clamp to 0"); + Assertions.assertEquals(maxOffset, invokeIntMethod(viewer, "clampOffset", 5000), + "offset past the end should clamp to maxOffset"); + Assertions.assertEquals(200, invokeIntMethod(viewer, "clampOffset", 200), + "in-range offset should be left unchanged"); + }); + } + + @Test + void testMaxOffset_isZeroWhenContentFitsViewport() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 200); + + Assertions.assertEquals(0, invokeIntMethod(viewer, "maxOffset"), + "content shorter than the viewport should not be scrollable"); + }); + } + + @Test + void testUpdateScrollBar_rangeMatchesTotalHeight() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + setFieldValue(viewer, "scrollOffset", 120); + + invokeVoidMethod(viewer, "updateScrollBar", 300); + + ScrollBar bar = viewer.getVerticalBar(); + Assertions.assertTrue(bar.getEnabled(), "scrollbar should be enabled when content overflows"); + Assertions.assertEquals(1000, bar.getMaximum(), "scrollbar maximum should equal totalHeight"); + Assertions.assertEquals(300, bar.getThumb(), "scrollbar thumb should equal the viewport height"); + Assertions.assertEquals(120, bar.getSelection(), "scrollbar selection should equal scrollOffset"); + }); + } + + @Test + void testUpdateScrollBar_disabledWhenContentFits() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 200); + + invokeVoidMethod(viewer, "updateScrollBar", 300); + + Assertions.assertFalse(viewer.getVerticalBar().getEnabled(), + "scrollbar should be disabled when all content fits"); + }); + } + + @Test + void testIsViewportAtBottom_togglesWhenLeavingAndReturning() { + SwtUtils.invokeOnDisplayThread(() -> { + sizeViewer(400, 300); + setFieldValue(viewer, "totalHeight", 1000); + int maxOffset = invokeIntMethod(viewer, "maxOffset"); + + setFieldValue(viewer, "scrollOffset", maxOffset); + Assertions.assertTrue(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "pinned to the very bottom should report at-bottom"); + + setFieldValue(viewer, "scrollOffset", 0); + Assertions.assertFalse(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "scrolled up to the top should report not-at-bottom"); + + // Just inside the bottom threshold (SCROLL_THRESHOLD = 100) should count as at-bottom again. + setFieldValue(viewer, "scrollOffset", maxOffset - 50); + Assertions.assertTrue(invokeBooleanMethod(viewer, "isViewportAtBottom"), + "back within the bottom threshold should report at-bottom again"); + }); + } + + /** Gives the viewer (and its content child) a deterministic client area for scroll-model math. */ + private void sizeViewer(int width, int height) { + viewer.setSize(width, height); + viewer.layout(true, true); + } + + private int invokeIntMethod(Object target, String name, int arg) { + return (int) invokeMethod(target, name, new Class<?>[] {int.class}, arg); + } + + private int invokeIntMethod(Object target, String name) { + return (int) invokeMethod(target, name, new Class<?>[] {}); + } + + private boolean invokeBooleanMethod(Object target, String name) { + return (boolean) invokeMethod(target, name, new Class<?>[] {}); + } + + private void invokeVoidMethod(Object target, String name, int arg) { + invokeMethod(target, name, new Class<?>[] {int.class}, arg); + } + + private Object invokeMethod(Object target, String name, Class<?>[] paramTypes, Object... args) { + try { + Method method = target.getClass().getDeclaredMethod(name, paramTypes); + method.setAccessible(true); + return method.invoke(target, args); + } catch (Exception e) { + throw new RuntimeException("Failed to invoke method " + name, e); + } + } + private Object getFieldValue(Object target, String fieldName) { try { Field field = target.getClass().getDeclaredField(fieldName); diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlockParseTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlockParseTest.java new file mode 100644 index 00000000..60ae08fb --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlockParseTest.java @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the private static parsing helpers in {@link ThinkingBlock}: {@code stripTrailingNewlines} and + * {@code parseSections}. Exercises the helpers via reflection so the production visibility stays untouched. + * + * <p>Primary goal: guard CRLF handling so a {@code \r\n}-terminated body or title boundary does not leak a stray + * {@code \r} into the rendered text. + */ +class ThinkingBlockParseTest { + + @Test + void stripTrailingNewlines_stripsLfCrAndCrlf() throws Exception { + assertEquals("body", invokeStripTrailingNewlines("body\n")); + assertEquals("body", invokeStripTrailingNewlines("body\r")); + assertEquals("body", invokeStripTrailingNewlines("body\r\n")); + assertEquals("body", invokeStripTrailingNewlines("body\r\n\r\n")); + assertEquals("body", invokeStripTrailingNewlines("body\n\r\n")); + assertEquals("body", invokeStripTrailingNewlines("body")); + assertEquals("", invokeStripTrailingNewlines("")); + assertEquals("", invokeStripTrailingNewlines("\r\n\r\n")); + // Internal newlines and leading whitespace must survive untouched. + assertEquals(" code\n more", invokeStripTrailingNewlines(" code\n more\r\n")); + } + + @Test + void parseSections_handlesCrlfTitleBoundary() throws Exception { + String raw = "intro body\r\n**Plan**\r\nplan body\r\n**Next**\r\nnext body\r\n"; + List<?> sections = invokeParseSections(raw); + assertEquals(3, sections.size()); + + assertNull(title(sections.get(0))); + assertEquals("intro body", body(sections.get(0))); + + assertEquals("Plan", title(sections.get(1))); + assertEquals("plan body", body(sections.get(1))); + + assertEquals("Next", title(sections.get(2))); + assertEquals("next body", body(sections.get(2))); + } + + @Test + void parseSections_handlesLfOnlyTitleBoundary() throws Exception { + // Existing LF behavior must remain unchanged. + String raw = "intro\n**Plan**\nplan body"; + List<?> sections = invokeParseSections(raw); + assertEquals(2, sections.size()); + assertNull(title(sections.get(0))); + assertEquals("intro", body(sections.get(0))); + assertEquals("Plan", title(sections.get(1))); + assertEquals("plan body", body(sections.get(1))); + } + + @Test + void parseSections_inlineBoldNotTreatedAsTitle() throws Exception { + // Inline bold at end of string should NOT be parsed as a section title. + String raw = "combined with **The Ship of Theseus**"; + List<?> sections = invokeParseSections(raw); + assertEquals(1, sections.size()); + assertNull(title(sections.get(0))); + assertEquals("combined with **The Ship of Theseus**", body(sections.get(0))); + } + + @Test + void parseSections_inlineBoldFollowedByText() throws Exception { + // Inline bold mid-line should remain as body text. + String raw = "text with **bold** and more text"; + List<?> sections = invokeParseSections(raw); + assertEquals(1, sections.size()); + assertNull(title(sections.get(0))); + assertEquals("text with **bold** and more text", body(sections.get(0))); + } + + @Test + void parseSections_mixOfInlineBoldAndStandaloneTitle() throws Exception { + // Inline bold on one line, standalone title on next line. + String raw = "text with **inline bold** here\n**Standalone Title**\nbody after title"; + List<?> sections = invokeParseSections(raw); + assertEquals(2, sections.size()); + assertNull(title(sections.get(0))); + assertEquals("text with **inline bold** here", body(sections.get(0))); + assertEquals("Standalone Title", title(sections.get(1))); + assertEquals("body after title", body(sections.get(1))); + } + + @Test + void parseSections_titleAtStartOfText() throws Exception { + String raw = "**First**\nbody one\n**Second**\nbody two"; + List<?> sections = invokeParseSections(raw); + assertEquals(2, sections.size()); + assertEquals("First", title(sections.get(0))); + assertEquals("body one", body(sections.get(0))); + assertEquals("Second", title(sections.get(1))); + assertEquals("body two", body(sections.get(1))); + } + + @Test + void parseSections_adjacentTitles_withNoBodyBetween() throws Exception { + // Two titles back-to-back with no body text between them must not throw + // StringIndexOutOfBoundsException (regression test for cursor > matcher.start() case). + String raw = "**Title1**\n**Title2**\nbody after both"; + List<?> sections = invokeParseSections(raw); + assertEquals(2, sections.size()); + assertEquals("Title1", title(sections.get(0))); + assertEquals("", body(sections.get(0))); + assertEquals("Title2", title(sections.get(1))); + assertEquals("body after both", body(sections.get(1))); + } + + @Test + void parseSections_adjacentTitles_crlf() throws Exception { + // Same scenario with CRLF line endings. + String raw = "**Title1**\r\n**Title2**\r\nbody"; + List<?> sections = invokeParseSections(raw); + assertEquals(2, sections.size()); + assertEquals("Title1", title(sections.get(0))); + assertEquals("", body(sections.get(0))); + assertEquals("Title2", title(sections.get(1))); + assertEquals("body", body(sections.get(1))); + } + + private static String invokeStripTrailingNewlines(String input) throws Exception { + Method m = ThinkingBlock.class.getDeclaredMethod("stripTrailingNewlines", String.class); + m.setAccessible(true); + return (String) m.invoke(null, input); + } + + private static List<?> invokeParseSections(String raw) throws Exception { + Method m = ThinkingBlock.class.getDeclaredMethod("parseSections", String.class); + m.setAccessible(true); + return (List<?>) m.invoke(null, raw); + } + + private static String title(Object parsedSection) throws Exception { + Method m = parsedSection.getClass().getDeclaredMethod("title"); + m.setAccessible(true); + return (String) m.invoke(parsedSection); + } + + private static String body(Object parsedSection) throws Exception { + Method m = parsedSection.getClass().getDeclaredMethod("body"); + m.setAccessible(true); + return (String) m.invoke(parsedSection); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java index d44508a3..3f5a288c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBarTest.java @@ -38,6 +38,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -103,7 +104,7 @@ private void setupMocks() { void testNoScrollForFewFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(3, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -122,7 +123,7 @@ void testNoScrollForFewFiles() { void testNoScrollForExactlyMaxFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(5, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -141,7 +142,7 @@ void testNoScrollForExactlyMaxFiles() { void testScrollCreatedForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(10, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -164,7 +165,7 @@ void testScrollCreatedForManyFiles() { void testScrollHeightHintForManyFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(8, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -190,7 +191,7 @@ void testAllFileRowsRenderedWithScroll() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); int fileCount = 7; - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(fileCount, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(fileCount); workingSetBar.buildSummaryBarFor(filesMap); @@ -215,7 +216,7 @@ void testAllFileRowsRenderedWithScroll() { void testContentAreaSetInScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(8, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(8); workingSetBar.buildSummaryBarFor(filesMap); @@ -242,7 +243,7 @@ void testContentAreaSetInScrolledComposite() { void testMinHeightSetForScrolledComposite() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(10, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(filesMap); @@ -266,7 +267,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // First build with few files (no scroll) - Map<IFile, FileChangeProperty> fewFiles = createMockFilesMap(3, false); + Map<ChangedFile, FileChangeProperty> fewFiles = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(fewFiles); Object changedFiles1 = getFieldValue(workingSetBar, "changedFiles"); @@ -275,7 +276,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { assertNull(scroll1, "No scroll should exist for 3 files"); // Rebuild with many files (should have scroll) - Map<IFile, FileChangeProperty> manyFiles = createMockFilesMap(10, false); + Map<ChangedFile, FileChangeProperty> manyFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(manyFiles); Object changedFiles2 = getFieldValue(workingSetBar, "changedFiles"); @@ -294,7 +295,7 @@ void testRebuildSummaryBarChangesScrollBehavior() { void testExpandIconImageWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(3, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -322,7 +323,7 @@ void testExpandIconImageWhenExpanded() { void testExpandIconImageWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(3, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -354,7 +355,7 @@ void testExpandIconImageWhenCollapsed() { void testTooltipTextWhenExpanded() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(3, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(3); workingSetBar.buildSummaryBarFor(filesMap); @@ -395,7 +396,7 @@ void testTooltipTextWhenExpanded() { void testTooltipTextWhenCollapsed() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(5, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(5); workingSetBar.buildSummaryBarFor(filesMap); @@ -436,7 +437,7 @@ void testTooltipTextWhenCollapsed() { void testTooltipAndImageToggleBehavior() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> filesMap = createMockFilesMap(4, false); + Map<ChangedFile, FileChangeProperty> filesMap = createMockFilesMap(4); workingSetBar.buildSummaryBarFor(filesMap); @@ -476,7 +477,7 @@ void testTooltipContainsCorrectFileCount() { workingSetBar = new WorkingSetBar(parent, SWT.NONE); // Test with 1 file - Map<IFile, FileChangeProperty> oneFile = createMockFilesMap(1, false); + Map<ChangedFile, FileChangeProperty> oneFile = createMockFilesMap(1); workingSetBar.buildSummaryBarFor(oneFile); Object titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -488,7 +489,7 @@ void testTooltipContainsCorrectFileCount() { "Tooltip should contain 'file' (singular)"); // Test with 10 files - Map<IFile, FileChangeProperty> tenFiles = createMockFilesMap(10, false); + Map<ChangedFile, FileChangeProperty> tenFiles = createMockFilesMap(10); workingSetBar.buildSummaryBarFor(tenFiles); titleBar = getFieldValue(workingSetBar, "titleBar"); @@ -508,7 +509,7 @@ void testTooltipContainsCorrectFileCount() { void testEmptyFilesMapDoesNotCreateChangedFiles() { SwtUtils.invokeOnDisplayThread(() -> { workingSetBar = new WorkingSetBar(parent, SWT.NONE); - Map<IFile, FileChangeProperty> emptyMap = new LinkedHashMap<>(); + Map<ChangedFile, FileChangeProperty> emptyMap = new LinkedHashMap<>(); workingSetBar.buildSummaryBarFor(emptyMap); @@ -524,11 +525,11 @@ void testEmptyFilesMapDoesNotCreateChangedFiles() { /** * Creates a map of mock files with the specified count. */ - private Map<IFile, FileChangeProperty> createMockFilesMap(int count, boolean isHandled) { - Map<IFile, FileChangeProperty> filesMap = new LinkedHashMap<>(); + private Map<ChangedFile, FileChangeProperty> createMockFilesMap(int count) { + Map<ChangedFile, FileChangeProperty> filesMap = new LinkedHashMap<>(); for (int i = 0; i < count; i++) { IFile mockFile = createMockFile("TestFile" + i + ".java"); - filesMap.put(mockFile, new FileChangeProperty(FileChangeType.Created)); + filesMap.put(ChangedFile.workspace(mockFile), new FileChangeProperty(FileChangeType.Created)); } return filesMap; } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java new file mode 100644 index 00000000..ba60a62e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandlerTests.java @@ -0,0 +1,827 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.SensitiveFileData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileOperationConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private AttachedFileRegistry attachedFileRegistry; + private FileOperationConfirmationHandler handler; + + @TempDir + private Path customizationBase; + + @BeforeEach + void setUp() { + attachedFileRegistry = new AttachedFileRegistry(); + handler = new FileOperationConfirmationHandler( + preferenceStore, attachedFileRegistry); + } + + // --- glob matching behavior (tested via evaluate + rules) --- + + @Test + void evaluate_globExactPathMatchCaseInsensitive() { + // Rule uses forward slash + lowercase; evaluate uses backslash + uppercase + stubRules(List.of( + new FileOperationAutoApproveRule("C:/Users/test.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\Users\\test.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globStarStarPatternMatches() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globPatternNoMatch() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + assertFalse(evaluate( + buildParams("/workspace/src/Main.java", false), CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_globBackslashPathNormalized() { + // Rule uses forward slashes; file path uses backslashes + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", "", true))); + stubUnmatched(false); + + assertTrue(evaluate( + buildParams("C:\\project\\.github\\instructions\\file.md", false), + CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_invalidGlobRuleFallsThrough() { + // Invalid glob should not match; falls through to unmatched setting + stubRules(List.of( + new FileOperationAutoApproveRule("[invalid", "", true))); + stubUnmatched(true); + + assertTrue(evaluate( + buildParams("/a/b.java", false), CONV_ID).isAutoApproved()); + } + + // --- evaluate: attached files --- + + @Test + void evaluate_autoApprovedWhenFileAttachedViaPending() { + attachedFileRegistry.addPending(List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenFileAttachedToConversation() { + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_attachedFilePathNormalized() { + // Attached with backslashes + uppercase + attachedFileRegistry.addPending( + List.of("C:\\Workspace\\Src\\Main.java")); + + // Evaluate with forward slashes + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: session overrides --- + + @Test + void evaluate_autoApprovedBySessionFileApproval() { + // Cache a file-level session approval + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + handler.cacheDecision(action, params, CONV_ID); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFileApprovalNormalizesPath() { + // Cache with backslash + uppercase + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "C:\\Workspace\\Main.java")); + handler.cacheDecision(action, + buildParams("C:\\Workspace\\Main.java", false), CONV_ID); + + // Evaluate with forward slash + lowercase + InvokeClientToolConfirmationParams params = + buildParams("c:/workspace/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_autoApprovedBySessionFolderApproval() { + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external/data.csv", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionFolderDoesNotMatchParentPath() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/home/user/external")); + handler.cacheDecision(action, + buildParams("/home/user/external/file.txt", true), CONV_ID); + + // File in a different folder (prefix but not under the folder) + InvokeClientToolConfirmationParams params = + buildParams("/home/user/external-other/file.txt", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_sessionApprovalDoesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, "other-conv").isAutoApproved()); + } + + // --- evaluate: outside workspace --- + + @Test + void evaluate_outsideWorkspaceAlwaysRequiresConfirmation() { + InvokeClientToolConfirmationParams params = + buildParams("/tmp/secret.txt", true); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_outsideWorkspaceStillAutoApprovedBySessionFolder() { + // Session folder approval overrides the outside-workspace check + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/tmp")); + handler.cacheDecision(action, + buildParams("/tmp/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/other.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: rule matching --- + + @Test + void evaluate_autoApprovedByAllowRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationByDenyRule() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/.github/**/*", "", false))); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/.github/instructions/rules.md", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_firstMatchingRuleWins() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", false), + new FileOperationAutoApproveRule("**/*", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + // The first rule (deny .java) should win + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: unmatched fallback --- + + @Test + void evaluate_unmatchedAutoApprovedWhenCheckboxTrue() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_unmatchedNeedsConfirmationWhenCheckboxFalse() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.py", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: blank file path --- + + @Test + void evaluate_blankFilePathNeedsConfirmation() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(null, false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate: file path extraction --- + + @Test + void evaluate_extractsFilePathFromSensitiveFileData() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // Path set via sensitiveFileData (toolMetadata), not input map + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsFilePathFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + // No toolMetadata, only input map with "filePath" + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map<String, Object> input = new HashMap<>(); + input.put("filePath", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_extractsPathKeyFromInputMapFallback() { + stubRules(List.of( + new FileOperationAutoApproveRule("**/*.java", "", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map<String, Object> input = new HashMap<>(); + input.put("path", "/workspace/src/Main.java"); + input.put("toolType", "file_write"); + params.setInput(input); + + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- cacheDecision: global rule --- + + @Test + void cacheDecision_globalAddsRuleToPreferenceStore() { + stubRules(List.of()); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + // The handler should have called setValue on the preference store. + // We verify by loading rules from the same store (which requires + // the mock to return the updated value). Instead, verify the + // store was called with the right key. + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_globalUpdatesExistingRuleCaseInsensitive() { + // Start with a deny rule + stubRules(List.of( + new FileOperationAutoApproveRule( + "C:/workspace/Main.java", "", false))); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "c:/workspace/Main.java")); + + handler.cacheDecision(action, + buildParams("c:/workspace/Main.java", false), CONV_ID); + + // Verify setValue was called (updated existing rule to autoApprove) + org.mockito.Mockito.verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_FILE_OP_RULES), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void cacheDecision_ignoresUnknownAction() { + Map<String, String> meta = Map.of( + ConfirmationAction.META_ACTION, "UNKNOWN_ACTION"); + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + @Test + void cacheDecision_ignoresNullActionMetadata() { + ConfirmationAction action = new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, Map.of(), false); + + // Should not throw + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + } + + // --- clearSession --- + + @Test + void clearSession_removesFileAndFolderApprovals() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction fileAction = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(fileAction, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + handler.clearSession("other-conv"); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void clearSession_clearsAttachedFileRegistry() { + stubRules(List.of()); + stubUnmatched(false); + + attachedFileRegistry.addAttachedFiles(CONV_ID, + List.of("/workspace/src/Main.java")); + + handler.clearSession(CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- buildContent: in-workspace actions --- + + @Test + void buildContent_inWorkspaceHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List<ConfirmationAction> actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + assertEquals(ConfirmationActionScope.ONCE, first.getScope()); + } + + @Test + void buildContent_inWorkspaceHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_inWorkspaceHasFileSessionAndGlobalActions() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasFileSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertTrue(hasFileSession); + assertTrue(hasFileGlobal); + } + + @Test + void buildContent_inWorkspaceNoFolderAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertFalse(hasFolderSession); + } + + // --- buildContent: outside-workspace actions --- + + @Test + void buildContent_outsideWorkspaceHasFolderSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasFolderSession = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION)); + assertTrue(hasFolderSession); + } + + @Test + void buildContent_outsideWorkspaceNoFileGlobalAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/tmp/data/file.txt", true); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasFileGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)); + assertFalse(hasFileGlobal); + } + + // --- buildContent: action scopes --- + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + FileOperationConfirmationHandler.Action.ACCEPT_FILE_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + // --- evaluate priority order --- + + @Test + void evaluate_priorityOrder_attachedFileBeatsGlobalDenyRule() { + // Attached file auto-approves even when a deny rule would otherwise apply + attachedFileRegistry.addPending( + List.of("/workspace/src/Main.java")); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionApprovalBeatsGlobalDenyRule() { + // Session-level approval auto-approves even when a deny rule would otherwise apply + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FILE_SESSION, + Map.of(FileOperationConfirmationHandler.META_FILE_PATH, + "/workspace/src/Main.java")); + handler.cacheDecision(action, + buildParams("/workspace/src/Main.java", false), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/workspace/src/Main.java", false); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void evaluate_priorityOrder_sessionFolderBeatsOutsideWorkspace() { + // Session folder approval auto-approves even for outside-workspace files + ConfirmationAction action = buildAction( + FileOperationConfirmationHandler.Action.ACCEPT_FOLDER_SESSION, + Map.of(FileOperationConfirmationHandler.META_FOLDER_PATH, + "/external/dir")); + handler.cacheDecision(action, + buildParams("/external/dir/file.txt", true), CONV_ID); + + InvokeClientToolConfirmationParams params = + buildParams("/external/dir/another.txt", true); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- customization file read recognition (isCustomizationRead) --- + + private Set<Path> customizationFiles() { + return Set.of( + customizationBase.resolve(".github/prompts/example.prompt.md"), + customizationBase.resolve(".github/instructions/coding.instructions.md"), + customizationBase.resolve(".github/agents/my.agent.md")); + } + + private Set<Path> skillFolders() { + return Set.of(customizationBase.resolve(".github/skills/demo")); + } + + @Test + void isCustomizationRead_promptFileExactMatch_isTrue() { + Path prompt = customizationBase.resolve(".github/prompts/example.prompt.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + prompt, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_instructionFileExactMatch_isTrue() { + Path instruction = customizationBase.resolve(".github/instructions/coding.instructions.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + instruction, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_agentFileExactMatch_isTrue() { + Path agent = customizationBase.resolve(".github/agents/my.agent.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + agent, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_skillMarkdownInsideFolder_isTrue() { + Path skill = customizationBase.resolve(".github/skills/demo/SKILL.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + skill, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_skillHelperFileInsideFolder_isTrue() { + Path helper = customizationBase.resolve(".github/skills/demo/notes.md"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + helper, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_nestedSkillHelperFile_isTrue() { + Path helper = customizationBase.resolve(".github/skills/demo/scripts/run.py"); + assertTrue(FileOperationConfirmationHandler.isCustomizationRead( + helper, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_unrelatedFile_isFalse() { + Path other = customizationBase.resolve("src/Main.java"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + other, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_promptOutsideReportedSet_isFalse() { + // A *.prompt.md the language server did not report must not be auto-approved. + Path loose = customizationBase.resolve("docs/other.prompt.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + loose, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_fileInUnreportedSkillFolder_isFalse() { + Path other = customizationBase.resolve(".github/skills/other/SKILL.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + other, customizationFiles(), skillFolders())); + } + + @Test + void isCustomizationRead_emptyInputs_isFalse() { + Path prompt = customizationBase.resolve(".github/prompts/example.prompt.md"); + assertFalse(FileOperationConfirmationHandler.isCustomizationRead( + prompt, Set.of(), Set.of())); + } + + // --- Helpers --- + + private void stubRules(List<FileOperationAutoApproveRule> rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String filePath, boolean isGlobal) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + + if (filePath != null) { + SensitiveFileData sfd = new SensitiveFileData(); + sfd.setFilePath(filePath); + sfd.setGlobal(isGlobal); + + ToolMetadata meta = new ToolMetadata(); + meta.setSensitiveFileData(sfd); + params.setToolMetadata(meta); + } + + Map<String, Object> input = new HashMap<>(); + input.put("toolType", "file_write"); + if (filePath != null) { + input.put("filePath", filePath); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + FileOperationConfirmationHandler.Action actionType, + Map<String, String> extra) { + Map<String, String> meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + FileOperationConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java new file mode 100644 index 00000000..4380439b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandlerTests.java @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class McpConfirmationHandlerTests { + + private static final String CONV_ID = "conv-mcp-1"; + private static final String SERVER = "myServer"; + private static final String TOOL = "myTool"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private McpConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new McpConfirmationHandler(preferenceStore); + stubGlobalServers(List.of()); + stubGlobalTools(List.of()); + stubTrustAnnotations(false); + } + + // --- evaluate: global server list --- + + @Test + void evaluate_autoApprovedWhenServerInGlobalList() { + stubGlobalServers(List.of(SERVER)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerInGlobalListCaseInsensitive() { + stubGlobalServers(List.of(SERVER.toUpperCase())); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenServerNotInGlobalList() { + stubGlobalServers(List.of("otherServer")); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: global tool list --- + + @Test + void evaluate_autoApprovedWhenToolInGlobalList() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenToolInGlobalListCaseInsensitive() { + String toolKey = SERVER.toUpperCase() + "::" + TOOL.toUpperCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER.toLowerCase(), TOOL.toLowerCase()), CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenOnlyOtherToolInGlobalList() { + String otherKey = SERVER.toLowerCase() + "::otherTool"; + stubGlobalTools(List.of(otherKey)); + + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: trust annotations --- + + @Test + void evaluate_autoApprovedWhenReadOnlyAndTrustAnnotationsEnabled() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButOpenWorldHint() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(true); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenAnnotationsTrustedButNotReadOnly() { + stubTrustAnnotations(true); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(false); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenReadOnlyButTrustAnnotationsDisabled() { + stubTrustAnnotations(false); + ToolAnnotations annotations = new ToolAnnotations(); + annotations.setReadOnlyHint(true); + annotations.setOpenWorldHint(false); + + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + params.setAnnotations(annotations); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + // --- evaluate: session approvals --- + + @Test + void evaluate_autoApprovedWhenToolApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_notAutoApprovedWhenToolApprovedForDifferentSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, "other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenServerApprovedForSession() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- cacheDecision: global persistence --- + + @Test + void cacheDecision_acceptToolGlobal_writesToPreferenceStore() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq(Constants.AUTO_APPROVE_MCP_TOOLS), + captor.capture()); + assertTrue(captor.getValue().contains(toolKey)); + } + + @Test + void cacheDecision_acceptServerGlobal_writesToPreferenceStore() { + stubGlobalServers(List.of()); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL, + Map.of(McpConfirmationHandler.META_SERVER_NAME, SERVER)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); + verify(preferenceStore).setValue( + org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_SERVERS), + captor.capture()); + assertTrue(captor.getValue().contains(SERVER)); + } + + @Test + void cacheDecision_acceptToolGlobal_noDuplicateWrite() { + String toolKey = SERVER.toLowerCase() + "::" + TOOL.toLowerCase(); + stubGlobalTools(List.of(toolKey)); + + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL, + Map.of(McpConfirmationHandler.META_TOOL_KEY, toolKey)); + handler.cacheDecision(action, buildParams(SERVER, TOOL), CONV_ID); + + // setValue should NOT be called — key already present + verify(preferenceStore, org.mockito.Mockito.never()) + .setValue(org.mockito.ArgumentMatchers.eq( + Constants.AUTO_APPROVE_MCP_TOOLS), + org.mockito.ArgumentMatchers.anyString()); + } + + // --- clearSession --- + + @Test + void clearSession_removesSessionApprovalsForConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + InvokeClientToolConfirmationParams params = buildParams(SERVER, TOOL); + ConfirmationAction action = buildAction( + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION, + Map.of(McpConfirmationHandler.META_TOOL_KEY, + SERVER.toLowerCase() + "::" + TOOL.toLowerCase())); + handler.cacheDecision(action, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent (actions) --- + + @Test + void buildContent_hasAllowOnceAsFirstAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + assertTrue(actions.get(0).isPrimary()); + assertTrue(actions.get(0).isAccept()); + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + } + + @Test + void buildContent_hasSkipAsLastAction() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + assertFalse(actions.get(actions.size() - 1).isAccept()); + } + + @Test + void buildContent_hasAllFourScopedActions() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + assertTrue(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)); + } + + @Test + void buildContent_toolAndServerActionsHaveCorrectScopes() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)) + .forEach(a -> assertEquals( + ConfirmationActionScope.SESSION, a.getScope())); + actions.stream() + .filter(a -> hasAction(a, + McpConfirmationHandler.Action.ACCEPT_TOOL_GLOBAL) + || hasAction(a, + McpConfirmationHandler.Action.ACCEPT_SERVER_GLOBAL)) + .forEach(a -> assertEquals( + ConfirmationActionScope.GLOBAL, a.getScope())); + } + + @Test + void buildContent_contentHasTitleWithToolAndServer() { + ConfirmationResult result = evaluate( + buildParams(SERVER, TOOL), CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + assertNotNull(content.getTitle()); + assertTrue(content.getTitle().contains(TOOL)); + assertTrue(content.getTitle().contains(SERVER)); + } + + @Test + void buildContent_noActionsWhenServerAndToolNull() { + InvokeClientToolConfirmationParams params = + buildParams(null, null); + + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List<ConfirmationAction> actions = result.getContent().getActions(); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_TOOL_SESSION)); + assertFalse(hasAction(actions, + McpConfirmationHandler.Action.ACCEPT_SERVER_SESSION)); + } + + // --- Helpers --- + + private void stubGlobalServers(List<String> servers) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_SERVERS)) + .thenReturn(GSON.toJson(servers)); + } + + private void stubGlobalTools(List<String> tools) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_MCP_TOOLS)) + .thenReturn(GSON.toJson(tools)); + } + + private void stubTrustAnnotations(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) + .thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String serverName, String toolName) { + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + Map<String, Object> input = new java.util.HashMap<>(); + input.put("toolType", "mcp_tool"); + if (serverName != null) { + input.put("mcpServerName", serverName); + } + if (toolName != null) { + input.put("mcpToolName", toolName); + } + params.setInput(input); + return params; + } + + private static ConfirmationAction buildAction( + McpConfirmationHandler.Action type, Map<String, String> extra) { + Map<String, String> meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasAction(List<ConfirmationAction> actions, + McpConfirmationHandler.Action type) { + return actions.stream().anyMatch(a -> hasAction(a, type)); + } + + private static boolean hasAction(ConfirmationAction action, + McpConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java new file mode 100644 index 00000000..5cb11e3e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandlerTests.java @@ -0,0 +1,650 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata.TerminalCommandData; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TerminalConfirmationHandlerTests { + + private static final String CONV_ID = "conv-1"; + private static final Gson GSON = new Gson(); + + @Mock + private IPreferenceStore preferenceStore; + + private TerminalConfirmationHandler handler; + + @BeforeEach + void setUp() { + handler = new TerminalConfirmationHandler(preferenceStore); + } + + // --- rule matching (tested through evaluate) --- + + @Test + void ruleMatching_simpleRuleMatchesCommandAtStart() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /tmp"}, new String[]{"rm"}, + "rm -rf /tmp"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_simpleRuleDoesNotMatchMiddleOfWord() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"remove something"}, + new String[]{"remove"}, "remove something"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexCaseInsensitive() { + stubRules(List.of(new TerminalAutoApproveRule("/^git\\b/i", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"Git status"}, new String[]{"Git"}, + "Git status"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_regexDotallMatchesSubshell() { + stubRules(List.of( + new TerminalAutoApproveRule("/(\\(.+\\))/s", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"(echo hello)"}, + new String[]{"(echo"}, "(echo hello)"); + assertTrue(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsNull() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(null, null, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandsEmpty() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, new String[]{}, "rm -rf"); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + @Test + void ruleMatching_noMatchWhenSubCommandBlank() { + stubRules(List.of(new TerminalAutoApproveRule("rm", true))); + stubUnmatched(false); + InvokeClientToolConfirmationParams params = + buildParams(new String[]{" "}, new String[]{" "}, " "); + assertFalse(evaluate(params, CONV_ID).isAutoApproved()); + } + + // --- evaluate --- + + @Test + void evaluate_autoApprovedWhenAllSubCommandsMatchAllowRules() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenDenyRuleMatches() { + stubRules(List.of(new TerminalAutoApproveRule("rm", false))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"rm -rf /"}, new String[]{"rm"}, + "rm -rf /"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + assertNotNull(result.getContent()); + } + + @Test + void evaluate_needsConfirmationWhenNoRulesMatchAndUnmatchedFalse() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_autoApprovedWhenNoRulesMatchAndUnmatchedTrue() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls -la"}, new String[]{"ls"}, + "ls -la"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsNull() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(null, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_needsConfirmationWhenSubCommandsEmpty() { + stubRules(List.of()); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{}, null, "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + } + + @Test + void evaluate_emptyRulesUsesUnmatchedSetting() { + stubRules(List.of()); + stubUnmatched(true); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"ls"}, new String[]{"ls"}, "ls"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + // --- Session memory via cacheDecision --- + + @Test + void cacheDecision_acceptAllSession_autoApprovesSubsequent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptNamesSession_autoApprovesMatchingNames() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction namesSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION); + handler.cacheDecision(namesSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void cacheDecision_acceptExactSession_autoApprovesMatchingCommand() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction exactSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION); + handler.cacheDecision(exactSession, params, CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + @Test + void clearSession_removesApprovalsForConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession(CONV_ID); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertFalse(result.isAutoApproved()); + } + + @Test + void clearSession_doesNotAffectOtherConversation() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + + ConfirmationAction allSession = buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION); + handler.cacheDecision(allSession, params, CONV_ID); + + handler.clearSession("other-conv"); + + ConfirmationResult result = evaluate(params, CONV_ID); + assertTrue(result.isAutoApproved()); + } + + // --- buildContent actions --- + + @Test + void buildContent_alwaysHasAllowOnceAsPrimary() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + ConfirmationContent content = result.getContent(); + assertNotNull(content); + List<ConfirmationAction> actions = content.getActions(); + ConfirmationAction first = actions.get(0); + assertTrue(first.isPrimary()); + assertTrue(first.isAccept()); + } + + @Test + void buildContent_alwaysHasSkipAsDismiss() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + ConfirmationAction last = actions.get(actions.size() - 1); + assertFalse(last.isAccept()); + } + + @Test + void buildContent_hasAllowAllSessionAction() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasAllSession = actions.stream().anyMatch(a -> + a.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && a.getMetadata().get(ConfirmationAction.META_ACTION) + .equals( + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION + .name())); + assertTrue(hasAllSession); + } + + @Test + void buildContent_hasCommandNameActionsWhenNamesPresent() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasNamesSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)); + boolean hasNamesGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL)); + assertTrue(hasNamesSession); + assertTrue(hasNamesGlobal); + } + + @Test + void buildContent_hasExactCommandActionsWhenDifferentFromName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine "echo hello" differs from single commandName "echo" + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasExactSession = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION)); + boolean hasExactGlobal = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertTrue(hasExactSession); + assertTrue(hasExactGlobal); + } + + @Test + void buildContent_noExactActionsWhenSingleSubCommandEqualsName() { + stubRules(List.of()); + stubUnmatched(false); + + // commandLine equals the single commandName + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + boolean hasExact = actions.stream().anyMatch(a -> + hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)); + assertFalse(hasExact); + } + + @Test + void buildContent_actionScopesAreCorrect() { + stubRules(List.of()); + stubUnmatched(false); + + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo"}, new String[]{"echo"}, + "echo hello"); + ConfirmationResult result = evaluate(params, CONV_ID); + + List<ConfirmationAction> actions = result.getContent().getActions(); + + // Allow Once → ONCE scope + assertEquals(ConfirmationActionScope.ONCE, actions.get(0).getScope()); + + // Session actions have SESSION scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_SESSION) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_ALL_SESSION)) + .forEach(a -> assertEquals(ConfirmationActionScope.SESSION, + a.getScope())); + + // Global actions have GLOBAL scope + actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_GLOBAL) + || hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_EXACT_GLOBAL)) + .forEach(a -> assertEquals(ConfirmationActionScope.GLOBAL, + a.getScope())); + } + + // --- Helpers --- + + private void stubRules(List<TerminalAutoApproveRule> rules) { + when(preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES)) + .thenReturn(GSON.toJson(rules)); + } + + private void stubUnmatched(boolean value) { + when(preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)).thenReturn(value); + } + + private ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, String conversationId) { + return handler.evaluate(params, conversationId, true); + } + + private static InvokeClientToolConfirmationParams buildParams( + String[] subCommands, String[] commandNames, String commandLine) { + TerminalCommandData tcd = new TerminalCommandData(); + tcd.setSubCommands(subCommands); + tcd.setCommandNames(commandNames); + + ToolMetadata meta = new ToolMetadata(); + meta.setTerminalCommandData(tcd); + + InvokeClientToolConfirmationParams params = + new InvokeClientToolConfirmationParams(); + params.setConversationId(CONV_ID); + params.setToolMetadata(meta); + params.setInput(Map.of("toolType", "terminal", "command", + commandLine != null ? commandLine : "")); + return params; + } + + private static ConfirmationAction buildSessionAction( + TerminalConfirmationHandler.Action actionType) { + Map<String, String> meta = Map.of( + ConfirmationAction.META_ACTION, actionType.name()); + return new ConfirmationAction( + "test", true, ConfirmationActionScope.SESSION, meta, false); + } + + private static boolean hasActionType(ConfirmationAction action, + TerminalConfirmationHandler.Action type) { + return action.getMetadata().containsKey(ConfirmationAction.META_ACTION) + && action.getMetadata().get(ConfirmationAction.META_ACTION) + .equals(type.name()); + } + + // --- Unapproved filtering tests --- + + @Test + void buildContent_filtersSessionApprovedNamesFromActions() { + stubRules(List.of()); + stubUnmatched(false); + + // "echo && curl" — approve echo in session first + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Now evaluate "echo hello && curl example.com" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List<ConfirmationAction> actions = result.getContent().getActions(); + + // Command-name actions should only mention "curl", not "echo" + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("curl")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_filtersGlobalApprovedNamesFromActions() { + // Global allow rule for "echo" + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Evaluate "echo hello && hostname" + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "hostname"}, + new String[]{"echo", "hostname"}, + "echo hello && hostname"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertFalse(result.isAutoApproved()); + List<ConfirmationAction> actions = result.getContent().getActions(); + + // "echo" is globally allowed — only "hostname" in actions + ConfirmationAction namesAction = actions.stream() + .filter(a -> hasActionType(a, + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION)) + .findFirst().orElse(null); + assertNotNull(namesAction); + assertTrue(namesAction.getLabel().contains("hostname")); + assertFalse(namesAction.getLabel().contains("echo")); + } + + @Test + void buildContent_allNamesApproved_autoApproves() { + stubRules(List.of(new TerminalAutoApproveRule("echo", true))); + stubUnmatched(false); + + // Session-approve "curl" + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"curl x"}, new String[]{"curl"}, + "curl x"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, CONV_ID); + + // Evaluate "echo hello && curl example.com" + // echo = global allow, curl = session allow → all approved + InvokeClientToolConfirmationParams params = + buildParams( + new String[]{"echo hello", "curl example.com"}, + new String[]{"echo", "curl"}, + "echo hello && curl example.com"); + ConfirmationResult result = evaluate(params, CONV_ID); + + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_surviveConversationSwitch() { + // Approve "echo" in conversation A + InvokeClientToolConfirmationParams approveParams = + buildParams(new String[]{"echo hello"}, new String[]{"echo"}, + "echo hello"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + approveParams, "conv-A"); + + // Switch to conversation B, then back to A — rule should survive + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo world"}, new String[]{"echo"}, + "echo world"); + ConfirmationResult result = evaluate(params, "conv-A"); + assertTrue(result.isAutoApproved()); + } + + @Test + void sessionRules_evictOldestWhenCapExceeded() { + // Fill up to MAX_SESSION_CONVERSATIONS with unique conversation IDs + for (int i = 0; i < TerminalConfirmationHandler.MAX_SESSION_CONVERSATIONS; + i++) { + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-" + i); + } + + // Add one more — should evict the oldest (conv-0) + InvokeClientToolConfirmationParams p = + buildParams(new String[]{"echo"}, new String[]{"echo"}, "echo"); + handler.cacheDecision( + buildSessionAction( + TerminalConfirmationHandler.Action.ACCEPT_NAMES_SESSION), + p, "conv-new"); + + // conv-0 should have been evicted + InvokeClientToolConfirmationParams params = + buildParams(new String[]{"echo test"}, new String[]{"echo"}, + "echo test"); + ConfirmationResult evicted = evaluate(params, "conv-0"); + assertFalse(evicted.isAutoApproved()); + + // conv-new should still work + ConfirmationResult kept = evaluate(params, "conv-new"); + assertTrue(kept.isAutoApproved()); + + // conv-1 (second oldest, not evicted) should still work + ConfirmationResult second = evaluate(params, "conv-1"); + assertTrue(second.isAutoApproved()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java index 7634eee8..79f9c3df 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionServiceTest.java @@ -7,55 +7,91 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.List; import java.util.concurrent.CompletableFuture; import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Mockito; import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; class ChatCompletionServiceTest { - @Mock private static CopilotLanguageServerConnection mockLsConnection; - @Mock - private static ConversationTemplate mockTemplate; - - @Mock private static AuthStatusManager mockAuthStatusManager; private static ChatCompletionService chatCompletionService; + private static MockedStatic<CopilotUi> copilotUiMock; + private static MockedStatic<PlatformUI> platformUiMock; @BeforeAll static void setUp() { // Initialize the mocks mockLsConnection = Mockito.mock(CopilotLanguageServerConnection.class); - mockTemplate = Mockito.mock(ConversationTemplate.class); mockAuthStatusManager = Mockito.mock(AuthStatusManager.class); - ConversationTemplate[] templates = new ConversationTemplate[] { mockTemplate }; - when(mockLsConnection.listConversationTemplates()).thenReturn(CompletableFuture.completedFuture(templates)); - when(mockTemplate.getScopes()).thenReturn(List.of(CopilotScope.CHAT_PANEL)); - when(mockTemplate.getId()).thenReturn("test"); + + // Mock CopilotUi.getPlugin() so the constructor can register its preference listener + CopilotUi mockPlugin = mock(CopilotUi.class); + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + LanguageServerSettingManager mockSettingManager = mock(LanguageServerSettingManager.class); + when(mockPlugin.getLanguageServerSettingManager()).thenReturn(mockSettingManager); + when(mockPlugin.getPreferenceStore()).thenReturn(mockPreferenceStore); + when(mockPreferenceStore.getBoolean(Constants.ENABLE_SKILLS)).thenReturn(true); + copilotUiMock = Mockito.mockStatic(CopilotUi.class); + copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin); + + // Mock PlatformUI so the constructor can safely obtain an IEventBroker + IWorkbench mockWorkbench = mock(IWorkbench.class); + when(mockWorkbench.getService(any())).thenReturn(null); + platformUiMock = Mockito.mockStatic(PlatformUI.class); + platformUiMock.when(PlatformUI::getWorkbench).thenReturn(mockWorkbench); + + ConversationTemplate template = new ConversationTemplate("test", null, null, + List.of(CopilotScope.CHAT_PANEL), null); + ConversationTemplate[] templates = new ConversationTemplate[] { template }; + when(mockLsConnection.listConversationTemplates(any())).thenReturn(CompletableFuture.completedFuture(templates)); + when(mockLsConnection.listConversationAgents()) + .thenReturn(CompletableFuture.completedFuture(new ConversationAgent[0])); when(mockAuthStatusManager.getCopilotStatus()).thenReturn(CopilotStatusResult.OK); chatCompletionService = new ChatCompletionService(mockLsConnection, mockAuthStatusManager); - Job[] jobs = Job.getJobManager().find(ChatCompletionService.INIT_JOB_FAMILY); - for (Job job : jobs) { - try { - job.join(); - } catch (InterruptedException e) { - continue; - } + try { + Job.getJobManager().join(ChatCompletionService.REFRESH_JOB_FAMILY, null); + } catch (InterruptedException e) { + // ignore + } + } + + @AfterAll + static void tearDown() { + if (chatCompletionService != null) { + chatCompletionService.dispose(); + } + if (copilotUiMock != null) { + copilotUiMock.close(); + } + if (platformUiMock != null) { + platformUiMock.close(); } } @@ -67,7 +103,7 @@ void testConstructor() { @Test void testInitConversationTemplates() throws Exception { - assertEquals(1, chatCompletionService.getTemplates().length); + assertEquals(1, chatCompletionService.getFilteredTemplates(ChatMode.Ask).length); } @Test @@ -83,7 +119,7 @@ void testIsCommand() { } @Test - void testGetTemplates() { - assertNotNull(chatCompletionService.getTemplates()); + void testGetFilteredTemplates() { + assertNotNull(chatCompletionService.getFilteredTemplates(ChatMode.Ask)); } } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java index 3f60a488..69c3e784 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManagerTest.java @@ -4,10 +4,13 @@ package com.microsoft.copilot.eclipse.ui.chat.services; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -207,4 +210,151 @@ void testUpdateApprovedMcpServerStringWithNullServers() throws Exception { Map<String, Object> resultServers = (Map<String, Object>) result.get("servers"); assertTrue(resultServers.isEmpty(), "Servers map should be empty when MCP servers are null"); } + + @Test + void testDetectChangesDropsApprovedServerWhenPluginUninstalled() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 1: + // a previously approved plugin is no longer providing any MCP server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map<String, Object> previouslyApprovedServers = new HashMap<>(); + previouslyApprovedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", + previouslyApprovedServers); + Map<String, McpRegistrationInfo> persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned nothing (plugin uninstalled or no longer provides servers). + setExtMcpInfoMap(new HashMap<>()); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers, "Approved servers JSON should be non-null after detect"); + Map<String, Object> result = gson.fromJson(approvedServers, Map.class); + Map<String, Object> resultServers = (Map<String, Object>) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Stale approved server must be dropped when the live extension scan returns nothing"); + + // No new approval prompt should be raised because there is no incoming registration to approve. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + + // The verified state must be persisted so subsequent startups do not resurrect the stale entry. + ArgumentCaptor<String> persistedJson = ArgumentCaptor.forClass(String.class); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + persistedJson.capture()); + assertEquals("{}", persistedJson.getValue(), + "Persisted contribution map should be empty after the plugin is gone"); + } + + @Test + void testDetectChangesDropsApprovalAndFlagsRedNoticeWhenConfigChanges() throws Exception { + // Regression test for https://github.com/microsoft/copilot-for-eclipse/issues/153 scenario 2: + // the contributing plugin returns a different config than what was previously approved. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map<String, Object> oldServers = new HashMap<>(); + oldServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", oldServers); + Map<String, McpRegistrationInfo> persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same plugin but with a different server config (port change). + Map<String, Object> newServers = new HashMap<>(); + newServers.put("server-X", Map.of("url", "http://localhost:9999")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", newServers); + Map<String, McpRegistrationInfo> currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + String approvedServers = manager.getApprovedExtMcpServers(); + assertNotNull(approvedServers); + Map<String, Object> result = gson.fromJson(approvedServers, Map.class); + Map<String, Object> resultServers = (Map<String, Object>) result.get("servers"); + assertTrue(resultServers.isEmpty(), + "Changed config must not be auto-applied; LSP must not receive the (now unapproved) entry"); + + assertFalse(currentInfo.isApproved(), + "Previously approved entry whose config changed must be marked as unapproved until the user re-approves"); + verify(mockMcpConfigService).setNewExtMcpRegFound(true); + verify(mockPreferenceStore, atLeastOnce()).setValue(eq(Constants.MCP_EXTENSION_POINT_CONTRIB), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void testDetectChangesPreservesApprovalWhenConfigUnchanged() throws Exception { + // Companion test: when the live extension scan reports the same config as the persisted cache, + // the previous approval must be carried over so the explicit LSP sync at the end of + // doRegistration() can push the verified servers to the language server. + IPreferenceStore mockPreferenceStore = mock(IPreferenceStore.class); + when(mockCopilotUi.getPreferenceStore()).thenReturn(mockPreferenceStore); + + Map<String, Object> approvedServers = new HashMap<>(); + approvedServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo persistedInfo = createMcpRegistrationInfo(true, true, "Test Plugin", approvedServers); + Map<String, McpRegistrationInfo> persisted = new HashMap<>(); + persisted.put("com.example.plugin", persistedInfo); + + // Live extension scan returned the same servers; default isApproved=false until carry-over runs. + Map<String, Object> sameServers = new HashMap<>(); + sameServers.put("server-X", Map.of("url", "http://localhost:9000")); + McpRegistrationInfo currentInfo = createMcpRegistrationInfo(true, false, "Test Plugin", sameServers); + Map<String, McpRegistrationInfo> currentMap = new HashMap<>(); + currentMap.put("com.example.plugin", currentInfo); + setExtMcpInfoMap(currentMap); + + invokeDetectChanges(persisted); + + assertTrue(currentInfo.isApproved(), + "When the contributed config matches the persisted JSON, the previous approval must be carried over"); + + String approvedJson = manager.getApprovedExtMcpServers(); + assertNotNull(approvedJson); + Map<String, Object> result = gson.fromJson(approvedJson, Map.class); + Map<String, Object> resultServers = (Map<String, Object>) result.get("servers"); + assertEquals(1, resultServers.size(), + "Carried-over approved server must be present in the approved servers JSON for the LSP sync"); + + // No red-notice should be raised because nothing has changed for the user to re-review. + verify(mockMcpConfigService, never()).setNewExtMcpRegFound(true); + } + + /** + * Reflectively replace the manager's private {@code extMcpInfoMap} so tests can simulate the + * outcome of {@code loadMcpRegistrationExtensionPoint()} without requiring a live OSGi + * extension registry. + */ + private void setExtMcpInfoMap(Map<String, McpRegistrationInfo> map) throws Exception { + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + field.set(manager, map); + } + + private void invokeDetectChanges(Map<String, McpRegistrationInfo> persisted) throws Exception { + // Get the scanned map that was previously set via setExtMcpInfoMap + Field field = McpExtensionPointManager.class.getDeclaredField("extMcpInfoMap"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map<String, McpRegistrationInfo> scannedMap = (Map<String, McpRegistrationInfo>) field.get(manager); + + // detectChangesInMcpContribs now takes (scannedMap, persistedMap) and no longer + // calls updateApprovedMcpServerString / persistExtMcpInfo (those moved to doRegistration). + Method detectMethod = McpExtensionPointManager.class.getDeclaredMethod("detectChangesInMcpContribs", Map.class, + Map.class); + detectMethod.setAccessible(true); + detectMethod.invoke(manager, scannedMap, persisted); + + // Mirror what doRegistration() does after detectChanges: swap the field and update/persist. + field.set(manager, scannedMap); + Method updateMethod = McpExtensionPointManager.class.getDeclaredMethod("updateApprovedMcpServerString", Map.class); + updateMethod.setAccessible(true); + updateMethod.invoke(manager, scannedMap); + Method persistMethod = McpExtensionPointManager.class.getDeclaredMethod("persistExtMcpInfo", Map.class); + persistMethod.setAccessible(true); + persistMethod.invoke(manager, scannedMap); + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java index 4bd2c9ed..428d7297 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileToolTest.java @@ -5,10 +5,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,12 +24,14 @@ import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.lsp4j.FileChangeType; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -52,6 +59,9 @@ class CreateFileToolTest { @Mock private FileToolService mockFileToolService; + @TempDir + private Path tempDir; + private MockedStatic<CopilotUi> mockedCopilotUi; @BeforeEach @@ -78,6 +88,7 @@ void tearDown() throws Exception { // Clean up test project cleanupTestProject(); + FileToolCacheAccessor.clearCaches(); } private IProject setupTestProject() throws Exception { @@ -251,11 +262,92 @@ void testInvokeWithNullContentReturnsSuccessStatus() throws Exception { assertTrue(newFile.exists()); } + @Test + void testInvokeWithExternalLocalFilePathCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file.txt"); + + Map<String, Object> input = new HashMap<>(); + input.put("filePath", newFile.toString()); + input.put("content", "test content"); + + CompletableFuture<LanguageModelToolResult[]> future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testInvokeWithExternalLocalFileUriCreatesFile() throws Exception { + setupMocks(); + Path newFile = tempDir.resolve("external-file-uri.txt"); + + Map<String, Object> input = new HashMap<>(); + input.put("filePath", newFile.toUri().toString()); + input.put("content", "test content"); + + CompletableFuture<LanguageModelToolResult[]> future = createFileTool.invoke(input, null); + LanguageModelToolResult[] results = future.get(); + + assertSuccessResult(results, "File created at"); + assertTrue(Files.exists(newFile)); + assertEquals("test content", Files.readString(newFile)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(newFile), FileChangeType.Created); + } + + @Test + void testOnKeepChangeWithWorkspaceFileClearsOriginalContentCache() { + IFile newFile = mock(IFile.class); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.workspace(newFile)); + + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithWorkspaceFileDeletesFileAndClearsOriginalContentCache() throws Exception { + IProject project = setupTestProject(); + IFile newFile = project.getFile("workspace-file-to-undo.txt"); + newFile.create(new java.io.ByteArrayInputStream("test content".getBytes()), true, null); + FileToolCacheAccessor.putWorkspaceFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.workspace(newFile)); + + assertTrue(!newFile.exists()); + assertNull(FileToolCacheAccessor.getWorkspaceFileContentCache(newFile)); + } + + @Test + void testOnKeepChangeWithExternalLocalFileClearsOriginalContentCache() { + Path newFile = tempDir.resolve("external-file-to-keep.txt"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onKeepChange(ChangedFile.local(newFile)); + + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + + @Test + void testOnUndoChangeWithExternalLocalFileDeletesFile() throws Exception { + Path newFile = tempDir.resolve("external-file-to-undo.txt"); + Files.writeString(newFile, "test content"); + FileToolCacheAccessor.putFileContentCache(newFile, ""); + + createFileTool.onUndoChange(ChangedFile.local(newFile)); + + assertTrue(Files.notExists(newFile)); + assertNull(FileToolCacheAccessor.getFileContentCache(newFile)); + } + @Test void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, ExecutionException { // Arrange Map<String, Object> input = new HashMap<>(); - input.put("filePath", "/invalid/path/that/does/not/exist.txt"); + input.put("filePath", "relative/path/that/does/not/exist.txt"); input.put("content", "test content"); // Act @@ -263,7 +355,7 @@ void testInvokeWithInvalidPathReturnsErrorStatus() throws InterruptedException, LanguageModelToolResult[] results = future.get(); // Assert - assertErrorResult(results, "Error creating file"); + assertErrorResult(results, "does not exist in the workspace"); } @Test @@ -286,4 +378,26 @@ void testToolName() { * Note: CoreException and IOException scenarios are difficult to test in unit tests * without complex mocking and would be better covered by integration tests. */ + + private static final class FileToolCacheAccessor extends CreateFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static void putWorkspaceFileContentCache(IFile file, String content) { + fileContentCache.put(ChangedFile.workspace(file), content); + } + + private static String getWorkspaceFileContentCache(IFile file) { + return fileContentCache.get(ChangedFile.workspace(file)); + } + + private static void putFileContentCache(Path file, String content) { + fileContentCache.put(ChangedFile.local(file), content); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java new file mode 100644 index 00000000..4ffabcbd --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileToolTest.java @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.lsp4j.FileChangeType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +@ExtendWith(MockitoExtension.class) +class EditFileToolTest { + + @TempDir + Path tempDir; + + @Mock + private CopilotUi mockCopilotUi; + @Mock + private ChatServiceManager mockChatServiceManager; + @Mock + private FileToolService mockFileToolService; + + private MockedStatic<CopilotUi> mockedCopilotUi; + + private void setupMocks() { + mockedCopilotUi = mockStatic(CopilotUi.class); + mockedCopilotUi.when(CopilotUi::getPlugin).thenReturn(mockCopilotUi); + when(mockCopilotUi.getChatServiceManager()).thenReturn(mockChatServiceManager); + when(mockChatServiceManager.getFileToolService()).thenReturn(mockFileToolService); + } + + @AfterEach + void tearDown() { + if (mockedCopilotUi != null) { + mockedCopilotUi.close(); + } + FileToolCacheAccessor.clearCaches(); + } + + @Test + void testInvoke_withExternalLocalFilePath_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.txt"); + Files.writeString(file, "original"); + + LanguageModelToolResult[] results = invokeEdit(file.toString(), "updated"); + + assertSuccess(results, "updated"); + assertEquals("updated", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testInvoke_withExternalLocalFileUri_editsFile() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target.patch"); + Files.writeString(file, "old patch content"); + + LanguageModelToolResult[] results = invokeEdit(file.toUri().toString(), "new patch content"); + + assertSuccess(results, "new patch content"); + assertEquals("new patch content", Files.readString(file)); + verify(mockFileToolService).addChangedFile(ChangedFile.local(file), FileChangeType.Changed); + } + + @Test + void testOnUndoChange_withExternalLocalFile_restoresOriginalContent() throws Exception { + setupMocks(); + Path file = tempDir.resolve("target-to-undo.txt"); + Files.writeString(file, "original"); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] results = invokeEdit(editFileTool, file.toString(), "updated"); + assertSuccess(results, "updated"); + + editFileTool.onUndoChange(ChangedFile.local(file)); + + assertEquals("original", Files.readString(file)); + } + + @Test + void testInvoke_createThenEditExternalLocalFile_preservesEmptyBaseline() throws Exception { + setupMocks(); + Path file = tempDir.resolve("created-then-edited.txt"); + Path normalizedPath = file.toAbsolutePath().normalize(); + + CreateFileTool createFileTool = new CreateFileTool(); + LanguageModelToolResult[] createResults = invokeCreate(createFileTool, file.toString(), "created content"); + assertSuccess(createResults, "File created at: " + normalizedPath); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + + EditFileTool editFileTool = new EditFileTool(); + LanguageModelToolResult[] editResults = invokeEdit(editFileTool, file.toString(), "edited content"); + + assertSuccess(editResults, "edited content"); + assertEquals("edited content", Files.readString(file)); + assertEquals("", FileToolCacheAccessor.getFileContentCache(normalizedPath)); + } + + @Test + void testInvoke_withMissingExternalLocalFile_returnsError() throws Exception { + LanguageModelToolResult[] results = invokeEdit(tempDir.resolve("missing.txt").toString(), "updated"); + + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.error, results[0].getStatus()); + } + + private LanguageModelToolResult[] invokeEdit(String filePath, String code) throws Exception { + return invokeEdit(new EditFileTool(), filePath, code); + } + + private LanguageModelToolResult[] invokeEdit(EditFileTool editFileTool, String filePath, String code) + throws Exception { + Map<String, Object> input = new HashMap<>(); + input.put("filePath", filePath); + input.put("code", code); + input.put("explanation", "test edit"); + + return editFileTool.invoke(input, null).get(); + } + + private LanguageModelToolResult[] invokeCreate(CreateFileTool createFileTool, String filePath, String content) + throws Exception { + Map<String, Object> input = new HashMap<>(); + input.put("filePath", filePath); + input.put("content", content); + + return createFileTool.invoke(input, null).get(); + } + + private void assertSuccess(LanguageModelToolResult[] results, String expectedContent) throws IOException { + assertNotNull(results); + assertEquals(1, results.length); + assertEquals(ToolInvocationStatus.success, results[0].getStatus()); + assertEquals(expectedContent, results[0].getContent().get(0).getValue()); + } + + private static final class FileToolCacheAccessor extends EditFileTool { + private static void clearCaches() { + fileContentCache.clear(); + } + + private static String getFileContentCache(Path file) { + return fileContentCache.get(ChangedFile.local(file)); + } + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java index 01ebcc41..5f6aa92c 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapterTest.java @@ -77,7 +77,8 @@ void testInvokeWithEmptyCommandReturnsErrorStatus() throws InterruptedException, assertNotNull(results); assertEquals(1, results.length); assertEquals(ToolInvocationStatus.error, results[0].getStatus()); - assertTrue(results[0].getContent().get(0).getValue().equals("No terminal implementation available. Terminal service not yet loaded or failed to load.")); + assertEquals("No terminal implementation available. Terminal service not yet loaded or failed to load.", + results[0].getContent().get(0).getValue()); } @Test @@ -113,7 +114,6 @@ void testToolName() { assertEquals("run_in_terminal", runInTerminalToolAdapter.getToolName()); } - @Test void testGetTerminalOutputToolWithNoTerminalServiceReturnsErrorStatus() throws InterruptedException, ExecutionException { diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java index 850a457c..0ac97045 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java @@ -15,4 +15,4 @@ void testMessagesInitialization() { assertNotNull(Messages.menu_signToGitHub); assertNotNull(Messages.menu_signOutOfGitHub); } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/nes/NesAnnotationTypeMappingTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/nes/NesAnnotationTypeMappingTest.java new file mode 100644 index 00000000..15aee61e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/nes/NesAnnotationTypeMappingTest.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.nes; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.runtime.IConfigurationElement; +import org.eclipse.core.runtime.IExtensionPoint; +import org.eclipse.core.runtime.Platform; +import org.eclipse.ui.texteditor.AnnotationTypeLookup; +import org.junit.jupiter.api.Test; + +class NesAnnotationTypeMappingTest { + + private static final String ANNOTATION_TYPES_EXTENSION_POINT = "org.eclipse.ui.editors.annotationTypes"; + private static final String FOREIGN_TEXT_MARKER = + "com.microsoft.copilot.eclipse.ui.test.foreignTextMarker"; + private static final String NES_ANNOTATION_PREFIX = "com.microsoft.copilot.eclipse.ui.nes."; + private static final String NES_CHANGE_ANNOTATION = NES_ANNOTATION_PREFIX + "change"; + private static final String NES_DELETE_ANNOTATION = NES_ANNOTATION_PREFIX + "delete"; + private static final String ROOT_TEXT_MARKER = "org.eclipse.core.resources.textmarker"; + + @Test + void testForeignTextMarkerSubtypeDoesNotResolveToNesAnnotations() { + AnnotationTypeLookup lookup = new AnnotationTypeLookup(); + + String annotationType = lookup.getAnnotationType(FOREIGN_TEXT_MARKER, IMarker.SEVERITY_INFO); + + assertNotEquals(NES_CHANGE_ANNOTATION, annotationType); + assertNotEquals(NES_DELETE_ANNOTATION, annotationType); + } + + @Test + void testNesAnnotationTypesDoNotMapToRootTextMarker() { + IExtensionPoint extensionPoint = Platform.getExtensionRegistry() + .getExtensionPoint(ANNOTATION_TYPES_EXTENSION_POINT); + assertNotNull(extensionPoint); + assertTrue(hasNesAnnotationType(extensionPoint, NES_CHANGE_ANNOTATION)); + assertTrue(hasNesAnnotationType(extensionPoint, NES_DELETE_ANNOTATION)); + assertFalse(hasNesRootTextMarkerMapping(extensionPoint)); + } + + private boolean hasNesAnnotationType(IExtensionPoint extensionPoint, String annotationType) { + for (IConfigurationElement element : extensionPoint.getConfigurationElements()) { + if ("type".equals(element.getName()) && annotationType.equals(element.getAttribute("name"))) { + return true; + } + } + return false; + } + + private boolean hasNesRootTextMarkerMapping(IExtensionPoint extensionPoint) { + for (IConfigurationElement element : extensionPoint.getConfigurationElements()) { + String annotationType = element.getAttribute("name"); + if ("type".equals(element.getName()) && annotationType != null + && annotationType.startsWith(NES_ANNOTATION_PREFIX) + && ROOT_TEXT_MARKER.equals(element.getAttribute("markerType"))) { + return true; + } + } + return false; + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java index 1be3ea3d..8cf28098 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManagerTests.java @@ -14,6 +14,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.LinkedHashMap; + import org.eclipse.core.net.proxy.IProxyData; import org.eclipse.core.net.proxy.IProxyService; import org.eclipse.jface.preference.IPreferenceStore; @@ -23,14 +25,19 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotLanguageServerSettings.CopilotSettings; +import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class LanguageServerSettingManagerTests { @Mock private IPreferenceStore mockPreferenceStore; @@ -47,7 +54,16 @@ void testNoProxy() { // arrange when(mockPreferenceStore.getBoolean(Constants.AUTO_SHOW_COMPLETION)).thenReturn(true); var params = new DidChangeConfigurationParams(); - params.setSettings(new CopilotLanguageServerSettings()); + var settings = new CopilotLanguageServerSettings(); + settings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()) + .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); + params.setSettings(settings); // act LanguageServerSettingManager manager = new LanguageServerSettingManager(mockLsConnection, mockProxyService, @@ -74,6 +90,14 @@ void testBasicProxy() { var params = new DidChangeConfigurationParams(); var settings = new CopilotLanguageServerSettings(); settings.getHttp().setProxy("HTTPS://localhost:8080"); + settings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()) + .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -107,8 +131,16 @@ void testUpdateConfigShouldBeCalledWhenWorkspaceInstructionsEnabledWithContent() DidChangeConfigurationParams params = new DidChangeConfigurationParams(); CopilotSettings copilotSettings = new CopilotSettings(); copilotSettings.setWorkspaceCopilotInstructions("Test instructions"); + copilotSettings.getAgent().setEnableSkills(PreferencesUtils.isSkillsEnabled()); + copilotSettings.getAgent().setAutoCompress(true); CopilotLanguageServerSettings settings = new CopilotLanguageServerSettings(); settings.getGithubSettings().setCopilotSettings(copilotSettings); + settings.getGithubSettings().getCopilotSettings().getAgent() + .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); params.setSettings(settings); // act @@ -137,6 +169,14 @@ void testUpdateConfigShouldBeCalledWithoutInstructionWhenWorkspaceInstructionsDi // Expected params should have empty workspace instructions since it's disabled DidChangeConfigurationParams expectedParams = new DidChangeConfigurationParams(); CopilotLanguageServerSettings expectedSettings = new CopilotLanguageServerSettings(); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()) + .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent().setAutoCompress(true); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(new LinkedHashMap<>()); + expectedSettings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(new LinkedHashMap<>()); expectedParams.setSettings(expectedSettings); // act diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java index 1d3b83f0..e2185223 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java @@ -4,12 +4,20 @@ package com.microsoft.copilot.eclipse.ui.utils; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; + import org.junit.jupiter.api.Test; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilities; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities; @@ -56,4 +64,128 @@ void testConvertByokModelToCopilotModel_withToolCallingCapability() { assertTrue(result.getScopes().contains(CopilotScope.CHAT_PANEL)); assertTrue(result.getScopes().contains(CopilotScope.AGENT_PANEL)); } + + @Test + void testConvertByokModelToCopilotModel_preservesTokenLimits() { + ByokModel byokModel = new ByokModel(); + byokModel.setModelId("gpt-4.1"); + + ByokModelCapabilities capabilities = new ByokModelCapabilities(); + capabilities.setMaxInputTokens(128000); + capabilities.setMaxOutputTokens(16000); + byokModel.setModelCapabilities(capabilities); + + CopilotModel result = ModelUtils.convertByokModelToCopilotModel(byokModel); + + assertNotNull(result.getCapabilities()); + assertNotNull(result.getCapabilities().limits()); + assertNull(result.getCapabilities().limits().maxContextWindowTokens()); + assertEquals(128000, result.getCapabilities().limits().maxInputTokens()); + assertEquals(16000, result.getCapabilities().limits().maxOutputTokens()); + } + + @Test + void testResolveDefaultReasoningEffort_prefersMediumForClaudeModels() { + CopilotModel model = new CopilotModel(); + model.setModelFamily("claude-3.7-sonnet"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, List.of("low", "medium", "high"), true), + new CopilotModelCapabilitiesLimits(null, null, null, null))); + + assertEquals("medium", ModelUtils.resolveDefaultReasoningEffort(model)); + } + + @Test + void testResolveDefaultReasoningEffort_returnsNullWhenSupportsReasoningEffortLevelFalse() { + CopilotModel model = new CopilotModel(); + model.setModelFamily("gpt-4o"); + // efforts list is populated, but the server has not vetted the model as supporting effort selection + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, List.of("low", "medium", "high"), false), + new CopilotModelCapabilitiesLimits(null, null, null, null))); + + assertNull(ModelUtils.resolveDefaultReasoningEffort(model)); + } + + @Test + void testSupportsReasoningEffortLevel_trueWhenCapabilityFlagSet() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, List.of("low", "medium", "high"), true), + new CopilotModelCapabilitiesLimits(null, null, null, null))); + + assertTrue(ModelUtils.supportsReasoningEffortLevel(model)); + } + + @Test + void testSupportsReasoningEffortLevel_falseWhenCapabilityFlagUnset() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-4o"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, List.of("low", "medium", "high"), false), + new CopilotModelCapabilitiesLimits(null, null, null, null))); + + assertFalse(ModelUtils.supportsReasoningEffortLevel(model)); + } + + @Test + void testSupportsReasoningEffortLevel_falseForAutoModel() { + CopilotModel model = new CopilotModel(); + model.setModelName("Auto"); + // Even if the server were to advertise the capability, the Auto model routes to other models and does not + // own its own effort selection. + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, List.of("low", "medium", "high"), true), + new CopilotModelCapabilitiesLimits(null, null, null, null))); + + assertFalse(ModelUtils.supportsReasoningEffortLevel(model)); + } + + @Test + void testSupportsReasoningEffortLevel_falseWhenCapabilitiesMissing() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + + assertFalse(ModelUtils.supportsReasoningEffortLevel(model)); + assertFalse(ModelUtils.supportsReasoningEffortLevel(null)); + } + + @Test + void testGetModelSuffix_customModelUsesProvider() { + // Organization-contributed custom models arrive without a providerName but carry their provider in the + // custom-model metadata; the suffix should surface that provider like a BYOK model. + CopilotModel model = new CopilotModel(); + model.setModelName("Sonnet (Org)"); + model.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "Azure")); + + assertEquals("Azure", ModelUtils.getModelSuffix(model, null)); + } + + @Test + void testGetModelSuffix_providerNameTakesPrecedenceOverCustomModel() { + CopilotModel model = new CopilotModel(); + model.setModelName("GPT-4o"); + model.setProviderName("OpenAI"); + model.setCustomModel(new CopilotModelCustomModel("Key", "Contoso", "organization", "Azure")); + + assertEquals("OpenAI", ModelUtils.getModelSuffix(model, null)); + } + + @Test + void testIsAutoModel() { + CopilotModel auto = new CopilotModel(); + auto.setModelName("Auto"); + assertTrue(ModelUtils.isAutoModel(auto)); + + CopilotModel autoLower = new CopilotModel(); + autoLower.setModelName("auto"); + assertFalse(ModelUtils.isAutoModel(autoLower)); + + CopilotModel other = new CopilotModel(); + other.setModelName("gpt-5"); + assertFalse(ModelUtils.isAutoModel(other)); + + assertFalse(ModelUtils.isAutoModel(null)); + } } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java new file mode 100644 index 00000000..86831c04 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtilsTest.java @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferenceStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; + +class PreferencesUtilsTest { + + private IPreferenceStore store; + + @BeforeEach + void setUp() { + store = new PreferenceStore(); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); + } + + @AfterEach + void tearDown() { + store = null; + } + + @ParameterizedTest + @EnumSource(CustomInstructionsChatLoadScope.class) + void testGetCustomInstructionsChatLoadScope_returnsStoredValue(CustomInstructionsChatLoadScope scope) { + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, scope.getValue()); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + assertEquals(scope, result); + } + + @Test + void testGetCustomInstructionsChatLoadScope_fallsBackToDefaultInCaseOfInvalidValueInStore() { + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, "invalid_value"); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result); + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue(), + store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The invalid stored value should have been corrected to the default"); + } + + @Test + void testGetCustomInstructionsChatLoadScopeDefault_returnsConfiguredDefault() { + // explicitly store varying values for InstanceScope and DefaultScope + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.ALL_PROJECTS.getValue()); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue()); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store); + + // Should return the default value, not the stored value + assertEquals(CustomInstructionsChatLoadScope.REFERENCED_PROJECTS, result); + } + + @Test + void testGetCustomInstructionsChatLoadScopeDefault_fallsBackToValidDefaultInCaseOfInvalidValue() { + String invalidDefaultValue = "invalid_default_value"; + String instanceScopValue = CustomInstructionsChatLoadScope.REFERENCED_PROJECTS.getValue(); + store.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, invalidDefaultValue); + store.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, instanceScopValue); + + CustomInstructionsChatLoadScope result = PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store); + + assertEquals(CustomInstructionsChatLoadScope.DEFAULT_VALUE, result); + assertEquals(invalidDefaultValue, + store.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The stored DefaultScope value should not change, even if it's invalid. " + + "That is repsonsibility of a PreferenceInitializer."); + assertEquals(instanceScopValue, + store.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE), + "The stored InstanceScope value should not change"); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java index 4194013e..2f4abb15 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtilsTest.java @@ -7,10 +7,16 @@ import static org.mockito.Mockito.*; import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,6 +24,8 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @@ -35,14 +43,30 @@ class ResourceUtilsTest { @Mock private IFolder mockFolder; + + @Mock + private IProject mockProjectA; + + @Mock + private IProject mockProjectB; private MockedStatic<FileUtils> mockedFileUtils; + private MockedStatic<LSPEclipseUtils> mockedLspUtils; + + private static final String nameProjectA= "ProjectA"; + private static final String nameProjectB= "ProjectB"; @BeforeEach void setUp() { mockedFileUtils = mockStatic(FileUtils.class); mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockValidFile)).thenReturn(false); mockedFileUtils.when(() -> FileUtils.isExcludedFromReferencedFiles(mockInvalidFile)).thenReturn(true); + + mockedLspUtils = mockStatic(LSPEclipseUtils.class); + mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectA)) + .thenReturn(new WorkspaceFolder("file:///" + nameProjectA, nameProjectA)); + mockedLspUtils.when(() -> LSPEclipseUtils.toWorkspaceFolder(mockProjectB)) + .thenReturn(new WorkspaceFolder("file:///" + nameProjectB, nameProjectB)); } @AfterEach @@ -50,6 +74,9 @@ void tearDown() { if (mockedFileUtils != null) { mockedFileUtils.close(); } + if (mockedLspUtils != null) { + mockedLspUtils.close(); + } } @Test @@ -82,4 +109,52 @@ void testCollectValidResourcesWithMocks() { assertTrue(validResources.contains(mockFolder), "Should contain folder"); assertFalse(validResources.contains(mockInvalidFile), "Should not contain excluded file"); } + + private static Stream<List<IResource>> provideResourcesForNeverNullTest() { + return Stream.of( + null, + List.of(), + Arrays.asList((IResource) null), + List.of(mock(IFolder.class)), + List.of(mock(IProject.class)) + ); + } + + @ParameterizedTest + @MethodSource("provideResourcesForNeverNullTest") + void testDeriveWorkspaceFoldersReturnsNeverNull(List<IResource> resources) { + List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(resources); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testDeriveWorkspaceFoldersWithMultipleResources() { + when(mockValidFile.getProject()).thenReturn(mockProjectA); + when(mockFolder.getProject()).thenReturn(mockProjectB); + when(mockProjectA.isAccessible()).thenReturn(true); + when(mockProjectB.isAccessible()).thenReturn(true); + + List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder)); + + assertNotNull(result); + assertFalse(result.isEmpty()); + assertEquals(2, result.size(), "Both projects from both resources should be derived as workspace folders"); + } + + @Test + void testDeriveWorkspaceFoldersDoesNotReturnDuplicates() { + when(mockValidFile.getProject()).thenReturn(mockProjectA); + when(mockFolder.getProject()).thenReturn(mockProjectA); + when(mockProjectA.isAccessible()).thenReturn(true); + when(mockProjectA.getName()).thenReturn(nameProjectA); + + List<WorkspaceFolder> result = ResourceUtils.deriveWorkspaceFoldersFrom(List.of(mockValidFile, mockFolder)); + + assertNotNull(result); + assertFalse(result.isEmpty()); + assertEquals(1, result.size(), "Projects in derived workspaces folders should be unique, no duplicates."); + assertEquals(mockProjectA.getName(), result.get(0).getName()); + } } diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index 39b10de8..dc86d49a 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -2,11 +2,12 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.microsoft.copilot.eclipse.ui Bundle-SymbolicName: com.microsoft.copilot.eclipse.ui;singleton:=true -Bundle-Version: 0.16.0.qualifier +Bundle-Version: 0.20.0.qualifier Bundle-Vendor: GitHub Copilot Bundle-Localization: plugin Export-Package: com.microsoft.copilot.eclipse.ui, com.microsoft.copilot.eclipse.ui.chat, + com.microsoft.copilot.eclipse.ui.chat.confirmation;x-friends:="com.microsoft.copilot.eclipse.ui.test", com.microsoft.copilot.eclipse.ui.chat.services, com.microsoft.copilot.eclipse.ui.chat.tools, com.microsoft.copilot.eclipse.ui.completion, @@ -24,14 +25,14 @@ Bundle-RequiredExecutionEnvironment: JavaSE-17 Automatic-Module-Name: com.microsoft.copilot.eclipse.ui Bundle-ActivationPolicy: lazy Import-Package: org.osgi.framework;version="[1.10.0,2.0.0)" -Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.0", - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.15.0", +Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.200", org.eclipse.ui.editors;bundle-version="3.17.100", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.navigator;bundle-version="3.12.200", - org.eclipse.jface.text;bundle-version="3.24.200", + org.eclipse.jface.text;bundle-version="3.24.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.eclipse.core.expressions, org.eclipse.jdt.annotation;resolution:=optional, diff --git a/com.microsoft.copilot.eclipse.ui/css/dark.css b/com.microsoft.copilot.eclipse.ui/css/dark.css index f3712622..528204cb 100644 --- a/com.microsoft.copilot.eclipse.ui/css/dark.css +++ b/com.microsoft.copilot.eclipse.ui/css/dark.css @@ -91,7 +91,7 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { background-color: #48484C; } @@ -115,14 +115,10 @@ background-color: #2F3030; } -#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary { - color: #A4A4A4; -} - -#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary { - color: #A4A4A4; -} - +#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > ThinkingBlock .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block ThinkingBlock .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary, #chat-content-viewer > Composite > CopilotTurnWidget > Composite > Label.model-info-label { color: #A4A4A4; } diff --git a/com.microsoft.copilot.eclipse.ui/css/light.css b/com.microsoft.copilot.eclipse.ui/css/light.css index f9fb3143..25c39464 100644 --- a/com.microsoft.copilot.eclipse.ui/css/light.css +++ b/com.microsoft.copilot.eclipse.ui/css/light.css @@ -91,7 +91,7 @@ background-color: #3584F1; } -#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > .bg-command-panel { +#chat-content-viewer > Composite > CopilotTurnWidget > InvokeToolConfirmationDialog > ScrolledComposite > .bg-command-panel { background-color: #FFFFFF; } @@ -115,14 +115,10 @@ background-color: #F1F1F2; } -#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary { - color: #808080; -} - -#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary { - color: #808080; -} - +#chat-content-viewer > Composite > CopilotTurnWidget > AgentStatusLabel > .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > ThinkingBlock .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > .subagent-message-block ThinkingBlock .text-secondary, +#chat-content-viewer > Composite > CopilotTurnWidget > AgentToolCancelLabel > Label.text-secondary, #chat-content-viewer > Composite > CopilotTurnWidget > Composite > Label.model-info-label { color: #808080; } diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark.png new file mode 100644 index 00000000..d6e078f3 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark@2x.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark@2x.png new file mode 100644 index 00000000..38eaa95b Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_dark@2x.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light.png new file mode 100644 index 00000000..5535a40e Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light@2x.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light@2x.png new file mode 100644 index 00000000..15f09552 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_down_light@2x.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark.png new file mode 100644 index 00000000..e5140b99 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark@2x.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark@2x.png new file mode 100644 index 00000000..981c6d64 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_dark@2x.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light.png new file mode 100644 index 00000000..3892b821 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light.png differ diff --git a/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light@2x.png b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light@2x.png new file mode 100644 index 00000000..0c6695c6 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/icons/dropdown/context_size_arrow_up_light@2x.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/changed_file_box.mp4 b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/changed_file_box.mp4 deleted file mode 100644 index 9dce1a50..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/changed_file_box.mp4 and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/toolbar.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/toolbar.png deleted file mode 100644 index efe81822..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.14.0/toolbar.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/agent_max_request.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/agent_max_request.png deleted file mode 100644 index c1da7c90..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/agent_max_request.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/chat_ux_improvements.mp4 b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/chat_ux_improvements.mp4 deleted file mode 100644 index 11edf94b..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/chat_ux_improvements.mp4 and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/commit_instructions.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/commit_instructions.png deleted file mode 100644 index 7fb15dd1..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/commit_instructions.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/editor_selection.mp4 b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/editor_selection.mp4 deleted file mode 100644 index 186ad37e..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/editor_selection.mp4 and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/mcp_registry.mp4 b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/mcp_registry.mp4 deleted file mode 100644 index 6f67002a..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/mcp_registry.mp4 and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/todo_tool.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/todo_tool.png deleted file mode 100644 index f44107cd..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.15.0/todo_tool.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png deleted file mode 100644 index 642b240c..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_chat_input.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png deleted file mode 100644 index f7ae997c..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/new_selector.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png deleted file mode 100644 index 71fc1c78..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/syntax_highlighting.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/tool_calling_in_ask_mode.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/tool_calling_in_ask_mode.png deleted file mode 100644 index e6943a02..00000000 Binary files a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.16.0/tool_calling_in_ask_mode.png and /dev/null differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/custom_instructions_loading.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/custom_instructions_loading.png new file mode 100644 index 00000000..90e2e6ca Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/custom_instructions_loading.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/skills_and_prompt_files.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/skills_and_prompt_files.png new file mode 100644 index 00000000..27d2630d Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/skills_and_prompt_files.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_blocks.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_blocks.png new file mode 100644 index 00000000..fa7caf36 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_blocks.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_effort.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_effort.png new file mode 100644 index 00000000..4c7f9500 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.18.0/thinking_effort.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png new file mode 100644 index 00000000..f9f98e22 Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.19.0/auto-approve.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png new file mode 100644 index 00000000..b767bbdb Binary files /dev/null and b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/0.20.0/custom-model.png differ diff --git a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/WHATISNEW.md b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/WHATISNEW.md index a3ef06bb..0e02ffc3 100644 --- a/com.microsoft.copilot.eclipse.ui/intro/whatsnew/WHATISNEW.md +++ b/com.microsoft.copilot.eclipse.ui/intro/whatsnew/WHATISNEW.md @@ -1,97 +1,89 @@ -# GitHub Copilot 0.16.0 Release Notes +# GitHub Copilot 0.20.0 Release Notes -### Tool Calling in Ask Mode +### Organization-Level Custom Models +Copilot for Eclipse now supports organization-enabled custom models in the model picker. If your organization has configured custom models, they appear automatically so you can select them alongside Copilot's built-in models. -Ask Mode now supports tool calling. When a question requires additional context, Copilot automatically invokes relevant tools — such as listing directories, searching for files, and reading file contents — to gather the information needed to provide an accurate response. Tools invoked in Ask Mode are read-only and will not modify your codebase. - -![Tool Calling in Ask Mode](0.16.0/tool_calling_in_ask_mode.png) +![Custom Model](0.20.0/custom-model.png) --- -### Redesigned Selectors and Chat Input Area - -- **Mode and Model Selectors**: The chat mode and model selectors have been redesigned to surface more information at a glance. The updated layout includes icons and descriptions, making it easier to identify the capabilities and warnings associated with each option. - -![New Selector](0.16.0/new_selector.png) +### Faster and More Reliable Chat Rendering +The chat view now renders long and streaming conversations more efficiently, reducing slowdowns during agent sessions and editor switches. -- **Chat Input Area**: The chat input area has been refined with a cleaner, borderless button design for a more streamlined appearance. -![New Chat Input](0.16.0/new_chat_input.png) +This release also includes chat fixes so long conversations scroll correctly to the newest messages, prompts that need user action are brought into view automatically, and model details display better on Linux. --- -### Syntax Highlighting in Chat +### Copilot Menu on the Left +The Copilot menu has moved to the left side of the Eclipse menu bar, immediately before the Help menu. -Code snippets in Copilot's chat view now render with full syntax highlighting. Code blocks in responses are automatically highlighted based on the detected language, improving readability and making it easier to follow along with code suggestions and explanations. +--- -![Syntax Highlighting](0.16.0/syntax_highlighting.png) +# GitHub Copilot 0.19.0 Release Notes ---- +### Agent Tool Auto-Approve Controls +Agent Mode now supports auto-approve controls for tool confirmations. Configure rules for terminal commands, file operations, and MCP tools from Copilot preferences, or use the confirmation dialog's **Allow for Session** and **Always Allow** actions to keep trusted workflows moving without repeated prompts. -# GitHub Copilot 0.15.0 Release Notes -### MCP Registry -Discover and install MCP servers from a centralized registry with just a few clicks. Browse available servers, view their capabilities, and add them to your workspace instantly — no manual configuration required. +Default file safety rules, MCP tool annotations, and the global auto-approve toggle are supported, so you can reduce friction while keeping risky actions visible. -<video controls="true" src="./0.15.0/mcp_registry.mp4" title="MCP Registry" style="max-width: 800px; width: 100%; height: auto;"></video> +![Tool Auto Approve](0.19.0/auto-approve.png) --- -### Chat View UX Enhancements -We've refreshed the chat experience with several improvements: +### Automatic Chat Context Compression +Copilot can now automatically compress chat context as conversations grow. When a session approaches the context limit, older conversation context is summarized so longer agent runs can continue with fewer interruptions. -- **Font Size Control**: Adjust the chat view font size to your preference using keyboard shortcuts or the view menu. Use `⌘ + =` / `⌘ + -` on macOS or `Ctrl + =` / `Ctrl + -` on Windows/Linux. Make it easier on your eyes! -- **Dark Theme Refresh**: A polished dark theme with improved contrast and readability for those late-night coding sessions. -- **Undo/Redo Support**: Made a typo in your chat input? Now you can undo and redo your edits seamlessly. - -<video controls="true" src="./0.15.0/chat_ux_improvements.mp4" title="Chat UX Improvements" style="max-width: 800px; width: 100%; height: auto;"></video> +The chat view also shows compression status while Copilot is compacting the conversation, making long-running sessions easier to follow. --- -### Editor Selection Context -Copilot now automatically includes your current editor selection in the chat context. Simply select some code, open the chat, and Copilot already knows what you're working with — making your conversations more relevant and focused. +### Create and Edit Local Files Outside the Workspace +Agent Mode can now create and edit local files by absolute path even when they are outside the Eclipse workspace. This helps when your code spans external folders, linked resources, or files that are not loaded as Eclipse projects. -<video controls="true" src="./0.15.0/editor_selection.mp4" title="Editor Selection Context" style="max-width: 800px; width: 100%; height: auto;"></video> +Local file changes are tracked alongside workspace edits in the changed-files bar, with support for **View Diff**, **Keep**, and **Undo** flows, including empty-baseline diffs for newly created files. --- -### Manage Todo List Tool -Stay organized with the new Todo List feature. When working on complex tasks, Copilot can now create and manage a structured todo list to track progress and plan steps. Watch as todos are checked off in real-time while the agent works through your request — giving you clear visibility into what's done and what's next. +### More Reliable Terminal Command Execution on Windows and Linux +Terminal command execution is more reliable across Windows and Linux. Copilot now runs commands through PowerShell on Windows and Bash on Linux, uses shell-integration markers to detect command completion and exit codes, and handles multiline commands with bracketed paste formatting. -![Manage Todo List Tool](0.15.0/todo_tool.png) +Copilot also interrupts previous foreground commands before starting new ones, stops active terminal work when a chat request is canceled, truncates long terminal output before sending it back to the model, and chooses a better working directory from the current file or referenced files. --- -### New Preferences -Fine-tune your Copilot experience with new preference options: +# GitHub Copilot 0.18.0 Release Notes + +### Prepare for the Upcoming Usage-Based Billing +Starting from this version, we have added internal support for the [upcoming usage-based billing experience](https://github.blog/news-insights/company-news/github-copilot-is-moving-to-usage-based-billing/), including experience updates to the usage panel, usage notifications, and model picker. These changes will become visible once usage-based billing is rolled out. -- **Agent Max Requests**: Control how many requests the agent can make before asking to reply 'continue', giving you more control over large, complex tasks. +Clients using older plugin versions will continue to function. However, the billing and usage experience may not be optimal and may not accurately reflect the latest usage-based billing experience. - ![Agent Max Requests](0.15.0/agent_max_request.png) +--- -- **Commit Instructions**: Customize how Copilot generates commit messages to match your team's conventions and style. +### Custom Instructions Loading Preference +A new Copilot preference lets you control how a chat's custom instructions are loaded. Tailor when and how your project-specific or personal instructions are picked up by Copilot, giving you finer control over the context that shapes each conversation. By default, custom instructions are loaded from **all projects** in your Eclipse workspace; switch to **Referenced projects** to only load instructions from projects whose files or folders are referenced in the current chat. - ![Commit Instructions](0.15.0/commit_instructions.png) +![Custom Instructions Loading Preference](0.18.0/custom_instructions_loading.png) --- -# GitHub Copilot 0.14.0 Release Notes -### Native Toolbar Integration -The buttons that used to sit on the chat view’s top bar have now found a new home in the Eclipse view’s toolbar. This change makes the interface feel more natural and integrated with your workflow. +### Skills and Prompt Files +Copilot for Eclipse now supports skills and prompt files. Define reusable prompts and skills to streamline your workflows — invoke them on demand to apply consistent instructions and patterns across your chats. Persist your skill files under `<workspace>/.github/skills/` (for example, `.github/skills/my-skill/SKILL.md`) and prompt files under `<workspace>/.github/prompts/` (e.g. `my-prompt.prompt.md`) so they're picked up automatically. -![Toolbar](0.14.0/toolbar.png) +To trigger a skill or prompt in chat, type `/` in the chat input box to open the slash command picker. -Note: If you cannot see the new buttons, please delete the **workbench.xmi** file located at: `<your_workspace>/.metadata/.plugins/org.eclipse.e4.workbench/`. +![Skills and Prompt Files](0.18.0/skills_and_prompt_files.png) --- -### New Changed Files Panel -The new changed files panel is now scrollable, collapsible, and expandable, so you can dive into details when you need them and tuck it away when you don’t. +### Thinking Blocks in Chat View +For models that support reasoning, the chat view now displays thinking blocks so you can follow Copilot's reasoning process alongside its final response. Expand or collapse the blocks to dive into the details or keep the view focused on results. -<video controls="true" src="./0.14.0/changed_file_box.mp4" title="Changed Files Panel" style="max-width: 800px; width: 100%; height: auto;"></video> +![Thinking Blocks in Chat View](0.18.0/thinking_blocks.png) --- -This release also squashed bugs, boosted performance, and polished the UI for a smoother, faster experience. - -Thank you for being part of this journey — here’s to an even better year ahead! +### Selectable Thinking Effort +You can now choose the thinking effort level for supported models. Dial the reasoning depth up for complex problems or keep it light for quick tasks — giving you control over the trade-off between latency and answer quality. -🎉 Wishing you a Happy New Year! 🎉 +![Selectable Thinking Effort](0.18.0/thinking_effort.png) diff --git a/com.microsoft.copilot.eclipse.ui/plugin.properties b/com.microsoft.copilot.eclipse.ui/plugin.properties index 38fd45a7..636fc1fb 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.properties +++ b/com.microsoft.copilot.eclipse.ui/plugin.properties @@ -47,4 +47,5 @@ intro.quickStart.description=Boost your coding efficiency with built-in Copilot theme.category.label=GitHub Copilot theme.category.description=Font and color settings for GitHub Copilot theme.chatFont.label=Chat Font -theme.chatFont.description=The font used for text in the Copilot Chat view \ No newline at end of file +theme.chatFont.description=The font used for text in the Copilot Chat view +page.preferencesPage.autoApprove.name=Tool Auto Approve \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/plugin.xml b/com.microsoft.copilot.eclipse.ui/plugin.xml index bcd4c053..8771f7ac 100644 --- a/com.microsoft.copilot.eclipse.ui/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui/plugin.xml @@ -29,7 +29,7 @@ </toolbar> </menuContribution> <menuContribution - locationURI="menu:org.eclipse.ui.main.menu?endof=additions"> + locationURI="menu:org.eclipse.ui.main.menu?before=help"> <menu id="com.microsoft.copilot.eclipse.ui.menuBar" label="Copilot" @@ -175,6 +175,12 @@ category="com.microsoft.copilot.eclipse.ui.preferences.CopilotPreferencesPage" class="com.microsoft.copilot.eclipse.ui.preferences.CustomModesPreferencePage"> </page> + <page + id="com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage" + name="%page.preferencesPage.autoApprove.name" + category="com.microsoft.copilot.eclipse.ui.preferences.CopilotPreferencesPage" + class="com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage"> + </page> </extension> <!-- Content type for agent.md files Matches *.md files but the describer validates only *.agent.md --> @@ -1048,12 +1054,10 @@ <!-- Annotation types for Next Edit Suggestions --> <extension point="org.eclipse.ui.editors.annotationTypes"> <type - name="com.microsoft.copilot.eclipse.ui.nes.change" - markerType="org.eclipse.core.resources.textmarker"> + name="com.microsoft.copilot.eclipse.ui.nes.change"> </type> <type - name="com.microsoft.copilot.eclipse.ui.nes.delete" - markerType="org.eclipse.core.resources.textmarker"> + name="com.microsoft.copilot.eclipse.ui.nes.delete"> </type> </extension> @@ -1113,4 +1117,4 @@ <description>%theme.chatFont.description</description> </fontDefinition> </extension> -</plugin> \ No newline at end of file +</plugin> diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java index 4e2d8bac..2c98738f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java @@ -65,6 +65,7 @@ import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.core.utils.ChatMessageUtils; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -118,6 +119,7 @@ public class ActionBar extends Composite implements NewConversationListener { private Image autoBreakpointDisabledImage; private ContextSizeDonut contextSizeDonut; private StaticBanner staticBanner; + private Composite inputArea; private ChatServiceManager chatServiceManager; IEventBroker eventBroker; @@ -163,13 +165,28 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagsChangedEventHandler); - Composite actionBar = new Composite(this, style | SWT.BORDER); + // Transparent wrapper for the optional TodoListBar / WorkingSetBar and the bordered input below. + // StaticBanner is created as a sibling of inputArea so it stays structurally above the whole stack. + this.inputArea = new Composite(this, SWT.NONE); + GridLayout glInputArea = new GridLayout(1, false); + glInputArea.marginWidth = 0; + glInputArea.marginHeight = 0; + glInputArea.marginLeft = 0; + glInputArea.marginRight = 0; + glInputArea.marginTop = 0; + glInputArea.marginBottom = 0; + glInputArea.horizontalSpacing = 0; + glInputArea.verticalSpacing = 0; + this.inputArea.setLayout(glInputArea); + this.inputArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + Composite borderedActionBar = new Composite(this.inputArea, style | SWT.BORDER); GridLayout gl = new GridLayout(1, false); gl.marginHeight = 5; gl.verticalSpacing = 0; - actionBar.setLayout(gl); - actionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - actionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); + borderedActionBar.setLayout(gl); + borderedActionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + borderedActionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); RowLayout rowLayout = new RowLayout(); rowLayout.wrap = true; @@ -185,7 +202,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana rowLayout.marginTop = 0; rowLayout.marginBottom = 10; rowLayout.center = true; - this.cmpFileRef = new Composite(actionBar, SWT.NONE); + this.cmpFileRef = new Composite(borderedActionBar, SWT.NONE); this.cmpFileRef.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); this.cmpFileRef.setLayout(rowLayout); new AddContextButton(this.cmpFileRef); @@ -197,8 +214,22 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana ModelService modelService = chatServiceManager.getModelService(); modelService.bindActionBarForSupportVisionChange(this); - ChatInputTextViewer tv = new ChatInputTextViewer(actionBar, chatServiceManager); + ChatInputTextViewer tv = new ChatInputTextViewer(borderedActionBar, chatServiceManager); tv.setEditable(true); + // Relayout the chat view container so the new ActionBar preferred height is honored when the input grows or + // shrinks (e.g., recalling a long history entry with Up arrow, wrapping a long line, or clearing the input). + // Avoids a brittle fixed-depth parent traversal inside ChatInputTextViewer (see issue #215). + tv.setLayoutRefreshCallback(() -> { + if (ActionBar.this.isDisposed()) { + return; + } + Composite layoutTarget = ActionBar.this.getParent(); + if (layoutTarget != null && !layoutTarget.isDisposed()) { + // TODO: An very interesting bug here, if we call layout(true, true), even no changes, + // The width of welcome view will become shorter and shorter, may investigate it later + layoutTarget.layout(true, false); + } + }); tv.addTextListener(new ITextListener() { @Override public void textChanged(TextEvent event) { @@ -279,7 +310,7 @@ private void updateTableLayout(Table table) { glActionArea.marginLeft = 0; glActionArea.marginTop = 5; glActionArea.marginBottom = -5; - this.cmpActionArea = new Composite(actionBar, SWT.NONE); + this.cmpActionArea = new Composite(borderedActionBar, SWT.NONE); this.cmpActionArea.setLayout(glActionArea); this.cmpActionArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); @@ -947,11 +978,39 @@ private List<IFile> selectFile() { } /** - * Show the static banner above the bordered action bar area. + * Show the rate-limit static banner above the input area, with a single "Get more info" action link. + * + * @param message the message to display + * @param warning {@code true} for the warning icon; {@code false} for the info icon + */ + public void createRateLimitBanner(String message, boolean warning) { + List<BannerAction> actions = List.of( + new BannerAction(Messages.chat_rateLimitBanner_getMoreInfo, UiConstants.COPILOT_RATE_LIMIT_INFO_URL)); + showStaticBanner(message, actions, warning); + } + + /** + * Show the quota-warning static banner above the input area. Action links are sourced from + * {@link QuotaActions#forPlan(CopilotPlan, boolean, Boolean)} so they stay in sync with the inline + * {@link WarnWidget}. * * @param message the message to display + * @param plan the user's Copilot plan, or {@code null} for no action links + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" + * @param canUpgradePlan whether the user can upgrade their Copilot plan, or {@code null} when the language + * server did not supply this field + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public void createStaticBanner(String message) { + public void createQuotaWarningBanner(String message, CopilotPlan plan, boolean overageEnabled, + Boolean canUpgradePlan, boolean warning) { + List<BannerAction> bannerActions = QuotaActions.forPlan(plan, overageEnabled, canUpgradePlan).stream() + .map(action -> new BannerAction(action.label(), action.url())) + .toList(); + showStaticBanner(message, bannerActions, warning); + } + + private void showStaticBanner(String message, List<BannerAction> actions, boolean warning) { if (isDisposed()) { return; } @@ -959,16 +1018,24 @@ public void createStaticBanner(String message) { this.staticBanner.dispose(); } - this.staticBanner = new StaticBanner(this, SWT.NONE, message, Messages.chat_rateLimitBanner_getMoreInfo, - UiConstants.COPILOT_RATE_LIMIT_INFO_URL, Messages.chat_rateLimitBanner_closeTooltip); - // Position the banner above the first child (the bordered action bar composite) - if (getChildren().length > 0) { - this.staticBanner.moveAbove(getChildren()[0]); + this.staticBanner = new StaticBanner(this, SWT.NONE, message, actions, + Messages.chat_rateLimitBanner_closeTooltip, warning); + // Keep the banner above the inputArea sibling, the only other child of this composite. + if (this.inputArea != null && !this.inputArea.isDisposed()) { + this.staticBanner.moveAbove(this.inputArea); } this.staticBanner.show(); requestLayout(); } + /** + * Returns the input-area wrapper that owns {@code TodoListBar}, {@code WorkingSetBar}, and the bordered chat input. + * Services creating those top bars should parent them here so the sibling {@code StaticBanner} stays above. + */ + public Composite getInputArea() { + return this.inputArea; + } + /** * Dispose the current static banner, if present. */ @@ -1030,10 +1097,7 @@ private void showMcpToolContextMenu() { approvalItem.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - String res = chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration(); - if (StringUtils.isNotBlank(res)) { - CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration(); - } + chatServiceManager.getMcpExtensionPointManager().approveExtMcpRegistration(); } }); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java index 039931b3..4979bfff 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/AgentStatusLabel.java @@ -10,7 +10,6 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; -import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -18,6 +17,7 @@ import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -25,15 +25,11 @@ * A label with icon that displays the running status of the agent. */ public class AgentStatusLabel extends Composite { - private static final int TOTAL_FRAMES = 8; // Adjust based on actual number of spinner images - - private Image runningIcon; private Image completedIcon; private Image cancelledIcon; private Label iconLabel; private ChatMarkupViewer textLabel; - private int currentFrame = 1; - private Runnable animationRunnable; + private SpinnerAnimator spinner; private Status status; private EventHandler cancelStatusHandler; private IEventBroker eventBroker; @@ -47,14 +43,12 @@ public class AgentStatusLabel extends Composite { public AgentStatusLabel(Composite parent, int style) { super(parent, style); GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 0; layout.horizontalSpacing = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); this.addDisposeListener(e -> { - stopAnimation(); - if (this.runningIcon != null && !this.runningIcon.isDisposed()) { - this.runningIcon.dispose(); - } if (this.completedIcon != null && !this.completedIcon.isDisposed()) { this.completedIcon.dispose(); } @@ -66,6 +60,7 @@ public AgentStatusLabel(Composite parent, int style) { } }); iconLabel = new Label(this, SWT.LEFT); + spinner = new SpinnerAnimator(iconLabel); this.status = Status.RUNNING; this.cancelStatusHandler = new EventHandler() { @@ -84,7 +79,7 @@ public void handleEvent(org.osgi.service.event.Event event) { * @param statusText the text to display when the agent is completed */ public void setCompletedStatus(String statusText) { - stopAnimation(); + spinner.stop(); if (this.completedIcon == null) { this.completedIcon = UiUtils.buildImageFromPngPath("/icons/complete_status.png"); @@ -101,11 +96,7 @@ public void setCompletedStatus(String statusText) { * @param statusText the text to display when the agent is running */ public void setRunningStatus(String statusText) { - // Stop any existing animation - stopAnimation(); - - // Start new animation - startAnimation(); + spinner.start(); setText(statusText); this.status = Status.RUNNING; @@ -116,7 +107,7 @@ public void setRunningStatus(String statusText) { */ public void setErrorStatus() { if (this.status == Status.RUNNING) { - stopAnimation(); + spinner.stop(); } iconLabel.setImage(PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_ERROR_TSK)); this.status = Status.ERROR; @@ -127,7 +118,7 @@ public void setErrorStatus() { */ public void setCancelledStatus() { if (this.status == Status.RUNNING) { - stopAnimation(); + spinner.stop(); if (this.cancelledIcon == null) { this.cancelledIcon = UiUtils.buildImageFromPngPath("/icons/cancel_status.png"); @@ -138,47 +129,6 @@ public void setCancelledStatus() { } } - private void startAnimation() { - final Display display = getDisplay(); - - animationRunnable = new Runnable() { - @Override - public void run() { - if (isDisposed() || iconLabel.isDisposed()) { - return; - } - - // Dispose previous image - if (runningIcon != null && !runningIcon.isDisposed()) { - runningIcon.dispose(); - } - - // Load the next frame - String imagePath = String.format("/icons/spinner/%d.png", currentFrame); - runningIcon = UiUtils.buildImageFromPngPath(imagePath); - iconLabel.setImage(runningIcon); - // request layout to update the icon, otherwise the scale of the spinner will be wrong - iconLabel.requestLayout(); - - // Update frame counter - currentFrame = (currentFrame % TOTAL_FRAMES) + 1; - - // Schedule next frame - display.timerExec(100, this); - } - }; - - // Start the animation - display.timerExec(0, animationRunnable); - } - - private void stopAnimation() { - if (animationRunnable != null) { - getDisplay().timerExec(-1, animationRunnable); - animationRunnable = null; - } - } - /** * Set the text to display next to the icon. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java new file mode 100644 index 00000000..06edf184 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +/** + * A clickable action rendered below the message of a {@link StaticBanner}. Each action is a localized label paired + * with the URL that should be opened when the user activates it. + * + * @param text the visible link label + * @param url the target URL opened on activation + */ +public record BannerAction(String text, String url) { +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 66e64742..9f099223 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -11,6 +11,7 @@ import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.source.SourceViewer; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Image; @@ -22,11 +23,21 @@ import org.eclipse.ui.PlatformUI; import org.osgi.service.event.EventHandler; +import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -45,6 +56,7 @@ public abstract class BaseTurnWidget extends Composite { protected SourceViewerComposite currentCodeBlock; protected Map<String, AgentStatusLabel> statusLabels; protected SubagentMessageBlock currentSubagentBlock; + protected Map<String, SubagentMessageBlock> subagentBlocks; // Data protected StringBuilder messageBuffer; @@ -61,6 +73,11 @@ public abstract class BaseTurnWidget extends Composite { protected Font boldFont = null; protected InvokeToolConfirmationDialog confirmDialog; + /** Returns the current confirmation dialog, or {@code null} if none active. */ + public InvokeToolConfirmationDialog getConfirmDialog() { + return confirmDialog; + } + // Footer protected Composite footer; @@ -90,6 +107,7 @@ protected BaseTurnWidget(Composite parent, int style, ChatServiceManager service this.turnId = turnId; this.codeBlockIndex = 1; this.statusLabels = new HashMap<>(); + this.subagentBlocks = new HashMap<>(); // editor group // align all children vertically GridLayout gl = new GridLayout(1, true); @@ -105,10 +123,16 @@ protected BaseTurnWidget(Composite parent, int style, ChatServiceManager service IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); this.cancelMsgEventHandler = event -> { cancelToolConfirmation(); + onChatMessageCancelled(); }; eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_MESSAGE_CANCELLED, cancelMsgEventHandler); } + /** Hook invoked when the chat message cancel event is broadcast. Default no-op; subclasses may override. */ + protected void onChatMessageCancelled() { + // no-op + } + public String getTurnId() { return turnId; } @@ -226,16 +250,26 @@ public void appendMessage(String message) { * @param toolCall the tool call of the agent turn */ public void appendToolCallStatus(AgentToolCall toolCall) { - if (toolCall == null || StringUtils.isEmpty(toolCall.getProgressMessage())) { + if (toolCall == null || toolCall.getStatus() == null) { return; } - // Check if this is a run_subagent tool call + // Subagent tool calls drive routing state for `currentSubagentBlock`/`inSubagentBlock`, + // so they must always be dispatched, even when the terminal event has no display message. if ("run_subagent".equalsIgnoreCase(toolCall.getName())) { handleSubagentToolCall(toolCall); return; } + String status = toolCall.getStatus().toLowerCase(); + // Non-error events require a non-blank progressMessage to render (otherwise we'd + // call ChatMarkupViewer#setMarkup(null) and NPE). Error events always pass through: + // getErrorDisplayText(...) provides a non-blank fallback. + boolean isError = "error".equals(status); + if (!isError && StringUtils.isBlank(toolCall.getProgressMessage())) { + return; + } + // If we're in a subagent block, route tool calls there if (inSubagentBlock && currentSubagentBlock != null) { currentSubagentBlock.appendToolCallStatus(toolCall); @@ -254,7 +288,6 @@ public void appendToolCallStatus(AgentToolCall toolCall) { AgentStatusLabel statusLabel = statusLabels.computeIfAbsent(toolCall.getId(), id -> new AgentStatusLabel(this, SWT.LEFT)); - String status = toolCall.getStatus().toLowerCase(); switch (status) { case "running": statusLabel.setRunningStatus(toolCall.getProgressMessage()); @@ -268,6 +301,7 @@ public void appendToolCallStatus(AgentToolCall toolCall) { break; case "error": statusLabel.setErrorStatus(); + statusLabel.setText(getErrorDisplayText(toolCall)); break; default: statusLabel.setErrorStatus(); @@ -292,23 +326,32 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { inSubagentBlock = true; currentSubagentBlock = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCall.getId(), toolCall); currentSubagentBlock.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCall.getId(), currentSubagentBlock); requestLayout(); } break; case "completed": // End of subagent block if (currentSubagentBlock != null) { - currentSubagentBlock.notifyTurnEnd(); + currentSubagentBlock.flushMessageBuffer(); inSubagentBlock = false; currentSubagentBlock = null; requestLayout(); + } else if (!subagentBlocks.containsKey(toolCall.getId())) { + // Restoration path: create a completed subagent block for later content injection + reset(); + SubagentMessageBlock block = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCall.getId(), + toolCall); + block.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCall.getId(), block); + requestLayout(); } break; case "cancelled": case "error": // Handle errors in subagent if (currentSubagentBlock != null) { - currentSubagentBlock.notifyTurnEnd(); + currentSubagentBlock.flushMessageBuffer(); inSubagentBlock = false; currentSubagentBlock = null; } @@ -317,12 +360,12 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { id -> new AgentStatusLabel(this, SWT.LEFT)); if ("cancelled".equals(status)) { statusLabel.setCancelledStatus(); - statusLabel.setText(toolCall.getProgressMessage()); + if (StringUtils.isNotBlank(toolCall.getProgressMessage())) { + statusLabel.setText(toolCall.getProgressMessage()); + } } else { statusLabel.setErrorStatus(); - String errorText = StringUtils.isNotEmpty(toolCall.getError()) ? toolCall.getError() - : toolCall.getProgressMessage(); - statusLabel.setText(errorText); + statusLabel.setText(getErrorDisplayText(toolCall)); } requestLayout(); break; @@ -335,6 +378,101 @@ private void handleSubagentToolCall(AgentToolCall toolCall) { } } + /** + /** + * Restores subagent content into the SubagentMessageBlock identified by the tool call ID. Creates the block if it + * doesn't exist (for restoration from persisted data). Used during conversation history restoration. + * + * @param toolCallId the run_subagent tool call ID + * @param copilotTurn the subagent's CopilotTurnData + * @param dataFactory the factory for converting tool call data + */ + public void restoreSubagentContent(String toolCallId, CopilotTurnData copilotTurn, + ConversationDataFactory dataFactory) { + // Find existing SubagentMessageBlock or create one for restoration + SubagentMessageBlock block = subagentBlocks.get(toolCallId); + if (block == null) { + block = new SubagentMessageBlock(this, SWT.NONE, serviceManager, toolCallId, null); + block.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + subagentBlocks.put(toolCallId, block); + requestLayout(); + } + + // Append subagent's content into the block + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + // Restore thinking blocks for the subagent + BaseTurnWidget subagentWidget = block.getSubagentTurnWidget(); + ThinkingTurnWidget thinkingWidget = + subagentWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) subagentWidget : null; + + if (StringUtils.isNotBlank(replyData.getText())) { + block.appendMessage(replyData.getText()); + } + + if (replyData.getEditAgentRounds() != null) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + // Restore thinking block before the round's reply and tool calls + if (thinkingWidget != null && round.getThinkingBlock() != null) { + thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); + } + if (round.getReply() != null && !round.getReply().isEmpty()) { + block.appendMessage(round.getReply()); + } + if (round.getToolCalls() != null) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = dataFactory.convertToolCallDataToAgentToolCall(toolCallData); + block.appendToolCallStatus(agentToolCall); + } + } + } + } + + // Restore error messages into the subagent block + if (replyData.getErrorMessages() != null) { + BaseTurnWidget errorWidget = block.getSubagentTurnWidget(); + if (errorWidget != null) { + for (CopilotTurnData.ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + CopilotTurnData.ErrorData errorData = errorMessageData.getError(); + String errorMessage = errorData != null ? errorData.getMessage() : ""; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + errorWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); + } + } + } + + block.flushMessageBuffer(); + } + + /** + * Resolve the user-facing error text for a tool call. + * + * <p>Picks the first non-blank of {@code toolCall.getError()} or {@code toolCall.getProgressMessage()}, + * falls back to a generic message when both are blank, and prefixes the result with the tool name + * so the user knows which tool failed. + * + * @param toolCall the failing tool call + * @return a non-blank, prefixed display string suitable for {@link AgentStatusLabel#setText(String)} + */ + private static String getErrorDisplayText(AgentToolCall toolCall) { + String detail = toolCall.getError(); + if (StringUtils.isBlank(detail)) { + detail = toolCall.getProgressMessage(); + } + if (StringUtils.isBlank(detail)) { + detail = Messages.chat_toolCall_genericError; + } + String name = toolCall.getName(); + if (StringUtils.isBlank(name)) { + return detail; + } + return NLS.bind(Messages.chat_toolCall_errorTemplate, name, detail); + } + private void processMessageLine(String line) { SwtUtils.invokeOnDisplayThread(() -> { if (line.trim().startsWith(CODE_BLOCK_ANNOTATION)) { @@ -375,6 +513,7 @@ private void appendTextToSourceViewer(String text) { private void appendTextToTextViewer(String text) { if (currentTextBlock == null) { this.createTextBlock(); + ensureFooterAtBottom(); } if (currentTextBlock instanceof ChatMarkupViewer markupViewer) { markupViewer.setMarkup(text); @@ -384,9 +523,10 @@ private void appendTextToTextViewer(String text) { } /** - * Notify the end of the turn. + * Flushes any buffered, not-yet-newline-terminated message text by processing it as a final + * line and clearing the buffer. */ - public void notifyTurnEnd() { + public void flushMessageBuffer() { if (messageBuffer.length() > 0) { this.processMessageLine(messageBuffer.toString()); messageBuffer.setLength(0); @@ -411,7 +551,8 @@ private void reset() { this.currentTextBlock = null; this.inCodeBlock = false; - // Don't reset subagent block state here - it's managed by tool call status + // Subagent and thinking blocks have their own lifecycle (tool call status / ThinkingTurnWidget) + // and are intentionally not reset here. } /** @@ -428,6 +569,7 @@ private void createCodeBlock(String language) { this.currentCodeBlock = codeBlock; this.codeBlockIndex++; + ensureFooterAtBottom(); } /** @@ -445,11 +587,53 @@ protected void createFooter() { } /** - * Create a warning dialog to the turn widget. + * Move the footer composite (if any) to the bottom of this turn widget. Should be called after + * adding a new child widget so the footer always remains visually at the bottom of the turn, + * including when content is appended to the same turn after the footer is first created (for + * example, when a legacy 402 response triggers a fallback-model replay into the same turn). */ - protected void createWarnDialog(String message, int code) { - new WarnWidget(this, SWT.BOTTOM, message, code); + protected void ensureFooterAtBottom() { + if (this.footer != null && !this.footer.isDisposed()) { + this.footer.moveBelow(null); + } + } + + /** + * Render a warning dialog under the turn. BYOK 402 (402 + non-blank provider name) shows the BYOK message with no + * actions; plain 402 shows the server message plus plan-driven actions; other codes show the server message only. + * + * @param message the server error message + * @param code the server error code + * @param modelProviderName the BYOK model-provider name, or {@code null} for built-in models + */ + protected Composite createWarnDialog(String message, int code, String modelProviderName) { + // TODO: Remove this legacy fallback after TBB is officially released. + // When the language server has not enabled token-based billing yet, restore the original + // main-branch warning behavior (no plan-driven actions; single upgrade button on the legacy + // 30-day free trial message). + if (!this.serviceManager.getAuthStatusManager().getQuotaStatus().tokenBasedBillingEnabled()) { + WarnWidget warnWidget = new WarnWidget(this, SWT.BOTTOM, message, code); + ensureFooterAtBottom(); + requestLayout(); + return warnWidget; + } + boolean byokQuotaExceeded = QuotaActions.isByokQuotaExceeded(code, modelProviderName); + String displayMessage = byokQuotaExceeded ? Messages.chat_warnWidget_byokQuotaUsageMessage : message; + CopilotPlan planForActions = null; + boolean overageEnabled = false; + Boolean canUpgradePlan = null; + if (code == 402 && !byokQuotaExceeded) { + CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + planForActions = quotaStatus.copilotPlan(); + overageEnabled = quotaStatus.premiumInteractions() != null + && quotaStatus.premiumInteractions().overagePermitted(); + canUpgradePlan = quotaStatus.canUpgradePlan(); + } + WarnWidget warnWidget = + new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled, canUpgradePlan); + ensureFooterAtBottom(); requestLayout(); + return warnWidget; } /** @@ -463,20 +647,41 @@ protected void createAgentMessageWidget(CodingAgentMessageRequestParams params) /** * Prompts the user to confirm or deny a tool execution. * - * @param title The title of the confirmation dialog. - * @param message The message to display in the confirmation dialog. + * @param content The confirmation content with title, message, and action buttons. * @param input The input object to be passed to the tool. */ - public CompletableFuture<LanguageModelToolConfirmationResult> requestToolExecutionConfirmation(String title, - String message, Object input) { - // process all the messages before showing the confirmation dialog + public CompletableFuture<LanguageModelToolConfirmationResult> requestToolExecutionConfirmation( + ConfirmationContent content, Object input) { reset(); - this.confirmDialog = new InvokeToolConfirmationDialog(this, title, message, input); + this.confirmDialog = new InvokeToolConfirmationDialog(this, content, input); + this.confirmDialog.addDisposeListener(e -> { + Composite ancestor = this.getParent(); + while (ancestor != null && !ancestor.isDisposed()) { + if (ancestor instanceof ChatContentViewer viewer) { + SwtUtils.invokeOnDisplayThreadAsync(() -> viewer.refreshLayoutFull(), viewer); + break; + } + ancestor = ancestor.getParent(); + } + }); CompletableFuture<LanguageModelToolConfirmationResult> toolConfirmationFuture = this.confirmDialog .getConfirmationFuture(); - this.getParent().layout(); + this.getParent().requestLayout(); + + // Ensure the chat content viewer scrolls to show the newly created confirmation + // dialog. Walk up the composite hierarchy to find a ChatContentViewer + // and request scrolling. Use async exec because layout needs to complete first. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + ChatContentViewer viewer = SwtUtils.findParentOfType(this.getParent(), ChatContentViewer.class); + if (viewer != null) { + if (this.confirmDialog != null && !this.confirmDialog.isDisposed()) { + viewer.showControl(this.confirmDialog); + } + } + + }, this.getParent()); return toolConfirmationFuture; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java index 208c64e5..0b961438 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatAssistProcessor.java @@ -3,10 +3,15 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; +import java.util.Map.Entry; import java.util.Objects; +import org.apache.commons.lang3.StringUtils; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.ITextViewer; @@ -27,6 +32,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -43,11 +49,17 @@ public ChatAssistProcessor(TextViewer input, ChatServiceManager chatServiceManag class ChatCompletionProposal implements ICompletionProposal, ICompletionProposalExtension6 { private String triggerCharacter; private String name; + private String displayName; private String description; public ChatCompletionProposal(String mark, String name, String description) { + this(mark, name, name, description); + } + + public ChatCompletionProposal(String mark, String name, String displayName, String description) { this.triggerCharacter = mark; this.name = name; + this.displayName = displayName; this.description = description; } @@ -80,7 +92,7 @@ public IContextInformation getContextInformation() { @Override public String getDisplayString() { - return triggerCharacter + name; + return triggerCharacter + displayName; } @Override @@ -96,30 +108,63 @@ public Point getSelection(IDocument document) { @Override public StyledString getStyledDisplayString() { StyledString styledString = new StyledString(); - styledString.append(triggerCharacter + name); + styledString.append(triggerCharacter + displayName); styledString.append(" - " + description, StyledString.QUALIFIER_STYLER); return styledString; } } public ICompletionProposal[] createCopilotCompletionTemplateProposals(String prefix) { - List<ICompletionProposal> proposals = new ArrayList<>(); ChatCompletionService commandService = chatServiceManager.getChatCompletionService(); if (!commandService.isTempaltesReady()) { return new ICompletionProposal[0]; } - // So far no template supports agent mode. - if (Objects.equals(chatServiceManager.getUserPreferenceService().getActiveChatMode(), ChatMode.Agent)) { - return new ICompletionProposal[0]; + // Filter templates by the scope matching the active chat mode (ask → chat-panel, agent → agent-panel). + ChatMode chatMode = chatServiceManager.getUserPreferenceService().getActiveChatMode(); + ConversationTemplate[] templates = commandService.getFilteredTemplates(chatMode); + String lowerPrefix = prefix.toLowerCase(); + + // Sort results by match quality, then build proposals. + return Arrays.stream(templates).filter(t -> StringUtils.isNotBlank(t.id())) + .map(t -> new SimpleEntry<>(t, getMatchPriority(t, lowerPrefix))) + .filter(e -> e.getValue() >= 0).sorted(Comparator.comparingInt(Entry::getValue)).map(e -> { + ConversationTemplate t = e.getKey(); + boolean isSkill = t.source() == TemplateSource.SKILL; + String displayName = isSkill && StringUtils.isNotBlank(t.shortDescription()) ? t.shortDescription() : t.id(); + return (ICompletionProposal) new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, t.id(), + displayName, t.description()); + }).toArray(ICompletionProposal[]::new); + } + + /** + * Returns a priority for how well the template matches the prefix (lower is better), + * or -1 if it does not match at all. + * + * <p>Priority buckets: + * 0 – id starts with prefix (or prefix is empty) + * 1 – id contains prefix (or skill shortDescription contains prefix) + * 2 – description starts with prefix + * 3 – description contains prefix + */ + private int getMatchPriority(ConversationTemplate template, String lowerPrefix) { + if (lowerPrefix.isEmpty()) { + return 0; } - ConversationTemplate[] templates = commandService.getTemplates(); - for (ConversationTemplate template : templates) { - if (prefix.isEmpty() || template.getId().startsWith(prefix)) { - proposals.add(new ChatCompletionProposal(ChatCompletionService.TEMPLATE_MARK, template.getId(), - template.getDescription())); - } + boolean isSkill = template.source() == TemplateSource.SKILL; + String id = template.id() != null ? template.id().toLowerCase() : ""; + String desc = template.description() != null ? template.description().toLowerCase() : ""; + String shortDesc = template.shortDescription() != null ? template.shortDescription().toLowerCase() : ""; + + if (id.startsWith(lowerPrefix)) { + return 0; + } else if (id.contains(lowerPrefix) || (isSkill && shortDesc.contains(lowerPrefix))) { + return 1; + } else if (desc.startsWith(lowerPrefix)) { + return 2; + } else if (desc.contains(lowerPrefix)) { + return 3; } - return proposals.toArray(new ICompletionProposal[proposals.size()]); + return -1; } public ICompletionProposal[] createCopilotCompletionAgentProposals(String prefix) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index a36a66cb..b3db64b4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -5,22 +5,26 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; -import org.eclipse.swt.custom.ScrolledComposite; -import org.eclipse.swt.events.ControlAdapter; -import org.eclipse.swt.events.ControlEvent; -import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; -import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.ui.PlatformUI; @@ -32,34 +36,72 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** * Widget to display chat content. + * + * <p>A self-managed, windowed vertical scroller (not a {@link org.eclipse.swt.custom.ScrolledComposite}): + * on Windows a classic scroller cannot move very tall content far enough to reveal the newest messages. + * Here {@code cmpContent} is pinned to the viewport and each turn is positioned in + * viewport-local coordinates; off-window turns are parked with {@code setVisible(false)}, so no child is + * ever given an out-of-range native coordinate.</p> */ -public class ChatContentViewer extends ScrolledComposite { +public class ChatContentViewer extends Composite { private static final int SCROLL_THRESHOLD = 100; + /** + * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the + * language server appends to user-facing error messages. + */ + private static final Pattern REQUEST_ID_SUFFIX = + Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); + private ChatServiceManager serviceManager; + private String conversationId; private Composite cmpContent; private Map<String, BaseTurnWidget> turns; + private Map<String, String> activeThinkingBlockIds; private Composite errorWidget; private BaseTurnWidget latestUserTurn; - private BaseTurnWidget latestCopilotTurn; + private CopilotTurnWidget latestCopilotTurn; private BaseTurnWidget latestTurnWidget; - // Auto-scroll state management private boolean autoScrollEnabled; + /** Streaming events queued by LSP threads and drained on the UI thread in batches. */ + private final Queue<ChatProgressValue> pendingEvents = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(false); + + /** Client-area width of the last layout pass; a change forces a full re-measure (text re-wraps). */ + private int lastLayoutWidth = -1; + + /** Guards against scheduling more than one pending async refresh from a burst of resize events. */ + private final AtomicBoolean refreshScheduled = new AtomicBoolean(false); + + /** Current logical scroll position (top of the viewport in content coordinates). */ + private int scrollOffset; + + /** Full logical content height in pixels; may far exceed any native coordinate limit. */ + private int totalHeight; + + /** Cached measured heights keyed by row control identity; invalidated on width change. */ + private final Map<Control, Integer> heightCache = new IdentityHashMap<>(); + + /** Cached font line height (px), the unit for one scroll line; recomputed when the font changes. */ + private int cachedLineHeight = -1; + /** * Create the composite. * @@ -67,46 +109,48 @@ public class ChatContentViewer extends ScrolledComposite { * @param style the style */ public ChatContentViewer(Composite parent, int style, ChatServiceManager serviceManager) { - super(parent, style | SWT.V_SCROLL); - this.setExpandHorizontal(true); - this.setExpandVertical(true); - this.setLayout(new GridLayout(1, true)); + super(parent, style | SWT.V_SCROLL | SWT.DOUBLE_BUFFERED); + // Null layout: children are positioned manually by relayoutWindow() so SWT never stacks them into + // one oversized composite. + this.setLayout(null); this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); this.setData(CssConstants.CSS_ID_KEY, "chat-content-viewer"); this.cmpContent = new Composite(this, SWT.NONE); - GridLayout gl = new GridLayout(1, true); - gl.marginHeight = 0; - gl.marginWidth = 0; - this.cmpContent.setLayout(gl); - this.cmpContent.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); - this.setContent(this.cmpContent); - - this.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - refreshScrollerLayout(); - } + this.cmpContent.setLayout(null); + + this.addListener(SWT.Resize, e -> { + layoutContentArea(); + coalesceAsync(refreshScheduled, this::refreshLayoutIncremental); }); - // Listen for user scroll events to manage auto-scroll behavior ScrollBar verticalBar = this.getVerticalBar(); if (verticalBar != null) { verticalBar.addListener(SWT.Selection, event -> { - int selection = verticalBar.getSelection(); - int maximum = verticalBar.getMaximum(); - int thumb = verticalBar.getThumb(); - - // If scrolled to bottom, keep auto-scroll enabled - // Otherwise disable it (user is viewing history) - int threshold = SCROLL_THRESHOLD; - int maxScrollPosition = maximum - thumb; - boolean isAtBottom = selection >= (maxScrollPosition - threshold); - autoScrollEnabled = isAtBottom; + scrollOffset = verticalBar.getSelection(); + autoScrollEnabled = isViewportAtBottom(); + relayoutWindow(); }); } + // The wheel does not move the scrollbar on a manually-laid-out composite, so handle it explicitly. + this.addListener(SWT.MouseWheel, event -> { + if (totalHeight <= getClientArea().height) { + return; + } + // event.count is the OS-provided number of lines to scroll for this notch; a font line height + // turns it into pixels, so one notch moves whole text lines like a native scrollable. + int newOffset = clampOffset(scrollOffset - event.count * lineHeight()); + if (newOffset != scrollOffset) { + scrollOffset = newOffset; + autoScrollEnabled = isViewportAtBottom(); + relayoutWindow(); + } + event.doit = false; + }); + this.turns = new HashMap<>(); + this.activeThinkingBlockIds = new ConcurrentHashMap<>(); this.serviceManager = serviceManager; @@ -119,9 +163,9 @@ public void controlResized(ControlEvent e) { public void startNewTurn(String workDoneToken, String message) { BaseTurnWidget turnWidget = getLatestOrCreateNewTurnWidget(workDoneToken, false, true); turnWidget.appendMessage(message); - turnWidget.notifyTurnEnd(); + turnWidget.flushMessageBuffer(); - refreshScrollerLayout(); + refreshLayoutFull(); scrollToLatestUserTurn(); // Reset auto-scroll for new conversation turn autoScrollEnabled = true; @@ -144,9 +188,11 @@ public BaseTurnWidget getLatestOrCreateNewTurnWidget(String workDoneToken, boole turnWidget = latestTurnWidget; } else if (isCopilot) { // Create new Copilot turn widget - turnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); - latestCopilotTurn = turnWidget; - latestTurnWidget = turnWidget; + CopilotTurnWidget copilotTurnWidget = new CopilotTurnWidget(cmpContent, SWT.NONE, serviceManager, + workDoneToken); + latestCopilotTurn = copilotTurnWidget; + latestTurnWidget = copilotTurnWidget; + turnWidget = copilotTurnWidget; } else { // Create new User turn widget turnWidget = new UserTurnWidget(cmpContent, SWT.NONE, serviceManager, workDoneToken); @@ -156,6 +202,7 @@ public BaseTurnWidget getLatestOrCreateNewTurnWidget(String workDoneToken, boole } turns.put(workDoneToken, turnWidget); + activeThinkingBlockIds.remove(workDoneToken); ref.set(turnWidget); }, this); @@ -163,91 +210,160 @@ public BaseTurnWidget getLatestOrCreateNewTurnWidget(String workDoneToken, boole } + /** Set the conversation ID used for thinking-block persistence. */ + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + /** - * Process turn event. + * Process turn event. Events are queued and drained in batches on the UI thread so the LSP thread + * is never blocked and multiple in-flight events coalesce into a single layout pass. */ public void processTurnEvent(ChatProgressValue value) { - SwtUtils.invokeOnDisplayThread(() -> { - if (!turns.containsKey(value.getTurnId())) { - CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); - return; - } - BaseTurnWidget turnWidget = turns.get(value.getTurnId()); - if (turnWidget == null) { - appendMessageToTheLatestTurn(value.getReply()); - } - - ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + pendingEvents.offer(value); + coalesceAsync(drainScheduled, this::drainPendingEvents); + } - if (value.getKind() == WorkDoneProgressKind.report) { - if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { - // Handle agent mode responses - AgentRound agentRound = value.getAgentRounds().get(0); + private void drainPendingEvents() { + if (isDisposed()) { + pendingEvents.clear(); + return; + } + ChatProgressValue event; + while ((event = pendingEvents.poll()) != null) { + doProcessTurnEvent(event); + } + refreshLayoutIncremental(); + scrollToBottomIfAutoScroll(); + // Events may have arrived while draining; schedule a follow-up drain if so. + if (!pendingEvents.isEmpty()) { + coalesceAsync(drainScheduled, this::drainPendingEvents); + } + } - if (agentRound.getReply() != null) { - turnWidget.appendMessage(agentRound.getReply()); - } + private void doProcessTurnEvent(ChatProgressValue value) { + if (!turns.containsKey(value.getTurnId())) { + CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); + return; + } + BaseTurnWidget turnWidget = turns.get(value.getTurnId()); + if (turnWidget == null) { + CopilotCore.LOGGER.error(new IllegalStateException("turnWidget not found: " + value.getTurnId())); + appendMessageToTheLatestTurn(value.getReply()); + return; + } - if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { - AgentToolCall toolCall = agentRound.getToolCalls().get(0); - turnWidget.appendToolCallStatus(toolCall); + ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - // Extract and process todo list from tool result details - processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); - } - } else { - // Handle chat mode responses - turnWidget.appendMessage(value.getReply()); + if (value.getKind() == WorkDoneProgressKind.report) { + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.setConversationContext(conversationId, value.getTurnId()); + thinkingTurn.appendThinking(value.getThinking()); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); + if (hasRenderableOutput(value)) { + // Seal before appending the reply so the spinner stops and the title is fetched. + thinkingTurn.sealThinking(); } - } else if (value.getKind() == WorkDoneProgressKind.end) { - turnWidget.notifyTurnEnd(); } - refreshScrollerLayout(); - // Auto-scroll to bottom if enabled - if (shouldAutoScrollToBottom()) { - scrollToBottom(); - } + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + // Handle agent mode responses + AgentRound agentRound = value.getAgentRounds().get(0); - String errMsg = value.getErrorMessage(); - String reason = value.getErrorReason(); - if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { - // TODO: add enable button for better UX. - errMsg = Messages.chat_model_unsupported_message; - } - if (StringUtils.isNotEmpty(errMsg)) { - // TODO: remove this error message replacement if statement when the CLS side warn message is aligned. - if (value.getCode() == 402) { - CopilotPlan userPlan = this.serviceManager.getAuthStatusManager().getQuotaStatus().getCopilotPlan(); - CopilotModel fallbackModel = this.serviceManager.getModelService().getFallbackModel(); - String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() - : Messages.chat_noQuotaView_fallbackModel; - - if (userPlan == CopilotPlan.individual || userPlan == CopilotPlan.individual_pro) { - // Pro and Pro+ message - errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); - } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { - // CE and CB message - errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); - } + if (agentRound.getReply() != null) { + turnWidget.appendMessage(agentRound.getReply()); } - renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode()); + if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { + AgentToolCall toolCall = agentRound.getToolCalls().get(0); + turnWidget.appendToolCallStatus(toolCall); - if (value.getCode() == 402 - && this.serviceManager.getAuthStatusManager().getQuotaStatus().getCopilotPlan() != CopilotPlan.free) { - this.serviceManager.getModelService().setFallBackModelAsActiveModel(); - this.serviceManager.getAuthStatusManager().checkQuota(); + // Extract and process todo list from tool result details + processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); + } + } else { + // Handle chat mode responses + turnWidget.appendMessage(value.getReply()); + } + } else if (value.getKind() == WorkDoneProgressKind.end) { + // Seal any in-progress thinking block before the turn ends. + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.sealThinking(); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); + } + turnWidget.flushMessageBuffer(); + } - String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); - if (StringUtils.isNotEmpty(previousInput)) { - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - Map<String, Object> properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); - eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); - } + String errMsg = value.getErrorMessage(); + if (StringUtils.isNotEmpty(errMsg)) { + errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); + } + String reason = value.getErrorReason(); + if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { + // TODO: add enable button for better UX. + errMsg = Messages.chat_model_unsupported_message; + } + if (StringUtils.isNotEmpty(errMsg)) { + // TODO: Remove this legacy fallback after TBB is officially released. + // When the language server has not enabled token-based billing yet, fall back to the + // original main-branch 402 behavior: replace the message with a plan-driven fallback + // notice, switch to the fallback model, refresh quota, and replay the previous input. + CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + CopilotModel fallbackModel = null; + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402) { + CopilotPlan userPlan = quotaStatus.copilotPlan(); + fallbackModel = this.serviceManager.getModelService().getFallbackModel(); + String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() + : Messages.chat_noQuotaView_fallbackModel; + + if (MenuUtils.isCfiPlan(userPlan)) { + // Pro, Pro+ and Max message + errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); + } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { + // CE and CB message + errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); } } - }, this); + + renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); + + // TODO: Remove this legacy fallback after TBB is officially released. + // Only replay the previous input when a fallback model is actually available; otherwise + // setFallBackModelAsActiveModel() is a no-op and re-posting the input with the same + // active model would just trigger the same 402 again. + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402 + && quotaStatus.copilotPlan() != CopilotPlan.free + && fallbackModel != null) { + // Detach the failed turn so the replayed response creates a new Copilot turn below the + // warning, instead of streaming into the same turn that just rendered the warn widget. + this.latestTurnWidget = null; + this.latestCopilotTurn = null; + + this.serviceManager.getModelService().setFallBackModelAsActiveModel(); + this.serviceManager.getAuthStatusManager().checkQuota(); + + String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); + if (StringUtils.isNotEmpty(previousInput)) { + IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + Map<String, Object> properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); + } + } + } + } + + /** Returns the active thinking block ID last observed while processing this turn's progress. */ + public String getActiveThinkingBlockId(String turnId) { + return activeThinkingBlockIds.get(turnId); + } + + private void updateActiveThinkingBlockId(String turnId, ThinkingTurnWidget thinkingTurn) { + String thinkingBlockId = thinkingTurn.getActiveThinkingBlockId(); + if (StringUtils.isBlank(thinkingBlockId)) { + activeThinkingBlockIds.remove(turnId); + } else { + activeThinkingBlockIds.put(turnId, thinkingBlockId); + } } /** @@ -259,6 +375,29 @@ public void appendMessageToTheLatestTurn(String message) { } } + /** + * Whether {@code value} carries reply text or an agent round with rendered content; thinking-only reports return + * {@code false} so the banner keeps streaming. + */ + private static boolean hasRenderableOutput(ChatProgressValue value) { + return StringUtils.isNotBlank(value.getReply()) || hasRenderableAgentRound(value); + } + + private static boolean hasRenderableAgentRound(ChatProgressValue value) { + if (value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) { + return false; + } + for (AgentRound round : value.getAgentRounds()) { + if (StringUtils.isNotBlank(round.getReply())) { + return true; + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + return true; + } + } + return false; + } + /** * Process todo list from tool call result. Extracts todo list data from the tool-specific data * and updates the TodoListService. @@ -289,6 +428,38 @@ private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, } } + /** + * Shows the compacting status on the latest Copilot turn after flushing any buffered reply text. + */ + public void showCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Flush any buffered reply text from the previous round so it is rendered + // above the compacting spinner; otherwise it would be concatenated with + // the next round's reply and produce a single garbled line. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.showCompactingStatus(); + refreshLayoutFull(); + scrollToBottomIfAutoScroll(); + } + + /** + * Hides the compacting status on the latest Copilot turn, flushing any buffered reply text + * first as a guard against buffered content that was not flushed by an end progress event. + */ + public void hideCompactingStatusOnLatestCopilotTurn() { + if (latestCopilotTurn == null || latestCopilotTurn.isDisposed()) { + return; + } + // Always flush before hiding; the buffer should be empty at this point, but flush as a guard + // in case a cancel path did not receive an end progress event to flush it. + latestCopilotTurn.flushMessageBuffer(); + latestCopilotTurn.hideCompactingStatus(); + refreshLayoutFull(); + scrollToBottomIfAutoScroll(); + } + /** * Get an existed turn widget by turn ID. */ @@ -296,10 +467,18 @@ public BaseTurnWidget getTurnWidget(String turnId) { return turns.get(turnId); } - private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code) { - latestTurnWidget.createWarnDialog(errorMessage, code); - refreshScrollerLayout(); + private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code, String modelProviderName) { + Composite warnWidget = latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); + refreshLayoutFull(); scrollToLatestUserTurn(); + // Ensure the chat content viewer scrolls to show the newly created warning banner. Walk up the composite hierarchy + // to find a ChatContentViewer and request scrolling. Use async exec because layout needs to complete first. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (warnWidget != null && !warnWidget.isDisposed()) { + showControl(warnWidget); + } + + }, this.getParent()); } /** @@ -310,77 +489,266 @@ public void renderErrorMessage(String errorMessage) { this.errorWidget.dispose(); } this.errorWidget = new ErrorWidget(cmpContent, SWT.BOTTOM, errorMessage); - refreshScrollerLayout(); + refreshLayoutFull(); scrollToLatestUserTurn(); + // Ensure the chat content viewer scrolls to show the newly created error banner. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.errorWidget != null && !this.errorWidget.isDisposed()) { + this.showControl(this.errorWidget); + } + }, this.getParent()); } /** - * Update the size of scrolled composite when there are content updates. + * Coalesces a burst of calls into a single async pass on the UI thread, clearing {@code scheduled} + * right before {@code task} runs so work arriving during the task re-schedules a follow-up pass. + * Breaks synchronous re-entrancy without a re-entrancy guard. + * + * @param scheduled the per-task latch guarding against duplicate scheduling + * @param task the work to run once on the next UI-thread turn */ - public void refreshScrollerLayout() { - if (this.isDisposed()) { - return; + private void coalesceAsync(AtomicBoolean scheduled, Runnable task) { + if (scheduled.compareAndSet(false, true)) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + scheduled.set(false); + task.run(); + }, this); } + } - Rectangle clientArea = this.getClientArea(); - Point containerSize = cmpContent.computeSize(clientArea.width, SWT.DEFAULT); + /** + * Full re-measure entry point for external callers. Layout only; scrolling is a separate concern + * handled by callers via {@link #scrollToBottomIfAutoScroll()}. + */ + public void refreshLayoutFull() { + refreshLayout(MeasureMode.FULL); + } - // Use the default size as a fallback - if (latestUserTurn == null) { - this.setMinSize(containerSize); + /** + * Incremental re-measure of just the trailing (streaming) turns. Layout only; scrolling is handled + * separately by callers via {@link #scrollToBottomIfAutoScroll()}. + */ + private void refreshLayoutIncremental() { + refreshLayout(MeasureMode.INCREMENTAL); + } + + /** + * Selects how many turns {@link #refreshLayout(MeasureMode)} re-measures. + */ + private enum MeasureMode { + /** Re-measure every turn. */ + FULL, + /** Only re-measure the trailing (mutating) turns; sealed turns keep cached sizes. */ + INCREMENTAL + } + + /** + * Re-measures turns and re-runs the windowing pass. {@link MeasureMode#INCREMENTAL} keeps sealed + * turns' cached heights; a width change forces a full re-measure because text re-wraps. + */ + private void refreshLayout(MeasureMode mode) { + if (this.isDisposed()) { return; } + Rectangle clientArea = this.getClientArea(); + int width = clientArea.width; + boolean fullMeasure = mode == MeasureMode.FULL || width != lastLayoutWidth; + lastLayoutWidth = width; - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - - // Calculate the content height, so that the latest user turn is able to be put at the top of the client area. - int contentHeight = 0; - int roundedHeight = userTurnSize.y + copilotTurnSize.y; - if (roundedHeight < clientArea.height) { - contentHeight = clientArea.height + containerSize.y - roundedHeight; + if (fullMeasure) { + heightCache.clear(); } else { - contentHeight = containerSize.y; + invalidateTrailingTurnHeights(); } - this.setMinHeight(contentHeight); - this.setMinWidth(containerSize.x); - this.layout(true, true); + layoutContentArea(); + relayoutWindow(); } /** - * Check if auto-scroll to bottom is needed. Only scroll when auto-scroll is enabled (user hasn't manually scrolled - * during response). + * Scrolls to the bottom when auto-scroll is enabled. The bottom padding reserved by {@link + * #relayoutWindow()} makes this pin the latest user turn to the top while the round is short, then + * follow the real bottom once it grows past the viewport. */ - private boolean shouldAutoScrollToBottom() { - if (this.isDisposed() || latestUserTurn == null) { - return false; + public void scrollToBottomIfAutoScroll() { + if (this.isDisposed() || latestUserTurn == null || latestUserTurn.isDisposed()) { + return; } - if (!autoScrollEnabled) { - return false; + return; + } + scrollOffset = Integer.MAX_VALUE; + relayoutWindow(); + } + + /** + * Drops the cached heights of the trailing (mutating) turns so they are re-measured next pass, while + * sealed historical turns keep their cached size. + */ + private void invalidateTrailingTurnHeights() { + if (latestUserTurn != null && !latestUserTurn.isDisposed()) { + heightCache.remove(latestUserTurn); + } + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + heightCache.remove(latestCopilotTurn); } + if (errorWidget != null && !errorWidget.isDisposed()) { + heightCache.remove(errorWidget); + } + } + /** Pins {@code cmpContent} to the current viewport rectangle so it is never grown or moved. */ + private void layoutContentArea() { + if (cmpContent == null || cmpContent.isDisposed()) { + return; + } Rectangle clientArea = this.getClientArea(); - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); + cmpContent.setBounds(0, 0, Math.max(0, clientArea.width), Math.max(0, clientArea.height)); + } - int roundedHeight = userTurnSize.y + copilotTurnSize.y; + /** + * The core windowing pass: measures every turn (cached), positions the ones intersecting the + * viewport in viewport-local coordinates, and parks the rest with {@code setVisible(false)} so no + * native child ever gets an out-of-range coordinate. + */ + private void relayoutWindow() { + if (this.isDisposed() || cmpContent == null || cmpContent.isDisposed()) { + return; + } + Rectangle clientArea = this.getClientArea(); + int width = clientArea.width; + int viewport = clientArea.height; + if (width <= 0 || viewport <= 0) { + return; + } + + Control[] children = cmpContent.getChildren(); + int[] tops = new int[children.length]; + int[] heights = new int[children.length]; + boolean[] remeasured = new boolean[children.length]; + int running = 0; + int latestUserTop = -1; + for (int i = 0; i < children.length; i++) { + if (children[i] == latestUserTurn) { + latestUserTop = running; + } + boolean wasCached = heightCache.containsKey(children[i]); + int height = measuredHeight(children[i], width); + tops[i] = running; + heights[i] = height; + // A cache miss means the turn was (re)measured this pass: its width changed or its content + // mutated, so its internal GridLayout must be re-run. + remeasured[i] = !wasCached; + running += height; + } + int rawHeight = running; + + // Bottom padding (virtual, no widget): when the last round is shorter than the viewport, reserve + // whitespace below it so the latest user turn can pin to the top instead of floating mid-screen. + // Without it maxOffset is too small, so the new message cannot reach the top and "scroll to + // bottom" misaligns with the real maximum, breaking auto-scroll. + int bottomPadding = 0; + if (latestUserTop >= 0) { + int lastRoundHeight = rawHeight - latestUserTop; + if (lastRoundHeight < viewport) { + bottomPadding = viewport - lastRoundHeight; + } + } + totalHeight = rawHeight + bottomPadding; + scrollOffset = clampOffset(scrollOffset); + + for (int i = 0; i < children.length; i++) { + Control child = children[i]; + if (child.isDisposed()) { + continue; + } + int y = tops[i] - scrollOffset; + if (y + heights[i] > 0 && y < viewport) { + child.setBounds(0, y, width, heights[i]); + if (!child.getVisible()) { + child.setVisible(true); + } + // Run the turn's own layout so its GridLayout children (wrapped text, code blocks, footers) + // reflow. + if (remeasured[i] && child instanceof Composite composite) { + composite.layout(); + } + } else if (child.getVisible()) { + child.setVisible(false); + } + } - // Only auto-scroll when content height exceeds the visible area - return roundedHeight >= clientArea.height; + updateScrollBar(viewport); + } + + /** Returns the measured height of a row, using the identity cache when available. */ + private int measuredHeight(Control child, int width) { + if (child == null || child.isDisposed()) { + return 0; + } + Integer cached = heightCache.get(child); + if (cached != null) { + return cached; + } + int height = child.computeSize(width, SWT.DEFAULT, true).y; + heightCache.put(child, height); + return height; + } + + private void updateScrollBar(int viewport) { + ScrollBar verticalBar = this.getVerticalBar(); + if (verticalBar == null) { + return; + } + if (totalHeight <= viewport) { + int safeViewport = Math.max(1, viewport); + verticalBar.setValues(0, 0, safeViewport, safeViewport, lineHeight(), safeViewport); + verticalBar.setEnabled(false); + return; + } + verticalBar.setEnabled(true); + verticalBar.setValues(scrollOffset, 0, totalHeight, viewport, lineHeight(), viewport); + } + + private int maxOffset() { + return Math.max(0, totalHeight - getClientArea().height); + } + + private int clampOffset(int offset) { + return Math.max(0, Math.min(offset, maxOffset())); + } + + private boolean isViewportAtBottom() { + return scrollOffset >= maxOffset() - SCROLL_THRESHOLD; + } + + /** One scroll "line" in pixels: the current font's line height. Cached until the font changes. */ + private int lineHeight() { + if (cachedLineHeight < 0) { + GC gc = new GC(this); + try { + gc.setFont(getFont()); + cachedLineHeight = Math.max(1, gc.getFontMetrics().getHeight()); + } finally { + gc.dispose(); + } + } + return cachedLineHeight; + } + + @Override + public void setFont(Font font) { + super.setFont(font); + cachedLineHeight = -1; } /** * Scroll to the bottom. */ private void scrollToBottom() { - ScrollBar verticalBar = this.getVerticalBar(); - if (verticalBar != null) { - this.setOrigin(0, verticalBar.getMaximum()); - } + autoScrollEnabled = true; + scrollOffset = Integer.MAX_VALUE; + relayoutWindow(); } /** @@ -388,25 +756,82 @@ private void scrollToBottom() { */ private void scrollToLatestUserTurn() { // Scroll to the bottom as a fallback. - if (latestUserTurn == null) { + if (latestUserTurn == null || latestUserTurn.isDisposed()) { scrollToBottom(); return; } - // Use async execution to ensure layout is computed before reading positions. - // Using sync execution would read positions before the layout is complete, - // resulting in incorrect scroll position (always scrolling to 0). + // Async so heights are measured before reading positions. SwtUtils.invokeOnDisplayThreadAsync(() -> { if (this.isDisposed() || latestUserTurn.isDisposed()) { return; } - Point turnLocation = latestUserTurn.getLocation(); - this.setOrigin(0, turnLocation.y); + scrollOffset = clampOffset(topOf(latestUserTurn)); + relayoutWindow(); }, this); } + /** Returns the cumulative top offset (in content coordinates) of the given row control. */ + private int topOf(Control target) { + int width = this.getClientArea().width; + int running = 0; + for (Control child : cmpContent.getChildren()) { + if (child == target) { + break; + } + running += measuredHeight(child, width); + } + return running; + } + + /** + * Scrolls the viewport to make {@code target} visible, equivalent to + * {@link org.eclipse.swt.custom.ScrolledComposite#showControl(Control)}. + * + * <p>Walks up the widget tree to find the direct child of {@code cmpContent} that contains + * {@code target}, computes the content-coordinate position of {@code target} by summing + * the turn's {@link #topOf} value with the local y offsets down to {@code target}, then + * adjusts {@link #scrollOffset} by the minimum amount needed to bring {@code target} fully + * into the viewport.</p> + */ + public void showControl(Composite target) { + if (target == null || target.isDisposed()) { + return; + } + // Walk up to find the direct child of cmpContent that is the ancestor of target. + Control ancestor = target; + while (ancestor != null && ancestor.getParent() != cmpContent) { + ancestor = ancestor.getParent(); + } + if (ancestor == null || ancestor.getParent() != cmpContent) { + return; + } + // Content-coordinate top of the enclosing turn widget. + int ancestorTop = topOf(ancestor); + // Accumulate the local y offset by walking from target up to (but not including) ancestor. + int localY = 0; + for (Control c = target; c != ancestor; c = c.getParent()) { + localY += c.getLocation().y; + } + int targetTop = ancestorTop + localY; + int targetBottom = targetTop + target.getSize().y; + int viewport = getClientArea().height; + // Scroll the minimum amount: down if target is below the visible area, up if above. + int newOffset = scrollOffset; + if (targetBottom > scrollOffset + viewport) { + newOffset = targetBottom - viewport; + } + if (targetTop < newOffset) { + newOffset = targetTop; + } + scrollOffset = clampOffset(newOffset); + relayoutWindow(); + } + @Override public void dispose() { + pendingEvents.clear(); + heightCache.clear(); super.dispose(); for (BaseTurnWidget turn : turns.values()) { turn.dispose(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java index 91f47dc0..adec34dc 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatInputTextViewer.java @@ -58,6 +58,8 @@ public class ChatInputTextViewer extends UndoableTextViewer implements PaintList private Color placeholderColor; + private Runnable layoutRefreshCallback; + /** * Constructs a new ChatInputTextViewer. * @@ -77,6 +79,17 @@ public void setSendMessageHandler(Consumer<String> handler) { this.sendMessageHandler = handler; } + /** + * Registers a callback invoked after the input area's preferred height changes, so the owner can relayout the + * enclosing chat view container. This avoids a brittle fixed-depth parent traversal that breaks whenever the input + * area is rewrapped in a new composite (see issue #215). + * + * @param callback the layout-refresh callback; may be {@code null} to clear + */ + public void setLayoutRefreshCallback(Runnable callback) { + this.layoutRefreshCallback = callback; + } + public String getContent() { return this.getDocument().get(); } @@ -188,15 +201,24 @@ private boolean isInsertLineBreakOnly(TextEvent event) { private void refreshHeightLayout() { StyledText tvw = this.getTextWidget(); + if (tvw == null || tvw.isDisposed()) { + return; + } // If the width is not initialized, use SWT.DEFAULT to compute the size // otherwise, swt will think that each line can only have one character. int widthHint = tvw.getSize().x == 0 ? SWT.DEFAULT : tvw.getSize().x; Point size = tvw.computeSize(widthHint, SWT.DEFAULT); GridData gd = (GridData) tvw.getLayoutData(); - gd.heightHint = Math.min(tvw.getLineHeight() * MAX_INPUT_ROWS, size.y); - // TODO: An very interesting bug here, if we call layout(true, true), even no changes, - // The width of welcome view will become shorter and shorter, may investigate it later - ChatInputTextViewer.this.parent.getParent().getParent().layout(true, false); + int newHeightHint = Math.min(tvw.getLineHeight() * MAX_INPUT_ROWS, size.y); + if (gd.heightHint == newHeightHint) { + return; + } + gd.heightHint = newHeightHint; + // Delegate the relayout to the owner (e.g., ActionBar) so the chat view container reserves space for the + // updated input height. + if (this.layoutRefreshCallback != null) { + this.layoutRefreshCallback.run(); + } } private void onKeyPressed(KeyEvent e) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 02966b30..c5aa62c6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -3,13 +3,17 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; @@ -21,14 +25,16 @@ import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.swt.SWT; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; -import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.IPartListener2; import org.eclipse.ui.IWorkbenchPartReference; import org.eclipse.ui.PlatformUI; @@ -44,8 +50,10 @@ import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; import com.microsoft.copilot.eclipse.core.chat.ChatProgressListener; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatCreateResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; @@ -54,6 +62,8 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepStatus; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatStepTitles; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatTurnResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionCompletedParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CompressionStartedParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.ContextSizeInfo; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; @@ -61,6 +71,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData; @@ -72,8 +83,11 @@ import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.AgentToolService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatCompletionService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.chat.services.DebugEventAutoResponseHandler; @@ -86,6 +100,7 @@ import com.microsoft.copilot.eclipse.ui.chat.viewers.LoadingViewer; import com.microsoft.copilot.eclipse.ui.chat.viewers.NoSubscriptionViewer; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -110,7 +125,9 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private boolean hasHistory = false; private String conversationId = ""; private String subagentConversationId = null; + private String lastRunSubagentToolCallId = null; private ConversationState conversationState = ConversationState.NEW_CONVERSATION; + private CompletableFuture<?> persistUserTurnFuture = CompletableFuture.completedFuture(null); private Set<CompletableFuture<?>> conversationFutures = new HashSet<>(); private IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); private DragReferenceManager dragReferenceManager; @@ -134,12 +151,25 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler codingAgentMessageHandler; private EventHandler autoBreakpointToggleHandler; private EventHandler rateLimitWarningHandler; + private EventHandler quotaWarningHandler; + private EventHandler compressionStartedHandler; + private EventHandler compressionCompletedHandler; // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; + + /** + * Percentage-remaining threshold below which the rate-limit banner switches from the informational + * icon to the warning icon. + */ + private static final double RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING = 25.0; + private IContextActivation chatViewContextActivation; private IPartListener2 partListener; + /** Controls whose {@link #requestLayout(Control...)} call was suppressed while this view was hidden. */ + private final Set<Control> pendingLayoutRequests = new LinkedHashSet<>(); + @Override public void createPartControl(Composite parent) { this.parent = parent; @@ -351,13 +381,71 @@ public void done(IJobChangeEvent event) { if (data instanceof RateLimitWarningParams params) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (actionBar != null && !actionBar.isDisposed()) { - actionBar.createStaticBanner(params.message()); + RateLimitWarningParams.RateLimit rateLimit = params.rateLimit(); + boolean warning = rateLimit != null + && rateLimit.percentRemaining() <= RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING; + actionBar.createRateLimitBanner(params.message(), warning); } }, parent); } }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_RATE_LIMIT_WARNING, this.rateLimitWarningHandler); + this.quotaWarningHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (data instanceof QuotaWarningParams params) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (actionBar != null && !actionBar.isDisposed()) { + boolean warning = "warning".equalsIgnoreCase(params.severity()); + boolean overageEnabled = params.premiumInteractions() != null + && params.premiumInteractions().overageEnabled(); + actionBar.createQuotaWarningBanner(params.message(), params.copilotPlan(), overageEnabled, + params.canUpgradePlan(), warning); + } + }, parent); + } + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, this.quotaWarningHandler); + + this.compressionStartedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionStartedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn(); + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, + this.compressionStartedHandler); + + this.compressionCompletedHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (!(data instanceof CompressionCompletedParams params)) { + return; + } + if (!isCompressionForActiveConversation(params.conversationId())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + return; + } + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + if (params.contextInfo() != null) { + this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo()); + } + }, parent); + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_COMPLETED, + this.compressionCompletedHandler); + // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); } @@ -435,7 +523,12 @@ public void partOpened(IWorkbenchPartReference partRef) { @Override public void partVisible(IWorkbenchPartReference partRef) { - // No action needed + // Replay layout requests skipped while this view was hidden behind another part in the same + // stack, targeted at just the specific control(s) that changed -- avoids a blind full-tree + // relayout of the (possibly long) chat conversation. + if (partRef.getPart(false) == ChatView.this) { + flushPendingLayoutRequests(); + } } }; @@ -791,10 +884,22 @@ public void onChatProgress(ChatProgressValue value) { this.conversationId = newConversationId; this.conversationState = ConversationState.CONTINUED_CONVERSATION; } + // Always sync conversationId — chatContentViewer may have been recreated + if (this.chatContentViewer != null) { + this.chatContentViewer.setConversationId(this.conversationId); + } } // Cache conversation progress on begin - persistenceManager.cacheConversationProgress(this.conversationId, value); + persistenceManager.cacheConversationProgress(this.conversationId, value, null); + + // Set the CLS-assigned turnId on the last user turn that doesn't have one yet. + // Chain off persistUserTurnFuture to ensure the UserTurnData has been created first. + if (StringUtils.isNotBlank(value.getTurnId())) { + final String convId = this.conversationId; + final String turnId = value.getTurnId(); + persistUserTurnFuture.thenRun(() -> persistenceManager.setUserTurnId(convId, turnId)); + } // Hide handoff container when new turn starts Display.getDefault().asyncExec(() -> { @@ -804,6 +909,11 @@ public void onChatProgress(ChatProgressValue value) { }); break; case report: + // Ignore progress events from a different conversation + if (!isProgressForCurrentConversation(value)) { + return; + } + // Update context size donut if data is available ContextSizeInfo contextSize = value.getContextSize(); if (contextSize != null) { @@ -823,33 +933,67 @@ public void onChatProgress(ChatProgressValue value) { } } if ((value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) - && (value.getReply() == null || value.getReply().isEmpty())) { + && (value.getReply() == null || value.getReply().isEmpty()) + && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))) { return; } if (this.chatContentViewer != null) { this.chatContentViewer.processTurnEvent(value); } + String thinkingBlockId = this.chatContentViewer != null + ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; + + // Track run_subagent tool call ID for associating subagent turns + if (StringUtils.isBlank(value.getParentTurnId()) && value.getAgentRounds() != null) { + for (AgentRound round : value.getAgentRounds()) { + if (round.getToolCalls() != null) { + for (AgentToolCall tc : round.getToolCalls()) { + if ("run_subagent".equals(tc.getName())) { + this.lastRunSubagentToolCallId = tc.getId(); + } + } + } + } + } // If exiting subagent context (no parentTurnId), clear the subagent conversation ID if (StringUtils.isBlank(value.getParentTurnId()) && this.subagentConversationId != null) { this.subagentConversationId = null; + this.lastRunSubagentToolCallId = null; } // Cache conversation progress on report if (persistenceManager != null) { - persistenceManager.cacheConversationProgress(this.conversationId, value); + final String currentConversationId = this.conversationId; + final String subagentToolCallId = this.lastRunSubagentToolCallId; + persistenceManager.cacheConversationProgress(currentConversationId, value, thinkingBlockId) + .thenCompose(v -> { + if (StringUtils.isNotBlank(value.getParentTurnId()) + && StringUtils.isNotBlank(subagentToolCallId)) { + return persistenceManager.setSubagentToolCallId( + currentConversationId, value.getTurnId(), subagentToolCallId); + } + return CompletableFuture.completedFuture(null); + }); } break; case end: + // Ignore progress events from a different conversation + if (!isProgressForCurrentConversation(value)) { + return; + } + if (this.chatContentViewer != null) { this.chatContentViewer.processTurnEvent(value); this.actionBar.resetSendButton(); this.topBanner.updateTitle(value.getSuggestedTitle()); } + String endThinkingBlockId = this.chatContentViewer != null + ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; // Persist final conversation state and conversation title on end if (persistenceManager != null) { - persistenceManager.persistConversationProgress(this.conversationId, value); + persistenceManager.persistConversationProgress(this.conversationId, value, endThinkingBlockId); // Persist todo list at end phase TodoListService todoListService = chatServiceManager.getTodoListService(); @@ -940,11 +1084,20 @@ private void onSendInternal(String workDoneToken, String message, String agentSl final CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); final CopilotModel activeModel = chatServiceManager.getModelService().getActiveModel(); + // Collect attached file paths for auto-approve of file operations. + // Stage as pending so confirmation requests can match immediately, + // even before the real conversation ID arrives. + List<String> pendingAttachedFiles = + collectAttachedFilePaths(currentFile, references); + stagePendingAttachedFiles(pendingAttachedFiles); + if (conversationState == ConversationState.CONTINUED_CONVERSATION) { + // conversationId is already the real one — flush pending into registry + flushPendingAttachedFiles(this.conversationId); // Continue existing conversation - persist user message and send to existing conversation if (persistenceManager != null) { - persistenceManager.persistUserTurnInfo(conversationId, workDoneToken, processedMessage, activeModel, - chatModeName, customChatModeId, currentFile, references); + this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(conversationId, null, processedMessage, + activeModel, chatModeName, customChatModeId, currentFile, references); } // Get current todo list to sync with the server @@ -955,21 +1108,24 @@ private void onSendInternal(String workDoneToken, String message, String agentSl currentTodos = todoListService != null ? todoListService.getTodoList() : null; } + String turnReasoningEffort = chatServiceManager.getModelService().resolveEffectiveReasoningEffort(activeModel); CompletableFuture<ChatTurnResult> addConversationFuture = ls.addConversationTurn(workDoneToken, conversationId, - processedMessage, references, currentFile, currentSelection, activeModel, chatModeName, customChatModeId, - currentTodos, agentSlug, agentJobWorkspaceFolder); + processedMessage, references, currentFile, currentSelection, activeModel, turnReasoningEffort, chatModeName, + customChatModeId, currentTodos, agentSlug, agentJobWorkspaceFolder, + deriveWorkspaceFolders(currentFile, references)); conversationFutures.add(addConversationFuture); addConversationFuture.thenAccept(result -> { // Render and persist model information in the Copilot turn widget if (result != null && StringUtils.isNotBlank(result.getModelName()) && !UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG.equals(result.getAgentSlug())) { - renderModelInfoInTurnWidget(result.getTurnId(), result.getModelName(), result.getBillingMultiplier()); + renderModelInfoInTurnWidget(result.getTurnId(), result.getModelName(), result.getBillingMultiplier(), + turnReasoningEffort); // Persist model information if (persistenceManager != null) { persistenceManager.persistModelInfo(result.getConversationId(), result.getTurnId(), result.getModelName(), - result.getBillingMultiplier()); + result.getBillingMultiplier(), turnReasoningEffort); } } }).exceptionally(th -> { @@ -983,12 +1139,26 @@ private void onSendInternal(String workDoneToken, String message, String agentSl // Create new conversation (either brand new or based on history) List<Turn> turns = null; List<TodoItem> todosToRestore = null; + String restoredConversationId = null; + String restoreToTurnId = null; if (conversationState == ConversationState.NEW_HISTORY_BASED_CONVERSATION) { // Load turns from the history conversation and persist user turn with current conversation ID turns = persistenceManager.loadConversationTurns(this.conversationId); - persistenceManager.persistUserTurnInfo(this.conversationId, workDoneToken, processedMessage, activeModel, - chatModeName, customChatModeId, currentFile, references); + this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, + processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); + + // Set conversationId and last completed turnId for CLS server-side session restoration. + restoredConversationId = this.conversationId; + if (turns != null && !turns.isEmpty()) { + for (int i = turns.size() - 1; i >= 0; i--) { + Turn turn = turns.get(i); + if (StringUtils.isNotBlank(turn.getTurnId())) { + restoreToTurnId = turn.getTurnId(); + break; + } + } + } // Get todos to restore for session continuation TodoListService todoListService = chatServiceManager.getTodoListService(); @@ -998,20 +1168,23 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } else if (conversationState == ConversationState.NEW_CONVERSATION) { // Generate a temporary ID for brand new conversation and persist user turn this.conversationId = UUID.randomUUID().toString(); - persistenceManager.persistUserTurnInfo(this.conversationId, workDoneToken, processedMessage, activeModel, - chatModeName, customChatModeId, currentFile, references); + this.persistUserTurnFuture = persistenceManager.persistUserTurnInfo(this.conversationId, null, + processedMessage, activeModel, chatModeName, customChatModeId, currentFile, references); } + List<WorkspaceFolder> workspaceFolders = deriveWorkspaceFolders(currentFile, references); + String reasoningEffort = chatServiceManager.getModelService().resolveEffectiveReasoningEffort(activeModel); CompletableFuture<ChatCreateResult> createConversationFuture = null; if (StringUtils.isBlank(agentSlug)) { createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile, - currentSelection, turns, activeModel, chatModeName, customChatModeId, todosToRestore, null, null); + currentSelection, turns, activeModel, reasoningEffort, chatModeName, customChatModeId, todosToRestore, null, + null, restoredConversationId, restoreToTurnId, workspaceFolders); } else { // For conversations sending to agents, include agentSlug and specify the target agentJobWorkspaceFolder // Don't send todo list for agent jobs - agents manage their own todo state independently createConversationFuture = ls.createConversation(workDoneToken, processedMessage, references, currentFile, - currentSelection, turns, activeModel, chatModeName, customChatModeId, null, agentSlug, - agentJobWorkspaceFolder); + currentSelection, turns, activeModel, reasoningEffort, chatModeName, customChatModeId, null, agentSlug, + agentJobWorkspaceFolder, restoredConversationId, restoreToTurnId, workspaceFolders); } conversationFutures.add(createConversationFuture); @@ -1024,18 +1197,23 @@ private void onSendInternal(String workDoneToken, String message, String agentSl CopilotCore.LOGGER.error("Error updating conversation ID in persistence manager: ", e); } + // Flush pending attached files into the real conversation ID + flushPendingAttachedFiles(newConversationId); + // Render model information in the Copilot turn widget if (result != null && StringUtils.isNotBlank(result.getModelName()) && !UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG.equals(result.getAgentSlug())) { - renderModelInfoInTurnWidget(result.getTurnId(), result.getModelName(), result.getBillingMultiplier()); + renderModelInfoInTurnWidget(result.getTurnId(), result.getModelName(), result.getBillingMultiplier(), + reasoningEffort); // Persist model information if (persistenceManager != null) { persistenceManager.persistModelInfo(newConversationId, result.getTurnId(), result.getModelName(), - result.getBillingMultiplier()); + result.getBillingMultiplier(), reasoningEffort); } } }).exceptionally(th -> { + clearPendingAttachedFiles(); if (!ConversationUtils.isConversationCancellationThrowable(th)) { CopilotCore.LOGGER.error("Error creating new conversation with exception: ", th); displayErrorAndResetSendButton(workDoneToken, th.getMessage()); @@ -1051,6 +1229,48 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } } + List<WorkspaceFolder> deriveWorkspaceFolders(IFile currentFile, List<IResource> references) { + String chatInstrScope = CopilotUi.getPlugin().getPreferenceStore().getString( + Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE); + CustomInstructionsChatLoadScope scope; + try { + scope = CustomInstructionsChatLoadScope.fromValue(chatInstrScope); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error( + "Failed parsing custom instructions load scope for chat preference, using default value", e); + scope = CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } + return switch (scope) { + // take all projects from Eclipse workspace + case ALL_PROJECTS -> LSPEclipseUtils.getWorkspaceFolders(); + + // take only projects from selected files/folders + case REFERENCED_PROJECTS -> ResourceUtils.deriveWorkspaceFoldersFrom( + Stream.concat(references.stream(), Stream.of(currentFile)).toList()); + }; + } + + /** + * Checks if a progress event belongs to the currently displayed conversation. Subagent events (with parentTurnId) + * must match the tracked subagent conversation ID. Non-subagent events must match the main conversation ID. + */ + private boolean isProgressForCurrentConversation(ChatProgressValue value) { + String progressConversationId = value.getConversationId(); + if (StringUtils.isNotBlank(value.getParentTurnId())) { + return StringUtils.equals(progressConversationId, this.subagentConversationId); + } + return StringUtils.equals(progressConversationId, this.conversationId); + } + + /** + * Checks whether a compression notification targets either the main conversation or the active subagent + * conversation, so the UI can reflect compaction happening at either level. + */ + private boolean isCompressionForActiveConversation(String compressionConversationId) { + return StringUtils.equals(compressionConversationId, this.conversationId) + || StringUtils.equals(compressionConversationId, this.subagentConversationId); + } + /** * Align with @Workspace of vscode, because we are actually indexing the whole workspace, not a single project. * (@Project is only for IntelliJ.) @@ -1105,8 +1325,75 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { }, parent); } + /** + * Collects absolute paths of the current file and explicitly attached + * references. The returned list is saved to the + * {@link AttachedFileRegistry} once a stable conversation ID is available. + */ + private List<String> collectAttachedFilePaths(IFile currentFile, + List<IResource> references) { + List<String> filePaths = new ArrayList<>(); + if (currentFile != null && currentFile.getLocation() != null) { + filePaths.add(currentFile.getLocation().toOSString()); + } + if (references != null) { + for (IResource r : references) { + if (r instanceof IFile && r.getLocation() != null) { + filePaths.add(r.getLocation().toOSString()); + } + } + } + return filePaths; + } + + /** + * Stages file paths as pending in the attached file registry. + * These are immediately visible to confirmation handlers. + */ + private void stagePendingAttachedFiles(List<String> filePaths) { + if (filePaths.isEmpty() || this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry().addPending(filePaths); + } + + /** + * Flushes pending attached files into per-conversation storage + * under the given (stable) conversation ID. + */ + private void flushPendingAttachedFiles(String conversationId) { + if (this.chatServiceManager == null + || StringUtils.isBlank(conversationId)) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService == null) { + return; + } + agentToolService.getAttachedFileRegistry() + .flushPending(conversationId); + } + + private void clearPendingAttachedFiles() { + if (this.chatServiceManager == null) { + return; + } + AgentToolService agentToolService = + this.chatServiceManager.getAgentToolService(); + if (agentToolService != null) { + agentToolService.getAttachedFileRegistry().clearPending(); + } + } + private void clearCurrentConversation() { this.onCancel(); + clearPendingAttachedFiles(); this.hasHistory = false; this.conversationId = ""; this.conversationState = ConversationState.NEW_CONVERSATION; @@ -1128,16 +1415,45 @@ private void clearCurrentConversation() { @Override public void onCancel() { - // Clear subagent conversation ID on cancel + // Destroy the temporary subagent conversation on CLS side + if (StringUtils.isNotBlank(this.subagentConversationId)) { + CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (ls != null) { + ls.destroyConversation(this.subagentConversationId); + } + } + this.subagentConversationId = null; + this.lastRunSubagentToolCallId = null; + cancelCurrentTerminalCommand(); if (persistenceManager != null && StringUtils.isNotBlank(this.conversationId)) { - persistenceManager.persistCachedConversation(this.conversationId); + persistenceManager.markRunningToolCallsCancelledAndPersist(this.conversationId); } conversationFutures.forEach(future -> { future.cancel(false); }); conversationFutures.clear(); + + // Reset send button in case the conversation was cancelled while in-progress + if (this.actionBar != null && !this.actionBar.isDisposed()) { + this.actionBar.resetSendButton(); + } + if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { + this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + } + } + + private void cancelCurrentTerminalCommand() { + try { + TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); + IRunInTerminalTool terminalTool = terminalManager != null ? terminalManager.getCurrentService() : null; + if (terminalTool != null) { + terminalTool.cancelCurrentCommand(); + } + } catch (RuntimeException e) { + CopilotCore.LOGGER.error("Failed to cancel terminal command", e); + } } @Override @@ -1310,6 +1626,18 @@ public void dispose() { this.eventBroker.unsubscribe(this.rateLimitWarningHandler); rateLimitWarningHandler = null; } + if (quotaWarningHandler != null) { + this.eventBroker.unsubscribe(this.quotaWarningHandler); + quotaWarningHandler = null; + } + if (compressionStartedHandler != null) { + this.eventBroker.unsubscribe(this.compressionStartedHandler); + compressionStartedHandler = null; + } + if (compressionCompletedHandler != null) { + this.eventBroker.unsubscribe(this.compressionCompletedHandler); + compressionCompletedHandler = null; + } } if (this.chatServiceManager != null) { @@ -1385,16 +1713,75 @@ public void dispose() { getSite().getPage().removePartListener(this.partListener); this.partListener = null; } + pendingLayoutRequests.clear(); deactivateChatViewContext(); super.dispose(); } /** - * Layout the view. + * Request a layout of the given control(s), which must be part of this view's widget tree. + * + * <p>Passing every control whose content actually changed (rather than a blind {@code + * parent.layout(true, true)}) lets SWT flush exactly those controls' cached sizes -- and only the + * ancestor chains leading to them -- so it never touches unrelated subtrees like the chat + * conversation history. Passing only a subset of what changed (e.g. a text label but not a + * sibling icon label that also changed) will leave the omitted control's cached size stale. + * + * <p>Skipped while the view isn't visible (e.g. stacked behind another part) to avoid layout cost + * on every editor switch; {@code changed} is instead remembered and replayed by {@link + * #flushPendingLayoutRequests()} once the view becomes visible again. + * + * @param changed every control whose content/layout data just changed */ - public void layout(boolean changed, boolean all) { - parent.layout(changed, all); + public void requestLayout(Control... changed) { + if (getSite() == null || getSite().getPage() == null || !getSite().getPage().isPartVisible(this)) { + if (changed != null) { + Collections.addAll(pendingLayoutRequests, changed); + } + return; + } + layoutNow(changed); + } + + /** + * Replays layout requests that were suppressed by {@link #requestLayout(Control...)} while this + * view was hidden, targeted at just the specific control(s) that changed. + */ + private void flushPendingLayoutRequests() { + if (pendingLayoutRequests.isEmpty()) { + return; + } + // Snapshot and clear before replaying: a control's requestLayout() could in principle + // synchronously trigger a new call back into requestLayout(Control...), which would otherwise + // mutate pendingLayoutRequests while it's being iterated. + Set<Control> toFlush = new LinkedHashSet<>(pendingLayoutRequests); + pendingLayoutRequests.clear(); + layoutNow(toFlush.toArray(new Control[0])); + } + + /** + * Flushes cached sizes for exactly the given controls (and the ancestor chains leading to them) + * and lays them out, in a single batched pass. Silently ignores {@code null} or disposed + * controls. + */ + private void layoutNow(Control... controls) { + List<Control> valid = new ArrayList<>(); + if (controls != null) { + for (Control control : controls) { + if (control != null && !control.isDisposed()) { + valid.add(control); + } + } + } + if (valid.isEmpty()) { + return; + } + Shell shell = valid.get(0).getShell(); + if (shell == null || shell.isDisposed()) { + return; + } + shell.layout(valid.toArray(new Control[0]), SWT.DEFER); } /** @@ -1501,16 +1888,10 @@ public void scrollContentToBottom() { return; } - chatContentViewer.getDisplay().asyncExec(() -> { - if (chatContentViewer.isDisposed()) { - return; - } - chatContentViewer.refreshScrollerLayout(); - ScrollBar verticalBar = chatContentViewer.getVerticalBar(); - if (verticalBar != null && !verticalBar.isDisposed()) { - chatContentViewer.setOrigin(0, verticalBar.getMaximum()); - } - }); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + chatContentViewer.refreshLayoutFull(); + chatContentViewer.scrollToBottomIfAutoScroll(); + }, chatContentViewer); } /** @@ -1520,14 +1901,20 @@ public void scrollContentToBottom() { * @param conversationId the conversation ID to use for persistence * @param modelName the model name * @param billingMultiplier the billing multiplier + * @param reasoningEffort the reasoning effort sent for this turn (may be {@code null} when the model does not + * support reasoning effort) */ - private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier) { + private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(turnId); if (turnWidget instanceof CopilotTurnWidget copilotWidget) { - copilotWidget.renderModelInfo(modelName, billingMultiplier); + copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); - // Refresh the scroller layout to ensure the footer is visible - SwtUtils.invokeOnDisplayThreadAsync(() -> this.chatContentViewer.refreshScrollerLayout(), this.chatContentViewer); + // Refresh the scroller layout to ensure the footer is visible. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + this.chatContentViewer.refreshLayoutFull(); + this.chatContentViewer.scrollToBottomIfAutoScroll(); + }, this.chatContentViewer); } } @@ -1541,84 +1928,110 @@ private void restoreTurn(AbstractTurnData turn) { return; } + // Subagent turns: render their content inside the parent turn's subagent block + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId()); + if (parentWidget != null) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + if (StringUtils.isNotBlank(toolCallId)) { + // Restore subagent content into the SubagentMessageBlock identified by the tool call ID + parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory()); + } else { + // Fallback: append to parent widget directly (legacy data without subagentToolCallId) + restoreCopilotTurnContent(copilotTurn, parentWidget); + } + } + return; + } + // Create user turn widget and populate with user message if (turn instanceof UserTurnData userTurn) { if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); userTurnWidget.appendMessage(userTurn.getMessage().getText()); - userTurnWidget.notifyTurnEnd(); + userTurnWidget.flushMessageBuffer(); return; } } else if (turn instanceof CopilotTurnData copilotTurn) { BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); - ReplyData replyData = copilotTurn.getReply(); + restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - if (replyData == null) { - return; - } + copilotTurnWidget.flushMessageBuffer(); - if (StringUtils.isNotBlank(replyData.getText())) { - copilotTurnWidget.appendMessage(replyData.getText()); + // Restore model info footer if model name is present + // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used + // for this turn, not whatever the user has selected now. + renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier(), + replyData.getReasoningEffort()); } + } + } - if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { - for (EditAgentRoundData round : replyData.getEditAgentRounds()) { - // Append each round's reply text. - if (round.getReply() != null && !round.getReply().isEmpty()) { - copilotTurnWidget.appendMessage(round.getReply()); - } + /** + * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the + * given turn widget. Used for both main copilot turns and subagent turns. + */ + private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } - // Concatenate tool call statuses from all rounds. - if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { - for (ToolCallData toolCallData : round.getToolCalls()) { - // Convert TurnData.ToolCallData to AgentToolCall - AgentToolCall agentToolCall = persistenceManager.getDataFactory() - .convertToolCallDataToAgentToolCall(toolCallData); - copilotTurnWidget.appendToolCallStatus(agentToolCall); - } - } - } - } + ThinkingTurnWidget thinkingWidget = + turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null; - // Restore any error messages widgets from the reply data - if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { - for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { - ErrorData errorData = errorMessageData.getError(); - SwtUtils.invokeOnDisplayThread(() -> { - String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; - int errorCode = errorData != null ? errorData.getCode() : 0; + if (StringUtils.isNotBlank(replyData.getText())) { + turnWidget.appendMessage(replyData.getText()); + } - copilotTurnWidget.createWarnDialog(errorMessage, errorCode); - }, parent); + if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + // Restore thinking block before the round's reply and tool calls + if (thinkingWidget != null && round.getThinkingBlock() != null) { + thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); } - } - - // Restore any agent messages from the reply data - if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { - for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { - // TODO: We currently only have GitHub Copilot Coding Agent, need to extend for other agents in the future - if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { - SwtUtils.invokeOnDisplayThread(() -> { - // Create CodingAgentMessageRequestParams from the persisted data - CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); - params.setTitle(agentMessageData.getTitle()); - params.setDescription(agentMessageData.getDescription()); - params.setPrLink(agentMessageData.getPrLink()); - params.setConversationId(this.conversationId); - params.setTurnId(turn.getTurnId()); - - copilotTurnWidget.createAgentMessageWidget(params); - }, parent); + if (round.getReply() != null && !round.getReply().isEmpty()) { + turnWidget.appendMessage(round.getReply()); + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = persistenceManager.getDataFactory() + .convertToolCallDataToAgentToolCall(toolCallData); + turnWidget.appendToolCallStatus(agentToolCall); } } } + } - copilotTurnWidget.notifyTurnEnd(); + if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + SwtUtils.invokeOnDisplayThread(() -> { + String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); + }, parent); + } + } - // Restore model info footer if model name is present - // This must be done AFTER notifyTurnEnd() to ensure footer appears at the bottom - if (StringUtils.isNotBlank(replyData.getModelName())) { - renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier()); + if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { + for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + SwtUtils.invokeOnDisplayThread(() -> { + CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); + params.setTitle(agentMessageData.getTitle()); + params.setDescription(agentMessageData.getDescription()); + params.setPrLink(agentMessageData.getPrLink()); + params.setConversationId(this.conversationId); + params.setTurnId(copilotTurn.getTurnId()); + turnWidget.createAgentMessageWidget(params); + }, parent); + } } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java index b32f4119..f026aa0e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java @@ -12,10 +12,12 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; @@ -23,12 +25,16 @@ /** * A custom widget that displays a turn for the copilot. */ -public class CopilotTurnWidget extends BaseTurnWidget { +public class CopilotTurnWidget extends ThinkingTurnWidget { + + private Composite compactingComposite; + private SpinnerAnimator compactingSpinner; + /** * Create the widget. */ public CopilotTurnWidget(Composite parent, int style, ChatServiceManager serviceManager, String turnId) { - super(parent, style, serviceManager, turnId, true, null); + super(parent, style, serviceManager, turnId, null); setData("org.eclipse.swtbot.widget.key", "copilot-turn"); } @@ -63,8 +69,10 @@ protected void createTextBlock() { * * @param modelName the name of the model used * @param billingMultiplier the billing multiplier for the model + * @param reasoningEffort the reasoning effort that was sent for this turn (may be {@code null} or blank if the model + * does not support reasoning effort or the user did not select one) */ - public void renderModelInfo(String modelName, double billingMultiplier) { + public void renderModelInfo(String modelName, double billingMultiplier, String reasoningEffort) { if (modelName != null && !modelName.isEmpty()) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (footer == null || footer.isDisposed()) { @@ -72,8 +80,26 @@ public void renderModelInfo(String modelName, double billingMultiplier) { } if (StringUtils.isNotBlank(modelName)) { Label modelInfoLabel = new Label(footer, SWT.NONE); - String formattedMultiplier = ModelUtils.formatBillingMultiplier(billingMultiplier); - String displayText = String.format("%s - %s", modelName, formattedMultiplier); + String formattedEffort = ModelUtils.formatReasoningEffortLevel(reasoningEffort); + String modelWithEffort; + if (StringUtils.isNotBlank(formattedEffort)) { + modelWithEffort = modelName + " - " + formattedEffort; + } else { + modelWithEffort = modelName; + } + // When token-based billing is enabled on the language server, the per-turn billing + // multiplier is no longer a meaningful price signal, so render the model name on its + // own. Fall back to the legacy "{model} - {multiplier}" format otherwise. + boolean tbbEnabled = CopilotCore.getPlugin().getAuthStatusManager() + .getQuotaStatus().tokenBasedBillingEnabled(); + String displayText; + if (tbbEnabled) { + displayText = modelWithEffort; + } else { + // TODO: Remove this legacy fallback after TBB is officially released. + String formattedMultiplier = ModelUtils.formatBillingMultiplier(billingMultiplier); + displayText = String.format("%s - %s", modelWithEffort, formattedMultiplier); + } modelInfoLabel.setText(displayText); GridData labelGridData = new GridData(SWT.RIGHT, SWT.CENTER, true, false); modelInfoLabel.setLayoutData(labelGridData); @@ -85,6 +111,50 @@ public void renderModelInfo(String modelName, double billingMultiplier) { } } + /** + * Shows a "Compacting conversation..." spinner below the last message in this turn. + * Must be called on the UI thread. + */ + public void showCompactingStatus() { + if (isDisposed() || compactingComposite != null) { + return; + } + compactingComposite = new Composite(this, SWT.NONE); + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 4; + compactingComposite.setLayout(layout); + compactingComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Label spinnerLabel = new Label(compactingComposite, SWT.NONE); + spinnerLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + compactingSpinner = new SpinnerAnimator(spinnerLabel); + compactingSpinner.start(); + + Label statusLabel = new Label(compactingComposite, SWT.NONE); + statusLabel.setText(Messages.chat_compacting_conversation); + statusLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, true, false)); + + ensureFooterAtBottom(); + requestLayout(); + } + + /** + * Hides the "Compacting conversation..." spinner. + * Must be called on the UI thread. + */ + public void hideCompactingStatus() { + if (compactingSpinner != null) { + compactingSpinner.stop(); + compactingSpinner = null; + } + if (compactingComposite != null && !compactingComposite.isDisposed()) { + compactingComposite.dispose(); + compactingComposite = null; + } + requestLayout(); + } + @Override protected void createFooter() { footer = new Composite(this, SWT.NONE); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java index 46f22d4d..19a9dde2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CurrentReferencedFile.java @@ -92,6 +92,12 @@ public void setFile(IResource file) { super.setFile(file); } + @Override + protected @Nullable String getAccessibilityName() { + IResource file = getFile(); + return file == null ? null : Messages.chat_currentReferencedFile_description + " " + file.getName(); + } + /** * Set the current selection to display. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java index eef5dc17..2a4b377b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/FileAnnotationHyperlinkDetector.java @@ -3,10 +3,13 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; -import org.eclipse.core.runtime.Path; import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.hyperlink.IHyperlink; import org.eclipse.jface.text.hyperlink.URLHyperlink; @@ -59,14 +62,27 @@ public void open() { String urlString = getURLString(); if (urlString.startsWith(LSPEclipseUtils.FILE_URI)) { IResource targetResource = LSPEclipseUtils.findResourceFor(urlString); - if (targetResource != null && targetResource.getType() == IResource.FILE) { - Location location = new Location(); - location.setUri(urlString); - LSPEclipseUtils.openInEditor(location); + if (targetResource != null) { + if (targetResource.getType() == IResource.FILE) { + Location location = new Location(); + location.setUri(urlString); + LSPEclipseUtils.openInEditor(location); + return; + } + if (targetResource.getType() == IResource.FOLDER + || targetResource.getType() == IResource.PROJECT) { + UiUtils.revealInExplorer(targetResource); + return; + } + } + Path localPath = FileUtils.getLocalFilePath(urlString); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + UiUtils.openLocalFileInEditor(localPath); return; } } else { - IFile file = ResourcesPlugin.getWorkspace().getRoot().getFile(new Path(urlString)); + IFile file = ResourcesPlugin.getWorkspace().getRoot() + .getFile(new org.eclipse.core.runtime.Path(urlString)); if (file.exists()) { var workbenchWindow = PlatformUI.getWorkbench().getActiveWorkbenchWindow(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java index 745f62a5..1f65aead 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/InvokeToolConfirmationDialog.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -13,23 +15,34 @@ import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Dialog to confirm tool execution. + * Dialog to confirm tool execution. Renders a title, message, optional command + * block, and action buttons driven by {@link ConfirmationContent}. The primary + * action is shown as a {@link SplitDropdownButton} with secondary actions in + * the dropdown menu. */ public class InvokeToolConfirmationDialog extends Composite { @@ -47,32 +60,74 @@ public class InvokeToolConfirmationDialog extends Composite { * The key for the action in the input map (used by debugger tool). */ private static final String ACTION_KEY = "action"; + private CompletableFuture<LanguageModelToolConfirmationResult> toolConfirmationFuture; private String cancelMessage; private Label titleLbl; private Font boldFont; private Runnable titleFontChangeCallback; + private ConfirmationContent confirmationContent; + private ConfirmationAction selectedAction; /** - * Create a new confirmation dialog for tool execution. + * Create a new confirmation dialog driven by {@link ConfirmationContent}. * - * @param parent The parent composite - * @param title The title of the confirmation dialog - * @param message The message to display - * @param input The input object to pass to the tool + * @param parent the parent composite + * @param content confirmation content with title, message, and action buttons + * @param input the input object to pass to the tool */ - public InvokeToolConfirmationDialog(Composite parent, String title, String message, Object input) { + public InvokeToolConfirmationDialog(Composite parent, + ConfirmationContent content, Object input) { super(parent, SWT.BORDER | SWT.WRAP); this.setLayout(new GridLayout(1, false)); this.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - createDialogContent(title, message, input); + this.confirmationContent = content; + createDialogContent(content.getTitle(), content.getMessage(), input); this.toolConfirmationFuture = new CompletableFuture<>(); } - private void createDialogContent(String title, String message, Object input) { - // Title of the confirmation dialog + /** + * Returns the action the user selected, or {@code null} if dismissed. + */ + public ConfirmationAction getSelectedAction() { + return selectedAction; + } + + /** + * Get the future that will be completed when the user makes a choice. + * + * @return CompletableFuture containing the result of user's choice + */ + public CompletableFuture<LanguageModelToolConfirmationResult> getConfirmationFuture() { + return toolConfirmationFuture; + } + + /** + * Cancels the current tool confirmation dialog programmatically. This has + * the same effect as clicking the Cancel / Skip button. + */ + public void cancelConfirmation() { + if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { + toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + + Composite parent = this.getParent(); + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (parent != null && !parent.isDisposed() + && StringUtils.isNotEmpty(this.cancelMessage)) { + new AgentToolCancelLabel(parent, SWT.NONE, this.cancelMessage); + } + disposeAndRequestParentLayout(); + }, this); + } + } + + // --------------- content creation --------------- + + private void createDialogContent(String title, String message, + Object input) { titleLbl = new Label(this, SWT.LEFT | SWT.WRAP); titleLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); titleLbl.setText(title); @@ -93,160 +148,181 @@ private void createDialogContent(String title, String message, Object input) { } }); - // Confirmation message of the confirmation dialog Label messageLbl = new Label(this, SWT.LEFT | SWT.WRAP); - GridData messageGridData = new GridData(SWT.FILL, SWT.FILL, true, false); - messageLbl.setLayoutData(messageGridData); - messageLbl.setText(message); + messageLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + messageLbl.setText(message != null ? message : ""); registerControlForFontUpdates(messageLbl); - // More information about the tool invocation - if (input != null) { - Map<String, Object> inputMap = (Map<String, Object>) input; - - // For debugger tool, show all input parameters - if (inputMap.containsKey(ACTION_KEY)) { - String displayText = formatDebuggerInput(inputMap); - - // Create a scrollable container for the input text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL | SWT.V_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = displayText.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } else if (inputMap.containsKey(COMMAND_KEY)) { - // For terminal tool, show command - // Create a scrollable container for the command text - ScrolledComposite commandScroll = new ScrolledComposite(this, SWT.H_SCROLL); - commandScroll.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - commandScroll.setExpandHorizontal(true); - commandScroll.setExpandVertical(true); - - Label commandLbl = new Label(commandScroll, SWT.LEFT); - String command = (String) inputMap.get(COMMAND_KEY); - // Escape & characters that are followed by non-space characters, needed for SWT labels where & is used as a - // mnemonic character - String escapedCommand = command.replace("&", "&&"); - commandLbl.setText(escapedCommand); - commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); - this.cancelMessage = escapedCommand; - registerControlForFontUpdates(commandLbl); - - commandScroll.setContent(commandLbl); - commandScroll.addControlListener(new ControlAdapter() { - @Override - public void controlResized(ControlEvent e) { - Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); - commandLbl.setSize(size); - commandScroll.setMinSize(size); - } - }); - // Initial size computation + createInputContent(input); + createActionButtons(); + } + + @SuppressWarnings("unchecked") + private void createInputContent(Object input) { + if (input == null) { + return; + } + Map<String, Object> inputMap = (Map<String, Object>) input; + + if (inputMap.containsKey(ACTION_KEY)) { + createScrollableCommand(formatDebuggerInput(inputMap), + SWT.H_SCROLL | SWT.V_SCROLL); + } else if (inputMap.containsKey(COMMAND_KEY)) { + createScrollableCommand((String) inputMap.get(COMMAND_KEY), + SWT.H_SCROLL); + } + + if (inputMap.containsKey(EXPLANATION_KEY)) { + Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); + explanationLbl.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); + registerControlForFontUpdates(explanationLbl); + } + } + + private void createScrollableCommand(String text, int scrollStyle) { + ScrolledComposite commandScroll = + new ScrolledComposite(this, scrollStyle); + commandScroll.setLayoutData( + new GridData(SWT.FILL, SWT.FILL, true, false)); + commandScroll.setExpandHorizontal(true); + commandScroll.setExpandVertical(true); + + Label commandLbl = new Label(commandScroll, SWT.LEFT); + String escapedCommand = text.replace("&", "&&"); + commandLbl.setText(escapedCommand); + commandLbl.setData(CssConstants.CSS_CLASS_NAME_KEY, "bg-command-panel"); + this.cancelMessage = escapedCommand; + registerControlForFontUpdates(commandLbl); + + commandScroll.setContent(commandLbl); + commandScroll.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); commandLbl.setSize(size); commandScroll.setMinSize(size); } + }); + Point size = commandLbl.computeSize(SWT.DEFAULT, SWT.DEFAULT); + commandLbl.setSize(size); + commandScroll.setMinSize(size); + } + + // --------------- action buttons with dropdown --------------- + + private void createActionButtons() { + List<ConfirmationAction> actions = confirmationContent.getActions(); - if (inputMap.containsKey(EXPLANATION_KEY)) { - Label explanationLbl = new Label(this, SWT.LEFT | SWT.WRAP); - explanationLbl.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - explanationLbl.setText((String) inputMap.get(EXPLANATION_KEY)); - registerControlForFontUpdates(explanationLbl); + ConfirmationAction primaryAction = null; + ConfirmationAction dismissAction = null; + List<ConfirmationAction> dropdownActions = new ArrayList<>(); + + for (ConfirmationAction action : actions) { + if (!action.isAccept()) { + dismissAction = action; + } else if (action.isPrimary()) { + primaryAction = action; + } else { + dropdownActions.add(action); } } - createButtons(); - } + if (primaryAction == null) { + return; + } - private void createButtons() { - GridLayout actionLayout = new GridLayout(2, false); - actionLayout.marginLeft = 0; - actionLayout.marginRight = 0; - actionLayout.marginWidth = 0; - actionLayout.horizontalSpacing = 0; - actionLayout.marginHeight = 0; - Composite actionArea = new Composite(this, SWT.NONE); - actionArea.setLayout(actionLayout); - actionArea.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - - Button continueButton = new Button(actionArea, SWT.PUSH); - continueButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - continueButton.setText("Continue"); - continueButton.addListener(SWT.Selection, e -> { - this.toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); - - // Store parent reference before disposal - Composite parent = this.getParent(); - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); + // Column count: primary dropdown button + dismiss + Composite actionArea = newButtonArea(2); + + // --- primary dropdown button --- + SplitDropdownButton primaryDropdown = + new SplitDropdownButton(actionArea, SWT.PUSH); + primaryDropdown.setText(primaryAction.getLabel()); + primaryDropdown.setShowArrow(!dropdownActions.isEmpty()); + primaryDropdown.setSeparatorColor( + getDisplay().getSystemColor(SWT.COLOR_WHITE)); + + Button primaryBtn = primaryDropdown.getButton(); + primaryBtn.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + primaryBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + registerControlForFontUpdates(primaryBtn); + + final ConfirmationAction primaryRef = primaryAction; + primaryDropdown.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (e.detail == SWT.ARROW && !dropdownActions.isEmpty()) { + Menu menu = new Menu(primaryBtn.getShell(), SWT.POP_UP); + for (ConfirmationAction action : dropdownActions) { + MenuItem item = new MenuItem(menu, SWT.PUSH); + item.setText(action.getLabel()); + item.addListener(SWT.Selection, + ev -> acceptAndDispose(action)); + } + menu.addListener(SWT.Hide, ev -> { + ev.display.asyncExec(menu::dispose); + }); + Rectangle bounds = primaryBtn.getBounds(); + Point loc = primaryBtn.getParent() + .toDisplay(bounds.x, bounds.y + bounds.height); + menu.setLocation(loc); + menu.setVisible(true); + } else { + acceptAndDispose(primaryRef); + } } }); - continueButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); - registerControlForFontUpdates(continueButton); - Button cancelButton = new Button(actionArea, SWT.PUSH); - cancelButton.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); - cancelButton.setText("Cancel"); - cancelButton.addListener(SWT.Selection, e -> { + // --- dismiss (skip) button --- + Button dismissBtn = new Button(actionArea, SWT.PUSH); + dismissBtn.setLayoutData( + new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); + dismissBtn.setText( + dismissAction != null ? dismissAction.getLabel() + : Messages.confirmation_action_skip); + registerControlForFontUpdates(dismissBtn); + + final ConfirmationAction dismissRef = dismissAction; + dismissBtn.addListener(SWT.Selection, e -> { + this.selectedAction = dismissRef; cancelConfirmation(); }); - registerControlForFontUpdates(cancelButton); } - /** - * Get the future that will be completed when the user makes a choice. - * - * @return CompletableFuture containing the result of user's choice - */ - public CompletableFuture<LanguageModelToolConfirmationResult> getConfirmationFuture() { - return toolConfirmationFuture; + // --------------- helpers --------------- + + private Composite newButtonArea(int columns) { + GridLayout layout = new GridLayout(columns, false); + layout.marginLeft = 0; + layout.marginRight = 0; + layout.marginWidth = 0; + layout.horizontalSpacing = 0; + layout.marginHeight = 0; + + Composite area = new Composite(this, SWT.NONE); + area.setLayout(layout); + area.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + return area; } - /** - * Cancels the current tool confirmation dialog programmatically. This has the same effect as clicking the Cancel - * button in the confirmation dialog. - */ - public void cancelConfirmation() { - if (toolConfirmationFuture != null && !toolConfirmationFuture.isDone()) { - toolConfirmationFuture.complete(new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + private void acceptAndDispose(ConfirmationAction action) { + this.selectedAction = action; + this.toolConfirmationFuture.complete( + new LanguageModelToolConfirmationResult( + ToolConfirmationResult.ACCEPT)); - // Store parent reference before disposal - Composite parent = this.getParent(); - SwtUtils.invokeOnDisplayThread(() -> { - // Only show the cancel widget for special cases when the tool has a parameter "command" in the input map - if (StringUtils.isNotEmpty(this.cancelMessage)) { - new AgentToolCancelLabel(this.getParent(), SWT.NONE, this.cancelMessage); - } - this.dispose(); - // Check if parent is still valid before using it - if (parent != null && !parent.isDisposed()) { - parent.layout(); - } - }, this); + disposeAndRequestParentLayout(); + } + + private void disposeAndRequestParentLayout() { + Composite parent = this.getParent(); + this.dispose(); + if (parent != null && !parent.isDisposed()) { + parent.requestLayout(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index 0f17edd2..f1a06d18 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -12,31 +12,67 @@ public final class Messages extends NLS { private static final String BUNDLE_NAME = "com.microsoft.copilot.eclipse.ui.chat.messages"; //$NON-NLS-1$ public static String chat_chatContentView_errorTemplate; + public static String chat_toolCall_genericError; + public static String chat_toolCall_errorTemplate; public static String endChat_confirmationTitle; public static String endChat_confirmationMessage; public static String confirmDialog_keepChangesButton; public static String confirmDialog_undoChangesButton; public static String chat_warnWidget_defaultErrorMsg; + public static String chat_warnWidget_byokQuotaUsageMessage; public static String configureModes; public static String agentMessageWidget_openInBrowserButton; public static String agentMessageWidget_openInBrowserTooltip; public static String agentMessageWidget_openJobListButton; public static String agentMessageWidget_openJobListTooltip; - public static String agentMessageWidget_openJobListError; public static String handoffContainer_proceedFrom; public static String fileChangeSummary_filesChanged; public static String fileChangeSummary_fileChanged; - public static String fileChangeSummary_doneButton; public static String fileChangeSummary_keepButton; public static String fileChangeSummary_undoButton; public static String fileChangeSummary_collapseTooltip; public static String fileChangeSummary_expandTooltip; - public static String todoList_title; public static String todoList_titleWithCount; public static String todoList_clearButton; public static String todoList_clearButtonDisabled; public static String todoList_expandTooltip; public static String todoList_collapseTooltip; + public static String thinking_title; + public static String thinking_expandTooltip; + public static String thinking_collapseTooltip; + + // Confirmation dialog action labels + public static String confirmation_action_allowOnce; + public static String confirmation_action_skip; + public static String confirmation_action_allowAllCommands; + public static String confirmation_action_allowNamesSession; + public static String confirmation_action_alwaysAllowNames; + public static String confirmation_action_allowExactSession; + public static String confirmation_action_alwaysAllowExact; + public static String confirmation_action_alwaysAllow; + public static String confirmation_action_allowFileSession; + public static String confirmation_action_allowFolderSession; + + // MCP confirmation dialog action labels + public static String confirmation_title_mcpTool; + public static String confirmation_title_mcpToolDefault; + public static String confirmation_action_allowServerSession; + public static String confirmation_action_alwaysAllowServer; + + // Confirmation dialog titles + public static String confirmation_title_terminal; + public static String confirmation_title_fallback; + public static String confirmation_title_fileRead; + public static String confirmation_title_fileWrite; + public static String confirmation_title_fileOperation; + + // Confirmation dialog messages + public static String confirmation_message_fileRead; + public static String confirmation_message_fileWrite; + public static String confirmation_message_fileOperation; + + // Misc + public static String confirmation_autoApprovedDescription; static { // initialize resource bundle diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java index 035a429f..46a245ee 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; import org.apache.commons.lang3.StringUtils; import org.eclipse.swt.SWT; @@ -44,13 +45,28 @@ private ModelPickerGroupsBuilder() { */ public static List<DropdownItemGroup> build(Map<String, CopilotModel> modelMap, boolean showAddPremiumModelOption, boolean showByokManageOption) { + return build(modelMap, showAddPremiumModelOption, showByokManageOption, null); + } + + /** + * Builds grouped dropdown items for the model picker, including the effective reasoning effort in the suffix. + * + * @param modelMap available models keyed by id + * @param showAddPremiumModelOption whether to include the premium upsell action + * @param showByokManageOption whether to include the BYOK manage action + * @param reasoningEffortResolver resolves the effective reasoning effort for a given model (user-selected when + * present, otherwise the inferred default), or {@code null} when none applies + * @return grouped dropdown items for the model picker + */ + public static List<DropdownItemGroup> build(Map<String, CopilotModel> modelMap, boolean showAddPremiumModelOption, + boolean showByokManageOption, Function<CopilotModel, String> reasoningEffortResolver) { List<CopilotModel> otherModels = new ArrayList<>(); List<CopilotModel> standardModels = new ArrayList<>(); List<CopilotModel> premiumModels = new ArrayList<>(); List<CopilotModel> customModels = new ArrayList<>(); for (CopilotModel model : modelMap.values()) { - if (model.getProviderName() != null) { + if (model.getProviderName() != null || model.getCustomModel() != null) { customModels.add(model); } else if (model.getBilling() != null) { if (model.getBilling().isPremium()) { @@ -69,17 +85,19 @@ public static List<DropdownItemGroup> build(Map<String, CopilotModel> modelMap, List<DropdownItemGroup> groups = new ArrayList<>(); if (!otherModels.isEmpty()) { - groups.add(DropdownItemGroup.of(buildModelDropdownItems(otherModels))); + groups.add(DropdownItemGroup.of(buildModelDropdownItems(otherModels, reasoningEffortResolver))); } if (!standardModels.isEmpty()) { - groups.add(DropdownItemGroup.of(Messages.chat_standardModels, buildModelDropdownItems(standardModels))); + groups.add(DropdownItemGroup.of(Messages.chat_standardModels, + buildModelDropdownItems(standardModels, reasoningEffortResolver))); } if (!premiumModels.isEmpty()) { String header = standardModels.isEmpty() ? Messages.chat_copilotModels : Messages.chat_premiumModels; - groups.add(DropdownItemGroup.of(header, buildModelDropdownItems(premiumModels))); + groups.add(DropdownItemGroup.of(header, buildModelDropdownItems(premiumModels, reasoningEffortResolver))); } if (!customModels.isEmpty()) { - groups.add(DropdownItemGroup.of(Messages.chat_customModels, buildModelDropdownItems(customModels))); + groups.add(DropdownItemGroup.of(Messages.chat_customModels, + buildModelDropdownItems(customModels, reasoningEffortResolver))); } List<DropdownItem> actionItems = new ArrayList<>(); @@ -98,11 +116,21 @@ public static List<DropdownItemGroup> build(Map<String, CopilotModel> modelMap, return groups; } - private static List<DropdownItem> buildModelDropdownItems(List<CopilotModel> models) { + private static List<DropdownItem> buildModelDropdownItems(List<CopilotModel> models, + Function<CopilotModel, String> reasoningEffortResolver) { List<DropdownItem> items = new ArrayList<>(); for (CopilotModel model : models) { - String suffix = ModelUtils.getModelSuffix(model); - items.add(new DropdownItem.Builder().id(model.getModelName()).label(model.getModelName()).suffix(suffix) + String rawName = model.getModelName(); + boolean alreadyHasPreview = rawName != null && rawName.toLowerCase().endsWith("(preview)"); + String name = model.isPreview() && !alreadyHasPreview ? rawName + " " + Messages.model_preview_suffix : rawName; + + String effectiveEffort = reasoningEffortResolver != null ? reasoningEffortResolver.apply(model) : null; + String suffix = ModelUtils.getModelSuffix(model, effectiveEffort); + String effortLevel = ModelUtils.formatReasoningEffortLevel(effectiveEffort); + String selectedLabel = StringUtils.isNotBlank(effortLevel) && StringUtils.isNotBlank(name) + ? name + " - " + effortLevel : null; + + items.add(new DropdownItem.Builder().id(rawName).label(name).selectedLabel(selectedLabel).suffix(suffix) .icon(resolveModelIcon(model)).hoverProvider(new ModelHoverContentProvider(model)).build()); } return items; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java new file mode 100644 index 00000000..5c3a0137 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Plan-driven call-to-action list shown when the user's quota is approaching or exceeded. Shared by the + * {@link StaticBanner}-based quota notification (rendered via {@code ActionBar#createQuotaWarningBanner}) + * and the inline {@link WarnWidget} rendered under a chat turn on a 402 error response, so both surfaces + * stay in sync when a new plan or call-to-action is added. + */ +public final class QuotaActions { + + /** + * Single quota call-to-action. + * + * @param label visible button or link label + * @param tooltip tooltip text used by the inline {@link WarnWidget}; ignored by {@link StaticBanner} + * @param url target URL opened on activation + * @param primary {@code true} when this action should be visually emphasised (e.g. {@code btn-primary} + * styling on a push button); ignored by {@link StaticBanner}, which renders all actions as links + */ + public record QuotaAction(String label, String tooltip, String url, boolean primary) { + } + + private QuotaActions() { + } + + /** + * Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan. + * + * <p>Non-upgrade actions are derived from the plan: + * <ul> + * <li>{@code free} → none</li> + * <li>{@code individual}, {@code individual_pro}, {@code individual_max} → + * "Enable Additional Usage" / "Increase Budget"</li> + * <li>{@code business}, {@code enterprise} → none</li> + * </ul> + * + * <p>The "Upgrade Plan" action is then appended when the user is eligible. When + * {@code canUpgradePlan} is non-{@code null} it takes precedence over the plan-based default; + * otherwise the plan default is used (eligible for {@code free}, {@code individual}, + * {@code individual_pro}). When the upgrade action is the only entry it is rendered as primary; + * when it follows another primary action it is rendered as secondary so the surfaces stay + * visually consistent. + * + * <p>The "Enable Additional Usage" label is replaced with "Increase Budget" when + * {@code overageEnabled} is {@code true}, matching the IntelliJ quota dialog wording. + * + * @param plan the user's Copilot plan, or {@code null} when unknown + * @param overageEnabled {@code true} when additional paid usage is already enabled for the user + * @param canUpgradePlan whether the user can upgrade their Copilot plan, or {@code null} when the + * language server did not supply this field + * @return an immutable, possibly empty list; never {@code null} + */ + public static List<QuotaAction> forPlan(CopilotPlan plan, boolean overageEnabled, Boolean canUpgradePlan) { + if (plan == null) { + return List.of(); + } + QuotaAction upgradePrimary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, true); + QuotaAction upgradeSecondary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, false); + String overageLabel = overageEnabled ? Messages.menu_quota_increaseBudget + : Messages.menu_quota_enableAdditionalUsage; + QuotaAction manageOverage = new QuotaAction(overageLabel, + Messages.chat_noQuotaView_enableAdditionalUsageButton_tooltip, + UiConstants.MANAGE_COPILOT_OVERAGE_URL, true); + + boolean hasOverage = switch (plan) { + case individual, individual_pro, individual_max -> true; + case free, business, enterprise -> false; + }; + boolean showUpgrade = canUpgradePlan != null ? canUpgradePlan : switch (plan) { + case free, individual, individual_pro -> true; + case individual_max, business, enterprise -> false; + }; + if (hasOverage && showUpgrade) { + return List.of(manageOverage, upgradeSecondary); + } + if (hasOverage) { + return List.of(manageOverage); + } + if (showUpgrade) { + return List.of(upgradePrimary); + } + return List.of(); + } + + /** + * Returns {@code true} when an error response represents a Bring-Your-Own-Key (BYOK) quota-exceeded + * condition, i.e. a {@code 402} from the language server that also carries a non-blank model + * provider name. BYOK usage is governed by the customer's provider account rather than the user's + * Copilot plan, so callers should suppress the plan-driven + * {@link #forPlan(CopilotPlan, boolean, Boolean)} actions and substitute the BYOK-specific + * message in this case. + * + * @param code the error code from the language server + * @param modelProviderName the BYOK model-provider name from the server payload, or {@code null} + * @return {@code true} when the error should be rendered as a BYOK quota notice + */ + public static boolean isByokQuotaExceeded(int code, String modelProviderName) { + return code == 402 && StringUtils.isNotBlank(modelProviderName); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java index 8c9faf9d..e1bf9b25 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ReferencedFile.java @@ -9,6 +9,8 @@ import org.eclipse.e4.ui.css.swt.CSSSWTConstants; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.swt.SWT; +import org.eclipse.swt.accessibility.AccessibleAdapter; +import org.eclipse.swt.accessibility.AccessibleEvent; import org.eclipse.swt.events.KeyAdapter; import org.eclipse.swt.events.KeyEvent; import org.eclipse.swt.events.MouseAdapter; @@ -19,6 +21,7 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.layout.RowData; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -100,6 +103,7 @@ public void keyPressed(KeyEvent e) { }); AccessibilityUtils.addFocusBorderToComposite(this); + addAccessibilityName(this); } /** @@ -166,10 +170,33 @@ protected void setFile(@Nullable IResource file) { setLayoutData(layoutData); ChatView chatView = UiUtils.getView(Constants.CHAT_VIEW_ID, ChatView.class); if (chatView != null) { - chatView.layout(true, true); + // Pass every child whose content setFile()/setupXDisplay() can touch (icon image, file name + // text/CSS class, close button image) -- not just this composite or a single child. SWT's + // targeted requestLayout() only flushes cached sizes for the exact controls it's given (plus + // their ancestor chains up to cmpFileRef and beyond); passing a subset silently leaves the + // others' cached sizes stale (previously: a clipped icon, and before that a chip that didn't + // shrink). Since all three are descendants of this composite, their ancestor walk-up already + // covers this chip's own RowData/visibility change too -- no need to pass `this` separately. + chatView.requestLayout(lblfileIcon, lblFileName, lblClose); } } + /** + * Returns the accessible name for this referenced file widget. + */ + protected @Nullable String getAccessibilityName() { + return file == null ? null : file.getName(); + } + + private void addAccessibilityName(Control control) { + control.getAccessible().addAccessibleListener(new AccessibleAdapter() { + @Override + public void getName(AccessibleEvent event) { + event.result = getAccessibilityName(); + } + }); + } + /** * Setup display for unsupported files (e.g., images without vision support). */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java index e5203cea..ec340b7e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java @@ -3,8 +3,9 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.apache.commons.lang3.StringUtils; -import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; @@ -22,31 +23,35 @@ import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * A reusable banner widget that displays an informational message with an inline link and a close button. Shows an info - * icon, the provided message with an appended link, and a dismiss (×) button. + * Reusable, dismissible banner shown above the chat input. Layout is a 3-column grid: severity icon, wrapping message, + * close button; an optional second row of action links is added when {@code actions} is non-empty. + * + * <p>The banner is constructed hidden and layout-excluded; call {@link #show()} to reveal it. Callers must pass + * already-localized strings. * * <p>Usage example: * - * <pre>var banner = new StaticBanner(parent, SWT.NONE, "You've used 90% of your rate limit.", "Get more info", - * "https://example.com", "Dismiss"); + * <pre> + * var banner = new StaticBanner(parent, SWT.NONE, "You've used 75% of your monthly quota.", + * List.of(new BannerAction("Upgrade Plan", "https://example.com/upgrade")), "Dismiss", true); * banner.show(); * </pre> */ public class StaticBanner extends Composite { - private Link messageLink; + private Label messageLabel; /** - * Create a static informational banner. + * Create a hidden static banner; call {@link #show()} to reveal. {@link SWT#BORDER} is always applied. * * @param parent the parent composite - * @param style the SWT style - * @param message the informational message to display - * @param linkText the text for the inline link (e.g. "Get more info") - * @param linkUrl the URL to open when the link is clicked - * @param closeTooltip the tooltip for the close button + * @param style additional SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param actions optional action links below the message; entries with blank label or URL are skipped + * @param closeTooltip tooltip for the close (×) button + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public StaticBanner(Composite parent, int style, String message, String linkText, String linkUrl, - String closeTooltip) { + public StaticBanner(Composite parent, int style, String message, List<BannerAction> actions, String closeTooltip, + boolean warning) { super(parent, style | SWT.BORDER); GridLayout layout = new GridLayout(3, false); @@ -56,26 +61,19 @@ public StaticBanner(Composite parent, int style, String message, String linkText setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); - // Info icon + // Severity icon Label iconLabel = new Label(this, SWT.NONE); - Image infoImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_INFO_TSK); - iconLabel.setImage(infoImage); + String iconKey = warning ? ISharedImages.IMG_OBJS_WARN_TSK : ISharedImages.IMG_OBJS_INFO_TSK; + Image iconImage = PlatformUI.getWorkbench().getSharedImages().getImage(iconKey); + iconLabel.setImage(iconImage); iconLabel.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false)); - // Message + inline link - this.messageLink = new Link(this, SWT.WRAP); - this.messageLink.setText(buildMessageText(message, linkText, linkUrl)); - this.messageLink.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - this.messageLink.addSelectionListener(new SelectionAdapter() { - @Override - public void widgetSelected(SelectionEvent e) { - if (StringUtils.isNotBlank(linkUrl)) { - UiUtils.openLink(linkUrl); - } - } - }); + // Wrapping message text + this.messageLabel = new Label(this, SWT.WRAP); + this.messageLabel.setText(StringUtils.defaultString(message)); + this.messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - // Close button + // Close (×) button Label closeButton = new Label(this, SWT.NONE); Image closeImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_ELCL_REMOVE); closeButton.setImage(closeImage); @@ -89,13 +87,35 @@ public void mouseUp(MouseEvent e) { } }); + // Optional action-link row, aligned under the message column. + List<BannerAction> safeActions = actions == null ? List.of() : actions; + if (!safeActions.isEmpty()) { + // Spacer to align with the icon column above. + new Label(this, SWT.NONE).setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + GridLayout actionLayout = new GridLayout(safeActions.size(), false); + actionLayout.marginWidth = 0; + actionLayout.marginHeight = 0; + actionLayout.horizontalSpacing = 12; + actionLayout.verticalSpacing = 0; + GridData actionRowData = new GridData(SWT.FILL, SWT.CENTER, true, false); + actionRowData.horizontalSpan = 2; + Composite actionRow = new Composite(this, SWT.NONE); + actionRow.setLayout(actionLayout); + actionRow.setLayoutData(actionRowData); + + for (BannerAction action : safeActions) { + addActionLink(actionRow, action); + } + } + setVisible(false); GridData gd = (GridData) getLayoutData(); gd.exclude = true; } /** - * Show the banner. + * Reveal the banner and re-layout the parent. No-op if disposed. */ public void show() { if (isDisposed()) { @@ -118,13 +138,19 @@ private void disposeBanner() { } } - private static String buildMessageText(String message, String linkText, String linkUrl) { - String safeMessage = escapeForLink(message); - if (StringUtils.isBlank(linkText) || StringUtils.isBlank(linkUrl)) { - return safeMessage; + private static void addActionLink(Composite parent, BannerAction action) { + if (action == null || StringUtils.isBlank(action.text()) || StringUtils.isBlank(action.url())) { + return; } - return NLS.bind(com.microsoft.copilot.eclipse.ui.i18n.Messages.chat_staticBanner_messageWithLink, safeMessage, - escapeForLink(linkText)); + Link link = new Link(parent, SWT.NONE); + link.setText("<a>" + escapeForLink(action.text()) + "</a>"); + link.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + link.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + UiUtils.openLink(action.url()); + } + }); } private static String escapeForLink(String text) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java index ea8430a1..e7dcf908 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentMessageBlock.java @@ -24,7 +24,7 @@ public class SubagentMessageBlock extends Composite { private AgentToolCall toolCall; // Track the current content widget for message processing - private BaseTurnWidget currentSubagentTurnWidget; + private ThinkingTurnWidget currentSubagentTurnWidget; /** * Create the subagent message block. @@ -91,9 +91,9 @@ public void appendToolCallStatus(AgentToolCall toolCall) { /** * Notify the end of the subagent turn. */ - public void notifyTurnEnd() { + public void flushMessageBuffer() { if (currentSubagentTurnWidget != null) { - currentSubagentTurnWidget.notifyTurnEnd(); + currentSubagentTurnWidget.flushMessageBuffer(); } } @@ -102,7 +102,7 @@ public void notifyTurnEnd() { * * @return the subagent turn widget */ - public BaseTurnWidget getSubagentTurnWidget() { + public ThinkingTurnWidget getSubagentTurnWidget() { return currentSubagentTurnWidget; } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentTurnWidget.java index 6f649458..99cc1a88 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SubagentTurnWidget.java @@ -19,14 +19,14 @@ * A turn widget for displaying subagent messages within a SubagentMessageBlock. * This widget doesn't show an avatar or role name, only the message content. */ -public class SubagentTurnWidget extends BaseTurnWidget { +public class SubagentTurnWidget extends ThinkingTurnWidget { /** * Create the widget. */ public SubagentTurnWidget(Composite parent, int style, ChatServiceManager serviceManager, String turnId, AgentToolCall toolCall) { - super(parent, style, serviceManager, turnId + "_subagent", true, + super(parent, style, serviceManager, turnId + "_subagent", getToolCallRoleName(toolCall)); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java new file mode 100644 index 00000000..e273986f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java @@ -0,0 +1,523 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.e4.ui.services.IStylingEngine; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.graphics.Cursor; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.swt.SpinnerAnimator; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Collapsible "Thinking" banner shown above an assistant turn while the model emits thinking stream. + * + * <p>Pure view: callers drive the visual state via {@link #showCompleted()} and {@link #showCancelled()}. + * The owning turn widget is responsible for cancellation events and title fetching. + */ +public class ThinkingBlock extends Composite { + private static final String SECONDARY_TEXT_CSS_CLASS = "text-secondary"; + private static final Pattern TITLE_PATTERN = + Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)"); + + private static final int STREAMING_MAX_HEIGHT = 180; + + private Composite header; + private Label iconLabel; + private Label titleLabel; + private Label chevronLabel; + + /** Scrollable wrapper around {@link #body}; used only during streaming. Disposed on finalized expand. */ + private ScrolledComposite bodyScroller; + /** Body container holding one {@link ThinkingSection} per parsed section. */ + private Composite body; + private final List<ThinkingSection> sections = new ArrayList<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private boolean expanded = true; + /** Auto-scroll to bottom during streaming. Turned off on any user scroll interaction. */ + private boolean autoScroll = true; + + /** + * Lifecycle of the block. Transitions are always forward: STREAMING → (SEALED →)? → COMPLETED|CANCELLED. + * SEALED means {@code sealThinking()} has fired and a title fetch is in flight; new thinking stream fragments must + * start a new block. + */ + private enum State { STREAMING, SEALED, COMPLETED, CANCELLED } + + private final String thinkingId = UUID.randomUUID().toString(); + private State state = State.STREAMING; + private SpinnerAnimator spinner; + private Image cancelledIcon; + private Image downArrowImage; + private Image rightArrowImage; + + private final IStylingEngine stylingEngine = PlatformUI.getWorkbench().getService(IStylingEngine.class); + + /** Construct an empty thinking block; the spinner starts immediately. */ + public ThinkingBlock(Composite parent, int style) { + super(parent, style); + GridLayout layout = new GridLayout(1, false); + layout.marginHeight = 2; + layout.marginWidth = 0; + layout.verticalSpacing = 4; + setLayout(layout); + setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + + createHeader(); + createBody(); + + addDisposeListener(e -> handleDispose()); + + setTitle(Messages.thinking_title); + spinner = new SpinnerAnimator(iconLabel); + spinner.start(); + updateChevron(); + } + + /** Append a thinking stream fragment. Null/empty fragments are ignored. */ + public void appendText(String fragment) { + if (fragment == null || fragment.isEmpty()) { + return; + } + textBuffer.append(fragment); + refreshBody(); + requestLayout(); + } + + /** Finalize as completed: hide spinner icon and transition to COMPLETED state. */ + public void showCompleted() { + if (isFinalized()) { + return; + } + if (!iconLabel.isDisposed()) { + iconLabel.setImage(null); + ((GridData) iconLabel.getLayoutData()).exclude = true; + iconLabel.setVisible(false); + iconLabel.requestLayout(); + } + state = State.COMPLETED; + } + + /** + * Cancel the thinking block. If still streaming, shows the cancel icon and collapses. If already sealed (thinking + * content finished, title fetch in flight), simply finalizes as completed since thinking itself was not interrupted. + * No-op if already finalized. + */ + public boolean showCancelled() { + if (isFinalized()) { + return false; + } + if (state == State.SEALED) { + // Thinking content already finished; just finalize without the cancel icon. + showCompleted(); + return false; + } + stopSpinner(); + if (cancelledIcon == null || cancelledIcon.isDisposed()) { + cancelledIcon = UiUtils.buildImageFromPngPath("/icons/cancel_status.png"); + } + if (!iconLabel.isDisposed()) { + iconLabel.setImage(cancelledIcon); + } + setExpanded(false); + unwrapBodyFromScroller(); + state = State.CANCELLED; + return true; + } + + /** + * Mark the block as sealed: the owning widget has requested a title and any further thinking stream fragments must + * land in a new block. Stops the spinner and collapses the block while the owning widget handles any subsequent + * title updates. No-op once the block has been finalized or already sealed. + */ + public void markSealed() { + if (state != State.STREAMING) { + return; + } + state = State.SEALED; + stopSpinner(); + setExpanded(false); + unwrapBodyFromScroller(); + } + + /** True only while new thinking stream fragments should still be appended to this block. */ + public boolean isAcceptingThinkStream() { + return state == State.STREAMING; + } + + /** True once the block has been completed or cancelled (spinner stopped, final title shown). */ + public boolean isFinalized() { + return state == State.COMPLETED || state == State.CANCELLED; + } + + /** The unique ID for this thinking block, shared with the persistence layer. */ + public String getThinkingId() { + return thinkingId; + } + + /** The full accumulated thinking text streamed so far. */ + public String getAccumulatedText() { + return textBuffer.toString(); + } + + /** Non-blank {@code **Title**} strings extracted from the accumulated thinking text. */ + public String[] getExtractedTitles() { + // Reuse the already-parsed section list rather than re-scanning the buffer. + return sections.stream() + .map(ThinkingSection::getTitle) + .filter(StringUtils::isNotBlank) + .toArray(String[]::new); + } + + private void stopSpinner() { + if (spinner != null) { + spinner.stop(); + spinner = null; + } + } + + private void createHeader() { + header = new Composite(this, SWT.NONE); + GridLayout headerLayout = new GridLayout(4, false); + headerLayout.marginHeight = 0; + headerLayout.marginWidth = 0; + // Match AgentStatusLabel's icon-to-text spacing for visual consistency. + headerLayout.horizontalSpacing = 2; + header.setLayout(headerLayout); + header.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + iconLabel = new Label(header, SWT.NONE); + iconLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + titleLabel = new Label(header, SWT.LEFT | SWT.WRAP); + titleLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + UiUtils.applyCssClass(titleLabel, SECONDARY_TEXT_CSS_CLASS, stylingEngine); + + ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + chatServiceManager.getChatFontService().registerControl(titleLabel); + } + + chevronLabel = new Label(header, SWT.NONE); + chevronLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + // Filler absorbs any remaining horizontal space so the chevron sits flush to the title. + Label filler = new Label(header, SWT.NONE); + filler.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + Cursor handCursor = getDisplay().getSystemCursor(SWT.CURSOR_HAND); + header.setCursor(handCursor); + titleLabel.setCursor(handCursor); + chevronLabel.setCursor(handCursor); + + // Constrain the title's width so SWT.WRAP can take effect when the header is narrower than + // the natural single-line width of the title. + header.addListener(SWT.Resize, e -> updateTitleWidthHint()); + + MouseAdapter toggleListener = new MouseAdapter() { + @Override + public void mouseUp(MouseEvent e) { + toggleExpanded(); + } + }; + // Attach to every header child (and the header itself) so the entire area that shows the hand + // cursor is actually clickable. iconLabel is intentionally excluded: it hosts the live spinner + // animation (and the cancel icon afterwards), and a clickable spinner is an odd affordance. + header.addMouseListener(toggleListener); + titleLabel.addMouseListener(toggleListener); + chevronLabel.addMouseListener(toggleListener); + filler.addMouseListener(toggleListener); + } + + private void createBody() { + bodyScroller = new ScrolledComposite(this, SWT.V_SCROLL); + bodyScroller.setExpandHorizontal(true); + bodyScroller.setExpandVertical(true); + bodyScroller.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + bodyScroller.setAlwaysShowScrollBars(false); + + body = new Composite(bodyScroller, SWT.NONE); + GridLayout bodyLayout = new GridLayout(1, false); + bodyLayout.marginHeight = 4; + bodyLayout.marginLeft = 4; + bodyLayout.marginWidth = 0; + bodyLayout.verticalSpacing = 6; + body.setLayout(bodyLayout); + + bodyScroller.setContent(body); + + // Any user scroll interaction disables auto-scroll unconditionally. + Runnable disableAutoScroll = () -> autoScroll = false; + bodyScroller.getVerticalBar().addListener(SWT.Selection, e -> disableAutoScroll.run()); + body.addListener(SWT.MouseVerticalWheel, e -> disableAutoScroll.run()); + } + + /** Parsed (title?, body) tuple. */ + private record ParsedSection(String title, String body) { + } + + private void refreshBody() { + if (body == null || body.isDisposed()) { + return; + } + List<ParsedSection> parsed; + try { + parsed = parseSections(textBuffer.toString()); + } catch (RuntimeException e) { + // Fallback: render the entire text as a single untitled section rather than crashing the chat view. + CopilotCore.LOGGER.error("Failed to parse thinking sections", e); + parsed = List.of(new ParsedSection(null, textBuffer.toString())); + } + + // Sections are append-only: titles never change and new ones are appended at the tail. Update existing + // bodies in place and create widgets only for newly parsed sections. + int reusable = Math.min(parsed.size(), sections.size()); + for (int i = 0; i < reusable; i++) { + // Defensive fallback if a title at the same index ever differs: rebuild from this index. + if (!Objects.equals(parsed.get(i).title, sections.get(i).getTitle())) { + for (int j = sections.size() - 1; j >= i; j--) { + ThinkingSection s = sections.remove(j); + if (!s.isDisposed()) { + s.dispose(); + } + } + reusable = i; + break; + } + sections.get(i).setBody(parsed.get(i).body); + } + for (int i = reusable; i < parsed.size(); i++) { + ThinkingSection s = new ThinkingSection(body, parsed.get(i).title, stylingEngine); + s.setBody(parsed.get(i).body); + sections.add(s); + } + + body.requestLayout(); + updateScrollerDuringStreaming(); + } + + /** Resize the scroller to fit content (up to max height) and auto-scroll to bottom if enabled. */ + private void updateScrollerDuringStreaming() { + if (bodyScroller == null || bodyScroller.isDisposed()) { + return; + } + int contentWidth = bodyScroller.getClientArea().width; + if (contentWidth <= 0) { + contentWidth = SWT.DEFAULT; + } + int contentHeight = body.computeSize(contentWidth, SWT.DEFAULT).y; + bodyScroller.setMinSize(contentWidth, contentHeight); + + // Grow with content up to max; avoids blank space when content is small. + GridData scrollerData = (GridData) bodyScroller.getLayoutData(); + int newHint = Math.min(contentHeight, STREAMING_MAX_HEIGHT); + if (scrollerData.heightHint != newHint) { + scrollerData.heightHint = newHint; + } + + if (state == State.STREAMING && autoScroll) { + bodyScroller.setOrigin(0, contentHeight); + } else if (state == State.STREAMING && !autoScroll) { + // Re-enable auto-scroll if user scrolled back to the bottom. + int scrollPos = bodyScroller.getOrigin().y; + int viewportHeight = bodyScroller.getClientArea().height; + if (scrollPos + viewportHeight >= contentHeight - 10) { + autoScroll = true; + } + } + } + + /** + * Split {@code raw} on standalone {@code **Title**} lines (terminator: newline or end of buffer). Text before any + * title becomes a leading section with {@code title == null}. + */ + private static List<ParsedSection> parseSections(String raw) { + List<ParsedSection> result = new ArrayList<>(); + if (raw == null || raw.isEmpty()) { + return result; + } + Matcher matcher = TITLE_PATTERN.matcher(raw); + String currentTitle = null; + int cursor = 0; + while (matcher.find()) { + // Preserve the body's original whitespace (e.g. leading indentation for code blocks); only strip the + // trailing newline(s) that visually separate the body from the upcoming title delimiter. + String body = cursor <= matcher.start() + ? stripTrailingNewlines(raw.substring(cursor, matcher.start())) + : ""; + if (currentTitle != null || !body.isBlank()) { + result.add(new ParsedSection(currentTitle, body)); + } + currentTitle = matcher.group(1).trim(); + cursor = matcher.end(); + // Swallow the trailing newline(s) after the title so they don't show up at the top of the body. + while (cursor < raw.length() && (raw.charAt(cursor) == '\n' || raw.charAt(cursor) == '\r')) { + cursor++; + } + } + // Tail has no following title delimiter; only trim trailing newlines so leading indentation survives. + String tail = stripTrailingNewlines(raw.substring(cursor)); + if (currentTitle != null || !tail.isBlank()) { + result.add(new ParsedSection(currentTitle, tail)); + } + return result; + } + + private static String stripTrailingNewlines(String s) { + int end = s.length(); + while (end > 0) { + char c = s.charAt(end - 1); + if (c != '\n' && c != '\r') { + break; + } + end--; + } + return s.substring(0, end); + } + + /** Update the header title text. */ + public void setTitle(String text) { + if (titleLabel == null || titleLabel.isDisposed()) { + return; + } + titleLabel.setText(text == null ? "" : text); + updateTitleWidthHint(); + titleLabel.requestLayout(); + } + + private void updateTitleWidthHint() { + if (titleLabel == null || titleLabel.isDisposed() || header == null || header.isDisposed()) { + return; + } + int headerWidth = header.getClientArea().width; + if (headerWidth <= 0) { + return; + } + GridLayout layout = (GridLayout) header.getLayout(); + int iconWidth = iconLabel != null && !iconLabel.isDisposed() && iconLabel.isVisible() + ? iconLabel.computeSize(SWT.DEFAULT, SWT.DEFAULT).x : 0; + int chevronWidth = chevronLabel != null && !chevronLabel.isDisposed() + ? chevronLabel.computeSize(SWT.DEFAULT, SWT.DEFAULT).x : 0; + int spacing = layout.horizontalSpacing * (layout.numColumns - 1); + int available = headerWidth - iconWidth - chevronWidth - spacing - layout.marginWidth * 2; + if (available <= 0) { + return; + } + GridData titleData = (GridData) titleLabel.getLayoutData(); + int natural = titleLabel.computeSize(SWT.DEFAULT, SWT.DEFAULT).x; + int newHint = Math.min(natural, available); + if (newHint != titleData.widthHint) { + titleData.widthHint = newHint; + header.requestLayout(); + } + } + + private void toggleExpanded() { + setExpanded(!expanded); + } + + private void setExpanded(boolean newExpanded) { + this.expanded = newExpanded; + + Composite bodyContainer = bodyScroller != null ? bodyScroller : body; + if (bodyContainer != null && !bodyContainer.isDisposed()) { + GridData data = (GridData) bodyContainer.getLayoutData(); + data.exclude = !expanded; + bodyContainer.setVisible(expanded); + } + updateChevron(); + requestLayout(); + // Refresh the enclosing scroller so the revealed/hidden body height is reachable. + refreshEnclosingScroller(); + } + + /** Move body from ScrolledComposite to be a direct child of this block. */ + private void unwrapBodyFromScroller() { + if (bodyScroller == null || bodyScroller.isDisposed() || body == null || body.isDisposed()) { + return; + } + bodyScroller.setContent(null); + body.setParent(this); + GridData bodyData = new GridData(SWT.FILL, SWT.FILL, true, false); + bodyData.exclude = !expanded; + body.setLayoutData(bodyData); + body.setVisible(expanded); + bodyScroller.dispose(); + bodyScroller = null; + } + + private void updateChevron() { + if (chevronLabel == null || chevronLabel.isDisposed()) { + return; + } + Image image; + String tooltip; + if (expanded) { + if (downArrowImage == null || downArrowImage.isDisposed()) { + downArrowImage = UiUtils.buildImageFromPngPath("/icons/chat/down_arrow.png"); + } + image = downArrowImage; + tooltip = Messages.thinking_collapseTooltip; + } else { + if (rightArrowImage == null || rightArrowImage.isDisposed()) { + rightArrowImage = UiUtils.buildImageFromPngPath("/icons/chat/right_arrow.png"); + } + image = rightArrowImage; + tooltip = Messages.thinking_expandTooltip; + } + header.setToolTipText(tooltip); + chevronLabel.setImage(image); + chevronLabel.setToolTipText(tooltip); + if (titleLabel != null && !titleLabel.isDisposed()) { + titleLabel.setToolTipText(tooltip); + } + } + + private void refreshEnclosingScroller() { + Composite p = getParent(); + while (p != null && !p.isDisposed()) { + if (p instanceof ChatContentViewer) { + ((ChatContentViewer) p).refreshLayoutFull(); + return; + } + p = p.getParent(); + } + } + + private void handleDispose() { + if (cancelledIcon != null && !cancelledIcon.isDisposed()) { + cancelledIcon.dispose(); + cancelledIcon = null; + } + if (downArrowImage != null && !downArrowImage.isDisposed()) { + downArrowImage.dispose(); + downArrowImage = null; + } + if (rightArrowImage != null && !rightArrowImage.isDisposed()) { + rightArrowImage.dispose(); + rightArrowImage = null; + } + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingSection.java new file mode 100644 index 00000000..b1260765 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingSection.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.e4.ui.services.IStylingEngine; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Canvas; +import org.eclipse.swt.widgets.Composite; + +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * One titled section inside a {@link ThinkingBlock}: gutter (dot beside the title, vertical line beside the body) + + * content column (bold title, then markdown body). The parent diffs parsed sections against live ones and calls + * {@link #setBody(String)} during streaming. + */ +final class ThinkingSection extends Composite { + private static final String SECONDARY_TEXT_CSS_CLASS = "text-secondary"; + private static final int GUTTER_WIDTH = 12; + private static final int DOT_DIAMETER = 4; + + private final String title; + private final IStylingEngine stylingEngine; + private ChatMarkupViewer bodyViewer; + private String body = ""; + + public ThinkingSection(Composite parent, String title, IStylingEngine stylingEngine) { + super(parent, SWT.NONE); + this.title = title; + this.stylingEngine = stylingEngine; + + GridLayout layout = new GridLayout(2, false); + layout.marginHeight = 0; + layout.marginWidth = 0; + layout.horizontalSpacing = 6; + layout.verticalSpacing = 2; + setLayout(layout); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + if (StringUtils.isNotBlank(title)) { + createTitleRow(title); + } + } + + /** Title this section was constructed with, or {@code null} for a leading untitled section. */ + public String getTitle() { + return title; + } + + /** Set or update the body markdown. Lazily creates the body viewer (and gutter line) on first non-empty call. */ + public void setBody(String newBody) { + if (Objects.equals(body, newBody)) { + return; + } + body = newBody; + if (newBody == null || newBody.isEmpty()) { + // Append-only streaming never empties a body once it has content; nothing to do. + return; + } + if (bodyViewer == null || bodyViewer.getTextWidget().isDisposed()) { + bodyViewer = createBodyViewer(newBody); + } else { + bodyViewer.setMarkup(newBody); + bodyViewer.getTextWidget().requestLayout(); + } + } + + private void createTitleRow(String titleText) { + Canvas dot = new Canvas(this, SWT.NONE); + GridData dotData = new GridData(SWT.CENTER, SWT.CENTER, false, false); + dotData.widthHint = GUTTER_WIDTH; + dotData.heightHint = DOT_DIAMETER + 4; + dot.setLayoutData(dotData); + dot.addPaintListener(e -> { + e.gc.setAntialias(SWT.ON); + e.gc.setBackground(e.display.getSystemColor(SWT.COLOR_WIDGET_NORMAL_SHADOW)); + Rectangle area = ((Canvas) e.widget).getClientArea(); + int cx = (area.width - DOT_DIAMETER) / 2; + int cy = (area.height - DOT_DIAMETER) / 2; + e.gc.fillOval(cx, cy, DOT_DIAMETER, DOT_DIAMETER); + }); + + ChatMarkupViewer titleView = new ChatMarkupViewer(this, SWT.LEFT | SWT.WRAP); + StyledText titleWidget = titleView.getTextWidget(); + titleWidget.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + titleWidget.setEditable(false); + titleWidget.setMargins(0, 0, 0, 0); + UiUtils.applyCssClass(titleWidget, SECONDARY_TEXT_CSS_CLASS, stylingEngine); + titleView.setMarkup("**" + titleText + "**"); + } + + private ChatMarkupViewer createBodyViewer(String markup) { + Canvas line = new Canvas(this, SWT.NONE); + GridData lineData = new GridData(SWT.CENTER, SWT.FILL, false, false); + lineData.widthHint = GUTTER_WIDTH; + lineData.heightHint = 5; + line.setLayoutData(lineData); + line.addPaintListener(e -> { + e.gc.setBackground(e.display.getSystemColor(SWT.COLOR_WIDGET_LIGHT_SHADOW)); + Rectangle area = ((Canvas) e.widget).getClientArea(); + int cx = (area.width - 1) / 2; + e.gc.fillRectangle(cx, 0, 1, area.height); + }); + + ChatMarkupViewer viewer = new ChatMarkupViewer(this, SWT.MULTI | SWT.WRAP); + StyledText text = viewer.getTextWidget(); + text.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + text.setEditable(false); + text.setMargins(0, 0, 0, 0); + UiUtils.applyCssClass(text, SECONDARY_TEXT_CSS_CLASS, stylingEngine); + viewer.setMarkup(markup); + return viewer; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java new file mode 100644 index 00000000..87505e23 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Base class for turn widgets that support thinking blocks (Copilot and subagent turns). + */ +public abstract class ThinkingTurnWidget extends BaseTurnWidget { + + private ThinkingBlock currentBlock; + private String conversationId; + + /** + * The server-assigned turn ID for persistence. Needed because widget turnId may not match the + * persistence key (e.g. SubagentTurnWidget uses toolCallId + suffix, not the server turn ID). + */ + private String persistTurnId; + + /** Construct a turn widget that supports streaming thinking blocks. */ + protected ThinkingTurnWidget(Composite parent, int style, ChatServiceManager serviceManager, String turnId, + String overrideRoleName) { + super(parent, style, serviceManager, turnId, true, overrideRoleName); + } + + @Override + public ThinkingTurnWidget getActiveTurnWidget() { + return (ThinkingTurnWidget) super.getActiveTurnWidget(); + } + + /** + * Set the conversation ID and server-assigned turn ID for thinking-block persistence. + * Sets on both this widget and the current active widget (if different) so that + * sealThinking/cancel work regardless of which widget is active at call time. + */ + public void setConversationContext(String conversationId, String persistTurnId) { + this.conversationId = conversationId; + this.persistTurnId = persistTurnId; + ThinkingTurnWidget active = getActiveTurnWidget(); + if (active != null && active != this) { + active.conversationId = conversationId; + active.persistTurnId = persistTurnId; + } + } + + /** + * Append a thinking stream fragment from the language server, routing to the active turn (parent or subagent). + * Must be called on the UI thread. + */ + public void appendThinking(Thinking thinking) { + // Preserve whitespace-only thinking fragments; they can carry markdown boundaries between sections. + if (thinking == null || StringUtils.isEmpty(thinking.text())) { + return; + } + if (isDisposed()) { + return; + } + ThinkingTurnWidget active = getActiveTurnWidget(); + if (active == null || active.isDisposed()) { + return; + } + // Single source of truth: ThinkingBlock decides whether it can accept more thinking stream fragments (it can't + // once sealed, completed, or cancelled). Any of those transitions must start a fresh block. + if (active.currentBlock == null || active.currentBlock.isDisposed() + || !active.currentBlock.isAcceptingThinkStream()) { + active.currentBlock = new ThinkingBlock(active, SWT.NONE); + } + active.currentBlock.appendText(thinking.text()); + } + + /** + * Seal the active thinking block and asynchronously fetch a title to finalize it. + * Must be called on the UI thread. Requires {@link #setConversationContext} to have been called first. + */ + public void sealThinking() { + if (isDisposed()) { + return; + } + ThinkingTurnWidget active = getActiveTurnWidget(); + if (active == null || active.isDisposed()) { + return; + } + if (active.persistTurnId == null) { + return; + } + ThinkingBlock target = active.currentBlock; + // Skip when the block is no longer accepting thinking stream fragments (already sealed, completed, or cancelled) + // so we don't fire a stale generateTitle request whose response would be discarded. + if (target == null || target.isDisposed() || !target.isAcceptingThinkStream()) { + return; + } + String content = target.getAccumulatedText(); + if (StringUtils.isBlank(content)) { + // Nothing to title; leave the block alone (still in-progress) so a later thinking stream fragment can keep + // streaming. + return; + } + CopilotLanguageServerConnection ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + target.markSealed(); + if (ls == null) { + target.showCompleted(); + requestLayout(); + return; + } + String[] titles = target.getExtractedTitles(); + // Server schema rejects null entries inside extractedTitles, so we send one of the two fields, never both. + boolean hasTitles = titles.length > 0; + GenerateThinkingTitleParams params = new GenerateThinkingTitleParams(hasTitles ? null : content, + hasTitles ? titles : null); + String thinkingBlockId = target.getThinkingId(); + ls.generateThinkingTitle(params) + .thenAccept(resp -> SwtUtils.invokeOnDisplayThread(() -> { + if (isDisposed() || target.isDisposed() || target.isFinalized()) { + return; + } + if (resp != null && StringUtils.isNotBlank(resp.title())) { + target.setTitle(resp.title()); + persistThinkingTitle(active.conversationId, active.persistTurnId, thinkingBlockId, resp.title()); + } + target.showCompleted(); + requestLayout(); + }, this)) + .exceptionally(ex -> { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (!isDisposed() && !target.isDisposed() && !target.isFinalized()) { + target.showCompleted(); + requestLayout(); + } + }, this); + return null; + }); + } + + /** Returns the active thinking block ID for persistence context, or {@code null} if none exists. */ + public String getActiveThinkingBlockId() { + ThinkingTurnWidget active = getActiveTurnWidget(); + if (active == null || active.isDisposed() || active.currentBlock == null || active.currentBlock.isDisposed()) { + return null; + } + return active.currentBlock.getThinkingId(); + } + + @Override + protected void onChatMessageCancelled() { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; + } + ThinkingTurnWidget active = getActiveTurnWidget(); + if (active == null || active.isDisposed()) { + return; + } + if (active.currentBlock != null && !active.currentBlock.isDisposed() + && !active.currentBlock.isFinalized()) { + boolean cancelled = active.currentBlock.showCancelled(); + if (cancelled && active.persistTurnId != null) { + active.cancelThinkingBlock(active.persistTurnId, active.currentBlock.getThinkingId()); + } + active.requestLayout(); + } + }, this); + } + + private void cancelThinkingBlock(String cancelTurnId, String thinkingBlockId) { + if (conversationId == null || serviceManager == null) { + return; + } + serviceManager.getPersistenceManager() + .cancelThinkingBlock(conversationId, cancelTurnId, thinkingBlockId); + } + + private void persistThinkingTitle(String conversationId, String persistTurnId, String thinkingBlockId, String title) { + if (conversationId == null || serviceManager == null) { + return; + } + serviceManager.getPersistenceManager() + .updateThinkingBlockTitle(conversationId, persistTurnId, thinkingBlockId, title); + } + + /** + * Restore a completed thinking block from persisted data. Creates a ThinkingBlock that is + * already in the completed state with the given content and title. + */ + public void restoreThinkingBlock(ThinkingBlockData data) { + if (isDisposed() || data == null || StringUtils.isBlank(data.getContent())) { + return; + } + ThinkingBlock block = new ThinkingBlock(this, SWT.NONE); + block.appendText(data.getContent()); + if (data.isCancelled()) { + block.showCancelled(); + } else { + block.markSealed(); + if (StringUtils.isNotBlank(data.getTitle())) { + block.setTitle(data.getTitle()); + } + block.showCompleted(); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java index 72955327..2582320c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.SelectionAdapter; @@ -16,12 +18,16 @@ import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Widget to display a message when the user has no quota. + * Widget that displays a warning message under a chat turn, optionally followed by plan-driven action buttons sourced + * from {@link QuotaActions#forPlan(CopilotPlan, boolean, Boolean)}. Presentation-only: the caller decides the message + * and whether to pass a plan. */ public class WarnWidget extends Composite { private int buttonLeftMargin; @@ -30,30 +36,78 @@ public class WarnWidget extends Composite { * Create the composite. * * @param parent the parent composite + * @param style the SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param userPlan the user's Copilot plan to render plan-driven action buttons, or {@code null} for no buttons + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" + * @param canUpgradePlan whether the user can upgrade their Copilot plan, or {@code null} when the language + * server did not supply this field; forwarded to {@link QuotaActions#forPlan(CopilotPlan, boolean, Boolean)} + */ + public WarnWidget(Composite parent, int style, String message, CopilotPlan userPlan, boolean overageEnabled, + Boolean canUpgradePlan) { + super(parent, style | SWT.BORDER); + GridLayout outerLayout = new GridLayout(1, true); + outerLayout.verticalSpacing = 0; + setLayout(outerLayout); + setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + + buildWarnLabelWithIcon(message); + + if (userPlan != null) { + buildActionButtons(userPlan, overageEnabled, canUpgradePlan); + } + parent.layout(); + } + + /** + * Legacy constructor used when token-based billing is not yet enabled on the language server. Renders the message + * and, for 402 responses whose text contains the legacy upgrade hint, a single "Upgrade to Copilot Pro" button. + * + * <p>TODO: Remove this constructor after TBB is officially released. + * + * @param parent the parent composite + * @param style the SWT style bits * @param message the message to display + * @param code the server error code */ public WarnWidget(Composite parent, int style, String message, int code) { super(parent, style | SWT.BORDER); - setLayout(new GridLayout(1, true)); + GridLayout outerLayout = new GridLayout(1, true); + outerLayout.verticalSpacing = 0; + setLayout(outerLayout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); buildWarnLabelWithIcon(message); // Render the button based on the error code. See: // https://github.com/microsoft/copilot-client/blob/77f8f28e1a1e2efb51b6f92649bd9d085b8b64f5/lib/src/conversation/fetchPostProcessor.ts#L232-L248 - if (code == 402) { - // TODO: This is just a temporary solution. We need compose a dialog to support any warn message once issue - // https://github.com/microsoft/copilot-client/issues/405 is resolved. - if (message.toLowerCase().contains("upgrade to copilot pro (30-day free trial)")) { - buildUpdatePlanButton(); - } + if (code == 402 && message != null + && message.toLowerCase().contains("upgrade to copilot pro (30-day free trial)")) { + buildLegacyUpdatePlanButton(); } parent.layout(); } + // TODO: Remove this legacy fallback after TBB is officially released. + private void buildLegacyUpdatePlanButton() { + RowLayout layout = new RowLayout(SWT.HORIZONTAL); + layout.marginLeft = this.buttonLeftMargin; + layout.marginTop = 0; + layout.spacing = 10; + + Composite composite = new Composite(this, SWT.NONE); + composite.setLayout(layout); + + addButton(composite, Messages.chat_noQuotaView_updatePlanButton, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + Messages.chat_noQuotaView_updatePlanLink, true); + } + private void buildWarnLabelWithIcon(String message) { Composite composite = new Composite(this, SWT.NONE); - composite.setLayout(new GridLayout(2, false)); + GridLayout warnLayout = new GridLayout(2, false); + composite.setLayout(warnLayout); composite.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); Label iconLabel = new Label(composite, SWT.TOP); @@ -62,7 +116,8 @@ private void buildWarnLabelWithIcon(String message) { GridData iconGd = new GridData(SWT.LEFT, SWT.TOP, false, false); iconGd.verticalIndent = 4; iconLabel.setLayoutData(iconGd); - buttonLeftMargin = warnImage.getBounds().width + iconGd.verticalIndent; + buttonLeftMargin = warnLayout.marginWidth + warnLayout.marginLeft + warnImage.getBounds().width + + warnLayout.horizontalSpacing; ChatMarkupViewer textLabel = new ChatMarkupViewer(composite, SWT.LEFT | SWT.WRAP); StyledText styledText = textLabel.getTextWidget(); @@ -73,22 +128,40 @@ private void buildWarnLabelWithIcon(String message) { requestLayout(); } - private void buildUpdatePlanButton() { - Composite composite = new Composite(this, SWT.NONE); + /** + * Render plan-driven action buttons for a quota-exceeded warning, kept in sync with the quota {@link StaticBanner}. + */ + private void buildActionButtons(CopilotPlan userPlan, boolean overageEnabled, Boolean canUpgradePlan) { + List<QuotaAction> actions = QuotaActions.forPlan(userPlan, overageEnabled, canUpgradePlan); + if (actions.isEmpty()) { + return; + } + RowLayout layout = new RowLayout(SWT.HORIZONTAL); - layout.marginLeft = this.buttonLeftMargin; // Add margin to the left of the buttons to align with the message + layout.marginLeft = this.buttonLeftMargin; // Align with the message text + layout.marginTop = 0; layout.spacing = 10; + + Composite composite = new Composite(this, SWT.NONE); composite.setLayout(layout); - Button updatePlanButton = new Button(composite, SWT.PUSH); - updatePlanButton.setText(Messages.chat_noQuotaView_updatePlanButton); - updatePlanButton.setToolTipText(Messages.chat_noQuotaView_updatePlanButton_Tooltip); - updatePlanButton.addSelectionListener(new SelectionAdapter() { + for (QuotaAction action : actions) { + addButton(composite, action.label(), action.tooltip(), action.url(), action.primary()); + } + } + + private static void addButton(Composite parent, String label, String tooltip, String link, boolean primary) { + Button button = new Button(parent, SWT.PUSH); + button.setText(label); + button.setToolTipText(tooltip); + button.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(org.eclipse.swt.events.SelectionEvent event) { - UiUtils.openLink(Messages.chat_noQuotaView_updatePlanLink); + UiUtils.openLink(link); } }); - updatePlanButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + if (primary) { + button.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java index cc6a8a5a..1c0780a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WorkingSetBar.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; -import org.eclipse.core.resources.IFile; import org.eclipse.e4.ui.services.IStylingEngine; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; @@ -31,6 +30,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.tools.ChangedFile; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService; import com.microsoft.copilot.eclipse.ui.chat.tools.FileToolService.FileChangeProperty; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; @@ -70,7 +70,7 @@ public WorkingSetBar(Composite parent, int style) { * * @param filesMap a map of files and their change status */ - public void buildSummaryBarFor(Map<IFile, FileChangeProperty> filesMap) { + public void buildSummaryBarFor(Map<ChangedFile, FileChangeProperty> filesMap) { if (filesMap == null || isDisposed()) { return; } @@ -167,7 +167,7 @@ class WorkingSetTitleBar extends Composite { private Button undoButton; private String changeFilesTitle; - public WorkingSetTitleBar(Composite parent, int style, Map<IFile, FileChangeProperty> filesMap) { + public WorkingSetTitleBar(Composite parent, int style, Map<ChangedFile, FileChangeProperty> filesMap) { super(parent, style); GridLayout gl = new GridLayout(3, false); gl.marginWidth = 0; @@ -302,9 +302,10 @@ class ChangedFiles extends Composite { private static final int MAX_VISIBLE_FILES = 5; private final Composite contentArea; private final ScrolledComposite scrolledComposite; + private final WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); private List<FileRow> fileRows; // List to keep track of file rows - public ChangedFiles(Composite parent, int style, Map<IFile, FileChangeProperty> filesMap) { + public ChangedFiles(Composite parent, int style, Map<ChangedFile, FileChangeProperty> filesMap) { super(parent, style); // Main layout @@ -313,6 +314,7 @@ public ChangedFiles(Composite parent, int style, Map<IFile, FileChangeProperty> layout.marginHeight = 0; setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + addDisposeListener(e -> labelProvider.dispose()); // Count files long fileCount = filesMap.size(); @@ -345,15 +347,14 @@ public ChangedFiles(Composite parent, int style, Map<IFile, FileChangeProperty> contentArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); } - // TODO: Should share a same instance with ReferencedFile - WorkbenchLabelProvider labelProvider = new WorkbenchLabelProvider(); fileRows = new LinkedList<>(); - for (IFile file : filesMap.keySet()) { + for (ChangedFile file : filesMap.keySet()) { if (file == null) { continue; } - Image image = labelProvider.getImage(file); + Image image = file.isWorkspaceFile() ? labelProvider.getImage(file.getWorkspaceFile()) + : PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJ_FILE); fileRows.add(new FileRow(contentArea, SWT.NONE, image, file)); } @@ -396,7 +397,7 @@ public class FileRow extends Composite { /** * Constructs a new FileRow. */ - public FileRow(Composite parent, int style, Image fileImage, IFile file) { + public FileRow(Composite parent, int style, Image fileImage, ChangedFile file) { super(parent, style); GridLayout layout = new GridLayout(2, false); @@ -434,7 +435,7 @@ public void mouseUp(MouseEvent e) { // File name (bold) Label nameLabel = new Label(fileInfo, SWT.NONE); nameLabel.setText(file.getName()); - nameLabel.setToolTipText(file.getFullPath().toString()); + nameLabel.setToolTipText(file.getDisplayPath()); nameLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, false, false)); nameLabel.addMouseListener(new MouseAdapter() { @Override @@ -466,7 +467,7 @@ public void mouseUp(MouseEvent e) { // File path CLabel pathLabel = new CLabel(fileInfo, SWT.NONE); - pathLabel.setText(file.getFullPath().toString()); + pathLabel.setText(file.getDisplayPath()); pathLabel.setLayoutData(new GridData(SWT.BEGINNING, SWT.CENTER, true, false)); pathLabel.addMouseListener(new MouseAdapter() { @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java new file mode 100644 index 00000000..bb548c77 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/AttachedFileRegistry.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +/** + * Tracks files explicitly attached by the user in the context panel. + * + * <p>New files are first stored in a {@code pending} set (not yet bound + * to any conversation ID). The confirmation handler checks pending files + * first, so auto-approve works even before the real conversation ID + * arrives from CLS. Once the real ID is known, call + * {@link #flushPending(String)} to move them into per-conversation + * storage. + */ +public class AttachedFileRegistry { + + private final Map<String, Set<String>> attachedPaths = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** Files waiting for a stable conversation ID. */ + private final Set<String> pendingFiles = + Collections.synchronizedSet(new HashSet<>()); + + /** + * Stages file paths for auto-approve before the conversation ID is + * known. These are checked by {@link #isAttachedFile} immediately. + */ + public void addPending(Collection<String> filePaths) { + if (filePaths == null || filePaths.isEmpty()) { + return; + } + filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .forEach(pendingFiles::add); + } + + /** + * Moves pending files into per-conversation storage under the given + * conversation ID, then clears the pending set. + */ + public void flushPending(String conversationId) { + if (StringUtils.isBlank(conversationId) || pendingFiles.isEmpty()) { + return; + } + Set<String> flushed; + synchronized (pendingFiles) { + flushed = new HashSet<>(pendingFiles); + pendingFiles.clear(); + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, flushed, (a, b) -> { + Set<String> merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Records files for an existing conversation (continued turns). + */ + public void addAttachedFiles(String conversationId, + Collection<String> filePaths) { + if (filePaths == null || filePaths.isEmpty() + || StringUtils.isBlank(conversationId)) { + return; + } + Set<String> keys = filePaths.stream() + .filter(StringUtils::isNotBlank) + .map(AttachedFileRegistry::toComparisonKey) + .collect(Collectors.toSet()); + if (keys.isEmpty()) { + return; + } + evictOldestIfNeeded(); + synchronized (attachedPaths) { + attachedPaths.merge(conversationId, keys, (a, b) -> { + Set<String> merged = new HashSet<>(a); + merged.addAll(b); + return merged; + }); + } + } + + /** + * Returns {@code true} when the given file was explicitly attached + * by the user — either in the pending set or for the given conversation. + */ + public boolean isAttachedFile(String conversationId, String filePath) { + if (StringUtils.isBlank(filePath)) { + return false; + } + String key = toComparisonKey(filePath); + // Check pending files first (before conversation ID is known) + if (pendingFiles.contains(key)) { + return true; + } + Set<String> paths = attachedPaths.get(conversationId); + return paths != null && paths.contains(key); + } + + /** Removes all tracked data for a conversation. */ + public void clearConversation(String conversationId) { + attachedPaths.remove(conversationId); + } + + /** Discards any pending (pre-conversation) files. */ + public void clearPending() { + pendingFiles.clear(); + } + + private void evictOldestIfNeeded() { + synchronized (attachedPaths) { + while (attachedPaths.size() >= ConfirmationHandler.MAX_SESSION_CONVERSATIONS) { + var it = attachedPaths.entrySet().iterator(); + if (it.hasNext()) { + it.next(); + it.remove(); + } + } + } + } + + private static String toComparisonKey(String path) { + return ConfirmationHandler.normalizePath(path); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java new file mode 100644 index 00000000..3e1e2e1a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationHandler.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Evaluates whether a tool confirmation request can be auto-approved. + * Each implementation handles a specific category of tool (terminal, file operations, MCP, etc.). + */ +public interface ConfirmationHandler { + + /** Maximum number of conversations tracked in session memory. */ + int MAX_SESSION_CONVERSATIONS = 50; + + /** + * Normalizes a file path for case-insensitive, separator-agnostic comparison. + * Converts backslashes to forward slashes and lowercases the result. + * + * @param path the file path to normalize + * @return the normalized path + */ + static String normalizePath(String path) { + return path.replace('\\', '/').toLowerCase(Locale.ROOT); + } + + /** + * Extracts the {@code toolType} string from the input map of a confirmation request. + * Returns {@code null} if the field is absent or not a string. + * + * @param params the confirmation request parameters from CLS + * @return the toolType value, or {@code null} + */ + static String extractToolType(InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map<?, ?> inputMap) { + Object toolType = inputMap.get("toolType"); + if (toolType instanceof String) { + return (String) toolType; + } + } + return null; + } + + /** + * Evaluates whether the given confirmation request should be auto-approved, + * taking into account whether the auto-approval feature is enabled by token/policy. + * + * @param params the confirmation request parameters from CLS + * @param sessionConversationId the conversation ID to use for session-scoped + * lookups (may differ from params.getConversationId() when called from a + * subagent context) + * @param isAutoApprovalEnabled whether the auto-approval feature is currently enabled + * @return the confirmation result + */ + ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled); + + /** + * Caches a user's decision based on the action scope. + * SESSION actions are stored in-memory per conversation; + * GLOBAL actions are written to preferences. + * + * @param action the user's selected action with metadata + * @param params the original confirmation params (for command data etc.) + * @param sessionConversationId the conversation ID to use for session storage + */ + default void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + // no-op by default + } + + /** Clears session-scoped approvals for the given conversation. */ + default void clearSession(String conversationId) { + // no-op by default + } + + /** + * Evicts the oldest entry from a {@link java.util.LinkedHashMap}-backed map when it reaches + * {@link #MAX_SESSION_CONVERSATIONS}. Thread-safe via {@code synchronized(map)}. + * + * @param <V> value type + * @param map the map to evict from (must be a {@code Collections.synchronizedMap} wrapping a + * {@code LinkedHashMap} for correct eviction order) + */ + static <V> void evictOldestIfNeeded(Map<String, V> map) { + synchronized (map) { + while (map.size() >= MAX_SESSION_CONVERSATIONS) { + Entry<String, V> oldest = map.entrySet().iterator().next(); + map.remove(oldest.getKey()); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java new file mode 100644 index 00000000..50242adc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/ConfirmationService.java @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.EnumMap; +import java.util.Map; + +import org.eclipse.jface.preference.IPreferenceStore; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; + +/** + * Central entry point for auto-approve evaluation. Classifies each tool confirmation request + * into a category using the toolType field provided by CLS, then dispatches to the registered + * handler for that category. All session/global persist logic lives in each handler. + */ +public class ConfirmationService { + + /** Tool functional types matching CLS ToolFunctionalType enum values. */ + public enum ToolCategory { + TERMINAL("terminal"), + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"), + MCP_TOOL("mcp_tool"), + SAFE_TOOL("safe_tool"), + WEB("web"), + UNKNOWN("unknown"); + + private final String value; + + ToolCategory(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** + * Resolves a CLS toolType string to a ToolCategory. + */ + public static ToolCategory fromValue(String value) { + if (value != null) { + for (ToolCategory category : values()) { + if (category.value.equals(value)) { + return category; + } + } + } + return UNKNOWN; + } + } + + private final Map<ToolCategory, ConfirmationHandler> handlers = + new EnumMap<>(ToolCategory.class); + private final ConfirmationHandler fallbackHandler = + new FallbackConfirmationHandler(); + private final IPreferenceStore preferenceStore; + + /** + * Creates a new ConfirmationService. + * + * @param preferenceStore the preference store for reading auto-approve settings + * @param attachedFileRegistry registry of user-attached context files + */ + public ConfirmationService(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + handlers.put(ToolCategory.TERMINAL, + new TerminalConfirmationHandler(preferenceStore)); + FileOperationConfirmationHandler fileHandler = + new FileOperationConfirmationHandler(preferenceStore, + attachedFileRegistry); + handlers.put(ToolCategory.FILE_READ, fileHandler); + handlers.put(ToolCategory.FILE_WRITE, fileHandler); + handlers.put(ToolCategory.FILE_OPERATION, fileHandler); + handlers.put(ToolCategory.MCP_TOOL, + new McpConfirmationHandler(preferenceStore)); + } + + /** + * Evaluates whether a tool confirmation request should be auto-approved. + * + * <p>When the auto-approval feature is disabled by token or organization policy, all + * auto-approve rules (YOLO, session, global) are bypassed and the user is always prompted + * with a simple Allow-Once / Skip dialog. + * + * @param params the confirmation request parameters + * @param sessionConversationId the conversation ID for session-scoped lookups + */ + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + boolean autoApprovalEnabled = flags == null || flags.isAutoApprovalEnabled(); + + if (autoApprovalEnabled && preferenceStore.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)) { + return ConfirmationResult.AUTO_APPROVED; + } + + ToolCategory category = classify(params); + if (category == ToolCategory.SAFE_TOOL) { + return ConfirmationResult.AUTO_APPROVED; + } + + ConfirmationHandler handler = handlers.getOrDefault(category, fallbackHandler); + return handler.evaluate(params, sessionConversationId, autoApprovalEnabled); + } + + /** + * Caches the user's decision for future auto-approve lookups. + * + * @param action the user's selected action + * @param params the original confirmation params + * @param sessionConversationId the conversation ID for session storage + */ + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + if (action == null || action.getScope() == null + || action.getScope() == ConfirmationActionScope.ONCE) { + return; + } + ToolCategory category = classify(params); + ConfirmationHandler handler = handlers.get(category); + if (handler != null) { + handler.cacheDecision(action, params, sessionConversationId); + } + } + + /** Clears session-scoped approvals for a conversation across all handlers. */ + public void clearSession(String conversationId) { + for (ConfirmationHandler handler : handlers.values()) { + handler.clearSession(conversationId); + } + } + + ToolCategory classify(InvokeClientToolConfirmationParams params) { + return ToolCategory.fromValue(ConfirmationHandler.extractToolType(params)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java new file mode 100644 index 00000000..d58c77e7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FallbackConfirmationHandler.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.util.List; + +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Catch-all handler for tool types that have no dedicated handler registered. + * Always shows a simple confirmation dialog with Allow Once / Skip. + */ +public class FallbackConfirmationHandler implements ConfirmationHandler { + + @Override + public ConfirmationResult evaluate( + InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + String title = params.getTitle() != null + ? params.getTitle() + : NLS.bind(Messages.confirmation_title_fallback, params.getName()); + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce), + ConfirmationAction.skip( + Messages.confirmation_action_skip)))); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java new file mode 100644 index 00000000..ba861ed4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/FileOperationConfirmationHandler.java @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.core.utils.FileUtils; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates file-operation confirmation requests against user-configured glob pattern rules. + * Files outside the workspace always require confirmation. + */ +public class FileOperationConfirmationHandler implements ConfirmationHandler { + + /** Action types for edit confirmation. */ + public enum Action { + /** Allow this specific file for the rest of the session. */ + ACCEPT_FILE_SESSION, + /** Always allow this specific file (persisted globally). */ + ACCEPT_FILE_GLOBAL, + /** Allow all files under this folder for the session. */ + ACCEPT_FOLDER_SESSION + } + + /** File tool type matching CLS values. */ + enum FileToolType { + FILE_READ("file_read"), + FILE_WRITE("file_write"), + FILE_OPERATION("file_operation"); + + private final String value; + + FileToolType(String value) { + this.value = value; + } + + String getDefaultTitle() { + switch (this) { + case FILE_READ: + return Messages.confirmation_title_fileRead; + case FILE_WRITE: + return Messages.confirmation_title_fileWrite; + default: + return Messages.confirmation_title_fileOperation; + } + } + + String getMessageTemplate() { + switch (this) { + case FILE_READ: + return Messages.confirmation_message_fileRead; + case FILE_WRITE: + return Messages.confirmation_message_fileWrite; + default: + return Messages.confirmation_message_fileOperation; + } + } + + static FileToolType fromValue(String value) { + if (value != null) { + for (FileToolType t : values()) { + if (t.value.equals(value)) { + return t; + } + } + } + return FILE_OPERATION; + } + } + + static final String META_FILE_PATH = "filePath"; + static final String META_FOLDER_PATH = "folderPath"; + + /** + * Local fallback defaults matching CLS + * {@code FileSafetyRulesService.defaultRules}. + * Used when CLS is not yet available. + */ + public static final List<FileOperationAutoApproveRule> FALLBACK_DEFAULT_RULES = List.of( + new FileOperationAutoApproveRule("**/.github/instructions/*", + "GitHub instructions files", false, true), + new FileOperationAutoApproveRule("**/github-copilot/**/*", + "GitHub Copilot settings and token files", false, true)); + + private static final Type RULES_TYPE = + new TypeToken<List<FileOperationAutoApproveRule>>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + private final AttachedFileRegistry attachedFileRegistry; + private final Map<String, Set<String>> sessionApprovedFiles = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map<String, Set<String>> sessionApprovedFolders = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new FileOperationConfirmationHandler. + * + * @param preferenceStore the preference store for reading file-operation auto-approve rules + * @param attachedFileRegistry registry of user-attached files for auto-approval + */ + public FileOperationConfirmationHandler(IPreferenceStore preferenceStore, + AttachedFileRegistry attachedFileRegistry) { + this.preferenceStore = preferenceStore; + this.attachedFileRegistry = attachedFileRegistry; + } + + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params, sessionConversationId); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Auto-approve files explicitly attached by the user in the context panel + if (attachedFileRegistry.isAttachedFile( + sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Session override — file level + String convId = sessionConversationId; + Set<String> convFiles = sessionApprovedFiles.get(convId); + if (convFiles != null && convFiles.contains(normalizePath(filePath))) { + return ConfirmationResult.AUTO_APPROVED; + } + // Session override — folder level + Set<String> convFolders = sessionApprovedFolders.get(convId); + if (convFolders != null && filePath != null) { + String normalizedPath = normalizePath(filePath); + for (String folder : convFolders) { + if (normalizedPath.startsWith(folder + "/")) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // Auto-approve reads of Copilot customization files (skills, instructions, prompts, agents) + // discovered by the language server. Reading them is normal agent operation; edits still prompt. + if (isFileRead(params)) { + Path localPath = FileUtils.getLocalFilePath(filePath); + ICustomizationFileService service = getCustomizationFileService(); + if (localPath != null && service != null && isCustomizationRead( + localPath, service.getCustomizationFiles(), service.getSkillFolders())) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // Files outside workspace always require confirmation + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + // Check against rules + List<FileOperationAutoApproveRule> rules = loadRules(); + for (FileOperationAutoApproveRule rule : rules) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildContent(params)); + } + } + + return evaluateUnmatched(params); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params, String sessionConversationId) { + String filePath = extractFilePath(params); + if (StringUtils.isBlank(filePath)) { + return ConfirmationResult.DISMISSED; + } + + // Still honor files explicitly attached by the user (intentional context) + if (attachedFileRegistry.isAttachedFile(sessionConversationId, filePath)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // Outside workspace always requires confirmation (simplified dialog only) + if (isOutsideWorkspace(params)) { + return ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + + // Only check default rules; ignore user-configured rules + for (FileOperationAutoApproveRule rule : FALLBACK_DEFAULT_RULES) { + if (matchesGlob(filePath, rule.getPattern())) { + return rule.isAutoApprove() + ? ConfirmationResult.AUTO_APPROVED + : ConfirmationResult.needsConfirmation(buildSimplifiedContent(params)); + } + } + + // Unmatched workspace file: auto-approve + return ConfirmationResult.AUTO_APPROVED; + } + + private ConfirmationResult evaluateUnmatched( + InvokeClientToolConfirmationParams params) { + if (preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)) { + return ConfirmationResult.AUTO_APPROVED; + } + return ConfirmationResult.needsConfirmation(buildContent(params)); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String safeFilePath = filePath != null ? filePath : ""; + boolean outsideWorkspace = isOutsideWorkspace(params); + + List<ConfirmationAction> actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (outsideWorkspace && filePath != null) { + // Outside workspace: offer folder-level approval + Path parent = Path.of(filePath).getParent(); + String folderName = (parent != null && parent.getFileName() != null) + ? parent.getFileName().toString() + : (parent != null ? parent.toString() : filePath); + String folderPath = parent != null ? parent.toString() : ""; + actions.add(action(Action.ACCEPT_FOLDER_SESSION, + NLS.bind(Messages.confirmation_action_allowFolderSession, + folderName), + ConfirmationActionScope.SESSION, + Map.of(META_FOLDER_PATH, folderPath))); + } else { + // In workspace: offer file-level approval + actions.add(action(Action.ACCEPT_FILE_SESSION, + Messages.confirmation_action_allowFileSession, + ConfirmationActionScope.SESSION, + Map.of(META_FILE_PATH, safeFilePath))); + actions.add(action(Action.ACCEPT_FILE_GLOBAL, + Messages.confirmation_action_alwaysAllow, + ConfirmationActionScope.GLOBAL, + Map.of(META_FILE_PATH, safeFilePath))); + } + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : fileType.getDefaultTitle(); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, actions); + } + + /** + * Builds a simplified confirmation dialog with only Allow Once and Skip actions. + * Used when the auto-approval feature is disabled by token/policy. + */ + private ConfirmationContent buildSimplifiedContent( + InvokeClientToolConfirmationParams params) { + String filePath = extractFilePath(params); + final FileToolType fileType = + FileToolType.fromValue(ConfirmationHandler.extractToolType(params)); + String fileName = ""; + try { + if (filePath != null) { + fileName = Path.of(filePath).getFileName().toString(); + } + } catch (Exception ignored) { + // use empty + } + String title = params.getTitle() != null ? params.getTitle() : fileType.getDefaultTitle(); + String message = NLS.bind(fileType.getMessageTemplate(), fileName); + return new ConfirmationContent(title, message, + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip))); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map<String, String> extra) { + Map<String, String> meta = new java.util.HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractFilePath(InvokeClientToolConfirmationParams params) { + // Try toolMetadata first + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().getFilePath(); + } + // Fallback: extract from input map + Object input = params.getInput(); + if (input instanceof Map) { + Object path = ((Map<?, ?>) input).get("filePath"); + if (path == null) { + path = ((Map<?, ?>) input).get("path"); + } + return path instanceof String ? (String) path : null; + } + return null; + } + + // Uses CLS-provided isGlobal flag from sensitiveFileData metadata + private boolean isOutsideWorkspace(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getSensitiveFileData() != null) { + return metadata.getSensitiveFileData().isGlobal(); + } + return false; + } + + private static boolean isFileRead(InvokeClientToolConfirmationParams params) { + return FileToolType.fromValue(ConfirmationHandler.extractToolType(params)) + == FileToolType.FILE_READ; + } + + private static ICustomizationFileService getCustomizationFileService() { + IChatServiceManager chatServiceManager = CopilotCore.getPlugin().getChatServiceManager(); + return chatServiceManager == null ? null : chatServiceManager.getCustomizationFileService(); + } + + /** + * Returns whether reading {@code file} is a customization-file read that is safe to auto-approve. + * A read matches when the file exactly equals a language-server-reported customization file, or + * sits inside a reported skill folder (covering {@code SKILL.md} and the helper files it uses). + * + * @param file the absolute path being read + * @param customizationFiles reported single-file customizations (prompts, instructions, agents) + * @param skillFolders reported skill folders (each directory containing a {@code SKILL.md}) + * @return {@code true} when the read is a recognized customization read + */ + public static boolean isCustomizationRead(Path file, Set<Path> customizationFiles, Set<Path> skillFolders) { + Path normalized = file.toAbsolutePath().normalize(); + if (customizationFiles.contains(normalized)) { + return true; + } + for (Path skillFolder : skillFolders) { + if (normalized.startsWith(skillFolder)) { + return true; + } + } + return false; + } + + /** Normalizes a file path for case-insensitive, separator-agnostic comparison. */ + private static String normalizePath(String path) { + return ConfirmationHandler.normalizePath(path); + } + + static boolean matchesGlob(String filePath, String globPattern) { + if (StringUtils.isBlank(filePath) || StringUtils.isBlank(globPattern)) { + return false; + } + try { + // Normalize both path and pattern to forward slashes for consistent matching + String normalizedPath = filePath.replace('\\', '/'); + String normalizedPattern = globPattern.replace('\\', '/'); + + // Fast exact-match for absolute file path rules (e.g., from "Always Allow") + if (normalizedPath.equalsIgnoreCase(normalizedPattern)) { + return true; + } + + PathMatcher matcher = FileSystems.getDefault() + .getPathMatcher("glob:" + normalizedPattern); + return matcher.matches(Path.of(normalizedPath)); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Invalid file-operation auto-approve glob: " + globPattern, e); + return false; + } + } + + List<FileOperationAutoApproveRule> loadRules() { + String json = + preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List<FileOperationAutoApproveRule> rules = + new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file-operation auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction action, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = action.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + Map<String, String> meta = action.getMetadata(); + switch (type) { + case ACCEPT_FILE_SESSION: + String fp = meta.getOrDefault(META_FILE_PATH, ""); + if (!fp.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFiles); + sessionApprovedFiles.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(fp)); + } + break; + case ACCEPT_FOLDER_SESSION: + String folder = meta.getOrDefault(META_FOLDER_PATH, ""); + if (!folder.isEmpty()) { + ConfirmationHandler.evictOldestIfNeeded(sessionApprovedFolders); + sessionApprovedFolders.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(normalizePath(folder)); + } + break; + case ACCEPT_FILE_GLOBAL: + String globalFp = meta.getOrDefault(META_FILE_PATH, ""); + if (!globalFp.isEmpty()) { + List<FileOperationAutoApproveRule> rules = + new ArrayList<>(loadRules()); + // Update existing rule if path matches (case-insensitive for Windows) + boolean found = false; + for (FileOperationAutoApproveRule r : rules) { + if (r.getPattern().equalsIgnoreCase(globalFp)) { + r.setAutoApprove(true); + found = true; + break; + } + } + if (!found) { + rules.add(new FileOperationAutoApproveRule(globalFp, + Messages.confirmation_autoApprovedDescription, true)); + } + preferenceStore.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(rules)); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + sessionApprovedFiles.remove(conversationId); + sessionApprovedFolders.remove(conversationId); + attachedFileRegistry.clearConversation(conversationId); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java new file mode 100644 index 00000000..c2757223 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/McpConfirmationHandler.java @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolAnnotations; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates MCP tool confirmation requests against session and global approval lists. + * Supports per-server and per-tool approval at both session and global scopes, + * plus optional trust-annotation-based auto-approve for read-only tools. + */ +public class McpConfirmationHandler implements ConfirmationHandler { + + /** Action types for MCP tool confirmation decisions. */ + public enum Action { + /** Allow a specific tool for the current session/conversation. */ + ACCEPT_TOOL_SESSION, + /** Always allow a specific tool (persisted globally). */ + ACCEPT_TOOL_GLOBAL, + /** Allow all tools from a server for the current session/conversation. */ + ACCEPT_SERVER_SESSION, + /** Always allow all tools from a server (persisted globally). */ + ACCEPT_SERVER_GLOBAL + } + + static final String META_SERVER_NAME = "serverName"; + static final String META_TOOL_KEY = "toolKey"; + + private static final String SEPARATOR = "::"; + private static final Type STRING_LIST_TYPE = + new TypeToken<List<String>>() {}.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + private final Map<String, Set<String>> approvedServers = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map<String, Set<String>> approvedTools = + Collections.synchronizedMap(new LinkedHashMap<>()); + + /** + * Creates a new McpConfirmationHandler. + * + * @param preferenceStore the preference store for reading MCP auto-approve settings + */ + public McpConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, MCP tools always prompt with + * Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where MCP ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates an MCP tool confirmation request. Check order: + * 1. Session approved servers (by conversationId) + * 2. Session approved tools (by conversationId, key = "server::tool") + * 3. Global approved servers list + * 4. Global approved tools list + * 5. Trust annotations (readOnlyHint=true AND openWorldHint=false) + * 6. Otherwise: needs confirmation + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + String serverLower = serverName != null + ? serverName.toLowerCase(Locale.ROOT) : null; + String toolKey = buildToolKey(serverLower, toolName); + + // 1. Session: server approved for this conversation + if (serverLower != null) { + Set<String> sessionServers = + approvedServers.get(sessionConversationId); + if (sessionServers != null + && sessionServers.contains(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 2. Session: specific tool approved for this conversation + if (toolKey != null) { + Set<String> sessionTools = + approvedTools.get(sessionConversationId); + if (sessionTools != null && sessionTools.contains(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 3. Global: server in approved servers list + if (serverLower != null) { + List<String> globalServers = loadJsonList( + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : globalServers) { + if (s.toLowerCase(Locale.ROOT).equals(serverLower)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 4. Global: tool in approved tools list + if (toolKey != null) { + List<String> globalTools = loadJsonList( + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : globalTools) { + if (t.toLowerCase(Locale.ROOT).equals(toolKey)) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Trust annotations: read-only and not open-world + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)) { + ToolAnnotations annotations = params.getAnnotations(); + if (annotations != null + && annotations.isReadOnlyHint() + && !annotations.isOpenWorldHint()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 6. Needs confirmation + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName)); + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String serverName = extractServerName(params); + String toolName = extractToolName(params); + return ConfirmationResult.needsConfirmation( + buildContent(params, serverName, toolName, /* simplifiedOnly= */ true)); + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + Map<String, String> meta = confirmAction.getMetadata(); + String serverName = meta.get(META_SERVER_NAME); + String toolKey = meta.get(META_TOOL_KEY); + + switch (type) { + case ACCEPT_TOOL_SESSION: + if (toolKey != null) { + synchronized (approvedTools) { + ConfirmationHandler.evictOldestIfNeeded(approvedTools); + approvedTools.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(toolKey.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_SERVER_SESSION: + if (serverName != null) { + synchronized (approvedServers) { + ConfirmationHandler.evictOldestIfNeeded(approvedServers); + approvedServers.computeIfAbsent( + sessionConversationId, + k -> ConcurrentHashMap.newKeySet()) + .add(serverName.toLowerCase(Locale.ROOT)); + } + } + break; + case ACCEPT_TOOL_GLOBAL: + if (toolKey != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_TOOLS, toolKey); + } + break; + case ACCEPT_SERVER_GLOBAL: + if (serverName != null) { + addToGlobalList(Constants.AUTO_APPROVE_MCP_SERVERS, serverName); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + approvedServers.remove(conversationId); + approvedTools.remove(conversationId); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName) { + return buildContent(params, serverName, toolName, false); + } + + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + String serverName, String toolName, boolean simplifiedOnly) { + String toolKey = buildToolKey( + serverName != null ? serverName.toLowerCase(Locale.ROOT) : null, + toolName); + + List<ConfirmationAction> actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce( + Messages.confirmation_action_allowOnce)); + + if (!simplifiedOnly) { + if (toolName != null && toolKey != null) { + actions.add(action(Action.ACCEPT_TOOL_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, + "'" + toolName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_TOOL_KEY, toolKey))); + actions.add(action(Action.ACCEPT_TOOL_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, + "'" + toolName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_TOOL_KEY, toolKey))); + } + + if (serverName != null) { + actions.add(action(Action.ACCEPT_SERVER_SESSION, + NLS.bind(Messages.confirmation_action_allowServerSession, + "'" + serverName + "'"), + ConfirmationActionScope.SESSION, + Map.of(META_SERVER_NAME, serverName))); + actions.add(action(Action.ACCEPT_SERVER_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowServer, + "'" + serverName + "'"), + ConfirmationActionScope.GLOBAL, + Map.of(META_SERVER_NAME, serverName))); + } + } + + actions.add(ConfirmationAction.skip( + Messages.confirmation_action_skip)); + + String title; + if (toolName != null && serverName != null) { + title = NLS.bind(Messages.confirmation_title_mcpTool, + toolName, serverName); + } else { + title = params.getTitle() != null + ? params.getTitle() + : Messages.confirmation_title_mcpToolDefault; + } + + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map<String, String> extra) { + Map<String, String> meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractServerName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map<?, ?> inputMap) { + Object value = inputMap.get("mcpServerName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private String extractToolName( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map<?, ?> inputMap) { + Object value = inputMap.get("mcpToolName"); + if (value instanceof String s && StringUtils.isNotBlank(s)) { + return s; + } + } + return null; + } + + private static String buildToolKey(String serverLower, String toolName) { + if (serverLower == null || toolName == null) { + return null; + } + return serverLower + SEPARATOR + + toolName.toLowerCase(Locale.ROOT); + } + + private List<String> loadJsonList(String preferenceKey) { + String json = preferenceStore.getString(preferenceKey); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List<String> list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + preferenceKey, e); + return Collections.emptyList(); + } + } + + private void addToGlobalList(String preferenceKey, String value) { + List<String> current = new ArrayList<>(loadJsonList(preferenceKey)); + String lowerValue = value.toLowerCase(Locale.ROOT); + for (String existing : current) { + if (existing.toLowerCase(Locale.ROOT).equals(lowerValue)) { + return; // already present + } + } + current.add(value); + preferenceStore.setValue(preferenceKey, new Gson().toJson(current)); + } + +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java new file mode 100644 index 00000000..b4641499 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/confirmation/TerminalConfirmationHandler.java @@ -0,0 +1,547 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.confirmation; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationActionScope; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolMetadata; +import com.microsoft.copilot.eclipse.ui.chat.Messages; + +/** + * Evaluates terminal command confirmation requests against user-configured allow/deny rules. + * Rules are matched against command names provided by CLS in toolMetadata.terminalCommandData. + */ +public class TerminalConfirmationHandler implements ConfirmationHandler { + + /** Action types matching IntelliJ's TerminalAutoApproveAction enum. */ + public enum Action { + /** Allow specific command names for the current session/conversation. */ + ACCEPT_NAMES_SESSION, + /** Always allow specific command names (persisted as a global rule). */ + ACCEPT_NAMES_GLOBAL, + /** Allow this exact command line for the current session/conversation. */ + ACCEPT_EXACT_SESSION, + /** Always allow this exact command line (persisted as a global rule). */ + ACCEPT_EXACT_GLOBAL, + /** Allow all terminal commands for the current session/conversation. */ + ACCEPT_ALL_SESSION + } + + /** Result of evaluating sub-commands against rules. */ + enum RuleVerdict { ALL_APPROVED, DENY_BLOCKED, UNMATCHED } + + private static final class RuleResult { + final RuleVerdict verdict; + final List<String> unapprovedItems; + + RuleResult(RuleVerdict verdict, List<String> unapprovedItems) { + this.verdict = verdict; + this.unapprovedItems = unapprovedItems; + } + } + + static final String META_COMMAND_NAMES = "commandNames"; + static final String META_COMMAND_LINE = "commandLine"; + + /** Default deny rules for dangerous terminal commands. */ + public static final List<TerminalAutoApproveRule> DEFAULT_RULES = List.of( + new TerminalAutoApproveRule("rm", false), + new TerminalAutoApproveRule("rmdir", false), + new TerminalAutoApproveRule("del", false), + new TerminalAutoApproveRule("kill", false), + new TerminalAutoApproveRule("curl", false), + new TerminalAutoApproveRule("wget", false), + new TerminalAutoApproveRule("eval", false), + new TerminalAutoApproveRule("chmod", false), + new TerminalAutoApproveRule("chown", false), + new TerminalAutoApproveRule("/^Remove-Item\\b/i", false), + new TerminalAutoApproveRule("/(\\(.+\\))/s", false), + new TerminalAutoApproveRule("/`.+`/s", false), + new TerminalAutoApproveRule("/\\{.+\\}/s", false)); + + private static final Type RULES_TYPE = new TypeToken<List<TerminalAutoApproveRule>>() { + }.getType(); + + private final IPreferenceStore preferenceStore; + + // Session-scoped in-memory storage keyed by conversationId. + // Uses insertion-ordered maps so we can evict the oldest entry when the + // map grows beyond MAX_SESSION_CONVERSATIONS. + private final Map<String, Set<String>> allowedCommandNames = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Map<String, Set<String>> allowedExactCommands = + Collections.synchronizedMap(new LinkedHashMap<>()); + private final Set<String> allowAllConversations = + Collections.newSetFromMap( + Collections.synchronizedMap(new LinkedHashMap<>())); + + /** + * Creates a new TerminalConfirmationHandler. + * + * @param preferenceStore the preference store for reading terminal auto-approve rules + */ + public TerminalConfirmationHandler(IPreferenceStore preferenceStore) { + this.preferenceStore = preferenceStore; + } + + /** + * When the auto-approval feature is disabled, terminal commands always prompt + * with Allow Once / Skip only — no session or global approval buttons. + * This matches IntelliJ's behavior where terminal ignores all rules when disabled. + */ + @Override + public ConfirmationResult evaluate(InvokeClientToolConfirmationParams params, + String sessionConversationId, boolean isAutoApprovalEnabled) { + if (!isAutoApprovalEnabled) { + return evaluateAutoApprovalDisabled(params); + } + return evaluateAutoApprovalEnabled(params, sessionConversationId); + } + + /** + * Evaluates a terminal confirmation request. Check order follows IntelliJ: + * 1. Session "allow all" flag + * 2. Session exact commandLine match + * 3. Session command name match (all names must be approved) + * 4. Global exact commandLine match against rules + * 5. Global per-subCommand regex/prefix match against rules + * 6. Unmatched fallback (auto-approve if preference enabled) + */ + private ConfirmationResult evaluateAutoApprovalEnabled( + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String convId = sessionConversationId; + String commandLine = extractCommandLine(params); + + // 1. Session: all commands allowed for this conversation + if (allowAllConversations.contains(convId)) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 2. Session: exact commandLine previously approved + Set<String> exactSet = allowedExactCommands.get(convId); + if (commandLine != null && exactSet != null + && exactSet.contains(commandLine.trim())) { + return ConfirmationResult.AUTO_APPROVED; + } + + // 3. Session: all command names (e.g. "tree", "echo") approved + String[] cmdNames = getCommandNames(params); + Set<String> namesSet = allowedCommandNames.get(convId); + if (cmdNames != null && namesSet != null && cmdNames.length > 0) { + boolean allApproved = true; + for (String name : cmdNames) { + if (!namesSet.contains(name)) { + allApproved = false; + break; + } + } + if (allApproved) { + return ConfirmationResult.AUTO_APPROVED; + } + } + + // 4-6. Global rules + String[] subCommands = getSubCommands(params); + if (subCommands == null || subCommands.length == 0) { + return ConfirmationResult.needsConfirmation( + buildContent(params, null)); + } + + List<TerminalAutoApproveRule> rules = loadRules(); + + // 4. Exact commandLine match + if (commandLine != null) { + for (TerminalAutoApproveRule rule : rules) { + if (commandLine.trim().equals(rule.getCommand().trim()) + && rule.isAutoApprove()) { + return ConfirmationResult.AUTO_APPROVED; + } + } + } + + // 5. Per-subCommand evaluation + RuleResult result = evaluateSubCommands( + subCommands, cmdNames, rules, namesSet); + + switch (result.verdict) { + case ALL_APPROVED: + return ConfirmationResult.AUTO_APPROVED; + case DENY_BLOCKED: + return ConfirmationResult.needsConfirmation( + buildContent(params, result.unapprovedItems)); + case UNMATCHED: + default: + // 6. Unmatched fallback + if (preferenceStore.getBoolean( + Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)) { + return ConfirmationResult.AUTO_APPROVED; + } + List<String> items = result.unapprovedItems.isEmpty() + ? null : result.unapprovedItems; + return ConfirmationResult.needsConfirmation( + buildContent(params, items)); + } + } + + private ConfirmationResult evaluateAutoApprovalDisabled( + InvokeClientToolConfirmationParams params) { + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return ConfirmationResult.needsConfirmation( + new ConfirmationContent(title, params.getMessage(), + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + ConfirmationAction.skip(Messages.confirmation_action_skip)))); + } + + /** + * Evaluates each sub-command against global rules and session state. + * Returns a verdict and the list of unapproved command names. + */ + private RuleResult evaluateSubCommands(String[] subCommands, + String[] cmdNames, List<TerminalAutoApproveRule> rules, + Set<String> sessionApprovedNames) { + boolean allApproved = true; + boolean hasDeny = false; + List<String> unapproved = new ArrayList<>(); + + for (int i = 0; i < subCommands.length; i++) { + String subCommand = subCommands[i]; + String cmdName = cmdNames != null && i < cmdNames.length + ? cmdNames[i] : null; + boolean hasAllow = false; + boolean denied = false; + + // Session command-name approval + if (cmdName != null && sessionApprovedNames != null + && sessionApprovedNames.contains(cmdName)) { + hasAllow = true; + } + + // Global rule matching + for (TerminalAutoApproveRule rule : rules) { + if (matchesRule(subCommand, rule.getCommand())) { + if (rule.isAutoApprove()) { + hasAllow = true; + } else { + denied = true; + } + break; + } + } + + if (denied && !hasAllow) { + hasDeny = true; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } else if (!hasAllow) { + allApproved = false; + if (cmdName != null && !unapproved.contains(cmdName)) { + unapproved.add(cmdName); + } + } + } + + RuleVerdict verdict; + if (hasDeny) { + verdict = RuleVerdict.DENY_BLOCKED; + } else if (allApproved) { + verdict = RuleVerdict.ALL_APPROVED; + } else { + verdict = RuleVerdict.UNMATCHED; + } + return new RuleResult(verdict, unapproved); + } + + /** + * Builds the confirmation dialog content. When {@code unapprovedNames} + * is non-null, only those names appear in the command-name actions + * (already-approved names are filtered out). + */ + private ConfirmationContent buildContent( + InvokeClientToolConfirmationParams params, + List<String> unapprovedNames) { + final String[] commandNames = getCommandNames(params); + String commandLine = extractCommandLine(params); + + // Use unapproved names if available, otherwise all unique names + List<String> uniqueNames = unapprovedNames != null + ? unapprovedNames : dedup(commandNames); + String label = !uniqueNames.isEmpty() + ? "'" + String.join(", ", uniqueNames) + "'" : "'command'"; + String namesValue = String.join(",", uniqueNames); + + // Show exact command actions when commandLine differs from + // a single command name (otherwise redundant). + boolean showExact = commandLine != null + && !(uniqueNames.size() == 1 + && commandLine.trim().equals(uniqueNames.get(0))); + + List<ConfirmationAction> actions = new ArrayList<>(); + actions.add(ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce)); + if (!uniqueNames.isEmpty()) { + actions.add(action(Action.ACCEPT_NAMES_SESSION, + NLS.bind(Messages.confirmation_action_allowNamesSession, label), + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_NAMES, namesValue))); + actions.add(action(Action.ACCEPT_NAMES_GLOBAL, + NLS.bind(Messages.confirmation_action_alwaysAllowNames, label), + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_NAMES, namesValue))); + } + if (showExact) { + actions.add(action(Action.ACCEPT_EXACT_SESSION, + Messages.confirmation_action_allowExactSession, + ConfirmationActionScope.SESSION, + Map.of(META_COMMAND_LINE, commandLine))); + actions.add(action(Action.ACCEPT_EXACT_GLOBAL, + Messages.confirmation_action_alwaysAllowExact, + ConfirmationActionScope.GLOBAL, + Map.of(META_COMMAND_LINE, commandLine))); + } + actions.add(action(Action.ACCEPT_ALL_SESSION, + Messages.confirmation_action_allowAllCommands, + ConfirmationActionScope.SESSION, Map.of())); + actions.add(ConfirmationAction.skip(Messages.confirmation_action_skip)); + + String title = params.getTitle() != null + ? params.getTitle() : Messages.confirmation_title_terminal; + return new ConfirmationContent(title, params.getMessage(), actions); + } + + private static ConfirmationAction action(Action type, String label, + ConfirmationActionScope scope, Map<String, String> extra) { + Map<String, String> meta = new HashMap<>(extra); + meta.put(ConfirmationAction.META_ACTION, type.name()); + return new ConfirmationAction(label, true, scope, meta, false); + } + + private String extractCommandLine( + InvokeClientToolConfirmationParams params) { + Object input = params.getInput(); + if (input instanceof Map<?, ?> inputMap) { + Object cmd = inputMap.get("command"); + if (cmd instanceof String) { + return (String) cmd; + } + } + return null; + } + + private static List<String> dedup(String[] items) { + if (items == null || items.length == 0) { + return Collections.emptyList(); + } + LinkedHashSet<String> set = new LinkedHashSet<>(); + for (String item : items) { + if (item != null && !item.isBlank()) { + set.add(item); + } + } + return new ArrayList<>(set); + } + + private String[] getSubCommands(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getSubCommands(); + } + return null; + } + + private String[] getCommandNames(InvokeClientToolConfirmationParams params) { + ToolMetadata metadata = params.getToolMetadata(); + if (metadata != null && metadata.getTerminalCommandData() != null) { + return metadata.getTerminalCommandData().getCommandNames(); + } + return null; + } + + /** + * Matches a sub-command against a rule. Exact string match is checked + * first (for exact-command rules). Then regex rules (/pattern/flags) are + * used directly, and simple rules (e.g., "rm") are converted to ^rm\b. + */ + static boolean matchesRule(String subCommand, String rulePattern) { + if (StringUtils.isBlank(subCommand) + || StringUtils.isBlank(rulePattern)) { + return false; + } + + // Exact match first + if (subCommand.trim().equals(rulePattern.trim())) { + return true; + } + + String regex; + int regexFlags = 0; + + if (rulePattern.startsWith("/") + && rulePattern.lastIndexOf('/') > 0) { + // Explicit regex: "/^git\b/i" + int lastSlash = rulePattern.lastIndexOf('/'); + regex = rulePattern.substring(1, lastSlash); + String flags = rulePattern.substring(lastSlash + 1); + if (flags.contains("i")) { + regexFlags |= Pattern.CASE_INSENSITIVE; + } + if (flags.contains("s")) { + regexFlags |= Pattern.DOTALL; + } + } else { + // Simple rule: "rm" → "^rm\b" + regex = "^" + Pattern.quote(rulePattern) + "\\b"; + } + + try { + return Pattern.compile(regex, regexFlags) + .matcher(subCommand).find(); + } catch (PatternSyntaxException e) { + CopilotCore.LOGGER.error( + "Invalid terminal auto-approve regex: " + rulePattern, e); + return false; + } + } + + List<TerminalAutoApproveRule> loadRules() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List<TerminalAutoApproveRule> rules = new Gson().fromJson(json, RULES_TYPE); + return rules != null ? rules : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal auto-approve rules", e); + return Collections.emptyList(); + } + } + + @Override + public void cacheDecision(ConfirmationAction confirmAction, + InvokeClientToolConfirmationParams params, + String sessionConversationId) { + String actionName = confirmAction.getMetadata() + .get(ConfirmationAction.META_ACTION); + if (actionName == null) { + return; + } + Action type; + try { + type = Action.valueOf(actionName); + } catch (IllegalArgumentException e) { + return; + } + + String convId = sessionConversationId; + + // Prefer command data from action metadata (set by buildContent) + // so we persist exactly what the user chose, not the full params. + Map<String, String> meta = confirmAction.getMetadata(); + String metaNames = meta.get(META_COMMAND_NAMES); + String metaLine = meta.get(META_COMMAND_LINE); + + String[] cmdNames = metaNames != null && !metaNames.isBlank() + ? metaNames.split(",") : getCommandNames(params); + String commandLine = metaLine != null && !metaLine.isBlank() + ? metaLine : extractCommandLine(params); + + switch (type) { + case ACCEPT_NAMES_SESSION: + if (cmdNames != null) { + ConfirmationHandler.evictOldestIfNeeded(allowedCommandNames); + Set<String> nameSet = allowedCommandNames.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()); + Collections.addAll(nameSet, cmdNames); + } + break; + case ACCEPT_EXACT_SESSION: + if (commandLine != null && !commandLine.isBlank()) { + ConfirmationHandler.evictOldestIfNeeded(allowedExactCommands); + allowedExactCommands.computeIfAbsent( + convId, k -> ConcurrentHashMap.newKeySet()) + .add(commandLine.trim()); + } + break; + case ACCEPT_ALL_SESSION: + allowAllConversations.add(convId); + // Cap allowAllConversations the same way + while (allowAllConversations.size() > MAX_SESSION_CONVERSATIONS) { + allowAllConversations.iterator().remove(); + } + break; + case ACCEPT_NAMES_GLOBAL: + if (cmdNames != null) { + addGlobalRules(List.of(cmdNames)); + } + break; + case ACCEPT_EXACT_GLOBAL: + if (commandLine != null && !commandLine.isBlank()) { + addGlobalRules(List.of(commandLine.trim())); + } + break; + default: + break; + } + } + + @Override + public void clearSession(String conversationId) { + allowedCommandNames.remove(conversationId); + allowedExactCommands.remove(conversationId); + allowAllConversations.remove(conversationId); + } + + private void addGlobalRules(List<String> commands) { + List<TerminalAutoApproveRule> original = loadRules(); + Set<String> existing = original.stream() + .map(TerminalAutoApproveRule::getCommand) + .collect(Collectors.toSet()); + + // Override existing deny rules → allow + List<TerminalAutoApproveRule> updated = original.stream() + .map(r -> commands.contains(r.getCommand()) && !r.isAutoApprove() + ? new TerminalAutoApproveRule(r.getCommand(), true) : r) + .collect(Collectors.toCollection(ArrayList::new)); + + // Append new rules for commands not yet present + commands.stream() + .filter(cmd -> !existing.contains(cmd)) + .map(cmd -> new TerminalAutoApproveRule(cmd, true)) + .forEach(updated::add); + + if (!updated.equals(original)) { + preferenceStore.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(updated)); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java index c6cc8630..6c226499 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextSizeDonut.java @@ -63,7 +63,7 @@ public ContextSizeDonut(Composite parent, ContextWindowService contextWindowServ e.gc.drawArc(arcOffset, arcOffset, arcSize, arcSize, 0, 360); // Used portion starting from 12 o'clock (90°) going clockwise (negative angle) - double pct = Math.min(info.utilizationPercentage(), 100.0); + double pct = Math.min(contextWindowService.getDisplayUtilizationPercentage(info), 100.0); int filledAngle = (int) Math.round(pct / 100.0 * 360); if (filledAngle > 0) { Color filledColor = pct >= 90 ? CssConstants.getDonutWarningColor(e.display) diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java index 7f67021a..15f38afd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowPopup.java @@ -67,12 +67,12 @@ protected void populateContent(Composite parent) { tokenUsageLabel = createSecondaryTextLabel(tokenRow, formatTokenRow(latestInfo)); tokenUsageLabel.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); percentageLabel = createSecondaryTextLabel(tokenRow, - formatPercentage(latestInfo.utilizationPercentage())); + formatPercentage(contextWindowService.getDisplayUtilizationPercentage(latestInfo))); percentageLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, false, false)); progressBar = new ContextWindowBar(parent, SWT.NONE); progressBar.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - progressBar.setPercentage((int) Math.round(latestInfo.utilizationPercentage())); + progressBar.setPercentage((int) Math.round(contextWindowService.getDisplayUtilizationPercentage(latestInfo))); addSeparator(parent, SECTION_SPACING); @@ -107,7 +107,7 @@ private void updateLabels(ContextSizeInfo info) { return; } setLabelText(tokenUsageLabel, formatTokenRow(info)); - setLabelText(percentageLabel, formatPercentage(info.utilizationPercentage())); + setLabelText(percentageLabel, formatPercentage(contextWindowService.getDisplayUtilizationPercentage(info))); setLabelText(systemInstructionsValue, percentageOf(info.systemPromptTokens(), info.totalTokenLimit())); setLabelText(toolDefinitionsValue, @@ -120,7 +120,7 @@ private void updateLabels(ContextSizeInfo info) { setLabelText(toolResultsValue, percentageOf(info.toolResultsTokens(), info.totalTokenLimit())); if (progressBar != null && !progressBar.isDisposed()) { - progressBar.setPercentage((int) Math.round(info.utilizationPercentage())); + progressBar.setPercentage((int) Math.round(contextWindowService.getDisplayUtilizationPercentage(info))); } shell.requestLayout(); } @@ -146,9 +146,9 @@ private static String formatPercentage(double pct) { return String.format("%.1f%%", pct); } - private static String formatTokenRow(ContextSizeInfo info) { + private String formatTokenRow(ContextSizeInfo info) { return MessageFormat.format(Messages.context_window_tokens, - formatTokens(info.totalUsedTokens()), formatTokens(info.totalTokenLimit())); + formatTokens(info.totalUsedTokens()), formatTokens(contextWindowService.getDisplayTokenLimit(info))); } private static String percentageOf(int tokens, int totalLimit) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java index 412c41ea..d1b7b781 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/contextwindow/ContextWindowService.java @@ -17,6 +17,9 @@ import org.eclipse.swt.widgets.Display; import com.microsoft.copilot.eclipse.core.lsp.protocol.ContextSizeInfo; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; +import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -27,11 +30,15 @@ public class ContextWindowService { private IObservableValue<ContextSizeInfo> contextSizeObservable; private final Map<ContextWindowPopup, ISideEffect> popupSideEffects = new HashMap<>(); + private final ModelService modelService; /** * Creates the service and initializes the observable state on the UI realm. + * + * @param modelService the model service used to resolve the active model's context window */ - public ContextWindowService() { + public ContextWindowService(ModelService modelService) { + this.modelService = modelService; AtomicReference<IObservableValue<ContextSizeInfo>> observableRef = new AtomicReference<>(); SwtUtils.invokeOnDisplayThread(() -> { Realm realm = Realm.getDefault(); @@ -58,6 +65,68 @@ public ContextSizeInfo getState() { return result.get(); } + /** + * Returns the context-window limit shown in the donut popup. Prefer the active model's resolved full context window, + * falling back to the language-server usage snapshot when the model metadata is unavailable. + * + * @param info the context usage snapshot + * @return the display context-window limit + */ + public int getDisplayTokenLimit(ContextSizeInfo info) { + if (info == null) { + return 0; + } + + Integer outputLimit = getActiveModelOutputLimit(); + if (info.reservedOutputTokens() > 0) { + outputLimit = info.reservedOutputTokens(); + } + if (outputLimit != null && outputLimit > 0) { + return info.totalTokenLimit() + outputLimit; + } + + Integer modelContextWindow = getActiveModelContextWindow(); + return modelContextWindow != null && modelContextWindow > 0 ? modelContextWindow : info.totalTokenLimit(); + } + + /** + * Returns the utilization percentage against the displayed context window. + * + * @param info the context usage snapshot + * @return the utilization percentage + */ + public double getDisplayUtilizationPercentage(ContextSizeInfo info) { + if (info == null) { + return 0; + } + int displayLimit = getDisplayTokenLimit(info); + if (displayLimit <= 0) { + return info.utilizationPercentage(); + } + return (double) info.totalUsedTokens() / displayLimit * 100; + } + + private Integer getActiveModelContextWindow() { + CopilotModel activeModel = getActiveModel(); + if (activeModel == null) { + return null; + } + return ModelUtils.resolveContextWindowSize(activeModel); + } + + private Integer getActiveModelOutputLimit() { + CopilotModel activeModel = getActiveModel(); + if (activeModel == null || activeModel.getCapabilities() == null + || activeModel.getCapabilities().limits() == null) { + return null; + } + return activeModel.getCapabilities().limits().maxOutputTokens(); + } + + private CopilotModel getActiveModel() { + return modelService == null ? null : modelService.getActiveModel(); + } + /** * Updates the current context size state and notifies bound UI. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index 30864676..d46ae613 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -1,5 +1,8 @@ chat_chatContentView_errorTemplate=Sorry, your request failed: %s. Request id: %s. chat_warnWidget_defaultErrorMsg=An error occurred. Please try again. +chat_warnWidget_byokQuotaUsageMessage=You have exceeded your quota for BYOK (Bring Your Own Key) models. +chat_toolCall_genericError=Tool call failed. +chat_toolCall_errorTemplate={0}: {1} confirmDialog_keepChangesButton=Keep and Continue confirmDialog_undoChangesButton=Undo and Continue @@ -13,21 +16,55 @@ agentMessageWidget_openInBrowserButton=Open in Browser agentMessageWidget_openInBrowserTooltip=Click to open the pull request in browser agentMessageWidget_openJobListButton=Open Job List agentMessageWidget_openJobListTooltip=Click to open the GitHub Copilot Agent Jobs view -agentMessageWidget_openJobListError=Failed to open GitHub Copilot Agent Jobs view handoffContainer_proceedFrom=PROCEED FROM {0} fileChangeSummary_filesChanged=\ Files Changed fileChangeSummary_fileChanged=\ File Changed -fileChangeSummary_doneButton=Done fileChangeSummary_keepButton=Keep fileChangeSummary_undoButton=Undo fileChangeSummary_collapseTooltip={0}. Click to collapse the file list fileChangeSummary_expandTooltip={0}. Click to expand the file list -todoList_title=Todos todoList_titleWithCount=Todos ({0}/{1}) todoList_clearButton=Clear all todos todoList_clearButtonDisabled=Cannot clear todos while a task is in progress todoList_expandTooltip=Click to expand the todo list todoList_collapseTooltip=Click to collapse the todo list + +thinking_title=Thinking +thinking_expandTooltip=Click to show the thinking details +thinking_collapseTooltip=Click to hide the thinking details + +# Confirmation dialog action labels +confirmation_action_allowOnce=Allow Once +confirmation_action_skip=Skip +confirmation_action_allowAllCommands=Allow all commands in this Session +confirmation_action_allowNamesSession=Allow {0} in this Session +confirmation_action_alwaysAllowNames=Always Allow {0} +confirmation_action_allowExactSession=Allow this exact command in this Session +confirmation_action_alwaysAllowExact=Always Allow this exact command +confirmation_action_alwaysAllow=Always Allow +confirmation_action_allowFileSession=Allow this file in this Session +confirmation_action_allowFolderSession=Allow folder ''{0}'' in this Session + +# MCP confirmation dialog +confirmation_title_mcpTool=Run ''{0}'' tool from ''{1}'' MCP server +confirmation_title_mcpToolDefault=Allow MCP tool? +confirmation_action_allowServerSession=Allow tools from {0} in this Session +confirmation_action_alwaysAllowServer=Always Allow tools from {0} + +# Confirmation dialog titles +confirmation_title_terminal=Run command in terminal +confirmation_title_fallback=Allow {0}? +confirmation_title_fileRead=Allow reading sensitive file? +confirmation_title_fileWrite=Allow edits to sensitive file? +confirmation_title_fileOperation=Allow operation on sensitive file? + +# Confirmation dialog messages +confirmation_message_fileRead=The model wants to read sensitive file ({0}). +confirmation_message_fileWrite=The model wants to edit sensitive file ({0}). +confirmation_message_fileOperation=The model wants to access sensitive file ({0}). + +# Misc +confirmation_autoApprovedDescription=Auto-approved from dialog diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index 4eab4f0c..c7a013c2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -18,6 +18,9 @@ import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.chat.ChatEventsManager; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationResult; import com.microsoft.copilot.eclipse.core.chat.ToolInvocationListener; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.protocol.InvokeClientToolConfirmationParams; @@ -32,9 +35,13 @@ import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.BaseTurnWidget; import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; import com.microsoft.copilot.eclipse.ui.chat.tools.CreateFileTool; import com.microsoft.copilot.eclipse.ui.chat.tools.EditFileTool; @@ -55,6 +62,8 @@ public class AgentToolService implements ToolInvocationListener, TerminalService protected CopilotLanguageServerConnection lsConnection; private volatile boolean terminalToolsRegistered = false; private List<LanguageModelToolInformation> cachedBuiltInTools; + private final ConfirmationService confirmationService; + private final AttachedFileRegistry attachedFileRegistry; /** * Constructor for AgentToolService. @@ -62,6 +71,10 @@ public class AgentToolService implements ToolInvocationListener, TerminalService public AgentToolService(CopilotLanguageServerConnection lsConnection) { this.tools = new ConcurrentHashMap<>(); this.lsConnection = lsConnection; + this.attachedFileRegistry = new AttachedFileRegistry(); + this.confirmationService = new ConfirmationService( + CopilotUi.getPlugin().getPreferenceStore(), + attachedFileRegistry); TerminalServiceManager terminalManager = TerminalServiceManager.getInstance(); if (terminalManager != null) { terminalManager.addListener(this); @@ -243,6 +256,29 @@ public CompletableFuture<LanguageModelToolConfirmationResult> onToolConfirmation return CompletableFuture.completedFuture(result); } + // Resolve the session conversation ID: map subagent conversations to the + // parent so that session-scoped approvals apply to the whole chat. + String sessionConversationId = params.getConversationId(); + if (boundChatView != null + && !Objects.equals(sessionConversationId, + boundChatView.getConversationId()) + && Objects.equals(sessionConversationId, + boundChatView.getSubagentConversationId())) { + sessionConversationId = boundChatView.getConversationId(); + } + + // Auto-approve evaluation + ConfirmationResult autoApproveResult = + confirmationService.evaluate(params, sessionConversationId); + if (autoApproveResult.isAutoApproved()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.ACCEPT)); + } + if (autoApproveResult.isDismissed()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId()); if (turnWidget == null) { LanguageModelToolConfirmationResult result = new LanguageModelToolConfirmationResult( @@ -254,13 +290,30 @@ public CompletableFuture<LanguageModelToolConfirmationResult> onToolConfirmation BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); AtomicReference<CompletableFuture<LanguageModelToolConfirmationResult>> ref = new AtomicReference<>(); + ConfirmationContent content = autoApproveResult.getContent(); SwtUtils.invokeOnDisplayThread(() -> { - ref.set( - activeTurnWidget.requestToolExecutionConfirmation(params.getTitle(), params.getMessage(), params.getInput())); - boundChatView.getChatContentViewer().refreshScrollerLayout(); + ref.set(activeTurnWidget.requestToolExecutionConfirmation( + content, params.getInput())); + boundChatView.getChatContentViewer().refreshLayoutFull(); }); - return ref.get(); + CompletableFuture<LanguageModelToolConfirmationResult> future = ref.get(); + if (future != null && content != null) { + // Capture dialog reference before it can be reset by a new request + final InvokeToolConfirmationDialog dialog = + activeTurnWidget.getConfirmDialog(); + final String sessConvId = sessionConversationId; + future = future.thenApply(result -> { + ConfirmationAction selected = dialog != null + ? dialog.getSelectedAction() : null; + if (selected != null && selected.isAccept()) { + confirmationService.cacheDecision(selected, params, + sessConvId); + } + return result; + }); + } + return future; } private boolean validToolConfirmInvokeParams(String conversationId, String turnId) { @@ -283,6 +336,20 @@ private boolean validToolConfirmInvokeParams(String conversationId, String turnI return true; } + /** + * Get the confirmation service for auto-approve evaluation. + * + * @return the confirmation service + */ + public ConfirmationService getConfirmationService() { + return confirmationService; + } + + /** Returns the registry of user-attached context files. */ + public AttachedFileRegistry getAttachedFileRegistry() { + return attachedFileRegistry; + } + /** * Dispose the service. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java index 89a60c58..1f0953cd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatCompletionService.java @@ -10,21 +10,36 @@ import java.util.Set; import java.util.concurrent.ExecutionException; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.IResourceDelta; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.e4.core.services.events.IEventBroker; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.ui.PlatformUI; +import org.osgi.service.event.EventHandler; import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.CopilotAuthStatusListener; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.FeatureFlags; -import com.microsoft.copilot.eclipse.core.IdeCapabilities; +import com.microsoft.copilot.eclipse.core.chat.service.ICustomizationFileService.CustomizationType; +import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationAgent; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationTemplate; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.TemplateSource; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; /** * Service for handling slash commands. @@ -33,15 +48,21 @@ public class ChatCompletionService implements CopilotAuthStatusListener { public static final String AGENT_MARK = "@"; public static final String TEMPLATE_MARK = "/"; - private List<ConversationTemplate> templates = new ArrayList<>(); - private List<ConversationAgent> agents = new ArrayList<>(); - private HashSet<String> allCommands = new HashSet<>(); + private volatile List<ConversationTemplate> templates = List.of(); + private volatile List<ConversationAgent> agents = List.of(); + private volatile Set<String> allCommands = Set.of(); // Exclude intelliJ sepcific slash commands private static final Set<String> EXCLUDED_COMMANDS = Set.of("help", "feedback"); - public static final String INIT_JOB_FAMILY = - "com.microsoft.copilot.eclipse.chat.services.SlashCommandService.initJob"; + public static final String REFRESH_JOB_FAMILY = + "com.microsoft.copilot.eclipse.chat.services.SlashCommandService.refreshJob"; private CopilotLanguageServerConnection lsConnection; private AuthStatusManager authStatusManager; + private IResourceChangeListener skillFileListener; + private IEventBroker eventBroker; + private EventHandler customPromptsChangedHandler; + + private static final String SKILL_FILE_NAME = "SKILL.md"; + private static final String PROMPT_FILE_SUFFIX = ".prompt.md"; /** * Constructor for the SlashCommandService. @@ -50,51 +71,85 @@ public ChatCompletionService(CopilotLanguageServerConnection lsConnection, AuthS this.authStatusManager = authStatusManager; this.lsConnection = lsConnection; this.authStatusManager.addCopilotAuthStatusListener(this); + // TODO: Remove this listener once workspace-root is removed from workspaceFolders in CopilotLanguageClient as CLS + // can watch the project prompt file change directly. + this.skillFileListener = new SkillFileChangeListener(); + ResourcesPlugin.getWorkspace().addResourceChangeListener(skillFileListener, IResourceChangeEvent.POST_CHANGE); + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + if (this.eventBroker != null) { + // Templates only surface skills and prompts, so ignore instruction/agent changes. + this.customPromptsChangedHandler = event -> { + Object type = event.getProperty(IEventBroker.DATA); + if (type == CustomizationType.SKILL || type == CustomizationType.PROMPT) { + fetchAsync(); + } + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_CUSTOMIZATION_FILES, + customPromptsChangedHandler); + } syncCommands(this.authStatusManager.getCopilotStatus()); } - private void initAsync() { - final Runnable initRunnable = () -> { - initConversationTemplates(); - }; + private void fetchAsync() { + Job.getJobManager().cancel(REFRESH_JOB_FAMILY); - Job initJob = new Job("Initialize slash commands service") { + Job refreshJob = new Job("Refresh slash commands service") { @Override protected IStatus run(IProgressMonitor monitor) { - initRunnable.run(); + initConversationTemplates(monitor); + if (monitor.isCanceled()) { + return Status.CANCEL_STATUS; + } return Status.OK_STATUS; } @Override public boolean belongsTo(Object family) { - return Objects.equals(INIT_JOB_FAMILY, family); + return Objects.equals(REFRESH_JOB_FAMILY, family); } }; - initJob.setUser(false); - initJob.schedule(); + refreshJob.setUser(false); + refreshJob.schedule(); } - private void initConversationTemplates() { - if (isTempaltesReady() && isAgentsReady()) { - return; - } + private void initConversationTemplates(IProgressMonitor monitor) { + List<ConversationTemplate> newTemplates = new ArrayList<>(); + List<ConversationAgent> newAgents = new ArrayList<>(); + Set<String> newCommands = new HashSet<>(); + boolean skillsEnabled = PreferencesUtils.isSkillsEnabled(); // Command: /*** + // Pass workspace folders so the language server returns workspace-specific + // prompt files (.prompt.md) and skills (SKILL.md) alongside built-in templates. try { - ConversationTemplate[] rawTemplates = this.lsConnection.listConversationTemplates().get(); + List<WorkspaceFolder> workspaceFolders = LSPEclipseUtils.getWorkspaceFolders(); + ConversationTemplate[] rawTemplates = this.lsConnection.listConversationTemplates(workspaceFolders).get(); + if (monitor.isCanceled()) { + return; + } for (ConversationTemplate template : rawTemplates) { - if (template.getScopes().contains(CopilotScope.CHAT_PANEL) && !EXCLUDED_COMMANDS.contains(template.getId())) { - templates.add(template); - allCommands.add(TEMPLATE_MARK + template.getId()); + if (!skillsEnabled && template.source() == TemplateSource.SKILL) { + continue; + } + if (!EXCLUDED_COMMANDS.contains(template.id())) { + newTemplates.add(template); + newCommands.add(TEMPLATE_MARK + template.id()); } } } catch (InterruptedException | ExecutionException e) { CopilotCore.LOGGER.error(e); } + if (monitor.isCanceled()) { + return; + } + // Command: @*** try { ConversationAgent[] rawAgents = this.lsConnection.listConversationAgents().get(); + if (monitor.isCanceled()) { + return; + } for (ConversationAgent agent : rawAgents) { String agentSlug = agent.getSlug(); // @see ui.chat.ChatView#replaceWorkspaceCommand(String) @@ -105,12 +160,32 @@ private void initConversationTemplates() { agent.setSlug("workspace"); } - agents.add(agent); - allCommands.add(AGENT_MARK + agent.getSlug()); + newAgents.add(agent); + newCommands.add(AGENT_MARK + agent.getSlug()); } } catch (InterruptedException | ExecutionException e) { CopilotCore.LOGGER.error(e); } + + if (monitor.isCanceled()) { + return; + } + + // Atomically swap the cached data so readers always see a consistent snapshot. + // Publish immutable snapshots so readers cannot accidentally mutate a live collection. + this.templates = List.copyOf(newTemplates); + this.agents = List.copyOf(newAgents); + this.allCommands = Set.copyOf(newCommands); + } + + /** + * Returns templates filtered by the scope appropriate for the given chat mode. In Agent mode only {@code agent-panel} + * scoped templates (including skills) are shown; in Ask mode only {@code chat-panel} scoped templates are shown. + */ + public ConversationTemplate[] getFilteredTemplates(ChatMode chatMode) { + String scope = chatMode == ChatMode.Agent ? CopilotScope.AGENT_PANEL : CopilotScope.CHAT_PANEL; + return templates.stream().filter(t -> t.scopes() != null && t.scopes().contains(scope)) + .toArray(ConversationTemplate[]::new); } /** @@ -170,10 +245,6 @@ public boolean isAgentsReady() { return agents != null && agents.size() > 0; } - public ConversationTemplate[] getTemplates() { - return templates.toArray(new ConversationTemplate[0]); - } - public ConversationAgent[] getAgents() { return agents.toArray(new ConversationAgent[0]); } @@ -187,12 +258,12 @@ public void onDidCopilotStatusChange(CopilotStatusResult copilotStatusResult) { private void syncCommands(String status) { switch (status) { case CopilotStatusResult.OK: - initAsync(); + fetchAsync(); break; default: - allCommands.clear(); - templates.clear(); - agents.clear(); + this.allCommands = Set.of(); + this.templates = List.of(); + this.agents = List.of(); break; } } @@ -202,5 +273,70 @@ private void syncCommands(String status) { */ public void dispose() { this.authStatusManager.removeCopilotAuthStatusListener(this); + ResourcesPlugin.getWorkspace().removeResourceChangeListener(skillFileListener); + if (this.eventBroker != null && this.customPromptsChangedHandler != null) { + this.eventBroker.unsubscribe(this.customPromptsChangedHandler); + } + } + + /** + * Listens for workspace resource changes involving SKILL.md or .prompt.md files and triggers a template refresh when + * such files are added, removed, or changed. + * + * <p>TODO: Remove this listener once workspace-root is removed from workspaceFolders in CopilotLanguageClient as CLS + * can watch the project prompt file change directly. + */ + private class SkillFileChangeListener implements IResourceChangeListener { + @Override + public void resourceChanged(IResourceChangeEvent event) { + IResourceDelta delta = event.getDelta(); + if (delta == null) { + return; + } + boolean[] needsRefresh = { false }; + try { + delta.accept(childDelta -> { + if (needsRefresh[0]) { + return false; + } + if (!shouldVisitDelta(childDelta)) { + return false; + } + if (isPromptOrSkillFileDelta(childDelta)) { + needsRefresh[0] = true; + return false; + } + return true; + }); + } catch (CoreException e) { + CopilotCore.LOGGER.error("Error visiting resource delta for skill file changes", e); + } + if (needsRefresh[0]) { + fetchAsync(); + } + } + + private boolean shouldVisitDelta(IResourceDelta delta) { + IResource resource = delta.getResource(); + return resource != null && !resource.isDerived() && !resource.isTeamPrivateMember(); + } + + private boolean isPromptOrSkillFileDelta(IResourceDelta delta) { + IResource resource = delta.getResource(); + if (resource.getType() != IResource.FILE || !isRelevantFileDelta(delta)) { + return false; + } + + String name = resource.getName(); + return SKILL_FILE_NAME.equals(name) || name.endsWith(PROMPT_FILE_SUFFIX); + } + + private boolean isRelevantFileDelta(IResourceDelta delta) { + int kind = delta.getKind(); + if (kind == IResourceDelta.ADDED || kind == IResourceDelta.REMOVED) { + return true; + } + return kind == IResourceDelta.CHANGED && (delta.getFlags() & IResourceDelta.CONTENT) != 0; + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java index ac3d955a..62e6c4a6 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ChatServiceManager.java @@ -5,6 +5,7 @@ import com.microsoft.copilot.eclipse.core.AuthStatusManager; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.service.CustomizationFileService; import com.microsoft.copilot.eclipse.core.chat.service.IChatServiceManager; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; @@ -29,6 +30,7 @@ public class ChatServiceManager implements IChatServiceManager { private ReferencedFileService referencedFileService; private McpConfigService mcpConfigService; private McpExtensionPointManager mcpExtensionPointManager; + private CustomizationFileService customizationFileService; private McpRuntimeLogger mcpRuntimeLogger; private ConversationPersistenceManager persistenceManager; @@ -51,10 +53,12 @@ public ChatServiceManager() { referencedFileService = new ReferencedFileService(); mcpConfigService = new McpConfigService(); mcpExtensionPointManager = new McpExtensionPointManager(mcpConfigService); + customizationFileService = new CustomizationFileService(this.lsConnection); + customizationFileService.refreshAllAsync(); mcpRuntimeLogger = new McpRuntimeLogger(); persistenceManager = new ConversationPersistenceManager(this.authStatusManager); chatFontService = new ChatFontService(); - contextWindowService = new ContextWindowService(); + contextWindowService = new ContextWindowService(modelService); } /** @@ -140,6 +144,11 @@ public McpConfigService getMcpConfigService() { return mcpConfigService; } + @Override + public CustomizationFileService getCustomizationFileService() { + return customizationFileService; + } + /** * Get the persistence manager. * @@ -202,6 +211,7 @@ public void dispose() { this.agentToolService.dispose(); this.referencedFileService.dispose(); this.mcpConfigService.dispose(); + this.customizationFileService.dispose(); this.contextWindowService.dispose(); if (this.byokService != null) { this.byokService.dispose(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java index 60ccc202..078faa1e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpConfigService.java @@ -29,6 +29,7 @@ import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.dialogs.DynamicOauthDialog; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.preferences.McpAutoApproveSection; import com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage; /** @@ -50,6 +51,7 @@ public class McpConfigService extends ChatBaseService implements IMcpConfigServi private ISideEffect mcpToolButtonEnableSideEffect; private ISideEffect mcpToolsButtonRedNoticeSideEffect; private ISideEffect mcpPrefencePageExtMcpTitleRedNoticeSideEffect; + private ISideEffect mcpAutoApproveSideEffect; private IEventBroker eventBroker; @@ -108,6 +110,28 @@ private void initializeMcpFeatureFlagUpdateEvent() { eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagNotifiedEventHandler); } + /** + * Bind the observable with UI in McpAutoApproveSection. + */ + public void bindWithAutoApproveSection(McpAutoApproveSection section) { + ensureRealm(() -> { + unbindWithAutoApproveSection(); + mcpAutoApproveSideEffect = ISideEffect.create( + mcpToolsObservableValue::getValue, + section::updateServerCollections); + }); + } + + /** + * Unbind the McpAutoApproveSection side-effect. + */ + public void unbindWithAutoApproveSection() { + if (mcpAutoApproveSideEffect != null) { + mcpAutoApproveSideEffect.dispose(); + mcpAutoApproveSideEffect = null; + } + } + /** * Bind the observable with UI in McpPreferencePage. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java index 759af1a0..07685bda 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/McpExtensionPointManager.java @@ -52,11 +52,12 @@ public class McpExtensionPointManager { private static final String ELEMENT_PROVIDER = "provider"; private static final String ATTRIBUTE_CLASS = "class"; - private String approvedExtMcpServers; + private volatile String approvedExtMcpServers; private Map<String, McpRegistrationInfo> extMcpInfoMap = new HashMap<>(); // Key: Plugin-Id(Bundle) private McpConfigService mcpConfigService; private Gson gson; + private IEventBroker eventBroker; /** * Constructor for McpExtensionPointManager. @@ -64,28 +65,36 @@ public class McpExtensionPointManager { public McpExtensionPointManager(McpConfigService mcpConfigService) { gson = new GsonBuilder().disableHtmlEscaping().create(); this.mcpConfigService = mcpConfigService; + this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { initializeExtMcpRegistration(); } - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); eventBroker.subscribe(CopilotEventConstants.TOPIC_DID_CHANGE_MCP_CONTRIBUTION_POINT_POLICY, event -> { Boolean enabled = (Boolean) event.getProperty(IEventBroker.DATA); if (enabled.booleanValue()) { initializeExtMcpRegistration(); } else { - extMcpInfoMap.clear(); - approvedExtMcpServers = null; - persistExtMcpInfo(extMcpInfoMap); - mcpConfigService.setNewExtMcpRegFound(false); + // Disabling the contribution point clears the in-memory state shared with doRegistration(). + // Hold the manager monitor so the (non-thread-safe) HashMap clear and the approved-servers + // reset are not observed mid-update by a concurrent doRegistration() running on the async + // worker. + synchronized (this) { + extMcpInfoMap.clear(); + approvedExtMcpServers = null; + persistExtMcpInfo(extMcpInfoMap); + mcpConfigService.setNewExtMcpRegFound(false); + } } }); } private synchronized void initializeExtMcpRegistration() { - // Previously approved servers will be started during Plugin startup. + // Avoid pre-populating the in-memory approved servers from the persisted cache. If we did so, + // the initial syncMcpRegistrationConfiguration() at startup would push potentially stale data + // (server removed by the contributing plugin, port changed, plugin uninstalled) to the language + // server, which would surface a connection failure before doRegistration() can refresh the state. Map<String, McpRegistrationInfo> persistedMcpContribs = loadPersistedMcpContribs(); - updateApprovedMcpServerString(persistedMcpContribs); // Run the heavy initialization work asynchronously, which has weak relation with Plugin startup. CompletableFuture.runAsync(() -> { @@ -93,16 +102,42 @@ private synchronized void initializeExtMcpRegistration() { }); } - private synchronized void doRegistration(Map<String, McpRegistrationInfo> persistedMcpContribs) { + private void doRegistration(Map<String, McpRegistrationInfo> persistedMcpContribs) { + String approvedServersToPublish = null; + boolean shouldPublish = false; try { FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); if (flags != null && !flags.isMcpEnabled()) { return; } - loadMcpRegistrationExtensionPoint(); - detectChangesInMcpContribs(persistedMcpContribs); + // Perform the slow extension-point scan and diff work outside the lock so that + // UI-thread callers (e.g. approveExtMcpRegistration) are not blocked. + Map<String, McpRegistrationInfo> scannedMap = loadMcpRegistrationExtensionPoint(); + detectChangesInMcpContribs(scannedMap, persistedMcpContribs); + synchronized (this) { + extMcpInfoMap = scannedMap; + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedServersToPublish = approvedExtMcpServers; + shouldPublish = true; + } } catch (Exception e) { CopilotCore.LOGGER.error("Error during EXT MCP registration initialization", e); + return; + } + // Publish the verified state outside the synchronized section so the manager monitor is not + // held while subscribers (notably LanguageServerSettingManager) push to the language server. + // The event fires unconditionally on success - including when the persisted JSON is unchanged + // and IPreferenceStore.setValue() therefore short-circuits its property-change notification - + // so the LSP always receives the verified extension-contributed servers. + if (shouldPublish) { + publishRegistrationCompleted(approvedServersToPublish); + } + } + + private void publishRegistrationCompleted(String approvedServersJson) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, approvedServersJson); } } @@ -151,11 +186,12 @@ private void updateApprovedMcpServerString(Map<String, McpRegistrationInfo> extM /** * Load MCP registration from extension point. */ - private void loadMcpRegistrationExtensionPoint() { + private Map<String, McpRegistrationInfo> loadMcpRegistrationExtensionPoint() { + Map<String, McpRegistrationInfo> result = new HashMap<>(); IExtensionRegistry registry = Platform.getExtensionRegistry(); IExtensionPoint extensionPoint = registry.getExtensionPoint(EXTENSION_POINT_ID); if (extensionPoint == null) { - return; + return result; } // Traverse all extensions/bundles. @@ -167,7 +203,7 @@ private void loadMcpRegistrationExtensionPoint() { CopilotCore.LOGGER.error("Cannot find bundle: " + bundleName, null); continue; // Skip inactive plug-ins } - if (bundle.getState() != Bundle.ACTIVE || bundle.getState() != Bundle.STARTING) { + if (bundle.getState() != Bundle.ACTIVE && bundle.getState() != Bundle.STARTING) { try { bundle.start(Bundle.START_ACTIVATION_POLICY); } catch (BundleException e) { @@ -221,9 +257,10 @@ private void loadMcpRegistrationExtensionPoint() { // Update registration info if (!mergedServers.isEmpty()) { - extMcpInfoMap.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); + result.put(bundleName, new McpRegistrationInfo(isTrusted, isApproved, pluginDisplayName, mergedServers)); } } + return result; } /** @@ -268,14 +305,16 @@ private boolean isMcpFromSignedBundle(Bundle bundle) { /** * Detect changes in MCP registration from extension point compared to the existing record. */ - private void detectChangesInMcpContribs(Map<String, McpRegistrationInfo> existingExtMcpInfoMap) { + private void detectChangesInMcpContribs( + Map<String, McpRegistrationInfo> scannedMap, + Map<String, McpRegistrationInfo> existingExtMcpInfoMap) { boolean newExtMcpRegFound = false; if (existingExtMcpInfoMap == null) { existingExtMcpInfoMap = Collections.emptyMap(); } // Compare each plugin's current MCP servers with the stored record - for (Map.Entry<String, McpRegistrationInfo> entry : extMcpInfoMap.entrySet()) { + for (Map.Entry<String, McpRegistrationInfo> entry : scannedMap.entrySet()) { String contributorName = entry.getKey(); McpRegistrationInfo mcpRegistrationInfo = entry.getValue(); McpRegistrationInfo storedInfo = existingExtMcpInfoMap.get(contributorName); @@ -296,13 +335,6 @@ private void detectChangesInMcpContribs(Map<String, McpRegistrationInfo> existin if (newExtMcpRegFound) { mcpConfigService.setNewExtMcpRegFound(true); } - - // Always persist the latest MCP registration info, in case some plug-ins are un-installed, or unregister MCP - // servers. - if (!extMcpInfoMap.equals(existingExtMcpInfoMap)) { - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - } } /** @@ -310,18 +342,35 @@ private void detectChangesInMcpContribs(Map<String, McpRegistrationInfo> existin */ public String approveExtMcpRegistration() { Shell shell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(); - if (extMcpInfoMap.isEmpty()) { - MessageDialog.openInformation(shell, "", "No MCP server registration found"); - return null; + Map<String, McpRegistrationInfo> dialogInput; + synchronized (this) { + if (extMcpInfoMap.isEmpty()) { + MessageDialog.openInformation(shell, "", "No MCP server registration found"); + return null; + } + // Take a shallow snapshot so the dialog can iterate the map safely even if doRegistration() + // mutates extMcpInfoMap on the async worker. Approval flips happen on the McpRegistrationInfo + // value objects, which are shared between the snapshot and the live map by reference, so the + // user's choices are reflected in extMcpInfoMap once the dialog returns. + dialogInput = new HashMap<>(extMcpInfoMap); } - McpApprovalDialog dialog = new McpApprovalDialog(shell, extMcpInfoMap); + // Open the modal dialog outside the synchronized block so a concurrent doRegistration() worker + // is not stalled on this manager's monitor while the user interacts with the dialog. + McpApprovalDialog dialog = new McpApprovalDialog(shell, dialogInput); dialog.open(); - mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval - updateApprovedMcpServerString(extMcpInfoMap); - persistExtMcpInfo(extMcpInfoMap); - return approvedExtMcpServers; + String approvedJson; + synchronized (this) { + mcpConfigService.setNewExtMcpRegFound(false); // Reset the flag after user approval + updateApprovedMcpServerString(extMcpInfoMap); + persistExtMcpInfo(extMcpInfoMap); + approvedJson = approvedExtMcpServers; + } + // Publish outside the lock so subscribers (notably LanguageServerSettingManager) are not + // running their LSP push while the manager monitor is held. + publishRegistrationCompleted(approvedJson); + return approvedJson; } private void persistExtMcpInfo(Map<String, McpRegistrationInfo> extMcpInfoMap) { @@ -396,7 +445,7 @@ public String getMcpServersAsJson() { /** * Check if there is any MCP registration from extension point. */ - public boolean hasExtMcpRegistration() { + public synchronized boolean hasExtMcpRegistration() { return !extMcpInfoMap.isEmpty(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java index 3233fa33..c76cd6b1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.apache.commons.lang3.StringUtils; @@ -54,6 +55,7 @@ public class ModelService extends ChatBaseService { // models for the model picker private IObservableValue<Map<String, CopilotModel>> modelObservable; private IObservableValue<CopilotModel> activeModelObservable; + private IObservableValue<Map<String, String>> reasoningEffortObservable; // Used to update modelObservable private Map<String, CopilotModel> copilotModels = new HashMap<>(); private Map<String, CopilotModel> registeredByokModels = new HashMap<>(); @@ -82,6 +84,12 @@ public ModelService(CopilotLanguageServerConnection lsConnection, AuthStatusMana ensureRealm(() -> { modelObservable = new WritableValue<>(new HashMap<>(), HashMap.class); activeModelObservable = new WritableValue<>(null, CopilotModel.class); + Map<String, String> initialEfforts = Map.of(); + UserPreference initialPreference = getUserPreference(); + if (initialPreference != null) { + initialEfforts = initialPreference.getReasoningEffortSnapshot(); + } + reasoningEffortObservable = new WritableValue<>(initialEfforts, Map.class); }); initializeEventHandlers(); @@ -111,6 +119,7 @@ private void initializeEventHandlers() { @SuppressWarnings("unchecked") Map<String, List<ByokModel>> byokModels = (Map<String, List<ByokModel>>) modelsMap; saveRegisteredByokModels(byokModels); + reconcileReasoningEfforts(); ensureRealm(() -> updateModelsForChatMode(currentChatMode)); } }; @@ -165,6 +174,7 @@ protected IStatus run(IProgressMonitor monitor) { try { fetchCopilotModels(); fetchByokModels(); + reconcileReasoningEfforts(); ensureRealm(() -> { updateModelsForChatMode(currentChatMode); }); @@ -193,7 +203,7 @@ private void fetchCopilotModels() throws InterruptedException, ExecutionExceptio boolean supportsChat = model.getScopes().contains(CopilotScope.CHAT_PANEL); boolean supportsAgent = model.getScopes().contains(CopilotScope.AGENT_PANEL); if (supportsChat || supportsAgent) { - newModels.put(model.getId(), model); + newModels.put(model.getModelKey(), model); } if (model.isChatDefault()) { defaultModel = model; @@ -274,13 +284,8 @@ private void updateModelsForChatMode(ChatMode chatMode) { */ private void validateAndSetActiveModelForMode(Map<String, CopilotModel> modelsForCurrentMode, String scope) { CopilotModel currentActive = getActiveModel(); - boolean isCurrentModelAvailable = false; - if (currentActive != null) { - String keyToFind = currentActive.getProviderName() != null - ? currentActive.getProviderName() + "_" + currentActive.getId() - : currentActive.getId(); - isCurrentModelAvailable = modelsForCurrentMode.containsKey(keyToFind); - } + boolean isCurrentModelAvailable = currentActive != null + && modelsForCurrentMode.containsKey(currentActive.getModelKey()); if (currentActive == null || !isCurrentModelAvailable) { // Try to restore user's preferred model if it's available in current mode String restoredModelId = restoreActiveModel(); @@ -348,7 +353,11 @@ public void setActiveModel(String modelName) { // Persist using the composite key for proper identification UserPreference preference = getUserPreference(); preference.setChatModel(compositeKey); - persistUserPreference(); + // Persist asynchronously to avoid deadlock: persistUserPreference() calls + // persistence().get() which blocks waiting for the LSP listener thread. + // If called on the UI thread while the listener is in syncExec, both threads + // deadlock. + CompletableFuture.runAsync(this::persistUserPreference); // Update observable ensureRealm(() -> activeModelObservable.setValue(model)); @@ -399,6 +408,115 @@ public boolean isVisionSupported() { return model != null && model.getCapabilities().supports().vision(); } + /** + * Returns the user-selected reasoning effort for the given model, or {@code null} when the user has not made a + * selection. The persisted snapshot is kept in sync with the current model inventory by + * {@link #reconcileReasoningEfforts()} after each model fetch, so this method is a pure lookup. + * + * @param model the model to query + * @return the selected reasoning effort, or {@code null} + */ + public String getSelectedReasoningEffort(CopilotModel model) { + if (model == null) { + return null; + } + String key = model.getModelKey(); + UserPreference preference = getUserPreference(); + return preference != null ? preference.getReasoningEffort(key) : null; + } + + /** + * Resolves the reasoning effort that should be sent to the language server for the given model: the user's explicit + * selection when present, otherwise the inferred client-side default from + * {@link ModelUtils#resolveDefaultReasoningEffort(CopilotModel)}. Returns {@code null} for models that do not + * support reasoning-effort selection (see {@link ModelUtils#supportsReasoningEffortLevel(CopilotModel)}) or for + * the special "auto" model. + * + * @param model the model that will receive the request + * @return the effort to send, or {@code null} to omit + */ + public String resolveEffectiveReasoningEffort(CopilotModel model) { + if (!ModelUtils.supportsReasoningEffortLevel(model)) { + return null; + } + String selected = getSelectedReasoningEffort(model); + if (StringUtils.isNotBlank(selected)) { + return selected; + } + return ModelUtils.resolveDefaultReasoningEffort(model); + } + + /** + * Persists the user-selected reasoning effort for the given model and updates dependent observers. + * + * @param model the model to update + * @param reasoningEffort the reasoning effort to store (may be {@code null} to clear) + */ + public void setSelectedReasoningEffort(CopilotModel model, String reasoningEffort) { + if (model == null) { + return; + } + String key = model.getModelKey(); + UserPreference preference = getUserPreference(); + if (preference == null) { + return; + } + preference.setReasoningEffort(key, reasoningEffort); + CompletableFuture.runAsync(this::persistUserPreference); + // Publish a fresh snapshot to drive bound picker re-renders. The actual rendering reads + // resolveEffectiveReasoningEffort (which queries UserPreference), so this observable serves + // purely as a change signal. + ensureRealm(() -> reasoningEffortObservable.setValue(preference.getReasoningEffortSnapshot())); + } + + /** + * Reconciles the persisted reasoning-effort snapshot with the current model inventory + * ({@link #copilotModels} ∪ {@link #registeredByokModels}). Entries are kept only when the model still exists and + * the stored effort is still in that model's advertised reasoning-effort list; everything else is dropped. The + * map is replaced atomically. + * + * <p>Skipped when the inventory is empty (e.g. the very first fetch has not produced results yet or both fetches + * failed) so a transient outage cannot wipe every stored selection. + */ + private void reconcileReasoningEfforts() { + if (copilotModels.isEmpty() && registeredByokModels.isEmpty()) { + return; + } + UserPreference preference = getUserPreference(); + if (preference == null) { + return; + } + Map<String, String> snapshot = preference.getReasoningEffortSnapshot(); + if (snapshot.isEmpty()) { + return; + } + Map<String, CopilotModel> inventory = new HashMap<>(); + for (CopilotModel model : copilotModels.values()) { + inventory.put(model.getModelKey(), model); + } + for (CopilotModel model : registeredByokModels.values()) { + inventory.put(model.getModelKey(), model); + } + Map<String, String> reconciled = new HashMap<>(); + for (Map.Entry<String, String> entry : snapshot.entrySet()) { + CopilotModel model = inventory.get(entry.getKey()); + if (model == null) { + continue; + } + String stored = entry.getValue(); + for (String effort : ModelUtils.getSupportedReasoningEfforts(model)) { + if (effort != null && effort.equalsIgnoreCase(stored)) { + reconciled.put(entry.getKey(), effort); + break; + } + } + } + if (preference.setReasoningEfforts(reconciled)) { + CompletableFuture.runAsync(this::persistUserPreference); + ensureRealm(() -> reasoningEffortObservable.setValue(preference.getReasoningEffortSnapshot())); + } + } + /** * Binds a {@link DropdownButton} to this service for model selection. The button displays model groups with per-item * tooltips and billing suffixes. @@ -417,33 +535,27 @@ public void bindModelPicker(final DropdownButton picker) { ensureRealm(() -> { ISideEffect modelsSideEffect = ISideEffect.create(() -> { Map<String, CopilotModel> modelMap = this.modelObservable.getValue(); + this.reasoningEffortObservable.getValue(); if (picker.isDisposed() || modelMap.isEmpty()) { return Collections.emptyMap(); } return modelMap; - }, (Map<String, CopilotModel> modelMap) -> { - if (!picker.isDisposed()) { - boolean showAddPremiumModelOption = this.authStatusManager.getQuotaStatus() - .getCopilotPlan() == CopilotPlan.free; - // TODO: need to remove this logic after group policy is available - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean showByokManageOption = flags == null || flags.isByokEnabled(); - picker.setItemGroups( - ModelPickerGroupsBuilder.build(modelMap, showAddPremiumModelOption, showByokManageOption)); - } - }); - - ISideEffect activeModelSideEffect = ISideEffect.create(() -> { - return this.activeModelObservable.getValue(); - }, (CopilotModel activeModel) -> { - if (activeModel == null || picker.isDisposed()) { - return; - } - picker.setSelectedItemId(activeModel.getModelName()); - String suffix = StringUtils.isNotBlank(activeModel.getDegradationReason()) - ? " - " + activeModel.getDegradationReason() : ""; - picker.setToolTipText(NLS.bind(Messages.chat_actionBar_modelPicker_Tooltip, suffix)); - }); + }, (Map<String, CopilotModel> modelMap) -> rebuildPickerItems(picker, modelMap)); + + // Active-model render path: only depends on the active model. The button-face text is read from the matching + // DropdownItem's selectedLabel (populated by ModelPickerGroupsBuilder with the effective effort), which is + // refreshed by modelsSideEffect above whenever the reasoning-effort observable changes. There is no need to + // track the effort observable here. + ISideEffect activeModelSideEffect = ISideEffect.create(this.activeModelObservable::getValue, + (CopilotModel activeModel) -> { + if (activeModel == null || picker.isDisposed()) { + return; + } + picker.setSelectedItemId(activeModel.getModelName()); + String suffix = StringUtils.isNotBlank(activeModel.getDegradationReason()) + ? " - " + activeModel.getDegradationReason() : ""; + picker.setToolTipText(NLS.bind(Messages.chat_actionBar_modelPicker_Tooltip, suffix)); + }); // Store the side effects for later disposal modelButtonSideEffects.put(picker, new ISideEffect[] { modelsSideEffect, activeModelSideEffect }); @@ -453,6 +565,23 @@ public void bindModelPicker(final DropdownButton picker) { }); } + /** + * Rebuilds the item groups for a single picker. Single render path shared by the model-list and + * reasoning-effort side effects. + */ + private void rebuildPickerItems(DropdownButton picker, Map<String, CopilotModel> modelMap) { + if (picker.isDisposed()) { + return; + } + boolean showAddPremiumModelOption = this.authStatusManager.getQuotaStatus() + .copilotPlan() == CopilotPlan.free; + // TODO: need to remove this logic after group policy is available + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + boolean showByokManageOption = flags == null || flags.isByokEnabled(); + picker.setItemGroups(ModelPickerGroupsBuilder.build(modelMap, showAddPremiumModelOption, showByokManageOption, + this::resolveEffectiveReasoningEffort)); + } + /** * Unbind and dispose side effects for a specific DropdownButton. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java index adddc2cd..d8856b2e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java @@ -69,9 +69,10 @@ public void bindTodoListBar(ChatView chatView) { disposeTodoListBar(); } else { if (this.todoListBar == null || this.todoListBar.isDisposed()) { - this.todoListBar = new TodoListBar(chatView.getActionBar(), SWT.NONE); + this.todoListBar = new TodoListBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Always position TodoListBar at the very top + // Always position TodoListBar at the very top of the inputArea (still below the + // StaticBanner, which lives on the outer ActionBar as a sibling of inputArea). this.todoListBar.moveAbove(null); this.todoListBar.buildTodoListBar(todoList); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java new file mode 100644 index 00000000..aed4594c --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/ChangedFile.java @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat.tools; + +import java.nio.file.Path; +import java.util.Objects; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.eclipse.core.resources.IFile; + +/** + * Represents a file tracked in the file change summary bar. + */ +public final class ChangedFile { + private final IFile workspaceFile; + private final Path localPath; + + private ChangedFile(IFile workspaceFile, Path localPath) { + this.workspaceFile = workspaceFile; + this.localPath = localPath; + } + + /** + * Creates a changed file entry for a workspace file. + * + * @param file the workspace file + * @return the changed file entry + */ + public static ChangedFile workspace(IFile file) { + return new ChangedFile(Objects.requireNonNull(file), null); + } + + /** + * Creates a changed file entry for a local file. + * + * @param path the local file path + * @return the changed file entry + */ + public static ChangedFile local(Path path) { + return new ChangedFile(null, normalize(path)); + } + + /** + * Returns true if this entry represents a workspace file. + * + * @return true for workspace files, false for local files + */ + public boolean isWorkspaceFile() { + return workspaceFile != null; + } + + /** + * Gets the workspace file for this entry. + * + * @return the workspace file, or null for local files + */ + public IFile getWorkspaceFile() { + return workspaceFile; + } + + /** + * Gets the local path for this entry. + * + * @return the local path, or null for workspace files + */ + public Path getLocalPath() { + return localPath; + } + + /** + * Gets the display name for this file. + * + * @return the file name + */ + public String getName() { + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localPath.getFileName(); + return fileName == null ? localPath.toString() : fileName.toString(); + } + + /** + * Gets the display path for this file. + * + * @return the workspace path or local filesystem path + */ + public String getDisplayPath() { + if (workspaceFile != null) { + return workspaceFile.getFullPath().toString(); + } + return localPath.toString(); + } + + private static Path normalize(Path path) { + return Objects.requireNonNull(path).toAbsolutePath().normalize(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ChangedFile other)) { + return false; + } + return Objects.equals(workspaceFile, other.workspaceFile) && Objects.equals(localPath, other.localPath); + } + + @Override + public int hashCode() { + return Objects.hash(workspaceFile, localPath); + } + + @Override + public String toString() { + ToStringBuilder builder = new ToStringBuilder(this); + builder.append("workspaceFile", workspaceFile); + builder.append("localPath", localPath); + return builder.toString(); + } +} \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java index 29a807ce..ca9485dd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/CreateFileTool.java @@ -5,9 +5,13 @@ import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -19,6 +23,7 @@ import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.lsp4j.FileChangeType; +import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchemaPropertyValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolInformation; @@ -57,7 +62,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - This is a tool for creating a new file in the workspace. + This is a tool for creating a new workspace file or a new file at an absolute local filesystem path. The file will be created with the specified content. """); @@ -90,34 +95,49 @@ public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> i return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); } - try { - // Resolve file in workspace - IFile file = FileUtils.getFileFromPath(filePath, false); + String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); + result = createFile(filePath, content); - if (file == null) { - result.setStatus(ToolInvocationStatus.error); - result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); - } + return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + } + + private LanguageModelToolResult createFile(String filePath, String content) { + IFile file = FileUtils.getFileFromPath(filePath, false); + + if (file != null && file.getProject().exists()) { + return createWorkspaceFile(file, filePath, content); + } + + Path localPath = FileUtils.getLocalFilePath(filePath); + if (localPath != null) { + return createLocalFile(localPath, content); + } - // Check if file already exists + LanguageModelToolResult result = new LanguageModelToolResult(); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Invalid file path: " + filePath + " does not exist in the workspace."); + return result; + } + + private LanguageModelToolResult createWorkspaceFile(IFile file, String filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + + try { if (file.exists()) { result.setStatus(ToolInvocationStatus.error); result.addContent("Failed: file already exists: " + filePath + ". Please use edit file tool to update."); - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; } - // Create parent folders if needed createParentFolders(file.getParent()); - // Create file with content - String content = StringUtils.isBlank((String) input.get("content")) ? "" : (String) input.get("content"); try (ByteArrayInputStream contentStream = new ByteArrayInputStream( content.getBytes(PlatformUtils.getFileCharset(file)))) { file.create(contentStream, IResource.FORCE, new NullProgressMonitor()); - cacheTheOriginalFileContent(file); + cacheTheOriginalFileContent(ChangedFile.workspace(file), StringUtils.EMPTY); } - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Created); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(ChangedFile.workspace(file), + FileChangeType.Created); file.refreshLocal(IResource.DEPTH_ZERO, new NullProgressMonitor()); result.addContent("File created at: " + file.getFullPath().toOSString()); @@ -130,7 +150,36 @@ public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> i result.addContent("Error handling file stream: " + e.getMessage()); } - return CompletableFuture.completedFuture(new LanguageModelToolResult[] { result }); + return result; + } + + private LanguageModelToolResult createLocalFile(Path filePath, String content) { + LanguageModelToolResult result = new LanguageModelToolResult(); + Path normalizedPath = normalizeLocalPath(filePath); + if (Files.exists(normalizedPath, LinkOption.NOFOLLOW_LINKS)) { + result.setStatus(ToolInvocationStatus.error); + result.addContent("Failed: file already exists: " + normalizedPath + ". Please use edit file tool to update."); + return result; + } + + try { + Path parent = normalizedPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.writeString(normalizedPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + cacheTheOriginalFileContent(ChangedFile.local(normalizedPath), StringUtils.EMPTY); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile( + ChangedFile.local(normalizedPath), FileChangeType.Created); + result.addContent("File created at: " + normalizedPath); + result.setStatus(ToolInvocationStatus.success); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error creating local file", e); + result.setStatus(ToolInvocationStatus.error); + result.addContent("Error creating file: " + e.getMessage()); + } + + return result; } /** @@ -152,33 +201,36 @@ private void createParentFolders(IResource parent) throws CoreException { } @Override - public void onKeepAllChanges(List<IFile> files) { - files.forEach(this::onKeepChange); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); + closeCompareEditor(file); } @Override - public void onKeepChange(IFile file) { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { + deleteCreatedFile(file); + removeCachedFileContent(file); closeCompareEditor(file); } - @Override - public void onUndoAllChanges(List<IFile> files) throws CoreException { - for (IFile file : files) { - onUndoChange(file); + private void deleteCreatedFile(ChangedFile file) throws CoreException, IOException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + if (workspaceFile != null && workspaceFile.exists()) { + workspaceFile.delete(true, new NullProgressMonitor()); + } + return; } + Files.deleteIfExists(file.getLocalPath()); } @Override - public void onUndoChange(IFile file) throws CoreException { - if (file != null && file.exists()) { - file.delete(true, new NullProgressMonitor()); + public void onViewDiff(ChangedFile file) { + if (file.isWorkspaceFile()) { + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file.getWorkspaceFile())); + return; } - closeCompareEditor(file); - } - - @Override - public void onViewDiff(IFile file) { - SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openInEditor(file)); + SwtUtils.invokeOnDisplayThreadAsync(() -> UiUtils.openLocalFileInEditor(file.getLocalPath())); } @Override diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java index 9a6c532e..1260b0a1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/EditFileTool.java @@ -7,13 +7,14 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import org.eclipse.compare.CompareEditorInput; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; @@ -52,7 +53,7 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); toolInfo.setDescription(""" - Insert new code into an existing file in the workspace. + Insert new code into an existing workspace file or local filesystem file. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. The system is very smart and can understand how to apply your edits to the files, @@ -122,30 +123,8 @@ class Person { public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> input, ChatView chatView) { CompletableFuture<LanguageModelToolResult[]> resultFuture = new CompletableFuture<>(); if (input.get("filePath") instanceof String filePath) { - IFile file = FileUtils.getFileFromPath(filePath, true); - - if (file == null || !file.exists()) { - resultFuture.complete(new LanguageModelToolResult[] { - new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", - ToolInvocationStatus.error) }); - return resultFuture; - } - if (input.get("code") instanceof String code) { - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(file, FileChangeType.Changed); - cacheTheOriginalFileContent(file); - try { - applyChangesToFile(code, file); - } catch (CoreException | IOException e) { - CopilotCore.LOGGER.error("Error replacing file content", e); - resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult( - "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }); - return resultFuture; - } - refreshCompareEditorIfOpen(fileContentCache.get(file), file); - // Must return the updated content as a result to the CLS. - resultFuture.complete( - new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }); + resultFuture.complete(editFile(filePath, code)); } else { resultFuture.complete(new LanguageModelToolResult[] { new LanguageModelToolResult("The code provided is not a valid string. Please check the code and try again.", @@ -160,6 +139,60 @@ public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> i return resultFuture; } + private LanguageModelToolResult[] editFile(String filePath, String code) { + IFile file = FileUtils.getFileFromPath(filePath, true); + + if (file != null && file.exists()) { + return editWorkspaceFile(file, code); + } + + Path localPath = FileUtils.getLocalFilePath(filePath); + if (localPath != null && Files.isRegularFile(localPath, LinkOption.NOFOLLOW_LINKS)) { + return editLocalFile(localPath, code); + } + + return new LanguageModelToolResult[] { + new LanguageModelToolResult("The file path provided does not exist. Please check the path and try again.", + ToolInvocationStatus.error) }; + } + + private LanguageModelToolResult[] editWorkspaceFile(IFile file, String code) { + ChangedFile changedFile = ChangedFile.workspace(file); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + cacheTheOriginalFileContent(changedFile); + try { + applyChangesToFile(code, file); + } catch (CoreException | IOException e) { + CopilotCore.LOGGER.error("Error replacing file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } + + private LanguageModelToolResult[] editLocalFile(Path filePath, String code) { + Path normalizedPath = normalizeLocalPath(filePath); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + try { + String originalContent = getCachedFileContent(changedFile); + if (originalContent == null) { + originalContent = Files.readString(normalizedPath, StandardCharsets.UTF_8); + } + Files.writeString(normalizedPath, code, StandardCharsets.UTF_8); + cacheTheOriginalFileContent(changedFile, originalContent); + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().addChangedFile(changedFile, + FileChangeType.Changed); + refreshCompareEditorIfOpen(getCachedFileContent(changedFile), changedFile); + return new LanguageModelToolResult[] { new LanguageModelToolResult(code, ToolInvocationStatus.success) }; + } catch (IOException e) { + CopilotCore.LOGGER.error("Error replacing local file content", e); + return new LanguageModelToolResult[] { new LanguageModelToolResult( + "Failed to apply changes to the file: " + e.getMessage(), ToolInvocationStatus.error) }; + } + } + private void applyChangesToFile(String changedContent, IFile file) throws CoreException, IOException { if (!validateEdit(file)) { throw new IllegalStateException("File validation failed for " + file.getFullPath()); @@ -189,55 +222,40 @@ private ByteArrayInputStream getInputStream(String changedContent, IFile file) { } @Override - public void onKeepChange(IFile file) { - fileContentCache.remove(file); + public void onKeepChange(ChangedFile file) { + removeCachedFileContent(file); closeCompareEditor(file); } @Override - public void onKeepAllChanges(List<IFile> files) { - for (IFile file : files) { - onKeepChange(file); - } - } - - @Override - public void onUndoChange(IFile file) throws CoreException, IOException { + public void onUndoChange(ChangedFile file) throws CoreException, IOException { undoChangesToFile(file); closeCompareEditor(file); } @Override - public void onUndoAllChanges(List<IFile> files) throws CoreException, IOException { - for (IFile file : files) { - onUndoChange(file); + public void onViewDiff(ChangedFile file) { + if (bringCompareEditorToTopIfOpen(file)) { + return; } + compareStringWithFile(getCachedFileContent(file), file); } - @Override - public void onViewDiff(IFile file) { - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (isCompareEditorOpen(input)) { - bringCompareEditorToTop(input); - return; - } - // Compare editor was closed by the user, remove stale entry and recreate - compareEditorInputMap.remove(file); + private void undoChangesToFile(ChangedFile file) throws CoreException, IOException { + String fileCache = getCachedFileContent(file); + if (fileCache == null) { + return; + } + if (file.isWorkspaceFile()) { + applyChangesToFile(fileCache, file.getWorkspaceFile()); + } else { + Files.writeString(file.getLocalPath(), fileCache, StandardCharsets.UTF_8); } - compareStringWithFile(fileContentCache.get(file), file); + removeCachedFileContent(file); } @Override public void onResolveAllChanges() { cleanupChangedFiles(); } - - private void undoChangesToFile(IFile file) throws CoreException, IOException { - String fileCache = fileContentCache.get(file); - if (fileCache != null) { - applyChangesToFile(fileCache, file); - } - fileContentCache.remove(file); - } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java index 1bf6fc6e..8d545fd7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolBase.java @@ -9,10 +9,13 @@ import java.io.UnsupportedEncodingException; import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import org.eclipse.compare.CompareConfiguration; import org.eclipse.compare.CompareEditorInput; @@ -30,6 +33,7 @@ import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.Status; import org.eclipse.swt.graphics.Image; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IEditorReference; @@ -49,8 +53,8 @@ * Abstract class for handling file change tool related actions. */ public abstract class FileToolBase extends BaseTool { - protected static Map<IFile, CompareEditorInput> compareEditorInputMap = new ConcurrentHashMap<>(); - protected static Map<IFile, String> fileContentCache = new ConcurrentHashMap<>(); + protected static Map<ChangedFile, CompareEditorInput> compareEditorInputMap = new ConcurrentHashMap<>(); + protected static Map<ChangedFile, String> fileContentCache = new ConcurrentHashMap<>(); @Override public abstract CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> input, ChatView chatView); @@ -59,7 +63,7 @@ public abstract class FileToolBase extends BaseTool { * Common method to handle cleanup of file changes. */ protected void cleanupChangedFiles() { - for (IFile file : compareEditorInputMap.keySet()) { + for (ChangedFile file : compareEditorInputMap.keySet()) { closeCompareEditor(file); } compareEditorInputMap.clear(); @@ -67,24 +71,62 @@ protected void cleanupChangedFiles() { } /** - * Caches the original content of the file to be compared with the proposed changes. + * Caches the original content of the changed file to be compared with the proposed changes. * - * @param file The file whose original content is to be cached. + * @param file The changed file whose original content is to be cached. */ - protected void cacheTheOriginalFileContent(IFile file) { + protected void cacheTheOriginalFileContent(ChangedFile file) { if (fileContentCache.containsKey(file)) { // We only need to cache the original file content once to keep the initial file content so that we can undo the // entire file edit even the file has been modified for multiple rounds. return; } - try (InputStream inputStream = file.getContents()) { - String content = new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(file)); - fileContentCache.put(file, content); + try { + fileContentCache.put(file, readCurrentFileContent(file)); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error caching original file content", e); } } + /** + * Caches the original content for a changed file if no baseline exists yet. + * + * @param file The file whose original content is to be cached. + * @param content The content to use as the original baseline. + */ + protected void cacheTheOriginalFileContent(ChangedFile file, String content) { + fileContentCache.putIfAbsent(file, content); + } + + private String readCurrentFileContent(ChangedFile file) throws IOException, CoreException { + if (file.isWorkspaceFile()) { + IFile workspaceFile = file.getWorkspaceFile(); + try (InputStream inputStream = workspaceFile.getContents()) { + return new String(inputStream.readAllBytes(), PlatformUtils.getFileCharset(workspaceFile)); + } + } + return Files.readString(file.getLocalPath(), StandardCharsets.UTF_8); + } + + /** + * Gets the cached original content for a changed file. + * + * @param file The changed file whose cached content should be returned. + * @return the cached content, or null if no content is cached. + */ + protected String getCachedFileContent(ChangedFile file) { + return fileContentCache.get(file); + } + + /** + * Removes the cached original content for a changed file. + * + * @param file The changed file whose cached content should be removed. + */ + protected void removeCachedFileContent(ChangedFile file) { + fileContentCache.remove(file); + } + /** * Validate the edit to ensure the files are writable. * @@ -105,14 +147,12 @@ public void run(IProgressMonitor monitor) throws CoreException { } /** - * Compares the given string with the content of the given file in a compare editor. + * Compares the given string with the content of a changed file in a compare editor. * * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - * @throws InvocationTargetException If the operation is canceled. - * @throws InterruptedException If the operation is canceled. + * @param file The changed file with the proposed changes applied. */ - protected void compareStringWithFile(String originalFileContent, IFile file) { + protected void compareStringWithFile(String originalFileContent, ChangedFile file) { try { CompareEditorInput input = createCompareEditorInput(originalFileContent, file); input.run(new NullProgressMonitor()); @@ -130,47 +170,12 @@ protected void compareStringWithFile(String originalFileContent, IFile file) { } /** - * Updates the current or creates a new compare editor with the given file content and file. - * - * @param originalFileContent The original string content of the file to compare with. - * @param file The user's file with the proposed changes has been applied. - */ - protected void updateOrCreateCompareStringWithFile(String fileContent, IFile file) { - if (fileContent == null) { - return; - } - - CompareEditorInput input = compareEditorInputMap.get(file); - if (input != null) { - if (fileContent.equals(fileContentCache.get(file))) { - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareUI.reuseCompareEditor(input, (IReusableEditor) getCompareEditor(input)); - }); - } else { - CompareEditorInput newInput = createCompareEditorInput(fileContent, file); - compareEditorInputMap.put(file, newInput); - SwtUtils.invokeOnDisplayThreadAsync(() -> { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) getCompareEditor(compareEditorInput)); - } - }); - } - bringCompareEditorToTop(input); - } else { - // If not, create a new compare editor - compareStringWithFile(fileContent, file); - } - } - - /** - * Refreshes the compare editor for the given file only if it is already open. Does not open a new editor or steal - * focus. + * Refreshes the compare editor for the given changed file only if it is already open. * * @param fileContent The original file content to compare against. - * @param file The file whose compare editor should be refreshed. + * @param file The changed file whose compare editor should be refreshed. */ - protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { + protected void refreshCompareEditorIfOpen(String fileContent, ChangedFile file) { if (fileContent == null) { return; } @@ -184,11 +189,10 @@ protected void refreshCompareEditorIfOpen(String fileContent, IFile file) { // If the compare editor is closed, remove the input from the map and skip refreshing. compareEditorInputMap.remove(file); return; - } else { - CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); - if (compareEditorInput != null) { - CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); - } + } + CompareEditorInput compareEditorInput = compareEditorInputMap.get(file); + if (compareEditorInput != null) { + CompareUI.reuseCompareEditor(compareEditorInput, (IReusableEditor) editor); } }); } @@ -236,12 +240,11 @@ private IEditorPart getCompareEditor(CompareEditorInput input) { } /** - * Close the compare editor for the given file if it is open. + * Closes the compare editor for the given changed file if it is open. * - * @param file The file to check. - * @return true if the compare editor is open, false otherwise. + * @param file The changed file to check. */ - protected void closeCompareEditor(IFile file) { + protected void closeCompareEditor(ChangedFile file) { CompareEditorInput input = compareEditorInputMap.get(file); if (input != null) { SwtUtils.invokeOnDisplayThread(() -> { @@ -262,70 +265,137 @@ protected void closeCompareEditor(IFile file) { compareEditorInputMap.remove(file); } - private CompareEditorInput createCompareEditorInput(String comparedContent, IFile file) { - // Create a new CompareConfiguration - CompareConfiguration config = new CompareConfiguration(); - config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); - config.setRightLabel(file.getName()); + /** + * Brings the compare editor for a changed file to the top if it is open. + * + * @param file The changed file whose compare editor should be shown. + * @return true if an open compare editor was found, false otherwise. + */ + protected boolean bringCompareEditorToTopIfOpen(ChangedFile file) { + CompareEditorInput input = compareEditorInputMap.get(file); + if (input == null) { + return false; + } + if (isCompareEditorOpen(input)) { + bringCompareEditorToTop(input); + return true; + } + compareEditorInputMap.remove(file); + return false; + } - // Enable editing on the proposed changes side and disable it on the original file side. Eclipse's original side - // and - // changes side are swapped, so we need to set the left side as editable to edit the proposed changes. - config.setLeftEditable(true); - config.setRightEditable(false); + /** + * Normalizes a local path for cache and map lookups. + * + * @param file the local file path + * @return the normalized absolute path + */ + protected Path normalizeLocalPath(Path file) { + return file.toAbsolutePath().normalize(); + } - // Set up the configuration to properly show differences - config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); - config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); - config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + + + private CompareEditorInput createWorkspaceCompareEditorInput(String comparedContent, IFile file) { + ChangedFile changedFile = ChangedFile.workspace(file); + EditableFileCompareInput originalFile = new EditableFileCompareInput(file); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + PlatformUtils.getFileCharset(file), () -> originalFile, (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + file.setContents(inputStream, true, true, monitor); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createLocalCompareEditorInput(String comparedContent, Path file) { + Path normalizedPath = normalizeLocalPath(file); + ChangedFile changedFile = ChangedFile.local(normalizedPath); + EditableFileCompareInput originalFile = new EditableFileCompareInput(normalizedPath); + return createCompareEditorInputForTarget(comparedContent, originalFile.getName(), originalFile.getType(), + StandardCharsets.UTF_8.name(), () -> originalFile, + (diffNode, monitor) -> { + EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); + try (InputStream inputStream = inputToBeApplied.getContents()) { + Files.write(normalizedPath, inputStream.readAllBytes()); + } catch (IOException e) { + CopilotCore.LOGGER.error("Error saving compare editor changes to local file", e); + } + CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(changedFile); + removeCachedFileContent(changedFile); + }); + } + + private CompareEditorInput createCompareEditorInput(String comparedContent, ChangedFile file) { + if (file.isWorkspaceFile()) { + return createWorkspaceCompareEditorInput(comparedContent, file.getWorkspaceFile()); + } + return createLocalCompareEditorInput(comparedContent, file.getLocalPath()); + } + + private CompareEditorInput createCompareEditorInputForTarget(String comparedContent, String fileName, + String fileExtension, String charset, Supplier<ITypedElement> originalFileSupplier, + CompareContentSaver contentSaver) { + CompareConfiguration config = createCompareConfiguration(fileName); return new CompareEditorInput(config) { @Override protected Object prepareInput(IProgressMonitor monitor) throws InvocationTargetException, InterruptedException { monitor.beginTask("Calculating differences", 10); - setTitle(Messages.agent_tool_compareEditor_titlePrefix + file.getName()); - // Keep proposedChanges virtual file's name and type same as the originalFile original file's name and type - EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, file.getName(), - file.getFileExtension(), PlatformUtils.getFileCharset(file)); - EditableFileCompareInput originalFile = new EditableFileCompareInput(file); - - // Create a diff node with proper configuration for text comparison - DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFile, proposedChanges); - + setTitle(Messages.agent_tool_compareEditor_titlePrefix + fileName); + EditableStringCompareInput proposedChanges = new EditableStringCompareInput(comparedContent, fileName, + fileExtension, charset); + DiffNode diffNode = new DiffNode(null, Differencer.CHANGE, null, originalFileSupplier.get(), proposedChanges); monitor.done(); return diffNode; } @Override public void saveChanges(IProgressMonitor monitor) throws CoreException { - // We need to set the right side as editable to save the changes made to the proposed changes. Otherwise, the - // changes won't be saved. if (isDirty()) { config.setRightEditable(true); super.saveChanges(monitor); - // Get the diff node which contains the comparison inputs DiffNode diffNode = (DiffNode) getCompareResult(); if (diffNode != null) { - // Get the right side input (the original file with any edits made) - EditableFileCompareInput inputToBeApplied = (EditableFileCompareInput) diffNode.getLeft(); - - // Save the modified content back to the file - try (InputStream inputStream = inputToBeApplied.getContents()) { - file.setContents(inputStream, true, true, monitor); - } catch (IOException e) { - CopilotCore.LOGGER.error("Error saving compare editor changes to file", e); - } + contentSaver.save(diffNode, monitor); } - - // If user keeps the changes with keyboard shortcut, we also need to complete the file. - CopilotUi.getPlugin().getChatServiceManager().getFileToolService().completeFile(file); - fileContentCache.remove(file); } } }; } + private CompareConfiguration createCompareConfiguration(String rightLabel) { + CompareConfiguration config = new CompareConfiguration(); + config.setLeftLabel(Messages.agent_tool_compareEditor_proposedChangesTitle.replaceAll("\"", "")); + config.setRightLabel(rightLabel); + config.setLeftEditable(true); + config.setRightEditable(false); + config.setProperty(CompareConfiguration.USE_OUTLINE_VIEW, Boolean.TRUE); + config.setProperty(CompareConfiguration.SHOW_PSEUDO_CONFLICTS, Boolean.TRUE); + config.setProperty(CompareConfiguration.IGNORE_WHITESPACE, Boolean.FALSE); + return config; + } + + /** + * Saves the editable compare content back to the target file type. + */ + @FunctionalInterface + private interface CompareContentSaver { + /** + * Saves the edited content represented by a compare diff node. + * + * @param diffNode The diff node containing the editable compare inputs. + * @param monitor The progress monitor for the save operation. + * @throws CoreException if saving through Eclipse APIs fails. + */ + void save(DiffNode diffNode, IProgressMonitor monitor) throws CoreException; + } + /** * Dispose the file change summary bar and related resources. */ @@ -342,8 +412,10 @@ protected void dispose() { /** * Editable file compare input class to handle file content editing on the compare editor. */ - public class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, IEditableContent { - private IFile file; + public static final class EditableFileCompareInput implements ITypedElement, IEncodedStreamContentAccessor, + IEditableContent { + private final IFile workspaceFile; + private final Path localFile; private byte[] modifiedContent = null; /** @@ -352,12 +424,27 @@ public class EditableFileCompareInput implements ITypedElement, IEncodedStreamCo * @param file The file to be edited. */ public EditableFileCompareInput(IFile file) { - this.file = file; + this.workspaceFile = file; + this.localFile = null; + } + + /** + * Constructor for EditableFileCompareInput. + * + * @param file The local file to be edited. + */ + EditableFileCompareInput(Path file) { + this.workspaceFile = null; + this.localFile = file.toAbsolutePath().normalize(); } @Override public String getName() { - return file.getName(); + if (workspaceFile != null) { + return workspaceFile.getName(); + } + Path fileName = localFile.getFileName(); + return fileName == null ? localFile.toString() : fileName.toString(); } @Override @@ -367,11 +454,19 @@ public Image getImage() { @Override public String getType() { - return file.getFileExtension(); + if (workspaceFile != null) { + return workspaceFile.getFileExtension(); + } + return getLocalFileExtension(localFile); } + /** + * Gets the workspace file represented by this compare input. + * + * @return the workspace file + */ public IFile getFile() { - return file; + return workspaceFile; } @Override @@ -379,12 +474,19 @@ public InputStream getContents() throws CoreException { if (modifiedContent != null) { return new ByteArrayInputStream(modifiedContent); } - return file.getContents(); + if (workspaceFile != null) { + return workspaceFile.getContents(); + } + try { + return Files.newInputStream(localFile); + } catch (IOException e) { + throw new CoreException(Status.error("Error reading local file", e)); + } } @Override public String getCharset() throws CoreException { - return file.getCharset(); + return workspaceFile == null ? StandardCharsets.UTF_8.name() : workspaceFile.getCharset(); } @Override @@ -401,7 +503,6 @@ public void setContent(byte[] newContent) { public ITypedElement replace(ITypedElement dest, ITypedElement src) { if (src instanceof IStreamContentAccessor sca) { try (InputStream is = sca.getContents()) { - // Just store changes in memory modifiedContent = is.readAllBytes(); } catch (IOException | CoreException e) { CopilotCore.LOGGER.error("Error occurred while replacing file content", e); @@ -409,6 +510,15 @@ public ITypedElement replace(ITypedElement dest, ITypedElement src) { } return this; } + + private static String getLocalFileExtension(Path file) { + String name = file.getFileName() == null ? file.toString() : file.getFileName().toString(); + int index = name.lastIndexOf('.'); + if (index < 0 || index == name.length() - 1) { + return ""; + } + return name.substring(index + 1); + } } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java index a5571718..151cf791 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java @@ -6,13 +6,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import org.eclipse.core.databinding.observable.sideeffect.ISideEffect; import org.eclipse.core.databinding.observable.value.IObservableValue; import org.eclipse.core.databinding.observable.value.WritableValue; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.lsp4j.FileChangeType; @@ -35,7 +33,7 @@ * the files to be created or edited and the enable state of the button. */ public class FileToolService extends ChatBaseService { - private IObservableValue<Map<IFile, FileChangeProperty>> filesObservable; + private IObservableValue<Map<ChangedFile, FileChangeProperty>> filesObservable; private IObservableValue<Boolean> buttonEnableObservable; private WorkingSetBar workingSetBar; @@ -78,14 +76,16 @@ public void bindWorkingSetBar(ChatView chatView) { ensureRealm(() -> { unbindWorkingSetBar(); filesSideEffect = ISideEffect.create(() -> filesObservable.getValue(), - (Map<IFile, FileChangeProperty> filesMap) -> { + (Map<ChangedFile, FileChangeProperty> filesMap) -> { if (filesMap.isEmpty()) { disposeWorkingSetBar(); } else { if (this.workingSetBar == null || this.workingSetBar.isDisposed()) { - this.workingSetBar = new WorkingSetBar(chatView.getActionBar(), SWT.NONE); + this.workingSetBar = new WorkingSetBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Position WorkingSetBar below TodoListBar (if present), otherwise at top + // Position WorkingSetBar below TodoListBar (if present), otherwise at the top of + // inputArea. The StaticBanner sits on the outer ActionBar as a sibling of inputArea, + // so it remains above this bar regardless of this call. positionWorkingSetBar(chatView); this.workingSetBar.buildSummaryBarFor(filesMap); } @@ -152,7 +152,7 @@ public void setWorkingSetBarButtonStatus(boolean status) { /** * Set the changed files for the working set bar. */ - public void setChangedFiles(Map<IFile, FileChangeProperty> files) { + public void setChangedFiles(Map<ChangedFile, FileChangeProperty> files) { ensureRealm(() -> { filesObservable.setValue(files); }); @@ -161,7 +161,7 @@ public void setChangedFiles(Map<IFile, FileChangeProperty> files) { /** * Get the changed files for the working set bar. */ - public Map<IFile, FileChangeProperty> getChangedFiles() { + public Map<ChangedFile, FileChangeProperty> getChangedFiles() { return filesObservable.getValue(); } @@ -173,11 +173,11 @@ public WorkingSetBar getWorkingSetBar() { } /** - * Add a newly created file to the working set bar. + * Add a changed file to the working set bar. */ - public void addChangedFile(IFile file, FileChangeType fileChangeType) { + public void addChangedFile(ChangedFile file, FileChangeType fileChangeType) { ensureRealm(() -> { - Map<IFile, FileChangeProperty> filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map<ChangedFile, FileChangeProperty> filesMap = new LinkedHashMap<>(filesObservable.getValue()); if (filesMap.containsKey(file)) { return; } @@ -192,9 +192,9 @@ public void addChangedFile(IFile file, FileChangeType fileChangeType) { * * @param file the file to complete */ - public void completeFile(IFile file) { + public void completeFile(ChangedFile file) { ensureRealm(() -> { - Map<IFile, FileChangeProperty> filesMap = new LinkedHashMap<>(filesObservable.getValue()); + Map<ChangedFile, FileChangeProperty> filesMap = new LinkedHashMap<>(filesObservable.getValue()); filesMap.remove(file); filesObservable.setValue(filesMap); @@ -210,7 +210,7 @@ public void completeFile(IFile file) { * @param file the file to get the change type for * @return the file change type, or null if the file is not in the list */ - public FileChangeType getFileChangeTypeOf(IFile file) { + private FileChangeType getFileChangeTypeInternal(ChangedFile file) { FileChangeProperty property = filesObservable.getValue().get(file); if (property != null) { return property.getChangeType(); @@ -224,10 +224,10 @@ public FileChangeType getFileChangeTypeOf(IFile file) { * * @param file the file to keep changes for */ - public void onKeepChange(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onKeepChange(ChangedFile file) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onKeepChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onKeepChange(file); } this.completeFile(file); @@ -237,8 +237,13 @@ public void onKeepChange(IFile file) { * Handles the action of keeping all changes to files. */ public void onKeepAllChanges() { - this.createFileTool.onKeepAllChanges(getCreatedFiles()); - this.editFileTool.onKeepAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onKeepChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onKeepChange(file); + } + } onResolveAllChanges(); } @@ -247,11 +252,11 @@ public void onKeepAllChanges() { * * @param file the file to undo changes for */ - public void onUndoChange(IFile file) { + public void onUndoChange(ChangedFile file) { try { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { this.createFileTool.onUndoChange(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { this.editFileTool.onUndoChange(file); } } catch (CoreException | IOException e) { @@ -265,8 +270,13 @@ public void onUndoChange(IFile file) { */ public void onUndoAllChanges() { try { - this.createFileTool.onUndoAllChanges(getCreatedFiles()); - this.editFileTool.onUndoAllChanges(getEditedFiles()); + for (ChangedFile file : new ArrayList<>(filesObservable.getValue().keySet())) { + if (getFileChangeTypeInternal(file) == FileChangeType.Created) { + this.createFileTool.onUndoChange(file); + } else if (getFileChangeTypeInternal(file) == FileChangeType.Changed) { + this.editFileTool.onUndoChange(file); + } + } } catch (CoreException | IOException e) { CopilotCore.LOGGER.error("Error undoing all changes for the files", e); } @@ -278,10 +288,14 @@ public void onUndoAllChanges() { * * @param file the file to view the diff for */ - public void onViewDiff(IFile file) { - if (getFileChangeTypeOf(file) == FileChangeType.Created) { + public void onViewDiff(ChangedFile file) { + FileChangeProperty property = filesObservable.getValue().get(file); + if (property == null) { + return; + } + if (property.getChangeType() == FileChangeType.Created) { this.createFileTool.onViewDiff(file); - } else if (getFileChangeTypeOf(file) == FileChangeType.Changed) { + } else if (property.getChangeType() == FileChangeType.Changed) { this.editFileTool.onViewDiff(file); } } @@ -311,29 +325,8 @@ public void disposeWorkingSetBar() { } } - private List<IFile> getCreatedFiles() { - List<IFile> createdFiles = new ArrayList<>(); - for (Map.Entry<IFile, FileChangeProperty> entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Created) { - createdFiles.add(entry.getKey()); - } - } - return createdFiles; - } - - private List<IFile> getEditedFiles() { - List<IFile> editedFiles = new ArrayList<>(); - for (Map.Entry<IFile, FileChangeProperty> entry : this.filesObservable.getValue().entrySet()) { - if (entry.getValue().getChangeType() == FileChangeType.Changed) { - editedFiles.add(entry.getKey()); - } - } - return editedFiles; - } - /** - * Class for file change properties. changeType - The type of file change (new or edited). isCompleted - Whether the - * file change is completed or not. + * Class for file change properties. */ public static class FileChangeProperty { private FileChangeType changeType; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java index 085d4913..2bfbdc11 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/RunInTerminalToolAdapter.java @@ -3,12 +3,17 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; +import java.io.File; +import java.net.URI; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.resources.IResource; +import org.eclipse.lsp4j.WorkspaceFolder; import com.microsoft.copilot.eclipse.core.lsp.protocol.ConfirmationMessages; import com.microsoft.copilot.eclipse.core.lsp.protocol.InputSchema; @@ -18,8 +23,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolResult.ToolInvocationStatus; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; +import com.microsoft.copilot.eclipse.terminal.api.TerminalCommandProcessor; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; +import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.ChatView; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.ReferencedFileService; +import com.microsoft.copilot.eclipse.ui.utils.ResourceUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** @@ -38,18 +48,22 @@ public RunInTerminalToolAdapter() { private String buildToolDescription() { if (PlatformUtils.isWindows()) { return """ - Shell: cmd.exe + Shell: powershell.exe - This tool allows you to execute Windows Command Prompt commands in a persistent terminal session, \ + This tool allows you to execute PowerShell commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use & to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., cmd /c "command") unless explicitly asked - - Use pipelines | for data flow + - Use ; to chain commands on one line + - Never create a sub-shell (e.g., powershell -c "command") unless explicitly asked + - Prefer pipelines | for object-based data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it with command flags (e.g., `git --no-pager`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -58,19 +72,23 @@ private String buildToolDescription() { } if (PlatformUtils.isLinux()) { return """ - Shell: /bin/sh (POSIX shell) + Shell: /bin/bash - This tool allows you to execute POSIX shell commands in a persistent terminal session, \ + This tool allows you to execute Bash commands in a persistent terminal session, \ preserving environment variables, working directory, and other context across multiple commands. Use this tool instead of printing a shell codeblock and asking the user to run it. Command Execution: - - Use ; to chain commands on one line (or && for conditional execution on success) - - Never create a sub-shell (e.g., sh -c "command") unless explicitly asked + - Use && to chain commands on one line + - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) - - Use POSIX-compliant syntax (avoid bash-specific features like arrays or [[ ]]) + - Bash syntax is supported, including arrays and [[ ]] + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -87,8 +105,12 @@ private String buildToolDescription() { - Use && to chain commands on one line - Never create a sub-shell (e.g., bash -c "command") unless explicitly asked - Prefer pipelines | for data flow + - Multi-line commands must be complete and non-interactive. Do not run REPLs, continuation-prompt commands, + unmatched quotes or brackets, or commands that wait for stdin. For Python/Node scripts, create a file first, + then run it - Must use absolute paths to avoid navigation issues - If a command may use a pager, disable it (e.g., `git --no-pager` or add `| cat`) + - Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow Background Processes: - For long-running tasks (e.g., servers), set isBackground=true @@ -178,13 +200,55 @@ public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> i } impl.setTerminalIconDescriptor(UiUtils.buildImageDescriptorFromPngPath("/icons/github_copilot.png")); + String workingDirectory = resolveWorkingDirectory(); - return impl.executeCommand(command, isBackground).thenApply( - result -> new LanguageModelToolResult[] { new LanguageModelToolResult(result, ToolInvocationStatus.success) }) + return impl.executeCommand(command, isBackground, workingDirectory) + .thenApply(result -> new LanguageModelToolResult[] { new LanguageModelToolResult( + TerminalCommandProcessor.prepareOutputForModel(result), ToolInvocationStatus.success) }) .exceptionally(throwable -> new LanguageModelToolResult[] { new LanguageModelToolResult( "Terminal execution failed: " + throwable.getMessage(), ToolInvocationStatus.error) }); } + static String resolveWorkingDirectoryFromResources(List<IResource> resources) { + return ResourceUtils.deriveWorkspaceFoldersFrom(resources).stream() + .findFirst() + .map(RunInTerminalToolAdapter::toLocalPath) + .orElse(""); + } + + private static String resolveWorkingDirectory() { + ChatServiceManager manager = CopilotUi.getPlugin() != null ? CopilotUi.getPlugin().getChatServiceManager() : null; + if (manager != null) { + ReferencedFileService fileService = manager.getReferencedFileService(); + if (fileService != null) { + List<IResource> resources = new ArrayList<>(); + if (fileService.getCurrentFile() != null) { + resources.add(fileService.getCurrentFile()); + } + if (fileService.getReferencedFiles() != null) { + resources.addAll(fileService.getReferencedFiles()); + } + String referencedLocation = resolveWorkingDirectoryFromResources(resources); + if (StringUtils.isNotBlank(referencedLocation)) { + return referencedLocation; + } + } + } + + return ""; + } + + private static String toLocalPath(WorkspaceFolder workspaceFolder) { + if (workspaceFolder == null || StringUtils.isBlank(workspaceFolder.getUri())) { + return ""; + } + try { + return new File(URI.create(workspaceFolder.getUri())).getPath(); + } catch (IllegalArgumentException e) { + return ""; + } + } + /** * Tool to retrieve the output of a terminal command that was previously started with run_in_terminal. */ @@ -204,7 +268,10 @@ public LanguageModelToolInformation getToolInformation() { // Set the name and description of the tool toolInfo.setName(TOOL_NAME); - toolInfo.setDescription("Get the output of a terminal command previous started with run_in_terminal."); + toolInfo.setDescription(""" + Get the output of a terminal command previously started with run_in_terminal. + Output returned to the model is automatically truncated to the last 1000 lines to prevent context overflow. + """); // Define the input schema for the tool InputSchema inputSchema = new InputSchema(); @@ -247,7 +314,7 @@ public CompletableFuture<LanguageModelToolResult[]> invoke(Map<String, Object> i toolResult.addContent("Invalid terminal ID " + id); } else { toolResult.setStatus(ToolInvocationStatus.success); - toolResult.addContent(output.toString()); + toolResult.addContent(TerminalCommandProcessor.prepareOutputForModel(output.toString())); } } resultFuture.complete(new LanguageModelToolResult[] { toolResult }); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java index c7619f7f..7176e9e2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/WorkingSetHandler.java @@ -4,9 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.tools; import java.io.IOException; -import java.util.List; -import org.eclipse.core.resources.IFile; import org.eclipse.core.runtime.CoreException; /** @@ -18,12 +16,7 @@ public interface WorkingSetHandler { * * @param file the file to keep changes for */ - void onKeepChange(IFile file) throws IOException, CoreException; - - /** - * Handles the action of keeping all changes to files. - */ - void onKeepAllChanges(List<IFile> files) throws IOException, CoreException; + void onKeepChange(ChangedFile file) throws IOException, CoreException; /** * Handles the action of undoing changes to a file. @@ -33,22 +26,14 @@ public interface WorkingSetHandler { * @throws CoreException if an error occurs during the undo operation, such as a failure to delete a file * @throws IOException if an error occurs while writing to the file */ - void onUndoChange(IFile file) throws CoreException, IOException; - - /** - * Handles the action of undoing all changes to files. - * - * @throws CoreException if error occurs during the undo all operation, such as a failure to delete a file - * @throws IOException if an error occurs while writing to the file - */ - void onUndoAllChanges(List<IFile> files) throws CoreException, IOException; + void onUndoChange(ChangedFile file) throws CoreException, IOException; /** * Handles the action of viewing the diff of a file. * * @param file the file to view the diff for */ - void onViewDiff(IFile file); + void onViewDiff(ChangedFile file); /** * Handles the action of click done button to resolve all changes. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/EditorsManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/EditorsManager.java index 6e62c2ce..5a44850f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/EditorsManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/completion/EditorsManager.java @@ -162,8 +162,21 @@ public RenderManager getOrCreateNesRenderManager(ITextEditor editor) { return null; } - return nesRenderManagers.computeIfAbsent(editor, - ed -> new RenderManager(this.languageServer, this.nesProvider, ed)); + // Avoid computeIfAbsent() here: the RenderManager constructor calls + // Display.syncExec() via registerListeners(), and computeIfAbsent() holds + // an internal bucket lock during the mapping function. If the UI thread + // concurrently calls remove() on the same bucket, both threads deadlock. + // See https://github.com/microsoft/copilot-for-eclipse/issues/175 + RenderManager mgr = nesRenderManagers.get(editor); + if (mgr == null) { + mgr = new RenderManager(this.languageServer, this.nesProvider, editor); + RenderManager existing = nesRenderManagers.putIfAbsent(editor, mgr); + if (existing != null) { + mgr.dispose(); + mgr = existing; + } + } + return mgr; } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java index f59f74ec..2c15179e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/McpServerItem.java @@ -45,6 +45,7 @@ import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ActionType; import com.microsoft.copilot.eclipse.ui.dialogs.mcp.McpServerInstallManager.ButtonState; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; +import com.microsoft.copilot.eclipse.ui.swt.SplitDropdownButton; import com.microsoft.copilot.eclipse.ui.utils.McpUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -301,15 +302,17 @@ public void widgetSelected(SelectionEvent e) { disabledInstallButton.setToolTipText(Messages.mcpServerItem_noInstallOptions); actionButton = disabledInstallButton; } else { - DropDownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, + SplitDropdownButton dropDownInstallButton = createDropDownInstallButton(actionComposite, initialState, installOptions); actionButton = dropDownInstallButton.getButton(); } } } - private DropDownButton createDropDownInstallButton(Composite parent, ButtonState state, List<InstallOption> options) { - DropDownButton dropDownInstallButton = new DropDownButton(parent, SWT.NONE); + private SplitDropdownButton createDropDownInstallButton( + Composite parent, ButtonState state, + List<InstallOption> options) { + SplitDropdownButton dropDownInstallButton = new SplitDropdownButton(parent, SWT.NONE); dropDownInstallButton.setShowArrow(options.size() > 1); dropDownInstallButton.setText(state.getText()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/Messages.java index f0fd5c10..7919364f 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/Messages.java @@ -12,13 +12,6 @@ public final class Messages extends NLS { public static String mcpRegistryDialog_mcpRegistry; public static String mcpRegistryDialog_banner_description; public static String mcpRegistryDialog_searchPlaceholder; - public static String mcpRegistryDialog_col_name; - public static String mcpRegistryDialog_col_desc; - public static String mcpRegistryDialog_col_actions; - public static String mcpRegistryDialog_details; - public static String mcpRegistryDialog_moreInfo; - public static String mcpRegistryDialog_install; - public static String mcpRegistryDialog_empty; public static String mcpRegistryDialog_errorLoading_prefix; public static String mcpRegistryDialog_errorLoading_default; public static String mcpRegistryDialog_invalidResponse; @@ -34,19 +27,14 @@ public final class Messages extends NLS { public static String mcpServerDetailDialog_title; public static String mcpServerDetailDialog_description; - public static String mcpServerDetailDialog_category; public static String mcpServerDetailDialog_version; public static String mcpServerDetailDialog_repository; public static String mcpServerDetailDialog_repository_tooltip; public static String mcpServerDetailDialog_close; public static String mcpServerDetailDialog_noDetailsAvailable; public static String mcpServerDetailDialog_noDescription; - public static String mcpServerDetailDialog_noVersion; - public static String mcpServerDetailDialog_categoryDeveloperTools; - public static String mcpServerDetailDialog_categoryDataScience; public static String mcpServerDetailDialog_published; public static String mcpServerDetailDialog_updated; - public static String mcpServerDetailDialog_install; public static String mcpServerDetailDialog_noPublishedDate; public static String mcpServerDetailDialog_noUpdatedDate; public static String mcpServerDetailDialog_installation_options; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/messages.properties index 0ee4393d..5d0754e8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/messages.properties @@ -32,13 +32,6 @@ mcpRegistryDialog_button_changeUrl=Configure Registry Base URL mcpRegistryDialog_button_changeUrl_tooltip=Click to modify MCP Registry Base URL mcpRegistryDialog_error_empty_url=Once configured, return to this window to view the list of available MCP servers. mcpRegistryDialog_loading_label=Loading MCP servers... -mcpRegistryDialog_col_name=MCP Servers -mcpRegistryDialog_col_desc=Description -mcpRegistryDialog_col_actions=Actions -mcpRegistryDialog_details=Details -mcpRegistryDialog_moreInfo=more info -mcpRegistryDialog_install=Install -mcpRegistryDialog_empty=No servers found mcpRegistryDialog_errorLoading_prefix=Failed to load MCP servers: {0} mcpRegistryDialog_errorLoading_default=Failed to load MCP servers, please check the URL or your network connection, then try again. mcpRegistryDialog_invalidResponse=Invalid response from MCP registry.\nThe registry URL {0} may be incorrect.\nPlease verify the URL is pointing to a valid MCP registry base URL like 'https://api.mcp.github.com'.\nClick "{1}" button above to configure MCP Registry Base URL @@ -47,19 +40,14 @@ mcpRegistryDialog_emptyUrlForRegistryOnly_msg=Your IT admin has restricted MCP a mcpServerDetailDialog_title=MCP Server Details mcpServerDetailDialog_description=Description -mcpServerDetailDialog_category=Category mcpServerDetailDialog_version=Version mcpServerDetailDialog_repository=Repository mcpServerDetailDialog_repository_tooltip=Click to open the MCP server repository in browser mcpServerDetailDialog_close=Close mcpServerDetailDialog_noDetailsAvailable=No details available mcpServerDetailDialog_noDescription=No description available -mcpServerDetailDialog_noVersion=Version not specified -mcpServerDetailDialog_categoryDeveloperTools=Developer Tools -mcpServerDetailDialog_categoryDataScience=Data Science mcpServerDetailDialog_published=Published mcpServerDetailDialog_updated=Updated -mcpServerDetailDialog_install=Install mcpServerDetailDialog_noPublishedDate=Publication date not available mcpServerDetailDialog_noUpdatedDate=Update date not available mcpServerDetailDialog_installation_options=Installation Options diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java index 15519b40..c7916ea7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/QuotaTextCalculator.java @@ -3,6 +3,7 @@ package com.microsoft.copilot.eclipse.ui.handlers; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; @@ -10,6 +11,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** @@ -39,65 +41,126 @@ private int calculateMaxWidth() { int max = 0; if (!PlatformUtils.isWindows()) { max = Math.max(max, - gc.stringExtent(Messages.menu_quota_codeCompletions + getPercentUsed(quotaResult.getCompletionsQuota())).x); + gc.stringExtent(Messages.menu_quota_codeCompletions + getPercentUsed(quotaResult.completions())).x); max = Math.max(max, - gc.stringExtent(Messages.menu_quota_chatMessages + getPercentUsed(quotaResult.getChatQuota())).x); - if (quotaResult.getCopilotPlan() != CopilotPlan.free) { - max = Math.max(max, gc.stringExtent( - Messages.menu_quota_premiumRequests + getPercentUsed(quotaResult.getPremiumInteractionsQuota())).x); + gc.stringExtent(Messages.menu_quota_chatMessages + getPercentUsed(quotaResult.chat())).x); + if (quotaResult.copilotPlan() != CopilotPlan.free && quotaResult.premiumInteractions() != null) { + if (quotaResult.tokenBasedBillingEnabled()) { + max = Math.max(max, gc.stringExtent( + getPremiumRequestsLabel() + getPremiumRequestsSuffix()).x); + } else { + // TODO: Remove this legacy fallback after TBB is officially released. + max = Math.max(max, gc.stringExtent( + Messages.menu_quota_premiumRequests + getPercentUsed(quotaResult.premiumInteractions())).x); + } } max += PADDING_WIDTH; } return max; } + /** + * Returns the label used for the monthly limit row. CFI (Copilot for Individuals) plans show + * "Included credits"; all other paid plans show "Monthly limit". + */ + private String getPremiumRequestsLabel() { + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan())) { + return Messages.menu_quota_includedCredits; + } + return Messages.menu_quota_monthlyLimit; + } + + /** + * Returns the tooltip used for the premium requests row. CFI (Copilot for Individuals) plans get + * the "included credits" tooltip; all other paid plans get the "monthly limit" tooltip. + */ + public String getPremiumRequestsTooltip() { + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan())) { + return Messages.menu_quota_includedCreditsTooltip; + } + return Messages.menu_quota_monthlyLimitTooltip; + } + /** * Returns the aligned text for code completions quota. */ public String getCompletionText() { - return getAlignedQuotaText(Messages.menu_quota_codeCompletions, quotaResult.getCompletionsQuota()); + return getAlignedQuotaText(Messages.menu_quota_codeCompletions, getPercentUsed(quotaResult.completions())); } /** * Returns the aligned text for chat messages quota. */ public String getChatText() { - return getAlignedQuotaText(Messages.menu_quota_chatMessages, quotaResult.getChatQuota()); + return getAlignedQuotaText(Messages.menu_quota_chatMessages, getPercentUsed(quotaResult.chat())); } /** - * Returns the aligned text for premium requests quota. + * Returns the aligned text for the monthly limit row, sourced from the premium interactions quota. + * CFI (Copilot for Individuals) plans label this row "Included credits" and display the absolute + * "{used}/{entitlement} AI credits used" suffix instead of a percentage. + */ + public String getPremiumRequestsText() { + return getAlignedQuotaText(getPremiumRequestsLabel(), getPremiumRequestsSuffix()); + } + + // TODO: Remove this legacy fallback after TBB is officially released. + /** + * Returns the aligned text for the legacy "Premium Requests" row used when token-based billing is + * not enabled on the language server. Preserves the original main-branch label and "{percent}%" + * suffix. */ public String getPremiumText() { - return getAlignedQuotaText(Messages.menu_quota_premiumRequests, quotaResult.getPremiumInteractionsQuota()); + return getAlignedQuotaText(Messages.menu_quota_premiumRequests, + getPercentUsed(quotaResult.premiumInteractions())); + } + + /** + * Returns the suffix used for the premium requests row. CFI (Copilot for Individuals) plans get + * the absolute "{used}/{entitlement} AI credits used" form; other paid plans get the standard + * "{percent}% used" form. + */ + private String getPremiumRequestsSuffix() { + Quota premiumQuota = quotaResult.premiumInteractions(); + if (MenuUtils.isCfiPlan(quotaResult.copilotPlan()) && premiumQuota != null && !premiumQuota.unlimited()) { + long entitlement = Math.round(premiumQuota.entitlement()); + long used = Math.max(0, entitlement - Math.round(premiumQuota.quotaRemaining())); + return NLS.bind(Messages.menu_quota_aiCreditsUsedFormat, used, entitlement); + } + return getPercentUsed(premiumQuota); } /** - * Helper method to generate aligned quota text for any quota type. + * Helper method to generate aligned quota text with a caller-supplied suffix. * - * @param messagePrefix the message prefix (e.g., "Code completions") - * @param quota the quota object to get percentage from + * @param messagePrefix the message prefix (e.g., "Included credits") + * @param quotaText the suffix to display on the right (e.g., "12/300 AI credits used") * @return the aligned quota text */ - private String getAlignedQuotaText(String messagePrefix, Quota quota) { + private String getAlignedQuotaText(String messagePrefix, String quotaText) { if (PlatformUtils.isWindows()) { // windows supports align the text via \t - return messagePrefix.trim() + "\t" + getPercentUsed(quota); + return messagePrefix.trim() + "\t" + quotaText; } - String quotaText = getPercentUsed(quota); int currentWidth = gc.stringExtent(messagePrefix + quotaText).x; int spacesToAdd = (int) Math.round((maxWidth - currentWidth) / (double) spaceWidth) + 1; return UiUtils.getAlignedText(gc, messagePrefix, UiUtils.HAIR_SPACE, quotaText, spacesToAdd, maxWidth); } private String getPercentUsed(Quota quota) { - if (quota.isUnlimited()) { - return "Included"; + if (quota == null) { + return ""; + } + if (quota.unlimited()) { + return Messages.menu_quota_included; } - double percent = Math.max(0, 100 - quota.getPercentRemaining()); - if (percent < 0.1) { - return "0%"; + double percent = Math.max(0, 100 - quota.percentRemaining()); + String formattedPercent = percent < 0.1 ? "0" + : String.format("%.1f", Math.round(percent * 10) / 10.0); + if (!quotaResult.tokenBasedBillingEnabled()) { + // TODO: Remove this legacy fallback after TBB is officially released. + return formattedPercent + "%"; } - return String.format("%.1f", Math.round(percent * 10) / 10.0) + "%"; + return NLS.bind(Messages.menu_quota_percentUsedFormat, formattedPercent); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java index 4c4dec7a..5ea52002 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowMenuBarMenuHandler.java @@ -15,6 +15,7 @@ import org.eclipse.jface.action.IContributionItem; import org.eclipse.jface.action.Separator; import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.actions.CompoundContributionItem; @@ -29,11 +30,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -45,6 +48,7 @@ public class ShowMenuBarMenuHandler extends CompoundContributionItem implements private CommandContributionItem chatUsageItem; private CommandContributionItem completionsUsageItem; private CommandContributionItem premiumRequestsUsageItem; + private CommandContributionItem allowanceResetItem; @Override public void initialize(IServiceLocator serviceLocator) { @@ -65,6 +69,10 @@ public void initialize(IServiceLocator serviceLocator) { premiumRequestsUsageItem.dispose(); premiumRequestsUsageItem = null; } + if (allowanceResetItem != null) { + allowanceResetItem.dispose(); + allowanceResetItem = null; + } } }); } @@ -81,8 +89,11 @@ protected IContributionItem[] getContributionItems() { items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.signIn", Messages.menu_signToGitHub, UiUtils.buildImageDescriptorFromPngPath("/icons/signin.png"))); } else if (CopilotStatusResult.OK.equals(status)) { + String userName = authStatusManager.getUserName(); + String planLabel = MenuUtils.getPlanLabel(authStatusManager.getQuotaStatus().copilotPlan()); + String userLabel = planLabel != null ? NLS.bind(Messages.menu_userPlanFormat, userName, planLabel) : userName; items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", - authStatusManager.getUserName(), authStatusManager.getUserName(), null)); + userLabel, userName, null)); } // menu: copilot Usage @@ -133,40 +144,129 @@ protected IContributionItem[] getContributionItems() { private void addCopilotUsageItems(AuthStatusManager authStatusManager, List<IContributionItem> items) { // menu: Copilot useage CheckQuotaResult quotaStatus = authStatusManager.getQuotaStatus(); - if (authStatusManager.isNotSignedInOrNotAuthorized() || quotaStatus.getCompletionsQuota() == null - || quotaStatus.getChatQuota() == null || StringUtils.isEmpty(quotaStatus.getResetDate())) { + if (authStatusManager.isNotSignedInOrNotAuthorized() || quotaStatus.completions() == null + || quotaStatus.chat() == null || StringUtils.isEmpty(quotaStatus.resetDate())) { return; } // TODO: remove reset date null check when the CLS is ready for all IDEs. items.add(new Separator()); - // Calculate percentRemaining based on plan - double percentRemaining; - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // For free plan, consider completions and chat quotas - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - quotaStatus.getChatQuota().getPercentRemaining()); + + if (quotaStatus.tokenBasedBillingEnabled()) { + addCopilotUsageItemsTbb(items, quotaStatus); } else { - // For paid plans, also consider premium interactions quota - if (quotaStatus.getCompletionsQuota() == null) { - // If completions quota is not available, set percentRemaining to 0 - percentRemaining = 0; - } else { - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - Math.min(quotaStatus.getChatQuota().getPercentRemaining(), - quotaStatus.getPremiumInteractionsQuota().getPercentRemaining())); + // TODO: Remove this legacy branch after TBB is officially released. + addCopilotUsageItemsLegacy(items, quotaStatus); + } + + // Create a CompletableFuture to update quota information + CopilotCore.getPlugin().getAuthStatusManager().checkQuota().thenAccept(this::updateQuotaItems); + } + + /** + * Renders the Copilot usage rows using the token-based-billing layout (Monthly Limit / Included + * Credits, Enable Additional Usage / Increase Budget, Upgrade Plan, dynamic allowance reset). + */ + private void addCopilotUsageItemsTbb(List<IContributionItem> items, CheckQuotaResult quotaStatus) { + ImageDescriptor usageIcon = MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaStatus)); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + boolean isOrgUnlimited = MenuUtils.isOrgUnlimited(quotaStatus); + boolean hasNonOrgPremiumQuota = MenuUtils.hasNonOrgPremiumQuota(quotaStatus); + // For non-free plans with a Monthly limit row, the usage icon belongs on that row instead of the header. + ImageDescriptor headerIcon = hasNonOrgPremiumQuota ? blankIcon : usageIcon; + + Map<String, String> parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); + items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, Messages.menu_quota_copilotUsage, parameters, + Messages.menu_quota_manageCopilotTooltip, headerIcon)); + + GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); + QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); + try { + if (plan == CopilotPlan.free) { + // Free plan: only show Code Completions and Chat Messages rows + this.completionsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", + calculator.getCompletionText(), blankIcon); + items.add(this.completionsUsageItem); + + this.chatUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", + calculator.getChatText(), blankIcon); + items.add(this.chatUsageItem); + } else if (isOrgUnlimited) { + // Business / Enterprise with unlimited premium interactions: show informational message + items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", + Messages.menu_quota_unlimitedOrgMessage, null)); + } else if (premiumQuota != null) { + // Other paid plans: show only the Monthly limit row sourced from premium interactions + this.premiumRequestsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", + calculator.getPremiumRequestsText(), calculator.getPremiumRequestsTooltip(), usageIcon); + items.add(this.premiumRequestsUsageItem); } + } finally { + gc.dispose(); + } + + // Allowance reset date + if (MenuUtils.shouldShowAllowanceResetRow(quotaStatus)) { + this.allowanceResetItem = createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", + MenuUtils.formatAllowanceReset(quotaStatus), null); + items.add(this.allowanceResetItem); + } + + // "Additional usage enabled" / "Additional usage not enabled" status row, shown for paid + // users with a bounded premium-interactions quota + if (hasNonOrgPremiumQuota) { + items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", + MenuUtils.getAdditionalUsageRowLabel(premiumQuota), + MenuUtils.getAdditionalUsageRowTooltip(quotaStatus), null)); + } + + // Upsell actions based on the user's plan + ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); + + // For non-free users (excluding org-unlimited business/enterprise): + // show "Enable Additional Usage" or "Increase Budget" depending on overage state. + // The overage row uses the same predicate as the Monthly limit row. + if (hasNonOrgPremiumQuota) { + items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, MenuUtils.getOverageRowLabel(premiumQuota), + Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), upgradeIcon)); } - ImageDescriptor icon; - // Set icon based on the lowest percentRemaining - if (percentRemaining <= 10) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); - } else if (percentRemaining > 10 && percentRemaining <= 25) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); + // For free / individual / individual_pro users, show an Upgrade Plan row. When the overage row is + // already showing the upgrade icon directly above, this row uses the blank icon to avoid duplication. + if (MenuUtils.shouldShowUpgradePlanRow(plan, quotaStatus.canUpgradePlan())) { + ImageDescriptor upgradePlanIcon = hasNonOrgPremiumQuota ? blankIcon : upgradeIcon; + items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", + Messages.menu_quota_upgradePlan, upgradePlanIcon)); + } + } + + // TODO: Remove this legacy fallback after TBB is officially released. + /** + * Renders the original main-branch Copilot usage rows. Used when the language server has not + * enabled token-based billing yet, in which case the new TBB-only APIs (premium interactions + * entitlement, overage budget UI, dynamic allowance-reset wording) are not relied on. + */ + private void addCopilotUsageItemsLegacy(List<IContributionItem> items, CheckQuotaResult quotaStatus) { + Quota completionsQuota = quotaStatus.completions(); + Quota chatQuota = quotaStatus.chat(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + CopilotPlan plan = quotaStatus.copilotPlan(); + + // Calculate percentRemaining based on plan + double percentRemaining; + if (plan == CopilotPlan.free) { + percentRemaining = Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); + } else if (premiumQuota != null) { + percentRemaining = Math.min(completionsQuota.percentRemaining(), + Math.min(chatQuota.percentRemaining(), premiumQuota.percentRemaining())); } else { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); + percentRemaining = Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); } + ImageDescriptor icon = MenuUtils.getUsageIcon(percentRemaining); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + Map<String, String> parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, Messages.menu_quota_copilotUsage, parameters, Messages.menu_quota_manageCopilotTooltip, icon)); @@ -174,42 +274,35 @@ private void addCopilotUsageItems(AuthStatusManager authStatusManager, List<ICon GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); try { - // Premium requests usage when rest plans are unlimited - if (quotaStatus.getCopilotPlan() != CopilotPlan.free && quotaStatus.getCompletionsQuota().isUnlimited() - && quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); + // Premium requests row first when both completion/chat quotas are unlimited. + if (plan != CopilotPlan.free && premiumQuota != null && completionsQuota.unlimited() && chatQuota.unlimited()) { this.premiumRequestsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - premiumRequestsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); + calculator.getPremiumText(), blankIcon); items.add(this.premiumRequestsUsageItem); } - // Code completions useage - String codeCompletionsText = calculator.getCompletionText(); + // Code completions usage this.completionsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - codeCompletionsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); + calculator.getCompletionText(), blankIcon); items.add(this.completionsUsageItem); // Chat messages usage - String chatMessagesText = calculator.getChatText(); this.chatUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - chatMessagesText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); + calculator.getChatText(), blankIcon); items.add(this.chatUsageItem); - // Premium requests usage - if (quotaStatus.getCopilotPlan() != CopilotPlan.free) { - // Premium requests usage when either of the rest plans is not unlimited - if (!quotaStatus.getCompletionsQuota().isUnlimited() || !quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); + // Premium requests usage / additional-paid status for non-free plans. + if (plan != CopilotPlan.free && premiumQuota != null) { + if (!completionsQuota.unlimited() || !chatQuota.unlimited()) { this.premiumRequestsUsageItem = createCommandItem("com.microsoft.copilot.eclipse.commands.enabledDoNothing", - premiumRequestsText, UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png")); + calculator.getPremiumText(), blankIcon); items.add(this.premiumRequestsUsageItem); } CommandContributionItem additionalPremiumRequestsDesc = createCommandItem( "com.microsoft.copilot.eclipse.commands.disabledDoNothing", Messages.menu_quota_additionalPremiumRequests - + (quotaStatus.getPremiumInteractionsQuota().isOveragePermitted() ? Messages.menu_quota_enabled - : Messages.menu_quota_disabled), + + (premiumQuota.overagePermitted() ? Messages.menu_quota_enabled : Messages.menu_quota_disabled), null); items.add(additionalPremiumRequestsDesc); } @@ -217,28 +310,23 @@ private void addCopilotUsageItems(AuthStatusManager authStatusManager, List<ICon gc.dispose(); } - // Allowance reset date - if (!StringUtils.isEmpty(quotaStatus.getResetDate())) { - LocalDate resetDate = LocalDate.parse(quotaStatus.getResetDate()); + // Allowance reset date (legacy: simple "Allowance resets <date>" string). + if (!StringUtils.isEmpty(quotaStatus.resetDate())) { + LocalDate resetDate = LocalDate.parse(quotaStatus.resetDate()); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.disabledDoNothing", Messages.menu_quota_allowanceReset + resetDate.format(formatter), null)); } - // Upsell actions based on the user's plan + // Upsell actions based on the user's plan (legacy wording). ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // If the user is on a free plan, show a link to upgrade. + if (plan == CopilotPlan.free) { items.add(createCommandItem("com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", Messages.menu_quota_updateCopilotToPro, Messages.menu_quota_updateCopilotToProPlus, upgradeIcon)); - } else if (quotaStatus.getCopilotPlan() != CopilotPlan.business - && quotaStatus.getCopilotPlan() != CopilotPlan.enterprise) { - // If the user is not on a free plan / business plan / enterprise plan, show a link to manage subscription. + } else if (plan != CopilotPlan.business && plan != CopilotPlan.enterprise) { items.add(createCommandItem(UiConstants.OPEN_URL_COMMAND_ID, Messages.menu_quota_managePaidPremiumRequests, Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), upgradeIcon)); } - // Create a CompletableFuture to update quota information - CopilotCore.getPlugin().getAuthStatusManager().checkQuota().thenAccept(this::updateQuotaItems); } private void addAuthenticationActions(List<IContributionItem> items, String status) { @@ -276,20 +364,38 @@ private void updateQuotaItems(CheckQuotaResult quotaResult) { private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaResult); + boolean tbbEnabled = quotaResult.tokenBasedBillingEnabled(); - if (this.chatUsageItem != null && quotaResult.getChatQuota() != null) { + if (this.chatUsageItem != null && quotaResult.chat() != null) { String chatMessagesText = calculator.getChatText(); - updateCommandItemLabel(this.chatUsageItem, chatMessagesText); + setCommandItemField(this.chatUsageItem, "label", chatMessagesText); } - if (this.completionsUsageItem != null && quotaResult.getCompletionsQuota() != null) { + if (this.completionsUsageItem != null && quotaResult.completions() != null) { String codeCompletionsText = calculator.getCompletionText(); - updateCommandItemLabel(this.completionsUsageItem, codeCompletionsText); + setCommandItemField(this.completionsUsageItem, "label", codeCompletionsText); } - if (this.premiumRequestsUsageItem != null && quotaResult.getPremiumInteractionsQuota() != null) { - String premiumRequestsText = calculator.getPremiumText(); - updateCommandItemLabel(this.premiumRequestsUsageItem, premiumRequestsText); + if (this.premiumRequestsUsageItem != null && quotaResult.premiumInteractions() != null) { + if (tbbEnabled) { + String monthlyLimitText = calculator.getPremiumRequestsText(); + setCommandItemField(this.premiumRequestsUsageItem, "label", monthlyLimitText); + // Refresh the usage icon (red/yellow/blue) to reflect the latest percent remaining. + setCommandItemField(this.premiumRequestsUsageItem, "icon", + MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaResult))); + } else { + // TODO: Remove this legacy fallback after TBB is officially released. + setCommandItemField(this.premiumRequestsUsageItem, "label", calculator.getPremiumText()); + } + } + + // Refresh the allowance-reset row label, which switches between "Reset in N days..." and + // "No usage yet" depending on whether any of the tracked quotas have been consumed. When the + // predicate flips off (e.g. plan changed to unlimited mid-session), skip the update and leave + // the stale label until the menu is rebuilt rather than rendering an empty disabled row. + if (tbbEnabled && this.allowanceResetItem != null && MenuUtils.shouldShowAllowanceResetRow(quotaResult)) { + setCommandItemField(this.allowanceResetItem, "label", MenuUtils.formatAllowanceReset(quotaResult)); + this.allowanceResetItem.update(); } if (this.chatUsageItem != null) { @@ -304,18 +410,18 @@ private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { } /** - * Updates the label of a CommandContributionItem. - * - * @param item The CommandContributionItem to update - * @param newLabel The new label to set + * Reflectively assigns a private field on {@link CommandContributionItem}. The platform does not + * expose mutators for {@code label}/{@code icon}, so this is used to refresh the menu entries in + * place. A failure here means a future Eclipse version renamed the field and the menu will stop + * refreshing - log it so the regression is visible. */ - private void updateCommandItemLabel(CommandContributionItem item, String newLabel) { + private void setCommandItemField(CommandContributionItem item, String fieldName, Object value) { try { - Field labelField = CommandContributionItem.class.getDeclaredField("label"); - labelField.setAccessible(true); - labelField.set(item, newLabel); - } catch (Exception e) { - // Skip updating the label if reflection fails + Field field = CommandContributionItem.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(item, value); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) { + CopilotCore.LOGGER.error("Failed to update CommandContributionItem field '" + fieldName + "'", e); } } @@ -367,9 +473,8 @@ private CommandContributionItemParameter createCommandContributionItemParameter( } private void setDefaultBlankIcon(CommandContributionItemParameter parameter) { - ImageDescriptor icon = UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"); if (PlatformUtils.isMac()) { - parameter.icon = icon; + parameter.icon = MenuUtils.getBlankIcon(); } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java index 7ee20969..178e23e3 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/handlers/ShowStatusBarMenuHandler.java @@ -22,6 +22,7 @@ import org.eclipse.jface.action.MenuManager; import org.eclipse.jface.action.Separator; import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.graphics.GC; import org.eclipse.swt.widgets.Menu; import org.eclipse.swt.widgets.Shell; @@ -38,11 +39,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.preferences.LanguageServerSettingManager; +import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -57,6 +60,7 @@ public class ShowStatusBarMenuHandler extends CopilotHandler implements IElement private Action completionRemainingAction; private Action chatRemainingAction; private Action premiumRequestsAction; + private Action allowanceResetAction; /** * Constructor for ShowStatusBarMenuHandler. @@ -75,6 +79,9 @@ public ShowStatusBarMenuHandler() { if (premiumRequestsAction != null) { premiumRequestsAction = null; } + if (allowanceResetAction != null) { + allowanceResetAction = null; + } } }); } @@ -147,6 +154,7 @@ private void updateQuotaActions(CheckQuotaResult quotaResult) { private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaResult); + boolean tbbEnabled = quotaResult.tokenBasedBillingEnabled(); if (completionRemainingAction != null) { completionRemainingAction.setText(calculator.getCompletionText()); @@ -154,8 +162,23 @@ private void updateQuotaActionTexts(CheckQuotaResult quotaResult, GC gc) { if (chatRemainingAction != null) { chatRemainingAction.setText(calculator.getChatText()); } - if (premiumRequestsAction != null && quotaResult.getCopilotPlan() != CopilotPlan.free) { - premiumRequestsAction.setText(calculator.getPremiumText()); + if (premiumRequestsAction != null && quotaResult.copilotPlan() != CopilotPlan.free) { + if (tbbEnabled) { + premiumRequestsAction.setText(calculator.getPremiumRequestsText()); + // Refresh the usage icon (red/yellow/blue) to reflect the latest percent remaining. + premiumRequestsAction.setImageDescriptor( + MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaResult))); + } else { + // TODO: Remove this legacy fallback after TBB is officially released. + premiumRequestsAction.setText(calculator.getPremiumText()); + } + } + // Refresh the allowance-reset row label, which switches between "Reset in N days..." and + // "No usage yet" depending on whether any of the tracked quotas have been consumed. When the + // predicate flips off (e.g. plan changed to unlimited mid-session), skip the update and leave + // the stale label until the menu is rebuilt rather than rendering an empty disabled row. + if (tbbEnabled && allowanceResetAction != null && MenuUtils.shouldShowAllowanceResetRow(quotaResult)) { + allowanceResetAction.setText(MenuUtils.formatAllowanceReset(quotaResult)); } } @@ -216,109 +239,195 @@ private void addSignInOrUsernameAction(MenuManager menuManager) { UiUtils.buildImageDescriptorFromPngPath("/icons/signin.png"), handlerService, "com.microsoft.copilot.eclipse.commands.signIn", true); } else if (CopilotStatusResult.OK.equals(status)) { - MenuActionFactory.createMenuAction(menuManager, authStatusManager.getUserName(), authStatusManager.getUserName(), + String userName = authStatusManager.getUserName(); + String planLabel = MenuUtils.getPlanLabel(authStatusManager.getQuotaStatus().copilotPlan()); + String userLabel = planLabel != null ? NLS.bind(Messages.menu_userPlanFormat, userName, planLabel) : userName; + MenuActionFactory.createMenuAction(menuManager, userLabel, userName, null, handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); } } private void addCopilotUsageAction(MenuManager menuManager) { CheckQuotaResult quotaStatus = CopilotCore.getPlugin().getAuthStatusManager().getQuotaStatus(); - if (quotaStatus.getCompletionsQuota() == null || quotaStatus.getChatQuota() == null - || StringUtils.isEmpty(quotaStatus.getResetDate())) { + if (quotaStatus.completions() == null || quotaStatus.chat() == null + || StringUtils.isEmpty(quotaStatus.resetDate())) { // skip quota status menu if quotas are not available // TODO: remove reset date null check when the CLS is ready for all IDEs. return; } + if (quotaStatus.tokenBasedBillingEnabled()) { + addCopilotUsageActionTbb(menuManager, quotaStatus); + } else { + // TODO: Remove this legacy branch after TBB is officially released. + addCopilotUsageActionLegacy(menuManager, quotaStatus); + } + } + + /** + * Renders the Copilot usage rows using the token-based-billing layout (Monthly Limit / Included + * Credits, Enable Additional Usage / Increase Budget, Upgrade Plan, dynamic allowance reset). + */ + private void addCopilotUsageActionTbb(MenuManager menuManager, CheckQuotaResult quotaStatus) { + ImageDescriptor usageIcon = MenuUtils.getUsageIcon(MenuUtils.calculatePercentRemaining(quotaStatus)); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + boolean isOrgUnlimited = MenuUtils.isOrgUnlimited(quotaStatus); + boolean hasNonOrgPremiumQuota = MenuUtils.hasNonOrgPremiumQuota(quotaStatus); + // For non-free plans with a Monthly limit row, the usage icon belongs on that row instead of the header. + ImageDescriptor headerIcon = hasNonOrgPremiumQuota ? blankIcon : usageIcon; + + Map<String, String> parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); + MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_copilotUsage, + Messages.menu_quota_manageCopilotTooltip, headerIcon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, + parameters, true); + + GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); + QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); + try { + if (plan == CopilotPlan.free) { + // Free plan: only show Code Completions and Chat Messages rows + completionRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getCompletionText(), + blankIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); + + chatRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getChatText(), + blankIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); + } else if (isOrgUnlimited) { + // Business / Enterprise with unlimited premium interactions: show informational message + MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_unlimitedOrgMessage, + handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); + } else if (premiumQuota != null) { + // Other paid plans: show only the Monthly limit row sourced from premium interactions + premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, calculator.getPremiumRequestsText(), + calculator.getPremiumRequestsTooltip(), usageIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); + } + // Paid plan with no premium-interactions quota yet: render nothing extra until the next refresh. + } finally { + gc.dispose(); + } + + // Allowance reset date + if (MenuUtils.shouldShowAllowanceResetRow(quotaStatus)) { + allowanceResetAction = MenuActionFactory.createMenuAction(menuManager, + MenuUtils.formatAllowanceReset(quotaStatus), + handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); + } + + // "Additional usage enabled" / "Additional usage not enabled" status row, shown for paid + // users with a bounded premium-interactions quota + if (hasNonOrgPremiumQuota) { + MenuActionFactory.createMenuAction(menuManager, MenuUtils.getAdditionalUsageRowLabel(premiumQuota), + MenuUtils.getAdditionalUsageRowTooltip(quotaStatus), null, handlerService, + "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); + } + + // Upsell actions based on the user's plan + ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); + + // For non-free users (excluding org-unlimited business/enterprise): + // show "Enable Additional Usage" or "Increase Budget" depending on overage state. + // The overage row uses the same predicate as the Monthly limit row. + if (hasNonOrgPremiumQuota) { + MenuActionFactory.createMenuAction(menuManager, MenuUtils.getOverageRowLabel(premiumQuota), upgradeIcon, + handlerService, UiConstants.OPEN_URL_COMMAND_ID, + Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), true); + } + + // For free / individual / individual_pro users, show an Upgrade Plan row. When the overage row is + // already showing the upgrade icon directly above, this row uses the blank icon to avoid duplication. + if (MenuUtils.shouldShowUpgradePlanRow(plan, quotaStatus.canUpgradePlan())) { + ImageDescriptor upgradePlanIcon = hasNonOrgPremiumQuota ? blankIcon : upgradeIcon; + MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_upgradePlan, upgradePlanIcon, handlerService, + "com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", true); + } + } + + // TODO: Remove this legacy fallback after TBB is officially released. + /** + * Renders the original main-branch Copilot usage rows. Used when the language server has not + * enabled token-based billing yet, in which case the new TBB-only APIs (premium interactions + * entitlement, overage budget UI, dynamic allowance-reset wording) are not relied on. + */ + private void addCopilotUsageActionLegacy(MenuManager menuManager, CheckQuotaResult quotaStatus) { + Quota completionsQuota = quotaStatus.completions(); + Quota chatQuota = quotaStatus.chat(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + CopilotPlan plan = quotaStatus.copilotPlan(); + // Calculate percentRemaining based on plan double percentRemaining; - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // For free plan, consider completions and chat quotas - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - quotaStatus.getChatQuota().getPercentRemaining()); - } else { - // For paid plans, also consider premium interactions quota - percentRemaining = Math.min(quotaStatus.getCompletionsQuota().getPercentRemaining(), - Math.min(quotaStatus.getChatQuota().getPercentRemaining(), - quotaStatus.getPremiumInteractionsQuota().getPercentRemaining())); - } - - ImageDescriptor icon; - // Set icon based on the lowest percentRemaining - if (percentRemaining <= 10) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); - } else if (percentRemaining > 10 && percentRemaining <= 25) { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); + if (plan == CopilotPlan.free) { + percentRemaining = Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); + } else if (premiumQuota != null) { + percentRemaining = Math.min(completionsQuota.percentRemaining(), + Math.min(chatQuota.percentRemaining(), premiumQuota.percentRemaining())); } else { - icon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); + percentRemaining = Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); } + ImageDescriptor icon = MenuUtils.getUsageIcon(percentRemaining); + ImageDescriptor blankIcon = MenuUtils.getBlankIcon(); + Map<String, String> parameters = Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_URL); MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_copilotUsage, - Messages.menu_quota_manageCopilotTooltip, icon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, parameters, - true); + Messages.menu_quota_manageCopilotTooltip, icon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, + parameters, true); GC gc = new GC(PlatformUI.getWorkbench().getDisplay()); QuotaTextCalculator calculator = new QuotaTextCalculator(gc, quotaStatus); try { - // Premium requests usage when rest plans are unlimited - if (quotaStatus.getCopilotPlan() != CopilotPlan.free && quotaStatus.getCompletionsQuota().isUnlimited() - && quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); - premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, premiumRequestsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, + // Premium requests row first when both completion/chat quotas are unlimited. + if (plan != CopilotPlan.free && premiumQuota != null && completionsQuota.unlimited() && chatQuota.unlimited()) { + premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, calculator.getPremiumText(), + blankIcon, handlerService, "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); } // Code completions usage - String codeCompletionsText = calculator.getCompletionText(); - completionRemainingAction = MenuActionFactory.createMenuAction(menuManager, codeCompletionsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, + completionRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getCompletionText(), + blankIcon, handlerService, "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); // Chat messages usage - String chatMessagesText = calculator.getChatText(); - chatRemainingAction = MenuActionFactory.createMenuAction(menuManager, chatMessagesText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, + chatRemainingAction = MenuActionFactory.createMenuAction(menuManager, calculator.getChatText(), + blankIcon, handlerService, "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); - // Premium requests usage - if (quotaStatus.getCopilotPlan() != CopilotPlan.free) { - // Premium requests usage when either of the rest plans is not unlimited - if (!quotaStatus.getCompletionsQuota().isUnlimited() || !quotaStatus.getChatQuota().isUnlimited()) { - String premiumRequestsText = calculator.getPremiumText(); - premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, premiumRequestsText, - UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"), handlerService, + // Premium requests usage / additional-paid status for non-free plans. + if (plan != CopilotPlan.free && premiumQuota != null) { + if (!completionsQuota.unlimited() || !chatQuota.unlimited()) { + premiumRequestsAction = MenuActionFactory.createMenuAction(menuManager, calculator.getPremiumText(), + blankIcon, handlerService, "com.microsoft.copilot.eclipse.commands.enabledDoNothing", true); } MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_additionalPremiumRequests - + (quotaStatus.getPremiumInteractionsQuota().isOveragePermitted() ? Messages.menu_quota_enabled - : Messages.menu_quota_disabled), + + (premiumQuota.overagePermitted() ? Messages.menu_quota_enabled : Messages.menu_quota_disabled), handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); } } finally { gc.dispose(); } - // Allowance reset date - if (!StringUtils.isEmpty(quotaStatus.getResetDate())) { - LocalDate resetDate = LocalDate.parse(quotaStatus.getResetDate()); + // Allowance reset date (legacy: simple "Allowance resets <date>" string). + if (!StringUtils.isEmpty(quotaStatus.resetDate())) { + LocalDate resetDate = LocalDate.parse(quotaStatus.resetDate()); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_allowanceReset + resetDate.format(formatter), handlerService, "com.microsoft.copilot.eclipse.commands.disabledDoNothing", false); } - // Upsell actions based on the user's plan + // Upsell actions based on the user's plan (legacy wording). ImageDescriptor upgradeIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/quota/upgrade.png"); - if (quotaStatus.getCopilotPlan() == CopilotPlan.free) { - // If the user is on a free plan, show a link to upgrade. + if (plan == CopilotPlan.free) { MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_updateCopilotToPro, upgradeIcon, handlerService, "com.microsoft.copilot.eclipse.commands.upgradeCopilotPlan", true); - } else if (quotaStatus.getCopilotPlan() != CopilotPlan.business - && quotaStatus.getCopilotPlan() != CopilotPlan.enterprise) { - // If the user is not on a free plan / business plan / enterprise plan, show a link to manage subscription. + } else if (plan != CopilotPlan.business && plan != CopilotPlan.enterprise) { MenuActionFactory.createMenuAction(menuManager, Messages.menu_quota_managePaidPremiumRequests, upgradeIcon, handlerService, UiConstants.OPEN_URL_COMMAND_ID, Map.of(UiConstants.OPEN_URL_PARAMETER_NAME, UiConstants.MANAGE_COPILOT_OVERAGE_URL), true); @@ -469,9 +578,8 @@ public static Action createMenuAction(MenuManager menuManager, String text, Stri } private static void setDefaultBlankIcon(Action action) { - ImageDescriptor blankIcon = UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"); if (PlatformUtils.isMac()) { - action.setImageDescriptor(blankIcon); + action.setImageDescriptor(MenuUtils.getBlankIcon()); } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index 1fef07da..642b71b1 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -10,14 +10,6 @@ */ public final class Messages extends NLS { private static final String BUNDLE_NAME = "com.microsoft.copilot.eclipse.ui.i18n.messages"; //$NON-NLS-1$ - public static String menu_copilotStatus; - public static String menu_copilotStatus_ready; - public static String menu_copilotStatus_loading; - public static String menu_copilotStatus_completionInProgress; - public static String menu_copilotStatus_notSignedInToGitHub; - public static String menu_copilotStatus_unknownError; - public static String menu_copilotStatus_notAuthorized; - public static String menu_copilotStatus_agentWarning; public static String menu_signToGitHub; public static String menu_signOutOfGitHub; public static String menu_configureGitHubCopilotSettings; @@ -31,12 +23,40 @@ public final class Messages extends NLS { public static String menu_quota_copilotUsage; public static String menu_quota_codeCompletions; public static String menu_quota_chatMessages; + public static String menu_quota_monthlyLimit; + public static String menu_quota_includedCredits; + public static String menu_quota_includedCreditsTooltip; + public static String menu_quota_monthlyLimitTooltip; + public static String menu_quota_percentUsedFormat; + public static String menu_quota_aiCreditsUsedFormat; + public static String menu_quota_included; + public static String menu_userPlanFormat; + public static String menu_quota_noUsageYet; + public static String menu_quota_allowanceReset_today; + public static String menu_quota_allowanceReset_singular; + public static String menu_quota_allowanceReset_plural; + public static String menu_quota_manageCopilotTooltip; + public static String menu_quota_enableAdditionalUsage; + public static String menu_quota_increaseBudget; + public static String menu_quota_upgradePlan; + public static String menu_quota_unlimitedOrgMessage; + public static String menu_quota_plan_free; + public static String menu_quota_plan_individual; + public static String menu_quota_plan_individualPro; + public static String menu_quota_plan_individualMax; + public static String menu_quota_plan_business; + public static String menu_quota_plan_enterprise; + // TODO: Remove these legacy keys after TBB is officially released. + // Legacy quota keys retained for installations where token-based billing is not yet enabled by the + // language server. When tokenBasedBillingEnabled is false on the CheckQuotaResult the menu + // handlers fall back to these strings to preserve the original main-branch UI. public static String menu_quota_premiumRequests; public static String menu_quota_additionalPremiumRequests; public static String menu_quota_enabled; public static String menu_quota_disabled; + public static String menu_quota_additionalUsageOrgEnabledTooltip; + public static String menu_quota_additionalUsageOrgNotConfiguredTooltip; public static String menu_quota_allowanceReset; - public static String menu_quota_manageCopilotTooltip; public static String menu_quota_updateCopilotToPro; public static String menu_quota_updateCopilotToProPlus; public static String menu_quota_managePaidPremiumRequests; @@ -58,22 +78,17 @@ public final class Messages extends NLS { public static String signInHandler_msgDialog_signInSuccess; public static String signInHandler_msgDialog_signInFailed; public static String signInHandler_msgDialog_signInFailedTryAgain; - public static String signInHandler_msgDialog_signInFailedFailure; public static String signOutHandler_msgDialog_githubCopilot; public static String signOutHandler_msgDialog_signOutSuccess; public static String signOutHandler_msgDialog_signOutFailed; public static String signOutHandler_msgDialog_signOutFailedFailure; - public static String chat_topBanner_mcpRegistry_Tooltip; - public static String chat_topBanner_newConversationButton_Tooltip; public static String chat_topBanner_defaultChatTitle; - public static String chat_topBanner_chatHistoryButton_Tooltip; public static String chat_topBanner_chatHistoryItem_newChat; public static String chat_topBanner_chatHistoryItem_newChatTime_Now; public static String chat_topBanner_chatHistoryItem_untitledConversation_placeholder; public static String chat_topBanner_chatHistoryItem_currentConversation_label; public static String chat_actionBar_initialContent; public static String chat_actionBar_initialContentForAgent; - public static String chat_actionBar_attachContextButton_Tooltip; public static String chat_actionBar_sendButton_Tooltip; public static String chat_actionBar_sendToJobButton_Tooltip; public static String chat_actionBar_sendToJob_noProject; @@ -126,15 +141,18 @@ public final class Messages extends NLS { public static String chat_addContext_tooltip; public static String chat_filePicker_title; public static String chat_filePicker_message; + public static String chat_noQuotaView_updatePlanButton_Tooltip; + public static String chat_noQuotaView_enableAdditionalUsageButton_tooltip; + + // TODO: Remove these legacy keys after TBB is officially released. + // Legacy quota-warning keys used when tokenBasedBillingEnabled is false on the CheckQuotaResult, + // in which case the chat warning falls back to the original main-branch behavior. public static String chat_noQuotaView_fallbackModel; public static String chat_noQuotaView_updatePlanButton; - public static String chat_noQuotaView_updatePlanButton_Tooltip; public static String chat_noQuotaView_updatePlanLink; - public static String chat_noQuotaView_enablePremiumRequestsButton; - public static String chat_noQuotaView_enablePremiumRequestsButton_tooltip; - public static String chat_noQuotaView_enablePremiumRequestsLink; public static String chat_noQuotaView_proProplusWarnMsg; public static String chat_noQuotaView_cbCeWarnMsg; + public static String chat_currentReferencedFile_description; public static String chat_turnWidget_copilot; public static String chat_turnWidget_user; @@ -145,7 +163,6 @@ public final class Messages extends NLS { public static String chat_customModels; public static String chat_addPremiumModels; public static String chat_referencedFile_noVision_tooltip; - public static String agent_tool_terminal_copilotTerminalTitle; public static String agent_tool_compareEditor_titlePrefix; public static String agent_tool_compareEditor_proposedChangesTitle; public static String agentFileEditor_contentAssist_statusMessage; @@ -166,7 +183,6 @@ public final class Messages extends NLS { public static String generateCommitMessage_noRepo_message; public static String generateCommitMessage_noStagedFiles_title; public static String generateCommitMessage_noStagedFiles_message; - public static String newChat_cancelButton; public static String addToReference_addFile_title; public static String addToReference_addFolder_title; public static String chat_historyView_backButton; @@ -177,16 +193,27 @@ public final class Messages extends NLS { public static String relative_dateFormat_weeksAgo; public static String relative_dateFormat_oneMonthAgo; public static String relative_dateFormat_monthsAgo; - public static String chat_historyView_textTruncation_ellipsis; public static String chat_historyView_enterIcon_tooltip; public static String chat_historyView_editIcon_tooltip; public static String chat_historyView_deleteIcon_tooltip; public static String model_billing_multiplier_suffix; public static String model_billing_multiplier_variable; - public static String model_tooltip_quota; - public static String model_hover_family; + public static String model_preview_suffix; + public static String model_hover_contextWindow; public static String model_hover_cost; - public static String model_hover_cost_premium; + public static String model_hover_thinkingEffort; + public static String model_hover_thinkingEffort_default_suffix; + public static String model_hover_customModelInfo; + public static String model_reasoningEffort_none; + public static String model_reasoningEffort_low; + public static String model_reasoningEffort_medium; + public static String model_reasoningEffort_high; + public static String model_reasoningEffort_xhigh; + public static String model_reasoningEffort_none_description; + public static String model_reasoningEffort_low_description; + public static String model_reasoningEffort_medium_description; + public static String model_reasoningEffort_high_description; + public static String model_reasoningEffort_xhigh_description; public static String chat_actionBar_modePicker_Tooltip; public static String chat_actionBar_modelPicker_Tooltip; public static String context_window_title; @@ -198,7 +225,7 @@ public final class Messages extends NLS { public static String context_window_messages; public static String context_window_files; public static String context_window_tool_results; - public static String chat_staticBanner_messageWithLink; + public static String chat_compacting_conversation; public static String chat_rateLimitBanner_getMoreInfo; public static String chat_rateLimitBanner_closeTooltip; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 86484f7a..bb6b4400 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -1,11 +1,3 @@ -menu_copilotStatus=Status -menu_copilotStatus_ready=Ready -menu_copilotStatus_loading=Loading -menu_copilotStatus_completionInProgress=Copilot completing in progress -menu_copilotStatus_notSignedInToGitHub=Not signed in to GitHub -menu_copilotStatus_unknownError=Unknown error -menu_copilotStatus_notAuthorized=No access to GitHub Copilot -menu_copilotStatus_agentWarning=Copilot is encountering temporary issues menu_signToGitHub=Sign In to GitHub menu_signOutOfGitHub=Sign Out of GitHub menu_configureGitHubCopilotSettings=Activate GitHub Copilot Account... @@ -20,14 +12,41 @@ menu_quota_copilotUsage=Copilot Usage menu_quota_manageCopilotTooltip=Manage Copilot menu_quota_codeCompletions=Code Completions menu_quota_chatMessages=Chat Messages +menu_quota_monthlyLimit=Monthly Limit +menu_quota_includedCredits=Included Credits +menu_quota_includedCreditsTooltip=AI credits included with your plan, reset monthly. Enable additional usage to continue with pay-as-you-go credits once you run out of your included usage. +menu_quota_monthlyLimitTooltip=Usage limit per month, resets automatically. AI credit usage counts toward your monthly limit. Ask your account manager about increasing your overage when monthly limit is hit. +menu_quota_percentUsedFormat={0}% used +menu_quota_aiCreditsUsedFormat={0}/{1} AI credits used +menu_quota_included=Included +menu_userPlanFormat={0} - {1} +menu_quota_noUsageYet=No usage yet +menu_quota_allowanceReset_today=Resets today +menu_quota_allowanceReset_singular=Reset in 1 day on {0} +menu_quota_allowanceReset_plural=Reset in {0} days on {1} +menu_quota_enableAdditionalUsage=Enable Additional Usage +menu_quota_increaseBudget=Increase Budget +menu_quota_upgradePlan=Upgrade Plan +menu_quota_unlimitedOrgMessage=You have no monthly limit on AI credits usage set by your organization +menu_quota_plan_free=Copilot Free Plan +menu_quota_plan_individual=Copilot Pro Plan +menu_quota_plan_individualPro=Copilot Pro+ Plan +menu_quota_plan_individualMax=Copilot Max Plan +menu_quota_plan_business=Business Plan +menu_quota_plan_enterprise=Enterprise Plan + +# TODO: Remove these legacy strings after TBB is officially released. +# Legacy quota strings used when the language server has not enabled token-based billing yet. menu_quota_premiumRequests=Premium Requests -menu_quota_additionalPremiumRequests=Additional paid premium requests -menu_quota_enabled=enabled. -menu_quota_disabled=disabled. +menu_quota_additionalPremiumRequests=Additional usage +menu_quota_enabled=enabled +menu_quota_disabled=not enabled +menu_quota_additionalUsageOrgEnabledTooltip=Usage will continue until limits are reset. +menu_quota_additionalUsageOrgNotConfiguredTooltip=Usage will pause if the monthly usage limit is reached. Request additional usage from your administrator. menu_quota_allowanceReset=Allowance resets menu_quota_updateCopilotToPro=Upgrade to Copilot Pro menu_quota_updateCopilotToProPlus=Upgrade to Copilot Pro+ -menu_quota_managePaidPremiumRequests=Manage paid premium requests +menu_quota_managePaidPremiumRequests=Manage Premium Requests signInDialog_title=Sign In to GitHub signInDialog_button_cancel=Cancel @@ -47,23 +66,18 @@ signInHandler_msgDialog_alreadySignedIn=User already signed in. signInHandler_msgDialog_signInSuccess=You have successfully signed in and authorized GitHub Copilot access to your GitHub account. signInHandler_msgDialog_signInFailed=Unable to sign in to GitHub Copilot at this time signInHandler_msgDialog_signInFailedTryAgain= Please try again to resume use of GitHub Copilot features. -signInHandler_msgDialog_signInFailedFailure=Copilot Sign In Failure signOutHandler_msgDialog_githubCopilot=GitHub Copilot signOutHandler_msgDialog_signOutSuccess=You have successfully signed out from Copilot. signOutHandler_msgDialog_signOutFailed=Unable to sign out to GitHub Copilot at this time signOutHandler_msgDialog_signOutFailedFailure=Copilot Sign Out Failure -chat_topBanner_mcpRegistry_Tooltip=MCP Registry -chat_topBanner_newConversationButton_Tooltip=New Chat chat_topBanner_defaultChatTitle=New Chat -chat_topBanner_chatHistoryButton_Tooltip=Chat History chat_topBanner_chatHistoryItem_newChat=New Chat chat_topBanner_chatHistoryItem_newChatTime_Now=Now chat_topBanner_chatHistoryItem_untitledConversation_placeholder=Untitled Conversation chat_topBanner_chatHistoryItem_currentConversation_label=(Current) chat_actionBar_initialContent=Ask Copilot chat_actionBar_initialContentForAgent=Edit files in your workspace in agent mode -chat_actionBar_attachContextButton_Tooltip=Attach Context chat_actionBar_sendButton_Tooltip=Send chat_actionBar_sendToJobButton_Tooltip=Delegate to coding agent chat_actionBar_sendToJob_noProject=No GitHub repository was found in the workspace. Please open a project that contains a Git repository to use the coding agent. @@ -108,15 +122,17 @@ chat_agentModeView_configureMcpSuffix=to configure MCP server chat_agentModeView_attachContextSuffix=to attach context chat_loadingView_title=Loading chat_loadingView_description=Initializing Copilot... +chat_noQuotaView_updatePlanButton_Tooltip=Upgrade your Copilot plan +chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend. + +# TODO: Remove these legacy strings after TBB is officially released. +# Legacy quota-warning strings used when the language server has not enabled token-based billing yet. chat_noQuotaView_fallbackModel=fallback model chat_noQuotaView_updatePlanButton=Upgrade to Copilot Pro -chat_noQuotaView_updatePlanButton_Tooltip=Update your plan to Copilot Pro chat_noQuotaView_updatePlanLink=https://aka.ms/github-copilot-upgrade-plan -chat_noQuotaView_enablePremiumRequestsButton=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsButton_tooltip=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsLink=https://aka.ms/github-copilot-manage-overage chat_noQuotaView_proProplusWarnMsg=You have exceeded your premium request allowance. We have automatically switched you to **%s** which is included with your plan. [Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models. chat_noQuotaView_cbCeWarnMsg=You have exceeded your free request allowance. We have automatically switched you to **%s** which is included with your plan. To enable additional paid premium requests, contact your organization admin. + chat_noAuthView_title=No Copilot subscription found chat_noAuthView_description=Request a license from your organization manager or sign up for a 60 day trial chat_noAuthView_checkSubButton=Check subscription plans @@ -130,12 +146,12 @@ relative_dateFormat_oneWeekAgo=1 week ago relative_dateFormat_weeksAgo= {0} weeks ago relative_dateFormat_oneMonthAgo=1 month ago relative_dateFormat_monthsAgo= {0} months ago -chat_historyView_textTruncation_ellipsis=... chat_historyView_enterIcon_tooltip=Apply title changes chat_historyView_editIcon_tooltip=Edit conversation title chat_historyView_deleteIcon_tooltip=Delete conversation model_billing_multiplier_suffix=x model_billing_multiplier_variable=Variable +model_preview_suffix=(Preview) chat_addContext_tooltip=Add Context... chat_filePicker_title=Search attachments chat_filePicker_message=Choose files from the list, or search for files: @@ -150,7 +166,6 @@ chat_customModels=Custom Models chat_addPremiumModels=Add Premium Models chat_referencedFile_noVision_tooltip=%s does not support images. -agent_tool_terminal_copilotTerminalTitle=Copilot agent_tool_compareEditor_titlePrefix=Changes from GitHub Copilot: agent_tool_compareEditor_proposedChangesTitle=Proposed Changes @@ -175,14 +190,24 @@ generateCommitMessage_noRepo_message=Cannot generate commit message: No reposito generateCommitMessage_noStagedFiles_title=No Staged Files generateCommitMessage_noStagedFiles_message=There are no staged files to generate a commit message from. -newChat_cancelButton=Cancel addToReference_addFile_title=Add File to Chat addToReference_addFolder_title=Add Folder to Chat -model_tooltip_quota=Each chat message counts %s towards your request quota -model_hover_family=Family: -model_hover_cost=Cost: -model_hover_cost_premium=premium +model_hover_contextWindow=Context Window: +model_hover_cost=Cost: +model_hover_thinkingEffort=Thinking Effort +model_hover_thinkingEffort_default_suffix={0} (default) +model_hover_customModelInfo={0} is contributed by {1} using {2} +model_reasoningEffort_none=None +model_reasoningEffort_low=Low +model_reasoningEffort_medium=Medium +model_reasoningEffort_high=High +model_reasoningEffort_xhigh=Extra High +model_reasoningEffort_none_description=No extended reasoning +model_reasoningEffort_low_description=Faster responses, less thorough reasoning +model_reasoningEffort_medium_description=Balanced reasoning and speed +model_reasoningEffort_high_description=Deeper reasoning, slower responses +model_reasoningEffort_xhigh_description=Maximum reasoning effort chat_actionBar_modePicker_Tooltip=Set Agents chat_actionBar_modelPicker_Tooltip=Pick Model{0} context_window_title=Context Window @@ -194,7 +219,7 @@ context_window_user_context=User Context context_window_messages=Messages context_window_files=Attached Files context_window_tool_results=Tool Results +chat_compacting_conversation=Compacting conversation... -chat_staticBanner_messageWithLink={0} <a>{1}</a> chat_rateLimitBanner_getMoreInfo=Get more info chat_rateLimitBanner_closeTooltip=Dismiss diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java new file mode 100644 index 00000000..cbaa82c1 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddFileOperationRuleDialog.java @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a file-operation auto-approve rule with pattern, description, and allow/deny. + */ +public class AddFileOperationRuleDialog extends Dialog { + + private Text patternText; + private Text descriptionText; + private Button allowRadio; + + private String pattern; + private String description; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddFileOperationRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Pattern * + Label patternLabel = new Label(container, SWT.NONE); + patternLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern + + " *"); + patternText = new Text(container, SWT.BORDER); + GridData patternData = new GridData(SWT.FILL, SWT.CENTER, true, false); + patternData.widthHint = 300; + patternText.setLayoutData(patternData); + patternText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_pattern_hint); + patternText.addModifyListener(e -> updateOkButton()); + + // Description + Label descLabel = new Label(container, SWT.NONE); + descLabel.setText( + Messages.preferences_page_file_op_auto_approve_add_dialog_description); + descriptionText = new Text(container, SWT.BORDER); + descriptionText.setMessage( + Messages.preferences_page_file_op_auto_approve_add_dialog_description_hint); + descriptionText.setLayoutData( + new GridData(SWT.FILL, SWT.CENTER, true, false)); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled( + patternText != null && !patternText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + pattern = patternText.getText().trim(); + description = descriptionText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getPattern() { + return pattern; + } + + public String getDescription() { + return description; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java new file mode 100644 index 00000000..aa4af5de --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AddTerminalRuleDialog.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Dialog for adding a terminal auto-approve rule with command and allow/deny. + */ +public class AddTerminalRuleDialog extends Dialog { + + private Text commandText; + private Button allowRadio; + + private String command; + private boolean autoApprove; + + /** + * Creates the dialog. + * + * @param parent the parent shell + */ + public AddTerminalRuleDialog(Shell parent) { + super(parent); + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText(Messages.preferences_page_terminal_auto_approve_add_dialog_title); + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite container = new Composite(area, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // Command * + Label commandLabel = new Label(container, SWT.NONE); + commandLabel.setText( + Messages.preferences_page_terminal_auto_approve_add_dialog_command + " *"); + commandText = new Text(container, SWT.BORDER); + GridData commandData = new GridData(SWT.FILL, SWT.CENTER, true, false); + commandData.widthHint = 300; + commandText.setLayoutData(commandData); + commandText.setMessage(Messages.preferences_page_terminal_auto_approve_add_dialog_placeholder); + commandText.addModifyListener(e -> updateOkButton()); + + // Allow / Deny + Label approveLabel = new Label(container, SWT.NONE); + approveLabel.setText( + Messages.preferences_page_auto_approve_add_dialog_approve); + Composite radioGroup = new Composite(container, SWT.NONE); + radioGroup.setLayout(new GridLayout(2, false)); + allowRadio = new Button(radioGroup, SWT.RADIO); + allowRadio.setText(Messages.preferences_page_auto_approve_allow); + allowRadio.setSelection(true); + Button denyRadio = new Button(radioGroup, SWT.RADIO); + denyRadio.setText(Messages.preferences_page_auto_approve_deny); + + return area; + } + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + updateOkButton(); + } + + private void updateOkButton() { + Button ok = getButton(OK); + if (ok != null) { + ok.setEnabled(commandText != null && !commandText.getText().trim().isEmpty()); + } + } + + @Override + protected void okPressed() { + command = commandText.getText().trim(); + autoApprove = allowRadio.getSelection(); + super.okPressed(); + } + + public String getCommand() { + return command; + } + + public boolean isAutoApprove() { + return autoApprove; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java new file mode 100644 index 00000000..fb0be2c0 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/AutoApprovePreferencePage.java @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferencePage; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.chat.services.McpConfigService; + +/** + * Auto-Approve preference page for terminal and file operation auto-approval rules. + */ +public class AutoApprovePreferencePage extends PreferencePage + implements IWorkbenchPreferencePage { + + public static final String ID = + "com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage"; + + private TerminalAutoApproveSection terminalSection; + private FileOperationAutoApproveSection fileOperationSection; + private McpAutoApproveSection mcpSection; + private GlobalAutoApproveSection globalSection; + + @Override + public void init(IWorkbench workbench) { + setPreferenceStore(CopilotUi.getPlugin().getPreferenceStore()); + noDefaultAndApplyButton(); + } + + @Override + protected Control createContents(Composite parent) { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + if (flags != null && !flags.isAutoApprovalEnabled()) { + return WrappableIconLink.createWithSharedImage(parent, + PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_INFO_TSK), + Messages.preferences_page_auto_approve_disabled_by_organization); + } + + // Wrap the sections in a height-capped ScrolledComposite. The override on + // computeSize bounds the height reported to the dialog's PageLayout so the + // Preferences shell stays stable; the real (taller) content scrolls here. + // This scroller is also the parent the section tables forward wheel events + // to (see forwardVerticalMouseWheelToParentScrollerAtBoundary). + ScrolledComposite scrolled = new ScrolledComposite(parent, SWT.V_SCROLL) { + @Override + public Point computeSize(int widthHint, int heightHint, boolean changed) { + Point size = super.computeSize(widthHint, heightHint, changed); + size.y = Math.min(size.y, PreferencePageUtils.STANDARD_CONTENT_HEIGHT); + return size; + } + }; + scrolled.setExpandHorizontal(true); + scrolled.setExpandVertical(true); + scrolled.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + Composite root = new Composite(scrolled, SWT.NONE); + GridLayout layout = new GridLayout(1, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + root.setLayout(layout); + + IPreferenceStore store = getPreferenceStore(); + terminalSection = new TerminalAutoApproveSection(root, SWT.NONE); + terminalSection.loadFromPreferences(store); + + fileOperationSection = new FileOperationAutoApproveSection(root, SWT.NONE); + fileOperationSection.loadFromPreferences(store); + + mcpSection = new McpAutoApproveSection(root, SWT.NONE); + mcpSection.loadFromPreferences(store); + bindMcpConfigService(); + + globalSection = new GlobalAutoApproveSection(root, SWT.NONE); + globalSection.loadFromPreferences(store); + + scrolled.setContent(root); + scrolled.setMinSize(root.computeSize(SWT.DEFAULT, SWT.DEFAULT)); + // Track width so wrapping labels reflow and only vertical scrolling occurs. + scrolled.addListener(SWT.Resize, e -> { + int width = scrolled.getClientArea().width; + scrolled.setMinSize(root.computeSize(width, SWT.DEFAULT)); + }); + + scrolled.addDisposeListener(e -> unbindMcpConfigService()); + + return scrolled; + } + + @Override + public boolean performOk() { + if (terminalSection == null) { + return true; + } + IPreferenceStore store = getPreferenceStore(); + terminalSection.saveToPreferences(store); + fileOperationSection.saveToPreferences(store); + mcpSection.saveToPreferences(store); + globalSection.saveToPreferences(store); + return true; + } + + private void bindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.bindWithAutoApproveSection(mcpSection); + } + } + } + + private void unbindMcpConfigService() { + ChatServiceManager chatServiceManager = + CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpConfigService mcpConfigService = + chatServiceManager.getMcpConfigService(); + if (mcpConfigService != null) { + mcpConfigService.unbindWithAutoApproveSection(); + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ByokPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ByokPreferencePage.java index 76450a6d..1392a529 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ByokPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ByokPreferencePage.java @@ -178,7 +178,7 @@ protected Control createContents(Composite parent) { disabledCompositeLayout.marginHeight = 0; disabledComposite.setLayout(disabledCompositeLayout); WrappableIconLink.createWithCustomizedImage(disabledComposite, "/icons/information.png", - Messages.preferences_page_byok_preview_disabled_tip); + Messages.preferences_page_byok_disabled_tip); contentComposite = createByokView(pageStateStack); updatePageState(); root.addDisposeListener(e -> { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java index f88283cb..11a791ef 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java @@ -35,6 +35,7 @@ */ public class ChatPreferencesPage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { public static final String ID = "com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage"; + private static final int FIELD_WIDTH_HINT = 400; /** * Constructor. @@ -50,40 +51,18 @@ public void createFieldEditors() { GridDataFactory gdf = GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, false); - Composite workspaceContextComposite = new Composite(parent, SWT.NONE); - workspaceContextComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(workspaceContextComposite); + Composite workspaceContextComposite = createSectionComposite(parent, gdf); BooleanFieldEditor workspaceContextField = new BooleanFieldEditor(Constants.WORKSPACE_CONTEXT_ENABLED, - Messages.preferences_page_watched_files, SWT.WRAP, - workspaceContextComposite); - GridData workspaceContextFieldGridData = new GridData(SWT.FILL, SWT.FILL, true, true); - workspaceContextFieldGridData.widthHint = 400; - workspaceContextField.getDescriptionControl(workspaceContextComposite).setLayoutData(workspaceContextFieldGridData); - + Messages.preferences_page_watched_files, SWT.WRAP, workspaceContextComposite); + applyFieldWidthHint(workspaceContextField, workspaceContextComposite); addField(workspaceContextField); - // add chat note using WrappableNoteLabel - WrappableNoteLabel workspaceContextNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_watched_files_note_content); - GridData workspaceContextNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - workspaceContextNoteGridData.horizontalSpan = 2; - workspaceContextNote.setLayoutData(workspaceContextNoteGridData); - - // add separator - Label separator = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); - GridData separatorGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - separatorGridData.horizontalSpan = 2; - separator.setLayoutData(separatorGridData); + addNote(parent, Messages.preferences_page_watched_files_note_content); + addSeparator(parent); // Add sub-agent toggle - Composite subAgentComposite = new Composite(parent, SWT.NONE); - subAgentComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(subAgentComposite); - // Check if sub-agent is disabled by policy - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); + Composite subAgentComposite = createSectionComposite(parent, gdf); + boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); if (!policyAllowsSubAgent) { Composite disabledComposite = new Composite(subAgentComposite, SWT.NONE); GridLayout disabledCompositeLayout = new GridLayout(1, false); @@ -98,29 +77,27 @@ public void createFieldEditors() { BooleanFieldEditor subAgentField = new BooleanFieldEditor(Constants.SUB_AGENT_ENABLED, Messages.preferences_page_sub_agent, SWT.WRAP, subAgentComposite); subAgentField.setEnabled(policyAllowsSubAgent, subAgentComposite); - GridData subAgentFieldGridData = new GridData(SWT.FILL, SWT.FILL, true, true); - subAgentFieldGridData.widthHint = 400; - subAgentField.getDescriptionControl(subAgentComposite).setLayoutData(subAgentFieldGridData); + applyFieldWidthHint(subAgentField, subAgentComposite); addField(subAgentField); - // add sub-agent note using WrappableNoteLabel - WrappableNoteLabel subAgentNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_sub_agent_note_content); - GridData subAgentNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - subAgentNoteGridData.horizontalSpan = 2; - subAgentNote.setLayoutData(subAgentNoteGridData); + addNote(parent, Messages.preferences_page_sub_agent_note_content); + addSeparator(parent); - // add separator - Label separator2 = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); - GridData separator2GridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - separator2GridData.horizontalSpan = 2; - separator2.setLayoutData(separator2GridData); + if (isClientPreviewFeatureEnabled()) { + // Add Enable Skills toggle + Composite skillsComposite = createSectionComposite(parent, gdf); + + BooleanFieldEditor skillsField = new BooleanFieldEditor(Constants.ENABLE_SKILLS, + Messages.preferences_page_skills_enabled, SWT.WRAP, skillsComposite); + applyFieldWidthHint(skillsField, skillsComposite); + addField(skillsField); + + addNote(parent, Messages.preferences_page_skills_enabled_note_content); + addSeparator(parent); + } // Add Agent Max Requests field - Composite agentMaxRequestsComposite = new Composite(parent, SWT.NONE); - agentMaxRequestsComposite.setLayout(new GridLayout(1, true)); - gdf.applyTo(agentMaxRequestsComposite); + Composite agentMaxRequestsComposite = createSectionComposite(parent, gdf); IntegerFieldEditor agentMaxRequestsField = new IntegerFieldEditor(Constants.AGENT_MAX_REQUESTS, Messages.preferences_page_agent_max_requests, agentMaxRequestsComposite); @@ -128,12 +105,7 @@ public void createFieldEditors() { agentMaxRequestsField.setErrorMessage(Messages.preferences_page_agent_max_requests_validation_error); addField(agentMaxRequestsField); - WrappableNoteLabel agentMaxRequestsNote = new WrappableNoteLabel(parent, - Messages.preferences_page_note_prefix + " ", - Messages.preferences_page_agent_max_requests_desc); - GridData agentMaxRequestsNoteGridData = new GridData(SWT.FILL, SWT.CENTER, true, false); - agentMaxRequestsNoteGridData.horizontalSpan = 2; - agentMaxRequestsNote.setLayoutData(agentMaxRequestsNoteGridData); + addNote(parent, Messages.preferences_page_agent_max_requests_desc); } @Override @@ -142,11 +114,7 @@ public void init(IWorkbench workbench) { // Ensure run_subagent tool configuration is consistent with sub-agent preference // Only check if sub-agent is policy-enabled - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); - - if (policyAllowsSubAgent) { + if (isPolicyAllowsSubAgent()) { boolean subAgentEnabled = getPreferenceStore().getBoolean(Constants.SUB_AGENT_ENABLED); updateSubAgentToolConfiguration(subAgentEnabled); } @@ -157,9 +125,7 @@ public boolean performOk() { final boolean oldWorkspaceContextValue = getPreferenceStore().getBoolean(Constants.WORKSPACE_CONTEXT_ENABLED); // Check if sub-agent is policy-enabled before handling sub-agent preferences - FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); - boolean policyAllowsSubAgent = flags != null && flags.isClientPreviewFeatureEnabled() - && flags.isSubAgentPolicyEnabled(); + boolean policyAllowsSubAgent = isPolicyAllowsSubAgent(); boolean oldSubAgentValue = false; if (policyAllowsSubAgent) { @@ -190,8 +156,7 @@ public boolean performOk() { } if (isSubAgentChanged || isWorkspaceContextChanged) { - boolean restart = MessageDialog.openQuestion(getShell(), - Messages.preferences_page_restart_required, + boolean restart = MessageDialog.openQuestion(getShell(), Messages.preferences_page_restart_required, Messages.preferences_page_restart_question); if (restart) { @@ -204,6 +169,43 @@ public boolean performOk() { return result; } + private Composite createSectionComposite(Composite parent, GridDataFactory gdf) { + Composite composite = new Composite(parent, SWT.NONE); + composite.setLayout(new GridLayout(1, true)); + gdf.applyTo(composite); + return composite; + } + + private void applyFieldWidthHint(BooleanFieldEditor field, Composite parent) { + GridData gridData = new GridData(SWT.FILL, SWT.FILL, true, true); + gridData.widthHint = FIELD_WIDTH_HINT; + field.getDescriptionControl(parent).setLayoutData(gridData); + } + + private void addNote(Composite parent, String noteContent) { + WrappableNoteLabel note = new WrappableNoteLabel(parent, Messages.preferences_page_note_prefix + " ", noteContent); + GridData gridData = new GridData(SWT.FILL, SWT.CENTER, true, false); + gridData.horizontalSpan = 2; + note.setLayoutData(gridData); + } + + private void addSeparator(Composite parent) { + Label separator = new Label(parent, SWT.SEPARATOR | SWT.HORIZONTAL); + GridData gridData = new GridData(SWT.FILL, SWT.CENTER, true, false); + gridData.horizontalSpan = 2; + separator.setLayoutData(gridData); + } + + private boolean isPolicyAllowsSubAgent() { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + return isClientPreviewFeatureEnabled() && flags != null && flags.isSubAgentPolicyEnabled(); + } + + private boolean isClientPreviewFeatureEnabled() { + FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); + return flags != null && flags.isClientPreviewFeatureEnabled(); + } + /** * Updates the MCP tool configuration to include or exclude the run_subagent tool for agent mode based on the * sub-agent preference setting. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 0fe32ff0..6b43717c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -3,13 +3,17 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import com.google.gson.Gson; import org.eclipse.core.runtime.preferences.AbstractPreferenceInitializer; import org.eclipse.core.runtime.preferences.ConfigurationScope; import org.eclipse.core.runtime.preferences.IEclipsePreferences; import org.eclipse.jface.preference.IPreferenceStore; import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; /** * A class to initialize the default preferences for the plugin. @@ -27,10 +31,13 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.PROXY_KERBEROS_SP, ""); pref.setDefault(Constants.GITHUB_ENTERPRISE, ""); pref.setDefault(Constants.WORKSPACE_CONTEXT_ENABLED, false); - pref.setDefault(Constants.SUB_AGENT_ENABLED, false); + pref.setDefault(Constants.SUB_AGENT_ENABLED, true); pref.setDefault(Constants.AGENT_MAX_REQUESTS, 25); + pref.setDefault(Constants.ENABLE_SKILLS, true); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED, false); pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE, ""); + pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false); pref.setDefault(Constants.MCP, """ { @@ -56,6 +63,18 @@ public void initializeDefaultPreferences() { """); pref.setDefault(Constants.MCP_TOOLS_STATUS, "{}"); + // Auto-approve defaults + pref.setDefault(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(TerminalConfirmationHandler.DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, false); + pref.setDefault(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES)); + pref.setDefault(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, true); + pref.setDefault(Constants.AUTO_APPROVE_MCP_SERVERS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_MCP_TOOLS, "[]"); + pref.setDefault(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, false); + pref.setDefault(Constants.AUTO_APPROVE_YOLO_MODE, false); + IEclipsePreferences configPrefs = ConfigurationScope.INSTANCE .getNode(CopilotUi.getPlugin().getBundle().getSymbolicName()); boolean autoShowWhatsNew = configPrefs.getBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, true); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java index 473c9313..a6e6da99 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferencesPage.java @@ -38,6 +38,8 @@ protected Control createContents(Composite parent) { McpPreferencePage.ID); PreferencePageUtils.createPreferenceLink(getShell(), container, "<a>Model Management</a>", null, ByokPreferencePage.ID); + PreferencePageUtils.createPreferenceLink(getShell(), container, "<a>Tool Auto Approve</a>", null, + AutoApprovePreferencePage.ID); return container; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java index 137fff42..79b82234 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CustomInstructionPreferencePage.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.preferences; +import java.util.Arrays; + import org.apache.commons.lang3.StringUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; @@ -15,18 +17,20 @@ import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.preference.BooleanFieldEditor; import org.eclipse.jface.preference.FieldEditorPreferencePage; +import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.preference.StringFieldEditor; import org.eclipse.jface.text.ITextViewer; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; -import org.eclipse.swt.events.ControlAdapter; import org.eclipse.swt.events.SelectionListener; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.TableColumn; @@ -41,7 +45,9 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -54,11 +60,15 @@ public class CustomInstructionPreferencePage extends FieldEditorPreferencePage i private BooleanFieldEditor enableWorkspaceInstrField; private StringFieldEditor workspaceInstrField; private StringFieldEditor gitCommitInstrField; + private Combo chatInstrLoadScopeCombo; + + private static final CustomInstructionsChatLoadScope[] SCOPES = CustomInstructionsChatLoadScope.values(); // Variables to track initial preference values for change detection private boolean initialWorkspaceEnabled; private String initialWorkspaceInstructions; private String initialGitCommitInstructions; + private CustomInstructionsChatLoadScope initialChatCustomInstrLoadScope; private static final String GITHUB = ".github"; private static final String COPILOT_INSTRUCTIONS = "copilot-instructions.md"; @@ -106,6 +116,9 @@ private void initializePreferenceValues() { initialWorkspaceEnabled = getPreferenceStore().getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED); initialWorkspaceInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE); initialGitCommitInstructions = getPreferenceStore().getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT); + + initialChatCustomInstrLoadScope = PreferencesUtils.getCustomInstructionsChatLoadScope(getPreferenceStore()); + updateChatInstrLoadScopeComboSelection(false); } /** @@ -117,10 +130,12 @@ private boolean hasPreferencesChanged() { boolean currentWorkspaceEnabled = enableWorkspaceInstrField.getBooleanValue(); String currentWorkspaceInstructions = workspaceInstrField.getStringValue(); String currentGitCommitInstructions = gitCommitInstrField.getStringValue(); + CustomInstructionsChatLoadScope currentCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); return currentWorkspaceEnabled != initialWorkspaceEnabled || !StringUtils.equals(currentWorkspaceInstructions, initialWorkspaceInstructions) - || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions); + || !StringUtils.equals(currentGitCommitInstructions, initialGitCommitInstructions) + || !initialChatCustomInstrLoadScope.equals(currentCustomInstrLoadScope); } @Override @@ -130,10 +145,53 @@ public boolean performOk() { initialWorkspaceInstructions = workspaceInstrField.getStringValue(); initialGitCommitInstructions = gitCommitInstrField.getStringValue(); + initialChatCustomInstrLoadScope = getSelectedCustomInstrLoadScope(); + getPreferenceStore().setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + initialChatCustomInstrLoadScope.getValue()); + // Call super to save preferences return super.performOk(); } + @Override + protected void performDefaults() { + super.performDefaults(); + + updateChatInstrLoadScopeComboSelection(true); + } + + private void updateChatInstrLoadScopeComboSelection(boolean useDefaultValue) { + IPreferenceStore store = getPreferenceStore(); + if (chatInstrLoadScopeCombo == null || chatInstrLoadScopeCombo.isDisposed() || store == null) { + return; + } + + CustomInstructionsChatLoadScope scope = useDefaultValue + ? PreferencesUtils.getCustomInstructionsChatLoadScopeDefault(store) + : PreferencesUtils.getCustomInstructionsChatLoadScope(store); + + // we rely here on using the enum entry order that we use in our combobox using the SCOPES array + chatInstrLoadScopeCombo.select(scope.ordinal()); + } + + private CustomInstructionsChatLoadScope getSelectedCustomInstrLoadScope() { + int selectionIndex = chatInstrLoadScopeCombo.getSelectionIndex(); + + if (selectionIndex >= 0 && selectionIndex < SCOPES.length) { + return SCOPES[selectionIndex]; + } else { + return CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } + } + + private static String getCustomInstrLoadScopeLabel(CustomInstructionsChatLoadScope scope) { + return switch (scope) { + case ALL_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_all; + case REFERENCED_PROJECTS -> Messages.preferences_page_custom_instructions_chat_load_scope_referenced; + }; + } + + private void createWorkspaceInstructionsField(Composite parent, GridLayout gl) { // workspace instructions group Group workspaceInstrGroup = new Group(parent, SWT.NONE); @@ -210,18 +268,7 @@ private void createProjectInstructionsField(Composite parent, GridLayout gl) { // Set the file location column to take remaining width (table width - project name column width) fileLocationColumn.setWidth(tableGridData.widthHint > 0 ? tableGridData.widthHint - 150 : 400); - // Add resize listener to make the file location column take remaining width - table.addControlListener(new ControlAdapter() { - @Override - public void controlResized(org.eclipse.swt.events.ControlEvent e) { - int tableWidth = table.getClientArea().width; - int projectNameWidth = projectNameColumn.getWidth(); - int remainingWidth = tableWidth - projectNameWidth; - if (remainingWidth > 100) { // Minimum width for file location column - fileLocationColumn.setWidth(remainingWidth); - } - } - }); + SwtUtils.resizeColumnToFillTable(table, fileLocationColumn, 100, projectNameColumn); // Populate table with actual workspace projects populateProjectTable(table); @@ -232,6 +279,26 @@ public void controlResized(org.eclipse.swt.events.ControlEvent e) { // Add note using WrappableNoteLabel new WrappableNoteLabel(projectInstrGroup, Messages.preferences_page_note_prefix + " ", Messages.preferences_page_custom_instructions_project_table_note); + + // add label and drop-down list for custom instructions loading scope options + Composite chatInstrContainer = new Composite(projectInstrGroup, SWT.NONE); + GridLayout containerLayout = new GridLayout(2, false); + containerLayout.marginWidth = 0; + containerLayout.marginHeight = 0; + chatInstrContainer.setLayout(containerLayout); + chatInstrContainer.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + Label label = new Label(chatInstrContainer, SWT.NONE); + label.setText(Messages.preferences_page_custom_instructions_chat_load_scope_label); + label.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + chatInstrLoadScopeCombo = new Combo(chatInstrContainer, SWT.DROP_DOWN | SWT.READ_ONLY); + String[] items = Arrays.stream(SCOPES) + .map(CustomInstructionPreferencePage::getCustomInstrLoadScopeLabel) + .toArray(String[]::new); + chatInstrLoadScopeCombo.setItems(items); + chatInstrLoadScopeCombo.setToolTipText(Messages.preferences_page_custom_instructions_chat_load_scope_combo_tooltip); + chatInstrLoadScopeCombo.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); } private void createGitCommitInstructionsField(Composite parent, GridLayout gl) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java new file mode 100644 index 00000000..c299d40a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/FileOperationAutoApproveSection.java @@ -0,0 +1,499 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; +import com.microsoft.copilot.eclipse.core.lsp.protocol.FileSafetyRuleInfo; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.FileOperationConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * File-operation auto-approve section with a rule table, action buttons, and + * unmatched-file-operation checkbox. + * + * <p>Default rules are fetched from CLS asynchronously and merged with + * local fallback defaults + * ({@link FileOperationConfirmationHandler#FALLBACK_DEFAULT_RULES}). + * Default rules cannot be removed; only user-added rules can be removed.</p> + */ +public class FileOperationAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type FILE_OP_RULES_TYPE = + new TypeToken<List<FileOperationAutoApproveRule>>() {}.getType(); + + private TableViewer tableViewer; + private final List<FileOperationAutoApproveRule> defaultRules = + new ArrayList<>(); + private final List<FileOperationAutoApproveRule> userRules = + new ArrayList<>(); + /** Combined view (defaults + user) shown in the table. */ + private final List<FileOperationAutoApproveRule> allRules = + new ArrayList<>(); + private boolean defaultRulesLoaded; + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the file-operation auto-approve section inside the given parent. */ + public FileOperationAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_file_op_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_file_op_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_file_op_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_file_op_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); + + TableViewerColumn patternCol = + new TableViewerColumn(tableViewer, SWT.NONE); + patternCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_pattern); + patternCol.getColumn().setWidth(200); + patternCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).getPattern(); + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn descCol = + new TableViewerColumn(tableViewer, SWT.NONE); + descCol.getColumn().setText( + Messages.preferences_page_file_op_auto_approve_column_description); + descCol.getColumn().setWidth(150); + descCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + String desc = + ((FileOperationAutoApproveRule) element).getDescription(); + return desc != null ? desc : ""; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((FileOperationAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + + @Override + public Color getForeground(Object element) { + return ((FileOperationAutoApproveRule) element).isDefault() + ? Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY) : null; + } + }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + patternCol.getColumn(), descCol.getColumn()); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddFileOperationRuleDialog dialog = + new AddFileOperationRuleDialog(getShell()); + if (dialog.open() == AddFileOperationRuleDialog.OK) { + String pattern = dialog.getPattern(); + if (isPatternExists(pattern)) { + MessageDialog.openWarning(getShell(), + Messages.preferences_page_file_op_auto_approve_duplicate_title, + Messages.preferences_page_file_op_auto_approve_duplicate_message); + return; + } + userRules.add(new FileOperationAutoApproveRule( + pattern, dialog.getDescription(), dialog.isAutoApprove())); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + private boolean isPatternExists(String pattern) { + return defaultRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)) + || userRules.stream() + .anyMatch(r -> r.getPattern().equals(pattern)); + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + userRules.remove(rule); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + FileOperationAutoApproveRule rule = + (FileOperationAutoApproveRule) sel.getFirstElement(); + if (!rule.isDefault()) { + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_file_op_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + userRules.clear(); + resetDefaultRulesToOriginal(); + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + } + + /** + * Resets default rules' autoApprove to their original values. + * CLS defaults use {@code requiresConfirmation=true} → {@code autoApprove=false}. + * Local fallback defaults are already {@code autoApprove=false}. + */ + private void resetDefaultRulesToOriginal() { + for (FileOperationAutoApproveRule rule : defaultRules) { + rule.setAutoApprove(false); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + FileOperationAutoApproveRule selected = hasSelection + ? (FileOperationAutoApproveRule) tableViewer + .getStructuredSelection().getFirstElement() + : null; + boolean isDefault = selected != null && selected.isDefault(); + + removeButton.setEnabled(hasSelection && !isDefault); + toggleButton.setEnabled(hasSelection && !isDefault); + if (hasSelection) { + toggleButton.setText(selected.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + /** + * Checks whether the current rule set matches defaults exactly + * (no user rules, all defaults at original autoApprove values). + */ + private boolean isMatchingDefaults() { + if (!userRules.isEmpty()) { + return false; + } + for (FileOperationAutoApproveRule rule : defaultRules) { + if (rule.isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads file-operation rules and unmatched-file-operation preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + List<FileOperationAutoApproveRule> savedRules = parseSavedRules(store); + + // Initialize with local fallback defaults + applyFallbackDefaults(); + + // Separate saved rules into defaults (override autoApprove) and user + Set<String> defaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.clear(); + for (FileOperationAutoApproveRule saved : savedRules) { + if (defaultPatterns.contains(saved.getPattern())) { + // Restore toggled autoApprove for default rules + defaultRules.stream() + .filter(d -> d.getPattern().equals(saved.getPattern())) + .findFirst() + .ifPresent(d -> d.setAutoApprove(saved.isAutoApprove())); + } else { + userRules.add(saved); + } + } + + rebuildAllRules(); + tableViewer.setInput(allRules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + updateButtonState(); + + fetchDefaultRulesFromCls(); + } + + private List<FileOperationAutoApproveRule> parseSavedRules( + IPreferenceStore store) { + String json = + store.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List<FileOperationAutoApproveRule> loaded = + new Gson().fromJson(json, FILE_OP_RULES_TYPE); + if (loaded != null) { + return loaded; + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse file operation auto-approve rules", e); + } + } + return List.of(); + } + + private void applyFallbackDefaults() { + defaultRules.clear(); + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + fallback.isAutoApprove(), true)); + } + } + + /** + * Fetches default file safety rules from CLS asynchronously. + * On success, merges CLS rules with local fallback and updates + * the table. On failure, keeps local fallback defaults. + */ + private void fetchDefaultRulesFromCls() { + CopilotLanguageServerConnection conn = + CopilotCore.getPlugin().getCopilotLanguageServer(); + if (conn == null) { + return; + } + conn.getDefaultFileSafetyRules().thenAccept(result -> { + if (result == null || result.getDefaultRules() == null) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed() || defaultRulesLoaded) { + return; + } + applyClsDefaults(result.getDefaultRules()); + }, FileOperationAutoApproveSection.this); + }).exceptionally(ex -> { + // CLS not available — keep local fallback defaults + CopilotCore.LOGGER.error( + "Failed to fetch default file safety rules from CLS", ex); + return null; + }); + } + + /** + * Applies CLS-provided default rules, merging with local fallback. + * CLS rules take priority; local fallbacks fill gaps. + */ + private void applyClsDefaults(List<FileSafetyRuleInfo> clsRules) { + // Preserve any user-toggled autoApprove on existing defaults + Map<String, Boolean> toggledDefaults = new HashMap<>(); + for (FileOperationAutoApproveRule existing : defaultRules) { + toggledDefaults.put(existing.getPattern(), existing.isAutoApprove()); + } + + defaultRules.clear(); + Set<String> clsPatterns = new HashSet<>(); + for (FileSafetyRuleInfo clsRule : clsRules) { + String pattern = clsRule.getPattern(); + clsPatterns.add(pattern); + boolean autoApprove = !clsRule.isRequiresConfirmation(); + // Restore user toggle if they had changed this default + if (toggledDefaults.containsKey(pattern)) { + autoApprove = toggledDefaults.get(pattern); + } + String desc = clsRule.getDescription() != null + ? clsRule.getDescription() : ""; + defaultRules.add(new FileOperationAutoApproveRule( + pattern, desc, autoApprove, true)); + } + + // Add local fallbacks for patterns not in CLS + for (FileOperationAutoApproveRule fallback + : FileOperationConfirmationHandler.FALLBACK_DEFAULT_RULES) { + if (!clsPatterns.contains(fallback.getPattern())) { + boolean autoApprove = fallback.isAutoApprove(); + if (toggledDefaults.containsKey(fallback.getPattern())) { + autoApprove = toggledDefaults.get(fallback.getPattern()); + } + defaultRules.add(new FileOperationAutoApproveRule( + fallback.getPattern(), fallback.getDescription(), + autoApprove, true)); + } + } + + // Remove user rules that overlap with new defaults + Set<String> allDefaultPatterns = defaultRules.stream() + .map(FileOperationAutoApproveRule::getPattern) + .collect(Collectors.toSet()); + userRules.removeIf(r -> allDefaultPatterns.contains(r.getPattern())); + + defaultRulesLoaded = true; + rebuildAllRules(); + tableViewer.refresh(); + updateButtonState(); + } + + /** Rebuilds the combined list shown in the table. */ + private void rebuildAllRules() { + allRules.clear(); + allRules.addAll(defaultRules); + allRules.addAll(userRules); + } + + /** Saves file-operation rules and unmatched-file-operation preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + // Save all rules (defaults + user) to preferences. + // On next load, defaults are re-identified by pattern matching. + store.setValue(Constants.AUTO_APPROVE_FILE_OP_RULES, + new Gson().toJson(allRules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java new file mode 100644 index 00000000..3aaa8c7a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/GlobalAutoApproveSection.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.Constants; + +/** + * Global auto-approve preference section with a YOLO mode checkbox + * that bypasses all confirmation dialogs when enabled. + */ +public class GlobalAutoApproveSection extends Composite { + + private static final int TOOLTIP_LINE_LENGTH = 90; + + private Button yoloCheckbox; + + /** Creates the global auto-approve section inside the given parent. */ + public GlobalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_global_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Composite yoloRow = new Composite(group, SWT.NONE); + GridLayout yoloRowLayout = new GridLayout(2, false); + yoloRowLayout.marginWidth = 0; + yoloRowLayout.marginHeight = 0; + yoloRow.setLayout(yoloRowLayout); + yoloRow.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + yoloCheckbox = new Button(yoloRow, SWT.CHECK); + yoloCheckbox.setText( + Messages.preferences_page_global_auto_approve_label); + yoloCheckbox.setLayoutData( + new GridData(SWT.LEFT, SWT.CENTER, false, false)); + yoloCheckbox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (yoloCheckbox.getSelection()) { + MessageDialog dialog = new MessageDialog( + getShell(), + Messages.preferences_page_global_auto_approve_confirm_title, + null, + Messages.preferences_page_global_auto_approve_confirm_message, + MessageDialog.WARNING, + new String[] { + Messages.preferences_page_global_auto_approve_confirm_button, + Messages.preferences_page_global_auto_approve_cancel_button + }, + 1); + int result = dialog.open(); + if (result != 0) { + yoloCheckbox.setSelection(false); + } + } + } + }); + + Label warningIcon = new Label(yoloRow, SWT.NONE); + warningIcon.setImage(PlatformUI.getWorkbench().getSharedImages() + .getImage(ISharedImages.IMG_OBJS_WARN_TSK)); + warningIcon.setToolTipText(wrapTooltip( + Messages.preferences_page_global_auto_approve_confirm_message)); + warningIcon.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_global_auto_approve_confirm_message); + } + + /** Loads global auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + yoloCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_YOLO_MODE)); + } + + /** Saves global auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_YOLO_MODE, + yoloCheckbox.getSelection()); + } + + private static String wrapTooltip(String text) { + StringBuilder wrapped = new StringBuilder(text.length()); + int lineLength = 0; + for (String word : text.split(" ")) { + if (lineLength > 0 && lineLength + word.length() + 1 > TOOLTIP_LINE_LENGTH) { + wrapped.append('\n'); + lineLength = 0; + } else if (lineLength > 0) { + wrapped.append(' '); + lineLength++; + } + wrapped.append(word); + lineLength += word.length(); + } + return wrapped.toString(); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java index 02f83e53..26b2c024 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/LanguageServerSettingManager.java @@ -5,10 +5,12 @@ import java.net.URI; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import org.apache.commons.lang3.StringUtils; @@ -25,7 +27,10 @@ import com.microsoft.copilot.eclipse.core.Constants; import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; +import com.microsoft.copilot.eclipse.core.chat.FileOperationAutoApproveRule; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.CopilotLanguageServerConnection; import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsStatusCollection; @@ -37,9 +42,11 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateConversationToolsStatusParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.UpdateMcpToolsStatusParams; import com.microsoft.copilot.eclipse.core.utils.GsonUtils; +import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.core.utils.WorkspaceUtils; import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.McpExtensionPointManager; +import com.microsoft.copilot.eclipse.ui.utils.PreferencesUtils; /** * A class to manage the proxy service for the Copilot Language Server. @@ -50,6 +57,7 @@ public class LanguageServerSettingManager implements IProxyChangeListener, IProp CopilotLanguageServerConnection copilotLanguageServerConnection = null; IPreferenceStore preferenceStore; IProxyData proxyData = null; + private IEventBroker eventBroker; /** * Gets the settings. @@ -82,6 +90,22 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy // agent related settings getSettings().getGithubSettings().getCopilotSettings().getAgent() .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoCompress(true); + + // Set transcript directory for CLS session persistence and restoration + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setTranscriptDirectory(PlatformUtils.getTranscriptDirectory()); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + getSettings().getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + syncTerminalRulesToCls(); + syncFileOperationRulesToCls(); // Set workspace context instructions when it is enabled if (preferenceStore.getBoolean(Constants.CUSTOM_INSTRUCTIONS_WORKSPACE_ENABLED)) { @@ -95,7 +119,7 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy getSettings().getGithubSettings() .setGitCommitCopilotInstructions(preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_GIT_COMMIT)); - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); eventBroker.subscribe(CopilotEventConstants.TOPIC_DID_CHANGE_MCP_CONTRIBUTION_POINT_POLICY, event -> { Boolean enabled = (Boolean) event.getProperty(IEventBroker.DATA); if (!enabled.booleanValue()) { @@ -103,6 +127,8 @@ public LanguageServerSettingManager(CopilotLanguageServerConnection conn, IProxy syncMcpRegistrationConfiguration(); } }); + eventBroker.subscribe(CopilotEventConstants.TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, + event -> syncMcpRegistrationConfiguration((String) event.getProperty(IEventBroker.DATA))); } /** @@ -140,7 +166,7 @@ public void propertyChange(PropertyChangeEvent event) { settings.getGithubEnterprise().setUri(preferenceStore.getString(Constants.GITHUB_ENTERPRISE)); singleSetting = new CopilotLanguageServerSettings(null, null, settings.getGithubEnterprise(), null); break; - case Constants.MCP, Constants.MCP_EXTENSION_POINT_CONTRIB: + case Constants.MCP: syncMcpRegistrationConfiguration(); return; case Constants.MCP_TOOLS_STATUS: @@ -168,6 +194,31 @@ public void propertyChange(PropertyChangeEvent event) { .setAgentMaxRequests(preferenceStore.getInt(Constants.AGENT_MAX_REQUESTS)); singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); break; + case Constants.ENABLE_SKILLS: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setEnableSkills(PreferencesUtils.isSkillsEnabled()); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_UNMATCHED_TERMINAL: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedTerminal( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_TERMINAL_RULES: + syncTerminalRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_UNMATCHED_FILE_OP: + settings.getGithubSettings().getCopilotSettings().getAgent() + .setAutoApproveUnmatchedFileOp( + preferenceStore.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_FILE_OP)); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; + case Constants.AUTO_APPROVE_FILE_OP_RULES: + syncFileOperationRulesToCls(); + singleSetting = new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings()); + break; default: return; } @@ -206,18 +257,93 @@ private void updateGithubPanicErrorReport() { * Sync MCP registration from both extension points and preference store. */ public void syncMcpRegistrationConfiguration() { + String approvedExtMcpServers = null; + if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { + // Defensive null-chain: callers from very early startup paths (e.g. CopilotUi.start()) may + // run before the ChatServiceManager singleton field is assigned. The extension-point flow + // delivers its own state via TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED, so missing it + // here just means the LSP gets the manual MCP state for now and the verified state arrives + // shortly after via that subscription. + var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + McpExtensionPointManager mgr = chatServiceManager.getMcpExtensionPointManager(); + if (mgr != null) { + approvedExtMcpServers = mgr.getApprovedExtMcpServers(); + } + } + } + syncMcpRegistrationConfiguration(approvedExtMcpServers); + } + + /** + * Sync MCP registration to the language server using the supplied extension-contributed approved + * servers JSON. Used by callers that already have the approved JSON in hand - notably the + * subscriber to {@link CopilotEventConstants#TOPIC_MCP_EXTENSION_POINT_REGISTRATION_COMPLETED} - + * so they do not need to traverse {@code CopilotUi.getChatServiceManager()} to look it up. + * + * @param approvedExtMcpServers JSON for the approved extension-contributed MCP servers, or + * {@code null} when none are approved or the contribution point is disabled. + */ + public void syncMcpRegistrationConfiguration(String approvedExtMcpServers) { // From manual configuration settings.setMcpServers(preferenceStore.getString(Constants.MCP)); // From McpRegistration extension point if (CopilotCore.getPlugin().getFeatureFlags().isMcpContributionPointEnabled()) { - McpExtensionPointManager mgr = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager(); - settings.addMcpServers(mgr.getApprovedExtMcpServers()); + settings.addMcpServers(approvedExtMcpServers); } syncSingleConfiguration(new CopilotLanguageServerSettings(null, null, null, settings.getGithubSettings())); } + /** + * Converts terminal auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncTerminalRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + Map<String, Boolean> rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List<TerminalAutoApproveRule> rules = + new Gson().fromJson(json, + new TypeToken<List<TerminalAutoApproveRule>>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getCommand(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse terminal rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getTerminal().setAutoApprove(rulesMap); + } + + /** + * Converts file-operation auto-approve rules from preference store JSON to the Map format + * expected by CLS and syncs them. + */ + private void syncFileOperationRulesToCls() { + String json = preferenceStore.getString(Constants.AUTO_APPROVE_FILE_OP_RULES); + Map<String, Boolean> rulesMap = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(json)) { + try { + List<FileOperationAutoApproveRule> rules = + new Gson().fromJson(json, + new TypeToken<List<FileOperationAutoApproveRule>>() { + }.getType()); + if (rules != null) { + rules.forEach(r -> rulesMap.put(r.getPattern(), r.isAutoApprove())); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to parse file-operation rules for CLS sync", e); + } + } + settings.getGithubSettings().getCopilotSettings().getAgent() + .getTools().getEdit().setAutoApprove(rulesMap); + } + /** * Initializes the MCP tools status from the preference store for built-in agent mode only. * Custom agent modes get their tool configuration from the LSP/file, not from preferences. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java new file mode 100644 index 00000000..357234b4 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpAutoApproveSection.java @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.CheckStateChangedEvent; +import org.eclipse.jface.viewers.CheckboxTreeViewer; +import org.eclipse.jface.viewers.ICheckStateListener; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpServerToolsCollection; +import com.microsoft.copilot.eclipse.core.lsp.mcp.McpToolInformation; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * MCP auto-approve preference section with a trust-annotations checkbox + * and a tree viewer for per-server/tool approval management. + */ +public class McpAutoApproveSection extends Composite { + + private static final int TREE_HEIGHT_HINT = 200; + private static final Type STRING_LIST_TYPE = + new TypeToken<List<String>>() {}.getType(); + + private Button trustAnnotationsCheckbox; + private CheckboxTreeViewer treeViewer; + private Group group; + + private List<McpServerToolsCollection> serverCollections = + new ArrayList<>(); + + // Current check state (lowercased for case-insensitive matching) + private final Set<String> checkedServers = new HashSet<>(); + private final Set<String> checkedTools = new HashSet<>(); + + /** Creates the MCP auto-approve section inside the given parent. */ + public McpAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_mcp_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + // Trust annotations checkbox + trustAnnotationsCheckbox = new Button(group, SWT.CHECK); + trustAnnotationsCheckbox.setText( + Messages.preferences_page_mcp_auto_approve_trust_annotations); + trustAnnotationsCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_mcp_auto_approve_trust_annotations_note); + + // Server/tool approval label + Label serverToolsLabel = new Label(group, SWT.NONE); + serverToolsLabel.setText( + Messages.preferences_page_mcp_auto_approve_server_tools_label); + GridData labelData = new GridData(SWT.FILL, SWT.TOP, true, false); + serverToolsLabel.setLayoutData(labelData); + + // Tree viewer for server/tool approval + treeViewer = new CheckboxTreeViewer(group, + SWT.BORDER | SWT.FULL_SELECTION | SWT.V_SCROLL); + GridData treeData = new GridData(SWT.FILL, SWT.FILL, true, false); + treeData.heightHint = TREE_HEIGHT_HINT; + treeViewer.getTree().setLayoutData(treeData); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(treeViewer.getTree()); + + treeViewer.setContentProvider(new McpTreeContentProvider()); + treeViewer.setLabelProvider(new McpTreeLabelProvider()); + treeViewer.addCheckStateListener(new McpCheckStateListener()); + treeViewer.setInput(serverCollections); + } + + /** Loads MCP auto-approve settings from the preference store. */ + public void loadFromPreferences(IPreferenceStore store) { + trustAnnotationsCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS)); + + checkedServers.clear(); + checkedTools.clear(); + + List<String> servers = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_SERVERS); + for (String s : servers) { + checkedServers.add(s.toLowerCase(Locale.ROOT)); + } + + List<String> tools = loadJsonList(store, + Constants.AUTO_APPROVE_MCP_TOOLS); + for (String t : tools) { + checkedTools.add(t.toLowerCase(Locale.ROOT)); + } + + refreshTreeCheckState(); + } + + /** Saves MCP auto-approve settings to the preference store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TRUST_TOOL_ANNOTATIONS, + trustAnnotationsCheckbox.getSelection()); + + store.setValue(Constants.AUTO_APPROVE_MCP_SERVERS, + new Gson().toJson(new ArrayList<>(checkedServers))); + store.setValue(Constants.AUTO_APPROVE_MCP_TOOLS, + new Gson().toJson(new ArrayList<>(checkedTools))); + } + + /** + * Updates the server/tool collections displayed in the tree viewer. + * Called from the MCP config service when server data changes. + */ + public void updateServerCollections( + List<McpServerToolsCollection> collections) { + if (isDisposed()) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; + } + this.serverCollections = collections != null + ? collections : Collections.emptyList(); + treeViewer.setInput(serverCollections); + refreshTreeCheckState(); + requestLayout(); + }, this); + } + + private void refreshTreeCheckState() { + if (treeViewer.getTree().isDisposed()) { + return; + } + for (McpServerToolsCollection server : serverCollections) { + // Expand to ensure child TreeItems exist before setChecked + treeViewer.expandToLevel(server, 1); + + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + boolean serverChecked = checkedServers.contains(serverLower); + + List<McpToolInformation> tools = server.getTools(); + if (tools == null) { + tools = Collections.emptyList(); + } + + if (serverChecked) { + // Server is approved: check server and all its tools + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, true); + } + } else { + // Check individual tools + int checkedCount = 0; + for (McpToolInformation tool : tools) { + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + boolean toolChecked = checkedTools.contains(toolKey); + treeViewer.setChecked(tool, toolChecked); + if (toolChecked) { + checkedCount++; + } + } + // Update parent check/gray state + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + } + } + } + } + + private static List<String> loadJsonList(IPreferenceStore store, + String key) { + String json = store.getString(key); + if (StringUtils.isBlank(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + List<String> list = new Gson().fromJson(json, STRING_LIST_TYPE); + return list != null ? list : Collections.emptyList(); + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse MCP auto-approve list: " + key, e); + return Collections.emptyList(); + } + } + + /** Content provider for the server/tool tree. */ + private static class McpTreeContentProvider + implements ITreeContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof List<?> list) { + return list.toArray(); + } + return new Object[0]; + } + + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof McpServerToolsCollection server) { + List<McpToolInformation> tools = server.getTools(); + return tools != null ? tools.toArray() : new Object[0]; + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof McpServerToolsCollection server) { + List<McpToolInformation> tools = server.getTools(); + return tools != null && !tools.isEmpty(); + } + return false; + } + } + + /** Label provider for the server/tool tree. */ + private static class McpTreeLabelProvider extends LabelProvider { + @Override + public String getText(Object element) { + if (element instanceof McpServerToolsCollection server) { + return server.getName() != null ? server.getName() : ""; + } + if (element instanceof McpToolInformation tool) { + return tool.getName() != null ? tool.getName() : ""; + } + return ""; + } + } + + /** Handles check state changes in the server/tool tree. */ + private class McpCheckStateListener implements ICheckStateListener { + @Override + public void checkStateChanged(CheckStateChangedEvent event) { + Object element = event.getElement(); + boolean checked = event.getChecked(); + + if (element instanceof McpServerToolsCollection server) { + handleServerCheckChanged(server, checked); + } else if (element instanceof McpToolInformation tool) { + handleToolCheckChanged(tool, checked); + } + } + + private void handleServerCheckChanged( + McpServerToolsCollection server, boolean checked) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + treeViewer.setGrayed(server, false); + + if (checked) { + checkedServers.add(serverLower); + } else { + checkedServers.remove(serverLower); + } + + // Check/uncheck all children + List<McpToolInformation> tools = server.getTools(); + if (tools != null) { + for (McpToolInformation tool : tools) { + treeViewer.setChecked(tool, checked); + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + } + } + } + + private void handleToolCheckChanged( + McpToolInformation tool, boolean checked) { + // Find parent server + McpServerToolsCollection parentServer = findParentServer(tool); + if (parentServer == null) { + return; + } + String serverLower = parentServer.getName() != null + ? parentServer.getName().toLowerCase(Locale.ROOT) : ""; + String toolKey = serverLower + "::" + + (tool.getName() != null + ? tool.getName().toLowerCase(Locale.ROOT) : ""); + + if (checked) { + checkedTools.add(toolKey); + } else { + checkedTools.remove(toolKey); + } + + // Update parent check/gray state + updateParentState(parentServer); + } + + private void updateParentState(McpServerToolsCollection server) { + String serverLower = server.getName() != null + ? server.getName().toLowerCase(Locale.ROOT) : ""; + List<McpToolInformation> tools = server.getTools(); + if (tools == null || tools.isEmpty()) { + return; + } + int checkedCount = 0; + for (McpToolInformation tool : tools) { + if (treeViewer.getChecked(tool)) { + checkedCount++; + } + } + if (checkedCount == 0) { + treeViewer.setChecked(server, false); + treeViewer.setGrayed(server, false); + checkedServers.remove(serverLower); + } else if (checkedCount == tools.size()) { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, false); + checkedServers.add(serverLower); + } else { + treeViewer.setChecked(server, true); + treeViewer.setGrayed(server, true); + checkedServers.remove(serverLower); + } + } + + private McpServerToolsCollection findParentServer( + McpToolInformation tool) { + for (McpServerToolsCollection server : serverCollections) { + List<McpToolInformation> tools = server.getTools(); + if (tools != null && tools.contains(tool)) { + return server; + } + } + return null; + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java index 61dd9d45..c41975d0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/McpPreferencePage.java @@ -83,7 +83,7 @@ public class McpPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage { public static final String ID = "com.microsoft.copilot.eclipse.ui.preferences.McpPreferencePage"; - private static final int GROUP_HEIGHT_HINT = 260; + private static final int GROUP_HEIGHT_HINT = PreferencePageUtils.STANDARD_CONTENT_HEIGHT / 2; private static final Gson GSON = new Gson(); private Group toolsGroup; @@ -431,11 +431,7 @@ private void createExtMcpRegistrationArea(Composite parent) { extMcpButton.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { - String res = CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager() - .approveExtMcpRegistration(); - if (StringUtils.isNotBlank(res)) { - CopilotUi.getPlugin().getLanguageServerSettingManager().syncMcpRegistrationConfiguration(); - } + CopilotUi.getPlugin().getChatServiceManager().getMcpExtensionPointManager().approveExtMcpRegistration(); } }); } @@ -970,8 +966,8 @@ private void loadModeOptions() { for (CustomChatMode mode : customModes) { String workspaceName = getWorkspaceNameForMode(mode); if (workspaceName.isEmpty()) { - CopilotCore.LOGGER.info("Workspace name is empty for custom agent: " + mode.getDisplayName() - + " (ID: " + mode.getId() + ")"); + CopilotCore.LOGGER.info("Workspace name is empty for custom agent: " + + mode.getDisplayName() + " (ID: " + mode.getId() + ")"); continue; } options.add(workspaceName + ": " + mode.getDisplayName()); @@ -1086,14 +1082,18 @@ private String getWorkspaceNameForMode(CustomChatMode mode) { List<WorkspaceFolder> workspaceFolders = WorkspaceUtils.listWorkspaceFolders(); if (workspaceFolders != null) { for (WorkspaceFolder folder : workspaceFolders) { - Path folderPath = Paths.get(java.net.URI.create(folder.getUri())); - if (modePath.startsWith(folderPath)) { - return folder.getName(); + try { + Path folderPath = Paths.get(java.net.URI.create(folder.getUri())); + if (modePath.startsWith(folderPath)) { + return folder.getName(); + } + } catch (Exception folderEx) { + CopilotCore.LOGGER.error("Failed to process folder uri=" + folder.getUri(), folderEx); } } } } catch (Exception e) { - CopilotCore.LOGGER.error("Failed to get workspace name for mode", e); + CopilotCore.LOGGER.error("Failed to get workspace name for mode id=" + mode.getId(), e); } return ""; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index bce65aed..7582fd7a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -46,17 +46,14 @@ public class Messages extends NLS { public static String preferences_page_byok_dialog_remove; public static String preferences_page_byok_customModels; public static String preferences_page_byok_enabledCount; - public static String preferences_page_byok_preview_disabled_tip; + public static String preferences_page_byok_disabled_tip; public static String preferences_page_completions_codeMiningNote; - public static String preferences_page_customModes_defaultDescription; - public static String preferences_page_customModes_defaultInstructions; public static String preferences_page_completions_enableNes; public static String preferences_page_restart_required; public static String preferences_page_enable_strict_ssl; public static String preferences_page_whats_new_settings; public static String preferences_page_enable_whats_new; public static String preferences_page_enable_whats_new_tooltip; - public static String preferences_page_proxy_kerberos_sp; public static String preferences_page_github_enterprise; public static String preferences_page_watched_files; public static String preferences_page_watched_files_note_content; @@ -88,17 +85,14 @@ public class Messages extends NLS { public static String preferences_page_mcp_registry_description; public static String preferences_page_mcp_registry_button; public static String preferences_page_mcp_registry_restricted_info; - public static String preferences_page_custom_instructions_copilot_instructions; public static String preferences_page_custom_instructions_copilot_instructions_desc; public static String preferences_page_custom_instructions_copilot_instructions_note; public static String preferences_page_custom_instructions_workspace; public static String preferences_page_custom_instructions_workspace_enable; public static String preferences_page_custom_instructions_project; - public static String preferences_page_custom_instructions_project_enable; public static String preferences_page_custom_instructions_project_intro; public static String preferences_page_custom_instructions_project_table_projectName; public static String preferences_page_custom_instructions_project_table_fileLocation; - public static String preferences_page_custom_instructions_project_table_deleteButton; public static String preferences_page_custom_instructions_project_table_editButton; public static String preferences_page_custom_instructions_project_table_note; public static String preferences_page_custom_instructions_project_editDialog_title; @@ -110,10 +104,12 @@ public class Messages extends NLS { public static String preferences_page_custom_instructions_git_commit; public static String preferences_page_custom_instructions_git_commit_desc; public static String preferences_page_custom_instructions_git_commit_note; + public static String preferences_page_custom_instructions_chat_load_scope_label; + public static String preferences_page_custom_instructions_chat_load_scope_all; + public static String preferences_page_custom_instructions_chat_load_scope_referenced; + public static String preferences_page_custom_instructions_chat_load_scope_combo_tooltip; public static String preferences_page_note_prefix; public static String preferences_page_note_content; - public static String preferences_page_mcpOAuth_confirmTitle; - public static String preferences_page_mcpOAuth_confirmMessage; // CustomModesPreferencePage public static String customModes_page_description; @@ -151,9 +147,67 @@ public class Messages extends NLS { public static String preferences_page_agent_max_requests_desc; public static String preferences_page_agent_max_requests_validation_error; + // Skills + public static String preferences_page_skills_enabled; + public static String preferences_page_skills_enabled_note_content; + public static String setting_managed_by_organization; public static String setting_disabled_by_organization; + // Shared Auto-Approve strings + public static String preferences_page_auto_approve_disabled_by_organization; + public static String preferences_page_auto_approve_column_status; + public static String preferences_page_auto_approve_add; + public static String preferences_page_auto_approve_remove; + public static String preferences_page_auto_approve_reset; + public static String preferences_page_auto_approve_reset_message; + public static String preferences_page_auto_approve_allow; + public static String preferences_page_auto_approve_deny; + public static String preferences_page_auto_approve_add_dialog_approve; + + // Terminal Auto-Approve + public static String preferences_page_terminal_auto_approve_title; + public static String preferences_page_terminal_auto_approve_description; + public static String preferences_page_terminal_auto_approve_column_command; + public static String preferences_page_terminal_auto_approve_reset_title; + public static String preferences_page_terminal_auto_approve_unmatched; + public static String preferences_page_terminal_auto_approve_unmatched_note; + public static String preferences_page_terminal_auto_approve_add_dialog_title; + public static String preferences_page_terminal_auto_approve_add_dialog_message; + public static String preferences_page_terminal_auto_approve_add_dialog_command; + public static String preferences_page_terminal_auto_approve_add_dialog_placeholder; + + // File Operations Auto Approve + public static String preferences_page_file_op_auto_approve_title; + public static String preferences_page_file_op_auto_approve_description; + public static String preferences_page_file_op_auto_approve_column_pattern; + public static String preferences_page_file_op_auto_approve_column_description; + public static String preferences_page_file_op_auto_approve_reset_title; + public static String preferences_page_file_op_auto_approve_unmatched; + public static String preferences_page_file_op_auto_approve_unmatched_note; + public static String preferences_page_file_op_auto_approve_add_dialog_title; + public static String preferences_page_file_op_auto_approve_add_dialog_message; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern; + public static String preferences_page_file_op_auto_approve_add_dialog_description; + public static String preferences_page_file_op_auto_approve_add_dialog_pattern_hint; + public static String preferences_page_file_op_auto_approve_add_dialog_description_hint; + public static String preferences_page_file_op_auto_approve_duplicate_title; + public static String preferences_page_file_op_auto_approve_duplicate_message; + + // MCP Auto-Approve + public static String preferences_page_mcp_auto_approve_title; + public static String preferences_page_mcp_auto_approve_trust_annotations; + public static String preferences_page_mcp_auto_approve_trust_annotations_note; + public static String preferences_page_mcp_auto_approve_server_tools_label; + + // Global Auto-Approve + public static String preferences_page_global_auto_approve_title; + public static String preferences_page_global_auto_approve_label; + public static String preferences_page_global_auto_approve_confirm_title; + public static String preferences_page_global_auto_approve_confirm_message; + public static String preferences_page_global_auto_approve_confirm_button; + public static String preferences_page_global_auto_approve_cancel_button; + static { // initialize resource bundle NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java index ab676ebf..13ed7ea9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/PreferencePageUtils.java @@ -25,6 +25,15 @@ */ public final class PreferencePageUtils { + /** + * Target content height, in pixels, for Copilot preference pages. JFace grows the shared Preferences dialog to + * the tallest page's preferred height and never shrinks it, so each page keeps its scrollable content within + * this height to hold the dialog at a stable size while the user navigates. Pages enforce it differently: + * {@code McpPreferencePage} divides it across two stacked groups; {@code AutoApprovePreferencePage} caps its + * {@code ScrolledComposite} at this height. + */ + public static final int STANDARD_CONTENT_HEIGHT = 520; + // Private constructor to prevent instantiation private PreferencePageUtils() { } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java new file mode 100644 index 00000000..46664360 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/TerminalAutoApproveSection.java @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.preferences; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.TerminalAutoApproveRule; +import com.microsoft.copilot.eclipse.ui.chat.confirmation.TerminalConfirmationHandler; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * Terminal auto-approve section with a rule table, action buttons, and + * unmatched-command checkbox. + */ +public class TerminalAutoApproveSection extends Composite { + + private static final int TABLE_HEIGHT_HINT = 200; + private static final Type TERMINAL_RULES_TYPE = + new TypeToken<List<TerminalAutoApproveRule>>() {}.getType(); + + private TableViewer tableViewer; + private List<TerminalAutoApproveRule> rules = new ArrayList<>(); + private Button removeButton; + private Button toggleButton; + private Button resetButton; + private Button unmatchedCheckbox; + + /** Creates the terminal auto-approve section inside the given parent. */ + public TerminalAutoApproveSection(Composite parent, int style) { + super(parent, style); + setLayout(new GridLayout(1, false)); + setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + createContents(); + } + + private void createContents() { + Group group = new Group(this, SWT.NONE); + group.setText(Messages.preferences_page_terminal_auto_approve_title); + group.setLayout(new GridLayout(1, false)); + group.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + group.setBackgroundMode(SWT.INHERIT_FORCE); + + Label description = new Label(group, SWT.WRAP); + description.setText( + Messages.preferences_page_terminal_auto_approve_description); + GridData descData = new GridData(SWT.FILL, SWT.TOP, true, false); + descData.widthHint = 400; + description.setLayoutData(descData); + + Composite container = new Composite(group, SWT.NONE); + container.setLayout(new GridLayout(2, false)); + container.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + createTable(container); + createButtons(container); + + unmatchedCheckbox = new Button(group, SWT.CHECK); + unmatchedCheckbox.setText( + Messages.preferences_page_terminal_auto_approve_unmatched); + unmatchedCheckbox.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + + new WrappableNoteLabel(group, + Messages.preferences_page_note_prefix + " ", + Messages.preferences_page_terminal_auto_approve_unmatched_note); + } + + private void createTable(Composite parent) { + tableViewer = new TableViewer(parent, + SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE | SWT.V_SCROLL); + Table table = tableViewer.getTable(); + GridData tableData = new GridData(SWT.FILL, SWT.FILL, true, false); + tableData.heightHint = TABLE_HEIGHT_HINT; + table.setLayoutData(tableData); + table.setHeaderVisible(true); + table.setLinesVisible(true); + SwtUtils.forwardVerticalMouseWheelToParentScrollerAtBoundary(table); + + TableViewerColumn commandCol = + new TableViewerColumn(tableViewer, SWT.NONE); + commandCol.getColumn().setText( + Messages.preferences_page_terminal_auto_approve_column_command); + commandCol.getColumn().setWidth(300); + commandCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).getCommand(); + } + }); + + TableViewerColumn statusCol = + new TableViewerColumn(tableViewer, SWT.NONE); + statusCol.getColumn().setText( + Messages.preferences_page_auto_approve_column_status); + statusCol.getColumn().setWidth(100); + statusCol.setLabelProvider(new ColumnLabelProvider() { + @Override + public String getText(Object element) { + return ((TerminalAutoApproveRule) element).isAutoApprove() + ? Messages.preferences_page_auto_approve_allow + : Messages.preferences_page_auto_approve_deny; + } + }); + SwtUtils.resizeColumnToFillTable(table, statusCol.getColumn(), 100, + commandCol.getColumn()); + + tableViewer.setContentProvider(ArrayContentProvider.getInstance()); + tableViewer.addSelectionChangedListener(e -> updateButtonState()); + } + + private void createButtons(Composite parent) { + Composite btnGroup = new Composite(parent, SWT.NONE); + btnGroup.setLayout(new GridLayout(1, false)); + btnGroup.setLayoutData( + new GridData(SWT.BEGINNING, SWT.BEGINNING, false, false)); + + Button addButton = new Button(btnGroup, SWT.PUSH); + addButton.setText(Messages.preferences_page_auto_approve_add); + addButton.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + addButton.addListener(SWT.Selection, e -> onAdd()); + + removeButton = new Button(btnGroup, SWT.PUSH); + removeButton.setText( + Messages.preferences_page_auto_approve_remove); + removeButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + removeButton.setEnabled(false); + removeButton.addListener(SWT.Selection, e -> onRemove()); + + toggleButton = new Button(btnGroup, SWT.PUSH); + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + toggleButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + toggleButton.setEnabled(false); + toggleButton.addListener(SWT.Selection, e -> onToggle()); + + resetButton = new Button(btnGroup, SWT.PUSH); + resetButton.setText( + Messages.preferences_page_auto_approve_reset); + resetButton.setLayoutData( + new GridData(SWT.FILL, SWT.TOP, true, false)); + resetButton.addListener(SWT.Selection, e -> onResetToDefaults()); + } + + private void onAdd() { + AddTerminalRuleDialog dialog = new AddTerminalRuleDialog(getShell()); + if (dialog.open() == AddTerminalRuleDialog.OK) { + String command = dialog.getCommand(); + // Remove existing rule with same command, then add at end + rules.removeIf(r -> r.getCommand().equals(command)); + rules.add(new TerminalAutoApproveRule(command, dialog.isAutoApprove())); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onRemove() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + rules.remove(sel.getFirstElement()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onToggle() { + IStructuredSelection sel = tableViewer.getStructuredSelection(); + if (!sel.isEmpty()) { + TerminalAutoApproveRule rule = + (TerminalAutoApproveRule) sel.getFirstElement(); + rule.setAutoApprove(!rule.isAutoApprove()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void onResetToDefaults() { + boolean confirmed = MessageDialog.openQuestion(getShell(), + Messages.preferences_page_terminal_auto_approve_reset_title, + Messages.preferences_page_auto_approve_reset_message); + if (confirmed) { + rules.clear(); + rules.addAll(TerminalConfirmationHandler.DEFAULT_RULES.stream() + .map(r -> new TerminalAutoApproveRule( + r.getCommand(), r.isAutoApprove())) + .toList()); + tableViewer.refresh(); + updateButtonState(); + } + } + + private void updateButtonState() { + boolean hasSelection = + !tableViewer.getStructuredSelection().isEmpty(); + removeButton.setEnabled(hasSelection); + toggleButton.setEnabled(hasSelection); + if (hasSelection) { + TerminalAutoApproveRule rule = (TerminalAutoApproveRule) + tableViewer.getStructuredSelection().getFirstElement(); + toggleButton.setText(rule.isAutoApprove() + ? Messages.preferences_page_auto_approve_deny + : Messages.preferences_page_auto_approve_allow); + } else { + toggleButton.setText( + Messages.preferences_page_auto_approve_allow); + } + resetButton.setEnabled(!isMatchingDefaults()); + } + + private boolean isMatchingDefaults() { + List<TerminalAutoApproveRule> defaults = TerminalConfirmationHandler.DEFAULT_RULES; + if (rules.size() != defaults.size()) { + return false; + } + for (int i = 0; i < rules.size(); i++) { + if (!rules.get(i).getCommand().equals(defaults.get(i).getCommand()) + || rules.get(i).isAutoApprove() != defaults.get(i).isAutoApprove()) { + return false; + } + } + return true; + } + + /** Loads terminal rules and unmatched-command preference from the store. */ + public void loadFromPreferences(IPreferenceStore store) { + String json = store.getString(Constants.AUTO_APPROVE_TERMINAL_RULES); + rules.clear(); + if (StringUtils.isNotBlank(json) && !"[]".equals(json.trim())) { + try { + List<TerminalAutoApproveRule> loaded = + new Gson().fromJson(json, TERMINAL_RULES_TYPE); + if (loaded != null) { + rules.addAll(loaded); + } + } catch (Exception e) { + CopilotCore.LOGGER.error( + "Failed to parse terminal auto-approve rules from preferences", e); + } + } + tableViewer.setInput(rules); + + unmatchedCheckbox.setSelection( + store.getBoolean(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL)); + updateButtonState(); + } + + /** Saves terminal rules and unmatched-command preference to the store. */ + public void saveToPreferences(IPreferenceStore store) { + store.setValue(Constants.AUTO_APPROVE_TERMINAL_RULES, + new Gson().toJson(rules)); + store.setValue(Constants.AUTO_APPROVE_UNMATCHED_TERMINAL, + unmatchedCheckbox.getSelection()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index 2b06110a..f041db59 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -19,7 +19,7 @@ preferences_page_byok_addModel_dialog_title=Add %s Models preferences_page_byok_addModel_modelId=Model ID: * preferences_page_byok_addModel_deploymentUrl=Deployment URL: * preferences_page_byok_addModel_apiKey=API Key: * -preferences_page_byok_addModel_displayName=Display Name: * +preferences_page_byok_addModel_displayName=Display Name: preferences_page_byok_addModel_supportVision=Support Vision preferences_page_byok_addModel_supportToolCalling=Support Tool Calling preferences_page_byok_changeApi_dialog_title=Change %s API Key? @@ -34,11 +34,9 @@ preferences_page_byok_dialog_cancel=Cancel preferences_page_byok_dialog_remove=Remove preferences_page_byok_customModels=Custom Models preferences_page_byok_enabledCount=%s of %s Enabled -preferences_page_byok_preview_disabled_tip=Custom models are available to Individual Copilot Users (Pro+, Pro and Free) who have enabled the Editor preview features in their Copilot Settings. +preferences_page_byok_disabled_tip=Custom models are disabled by your organization's GitHub settings. Please contact your organization's administrator for more information. preferences_page_completions_codeMiningNote= You need to enable <a>'Code Minings'</a> to use inline completions in Eclipse 2024-12 (4.34) and later. -preferences_page_customModes_defaultDescription=Describe what this custom agent does and when to use it. -preferences_page_customModes_defaultInstructions=Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. preferences_page_completions_enableNes=Enable Next Edit Suggestions preferences_page_enable_strict_ssl= Enable Strict SSL @@ -46,7 +44,6 @@ preferences_page_whats_new_settings= What's New preferences_page_enable_whats_new= Always display the 'What's New' dialog after GitHub Copilot updates. preferences_page_enable_whats_new_tooltip= Enable this to automatically display the "What's New" page whenever GitHub Copilot is updated. preferences_page_github_enterprise= GitHub Enterprise Authentication Endpoint -preferences_page_proxy_kerberos_sp= Proxy Kerberos Service Principal preferences_page_mcp= Server Configurations preferences_page_proxy_config_link= <a>Configure Proxy</a> preferences_page_proxy_settings= Proxy @@ -74,17 +71,14 @@ preferences_page_mcp_registry_url=URL preferences_page_mcp_registry_description=URL for a <a href="https://github.com/modelcontextprotocol/registry">specification-compliant</a> MCP registry. MCP servers listed in this registry will be visible to you. preferences_page_mcp_registry_button=Open MCP Registry preferences_page_mcp_registry_restricted_info=This url is managed by {0} and cannot be modified -preferences_page_custom_instructions_copilot_instructions= Copilot Instructions preferences_page_custom_instructions_copilot_instructions_desc=Set custom instructions for Copilot Chat in this workspace. <a href=\"https://docs.github.com/copilot/how-tos/configure-custom-instructions/add-repository-instructions?tool=eclipse\">Get more info</a> preferences_page_custom_instructions_copilot_instructions_note= Use clear, short instructions for best Copilot results. preferences_page_custom_instructions_workspace= Workspace preferences_page_custom_instructions_workspace_enable= Enable workspace instructions preferences_page_custom_instructions_project= Project -preferences_page_custom_instructions_project_enable= Use Instruction files .github/copilot-instructions.md preferences_page_custom_instructions_project_intro=Set custom instructions for Copilot Chat in a specific project. <a href=\"https://docs.github.com/copilot/how-tos/configure-custom-instructions/add-repository-instructions?tool=eclipse\">Get more info</a> preferences_page_custom_instructions_project_table_projectName= Project Name preferences_page_custom_instructions_project_table_fileLocation= Project Location -preferences_page_custom_instructions_project_table_deleteButton= Delete preferences_page_custom_instructions_project_table_editButton= Edit preferences_page_custom_instructions_project_table_note= Only projects that are open in the workspace and contain a .github/copilot-instructions.md file are listed in this table. Remember to save the file to ensure the project instructions take effect. preferences_page_custom_instructions_project_editDialog_title=Close Preference Page @@ -93,18 +87,19 @@ preferences_page_custom_instructions_project_editDialog_button_close=Close Page preferences_page_custom_instructions_project_editDialog_button_stay=Stay on Page preferences_page_custom_instructions_project_file_save_reminder_title=Save Reminder preferences_page_custom_instructions_project_file_save_reminder_desc=Remember to save the file to ensure the project instructions take effect. +preferences_page_custom_instructions_chat_load_scope_label=Load custom instructions from +preferences_page_custom_instructions_chat_load_scope_all=all projects in workspace +preferences_page_custom_instructions_chat_load_scope_referenced=projects inferred from chat-attached files +preferences_page_custom_instructions_chat_load_scope_combo_tooltip=Decide which of the custom instructions will be used in the Copilot chat. preferences_page_watched_files= Enable workspace context (experimental) preferences_page_custom_instructions_git_commit= Git Commit Instructions preferences_page_custom_instructions_git_commit_desc=Set custom instructions for Copilot Chat when generating commit messages. preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu. preferences_page_watched_files_note_content= Allow the use of @workspace in Ask Mode. Enabling this feature may affect startup performance. preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now? -preferences_page_sub_agent= Enable sub-agent (experimental) +preferences_page_sub_agent= Enable sub-agent preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks. preferences_page_restart_required= Restart Required -preferences_page_mcpOAuth_confirmTitle=GitHub Copilot -preferences_page_mcpOAuth_confirmMessage=The MCP Server Definition '%s' wants to authenticate to %s. - # CustomModesPreferencePage customModes_page_description=Configure custom agents stored as .agent.md files in .github/agents directory. customModes_table_column_modeName=Agent Name @@ -141,6 +136,66 @@ preferences_page_agent_max_requests=Agent Max Requests: preferences_page_agent_max_requests_desc=The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue. preferences_page_agent_max_requests_validation_error=Agent Max Requests must be a number between 1 and 500. +# Skills +preferences_page_skills_enabled=Enable Skills +preferences_page_skills_enabled_note_content=Controls whether agent skills can be used to enrich chat context. + # enterprise support setting_disabled_by_organization=This setting is disabled by your organization. setting_managed_by_organization=This setting is managed by your organization. + + +preferences_page_auto_approve_disabled_by_organization=Tool auto-approval rules are disabled by your organization's administrator. Please contact your organization's administrator for more information. + +# Shared Auto Approve strings (reusable by all auto-approve sections) +preferences_page_auto_approve_column_status=Auto Approve +preferences_page_auto_approve_add=Add... +preferences_page_auto_approve_remove=Remove +preferences_page_auto_approve_reset=Reset to Defaults +preferences_page_auto_approve_reset_message=This will replace all current rules with the defaults. Continue? +preferences_page_auto_approve_allow=Allow +preferences_page_auto_approve_deny=Deny +preferences_page_auto_approve_add_dialog_approve=Auto Approve: + +# Terminal Auto Approve +preferences_page_terminal_auto_approve_title=Terminal Auto Approve +preferences_page_terminal_auto_approve_description=Controls whether chat-initiated terminal commands are automatically approved. Set to Allow to auto approve matching commands; set to Deny to always require explicit approval. +preferences_page_terminal_auto_approve_column_command=Command +preferences_page_terminal_auto_approve_reset_title=Reset Terminal Auto Approve Rules +preferences_page_terminal_auto_approve_unmatched=Auto approve commands not covered by rules +preferences_page_terminal_auto_approve_unmatched_note=When enabled, terminal commands not covered by the rules above are automatically approved. Use this setting at your own discretion. +preferences_page_terminal_auto_approve_add_dialog_title=Add Terminal Command Rule +preferences_page_terminal_auto_approve_add_dialog_message=Enter the command name or regex pattern (e.g., npm, git, /^apt-get\\b/) +preferences_page_terminal_auto_approve_add_dialog_command=Command or Regex: +preferences_page_terminal_auto_approve_add_dialog_placeholder=e.g., npm, git, /^apt-get\\b/ + +# File Operations Auto Approve +preferences_page_file_op_auto_approve_title=File Operations Auto Approve +preferences_page_file_op_auto_approve_description=Controls whether file operations generated by Copilot are approved automatically. Rules only apply to files within the workspace; operations on files outside the workspace always require confirmation. Set to Allow to auto approve operations on matching files; set to Deny to always require explicit approval. +preferences_page_file_op_auto_approve_column_pattern=Pattern +preferences_page_file_op_auto_approve_column_description=Description +preferences_page_file_op_auto_approve_reset_title=Reset File Operations Auto Approve Rules +preferences_page_file_op_auto_approve_unmatched=Auto approve file operations not covered by rules +preferences_page_file_op_auto_approve_unmatched_note=When enabled, file operations not covered by the rules above are automatically approved. File operations outside the workspace always require confirmation. +preferences_page_file_op_auto_approve_add_dialog_title=Add File Operation Auto Approve Rule +preferences_page_file_op_auto_approve_add_dialog_message=Enter the glob pattern (e.g., **/.idea/**/* or **/*.config) +preferences_page_file_op_auto_approve_add_dialog_pattern=Pattern: +preferences_page_file_op_auto_approve_add_dialog_description=Description: +preferences_page_file_op_auto_approve_add_dialog_pattern_hint=e.g., **/.idea/**/* or **/*.config +preferences_page_file_op_auto_approve_add_dialog_description_hint=Optional description +preferences_page_file_op_auto_approve_duplicate_title=Duplicate Pattern +preferences_page_file_op_auto_approve_duplicate_message=A rule for this pattern already exists. + +# MCP Auto Approve +preferences_page_mcp_auto_approve_title=MCP Auto Approve +preferences_page_mcp_auto_approve_trust_annotations=Trust MCP tool annotations +preferences_page_mcp_auto_approve_trust_annotations_note=When enabled, Copilot uses MCP tool annotations to automatically approve read-only tool calls without confirmation. +preferences_page_mcp_auto_approve_server_tools_label=MCP Server and Tool Approval + +# Global Auto Approve +preferences_page_global_auto_approve_title=Global Auto Approve +preferences_page_global_auto_approve_label=Auto approve all tool calls +preferences_page_global_auto_approve_confirm_title=Enable Global Auto Approve? +preferences_page_global_auto_approve_confirm_message=Global auto-approve disables manual approval completely for all tools. When enabled, ALL tool calls (terminal commands, file edits, built-in tools, and MCP tools) will be automatically approved without any confirmation. This is extremely dangerous and is never recommended. +preferences_page_global_auto_approve_confirm_button=Confirm +preferences_page_global_auto_approve_cancel_button=Cancel diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java index 92f63ca9..6799c4a0 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownButton.java @@ -111,6 +111,7 @@ public void mouseExit(MouseEvent e) { */ public void setItemGroups(List<DropdownItemGroup> itemGroups) { this.itemGroups = itemGroups; + updateWidthHint(); redraw(); } @@ -191,7 +192,7 @@ private void paintControl(GC gc) { gc.fillRectangle(bounds); gc.setForeground(getForeground()); - String text = selected != null ? selected.getLabel() : ""; + String text = resolveDisplayText(selected); Point textExtent = gc.textExtent(text); int contentHeight = getContentHeight(textExtent, selectedIcon, arrowIcon); int contentTop = Math.max(0, (bounds.height - contentHeight) / 2); @@ -214,6 +215,14 @@ private void paintControl(GC gc) { } } + private String resolveDisplayText(DropdownItem selected) { + if (selected == null) { + return ""; + } + String selectedLabel = selected.getSelectedLabel(); + return selectedLabel != null ? selectedLabel : selected.getLabel(); + } + private DropdownItem findItemById(String id) { if (id == null || itemGroups == null) { return null; @@ -233,7 +242,7 @@ public Point computeSize(int widthHint, int heightHint, boolean changed) { GC gc = new GC(this); try { DropdownItem selected = findItemById(selectedItemId); - String text = selected != null ? selected.getLabel() : ""; + String text = resolveDisplayText(selected); Point textExtent = gc.textExtent(text.isEmpty() ? "M" : text); Image selectedIcon = getSelectedItemIcon(selected); int iconWidth = 0; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownItem.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownItem.java index 3a1b97e6..5edb3824 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownItem.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownItem.java @@ -32,6 +32,7 @@ public final class DropdownItem { private final String id; private final String label; + private final String selectedLabel; private final Image icon; private final String suffix; private final String tooltip; @@ -45,6 +46,7 @@ public final class DropdownItem { private DropdownItem(Builder builder) { this.id = builder.id; this.label = builder.label; + this.selectedLabel = builder.selectedLabel; this.icon = builder.icon; this.suffix = builder.suffix; this.tooltip = builder.tooltip; @@ -82,6 +84,19 @@ public String getLabel() { return label; } + /** + * Returns the optional label shown on the {@link DropdownButton} button face when this item is selected, or + * {@code null} when the regular {@link #getLabel() label} should be used. + * + * <p>Use this to render a richer or differently-formatted text on the button than what appears in the dropdown row + * (e.g. {@code "GPT-5.3-Codex - Medium"} on the button while the row only shows {@code "GPT-5.3-Codex"}). + * + * @return the button-face label override, or {@code null} + */ + public String getSelectedLabel() { + return selectedLabel; + } + /** * Returns the optional leading icon, or {@code null}. * @@ -159,6 +174,7 @@ public static final class Builder { private String id; private String label = ""; + private String selectedLabel; private Image icon; private String suffix; private String tooltip; @@ -189,6 +205,19 @@ public Builder label(String label) { return this; } + /** + * Sets the optional button-face label override. When this item is the selected one on a {@link DropdownButton}, + * the button paints this text instead of {@link #label(String)}. Pass {@code null} (the default) to reuse the + * row label on the button face. + * + * @param selectedLabel the button-face label, or {@code null} + * @return this builder + */ + public Builder selectedLabel(String selectedLabel) { + this.selectedLabel = selectedLabel; + return this; + } + /** * Sets the optional leading icon displayed to the left of the label. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java index 62a9d898..344eb79c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/DropdownPopup.java @@ -14,7 +14,6 @@ import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.events.MouseTrackAdapter; import org.eclipse.swt.graphics.Color; -import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.Rectangle; @@ -29,6 +28,7 @@ import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.PlatformUI; +import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; @@ -48,10 +48,8 @@ class DropdownPopup { private static final int ICON_TEXT_GAP = 6; private static final int LABEL_SUFFIX_GAP = 12; private static final int BORDER_ARC = 8; - private static final int ITEM_FOCUS_ARC = 6; private static final int MAX_VISIBLE_ITEMS = 15; - private static final int SHORT_POPUP_WIDTH = 230; - private static final int LONG_POPUP_WIDTH = 300; + private static final int SHORT_POPUP_WIDTH = 250; private static Image checkIcon; @@ -60,24 +58,24 @@ class DropdownPopup { private Consumer<String> selectionListener; private String selectedItemId; - private record ItemEntry(DropdownItem item, Composite composite) {} + private record ItemEntry(DropdownItem item, Composite composite, ItemController row) {} private final List<ItemEntry> items = new ArrayList<>(); private int focusedIndex = -1; private Listener keyboardFilter; private Shell hoverShell; + private Composite hoverAnchorItem; + private Runnable hoverPollRunnable; private ScrolledComposite scrolledComposite; private final IStylingEngine stylingEngine; private final Control anchorControl; - private static final String POPUP_ITEM_DEFAULT_ID = "popup-item-default"; - private static final String POPUP_ITEM_FOCUSED_ID = "popup-item-focused"; - private static final String POPUP_ITEM_SELECTED_ID = "popup-item-selected"; private static final String POPUP_SECONDARY_TEXT_CLASS = "popup-secondary-text"; private static final String POPUP_ACTION_TEXT_CLASS = "popup-action-text"; - private static final String POPUP_ITEM_BASE_ID_KEY = "dropdownPopupItemBaseId"; - private static final String POPUP_ITEM_FOCUSED_KEY = "dropdownPopupItemFocused"; + // Poll interval used to detect when the cursor has truly left both the dropdown item and the hover shell. + // Mirrors the approach used by BaseHoverPopup so the hover survives the transit gap between the two shells. + private static final int HOVER_POLL_INTERVAL_MS = 100; DropdownPopup(Shell parentShell, Control anchorControl) { this.parentShell = parentShell; @@ -183,6 +181,9 @@ void open(Point location, List<DropdownItemGroup> groups, String selectedItemId, if (anchorControl != null && !anchorControl.isDisposed() && isCursorInsideControl(anchorControl)) { return; } + if (hoverShell != null && !hoverShell.isDisposed() && hoverShell.isVisible()) { + return; + } close(); }); @@ -219,6 +220,7 @@ void open(Point location, List<DropdownItemGroup> groups, String selectedItemId, shell.pack(); constrainHeightIfNeeded(); + reserveScrollbarSpaceIfNeeded(contentSize); adjustBounds(location, anchorHeight); scrollToFocusedItem(); shell.setVisible(true); @@ -234,14 +236,71 @@ private void constrainHeightIfNeeded() { Rectangle lastBounds = lastVisible.getBounds(); int maxContentHeight = lastBounds.y + lastBounds.height; - int scrollBarWidth = 0; - if (scrolledComposite.getVerticalBar() != null) { - scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; - } - Point shellSize = shell.getSize(); int newHeight = maxContentHeight + 2 * POPUP_MARGIN; - shell.setSize(shellSize.x + scrollBarWidth, newHeight); + shell.setSize(shellSize.x, newHeight); + } + + /** + * Ensures the vertical scrollbar never hides item content, which manifests differently across platforms. + * + * <ul> + * <li><b>Classic (space-reserving) scrollbars</b> (typical on Windows and many GTK themes) shrink the + * {@link ScrolledComposite} client area. With {@link ScrolledComposite#setExpandHorizontal(boolean)} the + * content is stretched to that reduced width, so the shell must be widened by the reserved trough width to + * restore the full natural content width.</li> + * <li><b>Overlay scrollbars</b> (the default on modern GNOME, e.g. RHEL 9) do <em>not</em> reduce the client + * area; the scrollbar is painted on top of the right edge of the content, covering the right-aligned suffix + * labels. Widening the shell alone does not help because the suffix stays pinned to the (new) right edge, + * still under the overlay. Instead we stop horizontal expansion, keep the content at its natural width, and + * widen the shell so an empty scrollbar-width strip is left on the right for the overlay to paint over.</li> + * </ul> + * + * @param contentSize the natural (unconstrained) size of the item container + */ + private void reserveScrollbarSpaceIfNeeded(Point contentSize) { + if (items.size() <= MAX_VISIBLE_ITEMS || items.isEmpty()) { + return; + } + + if (scrolledComposite == null || scrolledComposite.isDisposed() || scrolledComposite.getVerticalBar() == null) { + return; + } + + // Fix #113: We adapt the drop-down dialog's size only for Linux (where the issue appeared), + // in order to avoid potential regressions, + // see https://github.com/microsoft/copilot-for-eclipse/pull/285#issuecomment-4686907980 + if (PlatformUtils.isLinux()) { + // Force a layout so the client area reflects the constrained height before we measure it. + // The layout operation doesn't lead to flickering, since it is not user-visible at this point. + shell.layout(true, true); + int scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; + if (scrollBarWidth <= 0) { + return; + } + Rectangle clientArea = scrolledComposite.getClientArea(); + Point shellSize = shell.getSize(); + if (clientArea.width < contentSize.x) { + // Classic scrollbar: the trough shrank the client area. Widen the shell to restore full content width. + int deficit = contentSize.x - clientArea.width; + shell.setSize(shellSize.x + deficit, shellSize.y); + } else { + // Overlay scrollbar: client area was not reduced, so the bar paints over the content's right edge. Pin the + // content to its natural width and reserve just enough room on the right for the overlay. + scrolledComposite.setExpandHorizontal(false); + Control content = scrolledComposite.getContent(); + if (content != null && !content.isDisposed()) { + content.setSize(contentSize); + } + int extraWidth = Math.max(0, scrollBarWidth - ITEM_H_PADDING); + shell.setSize(shellSize.x + extraWidth, shellSize.y); + } + } else { + // Behavior before fixing #113 + int scrollBarWidth = scrolledComposite.getVerticalBar().getSize().x; + Point shellSize = shell.getSize(); + shell.setSize(shellSize.x + scrollBarWidth, shellSize.y); + } } private void adjustBounds(Point location, int anchorHeight) { @@ -305,10 +364,10 @@ private void addGroupHeader(Composite parent, String text) { headerComp.setLayout(layout); Label headerLabel = new Label(headerComp, SWT.NONE); headerLabel.setText(text); - headerLabel.setData(CssConstants.CSS_ID_KEY, POPUP_ITEM_DEFAULT_ID); + headerLabel.setData(CssConstants.CSS_ID_KEY, ItemController.CSS_DEFAULT_ID); setCssClassOnly(headerLabel, POPUP_SECONDARY_TEXT_CLASS); headerLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - applyCssIdRecursively(headerComp, POPUP_ITEM_DEFAULT_ID); + applyCssIdRecursively(headerComp, ItemController.CSS_DEFAULT_ID); } private void addSeparator(Composite parent) { @@ -328,7 +387,8 @@ private void addSeparator(Composite parent) { private void addItem(Composite parent, DropdownItem item) { final Display display = parent.getDisplay(); final boolean isSelected = item.getId() != null && item.getId().equals(selectedItemId); - final String itemBaseCssId = isSelected ? POPUP_ITEM_SELECTED_ID : POPUP_ITEM_DEFAULT_ID; + final String itemBaseCssId = isSelected ? ItemController.CSS_SELECTED_ID + : ItemController.CSS_DEFAULT_ID; Composite itemComp = new Composite(parent, SWT.NONE); itemComp.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); @@ -337,10 +397,8 @@ private void addItem(Composite parent, DropdownItem item) { itemLayout.marginHeight = ITEM_V_PADDING; itemLayout.horizontalSpacing = ICON_TEXT_GAP; itemComp.setLayout(itemLayout); - itemComp.addPaintListener(e -> paintFocusedItemBackground(itemComp, e.gc)); - items.add(new ItemEntry(item, itemComp)); - final int itemIndex = items.size() - 1; + final int itemIndex = items.size(); List<Control> controls = new ArrayList<>(); controls.add(itemComp); @@ -381,7 +439,8 @@ private void addItem(Composite parent, DropdownItem item) { setCssClassOnly(suffixLabel, POPUP_SECONDARY_TEXT_CLASS); controls.add(suffixLabel); - setPopupItemState(itemComp, itemBaseCssId, false); + ItemController rowController = ItemController.attach(itemComp, stylingEngine, itemBaseCssId); + items.add(new ItemEntry(item, itemComp, rowController)); String tooltip = item.getTooltip(); MouseTrackAdapter hoverTracker = buildHoverTracker(item, itemComp, itemIndex); @@ -412,9 +471,14 @@ public void mouseEnter(MouseEvent e) { } updateFocusBorder(focusedIndex, true); } - if (item.getHoverProvider() != null - && (!alreadyFocused || hoverShell == null || hoverShell.isDisposed())) { - openHoverShell(item, itemComp); + if (item.getHoverProvider() != null) { + if (!alreadyFocused || hoverShell == null || hoverShell.isDisposed()) { + openHoverShell(item, itemComp); + } + } else { + // Entering an item without a hover provider should clear any lingering hover that was kept alive + // while the cursor was over the dropdown body (e.g. on the scrollbar or transit gap). + closeHoverShell(); } } @@ -423,11 +487,17 @@ public void mouseExit(MouseEvent e) { if (!itemComp.isDisposed() && isCursorInsideControl(itemComp)) { return; } + if (hoverShell != null && !hoverShell.isDisposed() && isCursorInsideControl(hoverShell)) { + // Cursor moved into the hover shell (e.g. to interact with controls there); keep the hover open. + return; + } if (!itemComp.isDisposed() && itemIndex == focusedIndex) { focusedIndex = -1; updateFocusBorder(itemIndex, false); } - closeHoverShell(); + // Don't close the hover synchronously -- the cursor may be in the transit gap between the item and the + // hover shell. The polling loop started in openHoverShell will close the hover once the cursor truly + // leaves both regions. If no hover is open we have nothing to defer. } }; } @@ -470,9 +540,12 @@ private void openHoverShell(DropdownItem item, Composite anchorItem) { if (shell == null || shell.isDisposed()) { return; } + hoverAnchorItem = anchorItem; hoverShell = new Shell(shell, SWT.NO_TRIM | SWT.ON_TOP); hoverShell.setData(CssConstants.CSS_ID_KEY, "dropdown-popup"); final Display display = hoverShell.getDisplay(); + Color popupBg = CssConstants.getPopupBgColor(display); + hoverShell.setBackground(popupBg); styleControl(hoverShell); GridLayout layout = new GridLayout(1, false); layout.marginWidth = ITEM_H_PADDING; @@ -481,14 +554,17 @@ private void openHoverShell(DropdownItem item, Composite anchorItem) { Composite hoverContent = new Composite(hoverShell, SWT.NONE); hoverContent.setData(CssConstants.CSS_ID_KEY, "dropdown-popup"); + hoverContent.setBackground(popupBg); styleControl(hoverContent); hoverContent.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); GridLayout contentLayout = new GridLayout(1, false); contentLayout.marginWidth = 0; contentLayout.marginHeight = 0; + contentLayout.marginTop = ITEM_V_PADDING; + contentLayout.marginBottom = ITEM_V_PADDING; hoverContent.setLayout(contentLayout); - item.getHoverProvider().configureHover(hoverContent, item); + item.getHoverProvider().configureHover(hoverContent, item, this::close); final Color borderColor = CssConstants.getBorderColor(display); hoverShell.addPaintListener(e -> { @@ -500,11 +576,9 @@ private void openHoverShell(DropdownItem item, Composite anchorItem) { hoverShell.pack(); Point hoverSize = hoverShell.getSize(); - int width = hoverSize.x <= SHORT_POPUP_WIDTH ? SHORT_POPUP_WIDTH : LONG_POPUP_WIDTH; - if (width != hoverSize.x) { - // Recompute size at the target width so wrapped labels lay out correctly. - hoverSize = hoverShell.computeSize(width, SWT.DEFAULT); - hoverSize.x = width; + if (hoverSize.x < SHORT_POPUP_WIDTH) { + hoverSize = hoverShell.computeSize(SHORT_POPUP_WIDTH, SWT.DEFAULT); + hoverSize.x = SHORT_POPUP_WIDTH; hoverShell.setSize(hoverSize); } @@ -520,16 +594,72 @@ private void openHoverShell(DropdownItem item, Composite anchorItem) { } else { x = popupBounds.x - hoverSize.x; } - int y = Math.max(screen.y, Math.min(itemLoc.y, screen.y + screen.height - hoverSize.y)); + // Vertically center the hover on the hovered item, then clamp to the visible monitor area. + int anchorHeight = anchorItem.getSize().y; + int desiredY = itemLoc.y + (anchorHeight - hoverSize.y) / 2; + int y = Math.max(screen.y, Math.min(desiredY, screen.y + screen.height - hoverSize.y)); hoverShell.setLocation(x, y); hoverShell.setVisible(true); + + // Drive close detection from a polling loop instead of MouseTrackAdapter.mouseExit so the hover survives the + // tiny cursor transit gap between the source item and the hover shell. The loop also closes the dropdown + // itself when the cursor leaves both regions -- needed because Deactivate is suppressed while the hover is + // visible (see the SWT.Deactivate listener registered on the main shell). + startHoverPolling(); } private void closeHoverShell() { + stopHoverPolling(); if (hoverShell != null && !hoverShell.isDisposed()) { hoverShell.dispose(); } hoverShell = null; + hoverAnchorItem = null; + } + + private void startHoverPolling() { + stopHoverPolling(); + if (hoverShell == null || hoverShell.isDisposed()) { + return; + } + final Display display = hoverShell.getDisplay(); + hoverPollRunnable = new Runnable() { + @Override + public void run() { + if (hoverShell == null || hoverShell.isDisposed()) { + return; + } + boolean overHover = isCursorInsideControl(hoverShell); + boolean overDropdown = shell != null && !shell.isDisposed() && isCursorInsideControl(shell); + if (!overHover && !overDropdown) { + closeHoverShell(); + // Deactivate is suppressed while the hover is visible, so dismiss the dropdown here once the + // cursor has truly left every region we treat as "stay". + close(); + return; + } + display.timerExec(HOVER_POLL_INTERVAL_MS, this); + } + }; + display.timerExec(HOVER_POLL_INTERVAL_MS, hoverPollRunnable); + } + + private void stopHoverPolling() { + if (hoverPollRunnable == null) { + return; + } + Display display = null; + if (hoverShell != null && !hoverShell.isDisposed()) { + display = hoverShell.getDisplay(); + } else if (shell != null && !shell.isDisposed()) { + display = shell.getDisplay(); + } else { + display = SwtUtils.getDisplay(); + } + if (display != null && !display.isDisposed()) { + display.timerExec(-1, hoverPollRunnable); + } + hoverPollRunnable = null; } private void moveFocus(int delta) { @@ -570,18 +700,15 @@ private void updateFocusBorder(int index, boolean focused) { if (index < 0 || index >= items.size()) { return; } - Composite itemComp = items.get(index).composite(); - if (!itemComp.isDisposed()) { - if (focused) { - String baseId = (String) itemComp.getData(POPUP_ITEM_BASE_ID_KEY); - setPopupItemState(itemComp, baseId != null ? baseId : POPUP_ITEM_DEFAULT_ID, true); - } else { - DropdownItem item = items.get(index).item(); - String baseId = item.getId() != null && item.getId().equals(selectedItemId) ? POPUP_ITEM_SELECTED_ID - : POPUP_ITEM_DEFAULT_ID; - setPopupItemState(itemComp, baseId, false); - } + ItemEntry entry = items.get(index); + if (entry.composite().isDisposed()) { + return; } + String baseId = entry.item().getId() != null && entry.item().getId().equals(selectedItemId) + ? ItemController.CSS_SELECTED_ID + : ItemController.CSS_DEFAULT_ID; + entry.row().setBaseCssId(baseId); + entry.row().setFocused(focused); } private void activateFocusedItem() { @@ -620,35 +747,6 @@ boolean isOpen() { return shell != null && !shell.isDisposed() && shell.isVisible(); } - private void setPopupItemState(Composite control, String baseCssId, boolean focused) { - String currentBaseCssId = (String) control.getData(POPUP_ITEM_BASE_ID_KEY); - boolean currentFocused = Boolean.TRUE.equals(control.getData(POPUP_ITEM_FOCUSED_KEY)); - if (focused == currentFocused && baseCssId.equals(currentBaseCssId)) { - return; - } - control.setData(POPUP_ITEM_BASE_ID_KEY, baseCssId); - control.setData(POPUP_ITEM_FOCUSED_KEY, focused); - control.setRedraw(false); - applyCssId(control, baseCssId); - for (Control child : control.getChildren()) { - applyCssIdRecursively(child, focused ? POPUP_ITEM_FOCUSED_ID : baseCssId); - } - control.setRedraw(true); - } - - private void paintFocusedItemBackground(Composite itemComp, GC gc) { - if (!Boolean.TRUE.equals(itemComp.getData(POPUP_ITEM_FOCUSED_KEY))) { - return; - } - Rectangle bounds = itemComp.getClientArea(); - if (bounds.width <= 0 || bounds.height <= 0) { - return; - } - gc.setAntialias(SWT.ON); - gc.setBackground(CssConstants.getPopupItemFocusBgColor(itemComp.getDisplay())); - gc.fillRoundRectangle(0, 0, bounds.width, bounds.height, ITEM_FOCUS_ARC, ITEM_FOCUS_ARC); - } - private void applyCssId(Control control, String cssId) { if (!cssId.equals(control.getData(CssConstants.CSS_ID_KEY))) { control.setData(CssConstants.CSS_ID_KEY, cssId); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/IDropdownItemHoverProvider.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/IDropdownItemHoverProvider.java index 29858e10..25e2efea 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/IDropdownItemHoverProvider.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/IDropdownItemHoverProvider.java @@ -22,6 +22,8 @@ public interface IDropdownItemHoverProvider { * * @param parent the parent composite inside the hover shell * @param item the item being hovered + * @param closeRequest invoked by interactive hover content (e.g. clickable rows) to request that the host dropdown + * dismiss itself, including this hover shell; never {@code null} */ - void configureHover(Composite parent, DropdownItem item); + void configureHover(Composite parent, DropdownItem item, Runnable closeRequest); } \ No newline at end of file diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java new file mode 100644 index 00000000..92726b19 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ItemController.java @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.swt; + +import org.eclipse.e4.ui.services.IStylingEngine; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.events.MouseTrackAdapter; +import org.eclipse.swt.events.MouseTrackListener; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +/** + * Controller that owns the focus/hover state and paint for a single popup item row. Shared by + * {@link DropdownPopup} (model picker items) and {@link ModelHoverContentProvider} (thinking effort + * rows). + * + * <p>A controller is attached to a row composite via {@link #attach(Composite, IStylingEngine, String)}. + * It then: + * <ul> + * <li>tracks a <em>base</em> CSS id ({@link #CSS_DEFAULT_ID} or {@link #CSS_SELECTED_ID}) reflecting + * whether the row represents the currently selected entry,</li> + * <li>tracks a <em>focused</em> flag indicating whether the row is currently highlighted (mouse hover + * or keyboard focus),</li> + * <li>propagates the resulting CSS id to every descendant control so the focused background paints + * behind child labels,</li> + * <li>paints a rounded background fill on the row while focused.</li> + * </ul> + */ +public final class ItemController { + + /** CSS id applied to a default (non-selected, non-focused) popup item row and its descendants. */ + public static final String CSS_DEFAULT_ID = "popup-item-default"; + + /** CSS id applied to the popup item row that represents the currently selected entry. */ + public static final String CSS_SELECTED_ID = "popup-item-selected"; + + /** CSS id applied to descendants of a popup item row that is currently highlighted. */ + public static final String CSS_FOCUSED_ID = "popup-item-focused"; + + /** Corner radius of the rounded background fill painted on focused popup item rows. */ + public static final int FOCUS_ARC = 6; + + /** + * Key under which a row composite stores its attached controller via {@link Composite#setData(String, Object)} so + * sibling rows can discover and reset each other's focused state from {@link #installHoverFocus(Control...)}. + */ + private static final String CONTROLLER_DATA_KEY = "com.microsoft.copilot.eclipse.ui.swt.ItemController"; + + private final Composite row; + private final IStylingEngine styling; + private String baseCssId; + private boolean focused; + + private ItemController(Composite row, IStylingEngine styling, String baseCssId) { + this.row = row; + this.styling = styling; + this.baseCssId = baseCssId; + } + + /** + * Attaches a controller to {@code row}: installs the focused-background paint listener and seeds the + * initial (non-focused) CSS state on the row and every existing descendant. + * + * @param row the row composite to manage; children should already be created so the initial CSS state + * propagates to them + * @param styling the workbench styling engine (may be {@code null} when CSS styling is unavailable) + * @param baseCssId the initial base CSS id (typically {@link #CSS_DEFAULT_ID} or {@link #CSS_SELECTED_ID}) + * @return the attached controller + */ + public static ItemController attach(Composite row, IStylingEngine styling, String baseCssId) { + ItemController controller = new ItemController(row, styling, baseCssId); + row.setData(CONTROLLER_DATA_KEY, controller); + row.addPaintListener(e -> controller.paintFocusedBackground(e.gc)); + controller.applyState(); + return controller; + } + + /** + * Updates the base CSS id (default vs. selected). No-op when unchanged. + * + * @param newBaseCssId the new base CSS id; ignored when {@code null} + */ + public void setBaseCssId(String newBaseCssId) { + if (newBaseCssId == null || newBaseCssId.equals(this.baseCssId)) { + return; + } + this.baseCssId = newBaseCssId; + applyState(); + } + + /** + * Updates the focused flag. No-op when unchanged. + * + * @param newFocused whether the row should be rendered as focused + */ + public void setFocused(boolean newFocused) { + if (newFocused == this.focused) { + return; + } + this.focused = newFocused; + applyState(); + } + + /** + * Installs simple per-row hover focus behavior on the row and every supplied child: entering any of + * them flips the row to focused, leaving all of them flips it back. Useful when the row owns its hover + * state independently of any parent (no shared keyboard focus across siblings). + * + * <p>Null entries in {@code children} are ignored. The row itself is always tracked. + * + * @param children child controls of the row that should also propagate hover focus + */ + public void installHoverFocus(Control... children) { + MouseTrackListener listener = new MouseTrackAdapter() { + @Override + public void mouseEnter(MouseEvent e) { + // Force-clear any sibling row that still thinks it is focused. Relying on mouseExit alone is fragile when + // SWT delivers events out of order or a tooltip on the previous row swallows the exit event, leaving two + // rows visibly highlighted at once. + clearSiblingFocus(); + setFocused(true); + } + + @Override + public void mouseExit(MouseEvent e) { + if (isCursorInside(row)) { + return; + } + setFocused(false); + } + }; + row.addMouseTrackListener(listener); + if (children == null) { + return; + } + for (Control c : children) { + if (c != null && c != row && !c.isDisposed()) { + c.addMouseTrackListener(listener); + } + } + } + + /** + * Resets the focused state on every sibling row in {@link #row}'s parent that has an attached controller. Used by + * {@link #installHoverFocus(Control...)} so the parent group is guaranteed to have at most one focused row at a + * time, even when {@code mouseExit} fails to deliver on the previously hovered row. + */ + private void clearSiblingFocus() { + if (row.isDisposed()) { + return; + } + Composite parent = row.getParent(); + if (parent == null || parent.isDisposed()) { + return; + } + for (Control sibling : parent.getChildren()) { + if (sibling == row || sibling.isDisposed()) { + continue; + } + Object data = sibling.getData(CONTROLLER_DATA_KEY); + if (data instanceof ItemController siblingController) { + siblingController.setFocused(false); + } + } + } + + private void applyState() { + if (row.isDisposed() || baseCssId == null) { + return; + } + String descendantCssId = focused ? CSS_FOCUSED_ID : baseCssId; + row.setRedraw(false); + applyCssId(row, baseCssId); + for (Control child : row.getChildren()) { + applyCssIdRecursively(child, descendantCssId); + } + row.setRedraw(true); + row.redraw(); + } + + private void paintFocusedBackground(GC gc) { + if (row.isDisposed() || !focused) { + return; + } + Rectangle bounds = row.getClientArea(); + if (bounds.width <= 0 || bounds.height <= 0) { + return; + } + gc.setAntialias(SWT.ON); + gc.setBackground(CssConstants.getPopupItemFocusBgColor(row.getDisplay())); + gc.fillRoundRectangle(0, 0, bounds.width, bounds.height, FOCUS_ARC, FOCUS_ARC); + } + + private void applyCssId(Control control, String cssId) { + if (control.isDisposed()) { + return; + } + if (!cssId.equals(control.getData(CssConstants.CSS_ID_KEY))) { + control.setData(CssConstants.CSS_ID_KEY, cssId); + if (styling != null) { + styling.style(control); + } + } + } + + private void applyCssIdRecursively(Control control, String cssId) { + applyCssId(control, cssId); + if (control instanceof Composite composite) { + for (Control child : composite.getChildren()) { + applyCssIdRecursively(child, cssId); + } + } + } + + private static boolean isCursorInside(Control control) { + if (control.isDisposed()) { + return false; + } + Point cursor = control.getDisplay().getCursorLocation(); + Point loc = control.toDisplay(0, 0); + Point size = control.getSize(); + return cursor.x >= loc.x && cursor.x < loc.x + size.x && cursor.y >= loc.y && cursor.y < loc.y + size.y; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java index 396a4be3..425a6b54 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java @@ -3,33 +3,54 @@ package com.microsoft.copilot.eclipse.ui.swt; +import java.util.List; + import org.apache.commons.lang3.StringUtils; import org.eclipse.e4.ui.services.IStylingEngine; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; +import org.eclipse.swt.events.MouseAdapter; +import org.eclipse.swt.events.MouseEvent; import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Cursor; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.ui.PlatformUI; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Renders the full hover UI for model items in the model picker dropdown. The layout consists of the bold title - * header, an optional degradation warning row, a separator, and model-specific details such as family and cost. + * Renders the full hover UI for model items in the model picker dropdown. The layout consists of the bold title header, + * an optional category badge, an optional degradation warning, and model-specific details such as context window and + * token pricing. */ public class ModelHoverContentProvider implements IDropdownItemHoverProvider { private static final int SECTION_SPACING = 3; private static final String POPUP_SECONDARY_TEXT_CLASS = "popup-secondary-text"; + /** Horizontal padding inside a thinking effort row, so the hover background has breathing room. */ + private static final int THINKING_EFFORT_ROW_H_PADDING = 4; + /** Vertical padding inside a thinking effort row, so the hover background has breathing room. */ + private static final int THINKING_EFFORT_ROW_V_PADDING = 2; + + private static Image effortCheckIcon; + private final CopilotModel model; private final IStylingEngine stylingEngine; @@ -44,7 +65,7 @@ public ModelHoverContentProvider(CopilotModel model) { } @Override - public void configureHover(Composite parent, DropdownItem item) { + public void configureHover(Composite parent, DropdownItem item, Runnable closeRequest) { renderHeader(parent, item); if (StringUtils.isNotBlank(model.getModelPickerCategory())) { @@ -56,41 +77,274 @@ public void configureHover(Composite parent, DropdownItem item) { addWarningRow(parent, model.getDegradationReason()); } - addSeparator(parent); - - // Details section - Composite detailsComp = new Composite(parent, SWT.NONE); - GridData detailsGd = new GridData(SWT.FILL, SWT.FILL, true, false); - detailsGd.verticalIndent = SECTION_SPACING; - detailsComp.setLayoutData(detailsGd); - GridLayout detailsLayout = new GridLayout(1, false); - detailsLayout.marginWidth = 0; - detailsLayout.marginHeight = 0; - detailsLayout.marginBottom = SECTION_SPACING; - detailsLayout.verticalSpacing = 2; - detailsComp.setLayout(detailsLayout); + addCustomModelInfoSection(parent, item.getLabel()); - // Family - if (StringUtils.isNotBlank(model.getModelFamily())) { - addDetailRow(detailsComp, Messages.model_hover_family, model.getModelFamily()); - } - - // Cost - String cost = buildCostText(); - if (StringUtils.isNotBlank(cost)) { - addDetailRow(detailsComp, Messages.model_hover_cost, cost); - } + addContextWindowSection(parent); + addPricingSection(parent, model.getModelPickerPriceCategory()); + addThinkingEffortSection(parent, closeRequest); } private void renderHeader(Composite parent, DropdownItem item) { Label titleLabel = new Label(parent, SWT.WRAP); titleLabel.setText(item.getLabel()); titleLabel.setFont(createBoldFont(titleLabel)); - GridData headerGd = new GridData(SWT.FILL, SWT.CENTER, true, false); - headerGd.verticalIndent = SECTION_SPACING; + GridData headerGd = new GridData(SWT.FILL, SWT.NONE, true, false); titleLabel.setLayoutData(headerGd); } + /** + * Renders the "contributed by" line for organization/enterprise-contributed custom (BYOK) models. Mirrors the + * IntelliJ model tooltip: the row only appears when the model carries custom-model metadata with a non-blank owner + * and key name, communicating that the model was provided by an owner through a specific key. + * + * @param parent the hover composite to render into + * @param displayedName the model name as shown in the hover header + */ + private void addCustomModelInfoSection(Composite parent, String displayedName) { + CopilotModelCustomModel customModel = model.getCustomModel(); + if (customModel == null) { + return; + } + String ownerName = StringUtils.trimToNull(customModel.ownerName()); + String keyName = StringUtils.trimToNull(customModel.keyName()); + if (ownerName == null || keyName == null) { + return; + } + + String modelLabel = StringUtils.defaultIfBlank(displayedName, model.getModelName()); + String infoText = NLS.bind(Messages.model_hover_customModelInfo, + new Object[] { modelLabel, ownerName, keyName }); + Label infoLabel = new Label(parent, SWT.WRAP); + infoLabel.setText(infoText); + setCssClass(infoLabel, POPUP_SECONDARY_TEXT_CLASS); + GridData gd = new GridData(SWT.FILL, SWT.NONE, true, false); + gd.verticalIndent = SECTION_SPACING; + infoLabel.setLayoutData(gd); + } + + private void addContextWindowSection(Composite parent) { + String contextWindowText = ModelUtils.getContextWindowText(model); + if (StringUtils.isBlank(contextWindowText)) { + return; + } + + addSeparator(parent); + addKeyValueRow(parent, Messages.model_hover_contextWindow, contextWindowText); + } + + private void addPricingSection(Composite parent, String priceCategory) { + String costSymbols = ModelUtils.formatPriceCategory(priceCategory); + if (StringUtils.isBlank(costSymbols)) { + return; + } + + addSeparator(parent); + addKeyValueRow(parent, Messages.model_hover_cost, costSymbols); + } + + private void addThinkingEffortSection(Composite parent, Runnable closeRequest) { + if (!ModelUtils.supportsReasoningEffortLevel(model)) { + return; + } + CopilotModelCapabilitiesSupports supports = model.getCapabilities().supports(); + List<String> efforts = supports.reasoningEfforts(); + if (efforts == null || efforts.isEmpty()) { + return; + } + + addSeparator(parent); + + Composite section = new Composite(parent, SWT.NONE); + section.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + GridLayout sectionLayout = new GridLayout(1, false); + sectionLayout.marginWidth = 0; + sectionLayout.marginHeight = 0; + sectionLayout.verticalSpacing = 2; + ((GridData) section.getLayoutData()).verticalIndent = SECTION_SPACING; + section.setLayout(sectionLayout); + + Label keyLabel = createSecondaryTextLabel(section, Messages.model_hover_thinkingEffort); + keyLabel.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); + + Composite options = new Composite(section, SWT.NONE); + options.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + GridLayout optionsLayout = new GridLayout(1, false); + optionsLayout.marginWidth = 0; + optionsLayout.marginHeight = 0; + optionsLayout.verticalSpacing = 2; + options.setLayout(optionsLayout); + + populateThinkingEffortOptions(options, efforts, closeRequest); + } + + private void populateThinkingEffortOptions(Composite options, List<String> efforts, Runnable closeRequest) { + ModelService modelService = resolveModelService(); + String selected = modelService != null ? modelService.getSelectedReasoningEffort(model) : null; + String defaultEffort = ModelUtils.resolveDefaultReasoningEffort(model); + // Show the user's selection when present, otherwise pre-mark the default so the hover always communicates + // which effort the request will use. + String effective = StringUtils.isNotBlank(selected) ? selected : defaultEffort; + + for (String effort : efforts) { + if (StringUtils.isBlank(effort)) { + continue; + } + addThinkingEffortOption(options, modelService, effort, effort.equals(effective), effort.equals(defaultEffort), + closeRequest); + } + options.requestLayout(); + } + + private void addThinkingEffortOption(Composite parent, ModelService modelService, String effort, boolean isSelected, + boolean isDefault, Runnable closeRequest) { + String displayText = ModelUtils.formatReasoningEffortLevel(effort); + if (displayText == null) { + return; + } + + // Three-column layout mirroring the model item row in DropdownPopup: a fixed-width leading icon column that + // reserves space for the selection check mark, the left-aligned effort label, and the right-aligned secondary + // description that grows to fill the remaining width. + GridLayout rowLayout = new GridLayout(3, false); + rowLayout.marginWidth = THINKING_EFFORT_ROW_H_PADDING; + rowLayout.marginHeight = THINKING_EFFORT_ROW_V_PADDING; + rowLayout.horizontalSpacing = 6; + Composite row = new Composite(parent, SWT.NONE); + row.setLayout(rowLayout); + row.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + + Image checkIcon = getCheckIcon(parent); + Label iconLabel = new Label(row, SWT.NONE); + GridData iconGd = new GridData(SWT.LEFT, SWT.CENTER, false, false); + if (checkIcon != null) { + Rectangle iconBounds = checkIcon.getBounds(); + iconGd.widthHint = iconBounds.width; + iconGd.heightHint = iconBounds.height; + } else { + iconGd.widthHint = 12; + } + iconLabel.setLayoutData(iconGd); + if (isSelected && checkIcon != null) { + iconLabel.setImage(checkIcon); + } + + String labelText = isDefault ? NLS.bind(Messages.model_hover_thinkingEffort_default_suffix, displayText) + : displayText; + Label optionLabel = new Label(row, SWT.NONE); + optionLabel.setText(labelText); + // Primary text color (default Label foreground); left-aligned in the middle column. + optionLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + String description = ModelUtils.formatReasoningEffortDescription(effort); + Label descriptionLabel = null; + if (StringUtils.isNotBlank(description)) { + descriptionLabel = new Label(row, SWT.NONE); + descriptionLabel.setText(description); + // Right-aligned and grabs the remaining horizontal space so the description hugs the right edge of the + // hover popup while the effort label stays anchored to the left. + descriptionLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, true, false)); + setCssClass(descriptionLabel, POPUP_SECONDARY_TEXT_CLASS); + } + + Cursor handCursor = parent.getDisplay().getSystemCursor(SWT.CURSOR_HAND); + row.setCursor(handCursor); + iconLabel.setCursor(handCursor); + optionLabel.setCursor(handCursor); + if (descriptionLabel != null) { + descriptionLabel.setCursor(handCursor); + } + + // Attach the shared row controller: seeds CSS state on the row + its descendants and installs the + // focused-background paint listener so hover/keyboard focus can flip the entire subtree to the + // focused color via the shared CSS rules. + ItemController rowController = ItemController.attach(row, stylingEngine, ItemController.CSS_DEFAULT_ID); + rowController.installHoverFocus(iconLabel, optionLabel, descriptionLabel); + + MouseAdapter clickHandler = new MouseAdapter() { + @Override + public void mouseDown(MouseEvent e) { + if (e.button != 1 || modelService == null) { + return; + } + // Persist the chosen effort first, then activate this model. Activating triggers the picker button + // to update its label/suffix so the dropdown control reflects the (model, effort) pair the user just + // chose -- even when they clicked an effort on a non-active model. + modelService.setSelectedReasoningEffort(model, effort); + modelService.setActiveModel(model.getModelName()); + // Close the entire dropdown (hover + main popup) via the host-provided callback so the user sees an + // immediate dismiss. Next time the dropdown opens, refreshBoundModelPickers (invoked from + // setSelectedReasoningEffort) has updated the model row's suffix to reflect the newly selected effort. + if (closeRequest != null) { + closeRequest.run(); + } + } + }; + + Control[] interactiveControls = descriptionLabel != null + ? new Control[] { row, iconLabel, optionLabel, descriptionLabel } + : new Control[] { row, iconLabel, optionLabel }; + for (Control c : interactiveControls) { + c.addMouseListener(clickHandler); + } + } + + private static ModelService resolveModelService() { + CopilotUi plugin = CopilotUi.getPlugin(); + if (plugin == null || plugin.getChatServiceManager() == null) { + return null; + } + return plugin.getChatServiceManager().getModelService(); + } + + private void addKeyValueRow(Composite parent, String keyText, String valueText) { + Composite row = createKeyValueRow(parent); + ((GridData) row.getLayoutData()).verticalIndent = SECTION_SPACING; + + Label keyLabel = createSecondaryTextLabel(row, keyText); + keyLabel.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); + + Label valueLabel = createSecondaryTextLabel(row, valueText); + valueLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.NONE, false, false)); + } + + private Composite createKeyValueRow(Composite parent) { + Composite row = new Composite(parent, SWT.NONE); + row.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); + GridLayout layout = new GridLayout(2, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + row.setLayout(layout); + return row; + } + + private static void disposeStaticIcons() { + if (effortCheckIcon != null && !effortCheckIcon.isDisposed()) { + effortCheckIcon.dispose(); + effortCheckIcon = null; + } + } + + /** + * Returns the cached check-mark image used to indicate the selected thinking effort row, lazily loaded on first + * access. The icon shares the asset used by the dropdown popup so the leading column lines up visually with the + * checkmarks shown next to selected model items. + */ + private static Image getCheckIcon(Composite parent) { + if (effortCheckIcon == null || effortCheckIcon.isDisposed()) { + effortCheckIcon = UiUtils.isDarkTheme() + ? UiUtils.buildImageFromPngPath("/icons/dropdown/dropdown_complete_status_dark.png") + : UiUtils.buildImageFromPngPath("/icons/dropdown/dropdown_complete_status.png"); + if (parent != null && !parent.isDisposed()) { + parent.getDisplay().addListener(SWT.Dispose, e -> disposeStaticIcons()); + } + } + return effortCheckIcon; + } + + private static boolean isPositive(Integer value) { + return value != null && value > 0; + } + private Font createBoldFont(Label label) { Display display = label.getDisplay(); FontData[] fontData = label.getFont().getFontData(); @@ -106,14 +360,14 @@ private void addWarningRow(Composite parent, String warningText) { Label warningLabel = new Label(parent, SWT.WRAP); warningLabel.setText(warningText); setCssClass(warningLabel, POPUP_SECONDARY_TEXT_CLASS); - warningLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + warningLabel.setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); } - private void addDetailRow(Composite parent, String label, String value) { - Label detailLabel = new Label(parent, SWT.NONE); - detailLabel.setText(label + value); - setCssClass(detailLabel, POPUP_SECONDARY_TEXT_CLASS); - detailLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + private Label createSecondaryTextLabel(Composite parent, String text) { + Label label = new Label(parent, SWT.NONE); + label.setText(text); + setCssClass(label, POPUP_SECONDARY_TEXT_CLASS); + return label; } private void setCssClass(Label control, String className) { @@ -125,17 +379,9 @@ private void setCssClass(Label control, String className) { } } - private String buildCostText() { - String suffix = ModelUtils.getModelSuffix(model); - if (model.getBilling() != null && model.getBilling().isPremium() && StringUtils.isNotBlank(suffix)) { - return suffix + " " + Messages.model_hover_cost_premium; - } - return suffix; - } - private void addSeparator(Composite parent) { Composite separator = new Composite(parent, SWT.NONE); - GridData gd = new GridData(SWT.FILL, SWT.CENTER, true, false); + GridData gd = new GridData(SWT.FILL, SWT.NONE, true, false); gd.heightHint = 1; gd.verticalIndent = SECTION_SPACING; separator.setLayoutData(gd); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java new file mode 100644 index 00000000..7ebafdb5 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SpinnerAnimator.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.swt; + +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; + +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * Drives a rotating spinner animation on a target {@link Label}. + * + * <p>The animator owns the lifecycle of the per-frame {@link Image} resources: each new frame is + * loaded, the previous one is disposed, and {@link #stop()} guarantees that the label no longer + * holds a reference to a disposed image. After {@link #stop()} the caller is free to swap in a + * final image (e.g. a "completed" icon) on the same label. + * + * <p>The animator hooks the target label's dispose listener so the animation is cancelled and the + * running frame is freed automatically when the label goes away. + */ +public final class SpinnerAnimator { + /** Total number of frames in the spinner animation under {@code /icons/spinner/}. */ + private static final int TOTAL_FRAMES = 8; + /** Per-frame interval in milliseconds. */ + private static final int FRAME_INTERVAL_MS = 100; + + private final Label target; + private Image currentFrameImage; + private int currentFrame = 1; + private Runnable animationRunnable; + + /** + * Create an animator that will rotate spinner frames on the given label. + * + * @param target the label to update with each frame; must not be {@code null} + */ + public SpinnerAnimator(Label target) { + this.target = target; + target.addDisposeListener(e -> stop()); + } + + /** + * Start (or restart) the animation. Safe to call when already running — the existing animation + * is cancelled first. + */ + public void start() { + if (target.isDisposed()) { + return; + } + stop(); + currentFrame = 1; + final Display display = target.getDisplay(); + animationRunnable = new Runnable() { + @Override + public void run() { + if (target.isDisposed()) { + return; + } + // Dispose the previous frame before loading the next one. + if (currentFrameImage != null && !currentFrameImage.isDisposed()) { + currentFrameImage.dispose(); + } + currentFrameImage = buildFrame(currentFrame); + target.setImage(currentFrameImage); + // Request layout so the icon scale stays correct as frames change. + target.requestLayout(); + currentFrame = (currentFrame % TOTAL_FRAMES) + 1; + display.timerExec(FRAME_INTERVAL_MS, this); + } + }; + display.timerExec(0, animationRunnable); + } + + /** + * Stop the animation and release the frame image. Detaches the image from the target label + * before disposing it so the label never points at a disposed image. Safe to call repeatedly. + */ + public void stop() { + if (animationRunnable != null && !target.isDisposed()) { + target.getDisplay().timerExec(-1, animationRunnable); + } + animationRunnable = null; + // Detach the image from the label before disposing it so the label never points at a + // disposed image. Callers that want a final icon (completed/cancelled/error) set it + // immediately after stop(), avoiding any visible flicker. + if (!target.isDisposed() && target.getImage() == currentFrameImage) { + target.setImage(null); + } + if (currentFrameImage != null && !currentFrameImage.isDisposed()) { + currentFrameImage.dispose(); + } + currentFrameImage = null; + } + + private static Image buildFrame(int frame) { + return UiUtils.buildImageFromPngPath(String.format("/icons/spinner/%d.png", frame)); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java similarity index 89% rename from com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java rename to com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java index 55aa6301..c1e908d8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/dialogs/mcp/DropDownButton.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/SplitDropdownButton.java @@ -14,7 +14,7 @@ * The Eclipse Foundation - initial API and implementation *******************************************************************************/ -package com.microsoft.copilot.eclipse.ui.dialogs.mcp; +package com.microsoft.copilot.eclipse.ui.swt; import java.util.ArrayList; import java.util.List; @@ -36,12 +36,18 @@ import org.eclipse.swt.widgets.Shell; /** - * A drop down button from Eclipse Foundation that can show a menu when the arrow is clicked. + * A split button that distinguishes between clicking the label area (primary action) + * and clicking the arrow area (opens a dropdown menu). Originally from Eclipse Foundation. + * + * <p>Selection listeners receive {@code e.detail == SWT.ARROW} when the arrow + * area is clicked, and {@code e.detail == 0} for the primary area. */ -public class DropDownButton { +public class SplitDropdownButton { private boolean showArrow; + private Color separatorColor; + private Rectangle arrowBounds; private String padding = null; @@ -71,9 +77,10 @@ public void paintControl(PaintEvent e) { try { int inset = 3; int lineX = arrowBounds.x; + Color lineColor = separatorColor != null ? separatorColor : shadowColor; gc.setLineWidth(1); - gc.setForeground(shadowColor); - gc.setBackground(shadowColor); + gc.setForeground(lineColor); + gc.setBackground(lineColor); gc.drawLine(lineX, arrowBounds.y + inset - 1, lineX, e.y + buttonBounds.height - inset); Color arrowColor = button.getForeground(); @@ -93,12 +100,12 @@ public void paintControl(PaintEvent e) { }; /** - * Creates a new DropDownButton. + * Creates a new SplitButton. * * @param parent the parent composite * @param style the style of button */ - public DropDownButton(Composite parent, int style) { + public SplitDropdownButton(Composite parent, int style) { button = new Button(parent, SWT.PUSH); } @@ -125,6 +132,17 @@ public boolean isShowArrow() { return showArrow; } + /** + * Overrides the separator line color. When {@code null} (default), + * the system shadow color is used. + * + * @param color the color for the separator, or {@code null} for the default + */ + public void setSeparatorColor(Color color) { + this.separatorColor = color; + button.redraw(); + } + /** * Sets the text of the button. * @@ -291,7 +309,8 @@ private DropDownSelectionListenerWrapper findWrapper(final SelectionListener lis * @param listener the listener to remove */ public void removeSelectionListener(SelectionListener listener) { - DropDownSelectionListenerWrapper wrapper = findWrapper(listener); + DropDownSelectionListenerWrapper wrapper = + findWrapper(listener); if (wrapper != null) { button.removeSelectionListener(wrapper); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java new file mode 100644 index 00000000..7af8ba99 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/MenuUtils.java @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.osgi.util.NLS; + +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.Quota; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Shared helpers used by the Copilot status-bar and menu-bar usage menus. + */ +public final class MenuUtils { + + private static final DateTimeFormatter ALLOWANCE_RESET_DATE_FORMATTER = + DateTimeFormatter.ofPattern("MMMM d, yyyy"); + + private MenuUtils() { + } + + /** + * Returns the localized plan label for the given plan, or {@code null} if the plan is unknown. + */ + public static String getPlanLabel(CopilotPlan plan) { + if (plan == null) { + return null; + } + switch (plan) { + case free: + return Messages.menu_quota_plan_free; + case individual: + return Messages.menu_quota_plan_individual; + case individual_pro: + return Messages.menu_quota_plan_individualPro; + case individual_max: + return Messages.menu_quota_plan_individualMax; + case business: + return Messages.menu_quota_plan_business; + case enterprise: + return Messages.menu_quota_plan_enterprise; + default: + return null; + } + } + + /** + * Returns the percent-remaining used to pick the usage icon, based on the user's plan. + */ + public static double calculatePercentRemaining(CheckQuotaResult quotaStatus) { + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + if (plan == CopilotPlan.free) { + Quota completionsQuota = quotaStatus.completions(); + Quota chatQuota = quotaStatus.chat(); + if (completionsQuota == null || chatQuota == null) { + return 100; + } + return Math.min(completionsQuota.percentRemaining(), chatQuota.percentRemaining()); + } + if (premiumQuota != null) { + return premiumQuota.percentRemaining(); + } + return 100; + } + + /** + * Returns the image descriptor for the usage row based on the lowest percentRemaining. + */ + public static ImageDescriptor getUsageIcon(double percentRemaining) { + if (percentRemaining <= 10) { + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_red.png"); + } + if (percentRemaining <= 25) { + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_yellow.png"); + } + return UiUtils.buildImageDescriptorFromPngPath("/icons/quota/usage_blue.png"); + } + + /** + * Returns the shared blank icon descriptor used for indented usage rows. + */ + public static ImageDescriptor getBlankIcon() { + return UiUtils.buildImageDescriptorFromPngPath("/icons/blank.png"); + } + + /** + * True when the user is on a Business / Enterprise plan with no monthly premium-interactions limit. + */ + public static boolean isOrgUnlimited(CheckQuotaResult quotaStatus) { + CopilotPlan plan = quotaStatus.copilotPlan(); + Quota premiumQuota = quotaStatus.premiumInteractions(); + return (plan == CopilotPlan.business || plan == CopilotPlan.enterprise) + && premiumQuota != null && premiumQuota.unlimited(); + } + + /** + * True when the user has a non-org premium-interactions quota - i.e. a paid plan with a + * populated, non-org-unlimited {@link CheckQuotaResult#premiumInteractions()}. This single + * predicate gates both the Monthly limit display row and the overage upsell row ("Enable + * Additional Usage" / "Increase Budget"): without metered premium data the upsell has no data + * to act on and would mislead the user. + */ + public static boolean hasNonOrgPremiumQuota(CheckQuotaResult quotaStatus) { + if (quotaStatus.copilotPlan() == CopilotPlan.free) { + return false; + } + if (isOrgUnlimited(quotaStatus)) { + return false; + } + return quotaStatus.premiumInteractions() != null; + } + + /** + * True when the "Upgrade Plan" row should be shown for the given plan. + * + * <p>{@code canUpgradePlan} is the language server's authoritative signal of whether the user is eligible + * to upgrade. When supplied (non-{@code null}) it takes precedence over the plan-based default; when + * {@code null} (older language server that does not yet send this field) we fall back to the previous + * plan-only heuristic. + * + * @param plan the user's Copilot plan + * @param canUpgradePlan whether the user can upgrade their Copilot plan, or {@code null} when the language + * server did not supply this field + */ + public static boolean shouldShowUpgradePlanRow(CopilotPlan plan, Boolean canUpgradePlan) { + if (canUpgradePlan != null) { + return canUpgradePlan; + } + return plan == CopilotPlan.free || plan == CopilotPlan.individual || plan == CopilotPlan.individual_pro; + } + + /** + * True when the plan is a CFI (Copilot for Individuals) plan: individual, individual_pro, or + * individual_max. + */ + public static boolean isCfiPlan(CopilotPlan plan) { + return plan == CopilotPlan.individual || plan == CopilotPlan.individual_pro + || plan == CopilotPlan.individual_max; + } + + /** + * Returns the label for the overage upsell row depending on the current overage state. + */ + public static String getOverageRowLabel(Quota premiumQuota) { + boolean overageEnabled = premiumQuota != null && premiumQuota.overagePermitted(); + return overageEnabled ? Messages.menu_quota_increaseBudget : Messages.menu_quota_enableAdditionalUsage; + } + + /** + * Returns the label for the "Additional usage" status row shown below the allowance-reset row + * for paid users when token-based billing is enabled. Renders as + * {@code "Additional usage enabled"} or {@code "Additional usage not enabled"} depending on + * {@link Quota#overagePermitted()}. + */ + public static String getAdditionalUsageRowLabel(Quota premiumQuota) { + boolean overageEnabled = premiumQuota != null && premiumQuota.overagePermitted(); + return Messages.menu_quota_additionalPremiumRequests + + (overageEnabled ? Messages.menu_quota_enabled : Messages.menu_quota_disabled); + } + + /** + * Returns the tooltip for the "Additional usage" status row, or {@code null} when no tooltip + * applies. Business / Enterprise plans receive a plan-specific tooltip; other plans currently + * have no tooltip. + */ + public static String getAdditionalUsageRowTooltip(CheckQuotaResult quotaStatus) { + CopilotPlan plan = quotaStatus.copilotPlan(); + boolean isOrg = plan == CopilotPlan.business || plan == CopilotPlan.enterprise; + if (!isOrg) { + return null; + } + Quota premiumQuota = quotaStatus.premiumInteractions(); + boolean overageEnabled = premiumQuota != null && premiumQuota.overagePermitted(); + return overageEnabled + ? Messages.menu_quota_additionalUsageOrgEnabledTooltip + : Messages.menu_quota_additionalUsageOrgNotConfiguredTooltip; + } + + /** + * True when the allowance-reset row should be shown. The row is hidden when there is no monthly + * allowance to reset (premium-interactions quota is unlimited), when no reset date was supplied, + * or when the supplied reset date cannot be parsed. + */ + public static boolean shouldShowAllowanceResetRow(CheckQuotaResult quotaStatus) { + Quota premiumQuota = quotaStatus.premiumInteractions(); + if (premiumQuota != null && premiumQuota.unlimited()) { + return false; + } + return parseResetDate(quotaStatus).isPresent(); + } + + /** + * True when none of the quotas tracked for the user's plan have any usage yet. For free plans this + * means both the chat and completions quotas are at 0% used; for paid plans this means the premium + * interactions quota is at 0% used. + */ + public static boolean noUsageYet(CheckQuotaResult quotaStatus) { + if (quotaStatus.copilotPlan() == CopilotPlan.free) { + return isUnused(quotaStatus.chat()) && isUnused(quotaStatus.completions()); + } + return isUnused(quotaStatus.premiumInteractions()); + } + + /** + * Formats the allowance-reset row label. Returns {@link Messages#menu_quota_noUsageYet} when the + * user has not consumed any of the tracked quotas yet (see {@link #noUsageYet}); otherwise + * returns {@code "Resets today"} / {@code "Reset in 1 day on {date}"} / {@code "Reset in {n} + * days on {date}"} depending on {@code n}. The 0-day case intentionally omits the date since it + * is implied by "today". + * + * <p>Callers <strong>must</strong> gate with {@link #shouldShowAllowanceResetRow}; this method + * assumes a parseable reset date is present. + */ + public static String formatAllowanceReset(CheckQuotaResult quotaStatus) { + if (noUsageYet(quotaStatus)) { + return Messages.menu_quota_noUsageYet; + } + LocalDate resetDate = parseResetDate(quotaStatus).orElseThrow( + () -> new IllegalStateException("formatAllowanceReset called without a parseable reset date; " + + "callers must gate with shouldShowAllowanceResetRow")); + long days = Math.max(0, ChronoUnit.DAYS.between(LocalDate.now(), resetDate)); + String formattedDate = resetDate.format(ALLOWANCE_RESET_DATE_FORMATTER); + if (days == 0) { + return Messages.menu_quota_allowanceReset_today; + } + if (days == 1) { + return NLS.bind(Messages.menu_quota_allowanceReset_singular, formattedDate); + } + return NLS.bind(Messages.menu_quota_allowanceReset_plural, days, formattedDate); + } + + /** + * Parses the reset date supplied by the language server, preferring {@code resetDateUtc} (an ISO + * instant resolved against the local time zone) over {@code resetDate} (a local ISO date) since + * the instant form carries more precision. Returns an empty optional when neither field is + * parseable. + */ + private static Optional<LocalDate> parseResetDate(CheckQuotaResult quotaStatus) { + String utc = quotaStatus.resetDateUtc(); + if (StringUtils.isNotBlank(utc)) { + try { + return Optional.of(Instant.parse(utc).atZone(ZoneId.systemDefault()).toLocalDate()); + } catch (DateTimeParseException e) { + CopilotCore.LOGGER.error("Unparseable quota resetDateUtc: " + utc, e); + } + } + String local = quotaStatus.resetDate(); + if (StringUtils.isNotBlank(local)) { + try { + return Optional.of(LocalDate.parse(local)); + } catch (DateTimeParseException e) { + CopilotCore.LOGGER.error("Unparseable quota resetDate: " + local, e); + } + } + return Optional.empty(); + } + + /** + * True when the quota has been measured but no allowance has been consumed yet (matches the + * {@code "0% used"} display threshold in {@code QuotaTextCalculator#getPercentUsed}). Unlimited + * quotas are treated as "used" so that the no-usage message is not shown when the limit is + * irrelevant. + */ + private static boolean isUnused(Quota quota) { + if (quota == null || quota.unlimited()) { + return false; + } + return (100 - quota.percentRemaining()) < 0.1; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java index ecae245c..5919742d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java @@ -6,12 +6,18 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import org.apache.commons.lang3.StringUtils; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilities; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelTokenPriceTier; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities; import com.microsoft.copilot.eclipse.ui.i18n.Messages; /** @@ -43,10 +49,14 @@ public static CopilotModel convertByokModelToCopilotModel(ByokModel byokModel) { } copilotModel.setScopes(scopes); - if (byokModel.getModelCapabilities() != null) { - CopilotModel.CopilotModelCapabilitiesSupports supports = new CopilotModel.CopilotModelCapabilitiesSupports( - byokModel.getModelCapabilities().isVision()); - copilotModel.setCapabilities(new CopilotModel.CopilotModelCapabilities(supports)); + ByokModelCapabilities byokCapabilities = byokModel.getModelCapabilities(); + if (byokCapabilities != null) { + CopilotModelCapabilitiesSupports supports = new CopilotModelCapabilitiesSupports( + byokCapabilities.isVision(), null, false); + // BYOK only exposes input/output token limits; context window and non-streaming output are unknown. + CopilotModelCapabilitiesLimits limits = new CopilotModelCapabilitiesLimits(null, + byokCapabilities.getMaxOutputTokens(), byokCapabilities.getMaxInputTokens(), null); + copilotModel.setCapabilities(new CopilotModelCapabilities(supports, limits)); } copilotModel.setBilling(null); copilotModel.setPreview(false); @@ -68,41 +78,300 @@ public static String formatBillingMultiplier(double multiplier) { return multiplierValue.toPlainString() + Messages.model_billing_multiplier_suffix; } + /** + * Separator used between parts of the model picker suffix (e.g. "1M - High - $$$"). + */ + private static final String SUFFIX_PART_SEPARATOR = " - "; + /** * Returns the display suffix for a model in the model picker. * + * <p>The suffix is composed of multiple parts joined by {@value #SUFFIX_PART_SEPARATOR} (e.g. context window, + * reasoning effort, price tier). For BYOK models the provider name is used as-is, and the {@code Auto} model uses + * a fixed {@code Variable} label. + * * @param model the model + * @param reasoningEffort the effective reasoning effort to display, or {@code null} to omit * @return the suffix string, or an empty string if no suffix applies */ - public static String getModelSuffix(CopilotModel model) { + public static String getModelSuffix(CopilotModel model, String reasoningEffort) { + if (model == null) { + return ""; + } if (model.getProviderName() != null) { return model.getProviderName(); } - if (model.getBilling() != null) { - return formatBillingMultiplier(model.getBilling().multiplier()); + // Organization/enterprise-contributed custom models arrive through copilot/models without a providerName, but + // still carry their underlying provider in the custom-model metadata. Surface it so they read like BYOK models. + if (model.getCustomModel() != null && StringUtils.isNotBlank(model.getCustomModel().provider())) { + return model.getCustomModel().provider(); } - if ("Auto".equals(model.getModelName())) { + if (isAutoModel(model)) { return Messages.model_billing_multiplier_variable; } - return ""; + // TODO: Remove this legacy fallback after TBB is officially released. + // When token-based billing is not enabled on the language server side, fall back to the + // original multiplier suffix (e.g. "1x", "1.5x") instead of the context-window | price tier + // form that depends on the TBB-only capability metadata. + if (model.getBilling() != null && !model.getBilling().tokenBasedBillingEnabled()) { + return formatBillingMultiplier(model.getBilling().multiplier()); + } + return String.join(SUFFIX_PART_SEPARATOR, buildSuffixParts(model, reasoningEffort)); + } + + /** + * Builds the ordered list of suffix parts for a model. Add new parts (e.g. thinking effort) here in the desired + * display order. Blank values are filtered out by the caller. + */ + private static List<String> buildSuffixParts(CopilotModel model, String reasoningEffort) { + List<String> parts = new ArrayList<>(); + addIfNotBlank(parts, getContextWindowText(model)); + // Only surface a reasoning-effort suffix when the language server has explicitly advertised the model as + // supporting selectable effort levels. The server only sets supportsReasoningEffortLevel when the model has + // more than one effort level AND is hosted on a compatible endpoint. + if (supportsReasoningEffortLevel(model)) { + addIfNotBlank(parts, formatReasoningEffortLevel(reasoningEffort)); + } + addIfNotBlank(parts, formatPriceCategory(model.getModelPickerPriceCategory())); + return parts; + } + + private static void addIfNotBlank(List<String> parts, String value) { + if (StringUtils.isNotBlank(value)) { + parts.add(value); + } } /** - * Composes tooltip text for a model item in the model picker. + * Formats the model picker price category as a dollar-sign tier: {@code Low} -> {@code $}, {@code Medium} -> + * {@code $$}, {@code High} -> {@code $$$}. Returns {@code null} for blank or unrecognized values. * - * @param model the model to compose tooltip for - * @param suffix the suffix shown next to the model name in the picker - * @return the tooltip text + * @param priceCategory the model picker price category (e.g. {@code Low}, {@code Medium}, {@code High}) + * @return the dollar-sign tier string, or {@code null} if the category is blank or unrecognized */ - public static String getModelTooltipText(CopilotModel model, String suffix) { - if (model == null) { - return ""; + public static String formatPriceCategory(String priceCategory) { + if (StringUtils.isBlank(priceCategory)) { + return null; + } + switch (priceCategory.toLowerCase()) { + case "low": + return "$"; + case "medium": + return "$$"; + case "high": + return "$$$"; + default: + return null; + } + } + + /** + * Returns the formatted context window size for the model, or {@code null} if unavailable. + */ + public static String getContextWindowText(CopilotModel model) { + Integer contextWindow = resolveContextWindowSize(model); + if (contextWindow == null || contextWindow <= 0) { + return null; + } + return formatTokenCount(contextWindow); + } + + /** + * Resolves the user-facing context window size for the model, mirroring the language-server / IntelliJ behavior. + * + * <p>When the model advertises a {@code default} price tier with its own {@code maxContext} (the input budget), the + * full window is {@code maxContext + maxOutputTokens}. Otherwise, token-based billing models fall back to + * {@code maxInputTokens + maxOutputTokens}, and finally to the advertised {@code maxContextWindowTokens}. + * + * @param model the model + * @return the context window size in tokens, or {@code null} when it cannot be determined + */ + public static Integer resolveContextWindowSize(CopilotModel model) { + if (model.getCapabilities() == null || model.getCapabilities().limits() == null) { + return null; + } + CopilotModelCapabilitiesLimits limits = model.getCapabilities().limits(); + Integer maxOutputTokens = limits.maxOutputTokens(); + int output = maxOutputTokens == null ? 0 : maxOutputTokens; + + CopilotModelTokenPriceTier defaultTier = getDefaultTokenPriceTier(model); + if (defaultTier != null && defaultTier.maxContext() != null) { + return defaultTier.maxContext() + output; + } + + // TODO: Remove this legacy fallback after TBB is officially released. + if (isTokenBasedBillingEnabled(model)) { + Integer maxInputTokens = limits.maxInputTokens(); + if (maxInputTokens != null && maxOutputTokens != null) { + return maxInputTokens + maxOutputTokens; + } + } + + return limits.maxContextWindowTokens(); + } + + // TODO: Remove this legacy fallback after TBB is officially released. + private static boolean isTokenBasedBillingEnabled(CopilotModel model) { + return model.getBilling() != null && model.getBilling().tokenBasedBillingEnabled(); + } + + /** + * Returns the model's {@code default} token price tier, or {@code null} when the model carries no token-based + * pricing. + */ + private static CopilotModelTokenPriceTier getDefaultTokenPriceTier(CopilotModel model) { + if (model.getBilling() == null || model.getBilling().tokenPrices() == null) { + return null; + } + return model.getBilling().tokenPrices().defaultTier(); + } + + /** + * Formats a token count into a compact human-readable string (e.g. 128K, 1M, 1.5M). + * + * @param tokens the token count + * @return the formatted string + */ + public static String formatTokenCount(int tokens) { + if (tokens >= 1_000_000 && tokens % 1_000_000 == 0) { + return tokens / 1_000_000 + "M"; + } else if (tokens >= 1_000_000) { + String formatted = String.format("%.1f", tokens / 1_000_000.0); + formatted = formatted.replaceAll("0+$", "").replaceAll("\\.$", ""); + return formatted + "M"; + } else if (tokens >= 1_000 && tokens % 1_000 == 0) { + return tokens / 1_000 + "K"; + } else if (tokens >= 1_000) { + String formatted = String.format("%.1f", tokens / 1_000.0); + formatted = formatted.replaceAll("0+$", "").replaceAll("\\.$", ""); + return formatted + "K"; + } + return String.valueOf(tokens); + } + + /** + * Returns the default reasoning effort to use when the user has not made a selection. Prefers {@code medium} when + * it is supported, falling back to the first entry in the supported list. Returns {@code null} when the model + * does not surface selectable reasoning effort levels (see {@link #supportsReasoningEffortLevel(CopilotModel)}). + * + * @param model the model + * @return the default effort identifier, or {@code null} when none can be determined + */ + public static String resolveDefaultReasoningEffort(CopilotModel model) { + if (!supportsReasoningEffortLevel(model)) { + return null; + } + List<String> efforts = getSupportedReasoningEfforts(model); + if (efforts.isEmpty()) { + return null; + } + for (String effort : efforts) { + if ("medium".equalsIgnoreCase(effort)) { + return effort; + } + } + return efforts.get(0); + } + + /** + * Formats a reasoning effort identifier as a localized display label. Known identifiers ({@code none}, {@code low}, + * {@code medium}, {@code high}, {@code xhigh}) resolve to their localized {@code Messages} constants; unknown + * identifiers fall back to a title-cased rendering of the first character (e.g. {@code custom} -> {@code Custom}). + * Returns {@code null} when {@code effort} is blank. + * + * @param effort the effort identifier + * @return the display label, or {@code null} when blank + */ + public static String formatReasoningEffortLevel(String effort) { + if (StringUtils.isBlank(effort)) { + return null; + } + String trimmed = effort.trim(); + switch (trimmed.toLowerCase(Locale.ROOT)) { + case "none": + return Messages.model_reasoningEffort_none; + case "low": + return Messages.model_reasoningEffort_low; + case "medium": + return Messages.model_reasoningEffort_medium; + case "high": + return Messages.model_reasoningEffort_high; + case "xhigh": + return Messages.model_reasoningEffort_xhigh; + default: + return Character.toUpperCase(trimmed.charAt(0)) + trimmed.substring(1).toLowerCase(Locale.ROOT); + } + } + + /** + * Returns the localized secondary description for a reasoning effort identifier (e.g. {@code low} -> "Faster + * responses, less thorough reasoning"). Returns {@code null} when {@code effort} is blank or unrecognized. + * + * @param effort the effort identifier + * @return the localized description, or {@code null} when blank or unrecognized + */ + public static String formatReasoningEffortDescription(String effort) { + if (StringUtils.isBlank(effort)) { + return null; + } + switch (effort.trim().toLowerCase(Locale.ROOT)) { + case "none": + return Messages.model_reasoningEffort_none_description; + case "low": + return Messages.model_reasoningEffort_low_description; + case "medium": + return Messages.model_reasoningEffort_medium_description; + case "high": + return Messages.model_reasoningEffort_high_description; + case "xhigh": + return Messages.model_reasoningEffort_xhigh_description; + default: + return null; + } + } + + /** + * Returns the list of reasoning effort levels advertised by the model, or an empty list when none are advertised. + * + * @param model the model + * @return the list of supported reasoning effort identifiers (e.g. {@code low}, {@code medium}, {@code high}) + */ + public static List<String> getSupportedReasoningEfforts(CopilotModel model) { + if (model == null || model.getCapabilities() == null || model.getCapabilities().supports() == null) { + return List.of(); + } + List<String> efforts = model.getCapabilities().supports().reasoningEfforts(); + return efforts == null ? List.of() : efforts; + } + + /** + * Returns whether the model is the special "Auto" model, which dynamically routes requests to other models and + * therefore does not expose its own reasoning-effort selection. + * + * @param model the model + * @return {@code true} when the model is the Auto model + */ + public static boolean isAutoModel(CopilotModel model) { + return model != null && "Auto".equals(model.getModelName()); + } + + /** + * Returns whether the model surfaces selectable reasoning effort levels to the user. The language server only + * advertises {@code supportsReasoningEffortLevel = true} when the model has more than one effort level and is + * hosted on a compatible endpoint, so this is the canonical gate for the reasoning-effort UI and for sending a + * {@code modelInfo.reasoningEffort} payload with chat requests. The Auto model is excluded because it routes to + * other models and does not own its own effort selection. + * + * @param model the model + * @return {@code true} when the user can select a reasoning effort for this model + */ + public static boolean supportsReasoningEffortLevel(CopilotModel model) { + if (model == null || model.getCapabilities() == null || model.getCapabilities().supports() == null) { + return false; } - StringBuilder sb = new StringBuilder(); - sb.append(model.getModelName()); - if (StringUtils.isNotBlank(suffix)) { - sb.append("\n").append(String.format(Messages.model_tooltip_quota, suffix)); + if (isAutoModel(model)) { + return false; } - return sb.toString(); + return model.getCapabilities().supports().supportsReasoningEffortLevel(); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java index efd3a7c8..20f0285d 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/PreferencesUtils.java @@ -3,6 +3,16 @@ package com.microsoft.copilot.eclipse.ui.utils; +import org.eclipse.core.runtime.preferences.DefaultScope; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.jface.preference.IPreferenceStore; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.FeatureFlags; +import com.microsoft.copilot.eclipse.core.chat.CustomInstructionsChatLoadScope; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.preferences.AutoApprovePreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ByokPreferencePage; import com.microsoft.copilot.eclipse.ui.preferences.ChatPreferencesPage; import com.microsoft.copilot.eclipse.ui.preferences.CompletionsPreferencesPage; @@ -24,7 +34,61 @@ private PreferencesUtils() { public static String[] getAllPreferenceIds() { return new String[] { CopilotPreferencesPage.ID, GeneralPreferencesPage.ID, ChatPreferencesPage.ID, CompletionsPreferencesPage.ID, CustomInstructionPreferencePage.ID, CustomModesPreferencePage.ID, - McpPreferencePage.ID, ByokPreferencePage.ID }; + McpPreferencePage.ID, ByokPreferencePage.ID, AutoApprovePreferencePage.ID }; + } + + /** + * Returns whether the skills feature is enabled. Skills require both the user preference + * {@link Constants#ENABLE_SKILLS} to be set and the client preview feature flag to be enabled. + * + * @return {@code true} if skills are enabled, {@code false} otherwise + */ + public static boolean isSkillsEnabled() { + CopilotCore plugin = CopilotCore.getPlugin(); + FeatureFlags flags = plugin != null ? plugin.getFeatureFlags() : null; + return CopilotUi.getPlugin().getPreferenceStore().getBoolean(Constants.ENABLE_SKILLS) + && flags != null && flags.isClientPreviewFeatureEnabled(); + } + + /** + * Returns the current value for the scope used for loading custom instructions in the chat. + * + * @param preferenceStore the preference store to read from + * @return the current setting from {@link InstanceScope} + */ + public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScope(IPreferenceStore preferenceStore) { + return getCustomInstructionsChatLoadScopeValue(preferenceStore, false); + } + + /** + * Returns the default value for the scope used for loading custom instructions in the chat. + * + * @param preferenceStore the preference store to read from + * @return the current setting from {@link DefaultScope} + */ + public static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeDefault( + IPreferenceStore preferenceStore) { + return getCustomInstructionsChatLoadScopeValue(preferenceStore, true); + } + + private static CustomInstructionsChatLoadScope getCustomInstructionsChatLoadScopeValue( + IPreferenceStore preferenceStore, boolean readDefault) { + + String value = readDefault ? preferenceStore.getDefaultString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE) + : preferenceStore.getString(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE); + + try { + return CustomInstructionsChatLoadScope.fromValue(value); + } catch (IllegalArgumentException e) { + CopilotCore.LOGGER.error("Failed to load custom instructions scope. Falling back to default value.", e); + + if (!readDefault) { + // If the stored value is invalid, use the default value instead + preferenceStore.setValue(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, + CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); + } + return CustomInstructionsChatLoadScope.DEFAULT_VALUE; + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java index ddcaad6f..1532ee98 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ResourceUtils.java @@ -5,13 +5,17 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.IAdaptable; import org.eclipse.core.runtime.Platform; import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.WorkspaceFolder; import com.microsoft.copilot.eclipse.core.utils.FileUtils; @@ -73,6 +77,29 @@ public static SelectionStats analyzeSelection(IStructuredSelection selection) { return new SelectionStats(fileCount, folderCount, invalidCount); } + /** + * Derive workspace folders from the given list of resources by extracting their parent projects. + * Returns an unmodifiable list with distinct, accessible workspace folders. + * If the input list is <code>null</code> or empty, returns an empty list. + * + * @param resources list of resources from which their parent projects are used to derive the workspace folders. + * @return a never <code>null</code> list of workspace folders derived from the given resources. + */ + public static List<WorkspaceFolder> deriveWorkspaceFoldersFrom(List<IResource> resources) { + if (resources == null || resources.isEmpty()) { + return List.of(); + } + + return resources.stream() + .filter(Objects::nonNull) + .map(IResource::getProject) + .filter(Objects::nonNull) + .distinct() + .filter(IProject::isAccessible) + .map(LSPEclipseUtils::toWorkspaceFolder) + .toList(); + } + /** * Adapt an object to IResource. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java index ea44da6f..73180b8c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/SwtUtils.java @@ -12,15 +12,25 @@ import org.eclipse.jface.resource.ColorRegistry; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.text.ITextViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.dnd.Clipboard; import org.eclipse.swt.dnd.TextTransfer; import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Scrollable; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IWorkbench; import org.eclipse.ui.IWorkbenchPage; @@ -38,8 +48,30 @@ private SwtUtils() { } private static final String INLINE_ANNOTATION_COLOR_KEY = "org.eclipse.ui.editors.inlineAnnotationColor"; + private static final int DEFAULT_GHOST_TEXT_SCALE = 128; + /** + * Walks up the parent chain of the given control and returns the first ancestor that is an instance of the specified + * type, or {@code null} if none is found. + * + * @param <T> the target type + * @param control the starting control (may be {@code null}) + * @param type the class to search for + * @return the first matching ancestor, or {@code null} + */ + @Nullable + public static <T> T findParentOfType(Control control, Class<T> type) { + Control current = control; + while (current != null) { + if (type.isInstance(current)) { + return type.cast(current); + } + current = current.getParent(); + } + return null; + } + /** * Invokes the given runnable on the display thread. */ @@ -272,6 +304,88 @@ public static Color getDefaultGhostTextColor(Display display) { return new Color(display, new RGB(DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE, DEFAULT_GHOST_TEXT_SCALE)); } + /** + * Forwards vertical mouse wheel scrolling from a nested scrollable to its nearest parent scroller when the nested + * control is already at the scroll boundary. + */ + public static void forwardVerticalMouseWheelToParentScrollerAtBoundary(Scrollable scrollable) { + scrollable.addListener(SWT.MouseWheel, event -> { + if (event.count == 0 || scrollable.isDisposed() + || canScrollVertically(scrollable.getVerticalBar(), event.count)) { + return; + } + + ScrolledComposite parentScroller = findParentScroller(scrollable); + if (parentScroller != null + && canScrollVertically(parentScroller.getVerticalBar(), event.count)) { + event.doit = false; + scrollParentVertically(parentScroller, event.count); + } + }); + } + + private static ScrolledComposite findParentScroller(Scrollable scrollable) { + Composite parent = scrollable.getParent(); + while (parent != null) { + if (parent instanceof ScrolledComposite scrolledComposite) { + return scrolledComposite; + } + parent = parent.getParent(); + } + return null; + } + + private static void scrollParentVertically(ScrolledComposite scrolledComposite, int wheelCount) { + ScrollBar verticalBar = scrolledComposite.getVerticalBar(); + if (verticalBar == null || verticalBar.isDisposed()) { + return; + } + + Point origin = scrolledComposite.getOrigin(); + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int delta = -wheelCount * Math.max(1, verticalBar.getIncrement()); + int nextY = Math.max(minimum, Math.min(maximum, origin.y + delta)); + scrolledComposite.setOrigin(origin.x, nextY); + } + + private static boolean canScrollVertically(ScrollBar verticalBar, int wheelCount) { + if (verticalBar == null || verticalBar.isDisposed() + || !verticalBar.getEnabled()) { + return false; + } + + int minimum = verticalBar.getMinimum(); + int maximum = Math.max(minimum, + verticalBar.getMaximum() - verticalBar.getThumb()); + int selection = Math.max(minimum, + Math.min(maximum, verticalBar.getSelection())); + if (wheelCount > 0) { + return selection > minimum; + } + return selection < maximum; + } + + /** + * Resizes a table column to fill the table client area not occupied by the fixed-width columns. + */ + public static void resizeColumnToFillTable(Table table, TableColumn fillColumn, + int minWidth, TableColumn... fixedColumns) { + table.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + int remainingWidth = table.getClientArea().width; + for (TableColumn fixedColumn : fixedColumns) { + remainingWidth -= fixedColumn.getWidth(); + } + if (remainingWidth > minWidth) { + fillColumn.setWidth(remainingWidth); + } + } + }); + } + /** * Copy the given text to the clipboard. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java index 7370d76f..5d1ab2a9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java @@ -8,6 +8,8 @@ import java.io.InputStream; import java.net.URI; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -29,6 +31,8 @@ import org.eclipse.core.commands.NotHandledException; import org.eclipse.core.commands.ParameterizedCommand; import org.eclipse.core.commands.common.NotDefinedException; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.preferences.InstanceScope; @@ -221,6 +225,27 @@ public static IEditorPart openInEditor(IFile file) { return null; } + /** + * Opens the given local filesystem file in an editor. + */ + public static IEditorPart openLocalFileInEditor(Path file) { + if (file == null || !Files.exists(file)) { + CopilotCore.LOGGER.error(new IllegalArgumentException("Cannot open editor: local file is null or doesn't exist")); + return null; + } + + try { + IWorkbenchPage page = getActivePage(); + if (page != null) { + IFileStore fileStore = EFS.getLocalFileSystem().getStore(file.toUri()); + return IDE.openEditorOnFileStore(page, fileStore); + } + } catch (PartInitException e) { + CopilotCore.LOGGER.error(e); + } + return null; + } + /** * Opens the file in the editor. */ diff --git a/pom.xml b/pom.xml index bfb27179..2a9b33d6 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ <name>${base.name}</name> <properties> - <copilot-plugin-version>0.16.0-SNAPSHOT</copilot-plugin-version> + <copilot-plugin-version>0.20.0-SNAPSHOT</copilot-plugin-version> <base.name>GitHub Copilot</base.name> <tycho-version>4.0.13</tycho-version> <checkstyle-version>3.6.0</checkstyle-version> diff --git a/test-plans/token-based-billing/README.md b/test-plans/token-based-billing/README.md new file mode 100644 index 00000000..86006f98 --- /dev/null +++ b/test-plans/token-based-billing/README.md @@ -0,0 +1,106 @@ +# Copilot Plan / Quota UI Test Plans + +## Overview +Test plans for the plan- and quota-aware UI surfaces in GitHub Copilot for +Eclipse. One file per Copilot plan; each plan exercises the same three +surfaces so cross-plan differences stay easy to compare. + +All accounts under test are on token-based billing. + +## Surfaces Under Test +1. **Status-bar usage menu** — opened by clicking the Copilot icon in the + Eclipse status bar. +2. **Menu-bar usage menu** — opened from `GitHub Copilot` in the Eclipse menu + bar. Must be visually identical to the status-bar menu. +3. **Quota-warning surfaces in the chat view** + - **Static banner** shown above the chat input area when the user crosses + one of the chat-view quota-usage thresholds (see *Quota-Warning + Thresholds* below). + - **Inline warning widget** shown under a chat turn when a chat request + fails because the quota is exhausted. + + The chat-view warning surfaces are driven only by the quotas that affect + chat usage: the **Chat Messages** quota on the Free plan and the + **AI-credit (premium interactions)** quota on every paid plan. The Code + Completions quota does not trigger banners or inline warnings in the chat + view, even though it is shown in the status-bar / menu-bar menus. + +## Quota-Warning Thresholds +The static banner is pushed by the language server when the signed-in +account's tracked usage crosses one of these two thresholds: + +| Usage crossed | Banner severity / icon | +|---------------|------------------------| +| 75% | Info icon | +| 90% | Warning icon | + +Each threshold fires at most once per quota reset period. To re-trigger a +banner for the same threshold, the quota usage on the GitHub quota portal +must first be lowered back below the threshold (or the reset date must pass) +so the language server re-arms the notification. + +Per-plan cases below are ordered from **rich quota to poor quota**: usage +**under 75%** (no banner), usage **above 75%** (info banner), and usage +**above 90%** (warning banner). To exercise a specific threshold set usage +on the quota portal a few percentage points **above** the target threshold +(for example, **~76%** to cross 75% and **~91%** to cross 90%) and then send +a chat message. + +## Plan → File Map +| Copilot plan | Plan label shown in the menu | File | +|--------------------|-----------------------------------|----------------------------| +| Free | Copilot Free Plan | `TBB-test-free.md` | +| Pro | Copilot Pro Plan | `TBB-test-pro.md` | +| Pro+ | Copilot Pro+ Plan | `TBB-test-pro-plus.md` | +| Max | Copilot Max Plan | `TBB-test-max.md` | +| Business | Business Plan | `TBB-test-business.md` | +| Enterprise | Enterprise Plan | `TBB-test-enterprise.md` | + +## Common Preconditions +- Eclipse is running with the GitHub Copilot for Eclipse plugin installed. +- The tester has access to the GitHub quota portal at + `https://github.com/github-copilot/quotas/<accountId>` to adjust usage on the + signed-in account. The menus refresh in real time after a quota change; no + Eclipse restart is required. +- The Copilot chat view is open in Agent mode for chat-related cases. +- Each plan starts with a sign-in case (`TC-<plan>-000`) using an account on + that plan with token-based billing enabled. + +## Quota Action Summary +This table describes which overage action the chat-view warning surfaces are +expected to show for each plan. + +| Plan | Overage action | +|--------------|-------------------------------------------------------| +| Free | — | +| Pro | Enable Additional Usage / Increase Budget (primary) | +| Pro+ | Enable Additional Usage / Increase Budget (primary) | +| Max | Enable Additional Usage / Increase Budget (primary) | +| Business | — | +| Enterprise | — | + +Notes: +- The label switches from "Enable Additional Usage" to "Increase Budget" once + additional paid usage has been enabled for the user. +- The **Upgrade Plan** action (row in the menu, link in the static banner, + button in the inline warning) is **not** plan-driven. It must appear if and + only if the language server reports `canUpgradePlan = true` for the + signed-in account; otherwise it must be absent from every surface. Each + per-plan file starts with a step that records the actual `canUpgradePlan` + value from the language-server log, and all later cases reference that + recorded value. + +## How to Read `canUpgradePlan` From the Language-Server Log +1. In Eclipse, open `Window → Preferences → Language Servers`. +2. Tick **Log to console** (and optionally **Log to file**) for the GitHub + Copilot language server, then `Apply and Close`. +3. Open `Window → Show View → Other... → General → Console`. +4. From the Console toolbar dropdown (the open-console icon), choose the + `Eclipse Copilot Language Servers Log` console entry that traces traffic for the GitHub + Copilot language server. +5. Sign in to GitHub (or sign out and back in) so a fresh handshake is logged. +6. Locate the most recent `copilot/quotaChange` notification (sent from the + server). Note the value of the `canUpgradePlan` field in its payload — it + is `true`, `false`, or absent. + - `true` → expect Upgrade Plan everywhere it can appear. + - `false` or absent → expect Upgrade Plan to **not** appear. diff --git a/test-plans/token-based-billing/TBB-test-business.md b/test-plans/token-based-billing/TBB-test-business.md new file mode 100644 index 00000000..e868b0ea --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-business.md @@ -0,0 +1,155 @@ +# Copilot Business Plan (Unlimited Org) — Quota UI + +## Overview +This file covers the **Business plan with unlimited premium-interaction +usage** configured by the organization. Under this configuration the usage +menus replace the Monthly Limit row with a single informational message, and +the chat-view quota-warning surfaces stay dormant because there is no +monthly cap to cross. + +### Surface-by-surface expectations (unlimited org) + +| Surface | Expected behaviour | +|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| Plan label in menu header | `Business Plan` | +| Header row icon | Blue (full) usage icon | +| Monthly Limit row | **Hidden.** Replaced by a disabled message: `You have no monthly limit on AI credits usage set by your organization`. | +| Allowance-reset row | **Hidden.** There is no monthly allowance to reset. | +| Additional-usage status row | **Hidden.** | +| Enable Additional Usage / Increase Budget row | **Hidden.** | +| `Upgrade Plan` row | Present **iff** the language server reports `canUpgradePlan = true` for the signed-in account. Typically `false`. | +| Static banner (chat view) | Not triggered. No 75% / 90% threshold to cross. | +| Inline quota warning under a chat turn | Not triggered by quota exhaustion (no cap). | + +### Why the chat-view warning surfaces are not exercised here +With unlimited premium-interaction usage there is no allowance to cross, so +neither the 75% info banner nor the 90% warning banner can fire, and the +quota-exhausted inline warning is unreachable. Banner / inline cases live in +the bounded-org and CFI plans. Any appearance of those surfaces on an +unlimited Business account is a regression. + +--- + +## Test Cases + +### TC-CB-U-000: Sign in with an unlimited Business org account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account that belongs to a **Copilot Business organization + configured with unlimited premium-interaction usage** is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers + Log console is open (see the README section *How to Read `canUpgradePlan` + From the Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the unlimited Business account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent + `copilot/quotaChange` notification and: + - **Record `canUpgradePlan`** (`true`, `false`, or absent). Later cases + refer to this as "the recorded `canUpgradePlan` value". For a typical + Business account this is `false`. + - Confirm the payload indicates that the premium-interactions quota is + unlimited. If it is not, the account is not actually on an unlimited + org and the test should be re-run on a correctly provisioned account. + +#### Expected Result +- The header row of both menus reads `<username> — Business Plan`. +- The `canUpgradePlan` value and the "unlimited" indication have been + recorded. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the `Business Plan` label +- [ ] Language-server log showing `canUpgradePlan` and the unlimited flag + +--- + +### TC-CB-U-001: Status-bar menu — unlimited org informational message + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-CB-U-000 completed; the signed-in user is on an unlimited Business org. + +#### Steps +1. Click the Copilot icon in the Eclipse status bar. + +#### Expected Result +1. Header row: `<username> — Business Plan` on top; second row reads + `Copilot Usage` with the **blue/full usage icon**. +2. A **single disabled message row** reading exactly: + `You have no monthly limit on AI credits usage set by your organization`. +3. **No** Monthly Limit row. +4. **No** allowance-reset row (no `Resets today` / `Reset in N days …` text). +5. **No** `Additional usage enabled` / `Additional usage not enabled` status + row, and **no** tooltip on such a row. +6. **No** `Enable Additional Usage` / `Increase Budget` action row. +7. `Upgrade Plan` row — present **iff** the recorded `canUpgradePlan` value + (TC-CB-U-000) is `true`; absent otherwise. For a typical Business account + it must not appear. +8. Standard footer rows (Sign Out, Preferences, etc.) appear as usual below + the usage section. + +#### 📸 Key Screenshots +- [ ] Status-bar menu, unlimited Business org (full menu) + +--- + +### TC-CB-U-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu captured in TC-CB-U-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order with the same labels, +tooltips, and icons as the status-bar menu. The unlimited-org informational +message must be identical between the two surfaces. + +#### 📸 Key Screenshots +- [ ] Menu-bar menu, unlimited Business org + +--- + +### TC-CB-U-003: Chat works without any quota banner or inline warning + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-CB-U-001 succeeded. +- Copilot chat view is open in Agent mode. + +#### Steps +1. Send a normal chat message (e.g. `Hello, what can you do?`) and wait for + the response. +2. Send a second chat message that triggers tool use (e.g. ask to read a file + in the workspace). +3. Inspect the chat view above the input area and under each completed turn. + +#### Expected Result +- Both turns complete successfully. +- **No** static banner appears above the chat input (no info-icon banner, no + warning-icon banner, no `Upgrade Plan` link). +- **No** inline warning appears under either turn (no quota-exhausted + message, no `Upgrade Plan` button, no `Enable Additional Usage` button). + +#### 📸 Key Screenshots +- [ ] Chat view with two completed turns, no banner and no inline warning + +--- + +## Screenshots Checklist +- [ ] `TC-CB-U-000` Signed-in `Business Plan` label + language-server log payload +- [ ] `TC-CB-U-001` Status-bar menu, unlimited org informational message +- [ ] `TC-CB-U-002` Menu-bar menu mirrors status-bar menu +- [ ] `TC-CB-U-003` Chat view with no banner / no inline warning diff --git a/test-plans/token-based-billing/TBB-test-enterprise.md b/test-plans/token-based-billing/TBB-test-enterprise.md new file mode 100644 index 00000000..5c926982 --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-enterprise.md @@ -0,0 +1,155 @@ +# Copilot Enterprise Plan (Unlimited Org) — Quota UI + +## Overview +This file covers the **Enterprise plan with unlimited premium-interaction +usage** configured by the organization — the most common Enterprise +configuration. Under this configuration the usage menus replace the Monthly +Limit row with a single informational message, and the chat-view +quota-warning surfaces stay dormant because there is no monthly cap to cross. + +### Surface-by-surface expectations (unlimited org) + +| Surface | Expected behaviour | +|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| Plan label in menu header | `Enterprise Plan` | +| Header row icon | Blue (full) usage icon | +| Monthly Limit row | **Hidden.** Replaced by a disabled message: `You have no monthly limit on AI credits usage set by your organization`. | +| Allowance-reset row | **Hidden.** There is no monthly allowance to reset. | +| Additional-usage status row | **Hidden.** | +| Enable Additional Usage / Increase Budget row | **Hidden.** | +| `Upgrade Plan` row | Present **iff** the language server reports `canUpgradePlan = true` for the signed-in account. Typically `false`. | +| Static banner (chat view) | Not triggered. No 75% / 90% threshold to cross. | +| Inline quota warning under a chat turn | Not triggered by quota exhaustion (no cap). | + +### Why the chat-view warning surfaces are not exercised here +With unlimited premium-interaction usage there is no allowance to cross, so +neither the 75% info banner nor the 90% warning banner can fire, and the +quota-exhausted inline warning is unreachable. Banner / inline cases live in +the bounded-org and CFI plans. Any appearance of those surfaces on an +unlimited Enterprise account is a regression. + +--- + +## Test Cases + +### TC-CE-U-000: Sign in with an unlimited Enterprise org account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account that belongs to a **Copilot Enterprise organization + configured with unlimited premium-interaction usage** is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers + Log console is open (see the README section *How to Read `canUpgradePlan` + From the Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the unlimited Enterprise account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent + `copilot/quotaChange` notification and: + - **Record `canUpgradePlan`** (`true`, `false`, or absent). Later cases + refer to this as "the recorded `canUpgradePlan` value". For a typical + Enterprise account this is `false`. + - Confirm the payload indicates that the premium-interactions quota is + unlimited. If it is not, the account is not actually on an unlimited + org and the test should be re-run on a correctly provisioned account. + +#### Expected Result +- The header row of both menus reads `<username> — Enterprise Plan`. +- The `canUpgradePlan` value and the "unlimited" indication have been + recorded. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the `Enterprise Plan` label +- [ ] Language-server log showing `canUpgradePlan` and the unlimited flag + +--- + +### TC-CE-U-001: Status-bar menu — unlimited org informational message + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-CE-U-000 completed; the signed-in user is on an unlimited Enterprise org. + +#### Steps +1. Click the Copilot icon in the Eclipse status bar. + +#### Expected Result +1. Header row: `<username> — Enterprise Plan` on top; second row reads + `Copilot Usage` with the **blue/full usage icon**. +2. A **single disabled message row** reading exactly: + `You have no monthly limit on AI credits usage set by your organization`. +3. **No** Monthly Limit row. +4. **No** allowance-reset row (no `Resets today` / `Reset in N days …` text). +5. **No** `Additional usage enabled` / `Additional usage not enabled` status + row, and **no** tooltip on such a row. +6. **No** `Enable Additional Usage` / `Increase Budget` action row. +7. `Upgrade Plan` row — present **iff** the recorded `canUpgradePlan` value + (TC-CE-U-000) is `true`; absent otherwise. For a typical Enterprise + account it must not appear. +8. Standard footer rows (Sign Out, Preferences, etc.) appear as usual below + the usage section. + +#### 📸 Key Screenshots +- [ ] Status-bar menu, unlimited Enterprise org (full menu) + +--- + +### TC-CE-U-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu captured in TC-CE-U-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order with the same labels, +tooltips, and icons as the status-bar menu. The unlimited-org informational +message must be identical between the two surfaces. + +#### 📸 Key Screenshots +- [ ] Menu-bar menu, unlimited Enterprise org + +--- + +### TC-CE-U-003: Chat works without any quota banner or inline warning + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- TC-CE-U-001 succeeded. +- Copilot chat view is open in Agent mode. + +#### Steps +1. Send a normal chat message (e.g. `Hello, what can you do?`) and wait for + the response. +2. Send a second chat message that triggers tool use (e.g. ask to read a file + in the workspace). +3. Inspect the chat view above the input area and under each completed turn. + +#### Expected Result +- Both turns complete successfully. +- **No** static banner appears above the chat input (no info-icon banner, no + warning-icon banner, no `Upgrade Plan` link). +- **No** inline warning appears under either turn (no quota-exhausted + message, no `Upgrade Plan` button, no `Enable Additional Usage` button). + +#### 📸 Key Screenshots +- [ ] Chat view with two completed turns, no banner and no inline warning + +--- + +## Screenshots Checklist +- [ ] `TC-CE-U-000` Signed-in `Enterprise Plan` label + language-server log payload +- [ ] `TC-CE-U-001` Status-bar menu, unlimited org informational message +- [ ] `TC-CE-U-002` Menu-bar menu mirrors status-bar menu +- [ ] `TC-CE-U-003` Chat view with no banner / no inline warning diff --git a/test-plans/token-based-billing/TBB-test-free.md b/test-plans/token-based-billing/TBB-test-free.md new file mode 100644 index 00000000..2d0d930f --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-free.md @@ -0,0 +1,204 @@ +# Copilot Free Plan — Quota UI + +## Overview +Free plan accounts track separate **Code Completions** and **Chat Messages** +quotas and have no overage row. Upgrade Plan visibility depends on the + language-server `canUpgradePlan` signal. + +Expected plan label in the menu header: `Copilot Free Plan`. + +--- + +## Test Cases + +### TC-Free-000: Sign in with a Copilot Free plan account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account on the Copilot Free plan is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers Log console is open + (see the README section *How to Read `canUpgradePlan` From the + Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the Free plan account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent `copilot/quotaChange` + notification and **record the value of `canUpgradePlan`** (`true`, `false`, + or absent). Later cases in this file refer to this value as + "the recorded `canUpgradePlan` value". + +#### Expected Result +- The header row of both menus reads `<username> — Copilot Free Plan`. +- The recorded `canUpgradePlan` value has been written down so the rest of + the cases can be evaluated against it. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the Free Plan label +- [ ] Eclipse Copilot Language Servers Log console showing the `canUpgradePlan` field + +--- + +### TC-Free-001: Status-bar menu — under quota + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Both the Code Completions and Chat Messages quotas are under 75% used. + +#### Steps +1. Click the Copilot icon in the status bar. + +#### Expected Result +- Header row: `Copilot Usage` with the blue usage icon and the tooltip + `Manage Copilot`. +- Row: `Code Completions NN% used`. +- Row: `Chat Messages NN% used`. +- Row showing the next reset date (e.g. `Reset in N days on <Month D, YYYY>`), + disabled. +- The `Upgrade Plan` row (with an upgrade icon) is present **iff** the + recorded `canUpgradePlan` value (TC-Free-000) is `true`; absent otherwise. +- No Included Credits / Monthly Limit row, no Enable Additional Usage row, no + Additional usage status row. + +#### 📸 Key Screenshots +- [ ] Full status-bar menu + +--- + +### TC-Free-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu shown in TC-Free-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order, with the same labels, +tooltips, and icons as the status-bar menu. + +#### 📸 Key Screenshots +- [ ] Full menu-bar menu + +--- + +### TC-Free-003: Usage icon transitions + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. On the GitHub quota portal, raise either the Code Completions or Chat + Messages usage to above 75% (to trigger the yellow icon); reopen the status-bar menu. +2. Raise the usage to above 90% (to trigger the red icon); reopen the menu. + +#### Expected Result +- The header usage icon switches to **yellow** when the lower of the two + quotas has roughly a quarter or less remaining. +- The header usage icon switches to **red** when the lower of the two quotas + has roughly a tenth or less remaining. +- Percent text on each row updates without reopening Eclipse. + +#### 📸 Key Screenshots +- [ ] Yellow header icon +- [ ] Red header icon + +--- + +### TC-Free-004: Quota-warning static banner — 75% threshold (info) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Chat Messages quota is set to ~76% used on the quota portal (so it + crosses the 75% threshold but is below 90%). The Code Completions quota + does not trigger a chat-view banner and is not relevant here. See + *Quota-Warning Thresholds* in the README. + +#### Steps +1. Send a chat message (or wait for the next quota refresh). +2. Observe the banner above the chat input area. + +#### Expected Result +- A banner appears with an **info icon** (not the warning icon). +- If the recorded `canUpgradePlan` value (TC-Free-000) is `true`, a single + action link `Upgrade Plan` is shown and opens the Copilot upgrade page in + a browser when clicked. If the value is `false` or absent, no action link + is shown. +- The dismiss (`×`) control closes the banner. + +#### 📸 Key Screenshots +- [ ] Static banner with info icon + +--- + +### TC-Free-005: Quota-warning static banner — 90% threshold (warning) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Chat Messages quota is set to ~91% used on the quota portal (so it + crosses the 90% threshold). The Code Completions quota does not trigger a + chat-view banner and is not relevant here. See *Quota-Warning Thresholds* + in the README. + +#### Steps +1. Send a chat message (or wait for the next quota refresh). +2. Observe the banner above the chat input area. + +#### Expected Result +- A banner appears with a **warning icon**. +- The message references the user having nearly exhausted the chat budget + and prompts them to upgrade. +- The same conditional `Upgrade Plan` action link is shown only when the + recorded `canUpgradePlan` value (TC-Free-000) is `true`. +- The dismiss (`×`) control closes the banner. + +#### 📸 Key Screenshots +- [ ] Static banner with warning icon and Upgrade Plan link + +--- + +### TC-Free-006: Inline warning under a chat turn when quota is exhausted + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- The Chat Messages quota is at 100% used. (Exhausting the Code Completions + quota does not produce a chat-view warning.) + +#### Steps +1. Send a chat message that the server rejects because the quota is exhausted. +2. Inspect the warning rendered under the assistant turn. + +#### Expected Result +- The warning shows a warn icon and the server-supplied error message text. +- If the recorded `canUpgradePlan` value (TC-Free-000) is `true`, one primary + button labelled `Upgrade Plan` is shown and opens the Copilot upgrade page + in a browser when clicked. If the value is `false` or absent, no button is + shown. + +#### 📸 Key Screenshots +- [ ] Inline warning with a single Upgrade Plan button + +--- + +## Screenshots Checklist +- [ ] `TC-Free-000` Signed-in Free Plan label +- [ ] `TC-Free-001` Status-bar menu +- [ ] `TC-Free-002` Menu-bar menu +- [ ] `TC-Free-003` Yellow header icon +- [ ] `TC-Free-003` Red header icon +- [ ] `TC-Free-004` Static banner (info, 75%) +- [ ] `TC-Free-005` Static banner (warning, 90%) +- [ ] `TC-Free-006` Inline warning under chat turn diff --git a/test-plans/token-based-billing/TBB-test-max.md b/test-plans/token-based-billing/TBB-test-max.md new file mode 100644 index 00000000..a1915753 --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-max.md @@ -0,0 +1,198 @@ +# Copilot Max Plan — Quota UI + +## Overview +Max accounts use the **Included Credits** row and have the Enable Additional +Usage overage row. Visibility of the `Upgrade Plan` row depends on the +upgrade-eligibility signal sent by the language server; Max accounts are +normally reported as not eligible to upgrade, in which case the row must be +absent. + +Expected plan label in the menu header: `Copilot Max Plan`. + +--- + +## Test Cases + +### TC-Max-000: Sign in with a Copilot Max plan account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account on the Copilot Max plan is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers Log console is open + (see the README section *How to Read `canUpgradePlan` From the + Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the Max plan account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent `copilot/quotaChange` + notification and **record the value of `canUpgradePlan`** (`true`, `false`, + or absent). Later cases in this file refer to this value as + "the recorded `canUpgradePlan` value". For a Max account this value is + normally `false`. + +#### Expected Result +- The header row of both menus reads `<username> — Copilot Max Plan`. +- The recorded `canUpgradePlan` value has been written down so the rest of + the cases can be evaluated against it. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the Max Plan label +- [ ] Eclipse Copilot Language Servers Log console showing the `canUpgradePlan` field + +--- + +### TC-Max-001: Status-bar menu — under quota, Upgrade Plan visibility matches `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is under 75% used. + +#### Steps +1. Click the Copilot icon in the status bar. +2. Note the **Additional usage** status row text — it reflects whether the + account currently has additional paid usage enabled. + +#### Expected Result +Rows: +1. `Copilot Usage` header with a blank header icon. +2. `Included Credits NN/MMM AI credits used` with the blue usage icon. +3. Row showing the next reset date. +4. Status row + action row **depend on the current additional-usage state** + (the tester does not need to know this in advance — both layouts are + valid starting points): + - **Additional usage not enabled (default):** + - Status row: `Additional usage not enabled`. + - Action row: `Enable Additional Usage` with an upgrade icon. + - **Additional usage enabled:** + - Status row: `Additional usage enabled`. + - Action row: `Increase Budget` with the same upgrade icon (clicking + either label opens the overage management page). +5. `Upgrade Plan` row — present **iff** the recorded `canUpgradePlan` value + (TC-Max-000) is `true`; absent otherwise. For a typical Max account the + recorded value is `false`, so this row should not appear. + +#### 📸 Key Screenshots +- [ ] Status-bar menu (capture whichever additional-usage state the account + starts in; Upgrade Plan visibility matches the recorded value) + +--- + +### TC-Max-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu shown in TC-Max-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order, with the same labels, +tooltips, and icons as the status-bar menu — including the same `Upgrade +Plan` visibility derived from the recorded `canUpgradePlan` value +(TC-Max-000). + +#### 📸 Key Screenshots +- [ ] Menu-bar menu (Upgrade Plan row matches the recorded value) + +--- + +### TC-Max-003: Usage icon transitions on the Included Credits row + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. On the quota portal, raise AI-credit usage to above 75% (to trigger the yellow icon), then above 90% (to trigger the red icon). + +#### Expected Result +Blue → yellow (≈25% or less remaining) → red (≈10% or less remaining) on the +Included Credits row. + +#### 📸 Key Screenshots +- [ ] Yellow / red Included Credits icon + +--- + +### TC-Max-004: Quota-warning static banner — 75% threshold (info) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~76% on the quota portal (so it crosses the 75% + threshold but is below 90%). See *Quota-Warning Thresholds* in the README. + +#### Expected Result +- Banner above the chat input with an **info icon** (not the warning icon). +- Action links: + - `Enable Additional Usage` (or `Increase Budget` when already enabled) — + always shown. + - `Upgrade Plan` — shown **iff** the recorded `canUpgradePlan` value + (TC-Max-000) is `true`. For a typical Max account it must be absent. + +#### 📸 Key Screenshots +- [ ] Static banner with info icon; Upgrade Plan link matches the recorded value + +--- + +### TC-Max-005: Quota-warning static banner — 90% threshold (warning) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~91% on the quota portal (so it crosses the 90% + threshold). See *Quota-Warning Thresholds* in the README. + +#### Expected Result +- Banner above the chat input with a **warning icon**. +- Action links are the same as in TC-Max-004: + - `Enable Additional Usage` (or `Increase Budget` when already enabled). + - `Upgrade Plan` — shown only when the recorded `canUpgradePlan` value + (TC-Max-000) is `true`. + +#### 📸 Key Screenshots +- [ ] Static banner with warning icon; Upgrade Plan link matches the recorded value + +--- + +### TC-Max-006: Inline warning under a chat turn when quota is exhausted + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is at 100%, additional usage not enabled. + +#### Steps +1. Send a chat message that the server rejects because the quota is exhausted. + +#### Expected Result +- Warn icon plus the server-supplied error message. +- A **primary** button `Enable Additional Usage` is shown and opens the + overage management page. +- A secondary button `Upgrade Plan` is shown **iff** the recorded + `canUpgradePlan` value (TC-Max-000) is `true`; absent otherwise. For a + typical Max account the button must not appear. + +#### 📸 Key Screenshots +- [ ] Inline warning; Upgrade Plan button matches the recorded value + +--- + +## Screenshots Checklist +- [ ] `TC-Max-000` Signed-in Max Plan label + `canUpgradePlan` log +- [ ] `TC-Max-001` Status-bar menu (capture the current additional-usage state; Upgrade Plan matches recorded value) +- [ ] `TC-Max-002` Menu-bar menu (same visibility) +- [ ] `TC-Max-003` Yellow / red Included Credits icon +- [ ] `TC-Max-004` Static banner (info, 75%) +- [ ] `TC-Max-005` Static banner (warning, 90%) +- [ ] `TC-Max-006` Inline warning; Upgrade Plan button matches recorded value diff --git a/test-plans/token-based-billing/TBB-test-pro-plus.md b/test-plans/token-based-billing/TBB-test-pro-plus.md new file mode 100644 index 00000000..6ef76a39 --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-pro-plus.md @@ -0,0 +1,194 @@ +# Copilot Pro+ Plan — Quota UI + +## Overview +Pro+ accounts use the **Included Credits** row, have an Enable Additional +Usage overage row, and may show the `Upgrade Plan` row depending on the +upgrade-eligibility signal sent by the language server. + +Expected plan label in the menu header: `Copilot Pro+ Plan`. + +--- + +## Test Cases + +### TC-ProPlus-000: Sign in with a Copilot Pro+ plan account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account on the Copilot Pro+ plan is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers Log console is open + (see the README section *How to Read `canUpgradePlan` From the + Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the Pro+ plan account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent `copilot/quotaChange` + notification and **record the value of `canUpgradePlan`** (`true`, `false`, + or absent). Later cases in this file refer to this value as + "the recorded `canUpgradePlan` value". + +#### Expected Result +- The header row of both menus reads `<username> — Copilot Pro+ Plan`. +- The recorded `canUpgradePlan` value has been written down so the rest of + the cases can be evaluated against it. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the Pro+ Plan label +- [ ] Eclipse Copilot Language Servers Log console showing the `canUpgradePlan` field + +--- + +### TC-ProPlus-001: Status-bar menu — under quota + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is under 75% used. + +#### Steps +1. Click the Copilot icon in the status bar. +2. Note the **Additional usage** status row text — it reflects whether the + account currently has additional paid usage enabled. + +#### Expected Result +Rows (top → bottom): +1. `Copilot Usage` header with a blank header icon. +2. `Included Credits NN/MMM AI credits used` with the blue usage icon and a + tooltip explaining included credits. +3. Row showing the next reset date. +4. Status row + action row **depend on the current additional-usage state** + (the tester does not need to know this in advance — both layouts are + valid starting points): + - **Additional usage not enabled (default):** + - Status row: `Additional usage not enabled`. + - Action row: `Enable Additional Usage` with an upgrade icon. + - **Additional usage enabled:** + - Status row: `Additional usage enabled`. + - Action row: `Increase Budget` with the same upgrade icon (clicking + either label opens the overage management page). +5. `Upgrade Plan` with a blank icon — present **iff** the recorded + `canUpgradePlan` value (TC-ProPlus-000) is `true`; absent otherwise. + +#### 📸 Key Screenshots +- [ ] Status-bar menu (capture whichever additional-usage state the account + starts in) + +--- + +### TC-ProPlus-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu shown in TC-ProPlus-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order, with the same labels, +tooltips, and icons as the status-bar menu. + +#### 📸 Key Screenshots +- [ ] Menu-bar menu + +--- + +### TC-ProPlus-003: Usage icon transitions on the Included Credits row + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. On the quota portal, raise AI-credit usage to above 75% (to trigger the yellow icon), then above 90% (to trigger the red icon). + +#### Expected Result +- Blue → yellow (≈25% or less remaining) → red (≈10% or less remaining) on + the Included Credits row. +- The header icon stays blank throughout. + +#### 📸 Key Screenshots +- [ ] Yellow / red Included Credits icon + +--- + +### TC-ProPlus-004: Quota-warning static banner — 75% threshold (info) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~76% on the quota portal (so it crosses the 75% + threshold but is below 90%). See *Quota-Warning Thresholds* in the README. +- Additional usage is disabled. + +#### Expected Result +- Banner above the chat input with an **info icon** (not the warning icon). +- Action links: + - `Enable Additional Usage` — always shown. + - `Upgrade Plan` — shown **iff** the recorded `canUpgradePlan` value + (TC-ProPlus-000) is `true`. + +#### 📸 Key Screenshots +- [ ] Static banner with info icon and both action links + +--- + +### TC-ProPlus-005: Quota-warning static banner — 90% threshold (warning) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~91% on the quota portal (so it crosses the 90% + threshold). See *Quota-Warning Thresholds* in the README. +- Additional usage is disabled. + +#### Expected Result +- Banner above the chat input with a **warning icon**. +- Action links are the same as in TC-ProPlus-004: + - `Enable Additional Usage` — always shown. + - `Upgrade Plan` — shown **iff** the recorded `canUpgradePlan` value + (TC-ProPlus-000) is `true`. + +#### 📸 Key Screenshots +- [ ] Static banner with warning icon and both action links + +--- + +### TC-ProPlus-006: Inline warning under a chat turn when quota is exhausted + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is at 100%, additional usage not enabled. + +#### Steps +1. Send a chat message that the server rejects because the quota is exhausted. + +#### Expected Result +- Warn icon plus the server-supplied error message. +- A **primary** button `Enable Additional Usage` is shown and opens the + overage management page. +- A **secondary** button `Upgrade Plan` is shown **iff** the recorded + `canUpgradePlan` value (TC-ProPlus-000) is `true`; absent otherwise. + +#### 📸 Key Screenshots +- [ ] Inline warning with two buttons + +--- + +## Screenshots Checklist +- [ ] `TC-ProPlus-000` Signed-in Pro+ Plan label +- [ ] `TC-ProPlus-001` Status-bar menu (capture the current additional-usage state) +- [ ] `TC-ProPlus-002` Menu-bar menu +- [ ] `TC-ProPlus-003` Yellow / red Included Credits icon +- [ ] `TC-ProPlus-004` Static banner (info, 75%) +- [ ] `TC-ProPlus-005` Static banner (warning, 90%) +- [ ] `TC-ProPlus-006` Inline warning under chat turn diff --git a/test-plans/token-based-billing/TBB-test-pro.md b/test-plans/token-based-billing/TBB-test-pro.md new file mode 100644 index 00000000..b741cdef --- /dev/null +++ b/test-plans/token-based-billing/TBB-test-pro.md @@ -0,0 +1,207 @@ +# Copilot Pro Plan — Quota UI + +## Overview +Pro accounts use the **Included Credits** row, have an Enable Additional Usage +overage row, and show Upgrade Plan based on the recorded `canUpgradePlan` value. + +Expected plan label in the menu header: `Copilot Pro Plan`. + +--- + +## Test Cases + +### TC-Pro-000: Sign in with a Copilot Pro plan account, record `canUpgradePlan` + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Plugin is installed and Eclipse is signed out. +- A GitHub account on the Copilot Pro plan is available. +- Language-server logging is enabled and the Eclipse Copilot Language Servers Log console is open + (see the README section *How to Read `canUpgradePlan` From the + Language-Server Log*). + +#### Steps +1. Open the Copilot status-bar menu and click `Sign in to GitHub`. +2. Complete the device-flow sign-in with the Pro plan account. +3. Wait for the status-bar icon to switch to the signed-in icon. +4. In the Eclipse Copilot Language Servers Log console, find the most recent `copilot/quotaChange` + notification and **record the value of `canUpgradePlan`** (`true`, `false`, + or absent). Later cases in this file refer to this value as + "the recorded `canUpgradePlan` value". + +#### Expected Result +- The header row of both menus reads `<username> — Copilot Pro Plan`. +- The recorded `canUpgradePlan` value has been written down so the rest of + the cases can be evaluated against it. + +#### 📸 Key Screenshots +- [ ] Signed-in state with the Pro Plan label +- [ ] Eclipse Copilot Language Servers Log console showing the `canUpgradePlan` field + +--- + +### TC-Pro-001: Status-bar menu — under quota + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- Premium / AI-credit usage is under 75% used. + +#### Steps +1. Click the Copilot icon in the status bar. +2. Note the **Additional usage** status row text — it reflects whether the + account currently has additional paid usage enabled. + +#### Expected Result +- Header row: `Copilot Usage`, with a blank header icon (the usage icon + appears on the Included Credits row instead). +- Row: `Included Credits NN/MMM AI credits used` with the blue usage icon + and a tooltip explaining included credits. +- Row showing the next reset date (e.g. `Reset in N days on <Month D, YYYY>`). +- Status row + action row **depend on the current additional-usage state** + (the tester does not need to know this in advance — both layouts are valid + starting points): + - **Additional usage not enabled (default):** + - Status row: `Additional usage not enabled` (no tooltip). + - Action row: `Enable Additional Usage` with an upgrade icon. + - **Additional usage enabled:** + - Status row: `Additional usage enabled` (no tooltip). + - Action row: `Increase Budget` with the same upgrade icon (clicking + either label opens the overage management page). +- Row: `Upgrade Plan` is present **iff** the recorded `canUpgradePlan` + value (TC-Pro-000) is `true`; absent otherwise. When present it uses a + blank icon (the upgrade icon was already used by the row above). + +#### 📸 Key Screenshots +- [ ] Status-bar menu (capture whichever additional-usage state the account + starts in) + +--- + +### TC-Pro-002: Menu-bar menu mirrors the status-bar menu + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. Open `GitHub Copilot` from the Eclipse menu bar. +2. Compare against the menu shown in TC-Pro-001. + +#### Expected Result +The menu-bar menu shows the same rows in the same order, with the same labels, +tooltips, and icons as the status-bar menu. + +#### 📸 Key Screenshots +- [ ] Menu-bar menu + +--- + +### TC-Pro-003: Usage icon transitions on the Included Credits row + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Steps +1. On the quota portal, raise the AI-credit usage to above 75% (to trigger the yellow icon), then above 90% (to trigger the red icon). +2. Reopen menus after each change. + +#### Expected Result +- The Included Credits row icon transitions from blue, to yellow when roughly + a quarter or less remains, to red when roughly a tenth or less remains. +- The header icon stays blank throughout. + +#### 📸 Key Screenshots +- [ ] Yellow Included Credits icon +- [ ] Red Included Credits icon + +--- + +### TC-Pro-004: Quota-warning static banner — 75% threshold (info) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~76% on the quota portal (so it crosses the 75% + threshold but is below 90%). See *Quota-Warning Thresholds* in the README. + +#### Steps +1. Send a chat message to trigger the quota-warning notification. + +#### Expected Result +- A banner appears above the chat input with an **info icon** (not the + warning icon). +- Two action links: + - `Enable Additional Usage` (or `Increase Budget` if already enabled), + opening the overage management page. + - `Upgrade Plan`, opening the Copilot upgrade page — shown only when the + recorded `canUpgradePlan` value (TC-Pro-000) is `true`. When the recorded + value is `false` or absent, only the overage link is shown. +- The dismiss (`×`) control closes the banner. + +#### 📸 Key Screenshots +- [ ] Static banner with info icon and both action links + +--- + +### TC-Pro-005: Quota-warning static banner — 90% threshold (warning) + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is set to ~91% on the quota portal (so it crosses the 90% + threshold). See *Quota-Warning Thresholds* in the README. + +#### Steps +1. Send a chat message to trigger the quota-warning notification. + +#### Expected Result +- A banner appears with a **warning icon**. +- Action links are the same as in TC-Pro-004: + - `Enable Additional Usage` (or `Increase Budget` if already enabled). + - `Upgrade Plan` — shown only when the recorded `canUpgradePlan` value + (TC-Pro-000) is `true`. + +#### 📸 Key Screenshots +- [ ] Static banner with warning icon and both action links + +--- + +### TC-Pro-006: Inline warning under a chat turn when quota is exhausted + +**Type:** `Happy Path` +**Priority:** `P0` + +#### Preconditions +- AI-credit usage is at 100%, with additional usage not enabled. + +#### Steps +1. Send a chat message that the server rejects because the quota is exhausted. +2. Inspect the warning rendered under the assistant turn. + +#### Expected Result +- The warning shows a warn icon and the server-supplied error message. +- A **primary** button `Enable Additional Usage` is shown and opens the + overage management page. +- A **secondary** button `Upgrade Plan` is shown **iff** the recorded + `canUpgradePlan` value (TC-Pro-000) is `true`; absent otherwise. When + shown, it opens the Copilot upgrade page. +- The active model in the model picker is not silently switched away. + +#### 📸 Key Screenshots +- [ ] Inline warning with two buttons + +--- + +## Screenshots Checklist +- [ ] `TC-Pro-000` Signed-in Pro Plan label +- [ ] `TC-Pro-001` Status-bar menu (capture the current additional-usage state) +- [ ] `TC-Pro-002` Menu-bar menu +- [ ] `TC-Pro-003` Yellow / red Included Credits icon +- [ ] `TC-Pro-004` Static banner (info, 75%) +- [ ] `TC-Pro-005` Static banner (warning, 90%) +- [ ] `TC-Pro-006` Inline warning under chat turn