diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c99ab914..dd5bb050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,12 +61,27 @@ jobs: with: path: dist-vsix merge-multiple: true + - name: Resolve release tag from package.json + id: meta + run: | + version="$(node -p "require('./package.json').version")" + if [ -z "$version" ]; then + echo "::error::Could not read version from package.json" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" - name: Generate release notes run: cp CHANGELOG.md release-notes.md - - uses: softprops/action-gh-release@v2 + # workflow_dispatch has no tag ref; create/update the GitHub Release from package.json. + - uses: softprops/action-gh-release@v3 with: + tag_name: ${{ steps.meta.outputs.tag }} + name: ${{ steps.meta.outputs.tag }} + target_commitish: ${{ github.sha }} body_path: release-notes.md files: 'dist-vsix/*.vsix' + fail_on_unmatched_files: true publish-marketplace: needs: package diff --git a/.vscode/settings.json b/.vscode/settings.json index edf706d3..1a100e79 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,7 @@ "wasms", "xenova" ], - "mitii.onboarding.completed": true + "mitii.onboarding.completed": true, + "mitii.provider.model": "my-qwen-64k:latest", + "mitii.provider.contextWindow": 45000 } diff --git a/README.md b/README.md index 2cbd5591..4edcbd05 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ License: AGPL v3 VS Code 1.124+ Node 20+ - Version 2.9.57 + Version 2.9.66 Documentation

diff --git a/apps/acp/package.json b/apps/acp/package.json index 6b58e605..a9216701 100644 --- a/apps/acp/package.json +++ b/apps/acp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/acp", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii ACP-lite stdio bridge (Phase 3). Decision Policy remains authority; V8 does not import ACP.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/cli/README.md b/apps/cli/README.md index de2f3fe7..e2192088 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -71,7 +71,7 @@ mitii export-session "Summarize this repo" --out session.json --echo | `run --auto ""` | Unattended CI run (agent + apply autonomy; no prompts) | | `session` | Interactive prompt loop with MITII banner | | `index` | Full workspace index + publish repository state | -| `review` | Deterministic review prep / SARIF (`--preview`, `--from`/`--to`, `--commit`, `--format`, `--output`). For LLM findings use `mitii ask … --skill code-review-and-quality` or VS Code **Review** mode | +| `review` | Deterministic review prep / SARIF (`--preview`, `--from`/`--to`, `--commit`, `--format`, `--output`). For LLM findings use `mitii ask … --skill code-review-and-quality` (recipe/skill) or the VS Code **Review** button for working-tree changes | | `status` | Show latest persisted repository state | | `export-session` | Run ask and write secret-free JSON export | | `connect` | Bridge Mitii into Telegram, Discord, or Slack | diff --git a/apps/cli/package.json b/apps/cli/package.json index 50c08350..278df836 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/cli", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii headless CLI over @mitii/sdk. Phase 0: --origin/--autonomy/--agent for CI automation.", "license": "AGPL-3.0-or-later", "publishConfig": { diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 7a9d97ea..f4f7e55b 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -46,7 +46,7 @@ Commands: --format json|sarif Output shape (default json) --output Write result to file --effort low|medium|high Prep effort band - Full LLM review: VS Code Review mode, or: + Full LLM review: VS Code Review button (git changes), or: mitii ask "review these changes" --mode ask --skill code-review-and-quality status Show latest persisted repository state export-session Run ask and write secret-free JSON export diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 5938f960..38fa84b6 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/daemon", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii automation daemon process entry (Phase 1). Long-lived schedule runner.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 9466c9eb..ca5e2f69 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -33,7 +33,7 @@ For cloud providers, run **Mitii: Set Provider API Key** (stored in VS Code Secr - **Repository-aware context** - SQLite FTS5, symbols, optional vectors, repo map, diagnostics, Git state, and `@` attachments - **Skills** - force-attach playbooks with `/` or `@skill:id` in chat (up to 3 per message); workspace skills in `.mitii/skills/` -- **Ask / Plan / Agent** - read-only Q&A, structured plans, and controlled edits. Use the collapsible **Review** bar above chat (file count → Review) for structured working-tree findings via `emit_review_finding` — not a fourth mode. After findings appear, use **Dismiss**, **Fix**, or **Fix all** (Agent + `fix-review-findings` recipe). +- **Ask / Plan / Agent** - read-only Q&A, structured plans, and controlled edits. The composer **Review** strip lists **this chat’s** Mitii file changes (expand with **Review**). Enable **Settings → Features → Code Review** to add **Code Review (N)** over the full git working tree, then severity-tagged findings with **Fix** / **Fix all**. Free-form “code review” chat is not a structured review run. - **FIM autocomplete** - optional inline ghost text from a low-latency OpenAI-compatible `prompt` + `suffix` endpoint - **Safety** - configurable approvals, path containment, command policy, pre-write checkpoints, workspace trust - **Providers** - Echo, Anthropic (Claude), Gemini, and OpenAI-compatible endpoints (DeepSeek, OpenRouter, Azure, Ollama, custom `/v1`) @@ -45,7 +45,7 @@ For cloud providers, run **Mitii: Set Provider API Key** (stored in VS Code Secr | Command | Purpose | |---|---| | **Mitii: Open Chat** | Open the sidebar | -| **Mitii: Review Working Tree Changes** | Switch to Review mode and run a structured review | +| **Mitii: Review Working Tree Changes** | Expand the chat review strip. **Review** = this-chat Mitii edits. With Settings → Features → Code Review on, **Code Review (N)** runs LLM analysis over all `N` git changes | | **Mitii: Index Workspace** | Rebuild repository index | | **Mitii: Show Settings** | Provider, index, MCP, workspace | | **Mitii: Toggle Autocomplete** | Enable or disable FIM inline suggestions | diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 8ea88bf9..c2630141 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with repository-aware context and controlled execution", - "version": "2.9.57", + "version": "2.9.66", "publisher": "mitii", "license": "AGPL-3.0-or-later", "icon": "media/mitii-logo.png", @@ -1028,6 +1028,11 @@ "default": true, "description": "Show live reasoning / thinking activity in the Mitii chat UI" }, + "mitii.ui.features.codeReviewButton": { + "type": "boolean", + "default": false, + "description": "Show the Code Review button on the working-tree bar. Review always lists git changes; Code Review runs an LLM analysis. Enable under Settings → Features." + }, "mitii.ui.reasoningPreviewMaxChars": { "type": "number", "default": 8000, @@ -1365,6 +1370,33 @@ } } } + }, + "mitii.tokenBudget.visiblePlanMinUsableRatio": { + "type": "number", + "default": 0.35, + "minimum": 0, + "maximum": 1, + "description": "Visible-plan affordability threshold as a fraction of the context window." + }, + "mitii.tokenBudget.changeImpactMinUsableRatio": { + "type": "number", + "default": 0.35, + "minimum": 0, + "maximum": 1, + "description": "Change-impact affordability threshold as a fraction of the context window." + }, + "mitii.tokenBudget.maxPatchesPerCallCap": { + "type": "number", + "default": 96, + "minimum": 1, + "description": "Maximum patches allowed in one mutation call." + }, + "mitii.loopPolicy.maxStructuredReviewRecoveries": { + "type": "number", + "default": 2, + "minimum": 0, + "maximum": 8, + "description": "Recovery attempts when a structured review ends without findings (0 disables)." } } } diff --git a/apps/vscode/src/automationHost.ts b/apps/vscode/src/automationHost.ts index 978d1047..c6a970df 100644 --- a/apps/vscode/src/automationHost.ts +++ b/apps/vscode/src/automationHost.ts @@ -1,5 +1,6 @@ import { AutomationService } from '@mitii/automation'; +import { openSqliteDatabase } from './nativeSqlite.js'; import type { AutomationRunView, AutomationSpecView, @@ -56,7 +57,11 @@ export function handleAutomationHostMessage(input: { post: (msg: HostToWebviewMessage) => void; workspaceRoot: string | undefined; }): void { - const service = new AutomationService({}); + const service = new AutomationService({ + // Bundled better-sqlite3 has no default .node next to extension.js; use the + // Electron-staged binding under dist/native (same path as workspace index). + openDatabase: openSqliteDatabase, + }); try { if (input.message.type === 'requestAutomations') { // Reconcile workspace file specs so the panel mirrors `mitii serve`. diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index bb746715..06dbdb53 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -9,6 +9,7 @@ import { unwrapRecipeAnswer, type MitiiWritingRecipeId, IndexLockedError, + resolveMaximumIndexFiles, } from '@mitii/host'; import { isSecurityConcern, WorkspaceIgnorePolicy } from '@mitii/v8'; @@ -378,6 +379,11 @@ export function activate(context: ExtensionContext): void { const snapshot = await buildWorkspaceSnapshot({ workspaceRoot: root, workspaceId, + maxFiles: resolveMaximumIndexFiles( + vscode.workspace + .getConfiguration('mitii') + .get('workspace.maximumIndexFiles'), + ), }); fileCount = snapshot.fileCount; truncated = snapshot.truncated; @@ -424,7 +430,8 @@ export function activate(context: ExtensionContext): void { const reviewChanges = async (): Promise => { await vscode.commands.executeCommand('mitii.sidebar.focus'); - sidebar?.post({ type: 'startReview', autoRun: true }); + // Show git changes only — LLM Code Review is the separate bar button. + sidebar?.post({ type: 'startReview', autoRun: false }); }; const runWritingRecipe = async ( diff --git a/apps/vscode/src/hostAsk.ts b/apps/vscode/src/hostAsk.ts index 2ee17dc5..17207211 100644 --- a/apps/vscode/src/hostAsk.ts +++ b/apps/vscode/src/hostAsk.ts @@ -10,7 +10,7 @@ import { type RunEvent, type TaskList, } from '@mitii/sdk'; -import { loadUserSafetyRules } from '@mitii/host'; +import { loadUserSafetyRules, resolveMaximumIndexFiles } from '@mitii/host'; import type * as vscode from 'vscode'; import { formatDiagnosticsPromptBlock } from './context/diagnosticsContext.js'; @@ -166,6 +166,8 @@ export async function runAskInOutputChannel(options: { approvalMode?: string; pinnedPaths?: string[]; requiredSkillIds?: string[]; + /** Skills that must not be auto-matched for this run. */ + excludedSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / host pin). */ requiredMcpServerIds?: string[]; workspaceId?: string; @@ -475,6 +477,9 @@ export async function runAskInOutputChannel(options: { ...(options.requiredSkillIds && options.requiredSkillIds.length > 0 ? { requiredSkillIds: [...options.requiredSkillIds] } : {}), + ...(options.excludedSkillIds && options.excludedSkillIds.length > 0 + ? { excludedSkillIds: [...options.excludedSkillIds] } + : {}), ...(options.requiredMcpServerIds && options.requiredMcpServerIds.length > 0 ? { requiredMcpServerIds: [...options.requiredMcpServerIds] } @@ -739,6 +744,11 @@ async function autoPublishFullOrSnapshot(options: { const snap = await buildWorkspaceSnapshot({ workspaceRoot: options.workspaceRoot, workspaceId: options.workspaceId, + maxFiles: resolveMaximumIndexFiles( + options.vs.workspace + .getConfiguration('mitii') + .get('workspace.maximumIndexFiles'), + ), }); await options.client.publishRepositoryState(snap.candidate); options.channel.appendLine( diff --git a/apps/vscode/src/liveSandboxProcess.ts b/apps/vscode/src/liveSandboxProcess.ts new file mode 100644 index 00000000..e0e095f4 --- /dev/null +++ b/apps/vscode/src/liveSandboxProcess.ts @@ -0,0 +1,85 @@ +import type * as vscode from 'vscode'; +import type { ProcessPort, ProcessExecRequest } from '@mitii/v8'; +import { + createSandboxedProcessPort, + resolveSandboxPolicy, + resolveSandboxSettingsFromPreset, + type SandboxBackend, + type SandboxNetworkMode, +} from '@mitii/host'; + +type ResolvedSandboxSettings = { + enabled: boolean; + network: SandboxNetworkMode; +}; + +function readSandboxSettings(vs: typeof vscode): ResolvedSandboxSettings { + const cfg = vs.workspace.getConfiguration('mitii'); + const configured = (key: string): boolean => { + const value = cfg.inspect(key); + return ( + value?.globalValue !== undefined || + value?.workspaceValue !== undefined || + value?.workspaceFolderValue !== undefined + ); + }; + return resolveSandboxSettingsFromPreset({ + approvalMode: cfg.get('safety.approvalMode') ?? 'guided', + ...(configured('safety.sandbox.enabled') + ? { enabled: cfg.get('safety.sandbox.enabled') === true } + : {}), + ...(configured('safety.sandbox.network') + ? { network: cfg.get('safety.sandbox.network') ?? 'deny' } + : {}), + }); +} + +function settingsKey(settings: ResolvedSandboxSettings): string { + return `${settings.enabled ? 1 : 0}:${settings.network}`; +} + +/** + * Process port that refreshes access policy when settings change, without + * rebuilding a wrapper on every exec. + * + * Once sandbox has been enabled for this port, it cannot be disabled through + * live settings (fail closed). Network may still tighten/loosen with approval + * mode so Guided ↔ Full access keeps working mid-session. + */ +export function createLiveSandboxedProcessPort( + vs: typeof vscode, + inner: ProcessPort, + workspaceRoot: string, + backend: SandboxBackend, +): ProcessPort { + let cachedKey: string | undefined; + let cachedPort: ProcessPort | undefined; + let sandboxLatchedOn = false; + + const resolvePort = (): ProcessPort => { + const raw = readSandboxSettings(vs); + const settings: ResolvedSandboxSettings = { + // Latch: after sandbox has run enabled, ignore live disable. + enabled: raw.enabled || sandboxLatchedOn, + network: raw.network, + }; + if (settings.enabled) sandboxLatchedOn = true; + + const key = settingsKey(settings); + if (cachedPort && cachedKey === key) return cachedPort; + + cachedKey = key; + cachedPort = createSandboxedProcessPort( + inner, + resolveSandboxPolicy({ ...settings, workspaceRoot }), + backend, + ); + return cachedPort; + }; + + return { + execFile(request: ProcessExecRequest) { + return resolvePort().execFile(request); + }, + }; +} diff --git a/apps/vscode/src/nativeSqlite.ts b/apps/vscode/src/nativeSqlite.ts index 88a73569..0055b01d 100644 --- a/apps/vscode/src/nativeSqlite.ts +++ b/apps/vscode/src/nativeSqlite.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import Database from 'better-sqlite3'; @@ -15,10 +15,27 @@ export function openSqliteDatabase( options: SqliteOptions = {}, ): SqliteDatabase { const nativeBinding = resolveNativeSqliteBinding(); - return new Database(filename, { - ...options, - ...(nativeBinding ? { nativeBinding } : {}), - }); + try { + return new Database(filename, { + ...options, + ...(nativeBinding ? { nativeBinding } : {}), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + // Surface Electron ABI / missing binding as an actionable Mitii error so the + // host does not silently fall back to a fingerprint pin without guidance. + if ( + /NODE_MODULE_VERSION|was compiled against a different|Cannot find module|better_sqlite3/i.test( + detail, + ) + ) { + throw new Error( + `Mitii SQLite failed to open (${detail}). For VS Code/Cursor Extension Host run \`pnpm run rebuild:native\` (or \`MITII_EDITOR=cursor pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} matches Electron, then reload the window.`, + { cause: error instanceof Error ? error : undefined }, + ); + } + throw error; + } } export function resolveNativeSqliteBinding(): string | undefined { @@ -31,10 +48,16 @@ export function resolveNativeSqliteBinding(): string | undefined { return undefined; } + const here = __dirname; const candidates = [ - join(__dirname, 'native', NATIVE_BINDING_FILE), - join(__dirname, '..', 'dist', 'native', NATIVE_BINDING_FILE), - join(__dirname, '..', 'native', NATIVE_BINDING_FILE), + join(here, 'native', NATIVE_BINDING_FILE), + join(here, '..', 'dist', 'native', NATIVE_BINDING_FILE), + join(here, '..', 'native', NATIVE_BINDING_FILE), + // Extension root when compiled to dist/*.js + join(here, '..', '..', 'dist', 'native', NATIVE_BINDING_FILE), + join(here, '..', '..', 'native', NATIVE_BINDING_FILE), + // Walk up from nested dist folders + ...ancestorNativeCandidates(here), ]; const bundled = candidates.find((candidate) => existsSync(candidate)); if (bundled) { @@ -42,6 +65,19 @@ export function resolveNativeSqliteBinding(): string | undefined { } throw new Error( - `Mitii SQLite native binding is missing. Checked: ${candidates.join(', ')}. Run \`pnpm run build:all\` (or \`pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} is staged into dist/native for the Electron extension host.`, + `Mitii SQLite native binding is missing. Checked: ${[...new Set(candidates)].join(', ')}. Run \`pnpm run build:all\` (or \`pnpm run rebuild:native\` / \`MITII_EDITOR=cursor pnpm run rebuild:native\`) so ${NATIVE_BINDING_FILE} is staged into dist/native for the Electron extension host, then reload the window.`, ); } + +function ancestorNativeCandidates(startDir: string): string[] { + const out: string[] = []; + let dir = startDir; + for (let i = 0; i < 6; i += 1) { + out.push(join(dir, 'native', NATIVE_BINDING_FILE)); + out.push(join(dir, 'dist', 'native', NATIVE_BINDING_FILE)); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return out; +} diff --git a/apps/vscode/src/ports.ts b/apps/vscode/src/ports.ts index 75812738..d259c6b3 100644 --- a/apps/vscode/src/ports.ts +++ b/apps/vscode/src/ports.ts @@ -26,19 +26,17 @@ import { createHostNetworkPort, createHostRepositoryGraphPort, createOptionalSearchPort, - createSandboxedProcessPort, createWorkspaceCheckpointStore, createWorkspaceKnowledgeGraph, createWorkspaceVerificationStore, detectSandboxBackend, resolveMemoryEmbeddingPort, - resolveSandboxPolicy, - resolveSandboxSettingsFromPreset, resolveProviderApiKey, type SandboxBackendPrefer, } from '@mitii/host'; import type * as vscode from 'vscode'; +import { createLiveSandboxedProcessPort } from './liveSandboxProcess.js'; import { VscodeDiagnosticsPort } from './diagnosticsPort.js'; import { getSharedMcpManager } from './mcp/manager.js'; import { defaultMcpSettings, readMcpSettings } from './mcpConfig.js'; @@ -280,25 +278,6 @@ export async function createVscodeClient( env: searchEnv, }); const cfg = vs.workspace.getConfiguration('mitii'); - const sandboxInspectEnabled = cfg.inspect('safety.sandbox.enabled'); - const sandboxInspectNetwork = cfg.inspect('safety.sandbox.network'); - const sandboxEnabledUnset = - sandboxInspectEnabled?.globalValue === undefined && - sandboxInspectEnabled?.workspaceValue === undefined && - sandboxInspectEnabled?.workspaceFolderValue === undefined; - const sandboxNetworkUnset = - sandboxInspectNetwork?.globalValue === undefined && - sandboxInspectNetwork?.workspaceValue === undefined && - sandboxInspectNetwork?.workspaceFolderValue === undefined; - const sandboxResolved = resolveSandboxSettingsFromPreset({ - approvalMode: cfg.get('safety.approvalMode') ?? 'guided', - ...(sandboxEnabledUnset - ? {} - : { enabled: cfg.get('safety.sandbox.enabled') === true }), - ...(sandboxNetworkUnset - ? {} - : { network: cfg.get('safety.sandbox.network') ?? 'deny' }), - }); const sandboxBackendRaw = cfg.get('safety.sandbox.backend') ?? 'auto'; const sandboxPrefer: SandboxBackendPrefer = sandboxBackendRaw === 'docker' || @@ -314,13 +293,10 @@ export async function createVscodeClient( ? new ToolRuntimePipeline( { fileSystem, - process: createSandboxedProcessPort( + process: createLiveSandboxedProcessPort( + vs, new NodeProcessAdapter(), - resolveSandboxPolicy({ - enabled: sandboxResolved.enabled, - network: sandboxResolved.network, - workspaceRoot, - }), + workspaceRoot, detectSandboxBackend({ prefer: sandboxPrefer }), ), network, diff --git a/apps/vscode/src/protocol.ts b/apps/vscode/src/protocol.ts index 58bae2d4..2f46110c 100644 --- a/apps/vscode/src/protocol.ts +++ b/apps/vscode/src/protocol.ts @@ -16,6 +16,7 @@ export type SettingsTab = | 'modes' | 'context' | 'integrations' + | 'features' | 'debug'; export type McpTransport = 'stdio' | 'sse' | 'streamable-http'; @@ -218,6 +219,8 @@ export type SemanticIndexSource = | 'disabled'; export interface IndexStatusSnapshot { + discoveredFileCount?: number; + progressStage?: string; fileCount: number; truncated: boolean; maximumIndexFiles?: number; @@ -248,6 +251,15 @@ export interface WorkspaceSnapshotInfo { displayRoot?: string; } +/** Opt-in VS Code UI features (Settings → Features). */ +export interface UiFeaturesSnapshot { + /** + * When true, show a Code Review button beside Review on the working-tree bar. + * Default off — Review alone covers quick git-change review. + */ + codeReviewButton: boolean; +} + export interface UiSettingsSnapshot { showReasoning: boolean; reasoningPreviewMaxChars: number; @@ -273,6 +285,8 @@ export interface UiSettingsSnapshot { * key is a boolean leaf in VS Code settings.) */ modelIoLogging: boolean; + /** Opt-in IDE features (VS Code only). */ + features: UiFeaturesSnapshot; /** Window-proportional token budget tunables (Debug → developer). */ tokenBudget: TokenBudgetSettingsSnapshot; /** Agent Engine loop/stall threshold tunables (Debug → developer). */ @@ -419,12 +433,14 @@ export type UiSettingsPatch = Partial< | 'tokenBudget' | 'loopPolicy' | 'policyLab' + | 'features' > & { contextToggles?: Partial; runBudget?: Partial; modeDefaults?: Partial< Record<'ask' | 'plan' | 'agent', Partial> >; + features?: Partial; tokenBudget?: { enabled?: boolean; policy?: Record; @@ -684,6 +700,11 @@ export type WebviewToHostMessage = requiredSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / pin chips). */ requiredMcpServerIds?: string[]; + /** + * When mode is review: `changes` = quick diff bug scan (default); + * `code` = thorough code-review-and-quality pass. + */ + reviewKind?: 'changes' | 'code'; } | { type: 'cancel' } | { @@ -891,7 +912,7 @@ export type HostToWebviewMessage = | { type: 'paths.results'; requestId: string; suggestions: PathSuggestion[] } | { type: 'openSettings'; tab?: SettingsTab } | { type: 'setTab'; tab: UiNav } - /** Open chat in Review mode; optionally auto-start an LLM review. */ + /** Run a working-tree review via the Review button (not a chat mode). */ | { type: 'startReview'; autoRun?: boolean; prompt?: string } | { type: 'editorPin'; path: string; source?: ContextPinSource } | { type: 'editorUnpin'; path: string } diff --git a/apps/vscode/src/review/README.md b/apps/vscode/src/review/README.md new file mode 100644 index 00000000..be5d5af9 --- /dev/null +++ b/apps/vscode/src/review/README.md @@ -0,0 +1,22 @@ +# Review (host) + +Host-side review support for the VS Code sidebar. Webview dual-scope UI lives in +`apps/vscode/webview-ui/src/review/README.md`. + +## Contract + +| Concern | Module | Notes | +|---|---|---| +| Git working-tree snapshot | `../reviewDiff.ts` → `buildReviewDiff` | Feeds `setReviewDiff` / Code Review `(N)` | +| Per-file patches for LLM prep | `buildReviewFileDiffs` | Used when assembling review context | +| Finding parse + severity | `reviewFindingParse.ts` | Tool-output → finding DTO | +| Editor Problems + comment threads | `reviewFindingsPresenter.ts` | Severity-mapped diagnostics | +| Feature gate | `mitii.ui.features.codeReviewButton` | Sidebar persists via settings | + +## Dual scope (must stay aligned with webview) + +1. **Review** in the composer strip lists **this chat’s** `run.fileChanges` only. +2. **Code Review (N)** always analyzes the **full** git working tree from `buildReviewDiff`. +3. Findings / Fix surfaces only when the Code Review feature flag is on. + +Do not collapse these scopes in the host prompt for Code Review — the model MUST see the full dirty tree, not only the latest Mitii run. diff --git a/apps/vscode/src/review/reviewFindingParse.ts b/apps/vscode/src/review/reviewFindingParse.ts new file mode 100644 index 00000000..777780bd --- /dev/null +++ b/apps/vscode/src/review/reviewFindingParse.ts @@ -0,0 +1,196 @@ +import type * as vscode from 'vscode'; + +export type ReviewFindingSeverity = + | 'critical' + | 'high' + | 'medium' + | 'low' + | 'info'; + +export interface ReviewFindingView { + path: string; + content: string; + existingCode?: string; + suggestionCode?: string; + startLine?: number; + endLine?: number; + category?: string; + severity: ReviewFindingSeverity; + anchored?: boolean; + /** Set after a successful Fix / Fix all run. */ + status?: 'open' | 'fixed'; +} + +/** Stable id for matching findings across Fix runs. */ +export function reviewFindingKey(finding: { + path: string; + content: string; + startLine?: number; +}): string { + const path = finding.path.replace(/\\/g, '/').replace(/^\.\//, ''); + const line = finding.startLine ?? ''; + const content = finding.content.trim().slice(0, 160); + return `${path}|${line}|${content}`; +} + +export function parseFindingFromOutputPreview( + preview: string, +): ReviewFindingView | null { + const text = preview.trim(); + if (!text) return null; + + const jsonStart = text.indexOf('{'); + if (jsonStart < 0) { + return parseFindingFromSummary(text); + } + let raw = text.slice(jsonStart); + raw = raw.replace(/…$/, '').replace(/\.\.\.$/, ''); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + const fromFields = parseFindingFromLooseFields(text); + return fromFields ?? parseFindingFromSummary(text); + } + + const record = + parsed && typeof parsed === 'object' + ? ((parsed as { finding?: unknown }).finding && + typeof (parsed as { finding: unknown }).finding === 'object' + ? (parsed as { finding: Record }).finding + : (parsed as Record)) + : null; + if (!record) return parseFindingFromSummary(text); + + const path = String(record.path ?? '').trim(); + const content = String(record.content ?? '').trim(); + if (!path || !content) return parseFindingFromSummary(text); + + return { + path, + content, + existingCode: + typeof record.existingCode === 'string' ? record.existingCode : undefined, + suggestionCode: + typeof record.suggestionCode === 'string' + ? record.suggestionCode + : undefined, + startLine: + typeof record.startLine === 'number' ? record.startLine : undefined, + endLine: typeof record.endLine === 'number' ? record.endLine : undefined, + category: typeof record.category === 'string' ? record.category : undefined, + severity: normalizeSeverity( + typeof record.severity === 'string' ? record.severity : undefined, + ), + anchored: record.anchored === true, + }; +} + +/** Parse `finding path=… line=… sev=… cat=… :: message` summaries. */ +export function parseFindingFromSummary( + summary: string, +): ReviewFindingView | null { + const text = summary.trim(); + if (!text.toLowerCase().includes('finding') && !/\bpath=/.test(text)) { + return null; + } + const path = matchKv(text, 'path'); + const content = text.includes('::') + ? text.slice(text.indexOf('::') + 2).trim() + : (matchKv(text, 'msg') ?? matchStringField(text, 'content')); + if (!path || !content) return null; + const lineRaw = matchKv(text, 'line'); + const line = lineRaw && /^\d+$/.test(lineRaw) ? Number(lineRaw) : undefined; + return { + path, + content, + startLine: line, + endLine: line, + category: matchKv(text, 'cat') ?? undefined, + severity: normalizeSeverity(matchKv(text, 'sev') ?? undefined), + }; +} + +function parseFindingFromLooseFields(text: string): ReviewFindingView | null { + const path = matchStringField(text, 'path'); + const content = matchStringField(text, 'content'); + if (!path || !content) return null; + return { + path, + content, + existingCode: matchStringField(text, 'existingCode') ?? undefined, + startLine: matchNumberField(text, 'startLine') ?? undefined, + endLine: matchNumberField(text, 'endLine') ?? undefined, + category: matchStringField(text, 'category') ?? undefined, + severity: normalizeSeverity(matchStringField(text, 'severity') ?? undefined), + }; +} + +function matchKv(text: string, key: string): string | null { + const re = new RegExp(`\\b${key}=([^\\s]+)`); + const m = text.match(re); + return m?.[1] ? m[1].replace(/^"|"$/g, '') : null; +} + +export function normalizeSeverity( + value: string | undefined, +): ReviewFindingSeverity { + const raw = (value ?? 'medium').toLowerCase(); + if (raw === 'critical' || raw === 'blocker') return 'critical'; + if (raw === 'high' || raw === 'error') return 'high'; + if (raw === 'low' || raw === 'nit' || raw === 'fyi') return 'low'; + if (raw === 'info' || raw === 'hint') return 'info'; + return 'medium'; +} + +export function severityLabel(severity: ReviewFindingSeverity): string { + switch (severity) { + case 'critical': + return 'Critical'; + case 'high': + return 'High'; + case 'medium': + return 'Medium'; + case 'low': + return 'Low'; + case 'info': + return 'Info'; + } +} + +export function toDiagnosticSeverity( + vs: typeof vscode, + severity: ReviewFindingSeverity, +): vscode.DiagnosticSeverity { + switch (severity) { + case 'critical': + case 'high': + return vs.DiagnosticSeverity.Error; + case 'medium': + return vs.DiagnosticSeverity.Warning; + case 'low': + return vs.DiagnosticSeverity.Information; + case 'info': + return vs.DiagnosticSeverity.Hint; + } +} + +function matchStringField(text: string, key: string): string | null { + const re = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`); + const m = text.match(re); + if (!m?.[1]) return null; + try { + return JSON.parse(`"${m[1]}"`) as string; + } catch { + return m[1]; + } +} + +function matchNumberField(text: string, key: string): number | null { + const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`); + const m = text.match(re); + if (!m?.[1]) return null; + const n = Number(m[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} diff --git a/apps/vscode/src/review/reviewFindingsPresenter.ts b/apps/vscode/src/review/reviewFindingsPresenter.ts index 00f5403e..fe7ff610 100644 --- a/apps/vscode/src/review/reviewFindingsPresenter.ts +++ b/apps/vscode/src/review/reviewFindingsPresenter.ts @@ -1,38 +1,24 @@ import { join } from 'node:path'; import type * as vscode from 'vscode'; -export type ReviewFindingSeverity = - | 'critical' - | 'high' - | 'medium' - | 'low' - | 'info'; - -export interface ReviewFindingView { - path: string; - content: string; - existingCode?: string; - suggestionCode?: string; - startLine?: number; - endLine?: number; - category?: string; - severity: ReviewFindingSeverity; - anchored?: boolean; - /** Set after a successful Fix / Fix all run. */ - status?: 'open' | 'fixed'; -} - -/** Stable id for matching findings across Fix runs. */ -export function reviewFindingKey(finding: { - path: string; - content: string; - startLine?: number; -}): string { - const path = finding.path.replace(/\\/g, '/').replace(/^\.\//, ''); - const line = finding.startLine ?? ''; - const content = finding.content.trim().slice(0, 160); - return `${path}|${line}|${content}`; -} +import { + parseFindingFromOutputPreview, + parseFindingFromSummary, + reviewFindingKey, + severityLabel, + toDiagnosticSeverity, + type ReviewFindingView, +} from './reviewFindingParse.js'; + +export type { + ReviewFindingSeverity, + ReviewFindingView, +} from './reviewFindingParse.js'; +export { + parseFindingFromOutputPreview, + parseFindingFromSummary, + reviewFindingKey, +} from './reviewFindingParse.js'; /** * Surfaces Mitii review findings in the editor like a real code review: @@ -132,7 +118,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { const root = this.workspaceRoot(); if (!root) return; - // Clear previous editor markers, then rebuild. this.diagnostics.clear(); for (const thread of this.threads) { thread.dispose(); @@ -142,7 +127,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { const byUri = new Map(); for (const finding of this.findings) { - // Fixed findings stay in the list for UI strike-through, but drop editor noise. if (finding.status === 'fixed') continue; const abs = join(root, finding.path); @@ -168,13 +152,14 @@ export class ReviewFindingsPresenter implements vscode.Disposable { list.push(diagnostic); byUri.set(uri.toString(), list); - const bodyParts = [ - `**${label}**${category}`, - '', - finding.content, - ]; + const bodyParts = [`**${label}**${category}`, '', finding.content]; if (finding.existingCode?.trim()) { - bodyParts.push('', '```', finding.existingCode.trim().slice(0, 800), '```'); + bodyParts.push( + '', + '```', + finding.existingCode.trim().slice(0, 800), + '```', + ); } if (finding.suggestionCode?.trim()) { bodyParts.push( @@ -204,7 +189,6 @@ export class ReviewFindingsPresenter implements vscode.Disposable { this.diagnostics.set(this.vs.Uri.parse(uriKey), diags); } - // Open the first open finding once per run so subsequent findings don't steal focus. const first = this.findings.find((f) => f.status !== 'fixed'); if (first && !this.revealedFirst) { this.revealedFirst = true; @@ -235,166 +219,3 @@ export class ReviewFindingsPresenter implements vscode.Disposable { for (const d of this.disposables) d.dispose(); } } - -export function parseFindingFromOutputPreview( - preview: string, -): ReviewFindingView | null { - const text = preview.trim(); - if (!text) return null; - - // Prefer JSON object in the preview (may be truncated / compacted). - const jsonStart = text.indexOf('{'); - if (jsonStart < 0) { - return parseFindingFromSummary(text); - } - let raw = text.slice(jsonStart); - // Truncation marker from tool-runtime / audit preview. - raw = raw.replace(/…$/, '').replace(/\.\.\.$/, ''); - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - const fromFields = parseFindingFromLooseFields(text); - return fromFields ?? parseFindingFromSummary(text); - } - - const record = - parsed && typeof parsed === 'object' - ? ((parsed as { finding?: unknown; accepted?: unknown }).finding && - typeof (parsed as { finding: unknown }).finding === 'object' - ? ((parsed as { finding: Record }).finding) - : (parsed as Record)) - : null; - if (!record) return parseFindingFromSummary(text); - - const path = String(record.path ?? '').trim(); - const content = String(record.content ?? '').trim(); - if (!path || !content) return parseFindingFromSummary(text); - - return { - path, - content, - existingCode: - typeof record.existingCode === 'string' ? record.existingCode : undefined, - suggestionCode: - typeof record.suggestionCode === 'string' - ? record.suggestionCode - : undefined, - startLine: - typeof record.startLine === 'number' ? record.startLine : undefined, - endLine: typeof record.endLine === 'number' ? record.endLine : undefined, - category: typeof record.category === 'string' ? record.category : undefined, - severity: normalizeSeverity( - typeof record.severity === 'string' ? record.severity : undefined, - ), - anchored: record.anchored === true, - }; -} - -/** Parse `finding path=… line=… sev=… cat=… :: message` summaries. */ -export function parseFindingFromSummary( - summary: string, -): ReviewFindingView | null { - const text = summary.trim(); - if (!text.toLowerCase().includes('finding') && !/\bpath=/.test(text)) { - return null; - } - const path = matchKv(text, 'path'); - const content = - text.includes('::') - ? text.slice(text.indexOf('::') + 2).trim() - : matchKv(text, 'msg') ?? matchStringField(text, 'content'); - if (!path || !content) return null; - const lineRaw = matchKv(text, 'line'); - const line = lineRaw && /^\d+$/.test(lineRaw) ? Number(lineRaw) : undefined; - return { - path, - content, - startLine: line, - endLine: line, - category: matchKv(text, 'cat') ?? undefined, - severity: normalizeSeverity(matchKv(text, 'sev') ?? undefined), - }; -} - -function parseFindingFromLooseFields(text: string): ReviewFindingView | null { - const path = matchStringField(text, 'path'); - const content = matchStringField(text, 'content'); - if (!path || !content) return null; - return { - path, - content, - existingCode: matchStringField(text, 'existingCode') ?? undefined, - startLine: matchNumberField(text, 'startLine') ?? undefined, - endLine: matchNumberField(text, 'endLine') ?? undefined, - category: matchStringField(text, 'category') ?? undefined, - severity: normalizeSeverity(matchStringField(text, 'severity') ?? undefined), - }; -} - -function matchKv(text: string, key: string): string | null { - const re = new RegExp(`\\b${key}=([^\\s]+)`); - const m = text.match(re); - return m?.[1] ? m[1].replace(/^"|"$/g, '') : null; -} - -function normalizeSeverity(value: string | undefined): ReviewFindingSeverity { - const raw = (value ?? 'medium').toLowerCase(); - if (raw === 'critical' || raw === 'blocker') return 'critical'; - if (raw === 'high' || raw === 'error') return 'high'; - if (raw === 'low' || raw === 'nit' || raw === 'fyi') return 'low'; - if (raw === 'info' || raw === 'hint') return 'info'; - return 'medium'; -} - -function severityLabel(severity: ReviewFindingSeverity): string { - switch (severity) { - case 'critical': - return 'Critical'; - case 'high': - return 'High'; - case 'medium': - return 'Medium'; - case 'low': - return 'Low'; - case 'info': - return 'Info'; - } -} - -function toDiagnosticSeverity( - vs: typeof vscode, - severity: ReviewFindingSeverity, -): vscode.DiagnosticSeverity { - switch (severity) { - case 'critical': - case 'high': - return vs.DiagnosticSeverity.Error; - case 'medium': - return vs.DiagnosticSeverity.Warning; - case 'low': - return vs.DiagnosticSeverity.Information; - case 'info': - return vs.DiagnosticSeverity.Hint; - } -} - -function matchStringField(text: string, key: string): string | null { - const re = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`); - const m = text.match(re); - if (!m?.[1]) return null; - try { - return JSON.parse(`"${m[1]}"`) as string; - } catch { - return m[1]; - } -} - -function matchNumberField(text: string, key: string): number | null { - const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`); - const m = text.match(re); - if (!m?.[1]) return null; - const n = Number(m[1]); - return Number.isFinite(n) && n > 0 ? n : null; -} diff --git a/apps/vscode/src/settingsFields.ts b/apps/vscode/src/settingsFields.ts index ac83978b..30362866 100644 --- a/apps/vscode/src/settingsFields.ts +++ b/apps/vscode/src/settingsFields.ts @@ -71,6 +71,7 @@ export const SETTINGS_NAV_ITEMS: readonly { { id: 'workspace', label: 'Workspace' }, { id: 'modes', label: 'Modes' }, { id: 'context', label: 'Context' }, + { id: 'features', label: 'Features' }, { id: 'integrations', label: 'MCP' }, { id: 'debug', label: 'Developer' }, ]; @@ -117,6 +118,7 @@ export type SettingsPage = | 'workspace' | 'modes' | 'context' + | 'features' | 'mcp' | 'developer'; @@ -246,9 +248,13 @@ export function applyUiPatch( base: UiSettingsSnapshot, patch: UiSettingsPatch, ): UiSettingsSnapshot { + const { features: _features, ...restPatch } = patch; return { ...base, - ...patch, + ...restPatch, + features: patch.features + ? { ...base.features, ...patch.features } + : base.features, contextToggles: patch.contextToggles ? { ...base.contextToggles, ...patch.contextToggles } : base.contextToggles, @@ -866,6 +872,16 @@ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [ reflect: 'raw', sample: false, }, + { + id: 'ui.features.codeReviewButton', + page: 'features', + tab: 'features', + setting: 'ui.features.codeReviewButton', + label: 'Show Code Review button', + kind: 'boolean', + reflect: 'raw', + sample: true, + }, { id: 'ui.reasoningPreviewMaxChars', page: 'modes', diff --git a/apps/vscode/src/sidebar.ts b/apps/vscode/src/sidebar.ts index 491f1bed..17297235 100644 --- a/apps/vscode/src/sidebar.ts +++ b/apps/vscode/src/sidebar.ts @@ -418,20 +418,22 @@ function needsFullIndexRefresh(index: IndexStatusSnapshot): boolean { return false; } -const REVIEW_HOST_PREFIX = - 'Review every selected file in the current git changes, including both staged and unstaged patches. Start by calling read_git_status with includeDiff=true. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not a valid review. Do not digress into filename-casing rabbit holes; account for every selected file while preferring high-signal findings.'; +/** Thorough code review of git working-tree changes (Code Review button). */ +const REVIEW_CODE_HOST_PREFIX = + 'Perform a thorough code review of every selected file in the current git changes, including both staged and unstaged patches. Start by calling read_git_status with includeDiff=true. Assess correctness, readability, architecture, tests, and operational risk. You MUST call emit_review_finding at least once before finishing — once per high-signal issue with path, content, existingCode, severity, and category. If there are no material issues, emit a single low/info finding that says so. Prose-only analysis is not a valid review. Prefer high-signal findings over nits.'; /** Avoid stacking identical review instructions from UI + host. */ function buildReviewLlmPrompt(userPrompt: string): string { const trimmed = userPrompt.trim(); - if (!trimmed) return REVIEW_HOST_PREFIX; + if (!trimmed) return REVIEW_CODE_HOST_PREFIX; if ( - /\bemit_review_finding\b/i.test(trimmed) || - /^Review the current (?:git |working-tree )/i.test(trimmed) + trimmed.startsWith( + 'Perform a thorough code review of every selected file in the current git changes', + ) ) { return trimmed; } - return `${REVIEW_HOST_PREFIX}\n\n${trimmed}`; + return `${REVIEW_CODE_HOST_PREFIX}\n\n${trimmed}`; } const EMBEDDING_SOURCES = [ @@ -461,6 +463,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private runCancel?: vscode.CancellationTokenSource; + private liveApprovalMode?: string; private pendingResume?: { resolve: (value: MitiiResumeInput | 'stop') => void; }; @@ -1481,6 +1484,17 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { resolve('stop'); } + private waitForSuspensionResume(suspension: SuspensionPayload): Promise { + this.lastSuspensionRunId = suspension.runId; + this.pendingSuspension = suspension; + this.post({ type: 'run.suspended', suspension }); + return new Promise((resolve) => { + this.pendingResume = { resolve }; + // Apply access changes made while a model/tool call was still in flight. + this.autoApprovePendingToolApprovalIfAllowed(this.liveApprovalMode); + }); + } + private autoApprovePendingToolApprovalIfAllowed( approvalMode: string | undefined, ): void { @@ -1562,11 +1576,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { // workspace files and interleave in the same session log). this.runCancel?.cancel(); this.runCancel?.dispose(); + this.liveApprovalMode = undefined; this.runCancel = new this.vs.CancellationTokenSource(); let llmPrompt = - message.mode === 'review' - ? buildReviewLlmPrompt(prompt) - : prompt; + message.mode === 'review' ? buildReviewLlmPrompt(prompt) : prompt; if (message.mode === 'review') { const root = this.effectiveRoot(); if (root) { @@ -1691,7 +1704,15 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { effort: message.effort, approvalMode: message.approvalMode, pinnedPaths: message.pinnedPaths, - requiredSkillIds: message.requiredSkillIds, + requiredSkillIds: + message.mode === 'review' + ? [ + 'code-review-and-quality', + ...(message.requiredSkillIds ?? []).filter( + (id) => id !== 'code-review-and-quality', + ), + ].slice(0, 3) + : message.requiredSkillIds, requiredMcpServerIds: message.requiredMcpServerIds, workspaceId: this.getWorkspaceId(), workspaceState: this.host.workspaceState, @@ -1833,12 +1854,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { this.post({ type: 'setPlan', plan: planView }); } } - this.lastSuspensionRunId = suspension.runId; - this.pendingSuspension = suspension; - this.post({ type: 'run.suspended', suspension }); - return new Promise((resolve) => { - this.pendingResume = { resolve }; - }); + return this.waitForSuspensionResume(suspension); }, }, }); @@ -2190,6 +2206,8 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ...this.lastIndex, message, readiness: 'indexing', + discoveredFileCount: undefined, + progressStage: undefined, }; this.post({ type: 'index.status', index: this.lastIndex }); } @@ -2209,6 +2227,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { secretKey: (await this.secrets.get('mitii.provider.apiKey')) ?? undefined, }); + this.discoveredModels = []; const result = await testProviderConnection({ type: message.provider.type, baseUrl: message.provider.baseUrl, @@ -2421,6 +2440,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ? error.message : 'Settings could not be saved.', }); + return; } // Echo full state after the quick ack so Save never waits on bootstrap. await this.sendBootstrap(); @@ -2458,8 +2478,21 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { const target = this.configurationTarget(); const update = async (key: string, value: unknown): Promise => { if (configValuesEqual(cfg.get(key), value)) return; - await cfg.update(key, value, target); + try { + await cfg.update(key, value, target); + } catch (error) { + if (error instanceof Error && /not a registered configuration/i.test(error.message)) { + throw new Error(`${error.message} Reload the VS Code window to load the extension's updated settings schema, then save again.`); + } + throw error; + } }; + const approvalMode = message.approvalMode ?? message.ui?.approvalMode; + if (approvalMode !== undefined) { + await update('safety.approvalMode', approvalMode); + if (this.runCancel) this.liveApprovalMode = approvalMode; + this.autoApprovePendingToolApprovalIfAllowed(approvalMode); + } if (message.provider) { await this.writeProviderSettings(message.provider); } @@ -2473,6 +2506,12 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { if (message.ui.showReasoning !== undefined) { await update('ui.showReasoning', message.ui.showReasoning); } + if (message.ui.features?.codeReviewButton !== undefined) { + await update( + 'ui.features.codeReviewButton', + message.ui.features.codeReviewButton === true, + ); + } if (message.ui.developerEnabled !== undefined) { await update('developer.enabled', message.ui.developerEnabled); } @@ -2601,11 +2640,6 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { } } } - const approvalMode = message.approvalMode ?? message.ui?.approvalMode; - if (approvalMode !== undefined) { - await update('safety.approvalMode', approvalMode); - this.autoApprovePendingToolApprovalIfAllowed(approvalMode); - } if (message.workspaceRootOverride !== undefined) { await update('workspace.rootPathOverride', message.workspaceRootOverride); } @@ -2709,6 +2743,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { Extract['provider'] >, ): Promise { + const type = provider.type ?? this.vs.workspace.getConfiguration('mitii').get('provider.type'); + if (type !== 'echo' && provider.model !== undefined && !provider.model.trim()) { + throw new Error('Choose a model before saving provider settings. Test connection to discover available models.'); + } if (provider.type !== undefined) { await this.writeConfigValue('provider.type', provider.type); } @@ -3157,6 +3195,10 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { developerEnabled: cfg.get('developer.enabled') ?? false, debugLogging: cfg.get('debug') ?? false, modelIoLogging: readModelIoLoggingEnabled(cfg), + features: { + codeReviewButton: + cfg.get('ui.features.codeReviewButton') === true, + }, tokenBudget: readTokenBudgetSettings( cfg, resolveContextWindow(this.vs), @@ -3414,6 +3456,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { let truncated = false; let indexMode: IndexStatusSnapshot['indexMode'] = 'full'; let fallbackReason: string | undefined; + let embeddingIssue: string | undefined; let published; try { const full = await runFullWorkspaceIndex({ @@ -3423,7 +3466,17 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { force: options.force === true, ...(options.filePaths?.length ? { filePaths: options.filePaths } : {}), ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}), - ...(options.onProgress ? { onProgress: options.onProgress } : {}), + onProgress: (progress) => { + options.onProgress?.(progress); + this.lastIndex = { + ...this.lastIndex, + progressStage: progress.stage, + discoveredFileCount: progress.fileCount ?? this.lastIndex.discoveredFileCount, + readiness: 'indexing', + message: progress.message, + }; + this.post({ type: 'index.status', index: this.lastIndex }); + }, semanticIndex: await resolveVsCodeSemanticIndexSettings( this.vs, this.secrets, @@ -3466,6 +3519,11 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { } fileCount = full.fileCount; truncated = full.truncated; + if (full.vectorIndex.status !== 'ready') { + embeddingIssue = + full.vectorIndex.reason ?? + `vector index ${full.vectorIndex.status}`; + } published = await client.publishRepositoryStateFromIndexing(full.indexing, { catalogRevisionByRoot: full.catalogRevisionByRoot, graphRevisionByRoot: full.graphRevisionByRoot, @@ -3473,7 +3531,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { }); if (full.status === 'unchanged') { this.channel.appendLine( - `[index] unchanged (up to date) at ${full.databasePath}; vector=${full.vectorIndex.status}${full.vectorIndex.profileId ? ` profile=${full.vectorIndex.profileId}` : ''}`, + `[index] unchanged (up to date) at ${full.databasePath}; vector=${full.vectorIndex.status}${full.vectorIndex.profileId ? ` profile=${full.vectorIndex.profileId}` : ''}${full.vectorIndex.reason ? ` reason=${full.vectorIndex.reason}` : ''}`, ); } else { this.channel.appendLine( @@ -3518,6 +3576,7 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { const snapshot = await buildWorkspaceSnapshot({ workspaceRoot: root, workspaceId: this.getWorkspaceId(), + maxFiles: maximumIndexFiles, }); fileCount = snapshot.fileCount; truncated = snapshot.truncated; @@ -3538,6 +3597,12 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { )}\n`, ); const descriptorStatus = indexStatusFromDescriptor(published.descriptor); + const baseMessage = + indexMode === 'host_snapshot' + ? `Indexed ${fileCount} files (host snapshot fallback: ${fallbackReason ?? 'full index unavailable'})` + : truncated + ? `Indexed ${fileCount} files (truncated)` + : `Indexed ${fileCount} files`; this.lastIndex = { fileCount, truncated, @@ -3545,11 +3610,9 @@ export class MitiiSidebarProvider implements vscode.WebviewViewProvider { ...descriptorStatus, indexMode, message: - indexMode === 'host_snapshot' - ? `Indexed ${fileCount} files (host snapshot fallback: ${fallbackReason ?? 'full index unavailable'})` - : truncated - ? `Indexed ${fileCount} files (truncated)` - : `Indexed ${fileCount} files`, + embeddingIssue && indexMode !== 'host_snapshot' + ? `${baseMessage} · embeddings unavailable: ${embeddingIssue}` + : baseMessage, }; } else { this.lastIndex = { diff --git a/apps/vscode/tests/SETTINGS_COVERAGE.md b/apps/vscode/tests/SETTINGS_COVERAGE.md new file mode 100644 index 00000000..759f5b2f --- /dev/null +++ b/apps/vscode/tests/SETTINGS_COVERAGE.md @@ -0,0 +1,29 @@ +# Settings regression coverage + +Run `pnpm test` for the full suite, including the rendered webview tests. Run +`pnpm test:webview` for the settings UI suite alone. + +| Suite | What it verifies | +| --- | --- | +| `settingsRoundTrip.test.ts` | Every catalog setting has a VS Code manifest registration (MCP uses the registered parent object). Every enum value, both boolean values, and representative text/numeric edits pass through the actual sidebar settings writer. Configuration and supported host snapshots reflect saved values. Hidden legacy/advanced settings are included here. | +| `sidebarSettingsPersistence.test.ts` | Workspace write target, value normalization/clamping, save/bootstrap ordering, and preservation of provider drafts during connection testing. The configuration mock rejects unregistered keys. | +| `webview-ui/tests/settings.test.tsx` | Mounts the real React App, changes rendered inputs, clicks Save, invokes the actual host writer against an in-memory VS Code configuration adapter, and reads host snapshots back. Scalar inputs are also remounted to verify persistence. Covers provider presets, custom/discovered models, missing-model validation, autocomplete, workspace inputs, all embedding sources, per-mode approvals/models, context switches, run budgets, logging, local loop controls, and both slider/number forms of Simple budgets. | +| `settingsFields.test.ts` | Parsing, clamping, defaults, token-limit calculations, profile reconciliation, mode mappings, and navigation behavior. The former tests that only wrote into a test-local object were replaced by the host/UI suites above. | +| `liveSandboxProcess.test.ts` | Subsequent commands observe access changes on the same process port; explicit sandbox network overrides remain authoritative. | +| `packages/host/src/config/createHostLlmPorts.spec.ts` | Model discovery works before model selection for OpenAI-compatible, Anthropic, and Gemini providers; unsuccessful discovery is not reported as connected. Requests are mocked. | + +Access regressions include upgrading a pending approval, upgrading while a model +call is in flight, switching back before the next approval, and preventing a stale +global Full access value from bypassing a new run's mode default. + +Index regressions distinguish the last published file count from the current +scan's discovered count, show the stage and truncation state, and preserve unsaved +embedding/file-limit changes when progress messages arrive. Discovered files are +not presented as an exact processed-files progress counter. + +These tests use jsdom and a VS Code configuration adapter, not a running VS Code +extension host. Profile and MCP disk storage are mocked in the webview suite; API +key prompts, real provider credentials/networking, native embedding availability, +and installed-extension activation require integration/manual checks. Reload VS +Code after updating the extension manifest so new settings registrations take +effect. The old installed 2.7.x manifest lacks per-mode approval registrations. diff --git a/apps/vscode/tests/helpers/settingsHarness.ts b/apps/vscode/tests/helpers/settingsHarness.ts new file mode 100644 index 00000000..46bec62f --- /dev/null +++ b/apps/vscode/tests/helpers/settingsHarness.ts @@ -0,0 +1,66 @@ +import { vi } from 'vitest'; +import manifest from '../../package.json'; +import { MitiiSidebarProvider } from '../../src/sidebar'; +const registered: Record = manifest.contributes.configuration.properties; + +export function createProviderHarness() { + const store = new Map([ + ['provider.type', 'echo'], + ['provider.preset', 'echo'], + ['provider.baseUrl', ''], + ['provider.model', 'echo'], + ]); + const updates: Array<{ key: string; value: unknown; target: unknown }> = []; + const cfg = { + inspect: (key: string) => ({ key, workspaceValue: store.get(key) }), + get: (key: string, fallback?: unknown) => + store.has(key) ? store.get(key) : fallback, + update: vi.fn(async (key: string, value: unknown, target: unknown) => { + if (!registered[`mitii.${key}`]) throw new Error(`${key} is not a registered configuration`); + updates.push({ key, value, target }); + if (value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + }), + }; + const vs = { + ConfigurationTarget: { Global: 'global', Workspace: 'workspace' }, + ExtensionMode: { Development: 1 }, + Uri: { file: (path: string) => ({ fsPath: path, scheme: 'file' }) }, + workspace: { + workspaceFolders: [{ uri: { fsPath: '/tmp/workspace' } }], + getConfiguration: () => cfg, + }, + window: { + showInformationMessage: vi.fn(), + }, + }; + const provider = new MitiiSidebarProvider( + vs as never, + { fsPath: '/tmp/ext', scheme: 'file' } as never, + async () => ({}) as never, + () => undefined, + () => 'workspace', + { appendLine: vi.fn(), show: vi.fn() } as never, + { get: vi.fn(async () => undefined) } as never, + vi.fn(), + async () => ({ fileCount: 0, truncated: false }), + { + extensionMode: 1 as never, + workspaceState: { get: vi.fn(), update: vi.fn() } as never, + inlineDiff: { setPending: vi.fn() } as never, + reviewFindings: {} as never, + onInlineDiffPending: vi.fn(), + }, + ); + (provider as unknown as { sendBootstrap: () => Promise }).sendBootstrap = + async () => undefined; + ( + provider as unknown as { + refreshDiscoveredModels: () => Promise; + } + ).refreshDiscoveredModels = async () => undefined; + return { provider, updates, store, cfg, target: vs.ConfigurationTarget.Workspace }; +} diff --git a/apps/vscode/tests/liveSandboxProcess.test.ts b/apps/vscode/tests/liveSandboxProcess.test.ts new file mode 100644 index 00000000..4ed45f68 --- /dev/null +++ b/apps/vscode/tests/liveSandboxProcess.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLiveSandboxedProcessPort } from '../src/liveSandboxProcess'; +import type { SandboxBackend } from '@mitii/host'; + +function harness() { + const settings = new Map([['safety.approvalMode', 'guided']]); + const vs = { + workspace: { + getConfiguration: () => ({ + get: (key: string) => settings.get(key), + inspect: (key: string) => + settings.has(key) ? { workspaceValue: settings.get(key) } : {}, + }), + }, + }; + const wrap = vi.fn((request) => request); + const backend: SandboxBackend = { id: 'seatbelt', available: true, wrap }; + const inner = { + execFile: vi.fn(async () => ({ + exitCode: 0, + stdout: '', + stderr: '', + timedOut: false, + cancelled: false, + truncated: false, + })), + }; + const port = createLiveSandboxedProcessPort( + vs as never, + inner, + '/workspace', + backend, + ); + return { settings, wrap, port }; +} + +const request = { + argv: ['git', 'status'], + cwd: '/workspace', + timeoutMs: 1000, + maxOutputBytes: 1024, +}; + +describe('live command access settings', () => { + it('applies full access and later restricted access on the same process port', async () => { + const { settings, wrap, port } = harness(); + for (const [mode, network] of [ + ['guided', 'deny'], + ['pilot', 'allow'], + ['safe', 'deny'], + ] as const) { + settings.set('safety.approvalMode', mode); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true, network }), + ); + } + }); + + it('preserves an explicit network override when access changes', async () => { + const { settings, wrap, port } = harness(); + settings.set('safety.sandbox.network', 'deny'); + settings.set('safety.approvalMode', 'pilot'); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ network: 'deny' }), + ); + }); + + it('reuses the sandboxed wrapper when policy is unchanged', async () => { + const { wrap, port } = harness(); + await port.execFile(request); + await port.execFile(request); + expect(wrap).toHaveBeenCalledTimes(2); + expect(wrap.mock.calls[0]?.[1]).toBe(wrap.mock.calls[1]?.[1]); + }); + + it('ignores a live sandbox disable after sandbox has already been enabled', async () => { + const { settings, wrap, port } = harness(); + settings.set('safety.approvalMode', 'guided'); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true }), + ); + + settings.set('safety.sandbox.enabled', false); + await port.execFile(request); + expect(wrap).toHaveBeenLastCalledWith( + request, + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/apps/vscode/tests/settingsFields.test.ts b/apps/vscode/tests/settingsFields.test.ts index 2e977e58..885e47fc 100644 --- a/apps/vscode/tests/settingsFields.test.ts +++ b/apps/vscode/tests/settingsFields.test.ts @@ -69,6 +69,7 @@ const BASE_UI: UiSettingsSnapshot = { developerEnabled: false, debugLogging: false, modelIoLogging: false, + features: { codeReviewButton: false }, tokenBudget: { enabled: false, policy: {}, @@ -156,46 +157,6 @@ const BASE_UI: UiSettingsSnapshot = { }, }; -function emptyStore(): Record { - return {}; -} - -function writeField( - store: Record, - setting: string, - value: unknown, -): void { - store[setting] = value; -} - -function readField(store: Record, setting: string): unknown { - return store[setting]; -} - -function editSaveReflect(fieldId: string, edited: unknown): unknown { - const field = SETTINGS_FIELDS.find((entry) => entry.id === fieldId); - if (!field) throw new Error(`Unknown field ${fieldId}`); - const store = emptyStore(); - let persisted: unknown = edited; - if (field.kind === 'int') { - persisted = normalizeTokenLimit(edited); - if (field.min !== undefined && field.min > 0) { - persisted = Math.max(field.min, Number(persisted) || field.min); - } - } else if (field.kind === 'number') { - const parsed = Number(edited); - persisted = Number.isFinite(parsed) ? parsed : field.sample; - if (field.min !== undefined) { - persisted = Math.max(field.min, Number(persisted)); - } - if (field.max !== undefined) { - persisted = Math.min(field.max, Number(persisted)); - } - } - writeField(store, field.setting, persisted); - return readField(store, field.setting); -} - describe('settings field catalog', () => { it('covers every settings page', () => { const pages = new Set(SETTINGS_FIELDS.map((field) => field.page)); @@ -203,6 +164,7 @@ describe('settings field catalog', () => { 'autocomplete', 'context', 'developer', + 'features', 'mcp', 'modes', 'provider', @@ -221,19 +183,6 @@ describe('settings field catalog', () => { expect(budgetIds).toEqual(visible); }); - it.each(SETTINGS_FIELDS.map((field) => [field.id, field] as const))( - 'edits, saves, and reflects %s', - (_id, field) => { - const reflected = editSaveReflect(field.id, field.sample); - if (field.kind === 'int') { - const expected = Math.max(field.min ?? 0, Math.floor(Number(field.sample))); - expect(reflected).toBe(expected); - } else { - expect(reflected).toEqual(field.sample); - } - expect(field.reflect).toBe('raw'); - }, - ); }); describe('context window edit / save / reflect', () => { @@ -532,6 +481,14 @@ describe('modes fields', () => { expect(reflected.reasoningPreviewMaxChars).toBe(4000); }); + it('saves the Code Review feature toggle', () => { + const next = applyUiPatch(BASE_UI, { + features: { codeReviewButton: true }, + }); + expect(next.features.codeReviewButton).toBe(true); + expect(reflectUiAfterSave(next).features.codeReviewButton).toBe(true); + }); + it('saves run budget caps and reflects them', () => { const next = applyUiPatch(BASE_UI, { runBudget: { @@ -639,18 +596,6 @@ describe('developer fields', () => { }); }); -describe('workspace override', () => { - it('saves a trimmed override and can clear it', () => { - const store = emptyStore(); - writeField(store, 'workspace.rootPathOverride', '/tmp/mitii-workspace'); - expect(readField(store, 'workspace.rootPathOverride')).toBe( - '/tmp/mitii-workspace', - ); - writeField(store, 'workspace.rootPathOverride', null); - expect(readField(store, 'workspace.rootPathOverride')).toBeNull(); - }); -}); - describe('compact settings nav tooltips', () => { it('collapses the left bar at the compact breakpoint', () => { expect(isSettingsNavCompact(300)).toBe(true); @@ -678,6 +623,7 @@ describe('compact settings nav tooltips', () => { 'workspace', 'modes', 'context', + 'features', 'integrations', 'debug', ]); diff --git a/apps/vscode/tests/settingsRoundTrip.test.ts b/apps/vscode/tests/settingsRoundTrip.test.ts new file mode 100644 index 00000000..ad242213 --- /dev/null +++ b/apps/vscode/tests/settingsRoundTrip.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { SETTINGS_FIELDS } from '../src/settingsFields'; +import { createProviderHarness } from './helpers/settingsHarness'; + +const properties = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).contributes.configuration.properties; +function nested(path: string[], value: unknown): any { + return path.reduceRight((child, key) => ({ [key]: child }), value); +} +function patchFor(id: string, value: unknown): any { + if (id.startsWith('provider.') || id.startsWith('autocomplete.')) return nested(id.split('.'), value); + if (id === 'workspace.rootPathOverride') return { workspaceRootOverride: value }; + if (id === 'workspace.maximumIndexFiles') return { workspaceMaximumIndexFiles: value }; + if (id === 'semanticIndex.source') return { semanticIndex: { source: value } }; + if (id.startsWith('ui.')) return nested(id.split('.'), value); + if (id.startsWith('runBudget.')) return { ui: nested(id.split('.'), value) }; + for (const group of ['tokenBudget', 'loopPolicy']) { + if (id.startsWith(`${group}.`)) { + const key = id.slice(group.length + 1); + return { ui: { [group]: key === 'enabled' ? { enabled: value } : { enabled: true, [group === 'tokenBudget' ? 'policy' : 'thresholds']: { [key]: value } } } }; + } + } + const names: Record = { 'developer.enabled': 'developerEnabled', 'developer.intensityOverrides': 'intensityOverrides', 'developer.modelIo': 'modelIoLogging', debug: 'debugLogging' }; + if (names[id]) return { ui: { [names[id]]: value } }; + throw new Error(`No real save mapping for ${id}`); +} +const editable = SETTINGS_FIELDS.filter(f => !['semanticIndex.backend', 'semanticIndex.enabled', 'mcp.enabled'].includes(f.id)); +const cases = editable.flatMap(field => { + const schema = properties[`mitii.${field.setting}`]; + const values = field.kind === 'boolean' ? [false, true] : schema?.enum ?? [field.sample]; + return values.map((value: unknown) => [field.id, value] as const); +}); +describe('registered settings through the actual sidebar writer', () => { + it.each(SETTINGS_FIELDS.map(f => [f.id, f.setting]))('%s has a VS Code registration', (_id, key) => { + expect(properties[`mitii.${key === 'mcp.enabled' ? 'mcp' : key}`]).toBeDefined(); + }); + it.each(cases)('saves %s = %s and reads it back from configuration', async (id, value) => { + const { provider, store, cfg } = createProviderHarness(); + store.set('developer.intensityOverrides', true); + const host = provider as any; + await host.applySettingsSet({ type: 'settings.set', ...patchFor(id, value) }); + expect(cfg.get(id), id).toEqual(value); + const ui = host.readUi(); + if (id.startsWith('ui.')) { + expect(id.slice(3).split('.').reduce((v: any, k) => v[k], ui)).toEqual(value); + } else if (id.startsWith('autocomplete.')) { + expect(host.readAutocomplete()[id.slice(13)]).toEqual(value); + } else if (id.startsWith('provider.')) { + expect((await host.readProvider())[id.slice(9)]).toEqual(value); + } else if (id === 'semanticIndex.source') { + expect((await host.withEmbedding({ fileCount: 0, truncated: false })).embeddingSource).toEqual(value); + } + }); + + it('applies full access to a pending approval even if stale mode registration fails', async () => { + const { provider, cfg, store } = createProviderHarness(); + const host = provider as any; + const resume = vi.fn(); + host.pendingResume = { resolve: resume }; + host.pendingSuspension = { kind: 'approval_required', runId: 'run-1', approval: { approvalId: 'approval-1' } }; + const update = cfg.update; + cfg.update = vi.fn(async (key, value, target) => { + if (key.startsWith('ui.modeDefaults')) throw new Error(`${key} is not a registered configuration`); + return update(key, value, target); + }); + await expect(host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot', ui: { modeDefaults: { agent: { approvalMode: 'pilot' } } } })).rejects.toThrow(/Reload the VS Code window/); + expect(store.get('safety.approvalMode')).toBe('pilot'); + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ approvalMode: 'never', approval: { approvalId: 'approval-1', decision: 'approved' } })); + }); +}); + + +describe('access changes during an active run', () => { + const suspension = { kind: 'approval_required', runId: 'run-2', approval: { approvalId: 'approval-2' } }; + it('applies full access selected while a model call was in flight to the next approval', async () => { + const { provider } = createProviderHarness(); + const host = provider as any; + host.runCancel = {}; + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot' }); + await expect(host.waitForSuspensionResume(suspension)).resolves.toMatchObject({ approvalMode: 'never', approval: { decision: 'approved' } }); + }); + it('does not use stale global Full access to bypass a new run mode default', async () => { + const { provider, store } = createProviderHarness(); + const host = provider as any; + store.set('safety.approvalMode', 'pilot'); + const result = host.waitForSuspensionResume(suspension); + const resolved = vi.fn(); + result.then(resolved); + await Promise.resolve(); + expect(resolved).not.toHaveBeenCalled(); + host.pendingResume.resolve('stop'); + await result; + }); + it('does not auto-approve after switching back from full access', async () => { + const { provider } = createProviderHarness(); + const host = provider as any; + host.runCancel = {}; + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'pilot' }); + await host.applySettingsSet({ type: 'settings.set', approvalMode: 'guided' }); + const result = host.waitForSuspensionResume(suspension); + expect(host.pendingResume).toBeDefined(); + host.pendingResume.resolve('stop'); + await expect(result).resolves.toBe('stop'); + }); +}); diff --git a/apps/vscode/tests/sidebarSettingsPersistence.test.ts b/apps/vscode/tests/sidebarSettingsPersistence.test.ts index ae1011a9..6918598b 100644 --- a/apps/vscode/tests/sidebarSettingsPersistence.test.ts +++ b/apps/vscode/tests/sidebarSettingsPersistence.test.ts @@ -3,65 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MitiiSidebarProvider } from '../src/sidebar'; import type { UiSettingsSnapshot } from '../src/protocol'; -function createProviderHarness() { - const store = new Map([ - ['provider.type', 'echo'], - ['provider.preset', 'echo'], - ['provider.baseUrl', ''], - ['provider.model', 'echo'], - ]); - const updates: Array<{ key: string; value: unknown; target: unknown }> = []; - const cfg = { - get: (key: string, fallback?: unknown) => - store.has(key) ? store.get(key) : fallback, - update: vi.fn(async (key: string, value: unknown, target: unknown) => { - updates.push({ key, value, target }); - if (value === undefined) { - store.delete(key); - } else { - store.set(key, value); - } - }), - }; - const vs = { - ConfigurationTarget: { Global: 'global', Workspace: 'workspace' }, - ExtensionMode: { Development: 1 }, - Uri: { file: (path: string) => ({ fsPath: path, scheme: 'file' }) }, - workspace: { - workspaceFolders: [{ uri: { fsPath: '/tmp/workspace' } }], - getConfiguration: () => cfg, - }, - window: { - showInformationMessage: vi.fn(), - }, - }; - const provider = new MitiiSidebarProvider( - vs as never, - { fsPath: '/tmp/ext', scheme: 'file' } as never, - async () => ({}) as never, - () => undefined, - () => 'workspace', - { appendLine: vi.fn(), show: vi.fn() } as never, - { get: vi.fn(async () => undefined) } as never, - vi.fn(), - async () => ({ fileCount: 0, truncated: false }), - { - extensionMode: 1 as never, - workspaceState: { get: vi.fn(), update: vi.fn() } as never, - inlineDiff: {} as never, - reviewFindings: {} as never, - onInlineDiffPending: vi.fn(), - }, - ); - (provider as unknown as { sendBootstrap: () => Promise }).sendBootstrap = - async () => undefined; - ( - provider as unknown as { - refreshDiscoveredModels: () => Promise; - } - ).refreshDiscoveredModels = async () => undefined; - return { provider, updates, target: vs.ConfigurationTarget.Workspace }; -} +import { createProviderHarness } from './helpers/settingsHarness'; const UI_PATCH: Partial = { showReasoning: false, @@ -69,6 +11,7 @@ const UI_PATCH: Partial = { intensityOverrides: true, debugLogging: true, modelIoLogging: true, + features: { codeReviewButton: true }, reasoningPreviewMaxChars: 4000, depth: 'deep', effort: 'high', @@ -181,6 +124,7 @@ describe('MitiiSidebarProvider settings persistence', () => { 'autocomplete.suffixChars', 'autocomplete.temperature', 'ui.showReasoning', + 'ui.features.codeReviewButton', 'developer.enabled', 'developer.intensityOverrides', 'debug', diff --git a/apps/vscode/webview-ui/package.json b/apps/vscode/webview-ui/package.json index 81b6e62e..a6c6fa63 100644 --- a/apps/vscode/webview-ui/package.json +++ b/apps/vscode/webview-ui/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --config vitest.config.ts" }, "dependencies": { "react": "^18.3.1", @@ -15,9 +16,12 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "typescript": "^5.5.2", "vite": "^5.4.11" } diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 0d31dcdd..32ca7cbe 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -40,7 +40,11 @@ import { approvalModeUiPatch } from './approvalPresets'; import { OnboardingPanel } from './components/OnboardingPanel'; import { PendingPlanBanner } from './components/PendingPlanBanner'; import { PlanFollowStrip } from './components/PlanPanel'; -import { WorkingTreeReviewBar } from './components/WorkingTreeReviewBar'; +import { + ComposerReviewStrip, + composerNeedsReviewStrip, + selectLatestRunChanges, +} from './review/ComposerReviewStrip'; import { SettingsErrorBoundary } from './components/SettingsErrorBoundary'; import { SettingsPanel } from './components/SettingsPanel'; import { WorkspaceBanner } from './components/WorkspaceBanner'; @@ -129,8 +133,9 @@ const EMPTY_TOKEN_USAGE: TokenUsageSnapshot = { }; const REVIEW_SKILL_ID = 'code-review-and-quality'; -const DEFAULT_REVIEW_PROMPT = - 'Review the current working-tree changes. Prefer high-signal bugs and security issues.'; +/** LLM code review of working-tree changes (Code Review button only). */ +const DEFAULT_CODE_REVIEW_PROMPT = + 'Perform a thorough code review of the current working-tree changes across correctness, readability, architecture, tests, and risk.'; function mergeReviewSkillIds(ids: string[]): string[] { const without = ids.filter((id) => id !== REVIEW_SKILL_ID); @@ -245,6 +250,7 @@ const DEFAULT_UI: UiSettingsSnapshot = { developerEnabled: false, debugLogging: false, modelIoLogging: false, + features: { codeReviewButton: false }, tokenBudget: DEFAULT_TOKEN_BUDGET, loopPolicy: DEFAULT_LOOP_POLICY, policyLab: DEFAULT_POLICY_LAB, @@ -299,6 +305,10 @@ function hydrateUiSnapshot( return { ...DEFAULT_UI, ...(raw ?? {}), + features: { + ...DEFAULT_UI.features, + ...(raw?.features ?? {}), + }, modeDefaults: { ...DEFAULT_UI.modeDefaults, ...(raw?.modeDefaults ?? {}), @@ -444,11 +454,15 @@ function mergeUiPatch( tokenBudget: _tb, loopPolicy: _lp, policyLab: _pl, + features: _features, ...scalarPatch } = patch; return { ...base, ...scalarPatch, + features: patch.features + ? { ...base.features, ...patch.features } + : base.features, contextToggles: patch.contextToggles ? { ...base.contextToggles, ...patch.contextToggles } : base.contextToggles, @@ -907,6 +921,8 @@ export function App() { const [connectionMessage, setConnectionMessage] = useState( null, ); + const maximumIndexFilesDraftRef = useRef(undefined); + const embeddingSourceDraftRef = useRef(undefined); const [customModel, setCustomModel] = useState(false); const [index, setIndex] = useState({ fileCount: 0, @@ -1071,6 +1087,23 @@ export function App() { [], ); + const applyIndexStatus = useCallback((incoming: IndexStatusSnapshot) => { + const next = { + ...incoming, + ...(maximumIndexFilesDraftRef.current !== undefined + ? { maximumIndexFiles: maximumIndexFilesDraftRef.current } + : {}), + ...(embeddingSourceDraftRef.current + ? { + embeddingSource: embeddingSourceDraftRef.current, + embeddingEnabled: embeddingSourceDraftRef.current !== 'disabled', + } + : {}), + }; + indexRef.current = next; + setIndex(next); + }, []); + const applyBootstrap = useCallback((msg: HostToWebviewMessage) => { if (msg.type === 'bootstrap' || msg.type === 'settings') { setWorkspace(msg.workspace); @@ -1176,7 +1209,7 @@ export function App() { settingsSavingRef.current = false; setSettingsSaving(false); if (msg.type === 'bootstrap') { - setIndex(msg.index); + applyIndexStatus(msg.index); setOnboardingRequired(msg.onboardingRequired); setHistory(msg.history); setActiveThreadId(msg.activeThreadId); @@ -1250,7 +1283,7 @@ export function App() { applyBootstrap(msg); break; case 'index.status': - setIndex(msg.index); + applyIndexStatus(msg.index); break; case 'run.started': { setRunning(true); @@ -1431,11 +1464,12 @@ export function App() { break; case 'startReview': { setNav('chat'); - // Review is a composer action, not a chat mode. + // Review = show git changes only (not a chat mode / not LLM). if (modeRef.current === 'review') setMode('ask'); setReviewBarExpandToken((n) => n + 1); postToHost({ type: 'refreshReviewDiff' }); - if (msg.autoRun) { + // Optional auto-run is Code Review when the feature is enabled. + if (msg.autoRun && uiRef.current.features.codeReviewButton) { pendingAutoReviewRef.current = { prompt: msg.prompt }; } else { pendingAutoReviewRef.current = null; @@ -1621,6 +1655,10 @@ export function App() { })); break; case 'settings.saved': + if (settingsSavingRef.current && msg.ok) { + embeddingSourceDraftRef.current = undefined; + maximumIndexFilesDraftRef.current = undefined; + } settingsSavingRef.current = false; setSettingsSaving(false); if (!msg.ok) { @@ -1639,7 +1677,7 @@ export function App() { postToHost({ type: 'ready' }); } return off; - }, [applyBootstrap, applyTokenUsage, markSuspensionResumed]); + }, [applyBootstrap, applyIndexStatus, applyTokenUsage, markSuspensionResumed]); useLayoutEffect(() => { const turnCountChanged = turns.length !== lastTurnCountRef.current; @@ -1692,10 +1730,11 @@ export function App() { setSuggestOpen(false); }, [prompt, running, mode, ui, approvalMode, pinned, pinnedSkillIds, pinnedMcpServerIds]); - const runReview = useCallback( + const runCodeReview = useCallback( (promptOverride?: string) => { if (running) return; - const text = (promptOverride ?? prompt).trim() || DEFAULT_REVIEW_PROMPT; + const text = + (promptOverride ?? prompt).trim() || DEFAULT_CODE_REVIEW_PROMPT; stickToBottomRef.current = true; forceScrollToBottomRef.current = true; const defaults = modeDefaultsFromUi(ui, 'ask'); @@ -1705,13 +1744,13 @@ export function App() { depth: defaults.depth, effort: ui.effort, }); - // Keep Ask/Plan/Agent selection; host maps mode:'review' → engine ask. if (mode === 'review') setMode('ask'); setReviewBarExpandToken((n) => n + 1); postToHost({ type: 'ask', prompt: text, mode: 'review', + reviewKind: 'code', depth: intensity.depth, effort: intensity.effort, approvalMode: defaults.approvalMode, @@ -1729,8 +1768,13 @@ export function App() { ); useEffect(() => { - runReviewRef.current = runReview; - }, [runReview]); + runReviewRef.current = runCodeReview; + }, [runCodeReview]); + + const showGitChanges = useCallback(() => { + setReviewBarExpandToken((n) => n + 1); + postToHost({ type: 'refreshReviewDiff' }); + }, []); const executePendingPlan = useCallback(() => { if (running) return; @@ -1950,7 +1994,6 @@ export function App() { path: file.path, }); } - queueMicrotask(() => runReviewRef.current?.()); }, []); const dismissFileChanges = useCallback((runId: string) => { @@ -2305,6 +2348,12 @@ export function App() { const saveAllSettings = () => { (document.activeElement as HTMLElement | null)?.blur?.(); const latestProvider = snapshotProvider(); + if (latestProvider.type !== 'echo' && !latestProvider.model.trim()) { + setError('Choose a model before saving provider settings. Test connection to discover available models.'); + setSettingsTab('model'); + return; + } + setError(null); const latestUi = clearStaleModeModelDefaultsAfterProviderModelChange({ ui: mergeUiPatch( uiRef.current, @@ -2331,6 +2380,9 @@ export function App() { ui: latestUi, workspaceRootOverride: overrideDraft.trim() || null, workspaceMaximumIndexFiles: indexRef.current.maximumIndexFiles ?? 0, + ...(embeddingSourceDraftRef.current + ? { semanticIndex: { source: embeddingSourceDraftRef.current } } + : {}), mcp, approvalMode, profile: { @@ -2628,9 +2680,13 @@ export function App() { ) : null}
0 || - turns.some((t) => t.fileChanges) || - reviewFindings.length > 0 + composerNeedsReviewStrip({ + chatFileCount: + selectLatestRunChanges(turns)?.files.length ?? 0, + gitFileCount: review?.files.length ?? 0, + findingsCount: reviewFindings.length, + codeReviewEnabled: ui.features.codeReviewButton === true, + }) ? ' composer-box--with-review' : '' }`} @@ -2640,16 +2696,13 @@ export function App() { } as CSSProperties } > - t.fileChanges)?.fileChanges ?? null - } + turns={turns} running={running} expandSignal={reviewBarExpandToken} + codeReviewEnabled={ui.features.codeReviewButton === true} onRefresh={() => postToHost({ type: 'refreshReviewDiff' })} onOpenFile={openFile} onOpenDiff={(path) => @@ -2658,19 +2711,10 @@ export function App() { onOpenFinding={(path, line) => postToHost({ type: 'openFile', path, line }) } - onRunReview={() => runReview()} - onUndoAll={() => { - const changes = [...turns] - .reverse() - .find((t) => t.fileChanges)?.fileChanges; - if (changes) undoFileChanges(changes.runId); - }} - onKeepAll={() => { - const changes = [...turns] - .reverse() - .find((t) => t.fileChanges)?.fileChanges; - if (changes) dismissFileChanges(changes.runId); - }} + onShowChanges={showGitChanges} + onRunCodeReview={() => runCodeReview()} + onUndoFileChanges={undoFileChanges} + onDismissFileChanges={dismissFileChanges} onDismissFindings={dismissReviewFindings} onFixAllFindings={() => fixReviewFindings()} onFixFinding={(index) => fixReviewFindings([index])} @@ -3060,6 +3104,7 @@ export function App() { 0, Math.min(240000, Math.floor(value)), ); + maximumIndexFilesDraftRef.current = maximumIndexFiles; indexRef.current = { ...indexRef.current, maximumIndexFiles, @@ -3070,12 +3115,17 @@ export function App() { })); }} onEmbeddingSourceChange={(source: SemanticIndexSource) => { + embeddingSourceDraftRef.current = source; + indexRef.current = { + ...indexRef.current, + embeddingSource: source, + embeddingEnabled: source !== 'disabled', + }; setIndex((current) => ({ ...current, embeddingSource: source, embeddingEnabled: source !== 'disabled', })); - postToHost({ type: 'settings.set', semanticIndex: { source } }); }} memories={memories} onAddMemory={(text) => postToHost({ type: 'addMemory', text })} diff --git a/apps/vscode/webview-ui/src/components/ApprovalCards.tsx b/apps/vscode/webview-ui/src/components/ApprovalCards.tsx index 2123ecc6..7e10b0c8 100644 --- a/apps/vscode/webview-ui/src/components/ApprovalCards.tsx +++ b/apps/vscode/webview-ui/src/components/ApprovalCards.tsx @@ -144,7 +144,7 @@ export function ApprovalCards({ (isPlan ? 'Review the plan, then approve to continue or reject to stop.' : isContinue - ? 'This run hit a safety limit. Continue with a fresh approach, or stop with current progress.' + ? 'We need a bit more research before we can start implementing. Mind if we keep looking?' : isGrantExpansion ? 'Expand workspace access for additional paths, or keep the current grant.' : 'I need a bit more detail before continuing.'); @@ -154,7 +154,7 @@ export function ApprovalCards({ : isPlan ? 'Plan approval required' : isContinue - ? 'Continue required' + ? 'A bit more research needed' : isGrantExpansion ? 'Workspace access expansion' : 'Approval required'; @@ -333,7 +333,7 @@ export function ApprovalCards({ rows={2} value={clarifyText} onChange={(e) => onClarifyChange(e.target.value)} - placeholder="Optional: narrow the task or point to files…" + placeholder="Optional: any tips on where to look?" />
diff --git a/apps/vscode/webview-ui/src/components/MessageList.tsx b/apps/vscode/webview-ui/src/components/MessageList.tsx index 8a31730b..d8a9b3b8 100644 --- a/apps/vscode/webview-ui/src/components/MessageList.tsx +++ b/apps/vscode/webview-ui/src/components/MessageList.tsx @@ -87,7 +87,7 @@ const MODE_LABELS: Record = { ask: 'Ask mode', plan: 'Plan mode', agent: 'Agent mode', - review: 'Review mode', + review: 'Working-tree review', }; export function MessageList({ diff --git a/apps/vscode/webview-ui/src/components/NumberField.tsx b/apps/vscode/webview-ui/src/components/NumberField.tsx index 9813eb84..df2a8853 100644 --- a/apps/vscode/webview-ui/src/components/NumberField.tsx +++ b/apps/vscode/webview-ui/src/components/NumberField.tsx @@ -31,6 +31,7 @@ export function NumberField({ const [draft, setDraft] = useState(String(safeValue)); const draftRef = useRef(draft); const focusedRef = useRef(false); + const valueAtFocusRef = useRef(safeValue); draftRef.current = draft; useEffect(() => { @@ -62,8 +63,14 @@ export function NumberField({ return; } setDraft(String(bounded)); - onDraftChange?.(bounded); - if (bounded !== safeValue) onCommit(bounded); + // Compare against the value when focus began so live parent updates + // (e.g. auto-scaled max output mirroring the draft) do not swallow commits. + if (bounded !== valueAtFocusRef.current) { + onDraftChange?.(bounded); + onCommit(bounded); + return; + } + onDraftChange?.(undefined); }; return ( @@ -82,6 +89,7 @@ export function NumberField({ value={draft} onFocus={() => { focusedRef.current = true; + valueAtFocusRef.current = safeValue; }} onChange={(e) => { const nextDraft = e.target.value; diff --git a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx index a63d7175..0c35984a 100644 --- a/apps/vscode/webview-ui/src/components/ReviewPanel.tsx +++ b/apps/vscode/webview-ui/src/components/ReviewPanel.tsx @@ -14,7 +14,7 @@ interface ReviewPanelProps { } const DEFAULT_REVIEW_HINT = - 'Review mode is read-only. Run a review below or type a focus (bugs, security, tests). Do not use Agent for review.'; + 'Review shows git changes. Enable Settings → Features → Code Review to run an LLM analysis of those changes.'; function reviewStatusLabel(status: string): string { const normalized = status.trim() || '?'; @@ -82,10 +82,10 @@ export function ReviewPanel({ title={ files.length === 0 ? 'Make git changes first, then refresh' - : 'Run a structured review of these changes' + : 'Run an LLM code review of these changes' } > - {running ? 'Reviewing…' : 'Run review'} + {running ? 'Reviewing…' : 'Code Review'} ) : null}
@@ -94,8 +94,8 @@ export function ReviewPanel({ {files.length === 0 ? (

- No working-tree diff. Edit files (or stage changes), click Refresh, - then Run review. + No working-tree diff. Edit files (or stage changes), then click + Refresh to see changes.

) : ( diff --git a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx index 6551134c..c6e85959 100644 --- a/apps/vscode/webview-ui/src/components/SettingsPanel.tsx +++ b/apps/vscode/webview-ui/src/components/SettingsPanel.tsx @@ -47,6 +47,7 @@ import { IconModel, IconPlan, IconPlug, + IconReview, } from './Icons'; import { NumberField } from './NumberField'; import { TokenBudgetAllocation } from './TokenBudgetAllocation'; @@ -137,6 +138,7 @@ const NAV: { { id: 'workspace', label: 'Workspace', icon: }, { id: 'modes', label: 'Modes', icon: }, { id: 'context', label: 'Context', icon: }, + { id: 'features', label: 'Features', icon: }, { id: 'integrations', label: 'MCP', icon: }, { id: 'debug', label: 'Developer', icon: }, ]; @@ -162,6 +164,10 @@ const PAGE_COPY: Record = { title: 'Context', description: 'What Mitii attaches to each turn.', }, + features: { + title: 'Features', + description: 'Opt-in IDE surfaces. Off by default until you enable them.', + }, integrations: { title: 'MCP', description: 'Optional servers. Off by default.', @@ -490,15 +496,16 @@ export function SettingsPanel(props: SettingsPanelProps) { effective: provider.effectiveContextWindow, fallback: ui.tokenBudget.preview.contextWindowTokens, }); - const storedMaxOutput = normalizeMaximumOutputTokens( - draftMaxOutput ?? provider.maximumOutputTokens, + const storedMaxOutput = normalizeMaximumOutputTokens(provider.maximumOutputTokens); + const previewMaxOutput = normalizeMaximumOutputTokens( + draftMaxOutput ?? storedMaxOutput, ); - const autoMaxOutput = isAutoMaximumOutputTokens(storedMaxOutput); + const autoMaxOutput = isAutoMaximumOutputTokens(previewMaxOutput); const livePreview = useMemo(() => { try { return deriveLiveTokenBudgetPreview({ contextWindowTokens: previewContextWindow, - maximumOutputTokens: storedMaxOutput, + maximumOutputTokens: previewMaxOutput, policy: ui.tokenBudget.enabled ? ui.tokenBudget.policy : undefined, runBudget: ui.runBudget, }); @@ -507,15 +514,17 @@ export function SettingsPanel(props: SettingsPanelProps) { } }, [ previewContextWindow, - storedMaxOutput, + previewMaxOutput, ui.runBudget, ui.tokenBudget.enabled, ui.tokenBudget.policy, ui.tokenBudget.preview, ]); + // When max output is auto (0), show the window-derived reserve so it tracks + // context-window edits live. Typing a positive value pins an override. const displayMaxOutput = autoMaxOutput ? livePreview.maximumOutputTokens - : storedMaxOutput; + : previewMaxOutput; return (
({ ...prev, model: next })); }} > + {options.map((id) => ( - {customModel || !options.includes(provider.model) ? ( + {customModel || (Boolean(provider.model.trim()) && !options.includes(provider.model)) ? ( onProviderChange((prev) => ({ ...prev, @@ -741,16 +752,7 @@ export function SettingsPanel(props: SettingsPanelProps) { onDraftChange={setDraftMaxOutput} onCommit={(value) => { setDraftMaxOutput(undefined); - // Keep auto (0) when the user accepts the derived reserve for - // the current window; only store a hard override when they - // pick a different number (or explicitly type 0). - const next = - value === 0 - ? 0 - : autoMaxOutput && - value === livePreview.maximumOutputTokens - ? 0 - : normalizeMaximumOutputTokens(value); + const next = normalizeMaximumOutputTokens(value); onProviderChange((prev) => ({ ...prev, maximumOutputTokens: next, @@ -765,10 +767,12 @@ export function SettingsPanel(props: SettingsPanelProps) { ? ` (currently ${provider.effectiveContextWindow.toLocaleString()} tokens)` : '' }.` - : `Context window will save as ${provider.contextWindow.toLocaleString()} tokens.`}{' '} + : `Context window will save as ${provider.contextWindow.toLocaleString()} tokens.`} +

+

{autoMaxOutput - ? `Max output follows the window (~${livePreview.maximumOutputTokens.toLocaleString()} tokens). Set a different number to hard-override.` - : `Max output will save as ${storedMaxOutput.toLocaleString()} tokens.`} + ? `Max output auto-scales with the window (${livePreview.maximumOutputTokens.toLocaleString()} tokens). Enter 0 to keep auto, or a positive number to override.` + : `Max output will save as ${previewMaxOutput.toLocaleString()} tokens (fixed override). Set to 0 to resume auto-scaling.`}

{ui.tokenBudget.enabled ? (

@@ -1128,7 +1132,11 @@ export function SettingsPanel(props: SettingsPanelProps) { /> ) : null} + {activeTab === 'features' ? ( +

+ + +

+ Review always lists staged/unstaged files. + Enable this to also show Code Review, which + runs the code-review skill over those changes. Off by default. +

+
+
+ ) : null} + {activeTab === 'integrations' ? (
void; - onOpenFile: (path: string) => void; - onOpenDiff: (path: string) => void; - onOpenFinding?: (path: string, line?: number) => void; - onRunReview: () => void; - onUndoAll?: () => void; - onKeepAll?: () => void; - /** Clear sticky review findings (Problems + comments + chips). */ - onDismissFindings?: () => void; - /** Fix one finding by index into `findings`. */ - onFixFinding?: (index: number) => void; - /** Fix every open finding via the host recipe. */ - onFixAllFindings?: () => void; -} - -function statusLabel(status: string): string { - const normalized = status.trim() || '?'; - if (normalized.includes('A') || normalized === 'A') return 'Added'; - if (normalized.includes('D')) return 'Deleted'; - if (normalized.includes('R')) return 'Renamed'; - if (normalized.includes('?')) return 'Untracked'; - if (normalized.includes('M') || normalized === 'M') return 'Edited'; - return normalized; -} - -function statusTone(status: string): string { - const normalized = status.trim(); - if (normalized.includes('A') || normalized.includes('?') || normalized === 'A') { - return 'added'; - } - if (normalized.includes('D')) return 'deleted'; - return 'edited'; -} - -function severityTone(severity: string): string { - const s = severity.toLowerCase(); - if (s === 'critical' || s === 'high') return 'high'; - if (s === 'medium') return 'medium'; - return 'low'; -} - /** - * Flush review strip attached to the top of the composer box (Cursor-style). - * Review is an action — not a chat mode. + * Re-export for existing App imports. Implementation lives in `../review/`. */ -export function WorkingTreeReviewBar({ - review, - findings = [], - runChanges = null, - running = false, - expandSignal = 0, - onRefresh, - onOpenFile, - onOpenDiff, - onOpenFinding, - onRunReview, - onUndoAll, - onKeepAll, - onDismissFindings, - onFixFinding, - onFixAllFindings, -}: WorkingTreeReviewBarProps) { - const files = review?.files ?? []; - const fileCount = files.length > 0 ? files.length : (runChanges?.files.length ?? 0); - const [expanded, setExpanded] = useState(false); - const [tab, setTab] = useState<'files' | 'findings'>('files'); - - const openFindings = findings.filter((f) => f.status !== 'fixed'); - const fixedFindings = findings.filter((f) => f.status === 'fixed'); - const reviewComplete = - findings.length > 0 && openFindings.length === 0 && fixedFindings.length > 0; - const hasOpenFindings = openFindings.length > 0; - - useEffect(() => { - if (expandSignal > 0) setExpanded(true); - }, [expandSignal]); - - useEffect(() => { - if (findings.length > 0) { - setTab('findings'); - setExpanded(true); - } - }, [findings.length]); - - useEffect(() => { - if (reviewComplete) setExpanded(false); - }, [reviewComplete]); - - if (fileCount === 0 && findings.length === 0 && !running) { - return null; - } - - const canUndoKeep = - !reviewComplete && Boolean(runChanges && runChanges.files.length > 0); - const canFix = - hasOpenFindings && !running && Boolean(onFixAllFindings) && !reviewComplete; - const listFiles = - files.length > 0 - ? files.map((f) => ({ path: f.path, status: f.status })) - : (runChanges?.files ?? []).map((f) => ({ - path: f.path, - status: f.status, - })); - - return ( -
-
- - {!reviewComplete ? ( -
- - - {findings.length > 0 && onDismissFindings ? ( - - ) : null} - {canFix ? ( - - ) : null} - -
- ) : ( -
- {onDismissFindings ? ( - - ) : null} -
- )} -
- {expanded ? ( -
-
-
- - -
- -
- {tab === 'findings' ? ( - findings.length === 0 ? ( -

- No findings yet. Click Review — issues appear on the code line - in the editor (and in Problems). -

- ) : ( -
    - {findings.map((f, i) => { - const fixed = f.status === 'fixed'; - return ( -
  • - - {!fixed && onFixFinding ? ( - - ) : fixed ? ( - - ✓ - - ) : null} -
  • - ); - })} -
- ) - ) : listFiles.length === 0 ? ( -

No changed files.

- ) : ( -
    - {listFiles.map((file) => { - const deleted = String(file.status).includes('D'); - return ( -
  • - - -
  • - ); - })} -
- )} -
- ) : null} -
- ); -} +export { + WorkingTreeReviewBar, + type ReviewFindingChip, +} from '../review/WorkingTreeReviewBar'; diff --git a/apps/vscode/webview-ui/src/protocol.ts b/apps/vscode/webview-ui/src/protocol.ts index 58bae2d4..2f46110c 100644 --- a/apps/vscode/webview-ui/src/protocol.ts +++ b/apps/vscode/webview-ui/src/protocol.ts @@ -16,6 +16,7 @@ export type SettingsTab = | 'modes' | 'context' | 'integrations' + | 'features' | 'debug'; export type McpTransport = 'stdio' | 'sse' | 'streamable-http'; @@ -218,6 +219,8 @@ export type SemanticIndexSource = | 'disabled'; export interface IndexStatusSnapshot { + discoveredFileCount?: number; + progressStage?: string; fileCount: number; truncated: boolean; maximumIndexFiles?: number; @@ -248,6 +251,15 @@ export interface WorkspaceSnapshotInfo { displayRoot?: string; } +/** Opt-in VS Code UI features (Settings → Features). */ +export interface UiFeaturesSnapshot { + /** + * When true, show a Code Review button beside Review on the working-tree bar. + * Default off — Review alone covers quick git-change review. + */ + codeReviewButton: boolean; +} + export interface UiSettingsSnapshot { showReasoning: boolean; reasoningPreviewMaxChars: number; @@ -273,6 +285,8 @@ export interface UiSettingsSnapshot { * key is a boolean leaf in VS Code settings.) */ modelIoLogging: boolean; + /** Opt-in IDE features (VS Code only). */ + features: UiFeaturesSnapshot; /** Window-proportional token budget tunables (Debug → developer). */ tokenBudget: TokenBudgetSettingsSnapshot; /** Agent Engine loop/stall threshold tunables (Debug → developer). */ @@ -419,12 +433,14 @@ export type UiSettingsPatch = Partial< | 'tokenBudget' | 'loopPolicy' | 'policyLab' + | 'features' > & { contextToggles?: Partial; runBudget?: Partial; modeDefaults?: Partial< Record<'ask' | 'plan' | 'agent', Partial> >; + features?: Partial; tokenBudget?: { enabled?: boolean; policy?: Record; @@ -684,6 +700,11 @@ export type WebviewToHostMessage = requiredSkillIds?: string[]; /** Attached MCP server ids for this turn (@mcp: / pin chips). */ requiredMcpServerIds?: string[]; + /** + * When mode is review: `changes` = quick diff bug scan (default); + * `code` = thorough code-review-and-quality pass. + */ + reviewKind?: 'changes' | 'code'; } | { type: 'cancel' } | { @@ -891,7 +912,7 @@ export type HostToWebviewMessage = | { type: 'paths.results'; requestId: string; suggestions: PathSuggestion[] } | { type: 'openSettings'; tab?: SettingsTab } | { type: 'setTab'; tab: UiNav } - /** Open chat in Review mode; optionally auto-start an LLM review. */ + /** Run a working-tree review via the Review button (not a chat mode). */ | { type: 'startReview'; autoRun?: boolean; prompt?: string } | { type: 'editorPin'; path: string; source?: ContextPinSource } | { type: 'editorUnpin'; path: string } diff --git a/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx b/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx new file mode 100644 index 00000000..26bd41b3 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ComposerReviewStrip.tsx @@ -0,0 +1,83 @@ +import type { ReviewDiffView, RunFileChangesView } from '../protocol'; +import { WorkingTreeReviewBar } from './WorkingTreeReviewBar'; +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { + composerNeedsReviewStrip, + selectLatestRunChanges, +} from './selectLatestRunChanges'; + +type TurnLike = { fileChanges?: RunFileChangesView }; + +type Props = { + review: ReviewDiffView | null; + findings: readonly ReviewFindingChip[]; + turns: readonly TurnLike[]; + running: boolean; + expandSignal: number; + codeReviewEnabled: boolean; + onRefresh: () => void; + onOpenFile: (path: string) => void; + onOpenDiff: (path: string) => void; + onOpenFinding: (path: string, line?: number) => void; + onShowChanges: () => void; + onRunCodeReview: () => void; + onUndoFileChanges: (runId: string) => void; + onDismissFileChanges: (runId: string) => void; + onDismissFindings: () => void; + onFixAllFindings: () => void; + onFixFinding: (index: number) => void; +}; + +/** + * Composer-attached review strip with dual-scope wiring. + * Keeps App.tsx free of run-change lookup / feature-gate details. + */ +export function ComposerReviewStrip({ + review, + findings, + turns, + running, + expandSignal, + codeReviewEnabled, + onRefresh, + onOpenFile, + onOpenDiff, + onOpenFinding, + onShowChanges, + onRunCodeReview, + onUndoFileChanges, + onDismissFileChanges, + onDismissFindings, + onFixAllFindings, + onFixFinding, +}: Props) { + const runChanges = selectLatestRunChanges(turns); + + return ( + { + if (runChanges) onUndoFileChanges(runChanges.runId); + }} + onKeepAll={() => { + if (runChanges) onDismissFileChanges(runChanges.runId); + }} + onDismissFindings={onDismissFindings} + onFixAllFindings={onFixAllFindings} + onFixFinding={onFixFinding} + /> + ); +} + +export { composerNeedsReviewStrip, selectLatestRunChanges }; diff --git a/apps/vscode/webview-ui/src/review/README.md b/apps/vscode/webview-ui/src/review/README.md new file mode 100644 index 00000000..16ca8b3f --- /dev/null +++ b/apps/vscode/webview-ui/src/review/README.md @@ -0,0 +1,41 @@ +# Review bar (webview) + +Composer strip above the Mitii chat input. Host ownership stays in `apps/vscode/src/` +(`reviewDiff.ts`, `review/reviewFindingsPresenter.ts`); this folder is UI + scope rules only. + +## Dual-scope contract + +| Action / surface | Data source | LLM | +|---|---|---| +| Summary (`2 file changes`) | Latest run `RunFileChangesView` | No | +| **Review** + Files tab | Same chat/run edits | No | +| **Code Review (N)** | Full git `ReviewDiffView` (`N` = file count) | Yes | +| Findings tab, severity chips, Fix / Fix all | Present only when `ui.features.codeReviewButton` is on | After Code Review | + +```text +› 2 file changes Undo All · Keep All · Review | Code Review (21) +``` + +- Left count = what **this chat** changed. +- `Code Review (21)` = analyze **all** dirty git files (staged + unstaged + untracked as reported by `buildReviewDiff`). + +## Feature gate + +`Settings → Features → Code Review` (`mitii.ui.features.codeReviewButton`): + +- **Off:** bar shows chat file changes + Review. No Code Review CTA, no Findings tab, no Fix. +- **On:** Code Review CTA with git count; findings appear after a review run with criticality + Fix. + +## Modules + +| File | Role | +|---|---| +| `reviewBarModel.ts` | Pure scope resolution (MUST stay host-free) | +| `WorkingTreeReviewBar.tsx` | Orchestrator | +| `ReviewBarActions.tsx` | Undo / Keep / Review / Code Review / Fix | +| `ReviewFilesPanel.tsx` | Chat file list | +| `ReviewFindingsPanel.tsx` | Finding chips by severity | + +## Tests + +`webview-ui/tests/reviewBarModel.test.ts` — scope visibility, labels, and gating. diff --git a/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx b/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx new file mode 100644 index 00000000..93940daa --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewBarActions.tsx @@ -0,0 +1,142 @@ +type Props = { + reviewComplete: boolean; + canUndoKeep: boolean; + canFix: boolean; + canExpandReview: boolean; + showCodeReview: boolean; + codeReviewButtonLabel: string; + canRunCodeReview: boolean; + running: boolean; + hasFindings: boolean; + onUndoAll?: () => void; + onKeepAll?: () => void; + onDismissFindings?: () => void; + onFixAllFindings?: () => void; + onShowChanges: () => void; + onRunCodeReview?: () => void; + onCollapse: () => void; + onSelectFilesTab: () => void; +}; + +/** Action cluster: Undo/Keep (chat) · Review (chat) · Code Review (git). */ +export function ReviewBarActions({ + reviewComplete, + canUndoKeep, + canFix, + canExpandReview, + showCodeReview, + codeReviewButtonLabel, + canRunCodeReview, + running, + hasFindings, + onUndoAll, + onKeepAll, + onDismissFindings, + onFixAllFindings, + onShowChanges, + onRunCodeReview, + onCollapse, + onSelectFilesTab, +}: Props) { + if (reviewComplete) { + return ( +
+ {onDismissFindings ? ( + + ) : null} +
+ ); + } + + return ( +
+ + + {hasFindings && onDismissFindings ? ( + + ) : null} + {canFix ? ( + + ) : null} + + {showCodeReview && onRunCodeReview ? ( + + ) : null} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx b/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx new file mode 100644 index 00000000..a33c3c6f --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewFilesPanel.tsx @@ -0,0 +1,53 @@ +import type { ReviewBarFile } from './reviewBarModel'; +import { statusLabel, statusTone } from './reviewBarFormat'; + +type Props = { + files: readonly ReviewBarFile[]; + onOpenDiff: (path: string) => void; + onOpenFile: (path: string) => void; +}; + +/** Files tab — always this-chat Mitii edits, never the full git tree. */ +export function ReviewFilesPanel({ files, onOpenDiff, onOpenFile }: Props) { + if (files.length === 0) { + return ( +

+ No Mitii edits in this chat yet. Code Review still covers the full git + working tree when enabled. +

+ ); + } + + return ( +
    + {files.map((file) => { + const deleted = String(file.status).includes('D'); + return ( +
  • + + +
  • + ); + })} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx b/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx new file mode 100644 index 00000000..78215839 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/ReviewFindingsPanel.tsx @@ -0,0 +1,78 @@ +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { severityTone } from './reviewBarFormat'; + +type Props = { + findings: readonly ReviewFindingChip[]; + running?: boolean; + onOpenFinding?: (path: string, line?: number) => void; + onOpenFile: (path: string) => void; + onFixFinding?: (index: number) => void; +}; + +/** Findings tab — severity chips + per-row Fix (Code Review feature only). */ +export function ReviewFindingsPanel({ + findings, + running = false, + onOpenFinding, + onOpenFile, + onFixFinding, +}: Props) { + if (findings.length === 0) { + return ( +

+ No findings yet. Click Code Review to analyze the git working-tree + changes. +

+ ); + } + + return ( +
    + {findings.map((f, i) => { + const fixed = f.status === 'fixed'; + return ( +
  • + + {!fixed && onFixFinding ? ( + + ) : fixed ? ( + + ✓ + + ) : null} +
  • + ); + })} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx b/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx new file mode 100644 index 00000000..f3640a7e --- /dev/null +++ b/apps/vscode/webview-ui/src/review/WorkingTreeReviewBar.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState } from 'react'; + +import type { ReviewDiffView, RunFileChangesView } from '../protocol'; +import { ReviewBarActions } from './ReviewBarActions'; +import { ReviewFilesPanel } from './ReviewFilesPanel'; +import { ReviewFindingsPanel } from './ReviewFindingsPanel'; +import type { ReviewFindingChip } from './reviewFindingTypes'; +import { + chatFilesFromRunChanges, + gitFilesFromReview, + resolveReviewBarScope, +} from './reviewBarModel'; + +export type { ReviewFindingChip } from './reviewFindingTypes'; + +interface WorkingTreeReviewBarProps { + review: ReviewDiffView | null; + findings?: readonly ReviewFindingChip[]; + /** Latest Mitii run edits — Files list + Undo All / Keep All. */ + runChanges?: RunFileChangesView | null; + running?: boolean; + /** Increment to force-expand (command palette / Review CTA). */ + expandSignal?: number; + onRefresh: () => void; + onOpenFile: (path: string) => void; + onOpenDiff: (path: string) => void; + onOpenFinding?: (path: string, line?: number) => void; + /** Expand the bar and show this-chat file changes (no LLM). */ + onShowChanges: () => void; + /** LLM code review of the full git working tree. */ + onRunCodeReview?: () => void; + showCodeReview?: boolean; + onUndoAll?: () => void; + onKeepAll?: () => void; + onDismissFindings?: () => void; + onFixFinding?: (index: number) => void; + onFixAllFindings?: () => void; +} + +/** + * Flush review strip attached to the top of the composer box. + * Review = this-chat diffs. Code Review = full git + findings (feature-gated). + */ +export function WorkingTreeReviewBar({ + review, + findings = [], + runChanges = null, + running = false, + expandSignal = 0, + onRefresh, + onOpenFile, + onOpenDiff, + onOpenFinding, + onShowChanges, + onRunCodeReview, + showCodeReview = false, + onUndoAll, + onKeepAll, + onDismissFindings, + onFixFinding, + onFixAllFindings, +}: WorkingTreeReviewBarProps) { + const scope = useMemo( + () => + resolveReviewBarScope({ + gitFiles: gitFilesFromReview(review?.files), + chatFiles: chatFilesFromRunChanges(runChanges?.files), + findingsCount: findings.length, + showCodeReview, + running, + }), + [review?.files, runChanges?.files, findings.length, showCodeReview, running], + ); + + const [expanded, setExpanded] = useState(false); + const [tab, setTab] = useState<'files' | 'findings'>('files'); + + const openFindings = findings.filter((f) => f.status !== 'fixed'); + const fixedFindings = findings.filter((f) => f.status === 'fixed'); + const reviewComplete = + scope.showFindingsUi && + findings.length > 0 && + openFindings.length === 0 && + fixedFindings.length > 0; + const hasOpenFindings = scope.showFindingsUi && openFindings.length > 0; + + useEffect(() => { + if (expandSignal > 0) setExpanded(true); + }, [expandSignal]); + + useEffect(() => { + if (scope.showFindingsUi && findings.length > 0) { + setTab('findings'); + setExpanded(true); + } + }, [findings.length, scope.showFindingsUi]); + + useEffect(() => { + if (reviewComplete) setExpanded(false); + }, [reviewComplete]); + + if (!scope.visible) return null; + + const canUndoKeep = + !reviewComplete && Boolean(runChanges && runChanges.files.length > 0); + const canFix = + hasOpenFindings && !running && Boolean(onFixAllFindings) && !reviewComplete; + + return ( +
+
+ + 0} + onUndoAll={onUndoAll} + onKeepAll={onKeepAll} + onDismissFindings={ + scope.showFindingsUi ? onDismissFindings : undefined + } + onFixAllFindings={scope.showFindingsUi ? onFixAllFindings : undefined} + onShowChanges={() => { + setExpanded(true); + onShowChanges(); + }} + onRunCodeReview={onRunCodeReview} + onCollapse={() => setExpanded(false)} + onSelectFilesTab={() => setTab('files')} + /> +
+ {expanded ? ( +
+
+
+ + {scope.showFindingsUi ? ( + + ) : null} +
+ +
+ {tab === 'findings' && scope.showFindingsUi ? ( + + ) : ( + + )} +
+ ) : null} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/review/index.ts b/apps/vscode/webview-ui/src/review/index.ts new file mode 100644 index 00000000..30e38811 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/index.ts @@ -0,0 +1,32 @@ +/** + * Composer review strip — dual-scope contract. + * + * | Surface | Scope | LLM | + * |---|---|---| + * | Summary count + Files tab + Review | This chat's Mitii run edits | No | + * | Code Review (N) | Full git working tree (`N` files) | Yes | + * | Findings / Fix / severity | Only when Settings → Features → Code Review is on | After Code Review | + * + * Host still refreshes `ReviewDiffView` via `refreshReviewDiff` so the Code Review + * badge stays accurate. Undo All / Keep All bind only to `RunFileChangesView`. + * + * Pure model: `reviewBarModel.ts` (unit-tested). UI shells stay under 300 lines. + */ + +export { WorkingTreeReviewBar } from './WorkingTreeReviewBar'; +export { ComposerReviewStrip } from './ComposerReviewStrip'; +export type { ReviewFindingChip } from './reviewFindingTypes'; +export { + resolveReviewBarScope, + chatFilesFromRunChanges, + gitFilesFromReview, +} from './reviewBarModel'; +export type { + ReviewBarFile, + ReviewBarScope, + ReviewBarScopeInput, +} from './reviewBarModel'; +export { + selectLatestRunChanges, + composerNeedsReviewStrip, +} from './selectLatestRunChanges'; diff --git a/apps/vscode/webview-ui/src/review/reviewBarFormat.ts b/apps/vscode/webview-ui/src/review/reviewBarFormat.ts new file mode 100644 index 00000000..8284b395 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewBarFormat.ts @@ -0,0 +1,31 @@ +/** Display helpers for git status and finding severity chips. */ + +export function statusLabel(status: string): string { + const normalized = status.trim() || '?'; + if (normalized.includes('A') || normalized === 'A') return 'Added'; + if (normalized.includes('D')) return 'Deleted'; + if (normalized.includes('R')) return 'Renamed'; + if (normalized.includes('?')) return 'Untracked'; + if (normalized.includes('M') || normalized === 'M') return 'Edited'; + return normalized; +} + +export function statusTone(status: string): string { + const normalized = status.trim(); + if ( + normalized.includes('A') || + normalized.includes('?') || + normalized === 'A' + ) { + return 'added'; + } + if (normalized.includes('D')) return 'deleted'; + return 'edited'; +} + +export function severityTone(severity: string): string { + const s = severity.toLowerCase(); + if (s === 'critical' || s === 'high') return 'high'; + if (s === 'medium') return 'medium'; + return 'low'; +} diff --git a/apps/vscode/webview-ui/src/review/reviewBarModel.ts b/apps/vscode/webview-ui/src/review/reviewBarModel.ts new file mode 100644 index 00000000..c3ef41b4 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewBarModel.ts @@ -0,0 +1,104 @@ +/** + * Dual-scope contract for the composer Working Tree review strip. + * + * - Review / Files list → this chat's Mitii run edits (`chatFiles`) + * - Code Review (N) → full git working tree (`gitFiles`), feature-gated + * - Findings / Fix → only when Code Review feature is enabled + */ + +export type ReviewBarFile = { + path: string; + status: string; +}; + +export type ReviewBarScopeInput = { + gitFiles: readonly ReviewBarFile[]; + chatFiles: readonly ReviewBarFile[]; + findingsCount: number; + showCodeReview: boolean; + running?: boolean; +}; + +export type ReviewBarScope = { + /** Files shown under Review / Files (this chat only). */ + chatFiles: ReviewBarFile[]; + chatFileCount: number; + /** Working-tree file count for the Code Review CTA. */ + gitFileCount: number; + summaryLabel: string; + visible: boolean; + canExpandReview: boolean; + canRunCodeReview: boolean; + showFindingsUi: boolean; + codeReviewButtonLabel: string; +}; + +function pluralFiles(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}`; +} + +/** + * Resolve what the review bar shows and which actions are enabled. + * Pure — safe to unit-test without React or the host bridge. + */ +export function resolveReviewBarScope( + input: ReviewBarScopeInput, +): ReviewBarScope { + const chatFiles = input.chatFiles.map((f) => ({ + path: f.path, + status: f.status, + })); + const chatFileCount = chatFiles.length; + const gitFileCount = input.gitFiles.length; + const showFindingsUi = input.showCodeReview === true; + const running = input.running === true; + + // Hide when there is nothing to review — do not show "0 file changes" + // just because a run is in progress. + const visible = + chatFileCount > 0 || + (showFindingsUi && input.findingsCount > 0) || + (showFindingsUi && gitFileCount > 0); + + let summaryLabel: string; + if (chatFileCount > 0) { + summaryLabel = pluralFiles(chatFileCount, 'file change'); + } else if (showFindingsUi && gitFileCount > 0) { + summaryLabel = pluralFiles(gitFileCount, 'git change'); + } else if (showFindingsUi && input.findingsCount > 0) { + summaryLabel = 'Review findings'; + } else { + summaryLabel = pluralFiles(0, 'file change'); + } + + return { + chatFiles, + chatFileCount, + gitFileCount, + summaryLabel, + visible, + canExpandReview: chatFileCount > 0, + canRunCodeReview: showFindingsUi && gitFileCount > 0 && !running, + showFindingsUi, + codeReviewButtonLabel: + gitFileCount > 0 + ? `Code Review (${gitFileCount})` + : 'Code Review', + }; +} + +/** Map run file-change entries into the bar's file DTO. */ +export function chatFilesFromRunChanges( + files: readonly { path: string; status: string }[] | null | undefined, +): ReviewBarFile[] { + if (!files?.length) return []; + return files.map((f) => ({ path: f.path, status: String(f.status) })); +} + +/** Map git review-diff entries into the bar's file DTO. */ +export function gitFilesFromReview( + files: readonly { path: string; status: string }[] | null | undefined, +): ReviewBarFile[] { + if (!files?.length) return []; + return files.map((f) => ({ path: f.path, status: String(f.status) })); +} diff --git a/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts b/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts new file mode 100644 index 00000000..4b22e4c3 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/reviewFindingTypes.ts @@ -0,0 +1,12 @@ +/** Finding chip DTO shared by the review bar panels. */ +export type ReviewFindingChip = { + path: string; + content: string; + startLine?: number; + endLine?: number; + severity: string; + category?: string; + existingCode?: string; + suggestionCode?: string; + status?: 'open' | 'fixed'; +}; diff --git a/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts b/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts new file mode 100644 index 00000000..c313bdb7 --- /dev/null +++ b/apps/vscode/webview-ui/src/review/selectLatestRunChanges.ts @@ -0,0 +1,29 @@ +import type { RunFileChangesView } from '../protocol'; + +type TurnWithFileChanges = { + fileChanges?: RunFileChangesView; +}; + +/** Latest Mitii run file-changes attached to a chat turn (newest first). */ +export function selectLatestRunChanges( + turns: readonly TurnWithFileChanges[], +): RunFileChangesView | null { + for (let i = turns.length - 1; i >= 0; i -= 1) { + const changes = turns[i]?.fileChanges; + if (changes?.files?.length) return changes; + } + return null; +} + +/** Whether the composer should reserve space for the review strip. */ +export function composerNeedsReviewStrip(input: { + chatFileCount: number; + gitFileCount: number; + findingsCount: number; + codeReviewEnabled: boolean; +}): boolean { + if (input.chatFileCount > 0) return true; + if (input.findingsCount > 0 && input.codeReviewEnabled) return true; + if (input.codeReviewEnabled && input.gitFileCount > 0) return true; + return false; +} diff --git a/apps/vscode/webview-ui/src/styles.css b/apps/vscode/webview-ui/src/styles.css index ba64ddd7..ed4b9727 100644 --- a/apps/vscode/webview-ui/src/styles.css +++ b/apps/vscode/webview-ui/src/styles.css @@ -570,10 +570,9 @@ body.vscode-high-contrast .empty-state__logo--dark { .timeline { position: relative; - --timeline-ecg-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='180' viewBox='0 0 26 180'%3E%3Cpath d='M13 0 V12 L13 16 L3 21 L23 27 L13 33 V48 C13 54 13 57 13 61 L8 66 L17 70 L5 77 L22 84 L13 90 V108 C13 113 13 116 13 120 L1 126 L14 132 L10 137 L25 144 L13 151 V166 L13 170 L6 173 L20 177 L13 180' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); list-style: none; margin: 6px 0 10px; - padding: 3px 0 3px 28px; + padding: 3px 0 3px 22px; display: flex; flex-direction: column; gap: 18px; @@ -595,31 +594,16 @@ body.vscode-high-contrast .empty-state__logo--dark { z-index: 1; top: -3px; bottom: -18px; - left: -41px; - width: 26px; - background: var(--turn-accent, var(--mitii-accent)); - -webkit-mask-image: var(--timeline-ecg-wave); - -webkit-mask-repeat: repeat-y; - -webkit-mask-position: center 0; - -webkit-mask-size: 26px 180px; - mask-image: var(--timeline-ecg-wave); - mask-repeat: repeat-y; - mask-position: center 0; - mask-size: 26px 180px; + left: -16px; + width: 1.5px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 22%, + var(--mitii-border-soft) + ); pointer-events: none; - transition: - background 220ms ease, - filter 220ms ease; -} - -.timeline__row:nth-child(3n + 2)::before { - -webkit-mask-position: center -60px; - mask-position: center -60px; -} - -.timeline__row:nth-child(3n)::before { - -webkit-mask-position: center -120px; - mask-position: center -120px; + transition: background 220ms ease, opacity 220ms ease; } .timeline__row:last-child::before { @@ -627,35 +611,42 @@ body.vscode-high-contrast .empty-state__logo--dark { } .timeline__row--done::before { - background: var(--turn-accent, var(--mitii-accent)); + background: color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 48%, + var(--mitii-border-soft) + ); } .timeline__row--active::before, .timeline__row--thinking-active::before { - background: var(--turn-accent, var(--mitii-accent)); - filter: drop-shadow( - 0 0 3px color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 62%, transparent) + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 12%, transparent) 0%, + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 72%, transparent) 45%, + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 12%, transparent) 100% ); - animation: timeline-ecg-active-step 1.1s ease-in-out infinite; + background-size: 100% 220%; + animation: timeline-rail-breathe 2.8s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; } .timeline__row--warn::before { - background: var(--turn-accent, var(--mitii-accent)); + background: color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 42%, + var(--mitii-border-soft) + ); } -@keyframes timeline-ecg-active-step { +@keyframes timeline-rail-breathe { 0%, 100% { - opacity: 0.58; - filter: drop-shadow( - 0 0 2px color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 48%, transparent) - ); + background-position: 0 0%; + opacity: 0.72; } - 45% { + 50% { + background-position: 0 100%; opacity: 1; - filter: drop-shadow( - 0 0 6px color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 82%, transparent) - ); } } @@ -681,26 +672,35 @@ body.vscode-high-contrast .empty-state__logo--dark { .timeline__marker { position: absolute; z-index: 3; - left: -30px; - top: 0.55em; - width: 4px; - height: 4px; + left: -18.25px; + top: 0.5em; + width: 6px; + height: 6px; border-radius: 999px; - background: var(--turn-accent, var(--mitii-accent)); + background: color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 78%, + var(--mitii-panel) + ); box-shadow: 0 0 0 3px var(--mitii-panel); - transition: background 160ms ease; + transition: + background 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; } .timeline__row--done .timeline__marker { background: var(--turn-accent, var(--mitii-accent)); } -.timeline__row--active .timeline__marker { +.timeline__row--active .timeline__marker, +.timeline__row--thinking-active .timeline__marker { background: var(--turn-accent, var(--mitii-accent)); box-shadow: 0 0 0 3px var(--mitii-panel), - 0 0 0 4px color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 16%, transparent); - animation: timeline-pulse 1.6s ease-in-out infinite; + 0 0 0 5px + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 14%, transparent); + animation: timeline-marker-breathe 2.4s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; } .timeline__row--warn .timeline__marker { @@ -708,31 +708,39 @@ body.vscode-high-contrast .empty-state__logo--dark { } .timeline__row--tool .timeline__marker { - top: 0.55em; + top: 0.5em; } .timeline__row--thinking-active .timeline__marker { - top: 0.55em; - background: var(--turn-accent, var(--mitii-accent)); - box-shadow: 0 0 0 3px var(--mitii-panel); - animation: timeline-pulse 1.6s ease-in-out infinite; + top: 0.5em; } -@keyframes timeline-pulse { +@keyframes timeline-marker-breathe { 0%, 100% { - opacity: 1; + transform: scale(1); + box-shadow: + 0 0 0 3px var(--mitii-panel), + 0 0 0 5px + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 12%, transparent); } 50% { - opacity: 0.4; + transform: scale(1.08); + box-shadow: + 0 0 0 3px var(--mitii-panel), + 0 0 0 8px + color-mix(in srgb, var(--turn-accent, var(--mitii-accent)) 8%, transparent); } } @media (prefers-reduced-motion: reduce) { .timeline__row--active::before, - .timeline__row--thinking-active::before { + .timeline__row--thinking-active::before, + .timeline__row--active .timeline__marker, + .timeline__row--thinking-active .timeline__marker { animation: none; opacity: 1; + transform: none; } } @@ -1042,18 +1050,18 @@ body.vscode-high-contrast .empty-state__logo--dark { } .live-status__dot { - width: 7px; - height: 7px; + width: 6px; + height: 6px; border-radius: 999px; flex: 0 0 auto; background: var(--turn-accent, var(--mitii-accent)); - box-shadow: 0 0 0 3px + box-shadow: 0 0 0 4px color-mix( in srgb, - var(--turn-accent, var(--mitii-accent)) 20%, + var(--turn-accent, var(--mitii-accent)) 12%, transparent ); - animation: timeline-pulse 1.4s ease-in-out infinite; + animation: live-status-breathe 2.4s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; } .live-status__text { @@ -1075,10 +1083,35 @@ body.vscode-high-contrast .empty-state__logo--dark { } } +@keyframes live-status-breathe { + 0%, + 100% { + transform: scale(1); + opacity: 0.78; + box-shadow: 0 0 0 4px + color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 10%, + transparent + ); + } + 50% { + transform: scale(1.12); + opacity: 1; + box-shadow: 0 0 0 7px + color-mix( + in srgb, + var(--turn-accent, var(--mitii-accent)) 6%, + transparent + ); + } +} + .live-status__dots { display: inline-flex; - gap: 2px; + gap: 3px; flex: 0 0 auto; + align-items: center; } .live-status__dots span { @@ -1087,28 +1120,40 @@ body.vscode-high-contrast .empty-state__logo--dark { border-radius: 999px; background: color-mix( in srgb, - var(--turn-accent, var(--mitii-accent)) 66%, + var(--turn-accent, var(--mitii-accent)) 55%, var(--mitii-muted) ); - animation: live-status-blink 1.2s ease-in-out infinite; + animation: live-status-ellipsis 1.6s ease-in-out infinite; } .live-status__dots span:nth-child(2) { - animation-delay: 0.15s; + animation-delay: 0.18s; } .live-status__dots span:nth-child(3) { - animation-delay: 0.3s; + animation-delay: 0.36s; } -@keyframes live-status-blink { +@keyframes live-status-ellipsis { 0%, 80%, 100% { - opacity: 0.25; + opacity: 0.28; + transform: translateY(0); } 40% { + opacity: 0.9; + transform: translateY(-1px); + } +} + +@media (prefers-reduced-motion: reduce) { + .live-status__dot, + .live-status__dots span, + .live-status__text { + animation: none; opacity: 1; + transform: none; } } @@ -3778,9 +3823,12 @@ select.depth-select { display: inline-flex; align-items: center; justify-content: center; - width: 28px; + min-width: 28px; + gap: 5px; + white-space: nowrap; + font-size: 11px; height: 28px; - padding: 0; + padding: 0 7px; border-radius: 6px; border: 1px solid var(--mitii-border-soft); background: transparent; @@ -3791,6 +3839,13 @@ select.depth-select { background 120ms ease; } +.indexing-chip--icon { + width: 28px; + min-width: 28px; + padding: 0; + gap: 0; +} + .indexing-chip:hover { border-color: color-mix(in srgb, var(--index-tone) 40%, var(--mitii-border)); background: color-mix(in srgb, var(--index-tone) 8%, transparent); @@ -5396,6 +5451,12 @@ button.md-file-link.md-inline-code:hover { background: color-mix(in srgb, var(--mitii-ok) 18%, var(--mitii-panel)); } +.wt-review__cta--code { + border-color: color-mix(in srgb, #38bdf8 45%, var(--mitii-border-soft)); + color: var(--mitii-text); + background: color-mix(in srgb, #38bdf8 14%, var(--mitii-panel)); +} + .wt-review__cta:hover:not(:disabled) { background: color-mix(in srgb, var(--mitii-surface) 90%, var(--mitii-panel)); } @@ -5404,6 +5465,10 @@ button.md-file-link.md-inline-code:hover { background: color-mix(in srgb, var(--mitii-ok) 28%, var(--mitii-panel)); } +.wt-review__cta--code:hover:not(:disabled) { + background: color-mix(in srgb, #38bdf8 24%, var(--mitii-panel)); +} + .wt-review__cta:disabled { opacity: 0.5; cursor: default; diff --git a/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx new file mode 100644 index 00000000..c5379eba --- /dev/null +++ b/apps/vscode/webview-ui/tests/WorkingTreeReviewBar.test.tsx @@ -0,0 +1,132 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { WorkingTreeReviewBar } from '../src/review/WorkingTreeReviewBar'; + +afterEach(() => cleanup()); + +describe('WorkingTreeReviewBar', () => { + const chatFiles = { + runId: 'run-1', + files: [ + { path: 'chat-a.ts', status: 'M' as const, additions: 1, deletions: 0 }, + { path: 'chat-b.ts', status: 'A' as const, additions: 2, deletions: 0 }, + ], + totalAdditions: 3, + totalDeletions: 0, + }; + + const review = { + summary: '## main', + files: [ + { path: 'chat-a.ts', status: 'M' }, + { path: 'chat-b.ts', status: 'A' }, + { path: 'extra.ts', status: 'M' }, + ], + }; + + it('lists this-chat files under Review and labels Code Review with git count', () => { + const onRunCodeReview = vi.fn(); + render( + , + ); + + expect(screen.getByText('2 file changes')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + expect(screen.getByText('chat-a.ts')).toBeTruthy(); + expect(screen.getByText('chat-b.ts')).toBeTruthy(); + expect(screen.queryByText('extra.ts')).toBeNull(); + + const codeReview = screen.getByRole('button', { + name: 'Code Review (3)', + }); + fireEvent.click(codeReview); + expect(onRunCodeReview).toHaveBeenCalledTimes(1); + }); + + it('renders nothing when there are 0 file changes', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByLabelText('Working tree review')).toBeNull(); + }); + + it('hides Findings and Code Review when the feature is off', () => { + render( + , + ); + + expect(screen.queryByRole('button', { name: /Code Review/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Review' })); + expect(screen.queryByRole('button', { name: /Findings/ })).toBeNull(); + expect(screen.queryByText('Bug')).toBeNull(); + }); + + it('shows findings with severity and Fix when Code Review is enabled', () => { + const onFixFinding = vi.fn(); + render( + , + ); + + expect(screen.getByText('critical')).toBeTruthy(); + expect(screen.getByText('Null deref')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Fix' })); + expect(onFixFinding).toHaveBeenCalledWith(0); + expect(screen.getByRole('button', { name: 'Fix all' })).toBeTruthy(); + }); +}); diff --git a/apps/vscode/webview-ui/tests/reviewBarModel.test.ts b/apps/vscode/webview-ui/tests/reviewBarModel.test.ts new file mode 100644 index 00000000..51cadcc1 --- /dev/null +++ b/apps/vscode/webview-ui/tests/reviewBarModel.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { + chatFilesFromRunChanges, + gitFilesFromReview, + resolveReviewBarScope, +} from '../src/review/reviewBarModel'; +import { + composerNeedsReviewStrip, + selectLatestRunChanges, +} from '../src/review/selectLatestRunChanges'; +import { severityTone, statusLabel, statusTone } from '../src/review/reviewBarFormat'; + +describe('resolveReviewBarScope', () => { + const chat = [{ path: 'a.ts', status: 'M' }, { path: 'b.ts', status: 'A' }]; + const git = [ + ...chat, + { path: 'c.ts', status: 'M' }, + { path: 'd.ts', status: '?' }, + ]; + + it('summarizes this-chat file changes and keeps Code Review on full git', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + }); + expect(scope.summaryLabel).toBe('2 file changes'); + expect(scope.chatFileCount).toBe(2); + expect(scope.gitFileCount).toBe(4); + expect(scope.codeReviewButtonLabel).toBe('Code Review (4)'); + expect(scope.canExpandReview).toBe(true); + expect(scope.canRunCodeReview).toBe(true); + expect(scope.showFindingsUi).toBe(true); + expect(scope.visible).toBe(true); + }); + + it('hides Code Review findings UI when the feature is off', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 3, + showCodeReview: false, + }); + expect(scope.showFindingsUi).toBe(false); + expect(scope.canRunCodeReview).toBe(false); + expect(scope.visible).toBe(true); + expect(scope.summaryLabel).toBe('2 file changes'); + }); + + it('stays visible for git-only dirty trees when Code Review is enabled', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + }); + expect(scope.visible).toBe(true); + expect(scope.summaryLabel).toBe('4 git changes'); + expect(scope.canExpandReview).toBe(false); + expect(scope.codeReviewButtonLabel).toBe('Code Review (4)'); + }); + + it('hides the bar when there are no chat edits and Code Review is off', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: git, + findingsCount: 0, + showCodeReview: false, + }); + expect(scope.visible).toBe(false); + }); + + it('hides the bar for 0 file changes even while a run is in progress', () => { + const scope = resolveReviewBarScope({ + chatFiles: [], + gitFiles: [], + findingsCount: 0, + showCodeReview: false, + running: true, + }); + expect(scope.visible).toBe(false); + }); + + it('disables Code Review while a run is in progress', () => { + const scope = resolveReviewBarScope({ + chatFiles: chat, + gitFiles: git, + findingsCount: 0, + showCodeReview: true, + running: true, + }); + expect(scope.canRunCodeReview).toBe(false); + expect(scope.visible).toBe(true); + }); +}); + +describe('file mappers', () => { + it('maps chat and git DTOs', () => { + expect(chatFilesFromRunChanges([{ path: 'x.ts', status: 'M' }])).toEqual([ + { path: 'x.ts', status: 'M' }, + ]); + expect(gitFilesFromReview([{ path: 'y.ts', status: 'A' }])).toEqual([ + { path: 'y.ts', status: 'A' }, + ]); + expect(chatFilesFromRunChanges(null)).toEqual([]); + expect(gitFilesFromReview(undefined)).toEqual([]); + }); +}); + +describe('selectLatestRunChanges', () => { + it('returns the newest turn with file changes', () => { + const latest = selectLatestRunChanges([ + { fileChanges: { runId: 'r1', files: [{ path: 'old.ts', status: 'M', additions: 1, deletions: 0 }], totalAdditions: 1, totalDeletions: 0 } }, + {}, + { fileChanges: { runId: 'r2', files: [{ path: 'new.ts', status: 'A', additions: 2, deletions: 0 }], totalAdditions: 2, totalDeletions: 0 } }, + ]); + expect(latest?.runId).toBe('r2'); + }); + + it('returns null when no turns have edits', () => { + expect(selectLatestRunChanges([{}, { fileChanges: { runId: 'r', files: [], totalAdditions: 0, totalDeletions: 0 } }])).toBeNull(); + }); +}); + +describe('composerNeedsReviewStrip', () => { + it('reserves composer space for chat edits or gated git/findings', () => { + expect( + composerNeedsReviewStrip({ + chatFileCount: 1, + gitFileCount: 0, + findingsCount: 0, + codeReviewEnabled: false, + }), + ).toBe(true); + expect( + composerNeedsReviewStrip({ + chatFileCount: 0, + gitFileCount: 5, + findingsCount: 0, + codeReviewEnabled: false, + }), + ).toBe(false); + expect( + composerNeedsReviewStrip({ + chatFileCount: 0, + gitFileCount: 5, + findingsCount: 0, + codeReviewEnabled: true, + }), + ).toBe(true); + }); +}); + +describe('reviewBarFormat', () => { + it('maps status and severity tones', () => { + expect(statusLabel('M')).toBe('Edited'); + expect(statusLabel('A')).toBe('Added'); + expect(statusTone('D')).toBe('deleted'); + expect(severityTone('critical')).toBe('high'); + expect(severityTone('medium')).toBe('medium'); + expect(severityTone('info')).toBe('low'); + }); +}); diff --git a/apps/vscode/webview-ui/tests/settings.test.tsx b/apps/vscode/webview-ui/tests/settings.test.tsx new file mode 100644 index 00000000..377e592e --- /dev/null +++ b/apps/vscode/webview-ui/tests/settings.test.tsx @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { createProviderHarness } from '../../tests/helpers/settingsHarness'; +import { App } from '../src/App'; +import { PROVIDER_OPTIONS } from '../src/providerOptions'; +import { LOOP_POLICY_FIELDS } from '../../src/loopPolicySettings'; +import { SETTINGS_FIELDS } from '../../src/settingsFields'; + +const bridge = vi.hoisted(() => ({ post: vi.fn(), listener: undefined as any })); +vi.mock('../src/bridge', () => ({ + postToHost: bridge.post, + onHostMessage: (listener: any) => { bridge.listener = listener; return () => {}; }, +})); +// Profile/MCP file storage is tested in host suites. Keep this bridge test in memory. +vi.mock('../../src/profiles', async (original) => ({ + ...await original(), + readProfiles: (_root: unknown, provider: unknown) => ({ activeProfileId: 'default', profiles: [{ id: 'default', name: 'Default', provider }] }), + writeProfiles: vi.fn(), +})); +vi.mock('../../src/mcpConfig', async (original) => ({ + ...await original(), writeMcpSettings: vi.fn(), +})); + +let harness: ReturnType; +let host: any; +let saves: Promise[]; +let mcp = { enabled: false, servers: [] }; +const emit = (message: any) => act(() => bridge.listener(message)); +async function hydrate() { + emit({ type: 'settings', provider: await host.readProvider(), autocomplete: host.readAutocomplete(), + search: await host.readSearch(), ui: host.readUi(), workspace: { root: '/tmp/workspace', rootOverride: harness.store.get('workspace.rootPathOverride') }, + profiles: [], activeProfileId: 'default', mcp, mcpStore: [], mcpRuntimeStatus: 'disabled', + notice: {}, tokenUsage: host.tokenUsage }); + emit({ type: 'index.status', index: { fileCount: 12, truncated: false, + maximumIndexFiles: harness.store.get('workspace.maximumIndexFiles') ?? 0, + embeddingSource: harness.store.get('semanticIndex.source') ?? 'bundled' } }); +} +async function open(tab: string) { emit({ type: 'openSettings', tab }); } +async function save() { + await userEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + await act(async () => { await Promise.all(saves); }); + expect(screen.queryByText(/could not be saved/i)).toBeNull(); +} +beforeEach(async () => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} unobserve() {} }); + Element.prototype.scrollIntoView = vi.fn(); + harness = createProviderHarness(); host = harness.provider as any; + host.post = (message: any) => bridge.listener(message); + saves = []; mcp = { enabled: false, servers: [] }; + bridge.post.mockImplementation((message: any) => { + if (message.type === 'settings.set') { + if (message.mcp) mcp = message.mcp; + saves.push(host.applySettingsSet(message).then(async () => { + bridge.listener({ type: 'settings.saved', ok: true }); + await hydrate(); + })); + } + }); + render(); + await hydrate(); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); bridge.post.mockReset(); }); + +describe('settings inputs through the host save/read bridge', () => { + it('keeps every embedding choice through status updates, save, and reopen', async () => { + await open('workspace'); + for (const source of ['ollama', 'openai-compatible', 'disabled', 'bundled']) { + fireEvent.change(screen.getByLabelText('Embedding source'), { target: { value: source } }); + emit({ type: 'index.status', index: { fileCount: 25, truncated: false, embeddingSource: 'bundled' } }); + expect((screen.getByLabelText('Embedding source') as HTMLSelectElement).value).toBe(source); + await save(); + expect(harness.store.get('semanticIndex.source')).toBe(source); + await open('model'); await open('workspace'); + expect((screen.getByLabelText('Embedding source') as HTMLSelectElement).value).toBe(source); + } + }); + + it('tests without a model and requires an explicit model on save', async () => { + harness.store.set('provider.type', 'openai-compatible'); + harness.store.set('provider.preset', 'custom'); + harness.store.set('provider.model', ''); + await hydrate(); await open('model'); + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(''); + expect(screen.queryByPlaceholderText('custom model id')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + expect(bridge.post).toHaveBeenCalledWith(expect.objectContaining({ type: 'provider.testConnection', provider: expect.objectContaining({ model: '' }) })); + emit({ type: 'provider.connectionResult', ok: true, message: 'Connected. Choose a model.', models: ['test-model'] }); + const before = saves.length; + fireEvent.click(screen.getByRole('button', { name: 'Save', exact: true })); + expect(saves.length).toBe(before); + expect(screen.getByText(/Choose a model before saving/)).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: 'test-model' } }); + await save(); + expect(harness.store.get('provider.model')).toBe('test-model'); + }); +}); + +// Hidden legacy/advanced fields use the host matrix; the UI exposes Simple budget sliders instead. +const inputFields = SETTINGS_FIELDS.filter(field => + !['provider.type', 'provider.preset', 'semanticIndex.source', 'semanticIndex.backend', 'semanticIndex.enabled', 'mcp.enabled', 'developer.intensityOverrides', 'ui.effort'].includes(field.id) + && (!field.id.startsWith('tokenBudget.') || field.id === 'tokenBudget.enabled') + && (!field.id.startsWith('loopPolicy.') || field.id === 'loopPolicy.enabled' || LOOP_POLICY_FIELDS.some(f => `loopPolicy.${f.key}` === field.id && f.tier !== 'advanced')) + && !/ui\.modeDefaults\..*\.(depth|thoroughness)$/.test(field.id)); +const labels: Record = { + 'autocomplete.enabled': 'Enable autocomplete', + 'runBudget.unlimited': 'Unlimited run budget', +}; +describe('each editable settings input saves and restores its value', () => { + it.each(inputFields.map(field => [field.id, field] as const))('%s', async (id, field) => { + harness.store.set('developer.enabled', true); + harness.store.set('tokenBudget.enabled', true); + harness.store.set('loopPolicy.enabled', true); + if (id === 'provider.baseUrl') { harness.store.set('provider.type', 'openai-compatible'); harness.store.set('provider.preset', 'custom'); } + await hydrate(); await open(field.tab); + if (id.startsWith('ui.modeDefaults.')) { + const mode = id.split('.')[2]; + fireEvent.click(screen.getByRole('tab', { name: mode[0].toUpperCase() + mode.slice(1), exact: true })); + } + document.querySelectorAll('details').forEach(el => { el.open = true; }); + let label = labels[id] ?? field.label; + if (id.endsWith('.approvalMode')) label = 'Approval mode'; + if (/ui\.modeDefaults\..*\.model$/.test(id)) label = 'Default model'; + const getInput = () => screen.getByLabelText(label, { exact: true }) as HTMLInputElement | HTMLSelectElement; + const input = getInput(); + let expected: string | boolean = String(field.sample); + if (input instanceof HTMLSelectElement) { + // Exercise each option, including returning to the original value. + for (const option of Array.from(input.options).filter(o => o.value !== '__custom__' && (o.value || id !== 'provider.model'))) { + fireEvent.change(getInput(), { target: { value: option.value } }); + await save(); + expect(getInput().value, `${id} option ${option.value}`).toBe(option.value); + expect(harness.store.get(id), id).toBe(option.value); + } + return; + } + if (input.type === 'checkbox') { + expected = !input.checked; + fireEvent.click(input); + } else { + if (String(expected) === input.value) { + const value = Number(expected); + const step = field.kind === 'number' ? 0.01 : 1; + expected = input.type === 'number' ? String(value + (value + step <= (field.max ?? Infinity) ? step : -step)) : `${expected}-edited`; + } + fireEvent.focus(input); + fireEvent.change(input, { target: { value: expected } }); + fireEvent.blur(input); + } + await save(); + expect(harness.store.get(id), id).toEqual(input.type === 'checkbox' ? expected : field.kind === 'int' || field.kind === 'number' ? Number(expected) : expected); + // Remount from persisted host snapshots; local React state cannot make this pass. + cleanup(); render(); await hydrate(); await open(field.tab); + if (id.startsWith('ui.modeDefaults.')) { + const mode = id.split('.')[2]; + fireEvent.click(screen.getByRole('tab', { name: mode[0].toUpperCase() + mode.slice(1), exact: true })); + } + expect(input.type === 'checkbox' ? (getInput() as HTMLInputElement).checked : getInput().value).toEqual(expected); + }); +}); + + +describe('simple budget controls and remaining settings', () => { + it.each([ + ['filesPerMutation', 7], ['outputRatio', 25], ['repositoryShare', 31], + ['conversationShare', 33], ['planShare', 9], ['skillsShare', 7], ['verificationChecks', 5], + ])('saves the %s slider and number input', async (key, edited) => { + harness.store.set('developer.enabled', true); + await hydrate(); await open('debug'); + const id = `tokenBudget.${key}`; + fireEvent.change(document.getElementById(id)!, { target: { value: String(edited) } }); + await save(); + cleanup(); render(); await hydrate(); await open('debug'); + expect((document.getElementById(id) as HTMLInputElement).value).toBe(String(edited)); + const number = document.getElementById(id)!.parentElement!.querySelector('input[type=number]')!; + fireEvent.focus(number); + fireEvent.change(number, { target: { value: String(Number(edited) + 1) } }); + fireEvent.blur(number); + await save(); + expect((document.getElementById(id) as HTMLInputElement).value).toBe(String(Number(edited) + 1)); + }); + + it('saves SearXNG URL and restores it', async () => { + await open('model'); + fireEvent.change(screen.getByLabelText('SearXNG base URL'), { target: { value: 'http://localhost:8888' } }); + await save(); + expect(harness.store.get('search.searxngBaseUrl')).toBe('http://localhost:8888'); + expect((screen.getByLabelText('SearXNG base URL') as HTMLInputElement).value).toBe('http://localhost:8888'); + }); + + it('saves both states of the MCP switch', async () => { + await open('integrations'); + for (const enabled of [true, false]) { + fireEvent.click(screen.getByLabelText('Enable MCP')); + await save(); + expect(mcp.enabled).toBe(enabled); + expect((screen.getByLabelText('Enable MCP') as HTMLInputElement).checked).toBe(enabled); + } + }); +}); + + +describe('provider choices and indexing status', () => { + it.each(PROVIDER_OPTIONS.map(p => [p.preset, p] as const))('saves and restores the %s provider', async (_id, preset) => { + await open('model'); + fireEvent.change(document.getElementById('ptype')!, { target: { value: preset.preset } }); + if (!preset.model) { + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(''); + emit({ type: 'provider.connectionResult', ok: true, message: 'Connected', models: ['chosen-model'] }); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: 'chosen-model' } }); + } + await save(); + expect(harness.store.get('provider.preset')).toBe(preset.preset); + expect(harness.store.get('provider.type')).toBe(preset.type); + expect((document.getElementById('ptype') as HTMLSelectElement).value).toBe(preset.preset); + expect((screen.getByLabelText('Model', { exact: true }) as HTMLSelectElement).value).toBe(preset.model || 'chosen-model'); + }); + it('shows exact discovered and published file counts separately', async () => { + await open('workspace'); + emit({ type: 'index.status', index: { fileCount: 12, discoveredFileCount: 1234, progressStage: 'indexing', readiness: 'indexing', truncated: true, message: 'Indexing code and text' } }); + expect(screen.getByText('Indexed files')).toBeTruthy(); + expect(screen.getByText('Files discovered in current scan')).toBeTruthy(); + expect(screen.getByText('1234')).toBeTruthy(); + expect(screen.getByText('Yes — scan is incomplete')).toBeTruthy(); + // Header chip is icon-only; file counts live in Workspace settings. + expect(screen.queryByText('1,234 files')).toBeNull(); + }); + it('keeps the file limit draft through a progress update', async () => { + await open('workspace'); + const input = screen.getByLabelText('Maximum index files'); + fireEvent.change(input, { target: { value: '12345' } }); fireEvent.blur(input); + emit({ type: 'index.status', index: { fileCount: 12, truncated: false, maximumIndexFiles: 0 } }); + await save(); + expect(harness.store.get('workspace.maximumIndexFiles')).toBe(12345); + expect((screen.getByLabelText('Maximum index files') as HTMLInputElement).value).toBe('12345'); + }); +}); + + +it('saves a manually entered custom model ID', async () => { + await open('model'); + fireEvent.change(screen.getByLabelText('Model', { exact: true }), { target: { value: '__custom__' } }); + fireEvent.change(screen.getByLabelText('Custom model ID'), { target: { value: 'my-custom-model' } }); + await save(); + expect(harness.store.get('provider.model')).toBe('my-custom-model'); +}); + +it('allows Full access to be selected while running', async () => { + emit({ type: 'run.started', mode: 'agent', prompt: 'test access' }); + await userEvent.click(screen.getByRole('button', { name: 'Approval', exact: true })); + await userEvent.click(screen.getByRole('option', { name: /Full access/ })); + await act(async () => { await Promise.all(saves); }); + expect(harness.store.get('safety.approvalMode')).toBe('pilot'); +}); diff --git a/apps/vscode/webview-ui/tests/vscode.ts b/apps/vscode/webview-ui/tests/vscode.ts new file mode 100644 index 00000000..f5eab7ee --- /dev/null +++ b/apps/vscode/webview-ui/tests/vscode.ts @@ -0,0 +1,2 @@ +// VS Code is provided by the shared Vitest setup. +export {}; diff --git a/apps/vscode/webview-ui/vitest.config.ts b/apps/vscode/webview-ui/vitest.config.ts new file mode 100644 index 00000000..43cbb909 --- /dev/null +++ b/apps/vscode/webview-ui/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + esbuild: { jsx: 'automatic' }, + resolve: { alias: { + vscode: resolve(__dirname, 'tests/vscode.ts'), + '@mitii/live-token-budget': resolve(__dirname, '../src/liveTokenBudgetPreview.ts'), + '@mitii/v8': resolve(__dirname, '../../../packages/v8/src/index.ts'), + '@mitii/sdk': resolve(__dirname, '../../../packages/sdk/src/index.ts'), + '@mitii/host': resolve(__dirname, '../../../packages/host/src/index.ts'), + '@mitii/search-kit': resolve(__dirname, '../../../packages/search-kit/src/index.ts'), + } }, + test: { + environment: 'jsdom', + include: ['tests/**/*.test.{ts,tsx}'], + setupFiles: ['../../../vitest.setup.ts'], + testTimeout: 30000, + }, +}); diff --git a/package.json b/package.json index 70cd6683..8c76650d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mitii-ai-agent", "description": "Private Mitii monorepo workspace orchestrator. Product packages: @mitii/v8, @mitii/sdk, @mitii/automation, @mitii/search-kit, @mitii/mcp, @mitii/mcp-web, @mitii/host, @mitii/cli, @mitii/daemon, @mitii/acp, apps/vscode.", - "version": "2.9.57", + "version": "2.9.66", "private": true, "license": "AGPL-3.0-or-later", "author": { @@ -47,7 +47,7 @@ "typecheck:daemon": "pnpm --filter @mitii/daemon typecheck", "typecheck:acp": "pnpm --filter @mitii/acp typecheck", "typecheck:vscode": "pnpm --filter ./apps/vscode typecheck", - "test": "node scripts/ensure-node-native.mjs && vitest run", + "test": "node scripts/ensure-node-native.mjs && vitest run && pnpm run test:webview", "test:watch": "node scripts/ensure-node-native.mjs && vitest", "test:v8": "pnpm --filter @mitii/v8 test", "test:sdk": "pnpm --filter @mitii/sdk test", @@ -89,11 +89,13 @@ "log-viewer": "node tools/log-viewer/server.mjs", "audit:dependencies": "node scripts/audit-dependencies.mjs", "audit:engines": "node scripts/audit-package-engines.mjs", - "check:circular-deps": "node scripts/check-circular-deps.mjs" + "check:circular-deps": "node scripts/check-circular-deps.mjs", + "test:webview": "pnpm --filter @mitii/vscode-webview test" }, "devDependencies": { "@electron/rebuild": "^4.2.0", "@types/node": "^20.14.0", + "jsdom": "^26.1.0", "knip": "^6.21.0", "madge": "^8.0.0", "typescript": "^5.5.2", diff --git a/packages/automation/package.json b/packages/automation/package.json index 1d31bbb8..e1ec55ea 100644 --- a/packages/automation/package.json +++ b/packages/automation/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/automation", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii automation control plane: schedules, event ingress, claim/lease runner, webhooks (Phases 1–2).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/automation/src/index.ts b/packages/automation/src/index.ts index bd4f8522..a4698fdb 100644 --- a/packages/automation/src/index.ts +++ b/packages/automation/src/index.ts @@ -41,7 +41,11 @@ export { export type { ParsedCron } from './cron/next.js'; export { SqliteAutomationStore } from './store/sqliteStore.js'; -export type { UpsertSpecInput } from './store/sqliteStore.js'; +export type { + OpenAutomationSqliteDatabase, + SqliteAutomationStoreOptions, + UpsertSpecInput, +} from './store/sqliteStore.js'; export { AUTOMATION_SCHEMA_STATEMENTS } from './store/schema.js'; export { diff --git a/packages/automation/src/service.ts b/packages/automation/src/service.ts index 54dc30a6..744d433a 100644 --- a/packages/automation/src/service.ts +++ b/packages/automation/src/service.ts @@ -18,7 +18,10 @@ import { import { ClaimRunner, type ClaimRunnerEvent } from './runner/claimRunner.js'; import type { AutomationRunExecutor } from './runner/types.js'; import { reconcileCronSpecsDir } from './specs/reconciler.js'; -import { SqliteAutomationStore } from './store/sqliteStore.js'; +import { + SqliteAutomationStore, + type OpenAutomationSqliteDatabase, +} from './store/sqliteStore.js'; import { createScheduleInputSchema, type AutomationRunRecord, @@ -35,6 +38,8 @@ import { export interface AutomationServiceOptions { dbPath?: string; + /** VS Code / Electron hosts must inject the staged better-sqlite3 binding. */ + openDatabase?: OpenAutomationSqliteDatabase; executor?: AutomationRunExecutor; pollIntervalMs?: number; claimLeaseSeconds?: number; @@ -56,7 +61,9 @@ export class AutomationService { constructor(options: AutomationServiceOptions = {}) { const dbPath = resolveAutomationDbPath({ dbPath: options.dbPath }); - this.store = new SqliteAutomationStore(dbPath); + this.store = new SqliteAutomationStore(dbPath, { + openDatabase: options.openDatabase, + }); this.options = options; this.ingress = new EventIngress({ store: this.store }); } diff --git a/packages/automation/src/store/sqliteStore.ts b/packages/automation/src/store/sqliteStore.ts index a049f736..d25865ca 100644 --- a/packages/automation/src/store/sqliteStore.ts +++ b/packages/automation/src/store/sqliteStore.ts @@ -26,6 +26,16 @@ import { AUTOMATION_SCHEMA_STATEMENTS } from './schema.js'; type SqliteDb = Database.Database; +/** Host-injected opener (VS Code passes Electron-native better-sqlite3 bindings). */ +export type OpenAutomationSqliteDatabase = ( + filename: string, + options?: Database.Options, +) => SqliteDb; + +export type SqliteAutomationStoreOptions = { + openDatabase?: OpenAutomationSqliteDatabase; +}; + function boolToInt(value: boolean): number { return value ? 1 : 0; } @@ -149,10 +159,13 @@ export class SqliteAutomationStore { readonly db: SqliteDb; readonly dbPath: string; - constructor(dbPath: string) { + constructor(dbPath: string, options: SqliteAutomationStoreOptions = {}) { mkdirSync(dirname(dbPath), { recursive: true }); this.dbPath = dbPath; - this.db = new Database(dbPath); + const open = + options.openDatabase ?? + ((filename, openOptions) => new Database(filename, openOptions)); + this.db = open(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); for (const statement of AUTOMATION_SCHEMA_STATEMENTS) { diff --git a/packages/host/package.json b/packages/host/package.json index 5ce1fc24..e601c9d3 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/host", - "version": "2.9.57", + "version": "2.9.66", "description": "Shared host kit for Mitii apps: SQLite injection, workspace indexing, repository context, durable ports (checkpoints/memory/skills/search/network), project rules, provider presets. Web retrieval via @mitii/search-kit.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/host/src/config/createHostLlmPorts.spec.ts b/packages/host/src/config/createHostLlmPorts.spec.ts index cd7740e1..0843785a 100644 --- a/packages/host/src/config/createHostLlmPorts.spec.ts +++ b/packages/host/src/config/createHostLlmPorts.spec.ts @@ -99,6 +99,27 @@ describe('resolveProviderApiKey', () => { }); describe('testProviderConnection', () => { + it.each(['openai-compatible', 'anthropic', 'gemini'])('discovers %s models before selection without a generation request', async (type) => { + const requests: string[] = []; + const result = await testProviderConnection({ + type, baseUrl: 'https://provider.example', model: '', apiKey: 'test-key', + fetchImpl: (async (url: RequestInfo | URL, init?: RequestInit) => { + requests.push(String(url)); + expect(init?.method ?? 'GET').toBe('GET'); + return new Response(JSON.stringify({ data: [{ id: 'test-model' }], models: [{ name: 'models/test-model' }] })); + }) as typeof fetch, + }); + expect(result.ok).toBe(true); + expect(result.models).toEqual(['test-model']); + expect(result.message).toContain('Choose a model'); + expect(requests).toHaveLength(1); + }); + + it('does not claim success when discovery fails without a model', async () => { + const result = await testProviderConnection({ type: 'openai-compatible', baseUrl: 'https://provider.example', model: '', fetchImpl: (async () => new Response('', { status: 401 })) as typeof fetch }); + expect(result.ok).toBe(false); + }); + it('accepts echo without a network call', async () => { const result = await testProviderConnection({ type: 'echo', diff --git a/packages/host/src/config/testProviderConnection.ts b/packages/host/src/config/testProviderConnection.ts index 20fc93a1..f399558c 100644 --- a/packages/host/src/config/testProviderConnection.ts +++ b/packages/host/src/config/testProviderConnection.ts @@ -43,7 +43,10 @@ export async function testProviderConnection( } if (!model.trim()) { - return { ok: false, message: 'Model is required.' }; + const models = await listProviderModels(input); + return models.length > 0 + ? { ok: true, message: 'Connected. Choose a model, then save settings.', models } + : { ok: false, message: 'Could not discover models. Check the endpoint and credentials, or enter a custom model to test.' }; } if (type === 'anthropic') { diff --git a/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts b/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts index c51e5977..c3eb77ad 100644 --- a/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts +++ b/packages/host/src/indexing/bundled-embedding/adapters/OnnxRuntimeSessionFactory.ts @@ -150,33 +150,46 @@ export class HostOnnxRuntimeSessionFactory implements OnnxRuntimeSessionFactory input.abortSignal?.throwIfAborted(); const preferred = resolvePreferredOnnxKind(input.preferredKind); const tryNative = preferred !== 'wasm'; + const nativeErrors: string[] = []; if (tryNative) { const native = this.loadModule(ONNX_RUNTIME_NODE_PACKAGE); if (native?.InferenceSession && native.Tensor) { - const raw = await native.InferenceSession.create(input.modelPath, { - executionProviders: ['cpu'], - }); - return { - session: wrapSession(raw), - resolution: describeOnnxExecutionProvider({ - kind: 'native', - packageId: ONNX_RUNTIME_NODE_PACKAGE, - }), - createInt64Tensor: (values, dims) => - new native.Tensor!( - 'int64', - BigInt64Array.from(values.map((value) => BigInt(value))), - [...dims], - ), - }; + try { + const raw = await native.InferenceSession.create(input.modelPath, { + executionProviders: ['cpu'], + }); + return { + session: wrapSession(raw), + resolution: describeOnnxExecutionProvider({ + kind: 'native', + packageId: ONNX_RUNTIME_NODE_PACKAGE, + }), + createInt64Tensor: (values, dims) => + new native.Tensor!( + 'int64', + BigInt64Array.from(values.map((value) => BigInt(value))), + [...dims], + ), + }; + } catch (error) { + // ABI / native create failures are common in Extension Host — fall + // through to WASM so bundled embeddings still work. + nativeErrors.push( + error instanceof Error ? error.message : String(error), + ); + } } } const wasm = this.loadModule(ONNX_RUNTIME_WEB_PACKAGE); if (!wasm?.InferenceSession || !wasm.Tensor) { + const nativeHint = + nativeErrors.length > 0 + ? ` Native attempt failed: ${nativeErrors.join('; ')}.` + : ''; throw new Error( - 'ONNX Runtime is unavailable. Install optional native modules onnxruntime-node (preferred) or onnxruntime-web (WASM fallback) for this OS/CPU.', + `ONNX Runtime is unavailable. Install optional native modules onnxruntime-node (preferred) or onnxruntime-web (WASM fallback) for this OS/CPU.${nativeHint}`, ); } configureWasmPaths(wasm, ONNX_RUNTIME_WEB_PACKAGE); diff --git a/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts b/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts index 2348619d..700b294a 100644 --- a/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts +++ b/packages/host/src/indexing/bundled-embedding/tests/OnnxRuntimeSessionFactory.spec.ts @@ -17,6 +17,52 @@ describe('HostOnnxRuntimeSessionFactory', () => { }), ).rejects.toThrow(/onnx|no such file|not exist|FILE|PROTOBUF|unavailable/i); }); + + it('falls back to WASM when native InferenceSession.create throws', async () => { + const modelPath = join(tmpdir(), 'mitii-fallback-minilm.onnx'); + let wasmCreateCalls = 0; + const factory = new HostOnnxRuntimeSessionFactory((packageId) => { + if (packageId === 'onnxruntime-node') { + return { + Tensor: class { + constructor() {} + } as never, + InferenceSession: { + create: async () => { + throw new Error('NODE_MODULE_VERSION mismatch'); + }, + }, + }; + } + if (packageId === 'onnxruntime-web') { + return { + Tensor: class { + constructor() {} + } as never, + InferenceSession: { + create: async () => { + wasmCreateCalls += 1; + return { + inputNames: ['input_ids'], + outputNames: ['last_hidden_state'], + run: async () => ({}), + }; + }, + }, + env: { wasm: {} }, + }; + } + return undefined; + }); + + // Force native-first so the create() throw path is exercised, then WASM. + const created = await factory.create({ + modelPath, + preferredKind: 'native', + }); + expect(wasmCreateCalls).toBe(1); + expect(created.resolution.kind).toBe('wasm'); + }); }); describe('resolvePreferredOnnxKind', () => { diff --git a/packages/host/src/indexing/fingerprintSnapshot.ts b/packages/host/src/indexing/fingerprintSnapshot.ts index 0c64d523..8c55e244 100644 --- a/packages/host/src/indexing/fingerprintSnapshot.ts +++ b/packages/host/src/indexing/fingerprintSnapshot.ts @@ -10,6 +10,7 @@ import { } from '@mitii/v8'; import { WORKSPACE_WALK_SKIP_DIR_NAMES, shouldSkipWorkspaceWalkFile } from '../internal/workspaceWalk.js'; +import { DEFAULT_MAXIMUM_INDEX_FILES } from './indexLimits.js'; import { readIndexRuntimeMetadata } from './semanticIndex.js'; export interface WorkspaceSnapshotOptions { @@ -55,7 +56,9 @@ export function fingerprintWorkspaceIndexSnapshot( export async function buildWorkspaceSnapshot( options: WorkspaceSnapshotOptions, ): Promise { - const maxFiles = options.maxFiles ?? 2_000; + // Match the full-index default so a temporary host_snapshot fallback is not + // stuck at a tiny 2k pin while embeddings/code indexes are unavailable. + const maxFiles = options.maxFiles ?? DEFAULT_MAXIMUM_INDEX_FILES; const entries: string[] = []; const relativePaths: string[] = []; let truncated = false; diff --git a/packages/host/src/indexing/fullWorkspaceIndex.ts b/packages/host/src/indexing/fullWorkspaceIndex.ts index 471dcf48..bd70d7fc 100644 --- a/packages/host/src/indexing/fullWorkspaceIndex.ts +++ b/packages/host/src/indexing/fullWorkspaceIndex.ts @@ -355,6 +355,15 @@ async function runFullWorkspaceIndexOnce(options: { resolvedSemantic.status === 'ready' && vectorIndex.status === 'ready' ? resolvedSemantic.provider.profile.id : 'unavailable', + ...(vectorIndex.status !== 'ready' + ? { + lastEmbeddingError: + vectorIndex.reason ?? + (resolvedSemantic.status === 'unavailable' + ? resolvedSemantic.reason + : 'Embedding synchronization did not complete.'), + } + : {}), snapshotFingerprint, fileCount: snapshot.statistics.files, truncated: snapshot.status !== 'complete', diff --git a/packages/host/src/indexing/semanticIndex.ts b/packages/host/src/indexing/semanticIndex.ts index 5b3ce0fa..096cafb6 100644 --- a/packages/host/src/indexing/semanticIndex.ts +++ b/packages/host/src/indexing/semanticIndex.ts @@ -111,6 +111,8 @@ export interface IndexRuntimeMetadata { lanceDbPath: string; embeddingProfile?: EmbeddingProfile; vectorRuntimeKey?: string; + /** Last probe/sync failure reason when vectors are unavailable or degraded. */ + lastEmbeddingError?: string; snapshotFingerprint?: string; fileCount?: number; truncated?: boolean; diff --git a/packages/host/src/workspaceKit.spec.ts b/packages/host/src/workspaceKit.spec.ts index d0bd9d78..b4b89520 100644 --- a/packages/host/src/workspaceKit.spec.ts +++ b/packages/host/src/workspaceKit.spec.ts @@ -220,4 +220,20 @@ describe('host durable workspace kit', () => { ); expect(snapshot.candidate.roots[0]?.rootId).toBe(root.split(/[\\/]/).pop()); }); + + it('honors an explicit fingerprint maxFiles override', async () => { + await writeFile(join(root, 'a.ts'), 'a', 'utf8'); + await writeFile(join(root, 'b.ts'), 'b', 'utf8'); + await writeFile(join(root, 'c.ts'), 'c', 'utf8'); + const snapshot = await buildWorkspaceSnapshot({ + workspaceRoot: root, + workspaceId: 'ws_1', + maxFiles: 2, + }); + expect(snapshot.fileCount).toBe(2); + expect(snapshot.truncated).toBe(true); + expect(snapshot.candidate.reasons?.[0]?.message).toContain( + 'truncated after 2 files', + ); + }); }); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 31907166..3f5f4763 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii MCP client kit: connect to MCP servers (stdio/SSE/streamable-HTTP) and register tools into V8 ToolRegistry. Does not expose Mitii as an MCP server.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/mcp/web/package.json b/packages/mcp/web/package.json index 06be26ac..20961e05 100644 --- a/packages/mcp/web/package.json +++ b/packages/mcp/web/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/mcp-web", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii MCP stdio server under packages/mcp/web: web_search, fetch_url, optional memory_search via search-kit (no v8).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5e9a35bf..6d4ff177 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/sdk", - "version": "2.9.57", + "version": "2.9.66", "description": "Host-neutral Mitii programmatic API over @mitii/v8 Agent Engine.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/sdk/src/contracts.ts b/packages/sdk/src/contracts.ts index 1c717b1b..3393124e 100644 --- a/packages/sdk/src/contracts.ts +++ b/packages/sdk/src/contracts.ts @@ -175,6 +175,10 @@ export const mitiiStartInputSchema = z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_SKILLS) .optional(), + excludedSkillIds: z + .array(z.string().min(1).max(64)) + .max(20) + .optional(), requiredMcpServerIds: z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_MCP_SERVERS) diff --git a/packages/search-kit/package.json b/packages/search-kit/package.json index 65f9d2ea..7b650534 100644 --- a/packages/search-kit/package.json +++ b/packages/search-kit/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/search-kit", - "version": "2.9.57", + "version": "2.9.66", "description": "Mitii web retrieval kit: pluggable search providers, content resolvers, and URL safety. Host-neutral; no V8 dependency.", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/package.json b/packages/v8/package.json index 2556cbe6..4128e487 100644 --- a/packages/v8/package.json +++ b/packages/v8/package.json @@ -1,6 +1,6 @@ { "name": "@mitii/v8", - "version": "2.9.57", + "version": "2.9.66", "description": "Host-neutral Mitii V8 agent runtime (modules + engine).", "license": "AGPL-3.0-or-later", "type": "module", diff --git a/packages/v8/src/engine/agent-engine/actions/buildStallContinueRationale.ts b/packages/v8/src/engine/agent-engine/actions/buildStallContinueRationale.ts index 6f527330..34211675 100644 --- a/packages/v8/src/engine/agent-engine/actions/buildStallContinueRationale.ts +++ b/packages/v8/src/engine/agent-engine/actions/buildStallContinueRationale.ts @@ -74,23 +74,23 @@ export function buildBudgetWallRationale(params: { case "exploration_stall": lines.push( zeroProgressMutation - ? "The run stalled after repeated file re-reads without applying the required workspace edits." - : "This task looks large and the run stalled after repeated file re-reads.", + ? "We've been looking around, but this task still needs a bit more research before we can start making changes." + : "This task looks larger than expected, and we could use a little more time to understand it.", ); break; case "unfulfilled_execute": lines.push( - "The run hit a mutation recovery limit without applying the required workspace edits.", + "We need a bit more research on this task before we can start implementing.", ); break; case "rejected_mutation": lines.push( - "The run could not land a valid workspace edit after rejected mutation attempts.", + "Our first edit attempts didn't land cleanly — we'd like to take another careful look before trying again.", ); break; case "incomplete_execute": lines.push( - "The execute run stopped with open change surfaces or a clear blocker before finishing.", + "We made some progress, but we're not quite finished yet and could use a little more time.", ); break; case "budget_exhausted": @@ -134,18 +134,18 @@ export function buildBudgetWallRationale(params: { params.reason === "incomplete_execute" ) { lines.push( - "Continue for a fresh approach (optionally narrow the task or point to files), or stop here.", + "Mind if we dig a little deeper first? You can also share any tips (like files to focus on), or we can stop here.", ); } else if (params.reason === "budget_exhausted") { lines.push( - "Continue to extend the run budget once and keep working, or stop here with current progress.", + "Want us to keep going a bit longer, or stop here with what we have so far?", ); } else if (params.reason === "verification_repair_capped") { lines.push( - "Continue for another verification repair pass, or stop here and keep the current changes.", + "Want us to take another pass at fixing the remaining issues, or stop here and keep the current changes?", ); } else { - lines.push("Continue to keep working, or stop here."); + lines.push("Want us to keep going, or stop here?"); } return lines.join(" "); diff --git a/packages/v8/src/engine/agent-engine/actions/tests/buildStallContinueRationale.spec.ts b/packages/v8/src/engine/agent-engine/actions/tests/buildStallContinueRationale.spec.ts index ccd72010..0069165d 100644 --- a/packages/v8/src/engine/agent-engine/actions/tests/buildStallContinueRationale.spec.ts +++ b/packages/v8/src/engine/agent-engine/actions/tests/buildStallContinueRationale.spec.ts @@ -43,8 +43,8 @@ describe("buildBudgetWallRationale", () => { changedFiles: [], mutationRequired: true, }); - expect(rationale).toContain("mutation recovery limit"); - expect(rationale).toContain("narrow the task"); + expect(rationale).toContain("more research"); + expect(rationale).toContain("dig a little deeper"); }); it("frames budget exhaustion with extend copy", () => { @@ -54,7 +54,7 @@ describe("buildBudgetWallRationale", () => { budgetMessage: "Model call budget exhausted.", }); expect(rationale).toContain("Model call budget exhausted"); - expect(rationale).toContain("extend the run budget"); + expect(rationale).toContain("keep going a bit longer"); }); it("frames verification repair caps", () => { @@ -63,7 +63,7 @@ describe("buildBudgetWallRationale", () => { changedFiles: ["src/a.ts"], }); expect(rationale).toContain("Verification repairs are capped"); - expect(rationale).toContain("another verification repair pass"); + expect(rationale).toContain("another pass at fixing"); }); }); @@ -76,8 +76,8 @@ describe("buildStallContinueRationale", () => { mutationRequired: true, }); - expect(rationale).toContain("without applying the required workspace edits"); - expect(rationale).toContain("narrow the task"); + expect(rationale).toContain("more research"); + expect(rationale).toContain("dig a little deeper"); }); }); diff --git a/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts b/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts index a741ca04..9ab108a7 100644 --- a/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts +++ b/packages/v8/src/engine/agent-engine/contracts/input/AgentEngineInput.ts @@ -177,6 +177,11 @@ export const agentEngineStartInputSchema = z * Explicitly attached skill ids for this run (@skill:, CLI --skill, host pin). */ requiredSkillIds: z.array(z.string().min(1).max(64)).max(3).default([]), + /** + * Skill ids that must not be auto-matched for this run. + * Explicit requiredSkillIds still win when listed there. + */ + excludedSkillIds: z.array(z.string().min(1).max(64)).max(20).default([]), /** * Explicitly attached MCP server ids for this run (@mcp:, host pin). * Empty = all enabled MCP servers under the grant (default). diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts b/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts index 23fb96fa..385be422 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeStart.ts @@ -513,6 +513,7 @@ export async function executeStart( memoryFacts, establishedFacts, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], selectedSkillIds: selectedSkills?.map((block) => block.id) ?? [], evidence: runEvidence, windowPolicy, @@ -559,6 +560,7 @@ export async function executeStart( projects: input.projects, memoryFacts, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], selectedSkillIds: selectedSkills?.map((block) => block.id) ?? [], establishedFacts, plan: shared.runPlan, diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts b/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts index f2834ce6..d59d5051 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeStartEnrichment.ts @@ -390,6 +390,7 @@ export async function runStartEnrichment( budgetTokens: windowPolicy.skills.budgetTokens, maxSkills: windowPolicy.skills.maxSkills, requiredSkillIds: input.requiredSkillIds ?? [], + excludedSkillIds: input.excludedSkillIds ?? [], forbidLargeSkills: resolveWindowBudgetBand(windowPolicy.contextWindowTokens) === "compact", evidence: { diff --git a/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts b/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts index 4c3c0600..250988a4 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/executeToolSupport.ts @@ -205,6 +205,7 @@ export async function refreshAuthorityAfterTools( route: ExecutionDecision["route"]; windowPolicy: WindowPolicy; requiredSkillIds?: readonly string[]; + excludedSkillIds?: readonly string[]; }): Promise { const discoveredPaths = [ ...new Set([ @@ -324,6 +325,7 @@ export async function refreshAuthorityAfterTools( budgetTokens: params.windowPolicy.skills.budgetTokens, maxSkills: params.windowPolicy.skills.maxSkills, requiredSkillIds: [...(params.requiredSkillIds ?? [])], + excludedSkillIds: [...(params.excludedSkillIds ?? [])], forbidLargeSkills: resolveWindowBudgetBand(params.windowPolicy.contextWindowTokens) === "compact", diff --git a/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts b/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts index 93e16434..84a61fb8 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/modelLoopToolPhase.ts @@ -108,6 +108,7 @@ export async function runModelLoopToolPhase(params: { mode: "ask" | "plan" | "agent" | undefined; projects: readonly ProjectDescriptor[] | undefined; requiredSkillIds: string[] | undefined; + excludedSkillIds: string[] | undefined; answer: string; changeImpactGate: { required: boolean; satisfied: boolean }; thresholds: AgentEngineThresholds; @@ -145,6 +146,7 @@ export async function runModelLoopToolPhase(params: { mode, projects, requiredSkillIds, + excludedSkillIds, answer, changeImpactGate, thresholds, @@ -553,6 +555,7 @@ export async function runModelLoopToolPhase(params: { route: decision.route, windowPolicy: windowPolicy, requiredSkillIds: requiredSkillIds, + excludedSkillIds: excludedSkillIds, }); if (grantExpansionOutcome.kind === "expansion_required") { session.decision = decision; diff --git a/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts b/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts index 0d74c268..9661e33e 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/modelToolLoop.ts @@ -101,6 +101,7 @@ export async function runModelToolLoop( establishedFacts?: EstablishedFact[]; selectedSkillIds?: string[]; requiredSkillIds?: string[]; + excludedSkillIds?: string[]; taskListRef: TaskListRef; evidence?: RunEvidence; windowPolicy: WindowPolicy; @@ -593,6 +594,7 @@ export async function runModelToolLoop( mode: params.mode, projects: params.projects, requiredSkillIds: params.requiredSkillIds, + excludedSkillIds: params.excludedSkillIds, answer: session.answer, changeImpactGate, thresholds, diff --git a/packages/v8/src/engine/agent-engine/pipeline/tests/tryOfferBudgetWallContinue.spec.ts b/packages/v8/src/engine/agent-engine/pipeline/tests/tryOfferBudgetWallContinue.spec.ts index 443b3598..6ddbf1fc 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/tests/tryOfferBudgetWallContinue.spec.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/tests/tryOfferBudgetWallContinue.spec.ts @@ -25,7 +25,7 @@ describe("tryOfferBudgetWallContinue", () => { expect(outcome?.kind).toBe("continue_required"); expect(outcome?.wallReason).toBe("unfulfilled_execute"); - expect(outcome?.rationale).toContain("mutation recovery limit"); + expect(outcome?.rationale).toContain("more research"); }); it("returns undefined when override cap is reached", () => { diff --git a/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts b/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts index 14d432e3..00a483aa 100644 --- a/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts +++ b/packages/v8/src/engine/agent-engine/pipeline/verificationFinish.ts @@ -120,6 +120,7 @@ export async function finishAfterLoop( memoryFacts?: readonly { id: string; content: string }[]; selectedSkillIds?: string[]; requiredSkillIds?: string[]; + excludedSkillIds?: string[]; establishedFacts: EstablishedFact[]; plan?: PlanArtifact; }; @@ -826,6 +827,7 @@ export async function finishAfterLoop( establishedFacts: params.loopContext?.establishedFacts ?? [], selectedSkillIds: params.loopContext?.selectedSkillIds, requiredSkillIds: params.loopContext?.requiredSkillIds, + excludedSkillIds: params.loopContext?.excludedSkillIds, evidence, windowPolicy, logVerbosity: input.logVerbosity, diff --git a/packages/v8/src/engine/agent-engine/tests/AgentEngineStall.spec.ts b/packages/v8/src/engine/agent-engine/tests/AgentEngineStall.spec.ts index 69e5f091..955c23eb 100644 --- a/packages/v8/src/engine/agent-engine/tests/AgentEngineStall.spec.ts +++ b/packages/v8/src/engine/agent-engine/tests/AgentEngineStall.spec.ts @@ -309,7 +309,7 @@ describe("AgentEnginePipeline stall and read dedup", () => { expect(result.reasonCodes).toContain("stall_continue_suspended"); expect(result.reasonCodes).not.toContain("mutation_applied"); expect(result.suspension?.continuePrompt ?? "").toContain( - "without applying the required workspace edits", + "more research before we can start making changes", ); expect(result.answer ?? "").not.toContain("I still need the same file"); @@ -391,7 +391,7 @@ describe("AgentEnginePipeline stall and read dedup", () => { expect(result.reasonCodes).toContain("stall_continue_suspended"); expect(result.reasonCodes).not.toContain("mutation_applied"); expect(result.suspension?.continuePrompt ?? "").toMatch( - /read-only discovery|mutation recovery limit/i, + /more research|dig a little deeper/i, ); expect(result.answer ?? "").not.toContain("Should not be reached"); }); @@ -683,7 +683,7 @@ describe("AgentEnginePipeline stall and read dedup", () => { expect(result.reasonCodes).toContain("unfulfilled_execute_exhausted"); expect(result.reasonCodes).toContain("stall_continue_suspended"); expect(result.suspension?.continuePrompt ?? "").toMatch( - /rejected mutation|valid workspace edit/i, + /didn't land cleanly|dig a little deeper/i, ); expect(result.usage.modelCalls).toBe(2); expect(result.answer ?? "").not.toContain("Should not be reached"); diff --git a/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts b/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts index 4a881c9f..e297c434 100644 --- a/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts +++ b/packages/v8/src/modules/decision-policy/actions/BuildToolGrant.ts @@ -109,10 +109,9 @@ export function buildToolGrant(params: { ) { if (route === "diagnose") { reasonCodes.push("diagnosis_readonly"); - if ( - understanding.intent.classification.primaryTaskIntent === "review" || - looksLikeCodeReviewRequest(params.message ?? "") - ) { + // Structured findings only when the host/CLI injected review markers — + // never from free-form Ask/Plan/Agent text or a review intent label alone. + if (looksLikeCodeReviewRequest(params.message ?? "")) { reasonCodes.push("review_pipeline_required"); reasonCodes.push("review_findings_structured"); } diff --git a/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts b/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts index e9d89ecd..fea22ee4 100644 --- a/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts +++ b/packages/v8/src/modules/decision-policy/actions/ResolveRoute.ts @@ -358,12 +358,12 @@ function resolveAskRoute(params: { }): RouteResolution { const { primary, taskAnalysis, message, reasonCodes } = params; - // Host Review UI maps to ask mode; classifiers often label these as - // "question" once review prep is prepended. Keep the diagnose + structured - // findings path regardless. - if (isDiagnosisIntent(primary) || looksLikeCodeReviewRequest(message)) { + // Structured review is button/CLI-prep only (host markers). Free-form + // "code review" questions stay normal diagnose/answer — never force findings. + const structuredReview = looksLikeCodeReviewRequest(message); + if (isDiagnosisIntent(primary) || structuredReview) { reasonCodes.push("diagnosis_readonly"); - if (primary === "review" || looksLikeCodeReviewRequest(message)) { + if (structuredReview) { reasonCodes.push("review_pipeline_required"); reasonCodes.push("review_findings_structured"); } @@ -406,8 +406,8 @@ function resolveAskRoute(params: { } /** - * Explicit code-review asks (VS Code Review mode, CLI, or free-form). - * Matches host prefixes that include emit_review_finding / working-tree language. + * Structured git review is opt-in only: VS Code Review button / CLI prep. + * Matches host-injected markers — not free-form "code review" chat phrasing. */ export function looksLikeCodeReviewRequest(message: string): boolean { const text = message.replace(/\nClarification:\s*[\s\S]*$/i, "").trim(); @@ -419,20 +419,6 @@ export function looksLikeCodeReviewRequest(message: string): boolean { return true; } - if ( - /\b(?:code\s*review|review\s+(?:the\s+)?(?:current\s+)?(?:git\s+)?(?:working[- ]tree\s+)?(?:changes|diff|patch|pr|pull\s+request|commit))\b/i.test( - text, - ) - ) { - return true; - } - - if ( - /\breview\b[\s\S]{0,80}\b(?:bugs?|security|findings?|severity)\b/i.test(text) - ) { - return true; - } - if (/^#\s*Review prep\b/m.test(text) || /\bReview prep\s*\(/i.test(text)) { return true; } diff --git a/packages/v8/src/modules/decision-policy/actions/index.ts b/packages/v8/src/modules/decision-policy/actions/index.ts index 50ccea76..3d81865c 100644 --- a/packages/v8/src/modules/decision-policy/actions/index.ts +++ b/packages/v8/src/modules/decision-policy/actions/index.ts @@ -1,6 +1,6 @@ export { toolGrantsEquivalent } from "./CompareToolGrants"; -export { resolveRoute, isMutationIntent, isDiagnosisIntent, looksLikeAgentVerificationRequest } from "./ResolveRoute"; +export { resolveRoute, isMutationIntent, isDiagnosisIntent, looksLikeAgentVerificationRequest, looksLikeCodeReviewRequest } from "./ResolveRoute"; export type { RouteResolution } from "./ResolveRoute"; export { looksLikeWorkspaceBugReport } from "./LooksLikeWorkspaceBugReport"; diff --git a/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts b/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts index 0b397009..8ed89299 100644 --- a/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts +++ b/packages/v8/src/modules/decision-policy/tests/DecisionPolicyPipeline.spec.ts @@ -500,6 +500,50 @@ describe("DecisionPolicyPipeline", () => { expect(decision.reasonCodes).toContain("mode_ask_readonly"); }); + it("does not force structured review for free-form code-review questions", () => { + const decision = new DecisionPolicyPipeline().decide( + createInput({ + mode: "ask", + message: + "can you show the code on how it detects the unused files for code review?", + understanding: createUnderstanding({ + primaryTaskIntent: "review", + interactionIntent: "question", + taskAnalysis: { + scope: "repository", + clarity: "unclear", + recommendsRepositoryDiscovery: true, + }, + }), + }), + ); + + expect(decision.route).toBe("diagnose"); + expect(decision.reasonCodes).toContain("diagnosis_readonly"); + expect(decision.reasonCodes).not.toContain("review_pipeline_required"); + expect(decision.reasonCodes).not.toContain("review_findings_structured"); + }); + + it("does not force structured review for free-form review phrasing without host markers", () => { + const decision = new DecisionPolicyPipeline().decide( + createInput({ + mode: "ask", + message: "Review the current git changes for bugs", + understanding: createUnderstanding({ + primaryTaskIntent: "review", + interactionIntent: "question", + taskAnalysis: { + scope: "multi_file", + recommendsRepositoryDiscovery: true, + }, + }), + }), + ); + + expect(decision.reasonCodes).not.toContain("review_pipeline_required"); + expect(decision.reasonCodes).not.toContain("review_findings_structured"); + }); + it("routes ask-mode project questions to repository_answer with read tools", () => { const decision = new DecisionPolicyPipeline().decide( createInput({ diff --git a/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts b/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts index f3b1a4e1..dab75e0d 100644 --- a/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts +++ b/packages/v8/src/modules/decision-policy/tests/fixtures/goldenCases.ts @@ -923,12 +923,14 @@ const GOLDEN_DECISION_CASES_CORE: GoldenDecisionCase[] = [ expected: { route: "diagnose", maximumWorkspaceEffect: "read", - reasonCodesIncludes: [ - "diagnosis_readonly", + // Free-form review intent is diagnose-only — structured findings require + // Review-button / CLI prep markers (emit_review_finding / Review prep). + reasonCodesIncludes: ["diagnosis_readonly"], + reasonCodesExcludes: [ "review_pipeline_required", "review_findings_structured", ], - allowedToolsIncludes: ["emit_review_finding", "read_file", "read_git_status"], + allowedToolsIncludes: ["read_file", "read_git_status"], allowedToolsExcludes: ["apply_patch"], }, }, diff --git a/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts b/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts index 537e1f46..b5ac779c 100644 --- a/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts +++ b/packages/v8/src/modules/repository-state/internal/embedding/SqliteEmbeddingVectorCache.ts @@ -21,10 +21,16 @@ interface CacheRow { dimensions: number; } +/** + * SQLite-backed embedding cache. Cache failures must never abort embedding + * synchronization — LanceDB is the source of truth for vectors. + */ export class SqliteEmbeddingVectorCache implements EmbeddingVectorCachePort { private ready = false; + private disabled = false; + private readonly memory = new Map(); constructor( private readonly database: SqliteDatabasePort, @@ -34,11 +40,21 @@ export class SqliteEmbeddingVectorCache profileId: string, contentHash: string, ): readonly number[] | undefined { - this.ensureSchema(); + const memoryKey = cacheKey(profileId, contentHash); + const mem = this.memory.get(memoryKey); + if (mem) return mem; - const row = this.database - .prepare( - ` + if (this.disabled) { + return undefined; + } + + try { + this.ensureSchema(); + if (this.disabled) return undefined; + + const row = this.database + .prepare( + ` SELECT vector_json AS vectorJson, dimensions AS dimensions @@ -47,14 +63,13 @@ export class SqliteEmbeddingVectorCache AND content_hash = ? LIMIT 1 `, - ) - .get(profileId, contentHash) as CacheRow | undefined; + ) + .get(profileId, contentHash) as CacheRow | undefined; - if (!row) { - return undefined; - } + if (!row) { + return undefined; + } - try { const parsed = JSON.parse(row.vectorJson) as unknown; if ( !Array.isArray(parsed) || @@ -66,6 +81,7 @@ export class SqliteEmbeddingVectorCache return parsed; } catch { + this.disabled = true; return undefined; } } @@ -75,11 +91,20 @@ export class SqliteEmbeddingVectorCache contentHash: string, vector: readonly number[], ): void { - this.ensureSchema(); + const memoryKey = cacheKey(profileId, contentHash); + this.memory.set(memoryKey, vector); + + if (this.disabled) { + return; + } + + try { + this.ensureSchema(); + if (this.disabled) return; - this.database - .prepare( - ` + this.database + .prepare( + ` INSERT INTO embedding_vector_cache ( profile_id, content_hash, @@ -94,22 +119,34 @@ export class SqliteEmbeddingVectorCache vector_json = excluded.vector_json, updated_at = excluded.updated_at `, - ) - .run( - profileId, - contentHash, - vector.length, - JSON.stringify(vector), - Date.now(), - ); + ) + .run( + profileId, + contentHash, + vector.length, + JSON.stringify(vector), + Date.now(), + ); + } catch { + // Keep process-local memory cache; continue embedding without SQLite. + this.disabled = true; + } } private ensureSchema(): void { - if (this.ready) { + if (this.ready || this.disabled) { return; } - this.database.exec(CREATE_CACHE_TABLE); - this.ready = true; + try { + this.database.exec(CREATE_CACHE_TABLE); + this.ready = true; + } catch { + this.disabled = true; + } } } + +function cacheKey(profileId: string, contentHash: string): string { + return `${profileId}\0${contentHash}`; +} diff --git a/packages/v8/src/modules/repository-state/internal/embedding/constants.ts b/packages/v8/src/modules/repository-state/internal/embedding/constants.ts index 0a06c583..1a4f8bd7 100644 --- a/packages/v8/src/modules/repository-state/internal/embedding/constants.ts +++ b/packages/v8/src/modules/repository-state/internal/embedding/constants.ts @@ -41,8 +41,10 @@ export const EMBEDDING_DEFAULTS = { MAXIMUM_ALLOWED_CHANGES_PER_BATCH: 10_000, + // High enough that a first full index of ~30k files can finish embeddings + // in one run instead of staying chronically "degraded"/partial. MAXIMUM_BATCHES_PER_RUN: - 100, + 2_000, MAXIMUM_ALLOWED_BATCHES_PER_RUN: 10_000, diff --git a/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts b/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts new file mode 100644 index 00000000..f95e174c --- /dev/null +++ b/packages/v8/src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { SqliteEmbeddingVectorCache } from "../SqliteEmbeddingVectorCache"; + +describe("SqliteEmbeddingVectorCache", () => { + it("falls back to memory when SQLite exec/get/set fails", () => { + const cache = new SqliteEmbeddingVectorCache({ + exec: () => { + throw new Error("SQLITE_ERROR: no such table"); + }, + prepare: () => ({ + get: () => { + throw new Error("SQLITE_ERROR"); + }, + run: () => { + throw new Error("SQLITE_ERROR"); + }, + }), + } as never); + + expect(cache.get("profile", "hash")).toBeUndefined(); + expect(() => + cache.set("profile", "hash", [0.1, 0.2, 0.3]), + ).not.toThrow(); + expect(cache.get("profile", "hash")).toEqual([0.1, 0.2, 0.3]); + }); +}); diff --git a/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts b/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts index ba651167..22c0067c 100644 --- a/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts +++ b/packages/v8/src/modules/repository-state/pipeline/ws-indexing-pipeline/WorkspaceIndexingRootFinalizer.ts @@ -318,54 +318,89 @@ export class WorkspaceIndexingRootFinalizer { input.request .synchronizeEmbeddings ) { + // Keep synchronizing until the text-index revision is fully consumed. + // A single partial pass used to leave vectorIndex chronically degraded. + const maxEmbeddingPasses = 64; try { - const embedding = - await this.dependencies - .embedding - .synchronize({ - workspace: - input.request - .workspace, - rootId, - updatedAt: - input.request - .indexedAt, - ...(input.request - .abortSignal - ? { - abortSignal: - input - .request - .abortSignal, - } - : {}), - }); + for ( + let pass = 0; + pass < maxEmbeddingPasses; + pass += 1 + ) { + if ( + input.request.abortSignal + ?.aborted + ) { + embeddingStatus = + "cancelled"; + break; + } - embeddingStatus = - embedding.status; - embeddingProfileId = - embedding - .profile.id; - initialTextRevision = - embedding - .initialTextRevision; - finalTextRevision = - embedding - .finalTextRevision; - latestTextRevision = - embedding - .latestTextRevision; - embeddedChunks = - embedding - .statistics - .chunksEmbedded; - vectorsDeleted = - embedding - .statistics - .vectorsDeleted; + const embedding = + await this.dependencies + .embedding + .synchronize({ + workspace: + input.request + .workspace, + rootId, + updatedAt: + input.request + .indexedAt, + ...(input.request + .abortSignal + ? { + abortSignal: + input + .request + .abortSignal, + } + : {}), + }); + + embeddingStatus = + embedding.status; + embeddingProfileId = + embedding + .profile.id; + if ( + initialTextRevision === + undefined + ) { + initialTextRevision = + embedding + .initialTextRevision; + } + finalTextRevision = + embedding + .finalTextRevision; + latestTextRevision = + embedding + .latestTextRevision; + embeddedChunks += + embedding + .statistics + .chunksEmbedded; + vectorsDeleted += + embedding + .statistics + .vectorsDeleted; + + if ( + embedding.status !== + "partial" + ) { + break; + } + } } catch ( error ) { + // Mark partial so publish/UI show degraded vectors instead of + // "never configured" (undefined embeddingStatus → unavailable). + embeddingStatus = + embeddingStatus ?? + "partial"; warnings.push( this.warning( rootId, diff --git a/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts b/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts index a0dd40b3..45d91a71 100644 --- a/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts +++ b/packages/v8/src/modules/request-understanding/intent/classifiers/rule/RulePatterns.ts @@ -44,9 +44,12 @@ const INTENT_PATTERNS: IntentRule[] = [ confidence: 0.84, }, { + // Diff/PR/change review asks only — bare "code review" as a topic is not + // a perform-review intent (those stay questions / diagnose without + // structured findings unless the Review button injects host markers). intent: "review", pattern: - /\b(?:review|critique)\b.*\b(?:diff|pr|pull request|patch|commit|code|changes|working[- ]tree|findings?)\b|\b(?:code\s*review|emit_review_finding)\b|\bgenerate\b.*\bpr\s+(?:description|summary)\b/i, + /\b(?:review|critique)\b.*\b(?:diff|pr|pull request|patch|commit|changes|working[- ]tree|findings?)\b|\bemit_review_finding\b|\bgenerate\b.*\bpr\s+(?:description|summary)\b/i, confidence: 0.86, }, { diff --git a/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts b/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts index 669d2edd..b4fe3b60 100644 --- a/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts +++ b/packages/v8/src/modules/skills/contracts/input/SkillsSelectInput.ts @@ -68,6 +68,14 @@ export const skillsSelectInputSchema = z .array(z.string().min(1).max(64)) .max(MAX_REQUIRED_SKILLS) .default([]), + /** + * Skill ids that must not be auto-matched for this run. + * Required skills still win if explicitly attached. + */ + excludedSkillIds: z + .array(z.string().min(1).max(64)) + .max(20) + .default([]), /** * When true, omit sizeClass L skills unless alwaysApply / required. * Engine sets this for compact no_cache windows. diff --git a/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts b/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts index c25c7b87..488265e4 100644 --- a/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts +++ b/packages/v8/src/modules/skills/pipeline/SkillsPipeline.ts @@ -153,7 +153,22 @@ export class SkillsPipeline { `${matched.nonMatchedCount} of ${catalog.length} catalog skill(s) did not match this request (path/intent/route/keyword/threshold) and were not considered.`, ); } - const merged = mergeSkillCandidates(required.scored, matched.scored); + const excluded = new Set( + parsed.excludedSkillIds.map((id) => id.trim()).filter(Boolean), + ); + const matchedWithoutExcluded = + excluded.size === 0 + ? matched.scored + : matched.scored.filter((entry) => !excluded.has(entry.skill.id)); + if ( + excluded.size > 0 && + matchedWithoutExcluded.length < matched.scored.length + ) { + warnings.push( + `Excluded ${matched.scored.length - matchedWithoutExcluded.length} auto-matched skill(s) for this run.`, + ); + } + const merged = mergeSkillCandidates(required.scored, matchedWithoutExcluded); const conflicts = resolveSkillConflicts({ scored: merged }); if (conflicts.conflictsResolved) { reasonCodes.push("conflicts_resolved"); diff --git a/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts b/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts index f69b207d..cd0420ec 100644 --- a/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts +++ b/packages/v8/src/modules/skills/tests/SkillsPipeline.spec.ts @@ -571,6 +571,45 @@ describe("SkillsPipeline", () => { ).toBe("required"); }); + it("excludes auto-matched skills listed in excludedSkillIds", async () => { + const pipeline = new SkillsPipeline({ + catalog: new InMemorySkillsCatalog(catalog), + }); + + const included = await pipeline.select( + baseInput({ + route: "execute", + query: "Fix the null check", + evidence: { + primaryIntent: "bugfix", + secondaryIntents: [], + }, + }), + ); + expect(included.instructions.map((block) => block.id)).toContain( + "bugfix-localize", + ); + + const excluded = await pipeline.select( + baseInput({ + route: "execute", + query: "Fix the null check", + evidence: { + primaryIntent: "bugfix", + secondaryIntents: [], + }, + excludedSkillIds: ["bugfix-localize"], + }), + ); + + expect(excluded.instructions.map((block) => block.id)).not.toContain( + "bugfix-localize", + ); + expect( + excluded.warnings.some((warning) => warning.includes("Excluded")), + ).toBe(true); + }); + it("reports not_found omissions for missing required skills", async () => { const pipeline = new SkillsPipeline({ catalog: new InMemorySkillsCatalog(catalog), diff --git a/packages/v8/vitest.config.ts b/packages/v8/vitest.config.ts index 9dafabaf..3e277ab2 100644 --- a/packages/v8/vitest.config.ts +++ b/packages/v8/vitest.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ 'src/modules/repository-state/internal/chunking/tests/CodeCollapse.spec.ts', 'src/modules/repository-state/internal/text-index/tests/TrigramFts.spec.ts', 'src/modules/repository-state/internal/embedding/tests/EmbeddingVectorCache.spec.ts', + 'src/modules/repository-state/internal/embedding/tests/SqliteEmbeddingVectorCache.spec.ts', 'src/modules/repository-state/pipeline/ws-indexing-pipeline/tests/FileProcessorStatFreshness.spec.ts', 'src/modules/model-gateway/tests/OpenAiCompatibleRetry.spec.ts', 'src/modules/model-gateway/tests/AnthropicLlmPort.spec.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e75db7..f82c1f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/node': specifier: ^20.14.0 version: 20.19.43 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 knip: specifier: ^6.21.0 version: 6.24.0 @@ -28,7 +31,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) apps/acp: dependencies: @@ -81,7 +84,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) optionalDependencies: '@lancedb/lancedb': specifier: 0.33.0 @@ -180,6 +183,12 @@ importers: specifier: ^4.0.1 version: 4.0.1 devDependencies: + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/react': specifier: ^18.3.12 version: 18.3.31 @@ -189,6 +198,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@20.19.43)) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 typescript: specifier: ^5.5.2 version: 5.9.3 @@ -216,7 +228,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/host: dependencies: @@ -247,7 +259,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) optionalDependencies: '@lancedb/lancedb': specifier: 0.33.0 @@ -282,7 +294,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/mcp/web: dependencies: @@ -301,7 +313,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/sdk: dependencies: @@ -320,7 +332,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/search-kit: dependencies: @@ -336,7 +348,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) packages/v8: dependencies: @@ -367,7 +379,7 @@ importers: version: 12.11.1 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0) tests: {} @@ -382,6 +394,9 @@ importers: packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -503,6 +518,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -515,6 +534,34 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dependents/detective-less@5.0.3': resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==} engines: {node: '>=18'} @@ -1472,6 +1519,31 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@textlint/ast-node-types@15.7.1': resolution: {integrity: sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==} @@ -1506,6 +1578,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1757,6 +1832,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1773,6 +1852,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-back@3.1.0: resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} engines: {node: '>=6'} @@ -2007,9 +2089,17 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2019,6 +2109,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2126,6 +2219,9 @@ packages: digest-fetch@1.3.0: resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2460,6 +2556,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -2561,6 +2661,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regexp@1.0.0: resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} engines: {node: '>=0.10.0'} @@ -2605,6 +2708,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2717,6 +2829,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + madge@8.0.0: resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==} engines: {node: '>=18'} @@ -3008,6 +3124,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.27: + resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3163,6 +3282,10 @@ packages: engines: {node: '>=18'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -3185,6 +3308,10 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -3207,6 +3334,9 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -3294,6 +3424,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -3316,6 +3449,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3500,6 +3637,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table-layout@4.1.1: resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} engines: {node: '>=12.17'} @@ -3556,6 +3696,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -3564,9 +3711,17 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-sitter-wasms@0.1.13: resolution: {integrity: sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==} @@ -3767,6 +3922,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -3792,6 +3951,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -3801,6 +3964,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -3826,10 +3993,26 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + 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 + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} @@ -3842,6 +4025,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3875,6 +4061,14 @@ packages: snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -4052,6 +4246,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4075,6 +4271,26 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dependents/detective-less@5.0.3': dependencies: gonzales-pe: 4.3.0 @@ -4765,6 +4981,31 @@ snapshots: tslib: 2.8.1 optional: true + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@textlint/ast-node-types@15.7.1': {} '@textlint/linter-formatter@15.7.1': @@ -4814,6 +5055,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -5147,6 +5390,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anynum@1.0.1: {} @@ -5168,6 +5413,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-back@3.1.0: optional: true @@ -5406,12 +5655,24 @@ snapshots: css-what@6.2.2: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5536,6 +5797,8 @@ snapshots: md5: 2.3.0 optional: true + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5947,6 +6210,10 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} htmlparser2@10.1.0: @@ -6034,6 +6301,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regexp@1.0.0: {} is-unicode-supported@0.1.0: {} @@ -6066,6 +6335,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.27 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-bignum@0.0.3: @@ -6187,6 +6483,8 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + madge@8.0.0(typescript@5.9.3): dependencies: chalk: 4.1.2 @@ -6694,6 +6992,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.27: {} + object-inspect@1.13.4: {} object-keys@1.1.1: @@ -6947,6 +7247,12 @@ snapshots: transitivePeerDependencies: - supports-color + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -6977,6 +7283,8 @@ snapshots: punycode.js@2.3.1: {} + punycode@2.3.1: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -7008,6 +7316,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): dependencies: '@types/hast': 3.0.5 @@ -7161,6 +7471,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -7178,6 +7490,10 @@ snapshots: sax@1.6.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -7398,6 +7714,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + table-layout@4.1.1: dependencies: array-back: 6.2.3 @@ -7463,15 +7781,29 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: optional: true + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-sitter-wasms@0.1.13: optional: true @@ -7631,7 +7963,7 @@ snapshots: '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -7659,6 +7991,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 20.19.43 + jsdom: 26.1.0 transitivePeerDependencies: - less - lightningcss @@ -7670,6 +8003,10 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} walkdir@0.4.1: {} @@ -7690,12 +8027,19 @@ snapshots: webidl-conversions@3.0.1: optional: true + webidl-conversions@7.0.0: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -7720,10 +8064,14 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} xml2js@0.5.0: @@ -7733,6 +8081,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@4.0.0: {}