diff --git a/.env.template b/.env.template index e686099..32fa6c0 100644 --- a/.env.template +++ b/.env.template @@ -95,8 +95,10 @@ GITPILOT_OLLAMA_MODEL=llama3 # ============================================================================= # OllaBridge / OllaBridge Cloud Configuration # ============================================================================= -# OllaBridge base URL (default: http://localhost:8000) -# OLLABRIDGE_BASE_URL=http://localhost:8000 +# OllaBridge gateway base URL (default: http://localhost:11435) +# Must NOT be GitPilot's own address (http://localhost:8000) — that is this +# server, not an OllaBridge gateway. +# OLLABRIDGE_BASE_URL=http://localhost:11435 # Model to use with OllaBridge (default: qwen2.5:1.5b) # GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b @@ -104,6 +106,41 @@ GITPILOT_OLLAMA_MODEL=llama3 # Optional: API key for authenticated endpoints # OLLABRIDGE_API_KEY= +# ============================================================================= +# Open WebUI Configuration +# ============================================================================= +# Root URL of your Open WebUI instance (default: http://localhost:3000). +# Paste the root — GitPilot appends the OpenAI-compatible API path itself. +# OPENWEBUI_BASE_URL=http://localhost:3000 + +# Optional: only if your instance requires authentication. +# Create one under Settings -> Account -> API keys. +# OPENWEBUI_API_KEY= + +# ============================================================================= +# Custom OpenAI-compatible Endpoint +# ============================================================================= +# Any gateway that speaks the OpenAI chat-completions API: self-hosted +# inference, a corporate model gateway, a proxy in front of several vendors. +# A full /chat/completions path works too — GitPilot trims it back to the root. +# GITPILOT_CUSTOM_BASE_URL=https://inference.example.com/v1 +# GITPILOT_CUSTOM_API_KEY= +# GITPILOT_CUSTOM_MODEL= + +# Extra request headers (attribution, routing) are configured in +# VS Code: GitPilot Settings -> AI Providers -> Custom endpoint. + +# ============================================================================= +# MCP Server Registry (Optional) +# ============================================================================= +# Where "MCP Servers -> Search" looks for servers to attach. Any registry +# serving a JSON catalogue works; point this at your own to publish an +# internal catalogue of approved servers. +# GITPILOT_MATRIXHUB_URL=https://api.matrixhub.io + +# Bearer token, if your registry requires one. +# GITPILOT_MATRIXHUB_TOKEN= + # ============================================================================= # Lite Mode (Optional — for small LLMs under 7B parameters) # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index d780a18..d72e5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Runtime-aware onboarding** — `/api/status` now reports `workspace.runtime` (`cloud`/`local`); in a cloud workspace the empty state guides you to connect GitHub and pick a repository, while a local install offers Folder / Local Git paths with GitHub optional. - **Account-first auth across split deployments** — portable `X-GitPilot-Session` token (cross-origin Vercel↔HF), a dedicated email-verification screen, a Settings → Account tab (update name, change password, delete account), and "GitHub not linked" now shows a calm Connect prompt instead of a repo-fetch error. +### Added +- **`make install-cli` and `make check-cli`** — point the `gitpilot` command at + the current checkout, and report which GitPilot it actually runs. `make + install` syncs `.venv` (which `make run` uses) but never touched PATH, so on + any machine that had once run `pip install gitcopilot`, `gitpilot serve` kept + starting a released wheel from `~/.local/lib/python3.11/site-packages/` + — a different version, different dependencies, none of your changes, and no + warning. The only visible difference was a version number inside a banner + (`v0.2.7` against `v0.2.8`), so a fix that was definitely in the tree could + appear not to work at all. `make install` now ends by naming the mismatch; + fixing it stays opt-in, because installing a command outside the project is + something to ask for rather than have done to you. + +### Fixed +- **Reasoning models could not build an agent at all.** Every query from the + web app failed with `2 validation errors for Agent` — `Agent.llm` is a + validated pydantic field typed `str | BaseLLM`, and the reasoning wrapper was + a plain class that is neither. Composition over subclassing was a deliberate + choice (CrewAI's LLM class changes between versions) and was safe right up + until that field started validating; after which deepseek-r1, QwQ and every + other reasoning model failed at agent construction, before a single token was + generated. The wrapper now subclasses CrewAI's `BaseLLM` — an ABC whose one + abstract method, `call`, is the method the wrapper existed to intercept — and + is built lazily so importing it still does not drag CrewAI into every + process. Verified end to end against Ollama 0.12.9 and deepseek-r1: agent + builds, crew runs, no `` leakage. Non-reasoning models are returned + unwrapped exactly as before. This module had no tests, which is how it + shipped; it has 17 now. +- **GitPilot's own log lines never reached the console.** + `uvicorn.run(log_level="info")` configures uvicorn's three loggers and + nothing else; GitPilot's records propagated to a root logger with no handler, + so Python fell back to `logging.lastResort` at WARNING. Every route decision, + provider call and timing was written and discarded, while uvicorn's own INFO + lines made logging look like it was working. GitPilot now logs at INFO by + default, with `gitpilot serve --log-level` / `GITPILOT_LOG_LEVEL` (the env + var also carries the choice into a `--reload` child). The root logger is left + alone — GitPilot is importable as a library and does not own the process's + logging. +- **Agent runs were silent on exactly the path that needed a trace.** The Lite + crews — the ones a small local model uses — were built with `verbose=False`, + so a multi-agent run produced no console output at all. They narrate now; + `GITPILOT_AGENT_VERBOSE=0` restores the quiet behaviour. +- **The workspace scan hid the project from the model.** The VS Code context + builder walked the tree depth-first and stopped at 300 entries, so the budget + went to whichever directory `readdir` returned first. Measured on this + repository: `extensions/` took 171 entries and `docs/` another 65, leaving + `pyproject.toml` and every file under `gitpilot/` — the application itself — + absent from the context. A model handed that listing cannot tell it is seeing + a fraction of a repository, so "explain this project's architecture" came back + describing something else entirely, confidently. The walk is breadth-first + now, files rank ahead of directories at each level, and the ignore list gained + the cache and build directories it was missing (`__pycache__`, + `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `.tox`, `target`, `vendor`, + `out` and others — the first two alone were taking 14 of the 300 slots). Same + repository after the change: `gitpilot/` 118 entries, `tests/` 61, + `pyproject.toml` and `README.md` present, no cache entries at all. +- **`Fallback to LiteLLM is not available` on a working Ollama.** Planning + goes through CrewAI, and CrewAI routes a call natively only when the + provider is in its `SUPPORTED_NATIVE_PROVIDERS` list — everything else + goes to its optional LiteLLM fallback, which raises outright when LiteLLM + is not installed. On CrewAI 1.6 that list is `openai, anthropic, claude, + azure, azure_openai, google, gemini, bedrock, aws`; Ollama joined it + around 1.10. So `POST /api/chat/plan` returned 500 on a configured, + reachable Ollama, and neither obvious spelling helped: + `provider="ollama"` names a provider CrewAI will not route, and + `model="ollama/llama3:8b"` is validated against CrewAI's model constants, + which no locally served model satisfies. + + **Ollama, OllaBridge, Open WebUI and custom endpoints** were all affected + — the last three spelled it `model="openai/"`, which fails the same + validation. All four now ask for `provider="openai"` explicitly and pass + the endpoint's own base URL and model id through untouched. This needs no + LiteLLM and works on old and new CrewAI alike. +- **Advice that pointed at the provider you were already using.** The agent + runtime hint told whoever hit this to "switch to Ollama, OllaBridge, Open + WebUI, OpenAI or a custom endpoint" — always the provider they were on. It + now reads the active provider and, for the endpoints that need no LiteLLM, + reports that the installed CrewAI is too old and gives the upgrade command. +- **The thinking animation stopped and restarted for a single question.** A + session with no GitHub repository has nothing for the multi-agent planner to + plan against, so the SSE stream closes at once and the client falls back to + `/api/chat/send`. That handover is deliberate — but it was announced as + `status_change("done")`, and "done" is what the UI reads to stop the spinner. + A request that had not started was reported finished, the animation cleared, + and the extension raised it again for the batch call. `agent_done` alone + terminates the stream now; the status belongs to work that happened. +- **Reasoning models on the direct path.** deepseek-r1 and QwQ think before + answering. Ollama (checked on 0.12.9 and 0.32.6) puts that in a sibling + `reasoning` field, while llama.cpp, vLLM and LM Studio inline it as + `` tags. `build_llm()` has always stripped the tags, but the direct + provider path does not go through CrewAI and handled neither: inlined + reasoning was shown as the answer, and a model that spent its whole budget + thinking — leaving `content` empty with the substance in `reasoning` — was + reported as "returned an empty response", blaming a provider that had + answered. Tags are now stripped, `reasoning` is read when `content` is + empty, and a reply that is *only* reasoning is shown rather than discarded. +- **No way to tell which chat pipeline ran.** The agent path prints CrewAI's + full verbose trace to the server console and the direct path prints nothing, + because it has no agents — so comparing the web app (which plans against a + repo) with VS Code (which often has only a folder) looked like logs being + suppressed. Both branches now announce themselves, with model and elapsed + time. The extension's Output channel gained the client half: why a stream + was abandoned (unreachable / HTTP status / empty), the event tally, and the + size and duration of the batch call that followed. +- **VS Code timed out on requests the backend was completing.** Chat, plan and + execute took the deadline meant for an ordinary HTTP request — 20s — while + a local Ollama answering with repository context routinely needs 20-60s. + The web app allowed five minutes for the identical call, so the same + backend appeared to work there and fail in VS Code + (`POST /api/chat/send took 21.26s (status=200)` against + `timed out after 20000ms`). A timeout also carries no HTTP status, so it + escaped the non-retryable-status guard and the default two retries fired: + three runs of a call the server was still executing, queued behind each + other, each slower than the last. `/api/chat/send` appends to the session + before returning, so duplicates that landed wrote the exchange to history + twice. These calls now take a five-minute deadline matching the web app, + are never retried, and report a timeout in terms of the model rather than + the elapsed milliseconds. New setting `gitpilot.llmTimeoutSeconds` + (default 300, minimum 30) for machines that need longer. +- **VS Code discarded the server's explanation.** `ErrorTranslator` chose its + text from the HTTP status alone, so a 503 whose body named the provider, + the missing package and the command surfaced as "circuit breaker active" — + a breaker that was never involved. The server's `detail` now wins for + 500/502/503/504, and the API client attaches it so there is something to + win with. + ### Changed — `make run` now starts the MCP Context Forge stack by default **Heads-up for upgraders.** Until this release, `make run` started only the diff --git a/Makefile b/Makefile index f0896b6..2e6a7e9 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ DOCKER_COMPOSE := $(shell if command -v docker > /dev/null && docker compose ver vercel vercel-build vercel-deploy \ build-container run-container stop-container logs-container clean-container publish-container \ extension-install extension-compile extension-package extension-publish publish-extension \ + extension-test extension-dev extension-uninstall \ mcp mcp-down mcp-logs gateway gateway-down gateway-logs gateway-register \ install-mcp run-mcp run-all run-all-local stop-mcp logs-mcp sync-mcp uninstall-mcp \ fix-line-endings install-mcp-workflows register-mcp-servers \ @@ -42,6 +43,8 @@ help: @echo " make install Install runtime deps + frontend + MCP stack" @echo " make install-dev Install developer/test tooling" @echo " make install-full Install runtime + dev/docs tooling + MCP stack" + @echo " make install-cli Point the 'gitpilot' command at this checkout" + @echo " make check-cli Report which GitPilot the 'gitpilot' command runs" @echo " make uv-install Create/refresh Python env with runtime deps only" @echo " make uv-install-dev Add developer/test tooling via uv" @echo " make uv-install-docs Add documentation tooling via uv" @@ -77,6 +80,9 @@ help: @echo " make extension-install Install extension npm dependencies" @echo " make extension-compile Compile TypeScript to JavaScript" @echo " make extension-package Package extension into .vsix file" + @echo " make extension-test Run the extension test suites" + @echo " make extension-dev Package + install into your local VS Code" + @echo " make extension-uninstall Remove the locally installed extension" @echo " make extension-publish Publish extension to VS Code Marketplace" @echo " make publish-extension Alias for extension-publish" @echo "" @@ -113,6 +119,23 @@ install: uv-install frontend-install install-mcp install-matrixlab-soft @echo " Run 'make startup' for the full GitPilot + MatrixLab + URL-fixup flow." @echo " No Docker? Use 'make run-bare' to start GitPilot without MCP." @echo " Optional: 'make install-dev' for test/lint/build tooling." + @bash scripts/check-cli-version.sh + +## Point the `gitpilot` command at this checkout. +## +## `make install` prepares .venv and stops there — `make run` uses it, and +## installing a command outside the project is something to ask for rather +## than have done to you. On a machine that ever ran `pip install gitcopilot`, +## `gitpilot serve` keeps running that released wheel until this is run, which +## is why a fix that is definitely in the tree can appear not to work. +.PHONY: install-cli +install-cli: + @bash scripts/install-cli.sh + +## Report which GitPilot the `gitpilot` command actually runs. +.PHONY: check-cli +check-cli: + @bash scripts/check-cli-version.sh ## Soft variant of install-matrixlab — warns and skips on docker-missing / ## daemon-down / port-held instead of aborting the parent installer. Wired @@ -618,10 +641,54 @@ extension-package: extension-compile @echo "✅ Extension packaged successfully!" @echo "" @echo "📁 VSIX file:" - @ls -lh $(EXTENSION_DIR)/*.vsix 2>/dev/null || echo " (no .vsix found)" + @ls -lh $$(ls -t $(EXTENSION_DIR)/*.vsix 2>/dev/null | head -1) 2>/dev/null || echo " (no .vsix found)" @echo "" @echo "Install locally with:" - @echo " code --install-extension $(EXTENSION_DIR)/gitpilot-vscode-*.vsix" + @echo " code --install-extension $$(ls -t $(EXTENSION_DIR)/*.vsix | head -1) --force" + @echo "" + @echo " --force is required: the version does not change between dev builds," + @echo " so VS Code otherwise refuses to reinstall. Or just: make extension-dev" + +## Run the extension's test suites (webview + panel host) +extension-test: extension-compile + @echo "🧪 Running VS Code extension tests..." + @cd $(EXTENSION_DIR) && node test/run.js + +## Package and install the extension into your local VS Code, then reload +extension-dev: extension-package + @echo "" + @echo "🔎 Built from: $$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown') $$(git diff --quiet 2>/dev/null && echo '(clean)' || echo '(with uncommitted changes)')" + @echo " If a change you expected is missing, check you have pulled it." + @echo "" + @$(MAKE) --no-print-directory version-check + @echo "💻 Installing into VS Code..." + @if command -v code >/dev/null 2>&1; then \ + code --install-extension $$(ls -t $(EXTENSION_DIR)/*.vsix | head -1) --force && \ + echo "" && \ + echo "✅ Installed. Reload VS Code to pick it up:" && \ + echo " Ctrl/Cmd+Shift+P → 'Developer: Reload Window'" && \ + echo "" && \ + echo "Then try it:" && \ + echo " Ctrl/Cmd+Shift+P → 'GitPilot: Settings'"; \ + else \ + echo "⚠️ The 'code' command is not on your PATH."; \ + echo ""; \ + echo " VS Code → Ctrl/Cmd+Shift+P → Shell Command: Install code command in PATH"; \ + echo ""; \ + echo " Or install the .vsix by hand:"; \ + echo " Extensions view → ... menu → Install from VSIX..."; \ + echo " $$(ls -t $(EXTENSION_DIR)/*.vsix | head -1)"; \ + exit 1; \ + fi + +## Remove the locally installed extension +extension-uninstall: + @if command -v code >/dev/null 2>&1; then \ + code --uninstall-extension ruslanmv.gitpilot-vscode || true; \ + echo "✅ Uninstalled. Reload VS Code to finish."; \ + else \ + echo "⚠️ The 'code' command is not on your PATH — remove it from the Extensions view."; \ + fi ## Publish extension to VS Code Marketplace extension-publish: extension-compile @@ -954,3 +1021,33 @@ fix-matrixlab-url: .PHONY: diagnose-matrixlab diagnose-matrixlab: @bash scripts/diagnose-matrixlab.sh + + +## Report the extension and backend versions, and flag a mismatch. +## +## `make extension-dev` builds the extension only. The Python backend is +## installed separately, and `gitpilot` is a console script that imports from +## site-packages rather than the directory you are standing in — so a fresh +## checkout and a stale install look identical until a feature misbehaves for +## reasons nothing on screen explains. +.PHONY: version-check +version-check: + @ext_ver=$$(grep -m1 '"version"' $(EXTENSION_DIR)/package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/'); \ + repo_ver=$$(grep -m1 '^version' pyproject.toml | sed 's/.*"\([^"]*\)".*/\1/'); \ + inst_ver=$$(python3 -c "from importlib.metadata import version; print(version('gitcopilot'))" 2>/dev/null); \ + echo "🧩 Versions"; \ + echo " extension (built) $$ext_ver"; \ + echo " backend (repo) $$repo_ver"; \ + echo " backend (installed) $${inst_ver:-not installed}"; \ + if [ -n "$$inst_ver" ] && [ "$$inst_ver" != "$$repo_ver" ]; then \ + echo ""; \ + echo " ⚠️ The installed backend is $$inst_ver but this checkout is $$repo_ver."; \ + echo " Nothing here touches Python. Reinstall the backend:"; \ + echo " pip install -e . --no-deps"; \ + echo " Then check that 'gitpilot serve' prints v$$repo_ver."; \ + elif [ -z "$$inst_ver" ]; then \ + echo ""; \ + echo " ⚠️ No installed gitcopilot found. The extension will have no backend."; \ + echo " pip install -e . --no-deps"; \ + fi + @echo "" diff --git a/README.md b/README.md index 8160abe..9286432 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ### The first open-source multi-agent AI coding assistant. -Multiple specialized agents — including Explorer, Planner, Coder, and Reviewer — collaborate seamlessly on every task. By default, GitPilot requests confirmation before executing high-impact actions. Switch to Auto or Plan mode at any time. +Ten specialized agents — Explorer, Planner, Coder, Reviewer and six more — collaborate on every task, coordinated by a router that picks the right ones for the request. By default, GitPilot requests confirmation before executing high-impact actions. Switch to Auto or Plan mode at any time. [![PyPI](https://img.shields.io/pypi/v/gitcopilot?style=flat-square&color=D95C3D&labelColor=1C1C1F&label=pypi)](https://pypi.org/project/gitcopilot/) [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-D95C3D?style=flat-square&labelColor=1C1C1F)](https://www.python.org/) @@ -29,16 +29,30 @@ Multiple specialized agents — including Explorer, Planner, Coder, and Reviewer ## Why GitPilot? -Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: it deploys a **team of four specialized AI agents** that collaborate on every task — just like a real engineering team. +Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: a **request router** inspects each task and dispatches it to the specialists that fit — just like a real engineering team. > **Matrix‑native:** GitPilot is the worker for [Matrix Builder](https://github.com/agent-matrix/matrix-builder) — it runs a signed Matrix Bundle under contract via `POST /api/v1/gitpilot/runs` (A2A‑secured), returns a controlled diff, and never approves or commits its own work. +### The agent roster + +Ten specialists. Which ones run depends on the request and the +[topology](docs/agents.md) in force — a small change may use one, a feature +build uses five. + | Agent | Role | What it does | |---|---|---| -| **Explorer** | Context | Reads your full repo, git log, test suite, and dependencies so the plan starts with real knowledge — not guesses | +| **Explorer** | Context | Reads your repo, git log, test suite, and dependencies so the plan starts with real knowledge — not guesses | | **Planner** | Strategy | Drafts a safe, step-by-step plan with diffs and surfaces risks before any file is touched | | **Coder** | Execution | Writes code, runs your tests, and self-corrects on failure — iterating until the suite passes | -| **Reviewer** | Quality | Validates the output, re-runs the suite, and drafts a commit message and PR summary | +| **Reviewer** | Quality | Audits changes for security, quality, test coverage and performance, ranked by severity | +| **Issue Manager** | GitHub | Creates, updates, triages and closes issues | +| **PR Manager** | GitHub | Opens branches, commits, pushes, and drafts pull requests with a test plan | +| **Search & Discovery** | GitHub | Searches code, repositories, issues and users | +| **Learning & Guidance** | GitHub | Explains GitHub features and best practices | +| **Local Editor** | Workspace | Reads and writes files directly in your local workspace | +| **Terminal** | Workspace | Runs shell commands inside the sandbox | + +**Full details:** [Agent architecture and topologies](docs/agents.md). **You control how the agent runs.** Three execution modes — selectable per session from the VS Code compose bar or backend API: @@ -151,20 +165,21 @@ The sidebar panel gives you everything in one place: | **Smart Commit** | AI-generated commit messages | | **Code Lens** | Inline "Explain / Review" hints on functions | | **Settings Tab** | Branded settings page (General, Provider, Agent, Editor) | -| **New Chat** | One click to clear chat and start a fresh session | +| **GitPilot Home** | Landing page in the editor — one composer, contextual to the file you have open | +| **New Task** | One click from the sidebar to Home, ready for the next thing | ### Execution modes -The compose bar includes a mode selector that controls how the multi-agent pipeline runs: +The compose bar carries a mode picker that controls how the multi-agent pipeline runs: ``` -[ Auto | Ask | Plan ] [ Send ] [ New Chat ] +[+] [@] [/] Ask ⌄ [ ↑ ] ``` | Mode | VS Code setting | Backend value | What happens | |---|---|---|---| | **Ask** (default) | `gitpilot.permissionMode: "normal"` | `"normal"` | Each dangerous tool (write, edit, run, commit) shows an approval card | -| **Auto** | `gitpilot.permissionMode: "auto"` | `"auto"` | Tools execute automatically — no approval prompts | +| **Agent** (shown as `Auto` in older builds) | `gitpilot.permissionMode: "auto"` | `"auto"` | Tools execute automatically — no approval prompts | | **Plan** | `gitpilot.permissionMode: "plan"` | `"plan"` | Plan is generated and displayed, all writes/commands blocked | Mode changes are persisted to VS Code settings and synced to the backend via `PUT /api/permissions/mode`. @@ -182,10 +197,19 @@ You send a request → Ask mode: approval card shown (Allow / Allow for session / Deny) → Auto mode: executes immediately → Plan mode: blocked - → Tests run, Reviewer validates + → Tests run → Done — Apply Patch or Revert ``` +The default flow stops at Coder. To add a review step — or a PR step — pick a +topology that includes one (Settings → Agent → Agent topology, or +`GitPilot: Select Topology`). **Feature Builder** runs the full +Explorer → Planner → Coder → Reviewer → PR Manager sequence. See +[Agent architecture and topologies](docs/agents.md). + +``` +``` + > **Note:** Simple questions (e.g. "explain this code") may return a direct answer without generating a multi-step plan. This is expected — the planner activates for tasks that require file changes or multi-step execution. ### Code generation and Apply Patch @@ -213,17 +237,22 @@ How it works under the hood: - `PatchApplier` writes files via `vscode.workspace.fs.writeFile` - After apply, project context refreshes and the first file opens -> **Note:** For folder-only sessions (no GitHub remote), code generation uses the LLM directly with structured output instructions. For GitHub-connected sessions, the full CrewAI multi-agent pipeline (Explorer → Planner → Coder → Reviewer) handles planning and execution. +> **Note:** For folder-only sessions (no GitHub remote), code generation uses the LLM directly with structured output instructions. For GitHub-connected sessions, the CrewAI pipeline (Explorer → Planner → Coder) handles planning and execution. Review and PR stages are added by selecting a topology that includes them — see [Agent architecture and topologies](docs/agents.md). ### Supported AI Providers +Configure any of these from **GitPilot: Settings → AI Providers** inside VS Code — +no browser, no config files. See the [provider setup guide](docs/vscode/ai-providers.md). + | Provider | Setup | Free? | |---|---|---| +| **OllaBridge** | Works out of the box; sign in for more models | Yes | | **Ollama** | Install Ollama, run `ollama pull llama3` | Yes | -| **OllaBridge** | Works out of the box (cloud Ollama) | Yes | -| **OpenAI** | Add your API key in settings | Paid | +| **Open WebUI** | Point GitPilot at your instance URL | Yes (self-hosted) | +| **OpenAI** | Add your API key | Paid | | **Claude** | Add your Anthropic API key | Paid | -| **Watsonx** | Add IBM credentials | Paid | +| **Watsonx** | Add IBM API key and project ID | Paid | +| **Custom endpoint** | Any OpenAI-compatible gateway — URL, key, headers | Depends | --- @@ -267,12 +296,19 @@ The web interface includes:

-GitPilot uses a multi-agent system powered by CrewAI: +GitPilot uses a multi-agent system powered by CrewAI. A **request router** +classifies each request and dispatches it to the specialists that fit; the +default plan-and-execute flow is: 1. **Explorer** reads your repo structure, git log, and key files 2. **Planner** creates a safe step-by-step plan with diffs -3. **Executor** writes code and runs tests, self-correcting on failure -4. **Reviewer** validates the output and summarises what changed +3. **Coder** writes code and runs tests, self-correcting on failure + +Other requests skip that chain entirely — a code-review request goes straight +to the **Reviewer**, an issue request to the **Issue Manager**. Selecting a +**topology** replaces the routing with a fixed sequence, which is how you add +a review or PR stage to every task. Ten agents and nine topologies are +documented in [Agent architecture and topologies](docs/agents.md). In **Ask** mode (default), you approve every change before it's applied. In **Auto** mode, tools execute without prompts. In **Plan** mode, only the plan is generated — no files are touched. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..a583fb8 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,189 @@ +# Agent architecture and topologies + +GitPilot is not one model behind a chat box. A **request router** classifies +each request and dispatches it to the specialists that fit, and a **topology** +decides whether that routing happens at all or a fixed pipeline runs instead. + +This page is the reference for both. Everything in it is asserted by +`tests/test_agent_topologies.py`, so it stays true as the code changes. + +--- + +## The agent roster + +Ten specialists, defined by `AgentType` in `gitpilot/agent_router.py` and +built in `gitpilot/agentic.py`. + +| Agent | Id | Tools | What it does | +|---|---|---|---| +| Repository Explorer | `explorer` | Repository (read) | Maps project structure, finds the relevant files, identifies patterns, dependencies and test conventions | +| Repository Refactor Planner | `planner` | Repository (read) | Turns exploration into a step-by-step plan: files to change, order, test strategy, trade-offs | +| Expert Code Writer | `code_writer` | Repository + write | Executes the plan step by step, running tests between steps and fixing failures before moving on | +| Code Review & Analysis Specialist | `code_reviewer` | Repository (read) | Audits changes for security, quality, coverage and performance; groups findings as Critical / Warning / Suggestion | +| GitHub Issue Management Specialist | `issue_manager` | GitHub API | Creates, updates, triages and closes issues | +| Pull Request Management Specialist | `pr_manager` | GitHub API | Branches, commits, pushes, and opens PRs with a summary and test plan | +| Search & Discovery Specialist | `search` | GitHub API | Searches code, repositories, issues and users | +| GitHub Learning & Guidance Specialist | `learning` | — | Explains GitHub features and best practices | +| Local File Editor | `local_editor` | Local file I/O | Reads and writes files directly in your workspace | +| Terminal & Shell Executor | `terminal` | Sandboxed shell | Runs commands inside the [sandbox](SANDBOX.md) | + +A **Request Router** sits in front of these. It is not an agent — it holds no +tools and writes nothing — it classifies intent and chooses the destination. + +### Why the count matters + +Earlier documentation described GitPilot as "four specialized agents +(Explorer, Planner, Coder, Reviewer)". That was two claims in one, and both +were imprecise: + +- The system has **ten** agents, not four. +- The **default** plan-and-execute flow runs **three** of them — + Explorer → Planner → Coder. There is no Reviewer stage in it. + +The four-stage description was really describing a *topology*, and the +closest one — Feature Builder — has five stages, not four. Where a Reviewer +runs is covered below. + +--- + +## When does the Reviewer run? + +Two ways, and neither is the default plan-and-execute path: + +1. **The router sends a request to it.** Ask GitPilot to review code and the + router dispatches straight to the Code Reviewer — no exploration or + planning phase. +2. **A topology includes it in its sequence.** Feature Builder, Bug Hunter + and Code Inspector all do. + +If you want every change reviewed, pin a topology that includes a reviewer. +That is what topologies are for. + +--- + +## Topologies + +A topology is a named execution shape. Nine ship with GitPilot, in two +categories. + +### Pipelines — fixed agent sequences + +Every request runs the same agents in the same order. Predictable, and the +right choice when you want a guaranteed review or PR step. + +| Topology | Sequence | Use it when | +|---|---|---| +| **Feature Builder** 🚀 | Explorer → Planner → Coder → Reviewer → PR Manager | Building a feature end to end, ending in a pull request | +| **Bug Hunter** 🐛 | Explorer → Coder → Reviewer → PR Manager | Fixing a defect — skips planning, keeps the review | +| **Code Inspector** 🔍 | Explorer → Reviewer | Auditing code without changing it. Read-only | +| **Architect Mode** 📐 | Explorer → Planner | Designing an approach before committing to it. Read-only | +| **Quick Fix** ⚡ | Coder → PR Manager | A change you have already scoped. Fastest path, no review | + +`developer` and `git_agent` are the sequence ids for the Coder and PR Manager +respectively — the ids the API returns in `agents_used`. + +Pipelines that include a write-capable agent (`developer`, `git_agent`) +create a working branch named `gitpilot---` before +touching anything, so your default branch is never modified in place. + +### System topologies — routed + +No fixed sequence; agents are chosen per request. + +| Topology | Style | Use it when | +|---|---|---| +| **Default (CrewAI Routing)** | single task | General use. The router picks agents by intent | +| **GitPilot Code (ReAct + Subagents)** | ReAct loop | Complex, open-ended work that needs iteration | +| **Lite Mode (Small LLMs)** | single task | Models under ~7B parameters. Simplified prompts, single-agent execution, pre-fetched context instead of tool-calling | +| **Tool-Augmented ReAct** | ReAct loop | Experimental | + +**Automatic** — the absence of a saved preference — is the recommended +default. It leaves routing to the router. + +--- + +## Choosing a topology in VS Code + +Two routes, both of which write to the GitPilot backend (the backend is what +actually routes work, so a setting that only lived in VS Code would do +nothing): + +**Settings UI** — `GitPilot: Settings` → **Agent** → *Agent topology*. Each +topology is a card showing its description and full agent sequence. Click one +to make it the default; the active choice is badged. + +**Command palette** — `GitPilot: Select Topology`. + +Selecting **Automatic (recommended)** clears the preference and restores +per-request routing. + +### Per-request override + +A topology chosen in settings is a *default*. An explicit `topology_id` on an +API request always wins, so automation can pin a pipeline without changing +what the UI does. + +--- + +## Choosing a topology from the API + +```bash +# List every preset, with its agent sequence +curl http://127.0.0.1:8000/api/flow/topologies + +# Read the current default ("topology": null means automatic) +curl http://127.0.0.1:8000/api/settings/topology + +# Pin a pipeline +curl -X POST http://127.0.0.1:8000/api/settings/topology \ + -H 'Content-Type: application/json' \ + -d '{"topology": "feature_builder"}' + +# Back to automatic routing +curl -X POST http://127.0.0.1:8000/api/settings/topology \ + -H 'Content-Type: application/json' \ + -d '{"topology": "auto"}' +``` + +An unknown topology id returns **400** rather than being stored. A silently +accepted bad name would look saved and do nothing. + +The full graph for one topology — nodes and edges, for rendering — comes from +`GET /api/flow/topology/{id}`. + +--- + +## Execution styles + +`execution_style` in the API payload tells you how a topology runs: + +| Style | Meaning | +|---|---| +| `crew_pipeline` | A multi-task CrewAI crew. Each agent gets its own task; step *N*'s output is chained into step *N+1* | +| `single_task` | One task, with the router selecting the agent | +| `react_loop` | An iterative reason-act loop with subagents | + +--- + +## Permission modes are separate + +Topology decides *which agents run*. Permission mode decides *what they may do +without asking*. They compose: + +| Mode | Behaviour | +|---|---| +| **Ask** (default) | Each dangerous tool — write, edit, run, commit — shows an approval card | +| **Auto** | Tools execute without prompting | +| **Plan** | Read-only. The plan is produced; all writes and commands are blocked | + +Plan mode with a write-capable pipeline is not a contradiction: the pipeline +runs, and the writes are blocked. You get the plan and the review without the +changes. + +--- + +## See also + +- [AI provider setup in VS Code](vscode/ai-providers.md) +- [Sandbox and approvals](SANDBOX.md) +- [API stability](API_STABILITY.md) diff --git a/docs/vscode/agent-topologies.md b/docs/vscode/agent-topologies.md new file mode 100644 index 0000000..5d2bf24 --- /dev/null +++ b/docs/vscode/agent-topologies.md @@ -0,0 +1,155 @@ +# Configuring agent topologies in VS Code + +A **topology** decides which agents run on your requests and in what order. +This page covers driving that from VS Code; for what each agent and topology +actually does, see [Agent architecture and topologies](../agents.md). + +--- + +## Where it lives + +**`GitPilot: Settings`** → **Agent** → *Agent topology*. + +Topologies load when you open the section, and each one is a card showing its +description and full agent sequence: + +``` +AGENT TOPOLOGY + + Automatic (recommended) Active + GitPilot routes each request to the agents that fit it. + Agents chosen per request + + PIPELINES — FIXED AGENT SEQUENCES + + 🚀 Feature Builder + Full pipeline: explore > plan > implement > review > PR + Explorer → Planner → Coder → Reviewer → PR Manager + + 🐛 Bug Hunter + Find and fix a defect, then open a PR + Explorer → Coder → Reviewer → PR Manager + + 🔍 Code Inspector + Read-only audit + Explorer → Reviewer + ... +``` + +Click a card to make it the default. The choice is saved to the GitPilot +backend immediately — the backend is what routes work, so this is not a +setting that only takes effect on restart. + +The command palette equivalent is **`GitPilot: Select Topology`**. + +--- + +## Automatic vs. a pinned pipeline + +**Automatic** is the recommended default and is the *absence* of a saved +topology. Each request is classified and routed: a review request goes to the +Reviewer, an issue request to the Issue Manager, a build request through +explore → plan → code. + +**Pin a pipeline** when you want the same thing to happen every time. The +usual reason is a guaranteed stage the default flow does not include: + +| You want | Pick | +|---|---| +| Every change reviewed before it lands | **Feature Builder** or **Bug Hunter** | +| A pull request opened automatically | any pipeline ending in PR Manager | +| Analysis with no possibility of a write | **Code Inspector** or **Architect Mode** | +| Minimum latency on a change you have already scoped | **Quick Fix** | + +Selecting **Automatic (recommended)** clears the preference again. + +--- + +## What a pinned pipeline changes + +- **Every** request runs the full sequence, including small ones. Quick + questions get slower. +- Pipelines containing a write-capable agent create a working branch — + `gitpilot---` — before touching anything. Your + default branch is not modified in place. +- Step *N*'s output is chained into step *N+1*, so the Reviewer sees what the + Coder produced. + +--- + +## Combining with permission mode + +Topology decides *which agents run*; permission mode decides *what they may +do unattended*. Both are on the **Agent** page and they compose. + +A useful pairing: **Feature Builder** with **Plan** mode. The full pipeline +runs — including the review — and every write is blocked. You get the plan +and the audit without the changes. + +| Goal | Topology | Mode | +|---|---|---| +| Review a codebase, change nothing | Code Inspector | Plan | +| Design an approach first | Architect Mode | Plan | +| Ship a feature with approval at each write | Feature Builder | Ask | +| Unattended batch work | Quick Fix | Auto | + +!!! warning + **Auto** mode executes every tool without prompting. Pair it with a + pipeline whose scope you trust. + +--- + +## Enterprise rollout + +**Standardise the pipeline.** Pin one topology so every engineer's work +follows the same path. Feature Builder gives an audit trail: explored, +planned, implemented, reviewed, PR'd — each stage's output visible in the +run. + +**Keep review non-optional.** A pinned topology containing a Reviewer means +the review cannot be skipped by phrasing a request differently. Routed mode +cannot promise that. + +**Automate per-request.** A topology set in settings is a default; an +explicit `topology_id` on an API request overrides it. CI can pin +`code_inspector` for audit runs without disturbing what developers see: + +```bash +curl -X POST http://127.0.0.1:8000/api/settings/topology \ + -H 'Content-Type: application/json' \ + -d '{"topology": "feature_builder"}' +``` + +An unknown id returns 400 rather than being stored silently. + +**Constrain execution.** Topology governs agents, not blast radius. Pair it +with permission mode and the [sandbox](../SANDBOX.md), which jails the +working directory, scrubs secrets and applies a destructive-command denylist. + +--- + +## Troubleshooting + +**The Agent page says topologies need the server** +Topology presets and the saved preference both live on the backend. Connect +from **AI Providers** — which has [recovery +actions](ai-providers.md#when-the-gitpilot-server-is-not-running) — then +return. + +**My topology choice had no effect** +Earlier builds wrote `gitpilot.defaultTopology` into VS Code settings only, +where nothing read it. It now writes through to the backend. If you set a +topology in an older version, set it again. + +**A pipeline seems to skip a stage** +An agent id that does not resolve is skipped with only a log line. Check the +**GitPilot** output channel; `tests/test_agent_topologies.py` guards the +shipped sequences against this. + +--- + +## See also + +- [Agent architecture and topologies](../agents.md) — the agents and presets +- [AI provider setup](ai-providers.md) — choosing the model behind the agents +- [Sandbox and approvals](../SANDBOX.md) diff --git a/docs/vscode/ai-providers.md b/docs/vscode/ai-providers.md new file mode 100644 index 0000000..45b4cc1 --- /dev/null +++ b/docs/vscode/ai-providers.md @@ -0,0 +1,300 @@ +# AI provider setup in VS Code + +Everything about provider configuration happens inside VS Code. There is no +browser step, no config file to hand-edit, and no API key that has to be +pasted into a terminal. + +Open it with **`GitPilot: Settings`** from the command palette +(Ctrl/Cmd+Shift+P), then choose +**AI Providers**. + +--- + +## The overview page + +The first thing you see is a summary, not a form: + +``` +AI Providers + + ● GitPilot Server Connected + http://127.0.0.1:8000 [Reconnect] [Change server] + + ACTIVE PROVIDER + Claude (Anthropic) Active › + claude-sonnet-4-5 + + AVAILABLE PROVIDERS + OllaBridge Cloud › + Free hosted models — sign in, no API key needed + Ollama (Local) › + Run models locally on your computer + ... +``` + +Click any provider to open its configuration page. Only one provider's form +is ever on screen. **‹ Back to AI Providers** returns to the overview without +leaving the settings tab. + +A provider is only activated when you press **Save and activate**. Opening a +page and closing it changes nothing. + +--- + +## How API keys are handled + +Keys are stored by the GitPilot backend, never in VS Code settings and never +in your workspace. + +The settings page never receives a stored key. It is told only that one +exists, and shown the last four characters: + +``` +API key configured: ••••A7X2. Leave the field empty to keep it. Remove API key +``` + +Three rules follow from that: + +- **An empty key field means "keep the current key."** You can change a model + or a base URL without re-entering the secret. +- **Removing a key is a separate, confirmed action** — the *Remove API key* + link, which asks before clearing. +- **Keys never appear in logs, notifications or error messages.** Error text + is redacted before it is displayed. + +If your GitPilot server is remote *and* reached over plain HTTP, GitPilot +warns before sending a key and lets you cancel. + +--- + +## Providers + +### OllaBridge Cloud + +Free hosted models, and the default. Three connection methods, shown as tabs: + +**Cloud Login** — click **Sign in with browser**. Your browser opens the +OllaBridge sign-in page and shows a pairing code; paste that code back into +VS Code and press **Pair device**. GitPilot never asks for your password. Once +paired the page shows your connection and a **Sign out** action. + +**API Key** — for an OllaBridge endpoint you already have a token for. Set the +endpoint, the key, and a model. + +**Local Gateway** — for a self-hosted OllaBridge. Defaults to +`http://127.0.0.1:11435`. + +!!! warning "Not port 8000" + `http://localhost:8000` is *GitPilot's own backend*, not an OllaBridge + gateway. Pointing OllaBridge there makes model discovery query GitPilot + about itself. GitPilot rejects that URL with an explanation, and repairs + the value automatically if an older install stored it. + +### Ollama (Local) + +Set the Ollama URL — `http://127.0.0.1:11434` by default — and pick from the +models that machine has pulled. **Refresh** re-scans. + +If Ollama is not running, the page says so plainly and offers **Retry** and +**Install Ollama** rather than leaving an empty dropdown. + +Enter the instance root, not the API root. GitPilot talks to Ollama's +OpenAI-compatible surface and appends `/v1` itself; typing it yourself is +harmless — the URL is normalised, so `http://127.0.0.1:11434` and +`http://127.0.0.1:11434/v1` mean the same thing and neither becomes `/v1/v1`. +Model ids are passed through exactly as Ollama reports them, so anything +`ollama list` shows works, including `ollama create` names and +`hf.co/user/model` pulls. + +### Claude (Anthropic) + +Needs an Anthropic API key from +[console.anthropic.com](https://console.anthropic.com/settings/keys). + +!!! note + A Claude.ai subscription does **not** include API access. The API is + billed separately and needs its own key. + +Model and an optional custom base URL are configured on the same page. + +### OpenAI + +An API key from [platform.openai.com](https://platform.openai.com/api-keys), +a model, and an optional base URL — set the base URL for Azure OpenAI or a +proxy. + +### IBM watsonx + +Needs **both** an API key and a project ID; watsonx is not considered +configured with only one. The region base URL defaults to +`https://us-south.ml.cloud.ibm.com`. + +### Open WebUI + +Point GitPilot at your Open WebUI instance — `http://localhost:3000` by +default. Paste the **root URL**: GitPilot appends the OpenAI-compatible API +path itself, so `/api` or `/v1` on the end is unnecessary (and handled if you +include it anyway). + +The API key is optional — an instance open to your local network needs none. +If yours requires one, create it in Open WebUI under +**Settings → Account → API keys**. + +Models are discovered from the instance. If it does not answer, the page says +so instead of showing an empty list. + +### Custom endpoint + +Any OpenAI-compatible chat-completions gateway: self-hosted inference, a +corporate model gateway, a proxy in front of several vendors. + +| Field | Notes | +|---|---| +| **Endpoint URL** | The OpenAI-compatible base URL. Pasting a full `/chat/completions` path also works — GitPilot trims it back to the root | +| **API key** | The token your endpoint issues | +| **Model** | The model id the endpoint expects | +| **Request headers** | Extra headers sent with every request | + +**Request headers** exist because gateways routinely require attribution or +routing headers alongside the key — for example a header carrying your user +identity so usage is attributed correctly: + +``` +x-user you@example.com +x-client-app-id gitpilot +``` + +Add rows with **Add header**, remove them with **Remove**. The saved set is +exactly what the editor shows: a row you delete is deleted, and a row with a +blank name is ignored. Put the token in the **API key** field, not in a +header. + +**Model discovery** is best-effort and tries the richer source first: + +1. A published catalogue at the endpoint's origin + (`/.well-known/opencode` or `/.well-known/models`) +2. The OpenAI-compatible `/models` listing + +If neither is available — plenty of gateways serve chat-completions without a +catalogue — the page says so and you enter the model id by hand. That is a +working configuration, not an error. + +--- + +## When the GitPilot server is not running + +The settings page always opens. If the backend is unreachable you get +recovery actions rather than a dead-end dialog: + +``` +GitPilot server is not connected + +Provider settings are stored by the GitPilot backend, so they +cannot be loaded right now. + +[Start local server] [Reconnect] [Change server URL] [Copy diagnostics] + +Or start it yourself: +gitpilot serve --no-open +``` + +**Start local server** runs `gitpilot serve --no-open` for you and follows +the port it actually binds — GitPilot moves to the next free port when 8000 +is taken, and the extension follows it rather than losing the connection. +Output goes to the **GitPilot** output channel. + +For a **remote** server URL there is no *Start local server* button: starting +a process on your machine would not make someone else's server reachable. +You get Reconnect, Change server URL, and Copy diagnostics. + +If `gitpilot` is not on your `PATH`, set `gitpilot.serverCommand` to its full +path. + +--- + +## Performance + +Provider pages avoid the slow endpoints deliberately: + +| Call | Timeout | Notes | +|---|---|---| +| Health | 3s | Single attempt. A slow answer is the same as no answer | +| Settings | 10s | | +| Model discovery | 15s | Lazy — only for the provider you are configuring — and cached for 60s | +| Connection test | 30s | Performs a real round-trip | + +`/api/status` is not called from these pages at all; it probes live providers +and can take upwards of 15 seconds. + +--- + +## Settings reference + +| Setting | Default | Purpose | +|---|---|---| +| `gitpilot.serverUrl` | `http://127.0.0.1:8000` | Where the GitPilot backend is | +| `gitpilot.serverCommand` | `gitpilot` | Command used to start a local server. Use a full path if it is not on `PATH` | +| `gitpilot.autoConnect` | `true` | Connect on startup | + +Provider credentials are deliberately **not** in this table. They live on the +GitPilot server, not in VS Code configuration, so they are never written to +`settings.json` or synced by Settings Sync. + +--- + +## Environment variables + +Configuring in VS Code is the recommended path. For headless or scripted +installs, the backend also reads: + +| Variable | Provider | +|---|---| +| `OPENAI_API_KEY`, `OPENAI_BASE_URL` | OpenAI | +| `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL` | Claude | +| `WATSONX_API_KEY`, `WATSONX_PROJECT_ID`, `WATSONX_BASE_URL` | watsonx | +| `OLLAMA_BASE_URL` | Ollama | +| `OLLABRIDGE_BASE_URL`, `OLLABRIDGE_API_KEY` | OllaBridge | +| `OPENWEBUI_BASE_URL`, `OPENWEBUI_API_KEY` | Open WebUI | +| `GITPILOT_CUSTOM_BASE_URL`, `GITPILOT_CUSTOM_API_KEY`, `GITPILOT_CUSTOM_MODEL` | Custom endpoint | + +Environment values are merged in on every load, so operators can supply +credentials without them being written to disk. Custom request headers are +configured in the settings UI. + +See [`.env.template`](https://github.com/ruslanmv/gitpilot/blob/master/.env.template) +for the annotated list. + +--- + +## Troubleshooting + +**"OLLABRIDGE API key not configured" on startup** +Fixed. OllaBridge needs no API key, and the startup check no longer claims +otherwise. Upgrade if you still see it. + +**Model list is empty** +Discovery is lazy — press **Refresh**. If the provider cannot be reached the +page shows why. Every provider page accepts a manually typed model id. + +**"Not connected" when the server is running** +Fixed. The connection state is re-probed rather than read from a cached flag +that refreshed on a 30-second timer. + +**Changing the provider dropdown did nothing** +That dropdown is gone. Provider selection is a page with an explicit +**Save and activate**, which writes through the backend API. + +**"Fallback to LiteLLM is not available" when a task produces a plan** +Fixed in 0.2.8, and it affected Ollama, OllaBridge, Open WebUI and custom +endpoints alike. Chat reaches these providers over plain HTTP, but planning +hands a model to CrewAI, which would only route a provider it recognised +natively — and on CrewAI 1.6 and earlier, none of these four qualified. +Upgrade GitPilot; if you cannot yet, `pip install litellm` unblocks it. +[Full explanation](troubleshooting.md#planning-is-the-path-that-still-goes-through-crewai). + +--- + +## See also + +- [Agent architecture and topologies](../agents.md) +- [Ports and defaults](../PORTS.md) diff --git a/docs/vscode/interface.md b/docs/vscode/interface.md new file mode 100644 index 0000000..e688429 --- /dev/null +++ b/docs/vscode/interface.md @@ -0,0 +1,375 @@ +# The GitPilot interface + +GitPilot has **two** surfaces, and each answers one question. + +| Surface | Question | What lives there | +| --- | --- | --- | +| **Sidebar** | *Which task?* | Status, **New Task**, recent tasks, quick actions, Settings | +| **Editor tab** | *Work on that task* | The landing page, then the conversation | + +``` +┌───────────────┬──────────────────────────────────────────┐ +│ GITPILOT │ GitPilot │ +│ │ │ +│ ● Ready │ What are we building? │ +│ gitpilot/main │ │ +│ │ ┌───────────────────────────┐ │ +│ + New Task │ │ Describe a task... │ │ +│ │ │ + @ / Ask ▾ ↑ │ │ +│ Recent Tasks │ └───────────────────────────┘ │ +│ Auth refactor│ │ +│ Fix CI │ Review Find bugs Tests Explain │ +│ │ │ +│ ⚙ Settings │ ● Ready · llama3:8b · gitpilot repo │ +└───────────────┴──────────────────────────────────────────┘ +``` + +**There is one composer.** The sidebar used to contain a complete second chat — +its own prompt box, its own quick actions, its own send flow — sitting under a +landing page that had all the same things. The first question anyone asked was +"which one am I supposed to use?", which is not a question a finished product +asks of anyone. + +## The sidebar + +Navigation, and nothing else. It answers four things: + +1. **Is GitPilot ready?** — one status line. +2. **What project am I in?** — repository and branch. +3. **What was I working on?** — recent tasks. +4. **How do I start?** — **New Task**. + +### One status, stated once + +There used to be four status concepts on screen at the same time — `Offline`, +`Disconnected`, `Provider not set`, `No model` — and two of them could +contradict each other, with a *Disconnected* pill sitting beside a *Ready* one. + +There is now a single primary state, with the reason underneath only when there +is one worth giving: + +| State | Means | Reason shown | +| --- | --- | --- | +| **● Ready** | You can start work | — | +| **● Connecting…** | In flight. The only state that animates | — | +| **● Needs setup** | Server reachable, but unusable | `No provider configured` | +| **● Offline** | Server unreachable | `GitPilot server isn't running` | + +*Needs setup* exists because a server you can reach but cannot use is not +ready — saying "Ready" there sends people into a task that cannot start. +When offline, **Start server** and **Reconnect** appear directly under the +reason, not in a second card that repeats it. + +### Recent tasks, not sessions + +`session` is the right word in the code. In the interface these are engineering +jobs — *Add OAuth*, *Fix CI*, *Refactor authentication* — so they are **tasks**. +GitPilot is a collection of development tasks, not a collection of chats. + +Clicking a task opens it. One click, one result — no select-then-open. + +### New Task starts a task + +**New Task** creates a new session and gives you a clean panel: the landing +page, an empty composer, no transcript, no plan, no changed files. The previous +conversation is not lost — it moves into **Recent Tasks**, one click away. + +It used to only reveal the editor tab, on the theory that a session should be +created once there was something to create it for. In practice that landed you +in the previous conversation with everything still on screen, so the one +control named for starting fresh was the one that never did. + +Clearing the store is not enough on its own, which is why this needed more than +a one-line fix: + +- **Tool-activity blocks** live in the transcript's DOM and carry no message + key, so a state change leaves them — and their presence keeps the transcript + on screen instead of the landing page. +- **The composer has its own memory**: a queued message, pinned files, and + whatever you had half-typed. All three belong to the task being replaced. +- **A run still streaming** would carry on writing into the new conversation, + because the panel opens a fresh streaming node the moment a chunk arrives + with none open. New Task cuts the stream first. + +Resuming a task from **Recent Tasks** clears the same things before replaying +that task's history, so a resumed conversation never appears underneath another +task's steps. + +### Quick actions + +Five shortcuts, below Recent Tasks. They open the GitPilot tab and run there, +so the answer always lands in the conversation. + +Recent Tasks comes first on purpose: once GitPilot is in daily use, resuming +work is more frequent than starting a canned action. + +### What is deliberately not there + +- **No composer.** There is one, in the editor. +- **No brand row.** VS Code already draws `GITPILOT` above the view; a second + one put the same word on screen twice. +- **No second status card**, no repository line repeated, no pills. + +## The editor tab + +One tab, titled **GitPilot**. Empty, it is the landing page; send a message and +the same tab is the conversation. They are the same surface at two moments, +which is why there is never a question about which composer is real. + +``` + GP GitPilot + + What are we building? + + Ask GitPilot to investigate, explain, change, review, or test your code. + + ┌──────────────────────────────────────────────────────────┐ + │ Describe the task, ask a question, or request a change… │ + │ │ + │ [+] [@] [/] Ask ⌄ [ ↑ ] │ + └──────────────────────────────────────────────────────────┘ + Enter to send · Shift+Enter for a new line Ready + + [ Review code ] [ Find bugs ] [ Write tests ] [ Explain project ] + + ● Ready · llama3:8b · gitpilot repository · Changes require approval +``` + +The order is deliberate: the question, then the box to answer it in, then the +shortcuts. Putting the shortcuts above the composer pushed the one thing to do +off the fold. + +The composer is the one framed object on the page — everything else sits on the +editor background, so what to do next is unmistakable. The four suggestions are +neutral outlines rather than accent pills on purpose: four bright buttons +compete with the send button and turn a landing page into a toolbar. + +`Send` is a small arrow rather than a full-width bar, for the same reason. Enter +is what people actually press. + +### The mode picker + +`Ask ⌄` opens a menu naming all three modes and what each will and will not do: + +| | | +| --- | --- | +| **Ask** | Proposes changes; you approve each change | +| **Plan** | Read-only. Investigates and plans; nothing is written | +| **Agent** | Edits files and runs tools without prompting | + +It replaced a three-segment control that ate the width the composer needed and +could only explain itself in a tooltip. The stored value is unchanged. + +The column is capped at a readable width — this is an editor tab, and on a wide +monitor an uncapped composer stretches to 2000px. + +### It knows what you are looking at + +Selecting code in the editor attaches it to the composer automatically. See +**Context chips** below. + +### When the server is not reachable + +The status line says `Offline`, and the sidebar carries **Start server** / +**Reconnect**. Recovery lives in one place, not two. + +## The conversation + +### Context chips + +What GitPilot will look at is stated **above** the composer, before you send — +not guessed afterwards. + +``` + ┌────────────────────────────────────────────────────┐ + │ ◉ src/api/user.ts:42-58 × @ src/db.ts × │ + │ │ + │ add error handling │ + │ │ + │ [+] [@] [/] Ask ⌄ [ ↑ ] │ + └────────────────────────────────────────────────────┘ +``` + +- **Selecting code in the editor attaches a chip automatically** — file and + line range, with the selected code travelling to the model, capped at 4000 + characters. Clearing the selection removes the chip; it never stacks. +- **`+` or `@`** pins a file. Pinned files are consumed by the send, so they are + not silently re-sent with every later message. The live selection chip stays, + because the selection is still there. +- **Every chip has an `×`.** Context you cannot see or take back is context you + cannot trust. + +### `@` and `/` completion + +Both complete **inline, in a dropdown above the composer** — not in a modal +quick pick — so the sentence you are part-way through writing stays on screen. + +| Key | Opens | Notes | +| --- | --- | --- | +| `@` | Workspace files | Accepting one adds a **chip**, not inline text | +| `/` | `/explain` `/review` `/fix` `/test` `/plan` `/security` | Only at the start of a message | + +/ move, Enter or Tab accepts, +Esc closes. A `@` inside a word (`someone@example.com`) and a `/` +mid-sentence (`http://x/rev`) are not triggers. + +### Typing while GitPilot works + +Thinking of the next thing to say while an agent runs is normal, so: + +- Enter **queues** the message. The status line reads `2 queued`, and + the queue is sent in order as the run ends. +- Enter never cancels. Losing a task to a reflex is not a trade + anyone would choose. +- Esc, or the **Stop** button, cancels the run *and* drops the queue — + firing it would restart the work you just stopped. + +### Tool activity is part of the conversation + +Consecutive tool calls collapse into one line **in the transcript**, and stay +there once the task is over: + +``` + ⌄ ✓ Investigated 3 steps + ✓ Read src/api/user.ts + ✓ Read src/api/auth.ts + ✓ Searched handleAuth +``` + +Click to expand. A failed step says so in the summary — `1 of 4 steps failed` — +and turns the marker red. Tool names are rendered as verbs, because +`read_file · completed` is a log and `Read src/api/user.ts` is an explanation. + +Blocks you expand stay expanded: the transcript is patched on each update +rather than rebuilt, so nothing closes underneath you. + +### Reading a change + +Proposed changes are stated as one line before they are listed: + +``` + ⌄ ✓ Updated user.ts +8 −2 +``` + +Counts come from the backend when it supplies them, and are otherwise read off +the diff preview. The per-file rows, with **Open** and **Diff**, are still +underneath. + +### Everything else in the transcript + +- **Timestamps appear on hover.** A column of times down the right-hand edge is + noise you scan past; on hover it is exactly as available as before. +- **A message is text, not a card.** The role is carried by a 2px rule. Cards + are reserved for things you can act on — approvals, proposed changes. +- **Code blocks have a Copy button**, and diffs open in VS Code's native diff + viewer. + +## Undo: checkpoints and rewind + +GitPilot snapshots the workspace **and** the conversation before every change +it makes. That is what turns Agent mode from a one-way door into a choice: let +it run, and rewind if it went wrong. + +### What a checkpoint is + +Three things captured together, before a mutating tool runs: + +1. The workspace, committed to a **shadow git repository** at + `~/.gitpilot/history//`. Your own `.git`, index and + uncommitted work are never touched. +2. The conversation up to that point. +3. The tool call that was about to run — which is where + `Before write_file · src/api/user.ts` comes from. + +Git stores one copy of an unchanged file however many checkpoints reference +it, which is what makes snapshotting before *every* tool call affordable. + +### Rewinding + +| How | What it does | +| --- | --- | +| **Rewind…** in the chat panel | Pick any checkpoint in this session | +| **Revert** | Undo the most recent change | +| *GitPilot: Rewind to a Checkpoint* | The same picker, from the palette | + +A rewind restores **both** halves. Files alone would leave the model reasoning +about edits that no longer exist, so the transcript is truncated to the same +point and everything after it is discarded. You are asked to confirm first, and +the dialog says which of the two it can actually do. + +Restoring is a **mirror, not an overlay** — a file the agent invented is +removed, not just overwritten. Ignored directories (`.git`, `node_modules`, +virtualenvs, build output) are never touched in either direction. + +### When the workspace is too large + +Past 256 MB of snapshottable files, a checkpoint records the conversation and +the tool call but not the files, and marks itself `has_files: false`. The +picker then offers *"Conversation only — the workspace was too large to +snapshot"* and rewinding leaves your files alone. Half a checkpoint that is +honest about being half beats a rewind that cannot happen. + +### Ask, Plan and Agent all get it + +Checkpoints are taken at the one place all three permission modes converge, so +an approved write in Ask mode is snapshotted exactly like an unattended write +in Agent mode. Plan mode writes nothing, so it records nothing. + +A checkpoint that fails is logged and the tool runs anyway — a safety net that +stops the show is worse than one with a hole in it. + +### API + +| Route | Purpose | +| --- | --- | +| `POST /api/sessions/{id}/checkpoint` | Take one now | +| `GET /api/sessions/{id}/checkpoints` | List them, newest first | +| `POST /api/sessions/{id}/rewind` | Restore files + conversation | + +## The permission model + +The mode is stated in three places — the composer, the trust footer, and the +chat's own mode selector — because it decides whether GitPilot may write to your +repository. + +| Mode | What GitPilot may do | +| --- | --- | +| **Ask** | Proposes changes; you approve each one. This is the default | +| **Plan** | Read-only. Investigates and plans; nothing is written | +| **Agent** | Edits files and runs tools without prompting | + +`Agent` is a label, not a new setting — the stored value is still `auto`, so +existing configuration and the `gitpilot.permissionMode` setting are unchanged. + +## The trust footer + +The quiet line at the bottom of Home answers the questions an enterprise user +asks before typing anything: + +``` +● Ready · llama3:8b · gitpilot repository · Changes require approval +``` + +Readiness, which model is answering, which repository is in scope, and what +GitPilot is allowed to do. The model and the permission mode are buttons. + +## Commands and settings + +| Command | Title | +| --- | --- | +| `gitpilot.openHome` | **GitPilot: Home** | +| `gitpilot.openChatTab` | **GitPilot: Open Chat in an Editor Tab** | +| `gitpilot.openChat` | **GitPilot: Open GitPilot Workspace** (Ctrl/Cmd+Shift+G) | +| `gitpilot.newSession` | **GitPilot: New Session** | + +| Setting | Default | Effect | +| --- | --- | --- | +| `gitpilot.showHomeOnStartup` | `true` | Open Home when a window starts with no files open. Home is never opened on top of work already in progress | + +## Motion + +Every animation the extension had is still present — message entrance, thinking +pulse and sweep, plan collapse, success flash, error shake, caret blink, spinner +and activity fade — and Home adds a single page entrance plus the connecting +pulse. All of it is disabled automatically when the operating system reports +`prefers-reduced-motion`. diff --git a/docs/vscode/local-testing.md b/docs/vscode/local-testing.md new file mode 100644 index 0000000..5cc6a89 --- /dev/null +++ b/docs/vscode/local-testing.md @@ -0,0 +1,398 @@ +# Testing the extension locally + +How to build the VS Code extension, install it into your own editor, and check +that the settings pages actually work. + +--- + +## Quick version + +```bash +make extension-test # run the automated suites +make extension-dev # package + install into your VS Code +``` + +Then in VS Code: **Ctrl/Cmd+Shift+P** → *Developer: Reload Window* → +**Ctrl/Cmd+Shift+P** → *GitPilot: Settings*. + +--- + +## The targets + +| Target | What it does | +|---|---| +| `make extension-install` | Install npm dependencies only | +| `make extension-compile` | TypeScript → JavaScript, and copy the webview assets into `out/` | +| `make extension-test` | Compile, then run the automated suites | +| `make extension-package` | Build a `.vsix` | +| `make extension-dev` | Package **and** install into your local VS Code | +| `make extension-uninstall` | Remove the locally installed extension | + +`extension-dev` needs the `code` command on your `PATH`. If it is not there: +**Ctrl/Cmd+Shift+P** → *Shell Command: Install 'code' command in PATH*. Failing +that, install the `.vsix` by hand from the Extensions view → `...` menu → +*Install from VSIX...*. + +!!! warning "Reload after installing" + VS Code keeps the previous copy loaded until the window reloads. If a + change appears to have done nothing, this is almost always why. + +--- + +## The automated suites + +```bash +make extension-test +# or, from extensions/vscode: +npm test +``` + +Ten suites, in separate processes so a crash in one still reports the others: + +**Connection procedure** (`test/connection.test.js`) drives the real client +against a real HTTP server whose answer speed the test controls, because the +bug it covers was entirely about timing: a backend answering 200 in ten seconds +was reported as Offline by a 3-second probe. It asserts the escalating +deadline, that a refused connection still fails fast, that "slow" and +"not there" are told apart, and that the watcher reconnects on its own once the +server appears. + +**Diagnostics** (`test/diagnostics.test.js`) covers the reporting that exists +because failures kept being invisible: a backend from a different build is +called out unprompted and only once, every request is recorded with its timing +and reason, and the report still works when the backend cannot answer — which +is exactly when it is needed. + +**Navigation sidebar** (`test/navView.test.js`) asserts the sidebar navigates +and nothing more — no composer, no message list, no quick actions, no provider +dropdown — plus one primary status that never contradicts itself, and one-click +task opening. + +**Landing page** (`test/landing.test.js`) asserts the landing and the +conversation are the same surface at two moments: exactly one composer on the +whole page, the landing gives way to the transcript on the first message and +comes back when it is cleared, status is stated once, and every animation the +panel had is still present. + +**Session commands** (`test/chatSession.test.js`) covers what "New Chat did +nothing" was really about: the conversation the panel renders is cleared before +the network round-trip, the task state goes with it, and resuming replays a +session's history into the surface the user can actually see. + +**Chat panel presentation** (`test/chatPanel.test.js`) pins the visual decisions +that are easy to undo by accident — a message is text and not a card, the +transcript uses the height it has, the mode selector states a permission model +— and asserts that every animation the panel ever had is still present. + +**Composer and transcript** (`test/composer.test.js`) drives the chat the way a +user does: context chips appear from a selection and can be taken back, `@` and +`/` complete inline, a message typed mid-run is queued rather than interrupting, +tool calls collapse into a block that survives a re-render with its open state +intact, and a diff is summarised before it is listed. + +**Command wiring** (`test/commandWiring.test.js`) checks the joins between the +webview, `package.json` and the commands the extension actually registers. It +exists because three buttons — Revert, Rewind and Approve & Execute — dispatched +commands nobody had registered, so they failed silently while every piece in +isolation looked fine. + +**Settings webview** (`test/settingsWebview.test.js`) loads the real template +into jsdom, plays the extension host's side of the message protocol, and drives +the pages like a user — clicking provider rows, switching OllaBridge tabs, +toggling MCP tools. It catches navigation and rendering bugs. + +**Settings panel host** (`test/settingsPanel.test.js`) loads the compiled panel +with a stubbed `vscode` module and calls its message handler directly. It +catches what a UI test cannot see: that no API key ever appears in a message +bound for the webview, that a save writes the config before activating the +provider, and that destructive actions ask first. + +Both load from `out/`, so they test what actually ships. `npm test` compiles +first; `npm run test:only` skips that when you have already built. + +### Backend suites + +```bash +pytest tests/test_provider_setup.py # provider rules, URL normalisation +pytest tests/test_agent_topologies.py # agent roster, pipeline sequences +pytest tests/test_mcp_catalog_remote.py # MCP registry search, catalogue packaging +pytest tests/test_checkpoints.py # snapshot, mirror-restore, size limit +pytest tests/test_checkpoint_api.py # checkpoint/rewind routes, auto-snapshot gate +pytest tests/test_direct_chat.py # chat over plain HTTP, and the probe that must not block +pytest tests/test_diagnostics_api.py # /api/health version, /api/diagnostics +``` + +--- + +## Running against a live backend + +The extension is a client; most pages need `gitpilot serve` running. + +```bash +pip install -e . # or: pip install gitcopilot +gitpilot serve --no-open +``` + +You do **not** have to start it by hand — that is one of the things worth +testing. See the offline checklist below. + +--- + +## Manual checklist + +Work through this after `make extension-dev` and a window reload. Each item +names what to do and what you should see. + +### The sidebar + +1. Open the GitPilot icon in the activity bar. ✅ The only view is **GitPilot** — + a status line, **New Task**, **Recent Tasks**, **Settings**. No message + list, no composer, no quick actions, no provider dropdown. +2. ✅ Quick actions are on the landing page, not here. +3. Click a session. ✅ It opens the chat in one action — you should not have + to click a second "Open Chat" button. +4. Hover a session row. ✅ A `⋯` menu fades in. Click it. ✅ A menu opens and + the session does **not** switch underneath you. +5. Stop the backend. ✅ The sidebar shows **Start server** / **Reconnect** in + place rather than going blank. + +### One surface + +See [The interface](interface.md) for what each surface is for. + +1. Open the GitPilot sidebar. ✅ There is **no chat** in it — no composer, no + message list, no quick actions. Status, **New Task**, **Recent Tasks**, + **Settings**, and nothing else. +2. ✅ There is no **GitPilot Workspace** view under it any more. +3. Reload the window with no files open. ✅ One editor tab, titled **GitPilot**, + showing the landing page. Reload again *with* a file open. ✅ It does not + appear — it never opens on top of work in progress + (`gitpilot.showHomeOnStartup` turns it off entirely). +4. Click **New Task**. ✅ The same one tab opens or comes forward. There is + never a second GitPilot tab. +5. Type a task and press Enter. ✅ The landing page gives way to the + conversation **in the same tab**. ✅ The composer is still exactly where it + was — it never moves or duplicates. +6. Click a recent task in the sidebar. ✅ The tab shows that conversation, in + one click. + +### New Task is a new task + +1. Run a task that changes a file, so the panel has a transcript, a collapsed + `⌄ ✓ Investigated N steps` block, and a **Proposed Changes** section. +2. Pin a file with `+`, and type half a sentence without sending it. +3. Click **New Task** in the sidebar. ✅ The panel shows the **landing page** — + `What are we building?` — with: + - no transcript, + - **no leftover activity block**, + - no plan, scope or changed-files section, + - an empty composer and no context chips. +4. ✅ The previous conversation is in **Recent Tasks**. Click it. ✅ Its history + comes back, and *not* underneath the new task's leftovers. +5. Start a long task, and click **New Task** while it is still streaming. + ✅ The old run stops rather than continuing to write into the new + conversation. +6. Queue a message during a run (type + Enter), then click + **New Task**. ✅ The queued message is dropped — it was meant for the + conversation you just replaced. + +### Status is stated once + +1. Stop the backend. ✅ The sidebar reads **● Offline** with + *GitPilot server isn't running* underneath, and **Start server** / + **Reconnect** directly below that. ✅ There is no second card repeating it, + and the words *Ready* and *Disconnected* never appear together. +2. Start the backend but clear the provider. ✅ The sidebar reads + **● Needs setup** / *No provider configured* — not "Ready". +3. While reconnecting. ✅ **● Connecting…** with a pulsing dot; no recovery + buttons, because nothing has failed yet. +4. Configure a provider. ✅ **● Ready**, model name beside it, no reason line. +5. ✅ The landing page's footer says the same thing once: + `● Ready · llama3:8b · gitpilot repository · Changes require approval`. + +### GitPilot Chat — composer and transcript + +1. Select a few lines in a source file. ✅ A chip appears **above** the chat + composer reading `◉ :42-58`. Deselect. ✅ It disappears rather than + stacking a second one. +2. Type `@` then part of a filename. ✅ A dropdown opens **above the composer** + with matches — no modal. / move, Enter + accepts. ✅ The file becomes a chip and the `@query` text is consumed. +3. Type `someone@example.com`. ✅ No dropdown — an `@` inside a word is not a + trigger. Same for `/` mid-sentence. +4. Type `/` at the start of a message. ✅ The command list opens. +5. Send with chips attached. ✅ The transcript shows **only what you typed**, and + the pinned file chips are gone (consumed). The selection chip stays, because + the selection is still there. +6. While GitPilot is working, type and press Enter. ✅ The status line + reads `1 queued` and the run is **not** cancelled. When the run ends, + ✅ the queued message is sent. +7. Press Esc mid-run. ✅ The run stops and the queue is dropped. +8. Watch a task that uses tools. ✅ They collapse into one line in the + transcript — `⌄ ✓ Investigated 3 steps`. Click it. ✅ It expands to + `Read `, `Searched ` and so on. Keep it open while the task + continues. ✅ It stays open. +9. Hover a message. ✅ The timestamp fades in; it is invisible otherwise. +10. After a change is proposed, look at **Proposed Changes**. ✅ The header reads + `✓ Updated user.ts` with `+8 −2` on the right, and the file rows are still + inside. + +### Undo — checkpoints and rewind + +1. In **Agent** mode, ask GitPilot to change a file. ✅ When it finishes, click + **Rewind…** in the chat panel. A picker lists checkpoints newest first, each + labelled `Before write_file · `. +2. Pick the one before the change and confirm. ✅ The file is back, ✅ the + conversation is truncated to that point, and ✅ any file GitPilot created is + gone — not merely overwritten. +3. Check your own repository. ✅ `git status` is unchanged by the rewind itself, + and `node_modules` / `.venv` are untouched. +4. Click **Revert** after a change has been applied. ✅ It undoes the most recent + change. (Before this release that button did nothing at all.) +5. Approve a plan with **Approve & Execute**. ✅ It actually runs. (It used to + set the status to *generating* and stop.) +6. `GitPilot: Rewind to a Checkpoint` from the palette. ✅ Same picker. + +### AI Providers — the original bug + +1. **Ctrl/Cmd+Shift+P** → *GitPilot: Settings* → **AI Providers**. +2. The page shows a server badge, one **Active Provider**, and a list of the + rest. ✅ No "Not connected to GitPilot server" dialog, even if the server + was started a moment ago. +3. Click **Claude (Anthropic)**. ✅ Only Claude's form appears. No OllaBridge + tabs, no Ollama fields. +4. ✅ The API key box is **empty**, with the placeholder *"Leave empty to keep + the current key"*. If a key is stored, the hint below reads + `API key configured: ••••XXXX`. +5. Type a model change but leave the key box empty → **Save and activate**. + ✅ Succeeds, and the stored key still works. +6. **‹ Back to AI Providers**. ✅ Returns to the list, still inside the + Settings tab. + +### The offline recovery path + +1. Stop the backend (Ctrl+C in the `gitpilot serve` terminal). +2. Reopen **AI Providers**. ✅ The page opens. It shows *"GitPilot server is + not connected"* with **Start local server**, **Reconnect**, **Change server + URL** and **Copy diagnostics** — not a dead-end dialog. +3. Click **Start local server**. ✅ A progress notification appears, the + **GitPilot** output channel shows the command, and the page reconnects. +4. Set `gitpilot.serverUrl` to a remote address and stop the server again. + ✅ **Start local server** is *hidden* — starting a process here would not + make someone else's server reachable. + +### OllaBridge, Open WebUI, custom endpoint + +1. Open **OllaBridge Cloud**. ✅ Three tabs: *Cloud Login*, *API Key*, + *Local Gateway*. Exactly one panel visible. +2. *Cloud Login* → **Sign in with browser**. ✅ Your browser opens the sign-in + page; VS Code never asks for a password. +3. *Local Gateway*. ✅ The URL defaults to `http://127.0.0.1:11435` — + **not** `:8000`, which is GitPilot's own backend. +4. Open **Open WebUI**. ✅ Asks for the instance root; the hint says GitPilot + appends the API path itself. +5. Open **Custom endpoint**. ✅ URL, key, model, and an editable + **Request headers** list with *Add header* / *Remove*. + +### Agent topologies + +1. **Agent** section. ✅ Topologies render as cards showing their full agent + sequence — Feature Builder reads + *Explorer → Planner → Coder → Reviewer → PR Manager*. +2. ✅ There is **no free-text topology box**. +3. Click one. ✅ It becomes Active, and the choice persists across a reload — + it is saved on the backend, not just in VS Code. +4. Confirm from the server: + ```bash + curl http://127.0.0.1:8000/api/settings/topology + ``` +5. Pick **Automatic (recommended)**. ✅ The same call now returns + `{"topology": null}`. + +### MCP servers + +1. **MCP Servers** section. ✅ A gateway card, attached servers, and a catalogue. +2. With no gateway running: ✅ **Install MCP Context Forge** is visible. With + one running: ✅ it is hidden. +3. Click **Attach** on a bundled server. ✅ It appears under *Attached servers* + marked **Disabled** — attaching is not enabling. +4. Open it. ✅ Endpoint, the *name* of its token env var (never a value), a + master switch, and every tool with a risk badge and a "Used by" line. +5. Toggle a high-risk tool (e.g. one containing `drop`) **on**. ✅ A modal asks + first. Decline → ✅ it stays off. +6. Toggle it **off** again. ✅ No prompt — removing a capability is not risky. +7. Search the registry for something. ✅ Results appear under the bundled + entries, or a *"Registry unavailable"* line if it cannot be reached — + either way the page still works. +8. **Add manually**. ✅ The third prompt asks for an environment variable + **name** and says not to paste the token. + +### Checking the agents actually gained the tools + +The point of attaching a server is what the agents can do. After enabling one: + +```bash +curl http://127.0.0.1:8000/api/mcp/status +``` + +✅ `tools_advertised` rises. Disable the server, call again, ✅ it falls back. + +--- + +## Debugging + +**Output channel** — View → Output → **GitPilot** in the dropdown. Server +startup, MCP installer commands and their output all land here. + +**Webview devtools** — with the settings tab focused: **Ctrl/Cmd+Shift+P** → +*Developer: Open Webview Developer Tools*. The Console shows webview errors, +and Network is empty by design: the webview makes no requests of its own. + +**Extension host log** — **Ctrl/Cmd+Shift+P** → *Developer: Show Logs...* → +*Extension Host*. + +### Faster loop: the Extension Development Host + +Packaging on every change is slow. For iterating: + +1. Open `extensions/vscode` as the VS Code workspace folder. +2. Press **F5**. A second window opens with the extension loaded from source. +3. After an edit, run `npm run compile` (or `npm run watch` in a terminal), + then **Ctrl/Cmd+R** in that window. + +!!! note + The settings panel reads its template from `out/`, so a template edit + needs `npm run compile` — the copy step is what moves it. `npm run watch` + only watches TypeScript, so re-run `npm run compile` after touching the + HTML. + +--- + +## Common problems + +**Changes do not appear** +Reload the window. If it still looks stale, `make extension-uninstall`, reload, +then `make extension-dev` again. + +**"Could not load settings template"** +`out/ui/webview/gitpilotSettingsTemplate.html` is missing — run +`npm run compile`, which copies it. + +**`make extension-dev` says `code` is not on your PATH** +VS Code → **Ctrl/Cmd+Shift+P** → *Shell Command: Install 'code' command in +PATH*, or install the `.vsix` from the Extensions view. + +**`vsce` fails to package** +Run `npm install` first. `make extension-package` does this via +`extension-compile`. + +**Tests fail with "Not compiled yet"** +Run `npm run compile`. `npm test` does it for you; `npm run test:only` does not. + +--- + +## See also + +- [AI provider setup](ai-providers.md) +- [Agent topologies](agent-topologies.md) +- [MCP servers](mcp-servers.md) diff --git a/docs/vscode/mcp-servers.md b/docs/vscode/mcp-servers.md new file mode 100644 index 0000000..8f76968 --- /dev/null +++ b/docs/vscode/mcp-servers.md @@ -0,0 +1,255 @@ +# MCP servers in VS Code + +GitPilot's agents ship with a fixed set of abilities: read the repo, plan, +write code, run tests, drive GitHub. **MCP servers extend that set.** Attach a +PostgreSQL server and the Explorer can read your live schema, the Coder can +write queries against real tables, and the Reviewer can check a migration +before it lands — for exactly as long as you leave it enabled. + +This page covers doing that from VS Code. For the agents themselves, see +[Agent architecture and topologies](../agents.md). + +Open it with **`GitPilot: Settings`** → **MCP Servers**. + +--- + +## How this changes what the agents do + +An enabled MCP server's tools are injected into the agents' tool list at the +start of a run. The Planner sees the tool descriptions and can plan around +them; the Coder can call them. Nothing is injected from a server that is +attached but disabled. + +That is the whole mechanism, and it has one important consequence: **enabling +a server widens what the agents can do without asking again.** So the flow is +deliberately two steps — attaching is not enabling. + +Tools are also mapped to the agents that use them, which is why each tool on +a server's page carries a "Used by" line: + +| Tool | Used by | +|---|---| +| `postgres.list_tables` | Explorer | +| `postgres.describe_table` | Coder, Reviewer, test runner | +| `postgres.safe_select` | Coder | +| `postgres.explain_query` | Reviewer | +| `postgres.validate_migration` | Reviewer | + +--- + +## The overview page + +``` +MCP Servers + + ● MCP Context Forge Connected + http://localhost:4444 + 2 of 3 server(s) enabled · 11 tool(s) available to the agents + [Configure] [Sync] + + ATTACHED SERVERS + mcp-postgre-server Enabled › + 9 of 11 tool(s) available to the agents + mcp-milvus-server Disabled › + + ADD A SERVER + [ search a registry… ] [Search] [Add manually] + Bundled with GitPilot + mcp-inspector-server [Attach] +``` + +Clicking a server opens its own page; **‹ Back to MCP Servers** returns. + +--- + +## Installing MCP Context Forge + +MCP servers are reached through a gateway — **MCP Context Forge** — and until +now getting one meant a terminal, a compose file and an env file. When no +gateway is reachable, the overview shows a single button: + +**[Install MCP Context Forge]** + +It checks Docker, starts Forge, waits for it to answer, and points GitPilot at +it. Progress appears as a notification and in the **GitPilot** output channel. +Two paths, chosen by what is on disk: + +| Situation | What happens | +|---|---| +| Your workspace is a GitPilot checkout (`docker-compose.mcp.yml` present) | Uses the project's MCP stack. First run builds images from pinned upstreams and can take several minutes. A `.mcp.env` is seeded with a freshly generated signing secret if one does not exist. | +| No checkout — a plain `pip install gitcopilot` | Runs the published Forge image directly. | + +The button disappears once a gateway is reachable — offering to install +something already running is noise. + +!!! note "Prerequisites" + Docker must be installed and its daemon running. The installer says which + of those is missing rather than failing later; there is nothing to + uninstall if you decline. + +| Setting | Default | Purpose | +|---|---|---| +| `gitpilot.mcp.forgePort` | `4444` | Port Forge listens on | +| `gitpilot.mcp.forgeImage` | `ghcr.io/ibm/mcp-context-forge:latest` | Image used when there is no compose file. Point this at a registry your network can reach if the default is unavailable. | + +**Configure** opens the gateway wizard (URL, sign-in method, credential) — +use it to point GitPilot at a Forge someone else runs. **Sync** re-reads the +gateway's registry and reconciles it with GitPilot's local list. + +--- + +## Attaching a server + +### From the bundled catalogue + +GitPilot ships with four, listed under **Bundled with GitPilot**: + +| Server | What it gives the agents | +|---|---| +| **mcp-postgre-server** | Schema discovery, safe SELECTs, EXPLAIN plans, migration validation, test-fixture generation | +| **mcp-milvus-server** | Collection discovery, vector and hybrid search, RAG pipeline context, deterministic test vectors | +| **mcp-inspector-server** | Validating, invoking and debugging other MCP servers | +| **gitpilot-mcp-server** | GitPilot's own surface, so external agents can drive it | + +Press **Attach**. The server arrives **disabled** — open it and turn it on. + +### From a registry + +Type what you need into the search box and press **Search**. GitPilot queries +a remote MCP registry — [MatrixHub](https://matrixhub.io) by default — and +lists what it finds under the bundled entries. + +``` +[ postgres ] [Search] [Add manually] + +12 result(s) from https://api.matrixhub.io +``` + +A registry that is unreachable says so and leaves the bundled catalogue +visible; browsing never changes what the agents can do. + +| Variable | Purpose | +|---|---| +| `GITPILOT_MATRIXHUB_URL` | Registry to search. Defaults to `https://api.matrixhub.io`. Point it at your own registry to publish an internal catalogue. | +| `GITPILOT_MATRIXHUB_TOKEN` | Bearer token, if your registry requires one | + +Any registry serving a JSON catalogue works — the results are normalised, so +`items`/`results`/`servers`/`data` envelopes and `id`/`name`/`slug` naming are +all accepted. Entries that are not MCP servers, or that carry no endpoint, +are dropped rather than offered. + +### By hand + +**Add manually** asks for three things: + +1. **Server id** — how it will be listed +2. **MCP endpoint URL** — e.g. `http://localhost:8080/mcp` +3. **Environment variable holding its token** — the variable's *name* + +!!! warning "Never paste a token here" + GitPilot asks for the **name** of an environment variable, and reads its + value on the GitPilot host. The token itself never enters VS Code, is + never stored in `settings.json`, and is never synced by Settings Sync. + +--- + +## A server's page + +``` +‹ Back to MCP Servers + + mcp-postgre-server Enabled + + Endpoint + http://mcp-postgre-server:8080/mcp + Token read from MCP_POSTGRE_SERVER_TOKEN on the GitPilot host. + + Available to the agents [●—] + + TOOLS + postgres.list_tables low Used by explorer [●—] + postgres.safe_select low Used by coder [●—] + postgres.drop_table high Not mapped [—○] + + [Test connection] [Detach server] +``` + +**Available to the agents** is the master switch. **Test connection** probes +the server for real and reports what came back. **Detach** removes it after +confirming. + +### Tool risk + +Every tool is classified from its name, and the classification decides its +default: + +| Risk | Examples | Default | +|---|---|---| +| **high** — destroys data | `drop`, `delete`, `truncate`, `remove`, `destroy` | **Off** | +| **medium** — mutates | `insert`, `update`, `upsert`, `create`, `alter`, `execute_write` | On | +| **low** — reads | everything else | On | + +Enabling a high-risk tool asks for confirmation first. Disabling one never +does — removing a capability is not a risk. + +Some bundled servers go further and deny destructive tools outright in their +manifest, so they are never offered. + +--- + +## A worked example: giving the agents your database + +1. **MCP Servers** → **Install MCP Context Forge** (if no gateway is running) +2. **Attach** `mcp-postgre-server` from the bundled catalogue +3. Set `MCP_POSTGRE_SERVER_TOKEN` and the server's connection string on the + GitPilot host +4. Open the server → **Test connection** +5. Turn on **Available to the agents** +6. Leave `postgres.drop_table` off + +Now ask GitPilot something that needs the schema: + +> *"Add a `last_login` column to users and write the migration."* + +The Explorer reads the live table definitions instead of guessing from the +ORM, the Planner writes a migration against what is actually there, and the +Reviewer runs `postgres.validate_migration` before you approve it. + +When the task is done, disable the server. The agents lose those tools again, +and nothing about your database is in the prompt for unrelated work. + +--- + +## Troubleshooting + +**The catalogue is empty** +Fixed. The bundled manifests now ship inside the wheel; before that a +`pip install` had nothing to list because the catalogue lived in a directory +only present in a repo checkout. Upgrade if you still see it. + +**"Install MCP Context Forge" fails immediately** +The message names the cause — Docker missing, daemon stopped, or the image +unreachable. For the last, set `gitpilot.mcp.forgeImage`. + +**A server is attached but the agents do not use its tools** +Check three things, in order: the server is **enabled** (not just attached), +the individual tool is enabled, and the gateway badge says Connected. + +**Registry search returns nothing** +The status line distinguishes "0 results" from "registry unavailable". For +the latter, check `GITPILOT_MATRIXHUB_URL`, and `GITPILOT_MATRIXHUB_TOKEN` if +it needs authentication. + +**MCP Servers says it needs the GitPilot server** +Attached servers are stored by the backend. Connect from +[AI Providers](ai-providers.md#when-the-gitpilot-server-is-not-running), then +come back. + +--- + +## See also + +- [Agent architecture and topologies](../agents.md) +- [AI provider setup](ai-providers.md) +- [MCP gateway authentication](../MCP_AUTH.md) +- [Sandbox and approvals](../SANDBOX.md) diff --git a/docs/vscode/troubleshooting.md b/docs/vscode/troubleshooting.md new file mode 100644 index 0000000..314e3de --- /dev/null +++ b/docs/vscode/troubleshooting.md @@ -0,0 +1,441 @@ +# When GitPilot cannot answer + +## "Error processing message: …" / "Fallback to LiteLLM is not available" + +**Symptom.** The server starts, the banner says `LLM Provider ✅ OLLAMA`, the +extension shows **Ready** — and the first message comes back as an error. + +**Cause.** Every chat path used to go through CrewAI, which pulls in LiteLLM +and ~180 other packages. That is the right machinery for a multi-agent run +against a GitHub repository, and the wrong machinery for answering a question +about a local folder. If the agent runtime was not installed, a perfectly +configured and reachable Ollama could not answer anything. + +**Fixed in 0.2.8.** Five of the seven providers speak the OpenAI +`/v1/chat/completions` shape, so GitPilot now talks to them directly over HTTP +— no CrewAI, no LiteLLM, nothing to install: + +| Provider | Chat path | Needs the agent runtime | +| --- | --- | --- | +| Ollama | direct HTTP | no | +| OllaBridge | direct HTTP | no | +| Open WebUI | direct HTTP | no | +| OpenAI (and compatible proxies) | direct HTTP | no | +| Custom endpoint | direct HTTP | no | +| Claude | CrewAI | yes | +| watsonx | CrewAI | yes | + +If you use Claude or watsonx and see a message about the agent runtime: + +```bash +pip install 'gitcopilot[agents]' +``` + +Or switch to any provider in the top half of the table, which need nothing. + +### Planning is the path that still goes through CrewAI + +The table above is about *chat*. Planning is different: `/api/chat/plan` +hands a model object to CrewAI Agents, so it cannot bypass CrewAI the way +chat does. That is why the same error kept appearing on Ollama after chat +was fixed, but only when a task produced a plan: + +``` +POST /api/chat/plan HTTP/1.1" 500 Internal Server Error +ImportError: Fallback to LiteLLM is not available +``` + +**Cause.** CrewAI routes a call natively only when the provider is in its +`SUPPORTED_NATIVE_PROVIDERS` list; everything else goes to its optional +LiteLLM fallback, which fails outright when LiteLLM is not installed. On +CrewAI 1.6 — and every release before about 1.10 — that list is: + +``` +openai, anthropic, claude, azure, azure_openai, google, gemini, bedrock, aws +``` + +Ollama is not in it. Neither spelling of the obvious fix works there: +`provider="ollama"` names a provider CrewAI will not route, and +`model="ollama/llama3:8b"` (or `model="openai/qwen2.5:1.5b"`, which is how +OllaBridge, Open WebUI and custom endpoints were spelled) is validated +against CrewAI's own model constants, which no locally served model +satisfies. Both land in the LiteLLM fallback. + +**Fixed in 0.2.8.** All four of these endpoints speak the OpenAI +chat-completions API, and `openai` has been natively routable for as long as +that list has existed — so GitPilot now asks for it by name, passing the +endpoint's own base URL and model id untouched: + +```python +LLM(model="llama3:8b", provider="openai", base_url="http://localhost:11434/v1", api_key="ollama") +``` + +This needs no LiteLLM and works on both old and new CrewAI. If you are on a +GitPilot older than 0.2.8 and cannot upgrade yet, either of these unblocks +planning: + +```bash +pip install -U gitcopilot # preferred — no LiteLLM needed at all +pip install litellm # installs the fallback CrewAI asked for +``` + +### The banner tells the truth now + +`gitpilot serve` used to validate provider *configuration* only, so it printed +`✅ OLLAMA` and then failed on an import it had never checked. It now also +checks that the runtime that will actually answer is present: + +``` +❌ CLAUDE cannot answer yet + claude needs the agent runtime, which is not installed. + Install it with: pip install 'gitcopilot[agents]' +``` + +## The web app answers and VS Code times out + +**Symptom.** The same backend, the same moment: the web app at `:5173` +replies normally, and VS Code shows + +``` +Chat Error: Error: Request to /api/chat/send timed out after 20000ms +``` + +The server log is the giveaway — it *succeeded*: + +``` +WARNING:gitpilot._api_app:[HTTP] 🐢 POST /api/chat/send took 21.26s (status=200) +``` + +**Cause.** Nothing was broken. Inference had been given the deadline meant +for an ordinary HTTP request. A local Ollama answering with repository +context routinely takes 20–60s; the extension gave up at 20s, while the web +app had always allowed five minutes for the same call. + +The retry budget made it worse. A timeout carries no HTTP status, so it slipped +past the "don't retry these" list and the default two retries fired — three +runs of a call the server was still executing, queued behind each other on a +single-threaded Ollama. That is why the log shows the same request at 21s, +23s and 35s: each attempt was slower than the one it was sent to rescue. + +**Fixed in 0.2.8.** Chat, plan and execute now get a five-minute deadline, +matching the web app, and are never retried — a timeout there means the +answer is lost, not duplicated. If a large model on CPU still gets cut off, +raise it: + +```jsonc +// settings.json +"gitpilot.llmTimeoutSeconds": 900 +``` + +Lite Mode (`gitpilot.liteMode`) or a smaller model will answer faster instead. + +## Where a VS Code chat message actually goes + +Worth knowing before debugging anything below, because two different +pipelines answer depending on whether your session has a GitHub repository. + +``` +Chat input + └─ sendChatToBackend() status → "planning" (spinner starts) + ├─ POST /api/v2/chat/stream (SSE, always tried first) + │ ├─ session HAS a repo → CrewAI planner + executor + │ │ emits text_delta … done + │ │ ⇒ answer streams in, CrewAI trace in server log + │ └─ session has NO repo → closes immediately, no text + │ ⇒ handover, not an error + └─ empty stream ⇒ POST /api/chat/send status → "generating" + ├─ repo session → agent pipeline (CrewAI) + └─ folder session → one direct call to the provider, no agents +``` + +The server log now names the branch it took: + +``` +[chat] session=… repo=owner/name → agent pipeline (CrewAI; verbose trace follows) +[chat] session=… folder-only → direct ollama call, model=deepseek-r1:latest + (single completion, no agents — CrewAI is not involved …) +[chat] session=… direct call returned 1841 chars in 22.4s +``` + +### The server prints uvicorn's INFO lines but none of GitPilot's + +`uvicorn.run(log_level="info")` reads as "log at info" and configures +uvicorn's three loggers. GitPilot's records propagate to a root logger with +no handler, so Python falls back to `logging.lastResort`, whose level is +WARNING. That produced a log like this — uvicorn INFO, GitPilot WARNING, +and nothing in between: + +``` +INFO: 127.0.0.1:59698 - "GET /api/health HTTP/1.1" 200 OK +WARNING:gitpilot._api_app:[HTTP] 🐢 POST /api/chat/send took 33.52s +``` + +Every route decision, provider call and timing was being written and +discarded. Fixed in 0.2.8 — GitPilot logs at INFO by default. To change it: + +```bash +gitpilot serve --log-level DEBUG # or -l DEBUG +GITPILOT_LOG_LEVEL=DEBUG gitpilot serve +``` + +DEBUG adds the agent internals. `--reload` re-imports the app in a child +process, so the level travels as `GITPILOT_LOG_LEVEL` rather than in memory. + +### Agent (CrewAI) verbosity + +The crews narrate themselves to the server console — agent banners, task +status, final answer. The Lite paths used to run silently, which meant the +exact configuration a small local model needs was also the one with no +visible trace. They are verbose now. To quiet them: + +```bash +GITPILOT_AGENT_VERBOSE=0 gitpilot serve +``` + +### "I see CrewAI logs in the web app but not in VS Code" + +Nothing is being hidden. The web app plans against a **selected repository**, +so it runs the CrewAI pipeline and CrewAI prints its trace to the server +console. A VS Code session with only a folder open runs the direct path, +which has no agents to trace — one provider call, no crew, no boxes. + +To get the agent trace in VS Code, select a GitHub repository for the +session. The `[chat]` lines above tell you which pipeline ran, so you never +have to guess. + +### Turning on the extension's own log + +`View → Output → GitPilot` shows the client half of the picture: + +``` +[GitPilot] stream → POST /api/v2/chat/stream session=… intent=… chars=2411 +[GitPilot] stream → empty after 0.3s (done=1); backend handed off → batch +[GitPilot] batch → POST /api/chat/send +[GitPilot] batch ✓ 1841 chars in 22.4s (plan=no edits=0) intent=explain session=… +``` + +A failing stream now says *why* — unreachable, an HTTP status, or empty — +instead of the single "streaming unavailable" line that covered all three. + +## `gitpilot serve` and `make run` are not the same GitPilot + +**Symptom.** A bug you already upgraded past comes back when you start the +server a different way — most often the LiteLLM error above. + +**Cause.** Two installs, two versions. Check the banner each one prints: + +``` +│ GitPilot v0.2.8 │ ← make run: the repo checkout, via uv +│ GitPilot v0.2.7 │ ← gitpilot serve: whatever pip installed, on PATH +``` + +`make run` runs the working tree. `gitpilot serve` runs the `gitpilot` on +your PATH — typically an older release under +`~/.local/lib/python3.11/site-packages/gitpilot/`, which has none of your +local changes and its own dependency set. A fix in the checkout does nothing +for it until it is installed. + +Note that `make install` does **not** fix this on its own, and is not meant +to: it prepares `.venv` for `make run`, and installing a command onto your +PATH is something to ask for rather than have done to you. It does now tell +you when the two disagree, at the end of its output. + +**Fix.** Point the command at the checkout: + +```bash +make install-cli # editable, so it tracks the tree from here on +``` + +**Check at any time:** + +```bash +make check-cli +``` + +which prints one of: + +``` +✓ gitpilot v0.2.8 → this checkout +⚠ 'gitpilot' on PATH is NOT this checkout. + on PATH: v0.2.7 ~/.local/lib/python3.11/site-packages/gitpilot + this repo: /mnt/c/workspace/gitpilot/gitpilot +``` + +If it still shows the old version afterwards, the shell has cached the old +path — open a new terminal, and make sure uv's tool directory is on PATH +(`uv tool update-shell`). + +A traceback tells you the same thing: a path under `site-packages/gitpilot/` +is the installed copy, not your checkout. + +## Every request takes about ten seconds + +**Symptom.** The log is full of lines like: + +``` +[HTTP] 🐢 GET /api/health took 10.02s (status=200) +[HTTP] 🐢 GET /api/status took 10.02s (status=200) +[HTTP] 🐢 GET /api/settings took 10.03s (status=200) +``` + +Always ~10.02s. A constant like that is a timeout, not slowness. + +**Cause.** GitPilot probes Ollama *and* OllaBridge to auto-pick a local model, +five seconds of socket timeout each. It ran inline inside `async def` request +handlers, so it blocked the event loop — every concurrent request finished +together, ten seconds later, and it repeated every 20 seconds. + +It only bites when a port **hangs** rather than refusing. On Linux and macOS an +unbound local port refuses instantly, so the probe costs milliseconds; under +**WSL2** it frequently hangs to the full timeout. That is why this shows up +almost exclusively on Windows. + +**Fixed in 0.2.8.** A stale probe now refreshes in a background thread and the +request gets the last known settings immediately. Only one refresh runs at a +time, however many requests arrive. `force=True` still probes inline for the +explicit bootstrap paths that need a fresh answer. + +## "Offline — Could not reach http://127.0.0.1:8000" while the server is running + +**Symptom.** `gitpilot serve` is up and its log shows `GET /api/health … 200 OK`, +but the sidebar says **Offline**. Clicking **Reconnect** changes nothing. + +**Cause.** Two things compounding. + +The server was answering — in ten seconds, for the reason above. The +extension's entire connect procedure was one probe with a **3-second** +deadline, so it gave up first and reported a working backend as down. Reconnect +repeated the identical 3-second probe against the identical slow server, which +is why the button appeared to do nothing. + +And the message was wrong in a way that mattered: the server *was* reached. It +said "Could not reach", which points at the one thing that was already working. + +**Fixed in 0.2.8.** + +- **The deadline grows.** 3s, then 10s, then 25s. A server still importing its + way to readiness is waited for; one that is genuinely absent still fails in + about three seconds, so **Offline** appears promptly when it is true. +- **Refused and slow are told apart.** Nothing listening reads + `Could not reach `; something listening but slow reads + ` is not answering yet — still trying`. The **Start server** button + hides in the second case, because starting a server that is already running + only fails on a port in use. +- **It reconnects on its own.** While offline, the extension probes in the + background with backoff (2s, doubling, capped at a minute) and connects the + moment the server answers. Starting `gitpilot serve` in a terminal is enough; + there is no trip back to the sidebar. The panel says *Retrying + automatically…* so it does not look dead. +- **Start server actually starts it.** That button used to open the Settings + page, which has no way to start anything either. +- **A live server that is briefly busy stays connected.** The periodic health + check gets a real deadline and one retry, instead of blinking the whole UI to + Offline on one slow moment. + +## The answer arrives but nothing streamed + +Expected, for now. Streaming runs through the multi-agent executor, which needs +a GitHub repository (`owner/repo`). A **folder** or **local git** session has +no such name, so `/api/v2/chat/stream` closes immediately and the extension +falls back to `/api/chat/send`, which is the correct path for those sessions. +You get the whole answer at once instead of token by token. + +## First stop for anything else: GitPilot: Diagnostics + +**Ctrl/Cmd+Shift+P → *GitPilot: Diagnostics*** opens a report in a new document, +ready to read or paste into an issue. It answers, in one place, the questions +that previously needed a terminal and several guesses: + +``` +Versions + extension 0.2.8 + backend 0.2.7 + ⚠ MISMATCH — the two halves are from different builds. + `make extension-dev` rebuilds only the extension. + Reinstall the backend: pip install -e . --no-deps + +Connection + server url http://127.0.0.1:8000 + state connected + last probe reachable after 41ms + retrying no + +Chat + path direct + can answer yes + +Provider + name ollama + model llama3:8b + endpoint http://localhost:11434/v1 + configured yes + +Backend environment + python 3.11.9 + interpreter /home/x/.venv/bin/python + package path /home/x/.venv/lib/python3.11/site-packages/gitpilot + runtimes crewai=no litellm=no httpx=yes + +Recent requests (14) + ✗ 3001ms POST /api/chat/send — timed out after 3000ms + 41ms GET /api/health (200) +``` + +**`package path` is the one to read first when a change you made had no +effect.** `gitpilot` is a console script, so it imports from site-packages +rather than the directory you are standing in — if that path points anywhere +other than your checkout, you are running a different copy of the code and +`git pull` will not change it. + +### The version mismatch is now caught for you + +Two places, so it is hard to miss: + +- **On connect.** The extension reads the backend version from `/api/health` + and warns once per session if it differs from its own. +- **At build time.** `make extension-dev` prints all three versions and flags a + mismatch, because that target builds the extension and never touches Python: + +``` +🧩 Versions + extension (built) 0.2.8 + backend (repo) 0.2.8 + backend (installed) 0.2.7 + + ⚠️ The installed backend is 0.2.7 but this checkout is 0.2.8. + Nothing here touches Python. Reinstall the backend: + pip install -e . --no-deps +``` + +Run it on its own any time with `make version-check`. + +### The Output channel is now a trace + +**View → Output → GitPilot** carries one line per request: + +``` +[conn] connecting +[conn] connected +[version] extension 0.2.8, backend 0.2.8 +[api] GET /api/health 41ms (200) +[api] 🐢 GET /api/settings 10031ms (200) +[api] ✗ POST /api/chat/send 3001ms — timed out after 3000ms +``` + +`🐢` marks anything over 3s and `✗` marks a failure with its reason, so a slow +or failing backend is visible from inside VS Code rather than only in the +terminal running the server. + +## Checking what GitPilot thinks it can do + +```python +python -c "from gitpilot.direct_chat import describe_runtime; print(describe_runtime())" +``` + +``` +{'provider': 'ollama', 'path': 'direct', 'ready': True, + 'detail': '', 'endpoint': 'http://localhost:11434/v1'} +``` + +`path` is `direct` when the provider is answered over plain HTTP, and `crewai` +when it needs the agent runtime. `ready` is what the startup banner reports. diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index 5889588..6fbfb4f 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -1,5 +1,7 @@ .vscode/** .vscode-test/** +# Test suites are for developing the extension, not for shipping to users. +test/** src/** !src/**/*.d.ts !src/ui/webview/gitpilotWorkspaceTemplate.html diff --git a/extensions/vscode/CHANGELOG.md b/extensions/vscode/CHANGELOG.md index e719827..a23a50f 100644 --- a/extensions/vscode/CHANGELOG.md +++ b/extensions/vscode/CHANGELOG.md @@ -1,5 +1,102 @@ # Changelog +## [0.2.8] - 2026-08-07 + +### Added +- **Navigation sidebar.** A new GitPilot view at the top of the sidebar holds a + one-line status, **New Chat**, recent sessions and quick actions — and + nothing else. The conversation stays in the workspace panel below it, so the + three zones each have one job: sidebar to find the task, editor to work on + the code, chat to work with GitPilot. + - Sessions are rows, not cards, with the active one marked by an accent bar. + - Clicking a session opens it in one action rather than select-then-open. + - The overflow menu appears on hover, so a list of ten reads as a list. + - Recent sits above Quick Actions: once GitPilot is in daily use, resuming + work is more frequent than starting a canned action. + - When the server is down the sidebar offers Start server / Reconnect in + place, rather than going blank. + +### Changed +- **The conversation reads as text, not a stack of cards.** Boxing every turn + made a long conversation visually exhausting; cards are now reserved for + things you can act on — a suggested change, an approval, a tool-activity + group. The role is carried by a 2px rule instead of a border-and-fill. +- **The transcript uses the height it has.** It was capped at a fixed 380px, + which wasted a tall panel and cramped a short one. +- **Modes read Ask / Plan / Agent**, least to most permission, each saying what + it will and will not do. This is a relabelling only: the stored value stays + `auto`, and Ask remains the default. + + All nine of the panel's animations are intact. +- **AI Providers settings page** — configure every provider from inside VS Code. + An overview lists the server connection, the active provider and the rest; + clicking one opens its own configuration page. No browser step, no config files. +- **Open WebUI** and a generic **custom OpenAI-compatible endpoint** as first-class + providers. The custom endpoint carries arbitrary request headers, which gateways + need for attribution and routing, and discovers models from a published catalogue + when the endpoint serves one. +- **OllaBridge sign-in** by browser device pairing, alongside API-key and + self-hosted gateway modes. GitPilot never asks for an account password. +- **Agent topology picker** (Settings → Agent). Presets render as cards showing + their full agent sequence, and the choice is saved to the GitPilot backend — + which is what actually routes work. +- **Start local server** from the settings page when the backend is not running. + Runs `gitpilot serve --no-open` and follows the port it actually binds. + Configurable via `gitpilot.serverCommand`. +- `GitPilot: Settings` command. + +- **MCP Servers settings page** — attach Model Context Protocol servers to give + the agents extra tools. An overview lists the gateway, what is attached and + what is on offer; each server has its own page showing every tool, its risk, + and which agents call it. A newly attached server arrives disabled, and + enabling a destructive tool asks first. +- **One-click MCP Context Forge install.** When no gateway is reachable the page + offers a button that checks Docker, starts Forge, waits for it and points + GitPilot at it — using the project's compose stack in a checkout, or the + published image otherwise. Configurable via `gitpilot.mcp.forgePort` and + `gitpilot.mcp.forgeImage`. +- **MCP registry search.** Search a remote registry (MatrixHub by default, + `GITPILOT_MATRIXHUB_URL` to change it) for servers beyond the bundled four, + and attach what you find. + +### Fixed +- **"New Chat" did not produce a new chat.** The session commands were wired to + the legacy chat provider, which is no longer registered as a view, so a new + session was created on the backend while the visible conversation stayed + exactly as it was. Clicking a saved session did even less — it called a + method on a webview that does not exist. Both now clear the transcript and + the task state, then start or resume through the coordinator the panel + actually reads. The clear happens before the round-trip, so the panel goes + empty on click rather than after the network. +- **The bundled MCP catalogue was empty on every `pip install`.** It was read + from `extensions/mcp_plugins/`, which is not packaged; the manifests now ship + inside the wheel, with the repo directory kept as a developer fallback. +- **"Not connected to GitPilot server"** no longer appears when the server is + running. The connection was read from a flag refreshed on a 30-second timer; + it is now re-probed. +- **The provider dropdown had no effect.** The webview sent a provider value that + the settings handler never read. Provider selection is now an explicit + *Save and activate* that writes through the backend API. +- **Open Admin Panel** opened nothing — it ran `showServerInfo`. The integrated + settings page replaces it; the browser admin remains under *Advanced*. +- **Request timeouts.** `/api/status` could take ~16 seconds with no deadline. + Provider pages no longer call it, and every request now has one (health 3s, + settings 10s, models 15s, provider test 30s). +- **Model discovery** is lazy, scoped to the provider being configured, and cached + for 60 seconds. Replies carry request ids so a late response cannot overwrite a + newer selection. +- **`gitpilot.defaultTopology`** was written to VS Code settings only, where nothing + read it. Topology selection now persists to the backend. + +### Changed +- API keys are stored by the GitPilot backend and never returned to the settings + page — it receives only a boolean and a masked tail (`••••A7X2`). An empty key + field means "keep the current key"; clearing one is a separate confirmed action. +- Documentation corrected: GitPilot dispatches to **ten** specialized agents, not + four, and the default plan-and-execute flow runs three of them + (Explorer → Planner → Coder). A review or PR stage is added by selecting a + topology that includes one. + ## [0.2.0] - 2026-03-24 ### Added diff --git a/extensions/vscode/EXTENSION_DOCS.md b/extensions/vscode/EXTENSION_DOCS.md index 219b56a..99676aa 100644 --- a/extensions/vscode/EXTENSION_DOCS.md +++ b/extensions/vscode/EXTENSION_DOCS.md @@ -33,7 +33,7 @@ GitPilot adds an AI-powered sidebar to VS Code. You can: - Run security scans across your workspace - Generate tests, fix bugs, and review code -GitPilot works with 5 AI providers: OpenAI, Claude, Ollama, Watsonx, and OllaBridge. You can switch between them at any time. +GitPilot works with seven AI providers: OllaBridge, Ollama, Open WebUI, OpenAI, Claude, IBM watsonx, and any OpenAI-compatible custom endpoint. You can switch between them at any time, from inside VS Code. --- @@ -99,8 +99,8 @@ make compile After installing, you'll see a GitPilot icon in the left sidebar. 1. **Click it** to open the GitPilot panel -2. **Set up a provider** — click "Provider" in the panel header -3. **Choose your AI** — Ollama (free, local) or OllaBridge (free, cloud) are the easiest +2. **Set up a provider** — run `GitPilot: Settings` and open **AI Providers** +3. **Choose your AI** — OllaBridge (free, cloud) or Ollama (free, local) are the easiest 4. **Type a message** — try "Explain this project" 5. **Press Enter** or click Send @@ -239,20 +239,34 @@ Security findings appear in the VS Code **Problems** panel (`Ctrl+Shift+M`) with ### Switching providers -1. Click "Provider" in the sidebar header -2. Select a provider from the dropdown -3. Enter your API key (if needed) -4. The model is selected automatically +Run `GitPilot: Settings` and open **AI Providers**. The overview shows the +server connection, the active provider, and the rest; click one to open its +configuration page, then press **Save and activate**. + +Only one provider's form is shown at a time, and **‹ Back to AI Providers** +returns to the overview without leaving the settings tab. ### Provider comparison | Provider | Cost | Speed | Privacy | Best for | |---|---|---|---|---| -| **Ollama** | Free | Fast | 100% local | Privacy-focused, offline work | | **OllaBridge** | Free | Medium | Cloud | Quick setup, no installation | +| **Ollama** | Free | Fast | 100% local | Privacy-focused, offline work | +| **Open WebUI** | Free | Varies | Self-hosted | Teams already running an instance | | **OpenAI** | Paid | Fast | Cloud | Best quality (GPT-4o) | | **Claude** | Paid | Fast | Cloud | Long context, reasoning | -| **Watsonx** | Paid | Medium | Cloud | Enterprise, IBM ecosystem | +| **IBM watsonx** | Paid | Medium | Cloud | Enterprise, IBM ecosystem | +| **Custom endpoint** | Varies | Varies | Yours | Corporate gateways, self-hosted inference | + +### Where API keys live + +Keys are stored by the **GitPilot backend**, not in VS Code settings — so they +are never written to `settings.json` and never picked up by Settings Sync. + +The settings page never receives a stored key. It is told only that one +exists, plus its last four characters. An empty key field therefore means +"keep the current key"; clearing one is the separate, confirmed **Remove API +key** action. ### Ollama setup (recommended for free use) @@ -263,10 +277,77 @@ curl -fsSL https://ollama.com/install.sh | sh # 2. Pull a model ollama pull llama3 -# 3. In VS Code, select Ollama as your provider -# It connects to http://localhost:11434 automatically +# 3. GitPilot: Settings -> AI Providers -> Ollama (Local) +# Defaults to http://127.0.0.1:11434 ``` +### Custom endpoint + +For any OpenAI-compatible gateway. Set the endpoint URL, the API key, the +model id, and any **request headers** your gateway requires for attribution +or routing — a full `/chat/completions` URL is accepted and trimmed to its +root. GitPilot discovers models from a published catalogue at the endpoint's +origin when one is served, and falls back to the `/models` listing. + +### When the server is not running + +The settings page still opens, and offers **Start local server**, **Reconnect** +and **Change server URL** instead of a dead-end dialog. Starting a server runs +`gitpilot serve --no-open` and follows the port it actually binds. + +See the [full provider guide](https://github.com/ruslanmv/gitpilot/blob/master/docs/vscode/ai-providers.md). + +--- + +## Agents and Topologies + +GitPilot dispatches to **ten specialized agents**. A request router picks the +ones that fit each request: + +| Agent | What it does | +|---|---| +| Repository Explorer | Maps structure, finds relevant files, identifies patterns and test conventions | +| Repository Refactor Planner | Turns exploration into a step-by-step plan with a test strategy | +| Expert Code Writer | Executes the plan, running tests between steps | +| Code Review & Analysis Specialist | Audits for security, quality, coverage, performance | +| GitHub Issue Management Specialist | Creates, updates and triages issues | +| Pull Request Management Specialist | Branches, commits, pushes, opens PRs | +| Search & Discovery Specialist | Searches code, repos, issues, users | +| GitHub Learning & Guidance Specialist | Explains GitHub features and practices | +| Local File Editor | Reads and writes files in your workspace | +| Terminal & Shell Executor | Runs commands in the sandbox | + +### Which agents run + +The **default** plan-and-execute flow runs three of them: +Explorer → Planner → Coder. There is no review stage in it. Other requests +skip that chain entirely — a review request goes straight to the Code +Reviewer. + +To make a stage mandatory, pin a **topology**: +`GitPilot: Settings` → **Agent** → *Agent topology*, or +`GitPilot: Select Topology`. + +| Topology | Sequence | +|---|---| +| **Automatic** (recommended) | Routed per request | +| Feature Builder | Explorer → Planner → Coder → Reviewer → PR Manager | +| Bug Hunter | Explorer → Coder → Reviewer → PR Manager | +| Code Inspector | Explorer → Reviewer (read-only) | +| Architect Mode | Explorer → Planner (read-only) | +| Quick Fix | Coder → PR Manager | + +Plus routed system topologies: Default, GitPilot Code (ReAct + Subagents), +Lite Mode (small LLMs), and an experimental Tool-Augmented ReAct. + +The preference is saved on the GitPilot backend — which is what actually +routes work — so it applies to every client, not just this editor. Pipelines +containing a write-capable agent create a working branch before touching +anything. + +See the [topology guide](https://github.com/ruslanmv/gitpilot/blob/master/docs/vscode/agent-topologies.md) +and the [agent architecture reference](https://github.com/ruslanmv/gitpilot/blob/master/docs/agents.md). + --- ## Architecture @@ -291,7 +372,7 @@ The extension can also run a **local agent** (in-process) using the built-in ## Commands Reference -All 55 commands are available via `Ctrl+Shift+P`: +All 64 commands are available via `Ctrl+Shift+P`: **Chat**: openChat, sendMessage, newSession, loadSession, deleteSession, refreshSessions @@ -301,7 +382,7 @@ All 55 commands are available via `Ctrl+Shift+P`: **Git**: gitStatus, gitDiffAnalysis, smartCommit, createPR, branchManager, conflictResolver, stashManager, repoHealthCheck, commitSearch, impactAnalysis, naturalLanguageGit -**Setup**: setServer, reconnect, showServerInfo, selectProvider, selectModel, openLlmSettings, setLlmApiKey, setLlmBaseUrl, setPermissionMode, setupWizard, toggleLiteMode +**Setup**: openSettings, setServer, reconnect, showServerInfo, selectProvider, selectModel, openLlmSettings, setLlmApiKey, setLlmBaseUrl, setPermissionMode, setupWizard, toggleLiteMode **Advanced**: invokeSkill, installPlugin, uninstallPlugin, refreshSkills, showAgentFlow, selectTopology, runCommand diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 711d0a7..0b60df2 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -4,7 +4,7 @@ ### Not a chatbot. A team of AI agents that code together. -Most AI coding tools are a single model guessing at your codebase. GitPilot is different: **four specialized agents** collaborate on every task — one explores your repo, one plans safe changes, one writes the code and runs your tests, and one reviews the result. You approve every step. +Most AI coding tools are a single model guessing at your codebase. GitPilot is different: **ten specialized agents**, coordinated by a router that picks the right ones for each request — one explores your repo, one plans safe changes, one writes the code and runs your tests, one reviews the result, and others handle issues, pull requests, search and your local workspace. You approve every step. **Open source · Multi-agent · Any LLM · Free with Ollama · Enterprise-ready** @@ -14,7 +14,7 @@ Most AI coding tools are a single model guessing at your codebase. GitPilot is d | | Single-model tools | **GitPilot** | |---|---|---| -| Architecture | One model, one prompt | **4 specialized agents** working as a team | +| Architecture | One model, one prompt | **10 specialized agents** with a request router | | Context | Reads the open file | **Explores** your full repo, git history, tests | | Safety | Suggests code inline | **Plans first**, shows diffs, waits for approval | | Testing | You run tests manually | **Runs your test suite** and self-corrects on failure | @@ -35,7 +35,7 @@ Then in VS Code: 1. **Install** "GitPilot" from the Extensions Marketplace 2. **Click** the GitPilot icon in the sidebar -3. **Choose** your AI provider (Ollama is free and local — no API key needed) +3. **Choose** your AI provider — `GitPilot: Settings` → **AI Providers** (OllaBridge and Ollama are free, no API key) 4. **Ask** anything: *"Explain this project"*, *"Fix the bug in login.ts"*, *"Write tests for auth"* That's it. No account required. No data leaves your machine unless you choose a cloud provider. @@ -84,45 +84,91 @@ One-click buttons in the sidebar: ## Setup Your AI Provider -GitPilot works with any of these providers: +All provider setup happens inside VS Code — no browser, no config files. -### Free (no API key needed) +Run **`GitPilot: Settings`** from the command palette and open **AI +Providers**. You get an overview of the server connection, the active +provider, and the rest; clicking one opens its configuration page. -**Ollama** (local, private, fast): -``` -1. Install Ollama: https://ollama.com -2. Run: ollama pull llama3 -3. In GitPilot: click Provider > Ollama -``` +| Provider | What you need | Free? | +|---|---|---| +| **OllaBridge Cloud** | Nothing — sign in with your browser for more models | Yes | +| **Ollama** | Ollama installed locally (`ollama pull llama3`) | Yes | +| **Open WebUI** | Your instance URL | Yes (self-hosted) | +| **OpenAI** | API key from [platform.openai.com](https://platform.openai.com/api-keys) | Paid | +| **Claude** | API key from [console.anthropic.com](https://console.anthropic.com/settings/keys) | Paid | +| **IBM watsonx** | API key **and** project ID from [cloud.ibm.com](https://cloud.ibm.com/iam/apikeys) | Paid | +| **Custom endpoint** | Any OpenAI-compatible URL, key, and request headers | Depends | -**OllaBridge** (cloud, works out of the box): -``` -1. In GitPilot: click Provider > OllaBridge -2. It connects automatically (no setup needed) -``` +A provider becomes active only when you press **Save and activate**. -### Paid (API key required) +**API keys are stored by the GitPilot backend, never in VS Code settings.** +The settings page is told only that a key exists, plus its last four +characters (`••••A7X2`). An empty key field means "keep the current key", so +you can change a model without re-entering the secret. -**OpenAI**: -``` -1. Get an API key from https://platform.openai.com -2. In GitPilot: click Provider > OpenAI -3. Paste your API key -``` +> **Claude:** a Claude.ai subscription does not include API access. You need +> an Anthropic API key, billed separately. -**Claude (Anthropic)**: -``` -1. Get an API key from https://console.anthropic.com -2. In GitPilot: click Provider > Claude -3. Paste your API key -``` +**Full guide:** [AI provider setup](https://github.com/ruslanmv/gitpilot/blob/master/docs/vscode/ai-providers.md) -**IBM Watsonx**: -``` -1. Get credentials from https://cloud.ibm.com -2. In GitPilot: click Provider > Watsonx -3. Add your API key and project ID -``` +--- + +## Agent Topologies + +A **topology** decides which of the ten agents run, and in what order. + +Open **`GitPilot: Settings`** → **Agent** → *Agent topology*. Each preset is a +card showing its full sequence: + +| Topology | Sequence | +|---|---| +| **Automatic** (recommended) | Routed per request | +| 🚀 **Feature Builder** | Explorer → Planner → Coder → Reviewer → PR Manager | +| 🐛 **Bug Hunter** | Explorer → Coder → Reviewer → PR Manager | +| 🔍 **Code Inspector** | Explorer → Reviewer (read-only) | +| 📐 **Architect Mode** | Explorer → Planner (read-only) | +| ⚡ **Quick Fix** | Coder → PR Manager | + +The default routed flow runs Explorer → Planner → Coder. To have **every** +change reviewed, or a PR opened automatically, pin a topology that includes +those stages. + +Topology (which agents run) and permission mode (what they may do unattended) +compose — Feature Builder in **Plan** mode gives you the full plan and review +with all writes blocked. + +**Full guide:** [Agent topologies](https://github.com/ruslanmv/gitpilot/blob/master/docs/vscode/agent-topologies.md) + +--- + +## MCP Servers + +MCP servers extend what the agents can do. Attach a PostgreSQL server and the +Explorer reads your live schema, the Coder writes queries against real tables, +and the Reviewer validates a migration before it lands. + +Open **`GitPilot: Settings`** → **MCP Servers**. + +- **[Install MCP Context Forge]** — one click. GitPilot checks Docker, starts + the gateway, waits for it, and points itself at it. No terminal, no compose + file. +- **Attach** a server from the bundled catalogue (PostgreSQL, Milvus, + Inspector, GitPilot's own), or **Search** a registry — + [MatrixHub](https://matrixhub.io) by default — for anything else. +- Each server has its own page listing every tool, its risk, and which agents + call it. Toggle tools individually. + +Two safety properties worth knowing: + +- **Attaching is not enabling.** A newly attached server arrives disabled, so + gaining a capability is never a side effect of browsing a catalogue. +- **Tokens never enter the editor.** GitPilot asks for the *name* of an + environment variable and reads its value on the GitPilot host. Destructive + tools (`drop`, `delete`, `truncate`) are off by default and ask before being + enabled. + +**Full guide:** [MCP servers](https://github.com/ruslanmv/gitpilot/blob/master/docs/vscode/mcp-servers.md) --- @@ -132,25 +178,38 @@ Open settings: `Ctrl+Shift+P` > "Preferences: Open Settings" > search "gitpilot" | Setting | Default | What it does | |---|---|---| -| `gitpilot.provider` | `ollabridge` | Which AI to use | -| `gitpilot.codeLens.enabled` | `true` | Show Explain/Review hints | +| `gitpilot.serverUrl` | `http://127.0.0.1:8000` | Where the GitPilot backend is | +| `gitpilot.serverCommand` | `gitpilot` | Command used to start a local server from the settings page | | `gitpilot.autoConnect` | `true` | Connect to server on startup | +| `gitpilot.permissionMode` | `normal` | `normal` (ask), `auto`, or `plan` (read-only) | +| `gitpilot.showInlineHints` | `true` | Show Explain/Review CodeLens hints | +| `gitpilot.liteMode` | `false` | Simplified prompts for models under ~7B parameters | +| `gitpilot.mcp.gatewayUrl` | `http://localhost:4444` | MCP Context Forge address | +| `gitpilot.mcp.forgePort` | `4444` | Port used when GitPilot starts Forge for you | +| `gitpilot.mcp.forgeImage` | `ghcr.io/ibm/mcp-context-forge:latest` | Image used when the workspace has no compose file | + +Provider credentials are deliberately absent: they live on the GitPilot +server, so they are never written to `settings.json` or synced by Settings +Sync. --- ## Troubleshooting **"Provider not configured"** -Click the "Provider" button in the sidebar header and select your AI provider. +Run `GitPilot: Settings` → **AI Providers** and pick one. OllaBridge and +Ollama need no API key. **"Disconnected"** -GitPilot needs a backend server running at `http://127.0.0.1:8000`. -Install and start it with: +GitPilot needs a backend server running at `http://127.0.0.1:8000`. The +settings page can start one for you — **AI Providers** → **Start local +server** — or do it yourself: ```bash pip install gitcopilot gitpilot serve ``` The PyPI package is **`gitcopilot`** (the CLI is `gitpilot`). Requires Python 3.11 or 3.12. +If `gitpilot` is not on your `PATH`, set `gitpilot.serverCommand` to its full path. **"Version mismatch"** This VS Code extension (v0.2.x) requires **GitPilot backend v0.2.x** (`gitcopilot>=0.2.6`). @@ -169,4 +228,4 @@ Open the Output panel (`Ctrl+Shift+U`) and select "GitPilot" from the dropdown t --- -**Made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)** | Apache 2.0 | v0.2.6 +**Made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)** | Apache 2.0 | v0.2.8 diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 0a29438..390f339 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -1,23 +1,45 @@ { "name": "gitpilot-vscode", - "version": "0.2.7", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitpilot-vscode", - "version": "0.2.7", + "version": "0.2.8", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.19.39", "@types/vscode": "^1.110.0", "@vscode/vsce": "^2.22.0", + "jsdom": "^24.1.0", "typescript": "^5.9.3" }, "engines": { "vscode": "^1.110.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -187,6 +209,121 @@ "node": ">=20" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -758,6 +895,41 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -776,6 +948,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1259,6 +1438,19 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -1417,6 +1609,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -1433,6 +1632,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -1764,6 +2004,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1925,6 +2172,19 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -1937,6 +2197,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -1953,6 +2223,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1999,6 +2276,20 @@ "node": ">= 6" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -2050,6 +2341,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2223,6 +2527,13 @@ "node": ">=4" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -2265,6 +2576,35 @@ "node": ">=14.14" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2353,6 +2693,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -2360,6 +2710,17 @@ "dev": true, "license": "MIT" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2378,6 +2739,29 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -2402,6 +2786,20 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2409,6 +2807,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -2425,6 +2845,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -2449,6 +2879,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index f21c839..e788357 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -1,8 +1,8 @@ { "name": "gitpilot-vscode", "displayName": "GitPilot \u2014 Multi-Agent AI Coding Assistant", - "description": "The first multi-agent AI pair programmer for VS Code. Four specialized agents (Explorer, Planner, Coder, Reviewer) collaborate on every task \u2014 you approve every change. Works with OpenAI, Claude, Ollama, Watsonx.", - "version": "0.2.7", + "description": "The first multi-agent AI pair programmer for VS Code. Ten specialized agents \u2014 Explorer, Planner, Coder, Reviewer and more \u2014 collaborate on every task, and you approve every change. Works with OllaBridge, Ollama, Open WebUI, OpenAI, Claude, watsonx, or any OpenAI-compatible endpoint.", + "version": "0.2.8", "preview": false, "publisher": "ruslanmv", "license": "Apache-2.0", @@ -48,6 +48,7 @@ "testing" ], "activationEvents": [ + "onCommand:gitpilot.diagnostics", "onCommand:gitpilot.explainSelection", "onCommand:gitpilot.explain_project", "onCommand:gitpilot.fixSelection", @@ -55,10 +56,15 @@ "onCommand:gitpilot.generate_tests", "onCommand:gitpilot.newSession", "onCommand:gitpilot.openChat", + "onCommand:gitpilot.openChatTab", + "onCommand:gitpilot.openHome", + "onCommand:gitpilot.openSettings", "onCommand:gitpilot.refreshProjectContext", "onCommand:gitpilot.refreshSessions", "onCommand:gitpilot.refreshSkills", "onCommand:gitpilot.refreshStatus", + "onCommand:gitpilot.rewind", + "onCommand:gitpilot.revertProposedChanges", "onCommand:gitpilot.reviewFile", "onCommand:gitpilot.reviewSelection", "onCommand:gitpilot.review_file", @@ -75,7 +81,7 @@ "onCommand:gitpilot.showServerInfo", "onCommand:gitpilot.testSelection", "onStartupFinished", - "onView:gitpilot.chatView" + "onView:gitpilot.navView" ], "main": "./out/extension.js", "contributes": { @@ -92,8 +98,8 @@ "gitpilot": [ { "type": "webview", - "id": "gitpilot.chatView", - "name": "GitPilot Workspace" + "id": "gitpilot.navView", + "name": "GitPilot" }, { "id": "gitpilot.sessionsView", @@ -106,12 +112,48 @@ ] }, "commands": [ + { + "command": "gitpilot.diagnostics", + "title": "Diagnostics", + "category": "GitPilot", + "icon": "$(pulse)" + }, + { + "command": "gitpilot.executeApprovedPlan", + "title": "Execute the Approved Plan", + "category": "GitPilot", + "icon": "$(play)" + }, + { + "command": "gitpilot.rewind", + "title": "Rewind to a Checkpoint", + "category": "GitPilot", + "icon": "$(discard)" + }, + { + "command": "gitpilot.revertProposedChanges", + "title": "Revert the Last Change", + "category": "GitPilot", + "icon": "$(discard)" + }, + { + "command": "gitpilot.openHome", + "title": "Home", + "category": "GitPilot", + "icon": "$(home)" + }, { "command": "gitpilot.openChat", "title": "Open GitPilot Workspace", "category": "GitPilot", "icon": "$(comment-discussion)" }, + { + "command": "gitpilot.openChatTab", + "title": "Open Chat in an Editor Tab", + "category": "GitPilot", + "icon": "$(comment-discussion)" + }, { "command": "gitpilot.sendMessage", "title": "Send Message", @@ -162,6 +204,12 @@ "category": "GitPilot", "icon": "$(refresh)" }, + { + "command": "gitpilot.openSettings", + "title": "Settings", + "category": "GitPilot", + "icon": "$(gear)" + }, { "command": "gitpilot.showServerInfo", "title": "Show Server Info", @@ -498,6 +546,12 @@ "description": "URL of the GitPilot API server", "order": 1 }, + "gitpilot.serverCommand": { + "type": "string", + "default": "gitpilot", + "description": "Command used to start the local GitPilot server from the Settings page. Use a full path if 'gitpilot' is not on your PATH.", + "order": 2 + }, "gitpilot.autoConnect": { "type": "boolean", "default": true, @@ -516,6 +570,12 @@ "description": "Show CodeLens hints (Explain/Review) above functions and classes", "order": 4 }, + "gitpilot.showHomeOnStartup": { + "type": "boolean", + "default": true, + "description": "Open GitPilot Home in the editor when a window starts with no files open. Home is never opened on top of work already in progress.", + "order": 5 + }, "gitpilot.permissionMode": { "type": "string", "enum": [ @@ -572,6 +632,13 @@ "description": "Lite Mode: optimized for small LLMs (< 7B params). Uses simplified prompts and single-agent execution instead of multi-agent pipelines. Recommended for qwen2.5:1.5b, phi-3-mini, gemma-2b, tinyllama.", "order": 11 }, + "gitpilot.llmTimeoutSeconds": { + "type": "number", + "default": 300, + "minimum": 30, + "description": "How long to wait for a chat, plan or execute request before giving up. These run a model, so they are slow by nature — a local Ollama answering with repository context routinely takes 20-60s. Raise this if a large model on CPU is being cut off; the request is never retried, so a timeout here means the answer is lost, not duplicated.", + "order": 12 + }, "gitpilot.mcp.gatewayUrl": { "type": "string", "default": "", @@ -601,6 +668,18 @@ "default": "", "description": "Admin email used to sign in to the MCP gateway. The password is NOT a setting: run 'GitPilot: Configure MCP Gateway' to enter it \u2014 it is stored by the GitPilot server, never in VS Code.", "order": 22 + }, + "gitpilot.mcp.forgePort": { + "type": "number", + "default": 4444, + "description": "Port MCP Context Forge listens on when GitPilot starts it for you.", + "order": 23 + }, + "gitpilot.mcp.forgeImage": { + "type": "string", + "default": "ghcr.io/ibm/mcp-context-forge:latest", + "description": "Container image used to start MCP Context Forge when the workspace has no docker-compose.mcp.yml. Point this at an image your network can reach if the default is unavailable.", + "order": 24 } } }, @@ -643,6 +722,11 @@ } ], "view/title": [ + { + "command": "gitpilot.openHome", + "when": "view == gitpilot.navView", + "group": "navigation" + }, { "command": "gitpilot.newSession", "when": "view == gitpilot.sessionsView", @@ -665,12 +749,7 @@ }, { "command": "gitpilot.setupWizard", - "when": "view == gitpilot.chatView", - "group": "navigation" - }, - { - "command": "gitpilot.refreshProjectContext", - "when": "view == gitpilot.chatView", + "when": "view == gitpilot.navView", "group": "navigation" } ], @@ -780,11 +859,14 @@ "compile": "tsc -p ./ && node scripts/copy-webview-assets.js", "watch": "tsc -watch -p ./", "lint": "eslint src --ext ts", + "test": "npm run compile && node test/run.js", + "test:only": "node test/run.js", "package": "vsce package", "publish": "vsce publish" }, "devDependencies": { "@types/node": "^20.19.39", + "jsdom": "^24.1.0", "@types/vscode": "^1.110.0", "@vscode/vsce": "^2.22.0", "typescript": "^5.9.3" diff --git a/extensions/vscode/scripts/copy-webview-assets.js b/extensions/vscode/scripts/copy-webview-assets.js index 0cd018d..5f9d6ec 100644 --- a/extensions/vscode/scripts/copy-webview-assets.js +++ b/extensions/vscode/scripts/copy-webview-assets.js @@ -15,6 +15,10 @@ const filesToCopy = [ from: path.join(root, "src", "ui", "webview", "gitpilotSettingsTemplate.html"), to: path.join(root, "out", "ui", "webview", "gitpilotSettingsTemplate.html"), }, + { + from: path.join(root, "src", "ui", "webview", "gitpilotNavTemplate.html"), + to: path.join(root, "out", "ui", "webview", "gitpilotNavTemplate.html"), + }, ]; for (const file of filesToCopy) { diff --git a/extensions/vscode/src/api/chatClient.ts b/extensions/vscode/src/api/chatClient.ts index b963a34..1fe3617 100644 --- a/extensions/vscode/src/api/chatClient.ts +++ b/extensions/vscode/src/api/chatClient.ts @@ -2,17 +2,27 @@ * GitPilot Redesign — Chat API Client */ -import { GitPilotApiClient } from "./client"; +import { GitPilotApiClient, llmRequestOptions } from "./client"; import { ChatMessageRequest, ChatMessageResponse, } from "../core/types"; +/** + * Every call here runs a model, so none of them take the default deadline + * or the default retry budget. See `llmRequestOptions` for what each half + * is for; the short version is that inference is slower than a request and + * re-sending it is worse than waiting. + */ export class ChatClient { constructor(private client: GitPilotApiClient) {} async sendMessage(req: ChatMessageRequest): Promise { - return this.client.post("/api/chat/send", req); + return this.client.post( + "/api/chat/send", + req, + llmRequestOptions() + ); } async reviewPlan( @@ -21,12 +31,16 @@ export class ChatClient { goal: string, branchName?: string ): Promise { - return this.client.post("/api/chat/plan", { - repo_owner: repoOwner, - repo_name: repoName, - goal, - branch_name: branchName, - }); + return this.client.post( + "/api/chat/plan", + { + repo_owner: repoOwner, + repo_name: repoName, + goal, + branch_name: branchName, + }, + llmRequestOptions() + ); } async applyPlan( @@ -35,11 +49,15 @@ export class ChatClient { plan: any, branchName?: string ): Promise { - return this.client.post("/api/chat/execute", { - repo_owner: repoOwner, - repo_name: repoName, - plan, - branch_name: branchName, - }); + return this.client.post( + "/api/chat/execute", + { + repo_owner: repoOwner, + repo_name: repoName, + plan, + branch_name: branchName, + }, + llmRequestOptions() + ); } } diff --git a/extensions/vscode/src/api/checkpointClient.ts b/extensions/vscode/src/api/checkpointClient.ts new file mode 100644 index 0000000..b8d4d1a --- /dev/null +++ b/extensions/vscode/src/api/checkpointClient.ts @@ -0,0 +1,58 @@ +/** + * Checkpoints — the undo behind Agent mode. + * + * GitPilot snapshots the workspace and the conversation before every mutating + * tool call, so a run that goes wrong is recoverable without reaching for git. + * Rewinding restores both halves: files alone would leave the model reasoning + * about edits that no longer exist. + */ +import { GitPilotApiClient } from "./client"; + +export interface Checkpoint { + id: string; + description: string; + /** ISO-8601, from the backend. */ + timestamp: string; + /** How many messages the conversation had when this was taken. */ + message_index: number; + /** The tool this checkpoint was taken in front of, or "manual". */ + tool_name: string; + target_path?: string | null; + /** + * False when the workspace was too large to snapshot. The conversation can + * still be rewound; the files cannot, and the UI must say so rather than + * offering a restore it cannot perform. + */ + has_files: boolean; +} + +export interface RewindResult { + session_id: string; + messages: Array<{ role: string; content: string; timestamp?: string }>; + checkpoints: Checkpoint[]; +} + +export class CheckpointClient { + constructor(private client: GitPilotApiClient) {} + + /** Newest first, as the backend returns them. */ + async list(sessionId: string): Promise { + const data = await this.client.get<{ checkpoints: Checkpoint[] }>( + `/api/sessions/${sessionId}/checkpoints` + ); + return data.checkpoints || []; + } + + async create(sessionId: string, description: string): Promise { + return this.client.post( + `/api/sessions/${sessionId}/checkpoint`, + { description } + ); + } + + async rewind(sessionId: string, checkpointId: string): Promise { + return this.client.post(`/api/sessions/${sessionId}/rewind`, { + checkpoint_id: checkpointId, + }); + } +} diff --git a/extensions/vscode/src/api/client.ts b/extensions/vscode/src/api/client.ts index 6289531..4cc120f 100644 --- a/extensions/vscode/src/api/client.ts +++ b/extensions/vscode/src/api/client.ts @@ -18,6 +18,148 @@ export interface ApiError { detail?: string; } +/** + * `RequestInit` plus the knobs the GitPilot client adds on top: + * a hard per-attempt deadline and a retry budget. + * + * A timeout matters more here than it looks: several GitPilot endpoints + * (`/api/status`, model discovery) probe live providers and can sit for tens + * of seconds. Without a deadline a settings page just hangs. + */ +export interface GitPilotRequestOptions extends RequestInit { + /** Abort a single attempt after this many milliseconds. */ + timeoutMs?: number; + /** Retries after the first attempt. Defaults to 2. */ + retries?: number; +} + +/** Deadlines, chosen per call class rather than one blanket number. */ +/** + * What a health probe found. + * + * `unreachable` means nothing accepted the connection — start the server. + * `timeout` means something did, and did not answer in time — wait for it. + * Reporting both as "Could not reach" sent people to fix the working half. + */ +/** One completed API call, for the log and the diagnostics report. */ +export interface RequestRecord { + method: string; + path: string; + durationMs: number; + /** HTTP status, or 0 when the request never got one. */ + status: number; + error?: string; + at: number; +} + +export interface ProbeResult { + ok: boolean; + outcome: 'reachable' | 'timeout' | 'unreachable'; + elapsedMs: number; + detail?: string; +} + +/** + * Deadlines for successive connect attempts. + * + * Short first, so a genuinely absent server renders "Offline" promptly; + * generous after that, so a backend still importing its way to readiness is + * waited for instead of pronounced dead. + */ +export const CONNECT_DEADLINES: readonly number[] = [3000, 10000, 25000]; + +/** Backoff bounds for the watcher that reconnects on its own. */ +export const AUTO_RECONNECT_MIN_MS = 2000; +export const AUTO_RECONNECT_MAX_MS = 60000; + +export const REQUEST_TIMEOUTS = { + /** Liveness probe — must fail fast so "offline" renders promptly. */ + health: 3000, + /** Persisted settings read; no provider probing server-side. */ + settings: 10000, + /** Model discovery talks to the provider, so it gets more room. */ + models: 15000, + /** A connection test performs a real completion round-trip. */ + providerTest: 30000, + /** + * A turn of actual inference — chat, plan, execute. + * + * These are not slow requests, they are a model thinking, and the + * default deadline is the wrong order of magnitude for one. A local + * Ollama answering from a repository context routinely takes 20-40s, + * so a 20s deadline failed a backend that was working: the server + * logged `POST /api/chat/send took 21.26s (status=200)` while the + * extension had already given up at 20000ms. The web app never hit + * this because it allows five minutes for the same call; this is that + * number, so both front ends wait equally long for the same backend. + */ + llm: 300000, + /** Everything else. */ + default: 20000, +} as const; + +/** Lower bound for the configured LLM deadline — below this, nothing finishes. */ +export const MIN_LLM_TIMEOUT_SECONDS = 30; + +/** + * How long to let a turn of inference run, in milliseconds. + * + * Five minutes suits a small model on a normal machine. It does not suit + * every machine — a 7B model on CPU can exceed it — so the ceiling is + * configurable rather than a constant someone has to rebuild the extension + * to change. + */ +export function llmTimeoutMs(): number { + const configured = vscode.workspace + .getConfiguration('gitpilot') + .get('llmTimeoutSeconds', REQUEST_TIMEOUTS.llm / 1000); + if (!Number.isFinite(configured) || configured <= 0) { + return REQUEST_TIMEOUTS.llm; + } + return Math.max(configured, MIN_LLM_TIMEOUT_SECONDS) * 1000; +} + +/** + * Request options for a turn of inference. + * + * `retries: 0` is the load-bearing half. A timeout carries no HTTP status, + * so it slips past the non-retryable-status guard and the default budget + * of two retries fires — three runs of a call the server is still + * executing. That is how one message became the three + * `POST /api/chat/send` entries in the report, at 21s, 23s and 35s: each + * retry queued behind the last on a single-threaded Ollama, so the wait + * grew with every attempt that was supposed to rescue it. Worse than slow, + * `/api/chat/send` appends to the session on the way out, so every + * duplicate attempt that does land writes the exchange to history again. + */ +export function llmRequestOptions(): { timeoutMs: number; retries: number } { + return { timeoutMs: llmTimeoutMs(), retries: 0 }; +} + +/** Endpoints whose slowness is a model thinking, not a server struggling. */ +const LLM_PATHS = ['/api/chat/send', '/api/chat/plan', '/api/chat/execute', '/api/chat/message']; + +/** + * What to say when a request runs out of time. + * + * "timed out after 20000ms" describes the client's own impatience and + * nothing the reader can act on — the backend in the report was answering + * that same call successfully, one second later. When the call was + * inference, name the cause and the setting that moves it. + */ +export function timeoutMessage(path: string, deadline: number): string { + const seconds = Math.round(deadline / 1000); + if (LLM_PATHS.some((p) => path.startsWith(p))) { + return ( + `The model did not answer within ${seconds}s, so GitPilot stopped waiting. ` + + `The request was not retried — the backend may still be working on it. ` + + `A smaller model, or Lite Mode, will answer faster; to simply wait longer, ` + + `raise "gitpilot.llmTimeoutSeconds" in Settings.` + ); + } + return `Request to ${path} timed out after ${deadline}ms`; +} + export interface ChatPlanResponse { answer: string; plan: Array<{ @@ -95,10 +237,21 @@ export class GitPilotApiClient { private _state: ConnectionState = 'disconnected'; private _onStateChange = new vscode.EventEmitter(); private _onError = new vscode.EventEmitter(); + private _onRequest = new vscode.EventEmitter(); private _healthTimer: ReturnType | undefined; + private _reconnectTimer: ReturnType | undefined; + private _lastProbe: ProbeResult | undefined; readonly onStateChange = this._onStateChange.event; readonly onError = this._onError.event; + /** + * Every completed request, so the extension can say what it actually did. + * + * The Output channel used to carry almost nothing, which meant a slow or + * failing backend looked identical to a working one from inside VS Code — + * the evidence only existed in the terminal running the server. + */ + readonly onRequest = this._onRequest.event; constructor(serverUrl: string, token?: string) { this._serverUrl = serverUrl.replace(/\/+$/, ''); @@ -112,6 +265,8 @@ export class GitPilotApiClient { setServerUrl(url: string): void { this._serverUrl = url.replace(/\/+$/, ''); this._state = 'disconnected'; + this._lastProbe = undefined; + this.stopAutoReconnect(); this._onStateChange.fire(this._state); } @@ -119,35 +274,156 @@ export class GitPilotApiClient { this._token = token; } - async connect(): Promise { - this._setState('connecting'); + /** + * Probe `/api/health` without touching connection state. + * + * Returns *why* it failed, not just that it did. "Nothing is listening" + * and "it answered, slowly, and we gave up first" call for opposite + * remedies — start the server versus wait for it — and the extension + * reported both as "Could not reach", which sent people to fix the one + * thing that was already working. + */ + async probe(timeoutMs: number = REQUEST_TIMEOUTS.health): Promise { + const started = Date.now(); try { - await this.request<{ status: string }>('/api/health'); - this._setState('connected'); - this.startHealthCheck(); - return true; - } catch { - this._setState('disconnected'); - return false; + await this.request<{ status: string }>('/api/health', { timeoutMs, retries: 0 }); + return { ok: true, outcome: 'reachable', elapsedMs: Date.now() - started }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + outcome: /timed out/i.test(message) ? 'timeout' : 'unreachable', + elapsedMs: Date.now() - started, + detail: message, + }; } } + /** Back-compatible boolean probe. */ + async health(timeoutMs: number = REQUEST_TIMEOUTS.health): Promise { + return (await this.probe(timeoutMs)).ok; + } + + /** + * Connect, giving a starting server room to finish starting. + * + * A single 3s probe was the whole procedure, so a backend that answered + * in 10s — which the Python side did on WSL, because it probed local + * providers inline — was pronounced dead on arrival. Clicking Reconnect + * repeated the same 3s probe and failed the same way, which is why the + * button appeared to do nothing. + * + * The deadline now grows across attempts. A server that is merely slow to + * warm up gets waited for; one that is genuinely absent still fails in + * about three seconds on the first attempt, so "offline" renders promptly + * when it is true. + */ + async connect(deadlines: readonly number[] = CONNECT_DEADLINES): Promise { + this._setState('connecting'); + + for (let attempt = 0; attempt < deadlines.length; attempt++) { + const result = await this.probe(deadlines[attempt]); + + if (result.ok) { + this._lastProbe = result; + this._setState('connected'); + this.startHealthCheck(); + this.stopAutoReconnect(); + return true; + } + + this._lastProbe = result; + + // Nothing is listening and this was the first look: no amount of + // waiting turns a refused connection into an answer, so only keep + // trying when something is there but slow. + if (result.outcome === 'unreachable' && attempt === 0) { + break; + } + } + + this._setState('disconnected'); + return false; + } + + /** True while the auto-reconnect watcher is armed. */ + get isRetrying(): boolean { + return this._reconnectTimer !== undefined; + } + + /** Why the last probe failed, for a UI that has to explain itself. */ + get lastProbe(): ProbeResult | undefined { + return this._lastProbe; + } + disconnect(): void { this.stopHealthCheck(); + this.stopAutoReconnect(); this._setState('disconnected'); } + /** + * Watch for a server that is not there yet. + * + * Starting `gitpilot serve` in a terminal should be enough; having to + * come back to the sidebar and click Reconnect is a step the extension + * can take for itself. Backs off so a permanently absent server costs + * one cheap probe a minute rather than one a second. + */ + startAutoReconnect(): void { + if (this._reconnectTimer) { + return; + } + + let delay = AUTO_RECONNECT_MIN_MS; + const tick = async (): Promise => { + this._reconnectTimer = undefined; + if (this._state === 'connected') { + return; + } + + const result = await this.probe(REQUEST_TIMEOUTS.health); + if (result.ok) { + // Full connect so state, health checks and listeners all move + // together rather than this timer quietly flipping a flag. + await this.connect(); + return; + } + + this._lastProbe = result; + delay = Math.min(delay * 2, AUTO_RECONNECT_MAX_MS); + this._reconnectTimer = setTimeout(() => void tick(), delay); + }; + + this._reconnectTimer = setTimeout(() => void tick(), delay); + } + + stopAutoReconnect(): void { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = undefined; + } + } + startHealthCheck(intervalMs = 30000): void { this.stopHealthCheck(); this._healthTimer = setInterval(async () => { + // A live server that is briefly busy is not a dead one. The + // periodic check gets a real deadline and one retry so a slow + // moment does not blink the whole UI to Offline. try { - await this.request('/api/health'); + await this.request('/api/health', { + timeoutMs: REQUEST_TIMEOUTS.settings, + retries: 1, + }); if (this._state !== 'connected') { this._setState('connected'); } } catch { if (this._state === 'connected') { this._setState('disconnected'); + // Lost it: start watching for it to come back. + this.startAutoReconnect(); } } }, intervalMs); @@ -164,6 +440,7 @@ export class GitPilotApiClient { async chatPlan(owner: string, repo: string, message: string, sessionId?: string): Promise { return this.request('/api/chat/plan', { + ...llmRequestOptions(), method: 'POST', body: JSON.stringify({ repo_owner: owner, repo_name: repo, message, session_id: sessionId }), }); @@ -171,6 +448,7 @@ export class GitPilotApiClient { async chatExecute(owner: string, repo: string, plan: ChatPlanResponse['plan'], sessionId?: string): Promise { return this.request('/api/chat/execute', { + ...llmRequestOptions(), method: 'POST', body: JSON.stringify({ repo_owner: owner, repo_name: repo, plan, session_id: sessionId }), }); @@ -178,11 +456,14 @@ export class GitPilotApiClient { async chatMessage(owner: string, repo: string, message: string, sessionId?: string): Promise<{ result: string }> { return this.request('/api/chat/message', { + ...llmRequestOptions(), method: 'POST', body: JSON.stringify({ repo_owner: owner, repo_name: repo, message, session_id: sessionId }), }); } + // Routing is a deterministic classifier server-side, not a model call, + // so it keeps the ordinary deadline. async chatRoute(message: string): Promise<{ topology: string; confidence: number }> { return this.request('/api/chat/route', { method: 'POST', @@ -342,8 +623,12 @@ export class GitPilotApiClient { // --- Pull Requests --- + // Runs the plan and opens a pull request, so it gets the inference + // budget — and, more importantly, no retries: a re-sent attempt can + // open a second PR for work the first one already did. async createPR(owner: string, repo: string, branch: string, title: string, body?: string): Promise<{ url: string; number: number }> { return this.request('/api/chat/execute-with-pr', { + ...llmRequestOptions(), method: 'POST', body: JSON.stringify({ repo_owner: owner, repo_name: repo, branch, title, body }), }); @@ -385,8 +670,13 @@ export class GitPilotApiClient { return this.request(path); } - async post(path: string, body?: any): Promise { + async post( + path: string, + body?: any, + options?: GitPilotRequestOptions + ): Promise { return this.request(path, { + ...options, method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined, }); @@ -408,17 +698,43 @@ export class GitPilotApiClient { /** Status codes that should NOT be retried (non-transient errors). */ private static readonly _noRetryStatuses = new Set([400, 401, 403, 404, 422, 429, 502, 503]); - async request(path: string, options?: RequestInit, retries = 2): Promise { + async request(path: string, options?: GitPilotRequestOptions, retriesArg = 2): Promise { const url = `${this._serverUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', ...(this._token ? { 'Authorization': `Bearer ${this._token}` } : {}), }; + const { timeoutMs, retries: retriesOpt, ...init } = options ?? {}; + const retries = retriesOpt ?? retriesArg; + const deadline = timeoutMs ?? REQUEST_TIMEOUTS.default; + + const method = (init.method || 'GET').toUpperCase(); + const startedAt = Date.now(); + const report = (status: number, error?: string): void => { + this._onRequest.fire({ + method, path, status, error, + durationMs: Date.now() - startedAt, + at: startedAt, + }); + }; + let lastError: Error | undefined; for (let attempt = 0; attempt <= retries; attempt++) { + const timer = new AbortController(); + const expire = setTimeout(() => timer.abort(), deadline); + // A caller-supplied signal still wins — chain it into ours so the + // request dies on whichever fires first. + const caller = init.signal; + const onCallerAbort = () => timer.abort(); + caller?.addEventListener('abort', onCallerAbort, { once: true }); + try { - const resp = await fetch(url, { ...options, headers: { ...headers, ...(options?.headers as Record || {}) } }); + const resp = await fetch(url, { + ...init, + signal: timer.signal, + headers: { ...headers, ...(init.headers as Record || {}) }, + }); if (!resp.ok) { const body = await resp.text().catch(() => ''); // Try to extract structured detail from JSON error body @@ -431,15 +747,29 @@ export class GitPilotApiClient { this._onError.fire(err); const error = new Error(`HTTP ${resp.status}: ${detail || resp.statusText}`); (error as any).status = resp.status; + // Carry the server's own explanation, not just the code. + // The backend writes the actionable sentence — which + // provider, which package, what to run — and a status + // number cannot be translated back into it. + (error as any).detail = detail; // Don't retry non-transient errors (circuit breaker, auth, rate limit) if (GitPilotApiClient._noRetryStatuses.has(resp.status)) { throw error; } throw error; } - return await resp.json() as T; + const parsed = await resp.json() as T; + report(resp.status); + return parsed; } catch (err: any) { - lastError = err; + const abortedByCaller = caller?.aborted === true; + lastError = err?.name === 'AbortError' && !abortedByCaller + ? new Error(timeoutMessage(path, deadline)) + : err; + // The caller gave up on purpose — surface that, don't retry. + if (abortedByCaller) { + break; + } // Skip retries for non-transient HTTP errors if (err.status && GitPilotApiClient._noRetryStatuses.has(err.status)) { break; @@ -447,8 +777,15 @@ export class GitPilotApiClient { if (attempt < retries) { await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000)); } + } finally { + clearTimeout(expire); + caller?.removeEventListener('abort', onCallerAbort); } } + report( + (lastError as { status?: number } | undefined)?.status ?? 0, + lastError?.message ?? 'unknown error' + ); throw lastError!; } diff --git a/extensions/vscode/src/api/mcpClient.ts b/extensions/vscode/src/api/mcpClient.ts new file mode 100644 index 0000000..a1dbf1c --- /dev/null +++ b/extensions/vscode/src/api/mcpClient.ts @@ -0,0 +1,241 @@ +/** + * MCP admin API client. + * + * Everything the "MCP Servers" settings page needs: what is attached, what is + * available to attach, and the per-tool switches that decide which of those + * tools the agents may actually call. + * + * Two deliberate absences: + * + * - **No credential ever crosses this boundary.** A server's auth token lives + * in an environment variable on the GitPilot host; this client sends the + * variable's *name*, never its value. + * - **No install implies an enable.** Attaching a server and letting the + * agents use it are separate calls, because gaining a capability should + * never be a side effect of browsing a catalogue. + */ + +import { GitPilotApiClient, REQUEST_TIMEOUTS } from "./client"; + +/** How long a registry search may take before it is abandoned. */ +const REGISTRY_TIMEOUT_MS = 20_000; + +/** How long a live probe of one MCP server may take. */ +const SERVER_TEST_TIMEOUT_MS = 20_000; + +export type ToolRisk = "high" | "medium" | "low"; + +/** One tool exposed by an attached server. */ +export interface McpTool { + name: string; + risk: ToolRisk; + /** Whether the agents may call it right now. */ + enabled: boolean; + enabled_default: boolean; + /** Agents that call this tool, for the "Used by" chip. */ + used_by: string[]; + destructive: boolean; + mutation: boolean; +} + +/** An MCP server GitPilot knows about, attached or not. */ +export interface McpServer { + id: string; + installed: boolean; + enabled: boolean; + endpoint: string; + auth_token_env: string; + description: string; + tags: string[]; + tool_count: number; + tools: McpTool[]; + is_known: boolean; + orphan: boolean; + source: string; +} + +/** A server on offer — from the bundled catalogue or a remote registry. */ +export interface McpCatalogEntry { + id: string; + slug: string; + description: string; + endpoint: string; + tags: string[]; + auth?: { type?: string; env?: string }; + metadata?: Record; + installed: boolean; + source?: string; + homepage?: string; + version?: string; + transport?: string; +} + +export interface McpStatus { + gateway_url: string; + gateway_reachable: boolean; + gateway_detail: string; + plugin_enabled: boolean; + servers_installed: number; + servers_enabled: number; + tools_advertised: number; +} + +export interface McpServerTestResult { + ok?: boolean; + reachable?: boolean; + detail?: string; + tools?: string[]; + [key: string]: unknown; +} + +export class McpClient { + constructor(private client: GitPilotApiClient) {} + + /** Gateway reachability and the attached/enabled counts. */ + async status(): Promise { + return this.client.request("/api/mcp/status", { + timeoutMs: REQUEST_TIMEOUTS.settings, + }); + } + + async listServers(): Promise { + const resp = await this.client.request<{ servers: McpServer[] }>( + "/api/mcp/servers", + { timeoutMs: REQUEST_TIMEOUTS.settings } + ); + return resp.servers || []; + } + + /** The catalogue that ships with GitPilot. */ + async listCatalog(): Promise { + const resp = await this.client.request<{ items: McpCatalogEntry[] }>( + "/api/mcp/catalog", + { timeoutMs: REQUEST_TIMEOUTS.settings } + ); + return resp.items || []; + } + + /** + * Search a remote registry. + * + * A registry that is down answers with an empty list and a reason rather + * than throwing, so the page it is rendered in survives the outage. + */ + async searchRegistry( + query: string, + registryUrl?: string + ): Promise<{ items: McpCatalogEntry[]; error?: string; registry_url: string }> { + const params = new URLSearchParams(); + if (query.trim()) { + params.set("q", query.trim()); + } + if (registryUrl) { + params.set("registry_url", registryUrl); + } + const qs = params.toString(); + return this.client.request(`/api/mcp/registry/search${qs ? `?${qs}` : ""}`, { + timeoutMs: REGISTRY_TIMEOUT_MS, + retries: 0, + }); + } + + /** Attach a server from the bundled catalogue. It arrives disabled. */ + async installFromCatalog(serverId: string): Promise { + await this.client.request("/api/mcp/servers/install", { + method: "POST", + body: JSON.stringify({ server_id: serverId }), + timeoutMs: REQUEST_TIMEOUTS.settings, + }); + } + + /** Attach a server discovered in a remote registry. It arrives disabled. */ + async installFromRegistry( + entryId: string, + registryUrl?: string + ): Promise { + await this.client.request("/api/mcp/registry/install", { + method: "POST", + body: JSON.stringify({ entry_id: entryId, registry_url: registryUrl }), + timeoutMs: REGISTRY_TIMEOUT_MS, + }); + } + + /** + * Attach a server by hand. + * + * `authTokenEnv` names an environment variable on the GitPilot host. The + * token itself is never sent from the editor. + */ + async installCustom(params: { + id: string; + endpoint: string; + description?: string; + authTokenEnv?: string; + tags?: string[]; + }): Promise { + await this.client.request("/api/mcp/servers/install-custom", { + method: "POST", + body: JSON.stringify({ + register_json: { + name: params.id, + endpoint: params.endpoint, + description: params.description || "", + auth: params.authTokenEnv ? { env: params.authTokenEnv } : {}, + tags: params.tags || [], + }, + }), + timeoutMs: REQUEST_TIMEOUTS.settings, + }); + } + + async uninstall(serverId: string): Promise { + await this.client.request( + `/api/mcp/servers/${encodeURIComponent(serverId)}/uninstall`, + { method: "POST", timeoutMs: REQUEST_TIMEOUTS.settings } + ); + } + + /** Enable or disable a whole server's tools for the agents. */ + async setServerEnabled(serverId: string, enabled: boolean): Promise { + const action = enabled ? "enable" : "disable"; + await this.client.request( + `/api/mcp/servers/${encodeURIComponent(serverId)}/${action}`, + { method: "POST", timeoutMs: REQUEST_TIMEOUTS.settings } + ); + } + + /** Enable or disable one tool, overriding its risk-based default. */ + async setToolEnabled( + serverId: string, + toolName: string, + enabled: boolean + ): Promise { + await this.client.request( + `/api/mcp/servers/${encodeURIComponent(serverId)}/tools/${encodeURIComponent( + toolName + )}/toggle`, + { + method: "POST", + body: JSON.stringify({ enabled }), + timeoutMs: REQUEST_TIMEOUTS.settings, + } + ); + } + + /** Probe a server for real, so a misconfiguration surfaces here. */ + async testServer(serverId: string): Promise { + return this.client.request( + `/api/mcp/servers/${encodeURIComponent(serverId)}/test`, + { method: "POST", timeoutMs: SERVER_TEST_TIMEOUT_MS, retries: 0 } + ); + } + + /** Re-read the gateway's registry and reconcile it with the local store. */ + async syncGateway(): Promise> { + return this.client.request("/api/mcp/sync", { + method: "POST", + timeoutMs: REGISTRY_TIMEOUT_MS, + retries: 0, + }); + } +} diff --git a/extensions/vscode/src/api/settingsClient.ts b/extensions/vscode/src/api/settingsClient.ts index adfadc6..1a3e425 100644 --- a/extensions/vscode/src/api/settingsClient.ts +++ b/extensions/vscode/src/api/settingsClient.ts @@ -2,13 +2,17 @@ * GitPilot Redesign — Settings API Client */ -import { GitPilotApiClient } from "./client"; +import { GitPilotApiClient, REQUEST_TIMEOUTS } from "./client"; import { ProviderName, ProviderTestRequest, ProviderTestResponse, + TopologySummary, } from "../core/types"; +/** How long a model list stays fresh enough to reuse. */ +const MODEL_CACHE_TTL_MS = 60_000; + export interface SettingsData { provider: string; providers: string[]; @@ -22,6 +26,13 @@ export interface SettingsData { }; ollama: { base_url?: string; model?: string }; ollabridge: { base_url?: string; model?: string; api_key?: string }; + openwebui: { base_url?: string; model?: string; api_key?: string }; + custom: { + base_url?: string; + model?: string; + api_key?: string; + headers?: Record; + }; } type ProviderConfigMap = { @@ -30,34 +41,116 @@ type ProviderConfigMap = { watsonx: SettingsData["watsonx"]; ollama: SettingsData["ollama"]; ollabridge: SettingsData["ollabridge"]; + openwebui: SettingsData["openwebui"]; + custom: SettingsData["custom"]; }; export class SettingsClient { + /** Model lists keyed by provider, with the time they were fetched. */ + private modelCache = new Map< + string, + { at: number; result: { models: string[]; error?: string } } + >(); + constructor(private client: GitPilotApiClient) {} async getSettings(): Promise { - return this.client.get("/api/settings"); + return this.client.request("/api/settings", { + timeoutMs: REQUEST_TIMEOUTS.settings, + }); } async updateSettings(updates: Partial): Promise { - return this.client.put("/api/settings/llm", updates); + // A write can change what models are on offer, so the cache goes with it. + this.modelCache.clear(); + return this.client.request("/api/settings/llm", { + method: "PUT", + body: JSON.stringify(updates), + timeoutMs: REQUEST_TIMEOUTS.settings, + }); } async setProvider(provider: ProviderName): Promise { - return this.client.post("/api/settings/provider", { - provider, + return this.client.request("/api/settings/provider", { + method: "POST", + body: JSON.stringify({ provider }), + timeoutMs: REQUEST_TIMEOUTS.settings, }); } async testProvider(req: ProviderTestRequest): Promise { - return this.client.post("/api/providers/test", req); + return this.client.request("/api/providers/test", { + method: "POST", + body: JSON.stringify(req), + timeoutMs: REQUEST_TIMEOUTS.providerTest, + retries: 0, + }); } async listModels( provider?: string ): Promise<{ models: string[]; error?: string }> { const query = provider ? `?provider=${provider}` : ""; - return this.client.get(`/api/settings/models${query}`); + return this.client.request(`/api/settings/models${query}`, { + timeoutMs: REQUEST_TIMEOUTS.models, + retries: 0, + }); + } + + /** + * Model discovery, cached for a minute. + * + * Discovery reaches out to the provider and regularly takes 4-15s, so a + * page that lists models on every render is a page that feels broken. Pass + * `force` for an explicit "Refresh models". + */ + async listModelsCached( + provider: string, + force = false + ): Promise<{ models: string[]; error?: string }> { + const hit = this.modelCache.get(provider); + if (!force && hit && Date.now() - hit.at < MODEL_CACHE_TTL_MS) { + return hit.result; + } + const result = await this.listModels(provider); + this.modelCache.set(provider, { at: Date.now(), result }); + return result; + } + + /** Drop cached model lists, e.g. after the server URL changes. */ + clearModelCache(): void { + this.modelCache.clear(); + } + + // ── Agent topologies ────────────────────────────────────────────── + + /** Every topology preset the server knows about. */ + async listTopologies(): Promise { + return this.client.request("/api/flow/topologies", { + timeoutMs: REQUEST_TIMEOUTS.settings, + }); + } + + /** + * The topology the server will use when a request does not name one. + * + * `null` means no preference is saved, and the server routes each request + * on intent rather than forcing a fixed pipeline. + */ + async getTopologyPreference(): Promise { + const resp = await this.client.request<{ topology: string | null }>( + "/api/settings/topology", + { timeoutMs: REQUEST_TIMEOUTS.settings } + ); + return resp.topology ?? null; + } + + async setTopologyPreference(topology: string): Promise { + await this.client.request("/api/settings/topology", { + method: "POST", + body: JSON.stringify({ topology }), + timeoutMs: REQUEST_TIMEOUTS.settings, + }); } async getActiveProvider(): Promise { diff --git a/extensions/vscode/src/commands/chat.ts b/extensions/vscode/src/commands/chat.ts index e777fc8..211ae7d 100644 --- a/extensions/vscode/src/commands/chat.ts +++ b/extensions/vscode/src/commands/chat.ts @@ -4,17 +4,63 @@ import * as vscode from 'vscode'; import { GitPilotApiClient } from '../api/client'; import { ChatViewProvider } from '../views/chatViewProvider'; -import { getWorkspaceContext } from '../utils/context'; +import { StateStore } from '../core/stateStore'; +import { SessionCoordinator } from '../services/workspace/sessionCoordinator'; +import { ModeResolver } from '../services/workspace/modeResolver'; +import { ChatMessagePayload } from '../core/types'; + +/** + * Collaborators owned by the workspace panel — the view the user actually + * sees. Session commands used to talk only to the legacy provider, which is + * no longer registered as a view, so "New Chat" created a session on the + * backend while the visible conversation stayed exactly as it was. + */ +export interface ChatSessionDeps { + stateStore: StateStore; + sessionCoordinator: SessionCoordinator; + modeResolver: ModeResolver; + /** + * Tell the panel to drop what the previous task left behind. + * + * Clearing the store is not enough. Tool-activity blocks live in the + * transcript's DOM and carry no message key, so they survive a state + * change and keep the transcript on screen; the composer's queue, pinned + * files and half-typed text are the panel's own memory too. + */ + resetPanel?: () => void; +} export function registerChatCommands( context: vscode.ExtensionContext, client: GitPilotApiClient, chatProvider: ChatViewProvider, + deps: ChatSessionDeps, ): void { + /** + * Wipe everything belonging to the previous conversation. + * + * A session change has to clear the task as well as the transcript; + * leaving a stale plan, diff or file list behind makes the new chat look + * like a continuation of the old one. + */ + const clearConversation = (): void => { + deps.stateStore.setChatMessages([]); + deps.stateStore.clearTaskState(); + deps.resetPanel?.(); + }; + + /** + * Bring the conversation into view. + * + * There is one GitPilot tab and it lives in the editor, so this is the + * only place a conversation can be revealed. The sidebar view it used to + * focus no longer exists. + */ + const revealChat = async (): Promise => { + await vscode.commands.executeCommand('gitpilot.openChatTab'); + }; context.subscriptions.push( - vscode.commands.registerCommand('gitpilot.openChat', () => { - vscode.commands.executeCommand('gitpilot.chatView.focus'); - }), + vscode.commands.registerCommand('gitpilot.openChat', () => revealChat()), vscode.commands.registerCommand('gitpilot.sendMessage', async () => { const message = await vscode.window.showInputBox({ @@ -23,24 +69,65 @@ export function registerChatCommands( }); if (message) { chatProvider.sendMessageFromCommand(message); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + await revealChat(); } }), + /** + * New Chat. + * + * Clears the visible conversation first, so the panel goes empty the + * moment it is asked to — waiting for the round-trip made the button + * look like it had done nothing. + */ vscode.commands.registerCommand('gitpilot.newSession', async () => { - const ctx = getWorkspaceContext(); - try { - const session = await client.createSession(ctx.repoOwner, ctx.repoName); - vscode.window.showInformationMessage(`New session created: ${session.id}`); - vscode.commands.executeCommand('gitpilot.refreshSessions'); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to create session: ${err.message}`); - } + clearConversation(); + await revealChat(); + + const state = deps.stateStore.state; + const mode = deps.modeResolver.resolve({ + folderOpen: state.workspace.folderOpen, + git: state.workspace.git, + github: state.github, + preferredMode: state.workspace.mode, + }); + + await deps.sessionCoordinator.startSession(mode); + await vscode.commands.executeCommand('gitpilot.refreshSessions'); }), + /** + * Resume a session. + * + * A GitPilot session is a task, not a transcript, so this restores the + * session's own state and then replays its messages. Task state from + * the previous conversation is cleared rather than carried over. + */ vscode.commands.registerCommand('gitpilot.loadSession', async (sessionId: string) => { - await chatProvider.loadSessionIntoChat(sessionId); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + if (!sessionId) { return; } + + clearConversation(); + await revealChat(); + await deps.sessionCoordinator.resumeSession(sessionId); + + try { + const session = await client.getSession(sessionId); + const messages: ChatMessagePayload[] = (session.messages || []).map( + (m, index) => ({ + id: `${sessionId}-${index}`, + role: (m.role === 'user' || m.role === 'assistant' ? m.role : 'system'), + content: m.content ?? '', + createdAt: new Date().toISOString(), + }), + ); + deps.stateStore.setChatMessages(messages); + } catch (err: any) { + // The session is restored either way; only its history is + // missing, which is not worth blocking the user over. + vscode.window.showWarningMessage( + `GitPilot restored the session but could not load its history: ${err.message}`, + ); + } }), vscode.commands.registerCommand('gitpilot.deleteSession', async (item: any) => { diff --git a/extensions/vscode/src/commands/git.ts b/extensions/vscode/src/commands/git.ts index fe38226..ece3d66 100644 --- a/extensions/vscode/src/commands/git.ts +++ b/extensions/vscode/src/commands/git.ts @@ -21,21 +21,21 @@ export function registerGitCommands( // ── Basic Git Operations ─────────────────────────────── vscode.commands.registerCommand('gitpilot.gitStatus', () => { chatProvider.sendMessageFromCommand('Show me the git status of this repository'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.gitDiffAnalysis', () => { chatProvider.sendMessageFromCommand( 'Analyze the current git diff and summarize what changed, why it might have changed, and if there are any issues', ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.smartCommit', () => { chatProvider.sendMessageFromCommand( 'Analyze the staged changes and generate a semantic commit message following conventional commits. Show me the plan before executing.', ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.createPR', async () => { @@ -49,7 +49,7 @@ export function registerGitCommands( : 'Analyze all commits on this branch and create a pull request with an AI-generated title and description.'; chatProvider.sendMessageFromCommand(message); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), // ── Intermediate Git Operations ──────────────────────── @@ -71,14 +71,14 @@ export function registerGitCommands( }; chatProvider.sendMessageFromCommand(prompts[action.action]); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.conflictResolver', () => { chatProvider.sendMessageFromCommand( 'Check for merge conflicts in the current repository. If any exist, explain each conflict, suggest resolutions, and show the plan before applying changes.', ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.stashManager', async () => { @@ -97,7 +97,7 @@ export function registerGitCommands( }; chatProvider.sendMessageFromCommand(prompts[action.action]); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), // ── Advanced Git Operations ──────────────────────────── @@ -106,7 +106,7 @@ export function registerGitCommands( chatProvider.sendMessageFromCommand( `Perform a comprehensive health check on this repository (${ctx.repoOwner}/${ctx.repoName}). Check for: large files, stale branches, missing .gitignore entries, untracked sensitive files, commit message quality, and branch protection status.`, ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.commitSearch', async () => { @@ -119,7 +119,7 @@ export function registerGitCommands( chatProvider.sendMessageFromCommand( `Search the commit history for: "${query}". Show relevant commits with their messages, authors, dates, and changed files.`, ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.impactAnalysis', async () => { @@ -137,7 +137,7 @@ export function registerGitCommands( chatProvider.sendMessageFromCommand( `Perform an impact analysis for "${target}". Show: which files depend on it, what would break if it changed, recent change frequency, and test coverage status.`, ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), // ── Natural Language Git ─────────────────────────────── @@ -151,7 +151,7 @@ export function registerGitCommands( chatProvider.sendMessageFromCommand( `I want to do this Git operation: "${command}". Generate the exact Git command(s), explain what each does, warn me about any risks, and show the plan before executing.`, ); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), ); } diff --git a/extensions/vscode/src/commands/review.ts b/extensions/vscode/src/commands/review.ts index 74b5d66..479fd77 100644 --- a/extensions/vscode/src/commands/review.ts +++ b/extensions/vscode/src/commands/review.ts @@ -21,7 +21,7 @@ export function registerReviewCommands( const fileName = vscode.workspace.asRelativePath(editor.document.uri); const content = editor.document.getText(); chatProvider.sendCodeContext(content, 'review'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); vscode.window.showInformationMessage(`Reviewing ${fileName}...`); }), @@ -29,28 +29,28 @@ export function registerReviewCommands( const code = getSelectedText(); if (!code) { return; } chatProvider.sendCodeContext(code, 'explain'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.reviewSelection', () => { const code = getSelectedText(); if (!code) { return; } chatProvider.sendCodeContext(code, 'review'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.fixSelection', () => { const code = getSelectedText(); if (!code) { return; } chatProvider.sendCodeContext(code, 'fix'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.testSelection', () => { const code = getSelectedText(); if (!code) { return; } chatProvider.sendCodeContext(code, 'test'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), // CodeLens commands for symbols @@ -62,7 +62,7 @@ export function registerReviewCommands( const bodyRange = new vscode.Range(range.start.line, 0, endLine, 0); const code = doc.getText(bodyRange); chatProvider.sendCodeContext(code, 'explain'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }, ), @@ -73,7 +73,7 @@ export function registerReviewCommands( const bodyRange = new vscode.Range(range.start.line, 0, endLine, 0); const code = doc.getText(bodyRange); chatProvider.sendCodeContext(code, 'review'); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }, ), ); diff --git a/extensions/vscode/src/commands/server.ts b/extensions/vscode/src/commands/server.ts index 1a9957c..64749f0 100644 --- a/extensions/vscode/src/commands/server.ts +++ b/extensions/vscode/src/commands/server.ts @@ -16,6 +16,39 @@ const PROVIDER_LABELS: Record = watsonx: { label: 'Watsonx', description: 'IBM Granite and foundation models' }, }; +/** + * Confirm the backend is reachable, reconnecting once if the cached state + * says otherwise. + * + * `client.isConnected` is a cached flag, refreshed on a 30s timer. Treating it + * as ground truth is how these commands came to report "Not connected" at a + * server that had been running for minutes. When the probe really does fail, + * the user is offered the settings page — which has recovery actions — + * instead of a dead-end warning. + */ +async function ensureConnected( + client: GitPilotApiClient, + action: string, +): Promise { + if (client.isConnected && await client.health()) { + return true; + } + const connected = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: 'Connecting to GitPilot...' }, + async () => client.connect(), + ); + if (connected) { return true; } + + const choice = await vscode.window.showWarningMessage( + `Could not reach the GitPilot server at ${client.serverUrl}, so ${action} is unavailable.`, + 'Open Settings', + ); + if (choice === 'Open Settings') { + await vscode.commands.executeCommand('gitpilot.openSettings'); + } + return false; +} + export function registerServerCommands( context: vscode.ExtensionContext, client: GitPilotApiClient, @@ -72,10 +105,7 @@ export function registerServerCommands( }), vscode.commands.registerCommand('gitpilot.showServerInfo', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected to GitPilot server'); - return; - } + if (!await ensureConnected(client, 'server info')) { return; } try { const settings = await client.getSettings(); @@ -105,10 +135,7 @@ export function registerServerCommands( // ── Provider Selection ───────────────────────────────────── vscode.commands.registerCommand('gitpilot.selectProvider', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected to GitPilot server. Run "GitPilot: Reconnect" first.'); - return; - } + if (!await ensureConnected(client, 'provider selection')) { return; } try { const settings = await client.getSettings(); @@ -146,10 +173,7 @@ export function registerServerCommands( // ── Model Selection ──────────────────────────────────────── vscode.commands.registerCommand('gitpilot.selectModel', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected to GitPilot server.'); - return; - } + if (!await ensureConnected(client, 'model selection')) { return; } try { const settings = await client.getSettings(); @@ -351,10 +375,7 @@ export function registerServerCommands( // ── LLM API Key Configuration ───────────────────────────── vscode.commands.registerCommand('gitpilot.setLlmApiKey', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected to GitPilot server.'); - return; - } + if (!await ensureConnected(client, 'API key configuration')) { return; } try { const settings = await client.getSettings(); @@ -386,10 +407,7 @@ export function registerServerCommands( // ── LLM Base URL Configuration ──────────────────────────── vscode.commands.registerCommand('gitpilot.setLlmBaseUrl', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected to GitPilot server.'); - return; - } + if (!await ensureConnected(client, 'base URL configuration')) { return; } try { const settings = await client.getSettings(); @@ -425,10 +443,7 @@ export function registerServerCommands( }), vscode.commands.registerCommand('gitpilot.selectTopology', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected'); - return; - } + if (!await ensureConnected(client, 'topology selection')) { return; } try { const topologies = await client.listTopologies(); const items = topologies.map(t => ({ @@ -452,10 +467,7 @@ export function registerServerCommands( }), vscode.commands.registerCommand('gitpilot.toggleLiteMode', async () => { - if (!client.isConnected) { - vscode.window.showWarningMessage('Not connected'); - return; - } + if (!await ensureConnected(client, 'Lite Mode')) { return; } try { // Read current state from server const data = await client.request<{ lite_mode: boolean }>('/api/settings/lite-mode'); diff --git a/extensions/vscode/src/commands/skills.ts b/extensions/vscode/src/commands/skills.ts index 76773ff..ea7124e 100644 --- a/extensions/vscode/src/commands/skills.ts +++ b/extensions/vscode/src/commands/skills.ts @@ -43,7 +43,7 @@ export function registerSkillCommands( } chatProvider.sendMessageFromCommand(`/${skillName}`); - vscode.commands.executeCommand('gitpilot.chatView.focus'); + vscode.commands.executeCommand('gitpilot.openChatTab'); }), vscode.commands.registerCommand('gitpilot.installPlugin', async () => { diff --git a/extensions/vscode/src/core/types.ts b/extensions/vscode/src/core/types.ts index 519f85a..3d87adc 100644 --- a/extensions/vscode/src/core/types.ts +++ b/extensions/vscode/src/core/types.ts @@ -18,7 +18,9 @@ export type ProviderName = | "claude" | "watsonx" | "ollama" - | "ollabridge"; + | "ollabridge" + | "openwebui" + | "custom"; export type ProviderConnectionType = | "local" @@ -80,6 +82,9 @@ export interface ChangedFile { reason?: string; hasDiff: boolean; diffPreview?: string; + /** Supplied by the backend when it knows; otherwise read off diffPreview. */ + additions?: number; + deletions?: number; contentPreview?: string; } @@ -310,13 +315,25 @@ export type ExtensionToWebviewMessage = | { type: "PLAN_STEP_UPDATE"; payload: { stepIndex: number; stepTitle: string; action: string; status: string } } | { type: "TERMINAL_OUTPUT"; payload: { stream: "stdout" | "stderr" | "exit"; text: string; exitCode?: number } } | { type: "DIAGNOSTICS_RESULT"; payload: { file?: string; errors: number; warnings: number; entries: Array<{ file: string; line: number; severity: string; message: string }> } } - | { type: "TEST_RESULT"; payload: { framework: string; passed: number; failed: number; skipped: number; exitCode: number } }; + | { type: "TEST_RESULT"; payload: { framework: string; passed: number; failed: number; skipped: number; exitCode: number } } + // ── Composer context: what GitPilot will look at, stated before you send ── + | { + type: "EDITOR_CONTEXT"; + payload: { file?: string; range?: string; text?: string } | undefined; + } + | { type: "FILE_INDEX"; payload: { files: string[] } } + | { type: "ATTACH_CONTEXT_FILE"; payload: { path: string } } + //: A new task begins: the panel drops everything the last one left behind. + | { type: "SESSION_RESET" }; export type WebviewToExtensionMessage = | { type: "INIT" } | { type: "START_SESSION"; payload: { mode: WorkspaceMode } } | { type: "CHANGE_MODE"; payload: { mode: WorkspaceMode } } | { type: "SEND_CHAT"; payload: { text: string } } + | { type: "REQUEST_FILE_INDEX" } + | { type: "REWIND" } + | { type: "PICK_CONTEXT_FILE" } | { type: "RUN_QUICK_ACTION"; payload: { @@ -451,8 +468,154 @@ export interface ProviderTestRequest { api_key?: string; connection_type?: "local" | "api_key" | "pairing"; }; + openwebui?: { + base_url?: string; + model?: string; + api_key?: string; + }; + custom?: { + base_url?: string; + model?: string; + api_key?: string; + headers?: Record; + }; } export interface ProviderTestResponse extends ProviderStatusResponse { details?: string; } + +// ── AI Provider setup (VS Code settings webview) ────────────────────────── +// +// The settings webview never sees a secret. The extension host reads provider +// settings from the GitPilot backend, strips API keys down to a boolean and a +// last-four hint, and sends only that. Keys travel in one direction: from an +// input box, through the host, to the backend. + +/** Which OllaBridge connection method the user is configuring. */ +export type OllaBridgeMode = "cloud" | "api_key" | "local"; + +/** A provider's stored configuration, with secrets replaced by a hint. */ +export interface SanitizedProviderConfig { + model?: string; + base_url?: string; + /** Watsonx only. */ + project_id?: string; + /** + * Custom endpoint only: extra request headers. + * + * These are not secrets — they carry attribution and routing values such as + * a user id — so they round-trip to the webview intact. A header used to + * pass a bearer token belongs in the API key field instead. + */ + headers?: Record; + /** True when the backend holds an API key for this provider. */ + hasApiKey: boolean; + /** Masked tail of the stored key, e.g. "••••A7X2". Never the key itself. */ + apiKeyHint?: string; +} + +/** What the overview page renders for one provider. */ +export interface ProviderOverviewEntry { + name: ProviderName; + label: string; + description: string; + /** Model currently configured, when there is one. */ + model?: string; + active: boolean; + configured: boolean; +} + +/** Everything the provider pages need, refreshed on every load and save. */ +export interface ProviderSetupData { + activeProvider: ProviderName; + providers: ProviderOverviewEntry[]; + configs: Partial>; + /** Which OllaBridge tab the stored configuration corresponds to. */ + ollabridgeMode: OllaBridgeMode; + serverUrl: string; +} + +/** + * Values a provider page submits. + * + * `api_key` follows the "blank means keep" rule: absent or empty leaves the + * stored key untouched, so a page can save a model change without ever having + * held the secret. Clearing a key is an explicit REMOVE_PROVIDER_KEY message. + */ +export interface ProviderConfigInput { + model?: string; + base_url?: string; + project_id?: string; + api_key?: string; + /** Custom endpoint only: extra request headers, replacing what is stored. */ + headers?: Record; + /** OllaBridge only: which tab produced these values. */ + mode?: OllaBridgeMode; +} + +/** Why the provider pages cannot reach the backend right now. */ +export type ServerConnectionState = + | "connecting" + | "starting" + | "online" + | "offline"; + +/** Messages the settings webview sends to the extension host. */ +export type ProviderSettingsMessage = + | { type: "LOAD_PROVIDER_OVERVIEW" } + | { type: "LOAD_PROVIDER_MODELS"; provider: ProviderName; requestId: number; force?: boolean } + | { type: "TEST_PROVIDER"; provider: ProviderName; requestId: number; config: ProviderConfigInput } + | { type: "SAVE_AND_ACTIVATE_PROVIDER"; provider: ProviderName; requestId: number; config: ProviderConfigInput } + | { type: "REMOVE_PROVIDER_KEY"; provider: ProviderName; requestId: number } + | { type: "START_OLLABRIDGE_LOGIN"; baseUrl?: string } + | { type: "PAIR_OLLABRIDGE"; requestId: number; code: string; baseUrl?: string } + | { type: "SIGN_OUT_OLLABRIDGE"; requestId: number } + | { type: "RECONNECT_SERVER" } + | { type: "START_LOCAL_SERVER" } + | { type: "CHANGE_SERVER_URL" } + | { type: "COPY_DIAGNOSTICS" } + | { type: "OPEN_WEB_ADMIN" } + | { type: "OPEN_EXTERNAL"; url: string }; + +/** + * A topology preset: which agents run, in what shape. + * + * `agents_used` is empty for routed topologies — those pick agents per + * request rather than running a fixed sequence. + */ +export interface TopologySummary { + id: string; + name: string; + description: string; + category: "system" | "pipeline" | string; + icon?: string; + agents_used: string[]; + execution_style: string; +} + +// ── MCP servers (VS Code settings webview) ──────────────────────────────── +// +// MCP servers augment what the agents can do: attaching a Postgres server +// gives the Explorer schema discovery and the Coder safe queries, for the +// duration it stays enabled. The settings page is where that surface is +// chosen, so it shows not just "which servers" but "which tools, and which +// agents call them". + +/** Where a server on offer came from. */ +export type McpCatalogSource = "bundled" | "registry"; + +/** Messages the MCP settings pages send to the extension host. */ +export type McpSettingsMessage = + | { type: "LOAD_MCP_OVERVIEW" } + | { type: "OPEN_MCP_SERVER"; serverId: string } + | { type: "SET_MCP_SERVER_ENABLED"; requestId: number; serverId: string; enabled: boolean } + | { type: "SET_MCP_TOOL_ENABLED"; requestId: number; serverId: string; tool: string; enabled: boolean } + | { type: "TEST_MCP_SERVER"; requestId: number; serverId: string } + | { type: "UNINSTALL_MCP_SERVER"; requestId: number; serverId: string } + | { type: "INSTALL_MCP_SERVER"; requestId: number; entryId: string; source: McpCatalogSource } + | { type: "SEARCH_MCP_REGISTRY"; requestId: number; query: string } + | { type: "ADD_CUSTOM_MCP_SERVER"; requestId: number } + | { type: "INSTALL_MCP_FORGE" } + | { type: "SYNC_MCP_GATEWAY"; requestId: number } + | { type: "CONFIGURE_MCP_GATEWAY" }; diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 8f1bb6e..5860e79 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -49,13 +49,19 @@ import { registerSetupCommands } from "./commands/setupCommands"; import { registerProviderCommands } from "./commands/providerCommands"; import { registerMcpGatewayCommands } from "./commands/mcpGatewayCommands"; import { McpGatewayClient } from "./api/mcpGatewayClient"; +import { McpClient } from "./api/mcpClient"; +import { McpForgeInstaller } from "./services/mcp/McpForgeInstaller"; import { registerSessionCommands } from "./commands/sessionCommands"; import { registerChatCommandsV2 } from "./commands/chatCommands"; import { registerPhase4Commands } from "./commands/phase4Commands"; import { StateStore } from "./core/stateStore"; +import { Checkpoint, CheckpointClient } from "./api/checkpointClient"; +import { DiagnosticsService } from "./services/diagnostics/DiagnosticsService"; import { GitPilotEvents } from "./core/events"; +import { GitPilotServerController } from "./services/server/GitPilotServerController"; +import { GitPilotNavView } from "./ui/webview/GitPilotNavView"; import { WorkspaceResolver } from "./services/workspace/workspaceResolver"; import { GitContextService } from "./services/workspace/gitContextService"; import { ModeResolver } from "./services/workspace/modeResolver"; @@ -165,8 +171,64 @@ export function activate(context: vscode.ExtensionContext): void { const sessionClient = new SessionClient(client); const chatClientV2 = new ChatClient(client); const settingsClient = new SettingsClient(client); + // Checkpoints are what make Agent mode a choice rather than a one-way door. + const checkpointClient = new CheckpointClient(client); + + /** + * The answer to "it says connected, so why did that not work?". + * + * Logs every request, notices a backend built from a different commit than + * the extension, and can print the whole picture on demand. + */ + const diagnostics = new DiagnosticsService( + client, + output, + context.extension.packageJSON.version as string + ); + context.subscriptions.push(diagnostics); + + context.subscriptions.push( + vscode.commands.registerCommand("gitpilot.diagnostics", async () => { + const report = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: "Collecting GitPilot diagnostics\u2026" }, + () => diagnostics.report() + ); + + // A document, not the Output channel: it can be selected, copied into an + // issue, and read without scrolling past everything else logged today. + const doc = await vscode.workspace.openTextDocument({ + content: report, + language: "markdown", + }); + await vscode.window.showTextDocument(doc, { preview: false }); + }) + ); const repoClient = new RepoClient(client); + // Owns the local `gitpilot serve` process so the settings page can recover + // from a stopped backend without sending the user to a terminal. + const serverController = new GitPilotServerController(client, output); + + // MCP: attaching servers augments what the agents can do, and the installer + // brings up a Context Forge to attach them to. + const mcpClient = new McpClient(client); + const forgeInstaller = new McpForgeInstaller(output); + + /** Open the branded settings tab, wired to the live backend clients. */ + const openSettingsPanel = async (): Promise => { + const { GitPilotSettingsPanel } = await import( + "./ui/webview/GitPilotSettingsPanel" + ); + GitPilotSettingsPanel.open({ + extensionUri: context.extensionUri, + client, + settingsClient, + serverController, + mcpClient, + forgeInstaller, + }); + }; + const workspaceResolver = new WorkspaceResolver(); const gitContextService = new GitContextService(); const modeResolver = new ModeResolver(); @@ -194,6 +256,8 @@ export function activate(context: vscode.ExtensionContext): void { stateStore, events, workspaceResolver, + serverController, + vscode.commands.registerCommand("gitpilot.openSettings", openSettingsPanel), { dispose: () => { try { @@ -228,6 +292,76 @@ export function activate(context: vscode.ExtensionContext): void { gitpilotPanel.postMessage(message); }; + /** + * Workspace files, for `@` completion and the attach picker. + * + * Cached for a few seconds: `@` filters on every keystroke, and re-globbing + * a large repository per character is the difference between a dropdown that + * feels instant and one that stutters. + */ + const FILE_INDEX_TTL_MS = 15_000; + const FILE_INDEX_EXCLUDE = + "**/{node_modules,.git,dist,out,build,.venv,venv,__pycache__,.next,target,vendor}/**"; + let fileIndexCache: { at: number; files: string[] } | undefined; + + const listWorkspaceFiles = async (): Promise => { + if (fileIndexCache && Date.now() - fileIndexCache.at < FILE_INDEX_TTL_MS) { + return fileIndexCache.files; + } + + const uris = await vscode.workspace.findFiles("**/*", FILE_INDEX_EXCLUDE, 3000); + const files = uris + .map((uri) => vscode.workspace.asRelativePath(uri, false)) + .sort((a, b) => a.localeCompare(b)); + + fileIndexCache = { at: Date.now(), files }; + return files; + }; + + /** + * Mirror the editor selection into the composer. + * + * A single-line caret is not a selection worth attaching — pushing + * "user.ts:42" on every cursor move would make the chip flicker for no + * benefit — so only a real range counts. The body travels with it, capped, + * so the model sees the code rather than a coordinate. + */ + const SELECTION_TEXT_LIMIT = 4000; + + const publishEditorContext = (): void => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.uri.scheme !== "file" || editor.selection.isEmpty) { + postMessageToPanel({ type: "EDITOR_CONTEXT", payload: undefined }); + return; + } + + const sel = editor.selection; + const start = sel.start.line + 1; + const end = sel.end.line + 1; + const text = editor.document.getText(sel); + + postMessageToPanel({ + type: "EDITOR_CONTEXT", + payload: { + file: vscode.workspace.asRelativePath(editor.document.uri, false), + range: start === end ? `${start}` : `${start}-${end}`, + text: + text.length > SELECTION_TEXT_LIMIT + ? `${text.slice(0, SELECTION_TEXT_LIMIT)}\n… (${text.length - SELECTION_TEXT_LIMIT} more characters)` + : text, + }, + }); + }; + + context.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection(publishEditorContext), + vscode.window.onDidChangeActiveTextEditor(publishEditorContext), + // A saved file can change what is worth attaching, and the index is + // cheap to drop. + vscode.workspace.onDidCreateFiles(() => { fileIndexCache = undefined; }), + vscode.workspace.onDidDeleteFiles(() => { fileIndexCache = undefined; }) + ); + const postErrorToPanel = (payload: { code: string; title: string; @@ -1046,6 +1180,20 @@ export function activate(context: vscode.ExtensionContext): void { activeStreamAbort = new AbortController(); const signal = activeStreamAbort.signal; + // Why this path gave up is the single most useful thing to know when a + // question produces no answer, and until now none of it was recorded: + // three different failures all returned a bare `null` and the log said + // only "streaming unavailable". Each one now names itself, with the + // event tally that distinguishes "server refused" from "server answered + // with nothing" — which are the same value here and completely + // different problems. + const startedAt = Date.now(); + const seen: Record = {}; + const elapsed = () => `${((Date.now() - startedAt) / 1000).toFixed(1)}s`; + output.appendLine( + `[GitPilot] stream → POST /api/v2/chat/stream session=${sessionId} intent=${intent ?? "none"} chars=${message.length}` + ); + let res: Response; try { res = await fetch(`${serverUrl}/api/v2/chat/stream`, { @@ -1058,12 +1206,18 @@ export function activate(context: vscode.ExtensionContext): void { }), signal, }); - } catch { + } catch (err: unknown) { // Server doesn't support v2 or network error — fall back + output.appendLine( + `[GitPilot] stream ✗ unreachable after ${elapsed()} (${err instanceof Error ? err.message : String(err)}) → batch` + ); return null; } if (!res.ok || !res.body) { + output.appendLine( + `[GitPilot] stream ✗ HTTP ${res.status}${res.body ? "" : " (no body)"} after ${elapsed()} → batch` + ); return null; } @@ -1091,6 +1245,7 @@ export function activate(context: vscode.ExtensionContext): void { } const type = String(event.type || ""); + seen[type] = (seen[type] || 0) + 1; if (type === "text_delta") { fullText += String(event.text || ""); @@ -1222,7 +1377,28 @@ export function activate(context: vscode.ExtensionContext): void { activeStreamAbort = null; } - return fullText || null; + const tally = Object.entries(seen) + .map(([k, v]) => `${k}=${v}`) + .join(" ") || "no events"; + + if (!fullText) { + // An empty stream is the backend's way of saying "this session is not + // one I can plan for — use the batch endpoint". It is a normal + // handover for folder-only sessions, not a fault, so it is logged as + // a route rather than an error. The tally is what tells the two apart + // when it is a fault: `done=1` alone is the handover, whereas a + // `status_change` run that produced no text is a real failure to + // generate. + output.appendLine( + `[GitPilot] stream → empty after ${elapsed()} (${tally}); backend handed off → batch` + ); + return null; + } + + output.appendLine( + `[GitPilot] stream ✓ ${fullText.length} chars in ${elapsed()} (${tally})` + ); + return fullText; }; const sendChatToBackend = async (rawText: string): Promise => { @@ -1318,14 +1494,17 @@ export function activate(context: vscode.ExtensionContext): void { `[GitPilot] Streamed response received (${streamedText.length} chars). intent=${intent} session=${sessionId}` ); } else { - // Streaming unavailable — fall back to batch mode (original path) - output.appendLine( - "[GitPilot] SSE streaming unavailable, falling back to batch mode" - ); + // Streaming produced nothing — the real answer comes from the batch + // endpoint. Keep the thinking state up for it: this call is the slow + // one, routinely 20-40s against a local model. + // + // The backend no longer reports "done" for a stream that did no + // work, so this is a continuation rather than the repair of a + // premature completion. It stays explicit because the stream may + // still have advanced the status to "planning" before handing off. + const batchStartedAt = Date.now(); + output.appendLine("[GitPilot] batch → POST /api/chat/send"); - // The v2 stream's cleanup already set task status to "done" and - // the webview cleared the thinking animation. Re-activate both - // so the user sees the thinking bubble during the 30s+ batch call. stateStore.updateActiveTask({ ...(stateStore.state.activeTask || {}), status: "generating", @@ -1381,7 +1560,10 @@ export function activate(context: vscode.ExtensionContext): void { }); output.appendLine( - `[GitPilot] Chat response received. intent=${intent} session=${sessionId}` + `[GitPilot] batch ✓ ${(response.answer || "").length} chars in ` + + `${((Date.now() - batchStartedAt) / 1000).toFixed(1)}s ` + + `(plan=${normalizedPlan ? "yes" : "no"} edits=${responseEdits.length}) ` + + `intent=${intent} session=${sessionId}` ); } } catch (error: unknown) { @@ -1539,6 +1721,34 @@ export function activate(context: vscode.ExtensionContext): void { await sendChatToBackend(msg.payload.text); return; + case "REQUEST_FILE_INDEX": + postMessageToPanel({ + type: "FILE_INDEX", + payload: { files: await listWorkspaceFiles() }, + }); + return; + + case "PICK_CONTEXT_FILE": { + const files = await listWorkspaceFiles(); + if (files.length === 0) { + void vscode.window.showInformationMessage( + "No files in this workspace to attach." + ); + return; + } + const picked = await vscode.window.showQuickPick(files, { + title: "Attach a file to the context", + placeHolder: "Type to filter…", + }); + if (picked) { + postMessageToPanel({ + type: "ATTACH_CONTEXT_FILE", + payload: { path: picked }, + }); + } + return; + } + case "RUN_QUICK_ACTION": { const prompt = await buildQuickActionPrompt( msg.payload.action as QuickActionId @@ -1552,11 +1762,9 @@ export function activate(context: vscode.ExtensionContext): void { return; } - case "OPEN_SETTINGS": { - const { GitPilotSettingsPanel } = await import("./ui/webview/GitPilotSettingsPanel"); - GitPilotSettingsPanel.open(context.extensionUri); + case "OPEN_SETTINGS": + await openSettingsPanel(); return; - } case "OPEN_WORKSPACE": await vscode.commands.executeCommand( @@ -1564,20 +1772,13 @@ export function activate(context: vscode.ExtensionContext): void { ); return; + // Provider, model and admin entry points all land on the same + // integrated settings page. Nothing here opens a browser. case "OPEN_ADMIN_UI": - await vscode.commands.executeCommand("gitpilot.showServerInfo"); - return; - case "OPEN_PROVIDER_SETUP": - await vscode.commands.executeCommand("gitpilot.selectProviderV2"); - return; - case "OPEN_MODEL_SETUP": - await vscode.commands.executeCommand("gitpilot.selectModelV2"); - return; - case "OPEN_LLM_SETTINGS": - await vscode.commands.executeCommand("gitpilot.openLlmSettings"); + await openSettingsPanel(); return; case "SET_WORKFLOW_MODE": { @@ -1672,6 +1873,10 @@ export function activate(context: vscode.ExtensionContext): void { await vscode.commands.executeCommand("gitpilot.revertProposedChanges"); return; + case "REWIND": + await vscode.commands.executeCommand("gitpilot.rewind"); + return; + case "REVEAL_FILE": { const folderPath = currentWorkspaceRoot(); if (!folderPath) { @@ -1762,14 +1967,9 @@ export function activate(context: vscode.ExtensionContext): void { ...(stateStore.state.activeTask || {}), status: "generating", }); - // Execute via the existing chat send path with plan context - try { - await vscode.commands.executeCommand("gitpilot.executeApprovedPlan"); - } catch { - // If no dedicated command exists, fall through — the status - // change to "generating" will be picked up by the stream - output.appendLine("[GitPilot] Plan execution delegated to active stream"); - } + // The approval is the trigger; there is no other stream to + // delegate to, which is what the old catch here assumed. + await vscode.commands.executeCommand("gitpilot.executeApprovedPlan"); } return; } @@ -1801,11 +2001,36 @@ export function activate(context: vscode.ExtensionContext): void { } ); + // The sidebar is navigation only; the workspace panel below it keeps every + // feature and animation it already had. + const navView = new GitPilotNavView({ + extensionUri: context.extensionUri, + client, + sessionClient, + stateStore, + serverController, + }); + + /** + * The editor is the product surface, and there is exactly one of it. + * + * Empty, the tab is GitPilot Home — brand, "What are we building?", one + * composer. Send a message and the same tab is the conversation. Two + * surfaces each with their own composer only ever raised the question of + * which one was real. + */ + const openChatTab = async (): Promise => { + gitpilotPanel.openInEditor(); + }; + context.subscriptions.push( gitpilotPanel, + vscode.commands.registerCommand("gitpilot.openChatTab", openChatTab), + // Home and Chat are the same tab at two moments, so they open the same way. + vscode.commands.registerCommand("gitpilot.openHome", openChatTab), vscode.window.registerWebviewViewProvider( - GitPilotPanel.viewType, - gitpilotPanel + GitPilotNavView.viewType, + navView ) ); @@ -1832,7 +2057,25 @@ export function activate(context: vscode.ExtensionContext): void { ) ); - registerChatCommands(context, client, legacyChatProvider); + registerChatCommands(context, client, legacyChatProvider, { + stateStore, + sessionCoordinator, + modeResolver, + /** + * Start the new task from a genuinely clean panel. + * + * Aborting first matters: a run still streaming into the old conversation + * would carry on writing into the new one, because the panel starts a + * fresh streaming node the moment a chunk arrives with none open. + */ + resetPanel: () => { + if (activeStreamAbort) { + activeStreamAbort.abort(); + activeStreamAbort = null; + } + postMessageToPanel({ type: "SESSION_RESET" }); + }, + }); registerReviewCommands(context, legacyChatProvider); registerSecurityCommands(context, securityProvider); registerSkillCommands(context, client, legacyChatProvider); @@ -1880,7 +2123,7 @@ export function activate(context: vscode.ExtensionContext): void { } legacyChatProvider.sendMessageFromCommand(`Run this command: ${command}`); - await vscode.commands.executeCommand("gitpilot.chatView.focus"); + await vscode.commands.executeCommand("gitpilot.openChatTab"); }); registerCommand("gitpilot.setupWizard", async () => { @@ -1969,6 +2212,204 @@ export function activate(context: vscode.ExtensionContext): void { } }); + /** + * Put the workspace and the conversation back to a checkpoint. + * + * GitPilot snapshots before every mutating tool call, so this is the undo + * that makes Agent mode a choice rather than a one-way door. Both halves go + * back together — restoring files under a transcript that still describes + * the work would leave the model reasoning about edits that no longer exist. + */ + const rewindToCheckpoint = async (checkpoint: Checkpoint): Promise => { + const sessionId = stateStore.state.session.sessionId; + if (!sessionId) { + return; + } + + const scope = checkpoint.has_files + ? "This restores your files and rewinds the conversation." + : "This workspace was too large to snapshot, so only the conversation rewinds. Your files are left alone."; + + const confirm = await vscode.window.showWarningMessage( + `Rewind to "${checkpoint.description}"?`, + { modal: true, detail: `${scope}\n\nWork done after this point is discarded.` }, + "Rewind" + ); + if (confirm !== "Rewind") { + return; + } + + try { + const result = await checkpointClient.rewind(sessionId, checkpoint.id); + + stateStore.setChatMessages( + (result.messages || []).map((m, index) => ({ + id: `${sessionId}-rewind-${index}`, + role: m.role === "user" || m.role === "assistant" ? m.role : "system", + content: m.content ?? "", + createdAt: m.timestamp || new Date().toISOString(), + })) + ); + // The plan, diff and changed-file list all described work that has just + // been undone; leaving them on screen would be a lie. + stateStore.clearTaskState(); + + void vscode.commands.executeCommand("gitpilot.refreshProjectContext"); + vscode.window.showInformationMessage( + checkpoint.has_files + ? `Rewound to "${checkpoint.description}".` + : `Rewound the conversation to "${checkpoint.description}". Files were not snapshotted.` + ); + } catch (err) { + appendOutputError("[GitPilot] Rewind failed", err); + vscode.window.showErrorMessage(`Could not rewind: ${err}`); + } + }; + + /** "2:34 PM" today, otherwise a short date — same rule as the sidebar. */ + const checkpointWhen = (iso: string): string => { + const then = new Date(iso); + if (Number.isNaN(then.getTime())) { + return ""; + } + const sameDay = then.toDateString() === new Date().toDateString(); + return sameDay + ? then.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) + : then.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + }; + + const pickCheckpoint = async (): Promise => { + const sessionId = stateStore.state.session.sessionId; + if (!sessionId) { + vscode.window.showInformationMessage( + "No active GitPilot session, so there is nothing to rewind." + ); + return undefined; + } + + let checkpoints: Checkpoint[]; + try { + checkpoints = await checkpointClient.list(sessionId); + } catch (err) { + appendOutputError("[GitPilot] Could not list checkpoints", err); + vscode.window.showErrorMessage(`Could not load checkpoints: ${err}`); + return undefined; + } + + if (checkpoints.length === 0) { + vscode.window.showInformationMessage( + "No checkpoints yet. GitPilot takes one before every change it makes." + ); + return undefined; + } + + const picked = await vscode.window.showQuickPick( + checkpoints.map((cp) => ({ + label: cp.description || cp.tool_name, + description: checkpointWhen(cp.timestamp), + detail: cp.has_files + ? `Restores files · rewinds to message ${cp.message_index}` + : "Conversation only — the workspace was too large to snapshot", + checkpoint: cp, + })), + { title: "Rewind GitPilot", placeHolder: "Pick a point to go back to" } + ); + + return picked?.checkpoint; + }; + + registerCommand("gitpilot.rewind", async () => { + const checkpoint = await pickCheckpoint(); + if (checkpoint) { + await rewindToCheckpoint(checkpoint); + } + }); + + /** + * Undo the last thing GitPilot changed. + * + * The Revert button has been in the chat panel all along, wired to a command + * that was never registered — so it silently did nothing. With checkpoints + * it has an honest meaning: go back to the snapshot taken before the most + * recent change. + */ + registerCommand("gitpilot.revertProposedChanges", async () => { + const task = stateStore.state.activeTask; + + // Changes that were only ever proposed are still just a pending edit list; + // dropping it is the whole revert, and no checkpoint is needed. + if ((task?.edits || []).length > 0 && task?.status !== "applying") { + stateStore.updateActiveTask({ + ...(stateStore.state.activeTask || {}), + edits: [], + changedFiles: [], + status: "idle", + summary: "Proposed changes discarded.", + }); + vscode.window.showInformationMessage("Discarded the proposed changes."); + return; + } + + const sessionId = stateStore.state.session.sessionId; + if (!sessionId) { + vscode.window.showInformationMessage("Nothing to revert."); + return; + } + + let checkpoints: Checkpoint[]; + try { + checkpoints = await checkpointClient.list(sessionId); + } catch (err) { + appendOutputError("[GitPilot] Could not list checkpoints", err); + vscode.window.showErrorMessage(`Could not load checkpoints: ${err}`); + return; + } + + const latest = checkpoints.find((cp) => cp.has_files) || checkpoints[0]; + if (!latest) { + vscode.window.showInformationMessage( + "No checkpoint to revert to — GitPilot has not changed anything in this session." + ); + return; + } + + await rewindToCheckpoint(latest); + }); + + /** + * Run the plan the user just approved. + * + * "Approve & Execute" dispatched this command, which nobody had registered. + * The `catch` around the dispatch logged "delegated to active stream" and + * moved on — so the task sat at `generating` while nothing executed. There + * is no other stream to delegate to; the approval *is* the trigger. + */ + registerCommand("gitpilot.executeApprovedPlan", async () => { + const plan = stateStore.state.activeTask?.plan; + const steps = plan?.steps || []; + + if (steps.length === 0) { + vscode.window.showInformationMessage("No approved plan to execute."); + stateStore.setTaskStatus("idle"); + return; + } + + const numbered = steps + .map((step, index) => { + const detail = + typeof step === "string" + ? step + : step.title || step.description || step.action || ""; + return `${index + 1}. ${detail}`; + }) + .filter((line) => line.trim().length > 3) + .join("\n"); + + await sendChatToBackend( + `[Execute approved plan] The plan below was approved. Carry it out step by step.\n\n${numbered}` + ); + }); + registerCommand("gitpilot.regenerateTaskPlan", async () => { const task = stateStore.state.activeTask; if (!task?.title && !task?.summary) { @@ -2037,17 +2478,47 @@ export function activate(context: vscode.ExtensionContext): void { } if (config.autoConnect) { + // Connecting once and giving up was the whole procedure. Now a server + // that is merely slow to start gets waited for, and one that is not + // running yet is watched for — so starting `gitpilot serve` in a terminal + // is enough on its own, with no trip back here to click Reconnect. + // The listener is registered before connect() resolves, so it would also + // see the first success. One bootstrap per arrival, not two. + let bootstrapped = false; + const bootstrapOnce = (reason: string): void => { + if (bootstrapped) { + return; + } + bootstrapped = true; + void refreshStatusAndBootstrap(reason); + }; + + context.subscriptions.push( + client.onStateChange((state) => { + if (state === "connected") { + bootstrapOnce("reconnected"); + } else if (state === "disconnected") { + // Ready to bootstrap again when it comes back. + bootstrapped = false; + } + }) + ); + void client.connect().then((connected) => { if (connected) { output.appendLine( `[GitPilot] Connected to server at ${config.serverUrl}` ); - void refreshStatusAndBootstrap("auto-connect"); - } else { - output.appendLine( - `[GitPilot] Auto-connect failed for ${config.serverUrl}` - ); + bootstrapOnce("auto-connect"); + return; } + + const probe = client.lastProbe; + output.appendLine( + `[GitPilot] Auto-connect failed for ${config.serverUrl}` + + (probe ? ` (${probe.outcome} after ${probe.elapsedMs}ms)` : "") + ); + client.startAutoReconnect(); }); } @@ -2127,6 +2598,24 @@ export function activate(context: vscode.ExtensionContext): void { void logCommandAvailability(); + /* + * Open GitPilot only into an empty editor area. + * + * The landing page is the product's front door, but a front door that opens + * on top of the file someone was reading is an interruption. So it appears + * on a fresh window and stays out of the way otherwise — the sidebar's + * New Task and the command palette are always there. + */ + const editorIsEmpty = vscode.window.tabGroups.all.every( + (group) => group.tabs.length === 0 + ); + if ( + editorIsEmpty && + vscode.workspace.getConfiguration("gitpilot").get("showHomeOnStartup", true) + ) { + void openChatTab(); + } + output.appendLine("[GitPilot] Extension activated."); } diff --git a/extensions/vscode/src/services/context/projectContextService.ts b/extensions/vscode/src/services/context/projectContextService.ts index 4d136a2..af264b6 100644 --- a/extensions/vscode/src/services/context/projectContextService.ts +++ b/extensions/vscode/src/services/context/projectContextService.ts @@ -29,8 +29,34 @@ const DEFAULT_IGNORES = new Set([ "coverage", ".turbo", ".cache", + // Everything below was missing, and the tree budget is small enough that + // a few hundred cache entries are the difference between the model seeing + // your source and describing your tooling. `.pytest_cache` and + // `.ruff_cache` alone took 14 of 300 slots in the repository this was + // diagnosed on. + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".tox", + ".eggs", + ".gradle", + ".idea", + ".svelte-kit", + ".parcel-cache", + ".terraform", + "out", + "target", + "vendor", + "htmlcov", + "site-packages", + "env", + ".env", ]); +/** Depth-1 directories are always worth showing; deep ones compete. */ +const ROOT_TREE_RESERVE = 0.4; + const MANIFESTS = new Set([ "package.json", "package-lock.json", @@ -155,26 +181,74 @@ export class ProjectContextService { }; } + /** + * A breadth-first sample of the tree, capped at `treeLimit`. + * + * Breadth-first is the whole point. The previous walk recursed into each + * directory the moment it found one and stopped dead at the limit, so the + * budget went to whichever directory `readdir` happened to return first. + * Measured on the repository this was diagnosed against: `extensions/` + * took 171 of 300 entries and `docs/` another 65, which left no room for + * the Python package that *is* the application — `pyproject.toml` and + * every file under `gitpilot/` were absent from the context entirely. + * + * A model handed that listing does not know it is looking at a fraction + * of a repository. It answers confidently about what it was shown, which + * is how "explain this project's architecture" came back describing a + * dependency instead of the project. + * + * Level by level, the root is always represented, and truncation costs + * depth rather than whole top-level subtrees. + */ private scanTree(root: string): ProjectTreeEntry[] { const results: ProjectTreeEntry[] = []; - const walk = (current: string) => { - if (results.length >= this.treeLimit) return; - const entries = fs.readdirSync(current, { withFileTypes: true }); - for (const entry of entries) { - if (results.length >= this.treeLimit) return; - if (DEFAULT_IGNORES.has(entry.name)) continue; - const absolute = path.join(current, entry.name); - const relative = path.relative(root, absolute).replace(/\\/g, "/"); - if (!relative) continue; - if (entry.isDirectory()) { - results.push({ path: relative, type: "dir" }); - walk(absolute); - } else if (entry.isFile()) { - results.push({ path: relative, type: "file" }); + const seen = new Set(); + let frontier: string[] = [root]; + + const push = (relative: string, type: "dir" | "file"): boolean => { + if (!relative || seen.has(relative)) return true; + seen.add(relative); + results.push({ path: relative, type }); + return results.length < this.treeLimit; + }; + + while (frontier.length > 0 && results.length < this.treeLimit) { + const nextFrontier: string[] = []; + + for (const current of frontier) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + // Unreadable directory (permissions, a broken symlink, a mount + // that went away). One bad directory must not cost the scan. + continue; + } + + // Files before directories at each level: a manifest or README is + // worth more to a reader than one more folder name, and `keyFiles` + // and `readmePreview` are both derived from this list. + const dirs = entries.filter((e) => e.isDirectory()); + const files = entries.filter((e) => e.isFile()); + + for (const entry of [...files, ...dirs]) { + if (DEFAULT_IGNORES.has(entry.name)) continue; + const absolute = path.join(current, entry.name); + const relative = path.relative(root, absolute).replace(/\\/g, "/"); + if (!relative) continue; + + if (entry.isDirectory()) { + if (!push(relative, "dir")) return results; + nextFrontier.push(absolute); + } else if (!push(relative, "file")) { + return results; + } } } - }; - walk(root); + + frontier = nextFrontier; + } + return results; } diff --git a/extensions/vscode/src/services/diagnostics/DiagnosticsService.ts b/extensions/vscode/src/services/diagnostics/DiagnosticsService.ts new file mode 100644 index 0000000..66d2a3a --- /dev/null +++ b/extensions/vscode/src/services/diagnostics/DiagnosticsService.ts @@ -0,0 +1,285 @@ +/** + * What GitPilot is actually doing, and what it actually failed at. + * + * Every hard-to-diagnose report in this project has had the same shape: the + * interface said one thing and the truth was somewhere the developer could not + * see. A backend older than the extension. A provider that was configured but + * could not answer. A request that took ten seconds and was abandoned at + * three. None of that was visible from inside VS Code, so debugging meant a + * terminal, a server log, and guesses. + * + * This collects it in one place: the recent request log, the extension and + * backend versions, and the backend's own account of whether it can answer. + */ +import * as vscode from "vscode"; + +import { GitPilotApiClient, RequestRecord } from "../../api/client"; + +/** The backend's `/api/diagnostics` payload. Every field is best-effort. */ +export interface BackendDiagnostics { + version?: string; + python?: { version?: string; executable?: string }; + package_path?: string; + chat?: { path?: string; ready?: boolean; detail?: string }; + provider?: { + name?: string; + model?: string; + endpoint?: string; + configured?: boolean; + }; + runtimes?: Record; +} + +/** How many calls to keep. Enough to cover a failed task, not a memory leak. */ +const HISTORY_LIMIT = 100; + +/** Anything slower than this is worth pointing at in the log. */ +export const SLOW_REQUEST_MS = 3000; + +export class DiagnosticsService implements vscode.Disposable { + private readonly _history: RequestRecord[] = []; + private readonly _disposables: vscode.Disposable[] = []; + private _backendVersion: string | undefined; + private _warnedAboutVersion = false; + + constructor( + private readonly client: GitPilotApiClient, + private readonly output: vscode.OutputChannel, + private readonly extensionVersion: string + ) { + this._disposables.push( + client.onRequest((record) => this._record(record)), + client.onStateChange((state) => { + this.output.appendLine(`[conn] ${state}`); + if (state === "connected") { + void this.checkVersion(); + } + }) + ); + } + + /** The last requests, newest last. */ + get history(): readonly RequestRecord[] { + return this._history; + } + + get backendVersion(): string | undefined { + return this._backendVersion; + } + + private _record(record: RequestRecord): void { + this._history.push(record); + if (this._history.length > HISTORY_LIMIT) { + this._history.shift(); + } + + // One line per call, so the Output channel is a usable trace rather + // than the near-silence it was. + const ms = `${record.durationMs}ms`; + if (record.error) { + this.output.appendLine( + `[api] ✗ ${record.method} ${record.path} ${ms} — ${record.error}` + ); + } else if (record.durationMs >= SLOW_REQUEST_MS) { + this.output.appendLine( + `[api] 🐢 ${record.method} ${record.path} ${ms} (${record.status})` + ); + } else { + this.output.appendLine( + `[api] ${record.method} ${record.path} ${ms} (${record.status})` + ); + } + } + + /** + * Warn when the backend is not the version this extension was built for. + * + * This is the check that would have saved the most time: an extension + * rebuilt from a fresh checkout, talking to a backend still installed from + * an older one, looks completely healthy — and then one feature fails for + * reasons nothing on screen explains. `make extension-dev` never touches + * the Python package, and `gitpilot` is a console script that imports from + * site-packages rather than the folder you are standing in, so the two + * halves drift apart quietly. + */ + async checkVersion(): Promise { + let version: string | undefined; + try { + const health = await this.client.request<{ version?: string }>( + "/api/health", + { timeoutMs: 10000, retries: 0 } + ); + version = health?.version; + } catch { + return; // Connection problems are the connection layer's story. + } + + this._backendVersion = version; + + if (!version) { + // A backend too old to report its version is itself the answer. + this.output.appendLine( + "[version] backend did not report a version — it predates 0.2.8" + ); + this._warnMismatch("older than 0.2.8"); + return; + } + + this.output.appendLine( + `[version] extension ${this.extensionVersion}, backend ${version}` + ); + + if (version !== this.extensionVersion) { + this._warnMismatch(version); + } + } + + private _warnMismatch(backend: string): void { + this.output.appendLine( + `[version] ⚠ mismatch: extension ${this.extensionVersion}, backend ${backend}` + ); + + // Once per session. A modal every reconnect would be worse than the + // problem it reports. + if (this._warnedAboutVersion) { + return; + } + this._warnedAboutVersion = true; + + void vscode.window + .showWarningMessage( + `GitPilot backend is ${backend} but the extension is ` + + `${this.extensionVersion}. Features can misbehave in ways ` + + `nothing on screen explains.`, + "How to fix", + "Show diagnostics" + ) + .then((choice) => { + if (choice === "Show diagnostics") { + void vscode.commands.executeCommand("gitpilot.diagnostics"); + } else if (choice === "How to fix") { + void vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/ruslanmv/gitpilot/blob/main/docs/vscode/troubleshooting.md" + ) + ); + } + }); + } + + /** Ask the backend to account for itself. */ + async fetchBackend(): Promise { + try { + return await this.client.request( + "/api/diagnostics", + { timeoutMs: 10000, retries: 0 } + ); + } catch { + return undefined; + } + } + + /** + * The whole picture as text, ready to read or paste into an issue. + * + * Deliberately plain: the point is that it can be copied into a bug report + * without the reporter having to know which parts matter. + */ + async report(): Promise { + const backend = await this.fetchBackend(); + const probe = this.client.lastProbe; + const lines: string[] = []; + + lines.push("GitPilot diagnostics"); + lines.push("=".repeat(60)); + lines.push(""); + + lines.push("Versions"); + lines.push(` extension ${this.extensionVersion}`); + lines.push(` backend ${backend?.version ?? this._backendVersion ?? "unknown"}`); + if (backend?.version && backend.version !== this.extensionVersion) { + lines.push(" ⚠ MISMATCH — the two halves are from different builds."); + lines.push(" `make extension-dev` rebuilds only the extension."); + lines.push(" Reinstall the backend: pip install -e . --no-deps"); + } + lines.push(""); + + lines.push("Connection"); + lines.push(` server url ${this.client.serverUrl}`); + lines.push(` state ${this.client.state}`); + if (probe) { + lines.push(` last probe ${probe.outcome} after ${probe.elapsedMs}ms`); + } + lines.push(` retrying ${this.client.isRetrying ? "yes" : "no"}`); + lines.push(""); + + if (backend) { + lines.push("Chat"); + lines.push(` path ${backend.chat?.path ?? "unknown"}`); + lines.push(` can answer ${backend.chat?.ready ? "yes" : "no"}`); + if (backend.chat?.detail) { + lines.push(` detail ${backend.chat.detail}`); + } + lines.push(""); + + lines.push("Provider"); + lines.push(` name ${backend.provider?.name ?? "unknown"}`); + lines.push(` model ${backend.provider?.model || "(none selected)"}`); + lines.push(` endpoint ${backend.provider?.endpoint || "(n/a)"}`); + lines.push(` configured ${backend.provider?.configured ? "yes" : "no"}`); + lines.push(""); + + lines.push("Backend environment"); + lines.push(` python ${backend.python?.version ?? "?"}`); + lines.push(` interpreter ${backend.python?.executable ?? "?"}`); + // The one that explains "I pulled but nothing changed": a console + // script imports from site-packages, not the folder you are in. + lines.push(` package path ${backend.package_path ?? "?"}`); + const runtimes = backend.runtimes ?? {}; + const names = Object.keys(runtimes); + if (names.length) { + lines.push( + ` runtimes ${names + .map((n) => `${n}=${runtimes[n] ? "yes" : "no"}`) + .join(" ")}` + ); + } + lines.push(""); + } else { + lines.push("Backend"); + lines.push(" /api/diagnostics did not answer."); + lines.push(" Either the server is down, or it predates 0.2.8."); + lines.push(""); + } + + lines.push(`Recent requests (${this._history.length})`); + if (this._history.length === 0) { + lines.push(" none yet"); + } else { + for (const r of this._history.slice(-25)) { + const mark = r.error ? "✗" : r.durationMs >= SLOW_REQUEST_MS ? "🐢" : " "; + lines.push( + ` ${mark} ${String(r.durationMs).padStart(6)}ms ` + + `${r.method.padEnd(6)} ${r.path}` + + (r.error ? ` — ${r.error}` : ` (${r.status})`) + ); + } + } + lines.push(""); + + const failures = this._history.filter((r) => r.error); + const slow = this._history.filter((r) => !r.error && r.durationMs >= SLOW_REQUEST_MS); + lines.push("Summary"); + lines.push(` requests ${this._history.length}`); + lines.push(` failed ${failures.length}`); + lines.push(` slow (>${SLOW_REQUEST_MS}ms) ${slow.length}`); + + return lines.join("\n"); + } + + dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + } +} diff --git a/extensions/vscode/src/services/mcp/McpForgeInstaller.ts b/extensions/vscode/src/services/mcp/McpForgeInstaller.ts new file mode 100644 index 0000000..2c5a602 --- /dev/null +++ b/extensions/vscode/src/services/mcp/McpForgeInstaller.ts @@ -0,0 +1,468 @@ +/** + * McpForgeInstaller — brings up MCP Context Forge from the editor. + * + * Attaching an MCP server needs a gateway to attach it to, and today getting + * one means a terminal, a compose file, and an env file. That is a reasonable + * ask of an operator and an unreasonable one of somebody who just wants their + * agents to see a database schema. This service closes that gap. + * + * It runs in the extension host rather than the backend for two reasons: the + * backend may itself be containerised and have no Docker socket, and progress + * from a multi-minute image pull belongs in the editor's output channel where + * the user is already looking. + * + * Two paths, chosen by what is actually on disk: + * + * - **Repo checkout** — `docker-compose.mcp.yml` in the workspace. Uses the + * project's own compose stack, which builds Forge plus the reference + * servers from pinned upstreams. + * - **Package install** — no checkout, which is what `pip install gitcopilot` + * leaves you with. Runs the published Forge image directly. + * + * Nothing here is silent: every command and its output is written to the + * GitPilot output channel, and every failure returns a sentence naming what to + * do about it. + */ +import * as vscode from "vscode"; +import * as cp from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; + +/** Forge's default port, and the one GitPilot's gateway config expects. */ +export const FORGE_DEFAULT_PORT = 4444; + +/** Container name, so a re-run adopts the existing container. */ +const CONTAINER_NAME = "gitpilot-mcp-context-forge"; + +/** Published image used when there is no repo checkout to build from. */ +const DEFAULT_FORGE_IMAGE = "ghcr.io/ibm/mcp-context-forge:latest"; + +const HEALTH_TIMEOUT_MS = 240_000; +const HEALTH_POLL_MS = 2_000; + +export type ForgeInstallStage = + | "checking" + | "starting" + | "waiting" + | "registering" + | "done" + | "failed"; + +export interface ForgeInstallProgress { + stage: ForgeInstallStage; + message: string; +} + +export type ForgeInstallResult = + | { ok: true; gatewayUrl: string; mode: "compose" | "container" } + | { ok: false; reason: string; hint?: string }; + +export interface PreflightReport { + dockerAvailable: boolean; + daemonRunning: boolean; + composeAvailable: boolean; + composeFile?: string; + detail: string; +} + +export class McpForgeInstaller { + private running: Promise | undefined; + + constructor(private readonly output: vscode.OutputChannel) {} + + /** True while an install is in flight, so the UI can disable its button. */ + get isRunning(): boolean { + return this.running !== undefined; + } + + /** The port Forge will be reachable on. */ + get port(): number { + return vscode.workspace + .getConfiguration("gitpilot") + .get("mcp.forgePort", FORGE_DEFAULT_PORT); + } + + get gatewayUrl(): string { + return `http://localhost:${this.port}`; + } + + private get image(): string { + return ( + vscode.workspace + .getConfiguration("gitpilot") + .get("mcp.forgeImage", DEFAULT_FORGE_IMAGE) + .trim() || DEFAULT_FORGE_IMAGE + ); + } + + /** + * What is available before anything is started. + * + * Reported rather than assumed: "Docker is not running" is a far more + * useful message than a compose error five minutes into a build. + */ + async preflight(): Promise { + const dockerAvailable = (await this.run("docker", ["--version"])).code === 0; + if (!dockerAvailable) { + return { + dockerAvailable: false, + daemonRunning: false, + composeAvailable: false, + detail: + "Docker is not installed or not on PATH. MCP Context Forge runs as a container.", + }; + } + + const daemonRunning = (await this.run("docker", ["info"])).code === 0; + if (!daemonRunning) { + return { + dockerAvailable: true, + daemonRunning: false, + composeAvailable: false, + detail: "Docker is installed but the daemon is not running. Start Docker and retry.", + }; + } + + const composeAvailable = + (await this.run("docker", ["compose", "version"])).code === 0; + const composeFile = this.findComposeFile(); + + return { + dockerAvailable: true, + daemonRunning: true, + composeAvailable, + composeFile, + detail: composeFile + ? `Ready. Using the project's MCP stack at ${path.basename(composeFile)}.` + : `Ready. Will run ${this.image}.`, + }; + } + + /** + * Start Forge and wait until it answers. + * + * Concurrent callers share one attempt — a settings page that retries while + * an install is in flight must not start a second container. + */ + async install( + onProgress: (progress: ForgeInstallProgress) => void, + token?: vscode.CancellationToken + ): Promise { + if (this.running) { + return this.running; + } + this.running = this.doInstall(onProgress, token).finally(() => { + this.running = undefined; + }); + return this.running; + } + + private async doInstall( + onProgress: (progress: ForgeInstallProgress) => void, + token?: vscode.CancellationToken + ): Promise { + onProgress({ stage: "checking", message: "Checking Docker…" }); + + const report = await this.preflight(); + if (!report.dockerAvailable || !report.daemonRunning) { + return { + ok: false, + reason: report.detail, + hint: "https://docs.docker.com/get-docker/", + }; + } + + // Something may already be listening — another window, an earlier run. + if (await this.isHealthy()) { + this.log(`Forge already answering on ${this.gatewayUrl}`); + onProgress({ stage: "done", message: "MCP Context Forge is already running." }); + return { ok: true, gatewayUrl: this.gatewayUrl, mode: "container" }; + } + + const useCompose = Boolean(report.composeFile && report.composeAvailable); + onProgress({ + stage: "starting", + message: useCompose + ? "Starting the project's MCP stack (this can take several minutes on first run)…" + : `Starting ${this.image}…`, + }); + + const started = useCompose + ? await this.startWithCompose(report.composeFile!) + : await this.startContainer(); + + if (!started.ok) { + return started; + } + + onProgress({ stage: "waiting", message: "Waiting for Context Forge to become ready…" }); + const ready = await this.waitForHealth(token); + if (!ready.ok) { + return ready; + } + + return { + ok: true, + gatewayUrl: this.gatewayUrl, + mode: useCompose ? "compose" : "container", + }; + } + + // ── Start strategies ──────────────────────────────────────────────── + + private async startWithCompose(composeFile: string): Promise { + const cwd = path.dirname(composeFile); + const envFile = path.join(cwd, ".mcp.env"); + + // The compose file refuses to start without MCP_AUTH_TOKEN — it is the + // JWT signing secret, and a shared default would be a real weakness. Seed + // a per-install random one rather than making the user invent it. + if (!fs.existsSync(envFile)) { + try { + fs.writeFileSync( + envFile, + [ + "# Written by the GitPilot VS Code extension.", + "# MCP_AUTH_TOKEN signs Forge's tokens. It is never sent anywhere.", + `MCP_AUTH_TOKEN=${crypto.randomBytes(32).toString("hex")}`, + "MCP_FORGE_ADMIN_EMAIL=admin@example.com", + "", + ].join("\n"), + { mode: 0o600 } + ); + this.log(`Seeded ${envFile} with a fresh signing secret`); + } catch (err) { + return { + ok: false, + reason: `Could not write ${envFile}: ${describe(err)}`, + }; + } + } + + const result = await this.run( + "docker", + [ + "compose", + "-f", + path.basename(composeFile), + "--profile", + "mcp", + "up", + "-d", + "mcp-context-forge", + ], + { cwd, timeoutMs: HEALTH_TIMEOUT_MS } + ); + + if (result.code !== 0) { + return { + ok: false, + reason: + "docker compose could not start MCP Context Forge. " + + "See the GitPilot output channel for the full log.", + }; + } + return { ok: true, gatewayUrl: this.gatewayUrl, mode: "compose" }; + } + + private async startContainer(): Promise { + // Adopt a stopped container from an earlier run rather than colliding + // with its name. + const existing = await this.run("docker", [ + "ps", + "-aq", + "-f", + `name=^${CONTAINER_NAME}$`, + ]); + if (existing.code === 0 && existing.stdout.trim()) { + this.log(`Reusing existing container ${CONTAINER_NAME}`); + const restarted = await this.run("docker", ["start", CONTAINER_NAME]); + if (restarted.code === 0) { + return { ok: true, gatewayUrl: this.gatewayUrl, mode: "container" }; + } + // Fall through and recreate: a container that will not start is worse + // than no container. + await this.run("docker", ["rm", "-f", CONTAINER_NAME]); + } + + const secret = crypto.randomBytes(32).toString("hex"); + const result = await this.run( + "docker", + [ + "run", + "-d", + "--name", + CONTAINER_NAME, + "--restart", + "unless-stopped", + "-p", + `${this.port}:4444`, + "-e", + "HOST=0.0.0.0", + "-e", + "PORT=4444", + // Local install over plain HTTP: a Secure cookie would be dropped by + // the browser and look like "login succeeds then bounces". + "-e", + "AUTH_REQUIRED=false", + "-e", + `JWT_SECRET_KEY=${secret}`, + "-e", + "MCPGATEWAY_UI_ENABLED=true", + "-e", + "MCPGATEWAY_ADMIN_API_ENABLED=true", + "-e", + "SECURE_COOKIES=false", + "--add-host", + "host.docker.internal:host-gateway", + this.image, + ], + { timeoutMs: HEALTH_TIMEOUT_MS } + ); + + if (result.code !== 0) { + const notFound = /manifest unknown|not found|pull access denied/i.test( + result.stderr + ); + return { + ok: false, + reason: notFound + ? `Could not pull ${this.image}. Set gitpilot.mcp.forgeImage to an image you can reach.` + : "docker run could not start MCP Context Forge. See the GitPilot output channel.", + }; + } + return { ok: true, gatewayUrl: this.gatewayUrl, mode: "container" }; + } + + // ── Health ────────────────────────────────────────────────────────── + + /** Single fast probe of Forge's health endpoint. */ + async isHealthy(timeoutMs = 2500): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(`${this.gatewayUrl}/health`, { + signal: controller.signal, + }); + return resp.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } + } + + private async waitForHealth( + token?: vscode.CancellationToken + ): Promise { + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + if (token?.isCancellationRequested) { + return { ok: false, reason: "Cancelled." }; + } + if (await this.isHealthy()) { + this.log(`Context Forge is ready at ${this.gatewayUrl}`); + return { ok: true, gatewayUrl: this.gatewayUrl, mode: "container" }; + } + await delay(HEALTH_POLL_MS); + } + return { + ok: false, + reason: + `MCP Context Forge did not become ready within ` + + `${Math.round(HEALTH_TIMEOUT_MS / 1000)}s. ` + + `Check "docker logs ${CONTAINER_NAME}".`, + }; + } + + /** Stop the container this extension started. Compose stacks are left alone. */ + async stop(): Promise { + await this.run("docker", ["stop", CONTAINER_NAME]); + } + + // ── Process plumbing ──────────────────────────────────────────────── + + /** The compose file in the open workspace, if this is a repo checkout. */ + private findComposeFile(): string | undefined { + for (const folder of vscode.workspace.workspaceFolders || []) { + const candidate = path.join(folder.uri.fsPath, "docker-compose.mcp.yml"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; + } + + private log(line: string): void { + this.output.appendLine(`[GitPilot MCP] ${line}`); + } + + private run( + command: string, + args: string[], + opts: { cwd?: string; timeoutMs?: number } = {} + ): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + this.log(`$ ${command} ${args.join(" ")}`); + + let child: cp.ChildProcess; + try { + child = cp.spawn(command, args, { + cwd: opts.cwd, + shell: process.platform === "win32", + env: { ...process.env }, + }); + } catch (err) { + this.log(`failed to spawn: ${describe(err)}`); + resolve({ code: -1, stdout: "", stderr: describe(err) }); + return; + } + + let stdout = ""; + let stderr = ""; + let settled = false; + + const finish = (code: number) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }; + + const timer = setTimeout(() => { + this.log(`timed out after ${opts.timeoutMs}ms; killing`); + child.kill(); + finish(-1); + }, opts.timeoutMs ?? 60_000); + + child.stdout?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + stdout += text; + this.output.append(text); + }); + child.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + stderr += text; + this.output.append(text); + }); + + child.on("error", (err) => { + this.log(`error: ${err.message}`); + stderr += err.message; + finish(-1); + }); + child.on("close", (code) => finish(code ?? -1)); + }); + } +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/vscode/src/services/server/GitPilotServerController.ts b/extensions/vscode/src/services/server/GitPilotServerController.ts new file mode 100644 index 0000000..62fc04f --- /dev/null +++ b/extensions/vscode/src/services/server/GitPilotServerController.ts @@ -0,0 +1,296 @@ +/** + * GitPilotServerController — owns the local `gitpilot serve` process. + * + * The settings page must be useful when the backend is down, and "download a + * terminal, type this command" is not useful. This controller closes that gap: + * it can tell whether the configured server is reachable, start a local one + * when the URL is a loopback address, follow the port GitPilot actually bound + * (it drifts when 8000 is busy), and hand back a live server URL. + * + * It deliberately does nothing for remote URLs — starting a process here would + * never make someone else's server reachable. + */ +import * as vscode from "vscode"; +import * as cp from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { GitPilotApiClient } from "../../api/client"; + +export type ServerStartResult = + | { ok: true; serverUrl: string } + | { ok: false; reason: string }; + +/** Hostnames that mean "this machine", and so may be started locally. */ +const LOOPBACK_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::1", + "[::1]", +]); + +/** How long to wait for `gitpilot serve` to answer /api/health. */ +const START_TIMEOUT_MS = 60_000; +const POLL_INTERVAL_MS = 500; + +export class GitPilotServerController implements vscode.Disposable { + private _process: cp.ChildProcess | undefined; + private _starting: Promise | undefined; + + constructor( + private readonly client: GitPilotApiClient, + private readonly output: vscode.OutputChannel + ) {} + + /** True when we started the server and it is still running. */ + get isManaged(): boolean { + return this._process !== undefined && this._process.exitCode === null; + } + + /** Whether `url` points at this machine, i.e. whether we could start it. */ + isLocalUrl(url: string = this.client.serverUrl): boolean { + try { + const host = new URL(url).hostname.toLowerCase(); + return LOOPBACK_HOSTS.has(host); + } catch { + return false; + } + } + + /** Single fast probe of the configured server. */ + async isHealthy(): Promise { + return this.client.health(); + } + + /** The command used to launch GitPilot, overridable for odd installs. */ + private get command(): string { + return vscode.workspace + .getConfiguration("gitpilot") + .get("serverCommand", "gitpilot") + .trim() || "gitpilot"; + } + + /** The exact command line we would run, for display and diagnostics. */ + get commandLine(): string { + return `${this.command} serve --no-open`; + } + + /** + * Start a local GitPilot server and wait until it answers. + * + * Concurrent callers share one attempt — a settings page that retries while + * a start is in flight should not spawn a second server. + */ + async start(token?: vscode.CancellationToken): Promise { + if (this._starting) { + return this._starting; + } + this._starting = this._start(token).finally(() => { + this._starting = undefined; + }); + return this._starting; + } + + private async _start( + token?: vscode.CancellationToken + ): Promise { + if (!this.isLocalUrl()) { + return { + ok: false, + reason: + `${this.client.serverUrl} is not a local address, so GitPilot ` + + `cannot start it from here. Start it on that host, or change the ` + + `server URL to a local one.`, + }; + } + + // Something may already be listening (another VS Code window, a terminal). + if (await this.isHealthy()) { + return { ok: true, serverUrl: this.client.serverUrl }; + } + + const portFile = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "gitpilot-")), + "port" + ); + + const args = ["serve", "--no-open", "--port-file", portFile]; + const requested = this.preferredPort(); + if (requested !== undefined) { + args.push("--port", String(requested)); + } + + this.output.appendLine( + `[GitPilot] Starting server: ${this.command} ${args.join(" ")}` + ); + + let child: cp.ChildProcess; + try { + child = cp.spawn(this.command, args, { + cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + // On Windows `gitpilot` is a .exe/.cmd shim that needs the shell to + // resolve; elsewhere spawning directly avoids quoting surprises. + shell: process.platform === "win32", + env: { ...process.env }, + }); + } catch (err) { + return { ok: false, reason: describeSpawnError(err, this.command) }; + } + + this._process = child; + + child.stdout?.on("data", (chunk: Buffer) => + this.output.append(`[gitpilot] ${chunk.toString()}`) + ); + child.stderr?.on("data", (chunk: Buffer) => + this.output.append(`[gitpilot] ${chunk.toString()}`) + ); + + let spawnError: string | undefined; + child.on("error", (err) => { + spawnError = describeSpawnError(err, this.command); + this.output.appendLine(`[GitPilot] ${spawnError}`); + }); + child.on("exit", (code, signal) => { + this.output.appendLine( + `[GitPilot] Server process exited (code=${code}, signal=${signal})` + ); + if (this._process === child) { + this._process = undefined; + } + }); + + const deadline = Date.now() + START_TIMEOUT_MS; + let adoptedPort: number | undefined; + + while (Date.now() < deadline) { + if (token?.isCancellationRequested) { + this.stop(); + return { ok: false, reason: "Cancelled." }; + } + if (spawnError) { + return { ok: false, reason: spawnError }; + } + if (child.exitCode !== null) { + return { + ok: false, + reason: + `GitPilot exited immediately (code ${child.exitCode}). ` + + `See the GitPilot output channel for the reason.`, + }; + } + + // GitPilot writes the port before binding, and moves off a busy port — + // so the file, not our request, is the source of truth. + if (adoptedPort === undefined) { + const port = readPortFile(portFile); + if (port !== undefined) { + adoptedPort = port; + const url = this.withPort(port); + if (url !== this.client.serverUrl) { + this.output.appendLine( + `[GitPilot] Server bound port ${port}; following it to ${url}` + ); + this.client.setServerUrl(url); + } + } + } + + if (adoptedPort !== undefined && (await this.isHealthy())) { + cleanupPortFile(portFile); + return { ok: true, serverUrl: this.client.serverUrl }; + } + + await delay(POLL_INTERVAL_MS); + } + + cleanupPortFile(portFile); + return { + ok: false, + reason: + `GitPilot did not become ready within ${Math.round( + START_TIMEOUT_MS / 1000 + )}s. See the GitPilot output channel for details.`, + }; + } + + /** Terminate the server we started. A server we did not start is left alone. */ + stop(): void { + const child = this._process; + this._process = undefined; + if (!child || child.exitCode !== null) { + return; + } + this.output.appendLine("[GitPilot] Stopping managed server process"); + try { + if (process.platform === "win32" && child.pid !== undefined) { + cp.spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"]); + } else { + child.kill("SIGTERM"); + } + } catch { + /* the process is already gone */ + } + } + + /** Port from the configured URL, when it names one. */ + private preferredPort(): number | undefined { + try { + const port = new URL(this.client.serverUrl).port; + return port ? Number(port) : undefined; + } catch { + return undefined; + } + } + + /** The configured URL with its port replaced by the one actually bound. */ + private withPort(port: number): string { + try { + const url = new URL(this.client.serverUrl); + url.port = String(port); + return url.toString().replace(/\/+$/, ""); + } catch { + return `http://127.0.0.1:${port}`; + } + } + + dispose(): void { + this.stop(); + } +} + +function readPortFile(file: string): number | undefined { + try { + const port = Number(fs.readFileSync(file, "utf-8").trim()); + return Number.isInteger(port) && port > 0 ? port : undefined; + } catch { + return undefined; + } +} + +function cleanupPortFile(file: string): void { + try { + fs.rmSync(path.dirname(file), { recursive: true, force: true }); + } catch { + /* a leftover temp file is not worth reporting */ + } +} + +function describeSpawnError(err: unknown, command: string): string { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "ENOENT") { + return ( + `Could not find "${command}" on your PATH. Install it with ` + + `"pip install gitpilot", or set gitpilot.serverCommand to its full path.` + ); + } + return `Failed to start "${command}": ${ + err instanceof Error ? err.message : String(err) + }`; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extensions/vscode/src/services/workspace/errorTranslator.ts b/extensions/vscode/src/services/workspace/errorTranslator.ts index 035a143..81dc74e 100644 --- a/extensions/vscode/src/services/workspace/errorTranslator.ts +++ b/extensions/vscode/src/services/workspace/errorTranslator.ts @@ -1,8 +1,24 @@ /** * GitPilot Redesign — Error Translator * Converts raw exceptions to user-friendly messages. + * + * The canned message per status code is a floor, not a ceiling. When the + * backend sends a `detail`, that text wins: it was written for this exact + * failure and names the provider, the missing package and the command to + * run, none of which can be recovered from a status number. Guessing over + * it is how a configured Ollama came to report a "circuit breaker" that + * was never involved. */ +/** Codes whose canned text is a guess we should not print over a real one. */ +const _detailedStatuses = new Set([500, 502, 503, 504]); + +/** Read the server's explanation off an error, if it carried one. */ +function _serverDetail(err: any): string { + const detail = err?.detail ?? err?.response?.data?.detail; + return typeof detail === "string" ? detail.trim() : ""; +} + export class ErrorTranslator { private _patterns: Array<{ match: (err: any) => boolean; @@ -41,8 +57,11 @@ export class ErrorTranslator { }, { match: (err) => err?.status === 503 || err?.statusCode === 503, + // Deliberately vague: 503 covers both a tripped circuit breaker and an + // agent runtime that could not be built, and only the server knows + // which. When it says, `_serverDetail` speaks instead of this. message: - "The LLM provider is temporarily unavailable (circuit breaker active). Please wait a moment and try again.", + "The LLM provider is unavailable. Check Settings → AI Providers, or wait a moment and try again.", }, { match: (err) => err?.status === 504 || err?.statusCode === 504, @@ -67,6 +86,13 @@ export class ErrorTranslator { return "An unknown error occurred."; } + // The server's own words, for the codes where the canned line is a guess. + const status = err?.status ?? err?.statusCode; + const detail = _serverDetail(err); + if (detail && _detailedStatuses.has(status)) { + return detail; + } + for (const pattern of this._patterns) { if (pattern.match(err)) { return pattern.message; diff --git a/extensions/vscode/src/ui/webview/GitPilotNavView.ts b/extensions/vscode/src/ui/webview/GitPilotNavView.ts new file mode 100644 index 0000000..937e8ee --- /dev/null +++ b/extensions/vscode/src/ui/webview/GitPilotNavView.ts @@ -0,0 +1,334 @@ +/** + * GitPilotNavView — the sidebar, which is navigation and nothing else. + * + * The sidebar used to contain a complete second chat: a message list, a + * composer, a provider picker and a status card. That duplicated the GitPilot + * Chat tab, so the same information appeared twice and the same action could + * be started from two places that behaved slightly differently. + * + * Three zones, one job each: + * + * sidebar → where do I want to go? (this file) + * editor → what am I doing? (GitPilotHomePanel, then chat) + * chat tab → what is happening now? (GitPilotPanel) + * + * So this view holds one status line, New Task, recent tasks and Settings. + * Nothing else: a second composer or a second copy of the quick actions only + * ever raised the question of which one was the real one. Picking a task + * opens GitPilot showing it — one action, not select-then-open. + */ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; + +import { SessionClient, SessionListItem } from "../../api/sessionClient"; +import { GitPilotApiClient } from "../../api/client"; +import { StateStore } from "../../core/stateStore"; +import { GitPilotServerController } from "../../services/server/GitPilotServerController"; + +/** What the sidebar renders. Everything else stays in the extension host. */ +interface NavState { + connected: boolean; + connecting: boolean; + canStartLocally: boolean; + detail?: string; + model?: string; + repo?: string; + branch?: string; + sessions: SessionListItem[]; + activeSessionId?: string; + /** True while the extension is retrying on its own. */ + retrying: boolean; +} + +export interface NavViewDeps { + extensionUri: vscode.Uri; + client: GitPilotApiClient; + sessionClient: SessionClient; + stateStore: StateStore; + serverController: GitPilotServerController; +} + +export class GitPilotNavView implements vscode.WebviewViewProvider { + public static readonly viewType = "gitpilot.navView"; + + private view: vscode.WebviewView | undefined; + private sessions: SessionListItem[] = []; + private connecting = false; + private detail: string | undefined; + + constructor(private readonly deps: NavViewDeps) { + // The sidebar mirrors state it does not own, so it re-renders whenever + // the store changes rather than polling. + deps.stateStore.onDidChangeState(() => this.render()); + deps.client.onStateChange(() => { + this.connecting = deps.client.state === "connecting"; + this.render(); + }); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.deps.extensionUri, "out")], + }; + view.webview.html = this.html(view.webview); + view.webview.onDidReceiveMessage((msg) => void this.onMessage(msg)); + + // Refreshing only while visible keeps a collapsed sidebar off the wire. + view.onDidChangeVisibility(() => { + if (view.visible) { + void this.refresh(); + } + }); + void this.refresh(); + } + + /** Re-read sessions from the backend and repaint. */ + async refresh(): Promise { + if (this.deps.client.isConnected) { + try { + this.sessions = await this.deps.sessionClient.listSessions(); + this.detail = undefined; + } catch (err) { + // A session list that cannot be fetched is not worth an error + // dialog; the sidebar simply shows what it last knew. + this.detail = err instanceof Error ? err.message : String(err); + } + } else { + this.sessions = []; + } + this.render(); + } + + private render(): void { + if (!this.view) { + return; + } + const s = this.deps.stateStore.state; + const connected = this.deps.client.isConnected; + + const state: NavState = { + connected, + connecting: this.connecting, + canStartLocally: this.deps.serverController.isLocalUrl(), + detail: connected ? this.detail : this.offlineReason(), + model: s.provider?.model, + repo: s.projectContextSummary?.repoName || s.workspace?.folderName, + branch: s.workspace?.git?.branch, + sessions: this.sessions, + activeSessionId: s.session?.sessionId, + retrying: this.deps.client.isRetrying, + }; + + void this.view.webview.postMessage({ type: "NAV_STATE", state }); + } + + /** + * Why the server is not answering, in the words that match the remedy. + * + * "Could not reach" was shown for both halves of a distinction that + * matters: a refused connection means start the server, while a timeout + * means the server is there and busy, and pressing Start server would only + * fail on a port already in use. + */ + private offlineReason(): string { + const probe = this.deps.client.lastProbe; + const url = this.deps.client.serverUrl; + + if (probe?.outcome === "timeout") { + return `${url} is not answering yet — still trying`; + } + return `Could not reach ${url}`; + } + + private async onMessage(msg: { type: string; [key: string]: unknown }): Promise { + switch (msg.type) { + case "NAV_READY": + await this.refresh(); + return; + + case "OPEN_SETTINGS": + await vscode.commands.executeCommand("gitpilot.openSettings"); + return; + + case "QUICK_ACTION": + // The chat is where a quick action's answer lands, so go there first. + await vscode.commands.executeCommand("gitpilot.openChatTab"); + await vscode.commands.executeCommand(`gitpilot.${msg.action as string}`); + return; + + case "SELECT_MODEL": + await vscode.commands.executeCommand("gitpilot.selectModel"); + await this.refresh(); + return; + + /** + * New Task is a new task. + * + * It used to only open the tab, on the theory that a session should be + * created once there was something to create it for. In practice that + * meant clicking New Task landed you in the previous conversation, with + * its transcript, its plan and its changed files still on screen — so + * the one control named for starting fresh was the one that never did. + */ + case "NEW_TASK": + await vscode.commands.executeCommand("gitpilot.newSession"); + await this.refresh(); + return; + + + /** + * Restore the session *and* reveal the chat. Requiring a second click + * on "Open Chat" was the friction this replaces. + */ + case "OPEN_SESSION": + await vscode.commands.executeCommand( + "gitpilot.loadSession", + msg.sessionId as string + ); + await vscode.commands.executeCommand("gitpilot.openChat"); + await this.refresh(); + return; + + case "SESSION_MENU": + await this.sessionMenu(msg.sessionId as string); + return; + + case "VIEW_ALL_SESSIONS": + await vscode.commands.executeCommand("gitpilot.sessionsView.focus"); + return; + + /** + * Actually start it. + * + * This button opened Settings, which is a page about configuration and + * has no way to start anything either — so the one control offered to a + * user whose server is down did not start the server. + */ + case "START_SERVER": + await this.startServer(); + return; + + case "RECONNECT": + await vscode.commands.executeCommand("gitpilot.reconnect"); + await this.refresh(); + return; + + case "STOP_WAITING": + this.deps.client.stopAutoReconnect(); + this.render(); + return; + + case "OPEN_DOCS": + await vscode.env.openExternal( + vscode.Uri.parse("https://github.com/ruslanmv/gitpilot") + ); + return; + } + } + + /** Start the local backend, then connect to it. */ + private async startServer(): Promise { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Starting the GitPilot server\u2026", + cancellable: true, + }, + (_progress, token) => this.deps.serverController.start(token) + ); + + if (!result.ok) { + vscode.window.showErrorMessage( + `Could not start the GitPilot server. ${result.reason ?? ""}`.trim() + ); + this.render(); + return; + } + + // A server that has just booted is the slow case this whole procedure + // exists for, so connect with the full escalating deadline. + await this.deps.client.connect(); + await this.refresh(); + } + + /** + * The row's overflow menu. + * + * Kept out of the row itself: permanently visible per-row controls are what + * turns a readable list into a cluttered one. + */ + private async sessionMenu(sessionId: string): Promise { + const session = this.sessions.find((s) => s.id === sessionId); + const choice = await vscode.window.showQuickPick( + [ + { label: "$(comment-discussion) Open", action: "open" }, + { label: "$(trash) Delete", action: "delete" }, + ], + { title: session?.name || "Session" } + ); + if (!choice) { + return; + } + + if (choice.action === "open") { + await vscode.commands.executeCommand("gitpilot.loadSession", sessionId); + await vscode.commands.executeCommand("gitpilot.openChat"); + } else if (choice.action === "delete") { + const confirm = await vscode.window.showWarningMessage( + `Delete "${session?.name || sessionId}"? This cannot be undone.`, + { modal: true }, + "Delete" + ); + if (confirm === "Delete") { + try { + await this.deps.sessionClient.deleteSession(sessionId); + } catch (err) { + vscode.window.showErrorMessage( + `Could not delete the session: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + } + } + await this.refresh(); + } + + private html(webview: vscode.Webview): string { + const nonce = getNonce(); + const csp = [ + "default-src 'none'", + `style-src 'nonce-${nonce}'`, + `script-src 'nonce-${nonce}'`, + `img-src ${webview.cspSource} https: data:`, + ].join("; "); + + const templatePath = path.join( + this.deps.extensionUri.fsPath, + "out", + "ui", + "webview", + "gitpilotNavTemplate.html" + ); + + let html: string; + try { + html = fs.readFileSync(templatePath, "utf-8"); + } catch { + return "

Could not load the GitPilot sidebar.

"; + } + return html.replace(/__CSP__/g, csp).replace(/__NONCE__/g, nonce); + } +} + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let out = ""; + for (let i = 0; i < 32; i++) { + out += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return out; +} diff --git a/extensions/vscode/src/ui/webview/GitPilotPanel.ts b/extensions/vscode/src/ui/webview/GitPilotPanel.ts index 47f1991..76fdff1 100644 --- a/extensions/vscode/src/ui/webview/GitPilotPanel.ts +++ b/extensions/vscode/src/ui/webview/GitPilotPanel.ts @@ -7,13 +7,32 @@ import { WebviewToExtensionMessage, } from "../../core/types"; -export class GitPilotPanel - implements vscode.WebviewViewProvider, vscode.Disposable -{ - public static readonly viewType = "gitpilot.chatView"; - - private _view?: vscode.WebviewView; - private _bridge?: WebviewBridge; +/** + * GitPilot — the one place you talk to it. + * + * There used to be two: a full chat in the sidebar and a landing page in the + * editor, each with its own composer and its own quick actions. A user's first + * question was "which one am I supposed to use?", which is not a question a + * finished product asks of anyone. + * + * So there is a single editor tab. Empty, it is the landing page — brand, + * "What are we building?", one composer. Send the first message and the same + * tab is the conversation. The sidebar navigates and nothing more. + * + * A surface is still just a bound webview, because VS Code may restore the tab + * itself and a second binding must not fight the first. + */ +interface ChatSurface { + webview: vscode.Webview; + bridge: WebviewBridge; +} + +export class GitPilotPanel implements vscode.Disposable { + /** The single editor tab. There is no sidebar chat any more. */ + public static readonly viewType = "gitpilot.chatPanel"; + + private readonly _surfaces = new Set(); + private _editorPanel?: vscode.WebviewPanel; private readonly _disposables: vscode.Disposable[] = []; private readonly _output: vscode.OutputChannel; @@ -23,17 +42,66 @@ export class GitPilotPanel private readonly _onMessage: (msg: WebviewToExtensionMessage) => void ) { this._output = vscode.window.createOutputChannel("GitPilot"); - this._disposables.push(this._output); + + // Subscribed once for the panel's lifetime rather than per surface: two + // surfaces must not mean two syncs for every state change. + this._disposables.push( + this._output, + this._stateStore.onDidChangeState(() => this._syncState()) + ); } - public resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken - ): void { - this._view = webviewView; + /** + * Open GitPilot, or bring it forward if it is already open. + * + * One tab: the landing page and the conversation are the same surface at + * two moments, so opening "Home" and opening "Chat" land in the same place. + */ + public openInEditor(column: vscode.ViewColumn = vscode.ViewColumn.One): void { + if (this._editorPanel) { + this._editorPanel.reveal(this._editorPanel.viewColumn ?? column, false); + return; + } - webviewView.webview.options = { + const panel = vscode.window.createWebviewPanel( + GitPilotPanel.viewType, + "GitPilot", + column, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath(this._extensionUri, "out"), + vscode.Uri.joinPath(this._extensionUri, "resources"), + vscode.Uri.joinPath(this._extensionUri, "src"), + ], + } + ); + + panel.iconPath = vscode.Uri.joinPath( + this._extensionUri, + "resources", + "icon.png" + ); + + this._editorPanel = panel; + const surface = this._attach(panel.webview); + + panel.onDidDispose(() => { + this._log("GitPilot tab closed"); + this._surfaces.delete(surface); + this._editorPanel = undefined; + }); + } + + /** True when the conversation already has an editor tab. */ + public get hasEditorTab(): boolean { + return this._editorPanel !== undefined; + } + + /** Bind a webview: options, bridge, message handler, HTML, first sync. */ + private _attach(webview: vscode.Webview): ChatSurface { + webview.options = { enableScripts: true, localResourceRoots: [ this._extensionUri, @@ -43,9 +111,10 @@ export class GitPilotPanel ], }; - this._bridge = new WebviewBridge(webviewView.webview); + const surface: ChatSurface = { webview, bridge: new WebviewBridge(webview) }; + this._surfaces.add(surface); - const onReceive = webviewView.webview.onDidReceiveMessage( + const onReceive = webview.onDidReceiveMessage( async (msg: WebviewToExtensionMessage) => { try { if (msg.type === "INIT") { @@ -75,25 +144,19 @@ export class GitPilotPanel } ); - const onStateChange = this._stateStore.onDidChangeState(() => { - this._syncState(); - }); - - const onDispose = webviewView.onDidDispose(() => { - this._log("Webview disposed"); - this.dispose(); - }); - - this._disposables.push(onReceive, onStateChange, onDispose); + this._disposables.push(onReceive); - void this._initializeWebview(webviewView); + void this._initializeWebview(webview); + return surface; } public postMessage(msg: ExtensionToWebviewMessage): void { - try { - this._bridge?.postMessage(msg); - } catch (error) { - this._logError("Failed to post message to webview", error); + for (const surface of this._surfaces) { + try { + surface.bridge.postMessage(msg); + } catch (error) { + this._logError("Failed to post message to webview", error); + } } } @@ -107,23 +170,19 @@ export class GitPilotPanel } } - this._bridge = undefined; - this._view = undefined; + this._surfaces.clear(); + this._editorPanel?.dispose(); + this._editorPanel = undefined; } - private async _initializeWebview( - webviewView: vscode.WebviewView - ): Promise { + private async _initializeWebview(webview: vscode.Webview): Promise { try { - webviewView.webview.html = await this._getHtml(webviewView.webview); + webview.html = await this._getHtml(webview); this._syncState(); this._log("GitPilot webview initialized successfully"); } catch (error) { this._logError("Failed to initialize GitPilot webview", error); - webviewView.webview.html = this._getFallbackHtml( - webviewView.webview, - error - ); + webview.html = this._getFallbackHtml(webview, error); void vscode.window.showErrorMessage( "GitPilot Workspace could not be loaded. Open Output → GitPilot for details." @@ -132,17 +191,15 @@ export class GitPilotPanel } private _syncState(): void { - if (!this._bridge) { - return; - } - - try { - this._bridge.postMessage({ - type: "STATE_SYNC", - payload: this._stateStore.state as GitPilotState, - }); - } catch (error) { - this._logError("Failed to sync state to webview", error); + for (const surface of this._surfaces) { + try { + surface.bridge.postMessage({ + type: "STATE_SYNC", + payload: this._stateStore.state as GitPilotState, + }); + } catch (error) { + this._logError("Failed to sync state to webview", error); + } } } diff --git a/extensions/vscode/src/ui/webview/GitPilotSettingsPanel.ts b/extensions/vscode/src/ui/webview/GitPilotSettingsPanel.ts index ffe4b37..af8803f 100644 --- a/extensions/vscode/src/ui/webview/GitPilotSettingsPanel.ts +++ b/extensions/vscode/src/ui/webview/GitPilotSettingsPanel.ts @@ -2,10 +2,52 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; +import { GitPilotApiClient } from "../../api/client"; +import { SettingsClient, SettingsData } from "../../api/settingsClient"; +import { GitPilotServerController } from "../../services/server/GitPilotServerController"; +import { McpClient, McpServer } from "../../api/mcpClient"; +import { McpForgeInstaller } from "../../services/mcp/McpForgeInstaller"; +import { + ProviderConfigInput, + ProviderName, + ProviderOverviewEntry, + ProviderSetupData, + ProviderSettingsMessage, + SanitizedProviderConfig, + ServerConnectionState, +} from "../../core/types"; +import { + inferOllaBridgeMode, + OLLABRIDGE_CLOUD_URL, + OLLABRIDGE_LINK_URL, + OLLABRIDGE_LOCAL_URL, + PROVIDER_CATALOG, + PROVIDER_ORDER, +} from "./providerCatalog"; + +export interface SettingsPanelDeps { + extensionUri: vscode.Uri; + client: GitPilotApiClient; + settingsClient: SettingsClient; + serverController: GitPilotServerController; + mcpClient: McpClient; + forgeInstaller: McpForgeInstaller; +} + /** - * GitPilotSettingsPanel — opens a branded settings tab (webview panel) - * with sidebar navigation (General, AI Provider, Agent, Editor) that - * reads/writes VS Code configuration for the gitpilot extension. + * GitPilotSettingsPanel — the branded settings tab. + * + * General/Agent/Editor sections read and write VS Code configuration. The AI + * Providers section is different: provider configuration lives on the GitPilot + * backend, so this panel is a client of that API rather than a settings store. + * + * Two rules shape the host/webview split: + * + * - Every network call happens here, never in the webview. The webview has no + * server URL, no token, and no fetch. + * - Secrets travel one way. Keys read from the backend are reduced to a + * boolean and a masked tail before they cross into the webview; keys typed + * by the user pass straight through to the backend and are never echoed. * * Only one instance is kept alive at a time (singleton pattern). */ @@ -15,23 +57,33 @@ export class GitPilotSettingsPanel { private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; + private readonly _client: GitPilotApiClient; + private readonly _settings: SettingsClient; + private readonly _server: GitPilotServerController; + private readonly _mcp: McpClient; + private readonly _forge: McpForgeInstaller; private _disposed = false; - private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { + private constructor(panel: vscode.WebviewPanel, deps: SettingsPanelDeps) { this._panel = panel; - this._extensionUri = extensionUri; + this._extensionUri = deps.extensionUri; + this._client = deps.client; + this._settings = deps.settingsClient; + this._server = deps.serverController; + this._mcp = deps.mcpClient; + this._forge = deps.forgeInstaller; panel.onDidDispose(() => { this._disposed = true; GitPilotSettingsPanel._instance = undefined; }); - panel.webview.onDidReceiveMessage((msg) => this._onMessage(msg)); + panel.webview.onDidReceiveMessage((msg) => void this._onMessage(msg)); void this._loadHtml(); } /** Show or reveal the settings tab. */ - public static open(extensionUri: vscode.Uri): void { + public static open(deps: SettingsPanelDeps): void { if (GitPilotSettingsPanel._instance) { GitPilotSettingsPanel._instance._panel.reveal(vscode.ViewColumn.One); return; @@ -44,17 +96,14 @@ export class GitPilotSettingsPanel { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.joinPath(extensionUri, "out")], + localResourceRoots: [vscode.Uri.joinPath(deps.extensionUri, "out")], } ); - GitPilotSettingsPanel._instance = new GitPilotSettingsPanel( - panel, - extensionUri - ); + GitPilotSettingsPanel._instance = new GitPilotSettingsPanel(panel, deps); } - // ── Private ── + // ── Webview plumbing ──────────────────────────────────────────────── private async _loadHtml(): Promise { const nonce = this._getNonce(); @@ -78,8 +127,7 @@ export class GitPilotSettingsPanel { try { html = fs.readFileSync(templatePath, "utf-8"); } catch { - this._panel.webview.html = - "

Could not load settings template.

"; + this._panel.webview.html = "

Could not load settings template.

"; return; } @@ -96,40 +144,1213 @@ export class GitPilotSettingsPanel { html = html .replace(/__CSP__/g, csp) .replace(/__NONCE__/g, nonce) - .replace(/__VERSION__/g, version); + .replace(/__VERSION__/g, version) + .replace(/__PROVIDER_CATALOG__/g, JSON.stringify(PROVIDER_CATALOG)) + .replace(/__PROVIDER_ORDER__/g, JSON.stringify(PROVIDER_ORDER)) + .replace( + /__OLLABRIDGE_DEFAULTS__/g, + JSON.stringify({ + cloud: OLLABRIDGE_CLOUD_URL, + local: OLLABRIDGE_LOCAL_URL, + link: OLLABRIDGE_LINK_URL, + }) + ); this._panel.webview.html = html; } - private _onMessage(msg: { type: string; payload?: Record }): void { - if (msg.type === "GET_SETTINGS") { - this._sendCurrentSettings(); + private post(message: Record): void { + if (this._disposed) { + return; + } + void this._panel.webview.postMessage(message); + } + + private async _onMessage( + msg: (ProviderSettingsMessage | { type: string; payload?: Record }) & { + requestId?: number; + } + ): Promise { + switch (msg.type) { + // ── VS Code-side settings (General / Agent / Editor) ── + case "GET_SETTINGS": + this._sendCurrentSettings(); + return; + + case "SAVE_SETTINGS": { + const payload = (msg as { payload?: Record }).payload; + if (payload) { + await this._applySettings(payload); + this.post({ type: "SETTINGS_SAVED" }); + } + return; + } + + // ── AI Providers ── + case "LOAD_PROVIDER_OVERVIEW": + await this.loadProviderSetup(); + return; + + case "RECONNECT_SERVER": + this._settings.clearModelCache(); + await this.loadProviderSetup({ forceReconnect: true }); + return; + + case "START_LOCAL_SERVER": + await this.startLocalServer(); + return; + + case "CHANGE_SERVER_URL": + await this.changeServerUrl(); + return; + + case "LOAD_PROVIDER_MODELS": + await this.loadModels( + (msg as any).provider, + (msg as any).requestId, + Boolean((msg as any).force) + ); + return; + + case "TEST_PROVIDER": + await this.testProvider( + (msg as any).provider, + (msg as any).requestId, + (msg as any).config || {} + ); + return; + + case "SAVE_AND_ACTIVATE_PROVIDER": + await this.saveProvider( + (msg as any).provider, + (msg as any).requestId, + (msg as any).config || {} + ); + return; + + // ── MCP servers ── + case "LOAD_MCP_OVERVIEW": + await this.loadMcpOverview(); + return; + + case "SET_MCP_SERVER_ENABLED": + await this.setMcpServerEnabled( + (msg as any).serverId, + Boolean((msg as any).enabled), + (msg as any).requestId + ); + return; + + case "SET_MCP_TOOL_ENABLED": + await this.setMcpToolEnabled( + (msg as any).serverId, + (msg as any).tool, + Boolean((msg as any).enabled), + (msg as any).requestId + ); + return; + + case "TEST_MCP_SERVER": + await this.testMcpServer((msg as any).serverId, (msg as any).requestId); + return; + + case "UNINSTALL_MCP_SERVER": + await this.uninstallMcpServer((msg as any).serverId, (msg as any).requestId); + return; + + case "INSTALL_MCP_SERVER": + await this.installMcpServer( + (msg as any).entryId, + (msg as any).source, + (msg as any).requestId + ); + return; + + case "SEARCH_MCP_REGISTRY": + await this.searchMcpRegistry((msg as any).query, (msg as any).requestId); + return; + + case "ADD_CUSTOM_MCP_SERVER": + await this.addCustomMcpServer((msg as any).requestId); + return; + + case "INSTALL_MCP_FORGE": + await this.installMcpForge(); + return; + + case "SYNC_MCP_GATEWAY": + await this.syncMcpGateway((msg as any).requestId); + return; + + case "CONFIGURE_MCP_GATEWAY": + await vscode.commands.executeCommand("gitpilot.configureMcpGateway"); + await this.loadMcpOverview(); + return; + + case "LOAD_TOPOLOGIES": + await this.loadTopologies(); + return; + + case "SET_TOPOLOGY": + await this.setTopology( + (msg as any).topology, + (msg as any).requestId + ); + return; + + case "REMOVE_PROVIDER_KEY": + await this.removeProviderKey( + (msg as any).provider, + (msg as any).requestId + ); + return; + + case "START_OLLABRIDGE_LOGIN": + await this.startOllaBridgeLogin((msg as any).baseUrl); + return; + + case "PAIR_OLLABRIDGE": + await this.pairOllaBridge( + (msg as any).requestId, + (msg as any).code, + (msg as any).baseUrl + ); + return; + + case "SIGN_OUT_OLLABRIDGE": + await this.signOutOllaBridge((msg as any).requestId); + return; + + // ── Escape hatches ── + case "COPY_DIAGNOSTICS": + await this.copyDiagnostics(); + return; + + case "OPEN_WEB_ADMIN": + await vscode.env.openExternal(vscode.Uri.parse(this._client.serverUrl)); + return; + + case "OPEN_EXTERNAL": { + const url = (msg as any).url; + if (typeof url === "string" && /^https?:\/\//i.test(url)) { + await vscode.env.openExternal(vscode.Uri.parse(url)); + } + return; + } + } + } + + // ── Provider setup ────────────────────────────────────────────────── + + /** + * Load the provider overview, reconnecting first if the cached connection + * state says we are down. + * + * The cached flag is not evidence: it goes stale the moment the user starts + * the server in a terminal. So an apparently-disconnected client gets one + * real health probe before the page reports trouble, and the page renders + * either way — there is no modal, and no dead end. + */ + private async loadProviderSetup( + opts: { forceReconnect?: boolean } = {} + ): Promise { + this.postServerState("connecting"); + + const connected = + !opts.forceReconnect && this._client.isConnected + ? await this._client.health() + : await this._client.connect(); + + if (!connected) { + this.postServerState("offline", { + detail: `Could not reach ${this._client.serverUrl}.`, + }); + return; + } + + try { + const settings = await this._settings.getSettings(); + this.postServerState("online"); + this.post({ + type: "PROVIDER_SETUP_DATA", + data: this.toSetupData(settings), + }); + } catch (err) { + this.postServerState("offline", { detail: describeError(err) }); + } + } + + private postServerState( + state: ServerConnectionState, + extra: { detail?: string } = {} + ): void { + this.post({ + type: "SERVER_STATE", + state, + serverUrl: this._client.serverUrl, + canStartLocally: this._server.isLocalUrl(), + command: this._server.commandLine, + ...extra, + }); + } + + private async startLocalServer(): Promise { + this.postServerState("starting"); + const result = await this._server.start(); + if (!result.ok) { + this.postServerState("offline", { detail: result.reason }); + return; + } + // start() may have followed GitPilot onto a different port; persist it so + // the rest of the extension and the next session agree. + await vscode.workspace + .getConfiguration("gitpilot") + .update( + "serverUrl", + result.serverUrl, + vscode.ConfigurationTarget.Global + ); + this._settings.clearModelCache(); + await this.loadProviderSetup({ forceReconnect: true }); + } + + private async changeServerUrl(): Promise { + const url = await vscode.window.showInputBox({ + title: "GitPilot server URL", + prompt: "Where is the GitPilot backend running?", + value: this._client.serverUrl, + validateInput: (val) => { + try { + new URL(val); + return null; + } catch { + return "Please enter a valid URL"; + } + }, + }); + if (!url) { + return; + } + + this._client.setServerUrl(url); + this._settings.clearModelCache(); + await vscode.workspace + .getConfiguration("gitpilot") + .update("serverUrl", url, vscode.ConfigurationTarget.Global); + this._sendCurrentSettings(); + await this.loadProviderSetup({ forceReconnect: true }); + } + + private async loadModels( + provider: ProviderName, + requestId: number, + force: boolean + ): Promise { + try { + const result = await this._settings.listModelsCached(provider, force); + this.post({ + type: "PROVIDER_MODELS_RESULT", + requestId, + provider, + models: result.models || [], + error: result.error, + }); + } catch (err) { + this.post({ + type: "PROVIDER_MODELS_RESULT", + requestId, + provider, + models: [], + error: describeError(err), + }); + } + } + + private async testProvider( + provider: ProviderName, + requestId: number, + config: ProviderConfigInput + ): Promise { + try { + const result = await this._settings.testProvider({ + provider, + [provider]: this.toBackendConfig(provider, config), + } as any); + + const ok = result.health === "ok" || (result.configured && !result.warning); + this.post({ + type: "PROVIDER_TEST_RESULT", + requestId, + provider, + ok, + message: ok + ? `${PROVIDER_CATALOG[provider].label} responded${ + result.model ? ` using ${result.model}` : "" + }.` + : result.warning || "The provider did not respond successfully.", + }); + } catch (err) { + this.post({ + type: "PROVIDER_TEST_RESULT", + requestId, + provider, + ok: false, + message: describeProviderError(provider, err), + }); + } + } + + /** + * Persist a provider's configuration, then make it the active one. + * + * The order is not incidental: activating first would leave GitPilot + * pointing at a provider whose key has not landed yet, and any request in + * that window fails. + */ + private async saveProvider( + provider: ProviderName, + requestId: number, + config: ProviderConfigInput + ): Promise { + const backendConfig = this.toBackendConfig(provider, config); + + if (backendConfig.api_key && !(await this.confirmKeyTransport())) { + this.post({ + type: "PROVIDER_SAVE_RESULT", + requestId, + provider, + ok: false, + message: "Save cancelled.", + }); + return; + } + + try { + await this._settings.updateProviderConfig(provider, backendConfig as any); + await this._settings.setProvider(provider); + const settings = await this._settings.getSettings(); + + this.post({ + type: "PROVIDER_SAVE_RESULT", + requestId, + provider, + ok: true, + message: `${PROVIDER_CATALOG[provider].label} is now the active provider.`, + data: this.toSetupData(settings), + }); + + await vscode.commands.executeCommand("gitpilot.refreshStatus"); + } catch (err) { + this.post({ + type: "PROVIDER_SAVE_RESULT", + requestId, + provider, + ok: false, + message: describeProviderError(provider, err), + }); + } + } + + private async removeProviderKey( + provider: ProviderName, + requestId: number + ): Promise { + const confirm = await vscode.window.showWarningMessage( + `Remove the stored ${PROVIDER_CATALOG[provider].label} API key?`, + { modal: true }, + "Remove key" + ); + if (confirm !== "Remove key") { + return; + } + + try { + await this._settings.updateProviderConfig(provider, { + api_key: "", + } as any); + const settings = await this._settings.getSettings(); + this.post({ + type: "PROVIDER_SAVE_RESULT", + requestId, + provider, + ok: true, + message: "API key removed.", + data: this.toSetupData(settings), + }); + } catch (err) { + this.post({ + type: "PROVIDER_SAVE_RESULT", + requestId, + provider, + ok: false, + message: describeProviderError(provider, err), + }); + } + } + + // ── MCP servers ───────────────────────────────────────────────────── + + /** + * Load everything the MCP overview renders: gateway status, attached + * servers, and the bundled catalogue. + * + * The three are fetched together because the page is meaningless without + * all of them — "no servers" and "cannot reach the backend" must not look + * the same. + */ + private async loadMcpOverview(): Promise { + const connected = this._client.isConnected + ? await this._client.health() + : await this._client.connect(); + + if (!connected) { + this.post({ + type: "MCP_DATA", + offline: true, + serverUrl: this._client.serverUrl, + }); + return; + } + + try { + // Status probes the gateway and can be slow; the other two are local + // reads. Failing status must not hide the server list. + const [servers, catalog, status] = await Promise.all([ + this._mcp.listServers(), + this._mcp.listCatalog().catch(() => []), + this._mcp.status().catch(() => undefined), + ]); + + this.post({ + type: "MCP_DATA", + offline: false, + servers: servers.map(summariseServer), + catalog, + status, + forgeRunning: await this._forge.isHealthy(), + forgeInstalling: this._forge.isRunning, + }); + } catch (err) { + this.post({ + type: "MCP_DATA", + offline: true, + serverUrl: this._client.serverUrl, + detail: describeError(err), + }); + } + } + + /** + * Enable or disable a whole server. + * + * This is the switch that changes what the agents can do, so it always + * reloads afterwards: the page must show what the backend now holds, not + * what the click optimistically implied. + */ + private async setMcpServerEnabled( + serverId: string, + enabled: boolean, + requestId: number + ): Promise { + try { + await this._mcp.setServerEnabled(serverId, enabled); + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: true, + message: enabled + ? `${serverId} enabled. Its tools are now available to the agents.` + : `${serverId} disabled.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + /** + * Toggle one tool. + * + * Enabling a destructive tool is confirmed first. The risk classification + * is the backend's — it defaults high-risk tools off — and quietly + * reversing that from a checkbox would defeat the point. + */ + private async setMcpToolEnabled( + serverId: string, + tool: string, + enabled: boolean, + requestId: number + ): Promise { + if (enabled && isDestructiveName(tool)) { + const confirm = await vscode.window.showWarningMessage( + `"${tool}" can destroy data. Allow the agents to call it?`, + { modal: true }, + "Enable anyway" + ); + if (confirm !== "Enable anyway") { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: "Left disabled.", + }); + await this.loadMcpOverview(); + return; + } + } + + try { + await this._mcp.setToolEnabled(serverId, tool, enabled); + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: true, + message: `${tool} ${enabled ? "enabled" : "disabled"}.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + private async testMcpServer(serverId: string, requestId: number): Promise { + try { + const result = await this._mcp.testServer(serverId); + const reachable = result.ok ?? result.reachable ?? false; + const toolCount = Array.isArray(result.tools) ? result.tools.length : undefined; + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: Boolean(reachable), + message: reachable + ? `${serverId} responded${toolCount != null ? ` with ${toolCount} tool(s)` : ""}.` + : String(result.detail || "The server did not respond."), + }); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + private async uninstallMcpServer( + serverId: string, + requestId: number + ): Promise { + const confirm = await vscode.window.showWarningMessage( + `Detach ${serverId}? Its tools will no longer be available to the agents.`, + { modal: true }, + "Detach" + ); + if (confirm !== "Detach") { + return; + } + + try { + await this._mcp.uninstall(serverId); + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: true, + message: `${serverId} detached.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + /** + * Attach a server from the bundled catalogue or a registry. + * + * It arrives disabled, and the message says so — installing a capability + * and granting it are deliberately two steps. + */ + private async installMcpServer( + entryId: string, + source: string, + requestId: number + ): Promise { + try { + if (source === "registry") { + await this._mcp.installFromRegistry(entryId); + } else { + await this._mcp.installFromCatalog(entryId); + } + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: true, + message: `${entryId} attached, and left disabled. Enable it to give the agents its tools.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + private async searchMcpRegistry( + query: string, + requestId: number + ): Promise { + try { + const result = await this._mcp.searchRegistry(query); + this.post({ + type: "MCP_REGISTRY_RESULT", + requestId, + items: result.items || [], + error: result.error, + registryUrl: result.registry_url, + }); + } catch (err) { + this.post({ + type: "MCP_REGISTRY_RESULT", + requestId, + items: [], + error: describeError(err), + }); + } + } + + /** Attach a server by hand, prompting for the pieces the backend needs. */ + private async addCustomMcpServer(requestId: number): Promise { + const id = await vscode.window.showInputBox({ + title: "Add an MCP server (1 of 3)", + prompt: "Server id — how it will be listed", + placeHolder: "my-mcp-server", + validateInput: (v) => (v.trim() ? null : "An id is required"), + }); + if (!id) { return; } - if (msg.type === "SAVE_SETTINGS" && msg.payload) { - this._applySettings(msg.payload); - void this._panel.webview.postMessage({ type: "SETTINGS_SAVED" }); + const endpoint = await vscode.window.showInputBox({ + title: "Add an MCP server (2 of 3)", + prompt: "MCP endpoint URL", + placeHolder: "http://localhost:8080/mcp", + validateInput: (v) => { + try { + new URL(v); + return null; + } catch { + return "Enter a valid URL"; + } + }, + }); + if (!endpoint) { return; } - if (msg.type === "OPEN_ADMIN") { - void vscode.commands.executeCommand("gitpilot.showServerInfo"); + // The variable's *name*, never its value: the token belongs on the + // GitPilot host, not in the editor. + const authTokenEnv = await vscode.window.showInputBox({ + title: "Add an MCP server (3 of 3)", + prompt: + "Environment variable holding this server's token, if it needs one. " + + "GitPilot reads the value on its own host — do not paste the token here.", + placeHolder: "MY_MCP_SERVER_TOKEN", + }); + + try { + await this._mcp.installCustom({ + id: id.trim(), + endpoint: endpoint.trim(), + authTokenEnv: (authTokenEnv || "").trim(), + }); + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: true, + message: `${id.trim()} attached, and left disabled.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + /** + * Install and start MCP Context Forge, then point GitPilot at it. + * + * Long enough to need a progress notification, so it gets one rather than a + * settings page that appears to have frozen. + */ + private async installMcpForge(): Promise { + if (this._forge.isRunning) { return; } + + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing MCP Context Forge", + cancellable: true, + }, + async (progress, token) => { + return this._forge.install((update) => { + progress.report({ message: update.message }); + this.post({ + type: "MCP_FORGE_PROGRESS", + stage: update.stage, + message: update.message, + }); + }, token); + } + ); + + if (!result.ok) { + this.post({ + type: "MCP_FORGE_PROGRESS", + stage: "failed", + message: result.reason, + }); + const action = result.hint ? "Open docs" : undefined; + const choice = await vscode.window.showErrorMessage( + result.reason, + ...(action ? [action] : []) + ); + if (choice === action && result.hint) { + await vscode.env.openExternal(vscode.Uri.parse(result.hint)); + } + await this.loadMcpOverview(); + return; + } + + // Point GitPilot's gateway config at the Forge we just started, so the + // agents actually reach it rather than the previous address. + try { + await this._client.request("/api/mcp/gateway", { + method: "POST", + body: JSON.stringify({ url: result.gatewayUrl, auth_mode: "auto" }), + }); + } catch (err) { + this._output(`Could not save the gateway URL: ${describeError(err)}`); + } + + this.post({ + type: "MCP_FORGE_PROGRESS", + stage: "done", + message: `MCP Context Forge is running at ${result.gatewayUrl}.`, + }); + vscode.window.showInformationMessage( + `MCP Context Forge is running at ${result.gatewayUrl}.` + ); + await this.loadMcpOverview(); } + private async syncMcpGateway(requestId: number): Promise { + try { + const result = await this._mcp.syncGateway(); + const added = Array.isArray(result.added) ? result.added.length : 0; + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: !result.error, + message: result.error + ? String(result.error) + : `Gateway synced. ${added} server(s) added.`, + }); + await this.loadMcpOverview(); + } catch (err) { + this.post({ + type: "MCP_ACTION_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + private _output(line: string): void { + // The panel has no channel of its own; surface installer notes as a + // non-modal message rather than swallowing them. + vscode.window.setStatusBarMessage(`GitPilot: ${line}`, 5000); + } + + // ── Agent topologies ──────────────────────────────────────────────── + + /** + * Load the topology presets and which one is currently in force. + * + * Like the provider pages, this needs the backend — the presets and the + * saved preference both live there. A page that cannot reach it says so + * rather than showing a stale list. + */ + private async loadTopologies(): Promise { + const connected = this._client.isConnected + ? await this._client.health() + : await this._client.connect(); + + if (!connected) { + this.post({ + type: "TOPOLOGY_DATA", + offline: true, + serverUrl: this._client.serverUrl, + }); + return; + } + + try { + const [topologies, saved] = await Promise.all([ + this._settings.listTopologies(), + this._settings.getTopologyPreference(), + ]); + this.post({ + type: "TOPOLOGY_DATA", + offline: false, + topologies, + // No saved preference means the server routes per request; the page + // shows that as "Automatic" rather than inventing a selection. + selected: saved, + }); + } catch (err) { + this.post({ + type: "TOPOLOGY_DATA", + offline: true, + serverUrl: this._client.serverUrl, + detail: describeError(err), + }); + } + } + + /** + * Make a topology the default for future requests. + * + * The preference lives on the server, which is what actually routes work — + * writing it only into VS Code configuration, as this page used to, left + * the setting with no effect at all. The local copy is kept in step so the + * rest of the extension agrees. + */ + private async setTopology( + topology: string, + requestId: number + ): Promise { + try { + await this._settings.setTopologyPreference(topology); + await vscode.workspace + .getConfiguration("gitpilot") + .update("defaultTopology", topology, vscode.ConfigurationTarget.Global); + + this.post({ + type: "TOPOLOGY_SAVED", + requestId, + ok: true, + topology, + }); + await vscode.commands.executeCommand("gitpilot.refreshStatus"); + } catch (err) { + this.post({ + type: "TOPOLOGY_SAVED", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + // ── OllaBridge Cloud ──────────────────────────────────────────────── + + /** + * Open the OllaBridge sign-in page in the user's browser. + * + * Device pairing rather than a password box: VS Code is the wrong place to + * type an account password, and a pairing code is revocable. + */ + private async startOllaBridgeLogin(baseUrl?: string): Promise { + const target = deriveLinkUrl(baseUrl); + await vscode.env.openExternal(vscode.Uri.parse(target)); + this.post({ type: "OLLABRIDGE_LOGIN_OPENED", url: target }); + } + + private async pairOllaBridge( + requestId: number, + code: string, + baseUrl?: string + ): Promise { + const gateway = (baseUrl || "").trim() || OLLABRIDGE_CLOUD_URL; + const trimmed = (code || "").trim(); + if (!trimmed) { + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: false, + message: "Enter the pairing code shown in your browser.", + }); + return; + } + + try { + const result = await this._client.ollaBridgePair(gateway, trimmed); + if (!result.success) { + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: false, + message: result.error || "Pairing failed.", + }); + return; + } + + await this._settings.updateProviderConfig("ollabridge", { + base_url: gateway, + api_key: result.token || "", + }); + await this._settings.setProvider("ollabridge"); + const settings = await this._settings.getSettings(); + + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: true, + message: "Device paired. OllaBridge Cloud is now the active provider.", + data: this.toSetupData(settings), + }); + await vscode.commands.executeCommand("gitpilot.refreshStatus"); + } catch (err) { + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + private async signOutOllaBridge(requestId: number): Promise { + const confirm = await vscode.window.showWarningMessage( + "Sign out of OllaBridge Cloud? The stored device token will be removed.", + { modal: true }, + "Sign out" + ); + if (confirm !== "Sign out") { + return; + } + + try { + await this._settings.updateProviderConfig("ollabridge", { api_key: "" }); + const settings = await this._settings.getSettings(); + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: true, + message: "Signed out of OllaBridge Cloud.", + data: this.toSetupData(settings), + }); + } catch (err) { + this.post({ + type: "OLLABRIDGE_PAIR_RESULT", + requestId, + ok: false, + message: describeError(err), + }); + } + } + + // ── Translation between backend settings and the webview ──────────── + + /** + * Reduce backend settings to what the webview is allowed to know. + * + * `SettingsData` carries plaintext API keys. Nothing below copies one: each + * key becomes `hasApiKey` plus a four-character tail. + */ + private toSetupData(settings: SettingsData): ProviderSetupData { + const active = (settings.provider as ProviderName) || "ollabridge"; + const configs: Partial> = {}; + const providers: ProviderOverviewEntry[] = []; + + for (const name of PROVIDER_ORDER) { + const raw = (settings as any)[name] as Record | undefined; + const meta = PROVIDER_CATALOG[name]; + const apiKey = raw?.api_key || ""; + const model = name === "watsonx" ? raw?.model_id : raw?.model; + + const sanitized: SanitizedProviderConfig = { + model: model || "", + base_url: raw?.base_url || "", + hasApiKey: Boolean(apiKey), + apiKeyHint: apiKey ? maskKey(apiKey) : undefined, + }; + if (name === "watsonx") { + sanitized.project_id = raw?.project_id || ""; + } + if (name === "custom") { + // Headers carry routing and attribution values, not credentials, so + // they round-trip to the page as stored. + sanitized.headers = { ...((raw?.headers as unknown as Record) || {}) }; + } + configs[name] = sanitized; + + providers.push({ + name, + label: meta.label, + description: meta.description, + model: model || undefined, + active: name === active, + configured: this.isConfigured(name, raw, apiKey, model), + }); + } + + return { + activeProvider: active, + providers, + configs, + ollabridgeMode: inferOllaBridgeMode( + configs.ollabridge?.base_url, + Boolean(configs.ollabridge?.hasApiKey) + ), + serverUrl: this._client.serverUrl, + }; + } + + /** + * Whether a provider has everything it needs to be selected. + * + * This mirrors the backend's `is_provider_configured`, which is the one + * place that knows a provider's real requirements — notably that Ollama, + * OllaBridge and Open WebUI need no API key. Inventing a "has a key?" rule + * here is what made OllaBridge look unconfigured. + */ + private isConfigured( + name: ProviderName, + raw: Record | undefined, + apiKey: string, + model: string | undefined + ): boolean { + switch (name) { + case "ollama": + case "ollabridge": + return true; + case "openwebui": + // An instance may be open to the local network without auth. + return Boolean(raw?.base_url); + case "custom": + // Nothing to fall back on: a custom endpoint needs both halves. + return Boolean(raw?.base_url && model); + case "watsonx": + return Boolean(apiKey && raw?.project_id); + default: + return Boolean(apiKey); + } + } + + /** + * Shape webview input for the backend. + * + * A blank API key is dropped rather than sent: an empty string would clear + * the stored key, and a page that saves a model change must not do that. + * Clearing is REMOVE_PROVIDER_KEY, which asks first. + */ + private toBackendConfig( + provider: ProviderName, + config: ProviderConfigInput + ): Record { + const out: Record = {}; + + const model = (config.model || "").trim(); + if (model) { + out[provider === "watsonx" ? "model_id" : "model"] = model; + } + if (config.base_url !== undefined) { + out.base_url = config.base_url.trim(); + } + if (provider === "watsonx" && config.project_id !== undefined) { + out.project_id = config.project_id.trim(); + } + if (provider === "custom" && config.headers !== undefined) { + // Headers replace wholesale rather than merge — the editor shows the + // complete set, so a row the user deleted has to actually go. + const headers: Record = {}; + for (const [name, value] of Object.entries(config.headers)) { + const key = name.trim(); + if (key) { + headers[key] = String(value ?? "").trim(); + } + } + out.headers = headers; + } + + const apiKey = (config.api_key || "").trim(); + if (apiKey) { + out.api_key = apiKey; + } + + return out; + } + + /** + * Ask before a credential leaves the machine in the clear. + * + * Sending a key to a local server over http is fine — it never touches the + * network. Sending one to a remote host over http is not. + */ + private async confirmKeyTransport(): Promise { + const url = this._client.serverUrl; + if (!isPlainHttpRemote(url)) { + return true; + } + const choice = await vscode.window.showWarningMessage( + `${url} is a remote server reached over plain HTTP. Your API key would ` + + `be sent unencrypted.`, + { modal: true }, + "Send anyway" + ); + return choice === "Send anyway"; + } + + private async copyDiagnostics(): Promise { + const lines = [ + `GitPilot server URL: ${this._client.serverUrl}`, + `Connection state: ${this._client.state}`, + `Local address: ${this._server.isLocalUrl() ? "yes" : "no"}`, + `Managed process: ${this._server.isManaged ? "yes" : "no"}`, + `Start command: ${this._server.commandLine}`, + `VS Code: ${vscode.version}`, + `Platform: ${process.platform}`, + ]; + await vscode.env.clipboard.writeText(lines.join("\n")); + vscode.window.showInformationMessage("GitPilot diagnostics copied."); + } + + // ── VS Code configuration (General / Agent / Editor) ──────────────── + private _sendCurrentSettings(): void { const cfg = vscode.workspace.getConfiguration("gitpilot"); - const serverState = vscode.workspace.getConfiguration("gitpilot"); - void this._panel.webview.postMessage({ + this.post({ type: "SETTINGS_DATA", payload: { serverUrl: cfg.get("serverUrl", "http://127.0.0.1:8000"), autoConnect: cfg.get("autoConnect", true), githubToken: cfg.get("githubToken", ""), - provider: this._detectProvider(), permissionMode: cfg.get("permissionMode", "normal"), defaultTopology: cfg.get("defaultTopology", "default"), liteMode: cfg.get("liteMode", false), @@ -141,48 +1362,46 @@ export class GitPilotSettingsPanel { ), chatFontSize: cfg.get("chatFontSize", 13), maxChatHistory: cfg.get("maxChatHistory", 100), - // Best-effort connection status: check if server URL responds - connected: serverState.get("autoConnect", true), }, }); } - private _detectProvider(): string { - // Try env vars first, then settings - const env = process.env; - if (env.GITPILOT_PROVIDER) return env.GITPILOT_PROVIDER.toLowerCase(); - - // Check if common env vars hint at a provider - if (env.OPENAI_API_KEY) return "openai"; - if (env.ANTHROPIC_API_KEY) return "claude"; - if (env.OLLAMA_BASE_URL) return "ollama"; - - return "ollabridge"; // default - } - - private _applySettings(payload: Record): void { + private async _applySettings( + payload: Record + ): Promise { const cfg = vscode.workspace.getConfiguration("gitpilot"); const target = vscode.ConfigurationTarget.Global; - const set = (key: string, value: unknown) => { - void cfg.update(key, value, target); + const set = async (key: string, value: unknown) => { + if (value !== undefined) { + await cfg.update(key, value, target); + } }; - if (payload.serverUrl !== undefined) set("serverUrl", payload.serverUrl); - if (payload.autoConnect !== undefined) set("autoConnect", payload.autoConnect); - if (payload.githubToken !== undefined) set("githubToken", payload.githubToken); - if (payload.permissionMode !== undefined) set("permissionMode", payload.permissionMode); - if (payload.defaultTopology !== undefined) set("defaultTopology", payload.defaultTopology); - if (payload.liteMode !== undefined) set("liteMode", payload.liteMode); - if (payload.showInlineHints !== undefined) set("showInlineHints", payload.showInlineHints); - if (payload.scanOnSave !== undefined) set("scanOnSave", payload.scanOnSave); - if (payload.showSecurityDiagnostics !== undefined) set("showSecurityDiagnostics", payload.showSecurityDiagnostics); - if (payload.chatFontSize !== undefined) set("chatFontSize", payload.chatFontSize); - if (payload.maxChatHistory !== undefined) set("maxChatHistory", payload.maxChatHistory); + await set("serverUrl", payload.serverUrl); + await set("autoConnect", payload.autoConnect); + await set("githubToken", payload.githubToken); + await set("permissionMode", payload.permissionMode); + await set("defaultTopology", payload.defaultTopology); + await set("liteMode", payload.liteMode); + await set("showInlineHints", payload.showInlineHints); + await set("scanOnSave", payload.scanOnSave); + await set("showSecurityDiagnostics", payload.showSecurityDiagnostics); + await set("chatFontSize", payload.chatFontSize); + await set("maxChatHistory", payload.maxChatHistory); + + // The server URL is not just a stored string — the live client has to + // follow it, or the provider pages keep talking to the old address. + const url = typeof payload.serverUrl === "string" ? payload.serverUrl : ""; + if (url && url.replace(/\/+$/, "") !== this._client.serverUrl) { + this._client.setServerUrl(url); + this._settings.clearModelCache(); + } } private _getNonce(): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); @@ -190,3 +1409,115 @@ export class GitPilotSettingsPanel { return result; } } + +// ── Helpers ─────────────────────────────────────────────────────────── + +/** + * Reduce a server to what the overview needs. + * + * The tool list is kept — it is the point of the page — but each tool is + * trimmed to the fields the UI renders, so a server advertising a hundred + * tools does not ship a hundred full schemas into the webview. + */ +function summariseServer(server: McpServer): Record { + const tools = (server.tools || []).map((tool) => ({ + name: tool.name, + risk: tool.risk, + enabled: tool.enabled, + destructive: tool.destructive, + mutation: tool.mutation, + used_by: tool.used_by || [], + })); + return { + id: server.id, + installed: server.installed, + enabled: server.enabled, + endpoint: server.endpoint, + description: server.description, + tags: server.tags || [], + tool_count: server.tool_count ?? tools.length, + enabled_tool_count: tools.filter((t) => t.enabled).length, + tools, + orphan: server.orphan, + source: server.source, + // The variable's name is not a secret; its value never leaves the host. + auth_token_env: server.auth_token_env, + }; +} + +/** Tool names that destroy data, which are never enabled without asking. */ +function isDestructiveName(tool: string): boolean { + return /\b(drop|delete|truncate|destroy|remove)\b/i.test(tool.replace(/[._-]/g, " ")); +} + +/** "sk-ant-...A7X2" → "••••A7X2". Never returns more than the last four. */ +function maskKey(key: string): string { + return `••••${key.slice(-4)}`; +} + +/** True when `url` is a remote host reached without TLS. */ +function isPlainHttpRemote(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:") { + return false; + } + const host = parsed.hostname.toLowerCase(); + return !(host === "localhost" || host === "127.0.0.1" || host === "::1"); + } catch { + return false; + } +} + +/** The browser sign-in page belonging to a given OllaBridge gateway. */ +function deriveLinkUrl(baseUrl?: string): string { + const base = (baseUrl || "").trim(); + if (!base || base.replace(/\/+$/, "") === OLLABRIDGE_CLOUD_URL) { + return OLLABRIDGE_LINK_URL; + } + try { + return `${new URL(base).toString().replace(/\/+$/, "")}/link`; + } catch { + return OLLABRIDGE_LINK_URL; + } +} + +/** + * A message safe to show and safe to log. + * + * Error text from a failed request can quote the request body, which for a + * save is the API key. Anything key-shaped is redacted before it is shown. + */ +function describeError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + return redactSecrets(raw); +} + +/** Turn a raw failure into something a user can act on. */ +function describeProviderError(provider: ProviderName, err: unknown): string { + const message = describeError(err); + const status = (err as { status?: number })?.status; + const label = PROVIDER_CATALOG[provider].label; + + if (status === 401 || status === 403) { + return `${label} rejected the credentials. Check the API key and try again.`; + } + if (status === 404) { + return `${label} endpoint not found. Check the base URL.`; + } + if (/timed out/i.test(message)) { + return `${label} did not respond in time. Check the base URL and that the service is running.`; + } + if (/fetch failed|ECONNREFUSED|ENOTFOUND/i.test(message)) { + return `Could not reach ${label}. Check the base URL and that the service is running.`; + } + return message; +} + +/** Blank out anything that looks like a credential. */ +function redactSecrets(text: string): string { + return text + .replace(/\b(sk|xai|gsk)-[A-Za-z0-9_-]{8,}/g, "[redacted]") + .replace(/"api_key"\s*:\s*"[^"]*"/g, '"api_key":"[redacted]"') + .replace(/\b[Bb]earer\s+[A-Za-z0-9._-]{8,}/g, "Bearer [redacted]"); +} diff --git a/extensions/vscode/src/ui/webview/gitpilotNavTemplate.html b/extensions/vscode/src/ui/webview/gitpilotNavTemplate.html new file mode 100644 index 0000000..7abc223 --- /dev/null +++ b/extensions/vscode/src/ui/webview/gitpilotNavTemplate.html @@ -0,0 +1,629 @@ + + + + + + + GitPilot + + + + +
+
+ + Connecting… + +
+ +
No folder open
+ + + + + + +
+ +
+ +
+ + + + + + + + + diff --git a/extensions/vscode/src/ui/webview/gitpilotSettingsTemplate.html b/extensions/vscode/src/ui/webview/gitpilotSettingsTemplate.html index 7cda7f9..573799b 100644 --- a/extensions/vscode/src/ui/webview/gitpilotSettingsTemplate.html +++ b/extensions/vscode/src/ui/webview/gitpilotSettingsTemplate.html @@ -17,6 +17,9 @@ --accent-bright: #ff7a3c; --hover: rgba(255,255,255,0.04); --active-bg: rgba(217,92,61,0.12); + --ok: #4ade80; + --warn: #fbbf24; + --bad: #f87171; --radius: 8px; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -30,10 +33,7 @@ } /* ── Layout: sidebar + content ── */ - .settings-shell { - display: flex; - height: 100vh; - } + .settings-shell { display: flex; height: 100vh; } .settings-sidebar { width: 210px; min-width: 210px; @@ -44,11 +44,7 @@ padding: 12px 0; overflow-y: auto; } - .settings-main { - flex: 1; - overflow-y: auto; - padding: 28px 36px 64px; - } + .settings-main { flex: 1; overflow-y: auto; padding: 28px 36px 64px; } /* ── Sidebar nav items ── */ .nav-item { @@ -86,10 +82,7 @@ color: var(--muted); line-height: 1.6; } - .nav-footer a { - color: var(--accent-bright); - text-decoration: none; - } + .nav-footer a { color: var(--accent-bright); text-decoration: none; cursor: pointer; } .nav-footer a:hover { text-decoration: underline; } /* ── Section headings ── */ @@ -99,9 +92,18 @@ font-size: 18px; font-weight: 700; color: var(--fg); - margin-bottom: 24px; + margin-bottom: 6px; letter-spacing: -0.3px; } + .section-sub { font-size: 12.5px; color: var(--muted); margin-bottom: 22px; } + .group-heading { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.7px; + text-transform: uppercase; + color: var(--muted); + margin: 26px 0 10px; + } /* ── Field rows ── */ .field { @@ -114,17 +116,8 @@ } .field:last-child { border-bottom: none; } .field-info { flex: 1; min-width: 0; } - .field-label { - font-size: 14px; - font-weight: 600; - color: var(--fg); - margin-bottom: 3px; - } - .field-desc { - font-size: 12px; - color: var(--muted); - line-height: 1.55; - } + .field-label { font-size: 14px; font-weight: 600; color: var(--fg); margin-bottom: 3px; } + .field-desc { font-size: 12px; color: var(--muted); line-height: 1.55; } .field-desc code { background: rgba(255,255,255,0.06); padding: 1px 5px; @@ -149,6 +142,7 @@ input:focus, select:focus { border-color: var(--accent); } input[type="number"] { min-width: 90px; } select { cursor: pointer; } + input:disabled, select:disabled { opacity: 0.55; cursor: not-allowed; } /* ── Toggle switch ── */ .toggle { @@ -173,53 +167,185 @@ } .toggle.active::after { transform: translateX(18px); } - /* ── Action buttons ── */ + /* ── Buttons ── */ .btn { font-family: inherit; font-size: 13px; font-weight: 600; - padding: 7px 18px; + padding: 7px 16px; border-radius: var(--radius); cursor: pointer; border: 1px solid var(--border); background: var(--surface); color: var(--fg); transition: background 100ms ease, border-color 100ms ease; + white-space: nowrap; } - .btn:hover { background: var(--hover); border-color: rgba(255,122,60,0.35); } - .btn-primary { - background: var(--accent); - border-color: var(--accent); - color: #fff; - } - .btn-primary:hover { background: #C44F32; } - .btn-danger { - color: #f87171; - border-color: rgba(248,113,113,0.35); - } - .btn-danger:hover { background: rgba(248,113,113,0.08); } + .btn:hover:not(:disabled) { background: var(--hover); border-color: rgba(255,122,60,0.35); } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } + .btn-primary:hover:not(:disabled) { background: #C44F32; } + .btn-ghost { background: none; border-color: transparent; color: var(--muted); } + .btn-ghost:hover:not(:disabled) { color: var(--fg); background: var(--hover); } + .btn-danger { color: var(--bad); border-color: rgba(248,113,113,0.35); } + .btn-danger:hover:not(:disabled) { background: rgba(248,113,113,0.08); } + .btn-sm { font-size: 12px; padding: 5px 11px; } + .btn-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } /* ── Status badge ── */ .status-badge { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 3px 10px; border-radius: 999px; - font-size: 12px; + font-size: 11.5px; font-weight: 600; + border: 1px solid transparent; + } + .status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; } + .status-badge.ok { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); } + .status-badge.bad { background: rgba(248,113,113,0.1); color: var(--bad); border-color: rgba(248,113,113,0.3); } + .status-badge.pending { background: rgba(251,191,36,0.1); color: var(--warn); border-color: rgba(251,191,36,0.3); } + .status-badge.neutral { background: var(--hover); color: var(--muted); border-color: var(--border); } + + /* ── Cards ── */ + .card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 18px; } - .status-badge.connected { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.3); } - .status-badge.disconnected { background: rgba(248,113,113,0.1); color: #f87171; border: 1px solid rgba(248,113,113,0.3); } - .status-dot { width: 7px; height: 7px; border-radius: 50%; } - .connected .status-dot { background: #4ade80; } - .disconnected .status-dot { background: #f87171; } + .card + .card { margin-top: 10px; } + + /* Server status card */ + .server-card { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } + .server-card .server-info { flex: 1; min-width: 180px; } + .server-card .server-title { + display: flex; align-items: center; gap: 10px; + font-size: 14px; font-weight: 600; margin-bottom: 4px; flex-wrap: wrap; + } + .server-url { font-size: 12px; color: var(--muted); word-break: break-all; } + + /* Provider list rows */ + .provider-row { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + text-align: left; + font-family: inherit; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 8px; + cursor: pointer; + color: var(--fg); + transition: border-color 120ms ease, background 120ms ease; + } + .provider-row:hover { background: var(--hover); border-color: rgba(255,122,60,0.4); } + .provider-row.active-provider { border-color: var(--accent); background: var(--active-bg); } + .provider-avatar { + width: 34px; height: 34px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; flex-shrink: 0; + background: rgba(255,122,60,0.16); color: var(--accent-bright); + border: 1px solid rgba(255,122,60,0.3); + } + .provider-text { flex: 1; min-width: 0; } + .provider-name { font-size: 13.5px; font-weight: 600; margin-bottom: 2px; } + .provider-meta { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } + .provider-chevron { color: var(--muted); flex-shrink: 0; font-size: 16px; line-height: 1; } + + /* ── Provider detail page ── */ + .detail-back { + display: inline-flex; align-items: center; gap: 7px; + background: none; border: none; padding: 4px 0; margin-bottom: 18px; + color: var(--muted); font-family: inherit; font-size: 12.5px; + font-weight: 600; cursor: pointer; + } + .detail-back:hover { color: var(--accent-bright); } + .detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; } + .detail-title { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; } + + .form-row { padding: 14px 0; border-bottom: 1px solid var(--border); } + .form-row:last-of-type { border-bottom: none; } + .form-label { font-size: 13px; font-weight: 600; margin-bottom: 6px; display: block; } + .form-hint { font-size: 11.5px; color: var(--muted); margin-top: 6px; line-height: 1.5; } + .form-hint a { color: var(--accent-bright); text-decoration: none; cursor: pointer; } + .form-hint a:hover { text-decoration: underline; } + .input-wide { width: 100%; min-width: 0; } + .input-group { display: flex; gap: 8px; align-items: center; } + .input-group > input, .input-group > select { flex: 1; min-width: 0; } + + /* Tabs (OllaBridge connection methods) */ + .tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--border); margin-bottom: 4px; flex-wrap: wrap; } + .tab { + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600; + padding: 8px 12px; cursor: pointer; + } + .tab:hover { color: var(--fg); } + .tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); } + .tab-panel { display: none; padding-top: 6px; } + .tab-panel.active { display: block; } + + /* Disclosure */ + .disclosure { border-top: 1px solid var(--border); margin-top: 18px; padding-top: 14px; } + .disclosure-toggle { + background: none; border: none; color: var(--muted); + font-family: inherit; font-size: 12.5px; font-weight: 600; + cursor: pointer; padding: 4px 0; display: flex; align-items: center; gap: 7px; + } + .disclosure-toggle:hover { color: var(--fg); } + .disclosure-body { display: none; padding-top: 12px; } + .disclosure.open .disclosure-body { display: block; } + .disclosure.open .disclosure-caret { transform: rotate(90deg); } + .disclosure-caret { display: inline-block; transition: transform 120ms ease; } + + /* Inline notices */ + .notice { + border-radius: 8px; + padding: 12px 14px; + font-size: 12.5px; + line-height: 1.55; + border: 1px solid var(--border); + background: var(--surface-2); + margin: 14px 0; + } + .notice.ok { border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.07); } + .notice.bad { border-color: rgba(248,113,113,0.35); background: rgba(248,113,113,0.07); } + .notice.info { border-color: rgba(255,122,60,0.3); background: rgba(255,122,60,0.06); } + .notice-title { font-weight: 700; margin-bottom: 4px; } + .notice code { + display: inline-block; + background: rgba(255,255,255,0.07); + padding: 3px 7px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11.5px; + margin-top: 6px; + user-select: all; + } + .hidden { display: none !important; } + .actions-bar { + display: flex; gap: 10px; flex-wrap: wrap; + margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); + } + .spinner { + width: 12px; height: 12px; border-radius: 50%; + border: 2px solid rgba(255,255,255,0.2); + border-top-color: var(--accent-bright); + display: inline-block; + animation: spin 700ms linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } /* ── Save toast ── */ .save-toast { position: fixed; - bottom: 20px; - right: 28px; + bottom: 20px; right: 28px; background: var(--accent); color: #fff; padding: 10px 22px; @@ -232,6 +358,18 @@ pointer-events: none; } .save-toast.visible { opacity: 1; transform: translateY(0); } + + /* ── Narrow editor windows ── */ + @media (max-width: 720px) { + .settings-sidebar { width: 62px; min-width: 62px; } + .settings-sidebar .nav-item { justify-content: center; font-size: 0; gap: 0; padding: 11px 0; } + .settings-sidebar .nav-item svg { width: 20px; height: 20px; } + .nav-footer { display: none; } + .settings-main { padding: 20px 16px 56px; } + .field { flex-direction: column; gap: 10px; align-items: stretch; } + .field-control { width: 100%; } + .field-control input, .field-control select { width: 100%; min-width: 0; } + } @@ -244,7 +382,11 @@ + + + -
- + + + + + +
-
-
-
Lite mode
-
Use simplified prompts optimized for small LLMs (<7B params) like tinyllama, phi-3-mini, gemma-2b, qwen2.5:1.5b.
+ + +
+ + +
+
+
MCP Servers
+
+ Attach Model Context Protocol servers to give the agents extra + tools — a database schema, a vector index, your own service. Tools + are off until you enable them.
-
-
+ + +
+
+
+ MCP Context Forge + + Checking… + +
+
+
+
+
+ + + +
-
-
-
-
Open Admin Panel
-
Advanced provider configuration, model selection, and API key management in the browser.
+ + + + -
- + + + +
+ + +
Agent Behavior
+
How much GitPilot may do without asking.
@@ -377,18 +598,36 @@
-
Default topology
-
Agent topology to use for multi-agent pipeline execution.
+
Lite mode
+
Use simplified prompts optimized for small LLMs (<7B params) like tinyllama, phi-3-mini, gemma-2b, qwen2.5:1.5b.
- +
+
+
+ +
Agent topology
+
+ A topology decides which agents run and in what order. Choose + Automatic to let GitPilot route each request on + intent, or pin a fixed pipeline for predictable behaviour. +
+ + + +
+
Editor Integration
+
What GitPilot shows inside your editor.
@@ -443,7 +682,6 @@
-
Settings saved
diff --git a/extensions/vscode/src/ui/webview/gitpilotWorkspace.css b/extensions/vscode/src/ui/webview/gitpilotWorkspace.css index 67af0c0..9c0bac7 100644 --- a/extensions/vscode/src/ui/webview/gitpilotWorkspace.css +++ b/extensions/vscode/src/ui/webview/gitpilotWorkspace.css @@ -154,11 +154,61 @@ textarea { line-height: 1.55; } +/* + * The composer is the one framed object on the page. + * + * Everything else — the transcript, the landing copy, the suggestions — sits + * on the editor background, so the box you type into is unmistakably the + * thing to do next. The frame lives on the wrapper rather than the textarea + * so the chips and the button row sit inside it. + */ +.chat-compose { + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 60%, transparent); + padding: 12px 12px 10px; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.chat-compose:focus-within { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.chat-compose textarea { + min-height: 74px; + border: none; + background: none; + padding: 2px 2px 0; + border-radius: 0; + resize: none; +} + +.chat-compose textarea:focus-visible { outline: none; } + +/* The panel is one column on a flat ground; only the composer is framed. */ +#chat-section.surface { + background: none; + border: none; + box-shadow: none; + overflow: visible; +} + +/* + * A measured column, not the full width of a monitor. + * + * This is an editor tab now, so on a wide screen the composer would otherwise + * stretch to 2000px and the conversation would read like a spreadsheet. The + * column stays the width a person can actually read. + */ .app { display: grid; gap: 10px; min-height: calc(100vh - 20px); align-content: start; + width: 100%; + max-width: 940px; + margin: 0 auto; } .surface { @@ -180,22 +230,44 @@ textarea { gap: 12px; } +/* + * A thin action bar. + * + * It used to be a status card carrying provider, connection, model, repo and + * branch — all of which the sidebar and the landing page already say — with a + * Disconnected pill beside a Ready pill. Restraint here is what lets the + * conversation be the thing you look at. + */ .header { - padding: 16px; + padding: 8px 10px; display: grid; - gap: 12px; - background: - radial-gradient(circle at top right, color-mix(in srgb, var(--accent) 12%, transparent), transparent 32%), - linear-gradient(180deg, color-mix(in srgb, var(--surface-2) 72%, transparent), color-mix(in srgb, var(--surface) 100%, transparent)); + gap: 0; + background: none; + border: none; + box-shadow: none; } .header-top { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; + min-height: 28px; +} + +/* Named only once there is a task to name. */ +.header-title { + font-size: var(--font-sm); + font-weight: 600; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + animation: messageIn .18s ease both; } +.header-title.hidden { display: none; } + .eyebrow { display: inline-flex; align-items: center; @@ -396,6 +468,16 @@ details.disclosure summary::-webkit-details-marker { display: none; } gap: 8px; } +/* The shape of a diff, stated in the header so the rows stay optional. */ +.summary-stat { + margin-left: auto; + margin-right: 8px; + color: var(--muted); + font-size: var(--font-xs); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .chevron { color: var(--muted); transition: transform var(--transition-fast); @@ -540,6 +622,132 @@ details[open] .chevron { gap: 8px; } +/* ─── Context chips ─── + * + * What GitPilot will look at, stated before you send. Selecting code attaches + * one on its own, because naming the file you mean is the most tedious step + * of an AI coding workflow. Every chip is removable: context you cannot see + * or take back is context you cannot trust. + */ +.context-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.context-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 3px 4px 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 88%, transparent); + color: var(--muted); + font-size: var(--font-xs); + animation: messageIn .16s ease both; +} + +.context-chip .chip-icon { color: var(--accent); flex-shrink: 0; } + +.context-chip .chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} + +.context-chip .chip-remove { + border: none; + background: none; + color: var(--muted); + font-size: 10px; + line-height: 1; + padding: 3px 4px; + border-radius: 999px; + cursor: pointer; + flex-shrink: 0; + transition: background 120ms ease, color 120ms ease; +} + +.context-chip .chip-remove:hover { + background: color-mix(in srgb, var(--fg) 12%, transparent); + color: var(--fg); +} + +/* ─── Inline completion for @ and / ─── + * + * A dropdown anchored to the composer rather than a modal quick pick: the + * sentence you are part-way through writing stays on screen while you choose. + */ +.compose-field { position: relative; } + +.compose-popup { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 6px); + z-index: 30; + max-height: 260px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.30); + animation: activityFadeIn .12s ease both; +} + +.popup-row { + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 11px; + font-size: var(--font-sm); + cursor: pointer; +} + +.popup-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); } +.popup-row:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } + +.popup-label { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popup-detail { + color: var(--muted); + font-size: var(--font-xs); + margin-left: auto; + white-space: nowrap; +} + +/* The + that opens a file picker. Neutral: the send button is the one action + on this row that deserves the accent. */ +.compose-chip { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: none; + color: var(--muted); + font-size: 15px; + line-height: 1; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.compose-chip:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg); +} + .chat-shell { display: grid; gap: 10px; @@ -551,24 +759,42 @@ details[open] .chevron { padding: 0; display: grid; gap: 8px; - max-height: 380px; + /* A fixed cap wastes a tall panel and cramps a short one. Bounded by the + viewport so the composer below stays reachable without a long scroll. */ + max-height: min(62vh, 760px); overflow: auto; scroll-behavior: smooth; } +/* + * A message is text, not a card. + * + * Boxing every turn makes a long conversation visually exhausting — card, + * then card, then card. Cards are reserved for things you can act on: a + * suggested change, an approval, a tool-activity group. The role is carried + * by a 2px rule instead of a border-and-fill, which reads at a glance without + * competing with the interactive blocks below it. + */ .chat-item { - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - border-left: 3px solid transparent; - padding: 11px 12px; + background: transparent; + border: none; + border-left: 2px solid transparent; + border-radius: 0; + padding: 4px 0 4px 11px; opacity: 0; transform: translateY(8px); animation: messageIn .18s ease forwards; } -.chat-item.user { border-left-color: var(--accent); } -.chat-item.assistant { border-left-color: var(--success); } +/* The user's own turn is the one worth setting apart; a faint fill does it + without the weight of a full card. */ +.chat-item.user { + border-left-color: var(--accent); + background: color-mix(in srgb, var(--accent) 7%, transparent); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + padding-right: 11px; +} +.chat-item.assistant { border-left-color: transparent; padding-left: 13px; } .chat-item.system { border-left-color: var(--warning); } .chat-role-row { @@ -587,12 +813,22 @@ details[open] .chevron { letter-spacing: .08em; } +/* + * The timestamp is reference material, not part of reading the conversation. + * A column of times down the right-hand edge of every turn is visual noise + * you scan past; on hover it is exactly as available as before. + */ .chat-time { color: var(--muted); font-size: var(--font-xs); white-space: nowrap; + opacity: 0; + transition: opacity 120ms ease; } +.chat-item:hover .chat-time, +.chat-item:focus-within .chat-time { opacity: 1; } + .chat-content { font-size: var(--font-md); line-height: 1.62; @@ -696,45 +932,102 @@ details[open] .chevron { align-items: center; } -/* ─── Execution mode selector (Auto / Ask / Plan) ─── */ -.mode-selector { +/* ─── The composer row ─── + * + * Left: what acts on the message you are writing. Right: how it will be run, + * and one accent-coloured button to run it. A three-segment permission + * control ate the width the composer needed, so it became a menu that still + * names every mode — and now says what each one will and will not do, which + * the segments only managed in a tooltip. + */ +.row-spacer { flex: 1; } + +.mode-picker { position: relative; flex-shrink: 0; } + +.mode-trigger { display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border); - overflow: hidden; - flex-shrink: 0; -} -.mode-btn { + background: none; + color: var(--fg); font-family: inherit; font-size: var(--font-xs); font-weight: 600; - padding: 5px 10px; - color: var(--muted); - background: transparent; - border: none; - cursor: pointer; - transition: background 100ms ease, color 100ms ease; line-height: 1; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); } -.mode-btn:not(:last-child) { - border-right: 1px solid var(--border); + +.mode-trigger:hover { background: color-mix(in srgb, var(--fg) 7%, transparent); } +.mode-trigger .caret { color: var(--muted); font-size: 10px; } + +.mode-menu { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + z-index: 40; + min-width: 268px; + display: grid; + padding: 5px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.34); + animation: activityFadeIn .12s ease both; } -.mode-btn:hover { + +.mode-btn { + display: grid; + gap: 2px; + justify-items: start; + text-align: left; + font-family: inherit; + padding: 8px 10px; + border: none; + border-radius: 8px; color: var(--fg); - background: rgba(255, 255, 255, 0.04); + background: none; + cursor: pointer; + transition: background 100ms ease; } -.mode-btn.active { - color: var(--accent-bright, #ff7a3c); - background: rgba(255, 122, 60, 0.1); + +.mode-btn:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } + +.mode-name { font-size: var(--font-sm); font-weight: 600; } + +.mode-detail { + font-size: var(--font-xs); + color: var(--muted); + line-height: 1.4; + font-weight: 400; } +.mode-btn.active .mode-name { color: var(--accent-bright, #ff7a3c); } +.mode-btn.active { background: rgba(255, 122, 60, 0.1); } + +/* + * One accent-coloured control on the page, and it is small. + * + * A full-width Send bar shouted louder than the question above it; the arrow + * is unmistakable, and Enter is what people actually press. + */ .primary-row #send-btn { - flex: 1; -} -.primary-row #new-chat-btn { + width: 34px; + height: 30px; flex-shrink: 0; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; } +.primary-row #send-btn svg { width: 15px; height: 15px; } +.primary-row #send-btn:active { transform: scale(0.94); } + .secondary-row { display: flex; gap: 8px; @@ -955,6 +1248,7 @@ details[open] .chevron { /* ─── Stop button (replaces Send during generation) ─── */ +/* Stopping is a different act, so it looks like one. */ #send-btn.stop-mode { background: var(--danger-soft); color: var(--danger); @@ -1012,68 +1306,160 @@ details[open] .chevron { /* ─── Empty state ─── */ +/* ─── The landing page ─── + * + * This is the empty state of the one GitPilot tab, so the landing page and + * the conversation are the same surface at two moments. It is generous + * because it is the first thing anyone sees, and it disappears entirely the + * moment there is a transcript to read instead. + */ .chat-empty-state { display: grid; gap: 14px; justify-items: center; - padding: 28px 16px 20px; + padding: clamp(24px, 7vh, 72px) 16px 24px; text-align: center; + animation: homeIn .26s cubic-bezier(0.16, 1, 0.3, 1) both; } -.empty-icon { - width: 44px; - height: 44px; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent); +@keyframes homeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} + +.home-brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + +.home-mark { + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--accent); + color: #fff; display: flex; align-items: center; justify-content: center; - font-size: var(--font-sm); + font-size: 15px; font-weight: 800; - border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); - box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 6%, transparent); + letter-spacing: -0.4px; } -.empty-heading { - font-size: var(--font-lg); +.home-name { + font-size: clamp(20px, 2.4vw, 27px); font-weight: 700; + letter-spacing: -0.6px; color: var(--fg); } -.empty-subtext { - font-size: var(--font-sm); +.home-headline { + margin: 0; + font-size: clamp(24px, 4vw, 38px); + font-weight: 700; + letter-spacing: -1.1px; + line-height: 1.15; + color: var(--fg); +} + +.home-subhead { + margin: 0; + font-size: var(--font-md); color: var(--muted); line-height: 1.5; - max-width: 280px; + max-width: 46ch; } -.empty-suggestions { +/* Stated once, quietly, and only while the landing page is on screen. */ +.home-trust { display: flex; + align-items: center; + justify-content: center; flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + font-size: var(--font-xs); + color: var(--muted); +} + +.home-trust .trust-item { + display: inline-flex; + align-items: center; gap: 6px; +} + +.home-trust .trust-sep { opacity: 0.45; } + +.home-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; +} + +.home-dot.ok { background: var(--success); } +.home-dot.bad { background: var(--danger); } +.home-dot.pending { + background: var(--warning); + animation: thinkingPulse 1.4s ease-in-out infinite; +} + +/* ─── The landing page's shortcuts, below the composer ─── */ +.home-actions { + display: grid; + gap: 0; + justify-items: center; + animation: homeIn .26s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.home-actions.hidden { display: none; } + +.empty-suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; justify-content: center; - margin-top: 2px; + margin-top: 10px; } +/* + * Neutral outlines, not accent pills. Four coloured buttons compete with the + * send button and turn a landing page into a toolbar; the composer has to + * stay the one obvious thing to do. + */ .suggestion-chip { - padding: 6px 12px; - border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 9px; font-size: var(--font-sm); - font-weight: 600; - background: var(--secondary); - color: var(--secondary-fg); + font-weight: 500; + background: none; + color: var(--muted); border: 1px solid var(--border); cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), + color var(--transition-fast), transform var(--transition-fast); } +.suggestion-chip svg { + width: 14px; + height: 14px; + opacity: 0.8; + flex-shrink: 0; +} + .suggestion-chip:hover { - border-color: color-mix(in srgb, var(--accent) 32%, var(--border)); - background: var(--accent-soft); + border-color: color-mix(in srgb, var(--fg) 22%, transparent); + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: var(--fg); transform: translateY(-1px); } @@ -1146,18 +1532,81 @@ details[open] .chevron { flex-wrap: wrap; } -/* ─── Tool activity feed ─── */ +/* ─── Tool activity, inline in the transcript ─── + * + * The work GitPilot did is part of the conversation, so it lives in the flow + * and stays there once the task is over. Collapsed, it is one quiet line — + * "Investigated 4 steps" — because a transcript that reads as prose is worth + * more than one that reads as a log. Expanded, every call is still there. + */ +.activity-block { + padding: 2px 0 2px 13px; +} -.tool-activity-feed { - padding: 6px 0; +.activity-summary { + display: inline-flex; + align-items: center; + gap: 7px; + width: 100%; + padding: 4px 6px; + margin-left: -6px; + border: none; + border-radius: var(--radius-sm, 8px); + background: none; + color: var(--muted); + font: inherit; + font-size: var(--font-sm); + text-align: left; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.activity-summary:hover { + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: var(--fg); +} + +.activity-caret { + font-size: 9px; + width: 10px; + flex-shrink: 0; + transition: transform 140ms ease; +} + +.activity-glyph { + font-size: var(--font-xs); + width: 13px; + text-align: center; + flex-shrink: 0; +} + +.activity-block.running .activity-glyph { + color: var(--accent); + animation: thinkingPulse 1.3s ease-in-out infinite; +} +.activity-block.failed .activity-glyph { color: var(--danger); } +.activity-block:not(.running):not(.failed) .activity-glyph { color: var(--success); } + +.activity-summary-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Collapsed by default: the summary is the answer, the calls are the detail. */ +.activity-block > .activity-list { display: none; } +.activity-block.open > .activity-list { + display: grid; + animation: activityFadeIn .16s ease both; } .activity-list { list-style: none; - margin: 0; - padding: 0; + margin: 3px 0 4px; + padding: 0 0 0 17px; display: grid; gap: 4px; + border-left: 1px solid var(--border); } .activity-item { diff --git a/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html b/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html index 56d7390..4411f12 100644 --- a/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html +++ b/extensions/vscode/src/ui/webview/gitpilotWorkspaceTemplate.html @@ -9,16 +9,18 @@
+
-
-
- - GitPilot -
-
Provider state
-
Repo state
-
+
+
-
- Disconnected - Ready -
-
-
- - Quick Actions - - -
-
- - - - - - - - - - - - - -
-
-
-
-
-

Chat

-
-
+
-
GP
-
What can I help you with?
-
Ask GitPilot to explain, review, change, or test your project.
-
- - - - +
+ GP + GitPilot
+

What are we building?

+

Ask GitPilot to investigate, explain, change, review, or test your code.

-
- -
@@ -233,7 +260,8 @@

Chat