diff --git a/src/llms/local/service.ts b/src/llms/local/service.ts index a4f73554..7c95912b 100644 --- a/src/llms/local/service.ts +++ b/src/llms/local/service.ts @@ -13,7 +13,7 @@ export class LocalModelService { private static instance: LocalModelService; constructor() { - this.logger = Logger.initialize(LocalModelService.name, { + this.logger = Logger.initialize("LocalModelService", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/services/chat-history-worker.ts b/src/services/chat-history-worker.ts index 8a7e5661..32984926 100644 --- a/src/services/chat-history-worker.ts +++ b/src/services/chat-history-worker.ts @@ -99,7 +99,7 @@ export class ChatHistoryWorker { constructor() { this.chatHistoryRepo = ChatHistoryRepository.getInstance(); - this.logger = Logger.initialize(ChatHistoryWorker.name, { + this.logger = Logger.initialize("ChatHistoryWorker", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/services/docker/DockerModelService.ts b/src/services/docker/DockerModelService.ts index b1ca34ff..df399a88 100644 --- a/src/services/docker/DockerModelService.ts +++ b/src/services/docker/DockerModelService.ts @@ -22,7 +22,7 @@ export class DockerModelService implements vscode.Disposable { private static readonly DAEMON_CHECK_COOLDOWN_MS = 15_000; // Re-check at most once per 15 seconds constructor() { - this.logger = Logger.initialize(DockerModelService.name, { + this.logger = Logger.initialize("DockerModelService", { minLevel: LogLevel.INFO, enableConsole: true, enableFile: true, diff --git a/src/services/file-storage.ts b/src/services/file-storage.ts index 207ba03d..2db4b1fa 100644 --- a/src/services/file-storage.ts +++ b/src/services/file-storage.ts @@ -18,7 +18,7 @@ export class FileStorage implements IStorage { constructor() { this.createCodeBuddyFolder(); - this.logger = Logger.initialize(FileStorage.name, { + this.logger = Logger.initialize("FileStorage", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/services/git-actions.ts b/src/services/git-actions.ts index b75d9242..5db36ffb 100644 --- a/src/services/git-actions.ts +++ b/src/services/git-actions.ts @@ -28,7 +28,7 @@ export class GitActions { baseDir: this.rootPath, }; this.git = simpleGit(options); - this.logger = Logger.initialize(GitActions.name, { + this.logger = Logger.initialize("GitActions", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/services/project-rules.service.ts b/src/services/project-rules.service.ts index 2238d69f..594fb61c 100644 --- a/src/services/project-rules.service.ts +++ b/src/services/project-rules.service.ts @@ -70,7 +70,7 @@ export class ProjectRulesService implements vscode.Disposable { private statusCallback: ((status: IProjectRulesStatus) => void) | undefined; private constructor() { - this.logger = Logger.initialize(ProjectRulesService.name, { + this.logger = Logger.initialize("ProjectRulesService", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/services/test-runner.service.ts b/src/services/test-runner.service.ts index dfa2b699..a4fd4280 100644 --- a/src/services/test-runner.service.ts +++ b/src/services/test-runner.service.ts @@ -121,18 +121,30 @@ class JestVitestStrategy implements TestFrameworkStrategy { } parseCounts(output: string) { - // "Tests: 3 passed, 1 failed, 4 total" - const m = output.match( - /Tests:\s*(?:(\d+)\s*failed,?\s*)?(?:(\d+)\s*skipped,?\s*)?(?:(\d+)\s*passed,?\s*)?(\d+)\s*total/i, - ); - if (!m) return null; - return { - failed: parseInt(m[1] || "0"), - skipped: parseInt(m[2] || "0"), - passed: parseInt(m[3] || "0"), - total: parseInt(m[4] || "0"), - duration: "unknown", - }; + // Match the Jest/Vitest summary line: "Tests: N passed, N failed, N total" + // Fields can appear in any order, so extract each independently. + const summaryMatch = output.match(/Tests:\s*(.+?)\s*total/i); + if (!summaryMatch) return null; + + const line = summaryMatch[0]; + const passedM = line.match(/(\d+)\s*passed/i); + const failedM = line.match(/(\d+)\s*failed/i); + const skippedM = line.match(/(\d+)\s*(?:skipped|pending|todo)/i); + const totalM = line.match(/(\d+)\s*total/i); + + if (!totalM) return null; + + const passed = passedM ? parseInt(passedM[1]) : 0; + const failed = failedM ? parseInt(failedM[1]) : 0; + const skipped = skippedM ? parseInt(skippedM[1]) : 0; + const total = parseInt(totalM[1]); + + // Duration: "Time: 20.56 s" or "Time: 1.234s" + let duration = "unknown"; + const durMatch = output.match(/Time:\s*([\d.]+\s*m?s)/i); + if (durMatch) duration = durMatch[1].trim(); + + return { failed, skipped, passed, total, duration }; } parseFailures(output: string): TestFailure[] { diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 333806ed..e78055bf 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -25,7 +25,7 @@ export class Terminal { > = new Map(); constructor() { - this.logger = Logger.initialize(Terminal.name, { + this.logger = Logger.initialize("Terminal", { minLevel: LogLevel.DEBUG, enableConsole: true, enableFile: true, diff --git a/src/webview-providers/base.ts b/src/webview-providers/base.ts index bbca6084..909c3cc2 100644 --- a/src/webview-providers/base.ts +++ b/src/webview-providers/base.ts @@ -72,6 +72,8 @@ import { TeamGraphHandler, DoctorHandler, OnboardingHandler, + GitStatusHandler, + TestRunnerHandler, } from "./handlers"; import { HandlerContext, MessageHandlerRegistry } from "./handlers/types"; import { AccessControlService } from "../services/access-control.service"; @@ -274,6 +276,8 @@ export abstract class BaseWebViewProvider implements vscode.Disposable { this.handlerRegistry.register(new TerminalViewerHandler()); this.handlerRegistry.register(new DoctorHandler()); this.handlerRegistry.register(new OnboardingHandler()); + this.handlerRegistry.register(new GitStatusHandler()); + this.handlerRegistry.register(new TestRunnerHandler()); this.handlerRegistry.register( new PerformanceHandler( () => this.performanceProfiler, diff --git a/src/webview-providers/handlers/git-status-handler.ts b/src/webview-providers/handlers/git-status-handler.ts new file mode 100644 index 00000000..f7f101ca --- /dev/null +++ b/src/webview-providers/handlers/git-status-handler.ts @@ -0,0 +1,66 @@ +import { WebviewMessageHandler, HandlerContext } from "./types"; +import { GitActions, BranchInfo } from "../../services/git-actions"; + +const GIT_COMMANDS = ["git-status-request"] as const; + +function isGitMessage( + msg: unknown, +): msg is { command: (typeof GIT_COMMANDS)[number] } { + return ( + typeof msg === "object" && + msg !== null && + "command" in msg && + typeof (msg as Record).command === "string" && + GIT_COMMANDS.includes( + (msg as Record).command as (typeof GIT_COMMANDS)[number], + ) + ); +} + +export class GitStatusHandler implements WebviewMessageHandler { + readonly commands = [...GIT_COMMANDS]; + + async handle( + message: Record, + ctx: HandlerContext, + ): Promise { + if (!isGitMessage(message)) return; + + try { + const git = new GitActions(); + const [branchInfo, status] = await Promise.all([ + git.getCurrentBranchInfo(), + git.getRepositoryStatus(), + ]); + + const changedFiles: number = + (status.modified?.length ?? 0) + + (status.not_added?.length ?? 0) + + (status.created?.length ?? 0) + + (status.deleted?.length ?? 0) + + (status.renamed?.length ?? 0); + + const staged: number = status.staged?.length ?? 0; + + await ctx.webview.webview.postMessage({ + type: "git-status-result", + branch: branchInfo.current, + upstream: branchInfo.upstream ?? null, + changedFiles, + staged, + ahead: status.ahead ?? 0, + behind: status.behind ?? 0, + }); + } catch { + await ctx.webview.webview.postMessage({ + type: "git-status-result", + branch: null, + upstream: null, + changedFiles: 0, + staged: 0, + ahead: 0, + behind: 0, + }); + } + } +} diff --git a/src/webview-providers/handlers/index.ts b/src/webview-providers/handlers/index.ts index b0e3c124..cace5b17 100644 --- a/src/webview-providers/handlers/index.ts +++ b/src/webview-providers/handlers/index.ts @@ -23,3 +23,5 @@ export { CostTrackingHandler } from "./cost-tracking-handler"; export { TerminalViewerHandler } from "./terminal-viewer-handler"; export { DoctorHandler } from "./doctor-handler"; export { OnboardingHandler } from "./onboarding-handler"; +export { GitStatusHandler } from "./git-status-handler"; +export { TestRunnerHandler } from "./test-runner-handler"; diff --git a/src/webview-providers/handlers/test-runner-handler.ts b/src/webview-providers/handlers/test-runner-handler.ts new file mode 100644 index 00000000..84a7e1bf --- /dev/null +++ b/src/webview-providers/handlers/test-runner-handler.ts @@ -0,0 +1,105 @@ +import { WebviewMessageHandler, HandlerContext } from "./types"; +import { + TestRunnerService, + TestResult, +} from "../../services/test-runner.service"; + +const TEST_COMMANDS = ["test-run"] as const; + +function isTestMessage(msg: unknown): msg is { + command: (typeof TEST_COMMANDS)[number]; + testPath?: string; + testName?: string; +} { + return ( + typeof msg === "object" && + msg !== null && + "command" in msg && + typeof (msg as Record).command === "string" && + TEST_COMMANDS.includes( + (msg as Record) + .command as (typeof TEST_COMMANDS)[number], + ) + ); +} + +export class TestRunnerHandler implements WebviewMessageHandler { + readonly commands = [...TEST_COMMANDS]; + + async handle( + message: Record, + ctx: HandlerContext, + ): Promise { + if (!isTestMessage(message)) return; + + const service = TestRunnerService.getInstance(); + + // Let the webview know tests are starting + await ctx.webview.webview.postMessage({ + type: "test-run-started", + }); + + try { + const result: TestResult = await service.runTests( + typeof message.testPath === "string" ? message.testPath : undefined, + typeof message.testName === "string" ? message.testName : undefined, + ); + + await ctx.webview.webview.postMessage({ + type: "test-run-result", + result: { + framework: result.framework, + command: result.command, + passed: result.passed, + failed: result.failed, + skipped: result.skipped, + total: result.total, + duration: result.duration, + success: result.success, + failures: result.failures, + parseWarning: result.parseWarning ?? null, + }, + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : "Test run failed"; + + // Attempt partial parsing from timeout output + const timeoutMatch = errMsg.match( + /^Test command timed out after [\d.]+s\. Output so far:\n([\s\S]+)$/, + ); + if (timeoutMatch) { + const partialOutput = timeoutMatch[1]; + const partial = service.parseOutput( + partialOutput, + "unknown", + "timed-out", + ); + + if (partial.total > 0 || partial.failures.length > 0) { + await ctx.webview.webview.postMessage({ + type: "test-run-result", + result: { + framework: partial.framework, + command: partial.command, + passed: partial.passed, + failed: partial.failed, + skipped: partial.skipped, + total: partial.total, + duration: partial.duration, + success: false, + failures: partial.failures, + parseWarning: + "Test command timed out. Results shown are partial.", + }, + }); + return; + } + } + + await ctx.webview.webview.postMessage({ + type: "test-run-error", + error: errMsg, + }); + } + } +} diff --git a/webviewUi/src/components/GitStatusBar.tsx b/webviewUi/src/components/GitStatusBar.tsx new file mode 100644 index 00000000..502f4ad6 --- /dev/null +++ b/webviewUi/src/components/GitStatusBar.tsx @@ -0,0 +1,95 @@ +import { useEffect } from "react"; +import styled from "styled-components"; +import { useGitStore } from "../stores/git.store"; + +const Bar = styled.div` + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--vscode-descriptionForeground, #888); + white-space: nowrap; + overflow: hidden; +`; + +const BranchName = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vscode-foreground, #ccc); + font-weight: 500; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Badge = styled.span<{ $color?: string }>` + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 5px; + border-radius: 8px; + font-size: 10px; + font-weight: 500; + background: ${(p) => p.$color ?? "rgba(255,255,255,0.08)"}; + color: var(--vscode-foreground, #ccc); +`; + +const BranchIcon = () => ( + + + + + + +); + +export function GitStatusBar() { + const { branch, changedFiles, staged, ahead, behind, startPolling, stopPolling } = + useGitStore(); + + useEffect(() => { + startPolling(); + return stopPolling; + }, [startPolling, stopPolling]); + + if (!branch) return null; + + return ( + + + + {branch} + + {changedFiles > 0 && ( + + {changedFiles}M + + )} + {staged > 0 && ( + + {staged}S + + )} + {ahead > 0 && ( + + ↑{ahead} + + )} + {behind > 0 && ( + + ↓{behind} + + )} + + ); +} diff --git a/webviewUi/src/components/ModeSelector.tsx b/webviewUi/src/components/ModeSelector.tsx new file mode 100644 index 00000000..82d9bca5 --- /dev/null +++ b/webviewUi/src/components/ModeSelector.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { useSettingsStore } from "../stores/settings.store"; +import { codeBuddyMode } from "../constants/constant"; + +const MODE_META: Record = { + Agent: { hint: "Autonomous — runs tools, edits files, executes commands" }, + Ask: { hint: "Conversational — answers questions, no side-effects" }, +}; + +/* ── Inline styles ── */ + +const pillStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "4px", + fontSize: "11px", + padding: "2px 8px", + borderRadius: "10px", + cursor: "pointer", + background: "rgba(255,255,255,0.06)", + border: "1px solid rgba(255,255,255,0.1)", + color: "var(--vscode-descriptionForeground, rgba(255,255,255,0.7))", + whiteSpace: "nowrap", + position: "relative", + userSelect: "none", +}; + +const popoverStyle: React.CSSProperties = { + position: "absolute", + bottom: "calc(100% + 6px)", + left: 0, + minWidth: "200px", + background: "var(--vscode-editorWidget-background, #252526)", + border: "1px solid var(--vscode-editorWidget-border, rgba(255,255,255,0.12))", + borderRadius: "8px", + boxShadow: "0 4px 16px rgba(0,0,0,0.35)", + zIndex: 1000, + padding: "6px 0", +}; + +const itemStyle = (isActive: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "7px 12px", + fontSize: "12px", + cursor: "pointer", + background: isActive ? "rgba(255,255,255,0.08)" : "transparent", + color: isActive + ? "var(--vscode-textLink-foreground, #3794ff)" + : "var(--vscode-foreground, #ccc)", + borderLeft: isActive + ? "2px solid var(--vscode-textLink-foreground, #3794ff)" + : "2px solid transparent", +}); + +const hintStyle: React.CSSProperties = { + fontSize: "10px", + color: "rgba(255,255,255,0.4)", + marginTop: "2px", +}; + +/* ── Component ── */ + +export function ModeSelector() { + const selectedMode = useSettingsStore((s) => s.selectedCodeBuddyMode); + const handleModeChange = useSettingsStore((s) => s.handleCodeBuddyModeChange); + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [isOpen]); + + const select = useCallback( + (value: string) => { + handleModeChange(value); + setIsOpen(false); + }, + [handleModeChange], + ); + + const meta = MODE_META[selectedMode] ?? MODE_META.Agent; + + return ( +
+
setIsOpen((o) => !o)} + title={`Mode: ${selectedMode} — ${meta.hint}`} + role="button" + aria-haspopup="listbox" + aria-expanded={isOpen} + > + + {selectedMode} + +
+ + {isOpen && ( +
+ {codeBuddyMode.map((opt) => { + const isActive = selectedMode === opt.value; + const m = MODE_META[opt.value]; + return ( +
select(opt.value)} + role="option" + aria-selected={isActive} + onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "rgba(255,255,255,0.05)"; }} + onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }} + > + +
+
{opt.label}
+ {m &&
{m.hint}
} +
+
+ ); + })} +
+ )} +
+ ); +} + +/* ── Tiny chevron icon ── */ + +function ChevronIcon({ size = 10 }: { size?: number }) { + return ( + + + + ); +} + +/* ── Mode icons (Agent = bolt, Ask = chat bubble) ── */ + +function ModeIcon({ mode, size = 14 }: { mode: string; size?: number }) { + if (mode === "Agent") { + return ( + + + + ); + } + return ( + + + + ); +} diff --git a/webviewUi/src/components/ModelSelector.tsx b/webviewUi/src/components/ModelSelector.tsx new file mode 100644 index 00000000..aa6a0dbc --- /dev/null +++ b/webviewUi/src/components/ModelSelector.tsx @@ -0,0 +1,379 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { useSettingsStore } from "../stores/settings.store"; +import { modelOptions, PREDEFINED_LOCAL_MODELS } from "../constants/constant"; +import { usePanelStore } from "../stores/panels.store"; +import { vscode } from "../utils/vscode"; + +/* ── Inline styles (compact status-bar component) ── */ + +const pillStyle: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "5px", + fontSize: "11px", + padding: "2px 8px", + borderRadius: "10px", + cursor: "pointer", + background: "rgba(255,255,255,0.06)", + border: "1px solid rgba(255,255,255,0.1)", + color: "var(--vscode-descriptionForeground, rgba(255,255,255,0.7))", + whiteSpace: "nowrap", + position: "relative", + userSelect: "none", +}; + +const popoverStyle: React.CSSProperties = { + position: "absolute", + bottom: "calc(100% + 6px)", + left: 0, + minWidth: "220px", + maxWidth: "280px", + background: "var(--vscode-editorWidget-background, #252526)", + border: "1px solid var(--vscode-editorWidget-border, rgba(255,255,255,0.12))", + borderRadius: "8px", + boxShadow: "0 4px 16px rgba(0,0,0,0.35)", + zIndex: 1000, + padding: "6px 0", + maxHeight: "320px", + overflowY: "auto", +}; + +const sectionLabelStyle: React.CSSProperties = { + fontSize: "10px", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.5px", + color: "rgba(255,255,255,0.4)", + padding: "6px 12px 3px", +}; + +const itemStyle = (isActive: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "5px 12px", + fontSize: "12px", + cursor: "pointer", + background: isActive ? "rgba(255,255,255,0.08)" : "transparent", + color: isActive + ? "var(--vscode-textLink-foreground, #3794ff)" + : "var(--vscode-foreground, #ccc)", + borderLeft: isActive ? "2px solid var(--vscode-textLink-foreground, #3794ff)" : "2px solid transparent", +}); + +const itemHoverHandlers = (isActive: boolean) => ({ + onMouseEnter: (e: React.MouseEvent) => { + if (!isActive) e.currentTarget.style.background = "rgba(255,255,255,0.05)"; + }, + onMouseLeave: (e: React.MouseEvent) => { + if (!isActive) e.currentTarget.style.background = "transparent"; + }, +}); + +const badgeStyle = (color: string): React.CSSProperties => ({ + display: "inline-block", + width: "6px", + height: "6px", + borderRadius: "50%", + background: color, + flexShrink: 0, +}); + +const localSubStyle: React.CSSProperties = { + fontSize: "10px", + color: "rgba(255,255,255,0.45)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +const footerStyle: React.CSSProperties = { + borderTop: "1px solid rgba(255,255,255,0.08)", + padding: "6px 12px", + fontSize: "11px", + color: "var(--vscode-textLink-foreground, #3794ff)", + cursor: "pointer", + textAlign: "center", +}; + +/* ── Component ── */ + +export function ModelSelector() { + const selectedModel = useSettingsStore((s) => s.selectedModel); + const handleModelChange = useSettingsStore((s) => s.handleModelChange); + const [isOpen, setIsOpen] = useState(false); + const [dockerAvailable, setDockerAvailable] = useState(false); + const [ollamaRunning, setOllamaRunning] = useState(false); + const [activeLocalModel, setActiveLocalModel] = useState(null); + const [pulledModels, setPulledModels] = useState([]); + const ref = useRef(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [isOpen]); + + // Listen for Docker messages (same pattern as ModelsSettings) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const msg = event.data; + switch (msg.type) { + case "docker-status": + setDockerAvailable(msg.available); + break; + case "docker-ollama-status": + setOllamaRunning(msg.running); + break; + case "docker-local-model": + setActiveLocalModel(msg.model); + break; + case "docker-models-list": + if (msg.models) setPulledModels(msg.models.map((m: any) => m.name)); + break; + case "docker-model-selected": + if (msg.success) { + setActiveLocalModel(msg.model); + handleModelChange("Local"); + } + break; + } + }; + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [handleModelChange]); + + // Request Docker status on mount + poll every 30s + useEffect(() => { + const check = () => { + vscode?.postMessage({ command: "docker-check-status" }); + vscode?.postMessage({ command: "docker-check-ollama-status" }); + vscode?.postMessage({ command: "docker-get-local-model" }); + vscode?.postMessage({ command: "docker-get-models" }); + }; + check(); + const id = setInterval(check, 30_000); + return () => clearInterval(id); + }, []); + + const selectProvider = useCallback( + (value: string) => { + handleModelChange(value); + setIsOpen(false); + }, + [handleModelChange], + ); + + const selectLocalModel = useCallback( + (modelName: string) => { + vscode?.postMessage({ command: "docker-use-model", model: modelName }); + // Keep popover open briefly so user sees feedback + }, + [], + ); + + const openModelSettings = useCallback(() => { + setIsOpen(false); + usePanelStore.getState().openSettings(); + }, []); + + const isLocal = selectedModel === "Local"; + const localReady = dockerAvailable || ollamaRunning; + const displayLabel = + isLocal && activeLocalModel + ? activeLocalModel + : modelOptions.find((o) => o.value === selectedModel)?.label ?? selectedModel; + + const cloudProviders = modelOptions.filter((o) => o.value !== "Local"); + + return ( +
+
setIsOpen((o) => !o)} + title={`Model: ${displayLabel}${isLocal && localReady ? " (running)" : ""}`} + role="button" + aria-haspopup="listbox" + aria-expanded={isOpen} + > + + + {displayLabel} + + {isLocal && ( + + )} + +
+ + {isOpen && ( +
+ {/* ── Cloud Providers ── */} +
Providers
+ {cloudProviders.map((opt) => ( +
selectProvider(opt.value)} + role="option" + aria-selected={selectedModel === opt.value} + {...itemHoverHandlers(selectedModel === opt.value)} + > + {opt.label} + + {opt.pricingHint} + +
+ ))} + + {/* ── Local Models ── */} +
+ Local Models + +
+ + {/* Active local model */} + {isLocal && activeLocalModel && ( +
+ {activeLocalModel} + Active · {dockerAvailable ? "Docker" : "Ollama"} +
+ )} + + {/* Pulled / available models */} + {pulledModels + .filter((m) => m !== activeLocalModel) + .slice(0, 5) + .map((m) => ( +
selectLocalModel(m)} + role="option" + aria-selected={false} + {...itemHoverHandlers(false)} + > + {m} + Use +
+ ))} + + {/* Quick-pull suggestions when no pulled models */} + {pulledModels.length === 0 && ( + <> + {PREDEFINED_LOCAL_MODELS.slice(0, 3).map((m) => ( +
+ {m.label} + available +
+ ))} + + )} + + {/* Select Local provider if not already */} + {!isLocal && ( +
selectProvider("Local")} + role="option" + aria-selected={false} + {...itemHoverHandlers(false)} + > + Switch to Local + free +
+ )} + + {/* Footer link */} +
) => { + e.currentTarget.style.background = "rgba(255,255,255,0.04)"; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.background = "transparent"; + }, + }} + > + Manage models… +
+
+ )} +
+ ); +} + +/* ── Tiny inline icons ── */ + +function ModelIcon({ size = 14 }: { size?: number }) { + return ( + + + + + + ); +} + +function ChevronIcon({ size = 10 }: { size?: number }) { + return ( + + + + ); +} diff --git a/webviewUi/src/components/team/TeamPanel.tsx b/webviewUi/src/components/team/TeamPanel.tsx index 9228f269..a23376ed 100644 --- a/webviewUi/src/components/team/TeamPanel.tsx +++ b/webviewUi/src/components/team/TeamPanel.tsx @@ -160,9 +160,9 @@ const NotesTextArea = styled.textarea` `; const IngestButton = styled.button<{ $loading?: boolean }>` - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; + background: rgba(255, 255, 255, 0.08); + color: var(--vscode-foreground); + border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 4px; padding: 6px 14px; cursor: ${(p) => (p.$loading ? "wait" : "pointer")}; @@ -174,7 +174,7 @@ const IngestButton = styled.button<{ $loading?: boolean }>` transition: all 0.15s ease; &:hover:not(:disabled) { - background: var(--vscode-button-hoverBackground); + background: rgba(255, 255, 255, 0.14); } &:disabled { diff --git a/webviewUi/src/components/test-runner/TestRunnerPanel.tsx b/webviewUi/src/components/test-runner/TestRunnerPanel.tsx new file mode 100644 index 00000000..0f3cc128 --- /dev/null +++ b/webviewUi/src/components/test-runner/TestRunnerPanel.tsx @@ -0,0 +1,381 @@ +import styled from "styled-components"; +import { useTestRunnerStore } from "../../stores/testRunner.store"; +import type { TestFailureInfo } from "../../stores/testRunner.store"; + +interface TestRunnerPanelProps { + isOpen: boolean; + onClose: () => void; +} + +/* ─── Styled Components ─── */ + +const PanelOverlay = styled.div<{ $isOpen: boolean }>` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: ${(p) => (p.$isOpen ? "flex" : "none")}; + justify-content: flex-end; + animation: fadeIn 0.2s ease-in-out; + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } +`; + +const PanelContainer = styled.div` + width: 460px; + max-width: 100%; + height: 100%; + background: var(--vscode-editor-background); + border-left: 1px solid var(--vscode-widget-border); + display: flex; + flex-direction: column; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2); + animation: slideIn 0.2s ease-in-out; + + @keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } +`; + +const Header = styled.div` + padding: 16px; + border-bottom: 1px solid var(--vscode-widget-border); + display: flex; + justify-content: space-between; + align-items: center; +`; + +const Title = styled.h2` + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + display: flex; + align-items: center; + gap: 8px; +`; + +const CloseButton = styled.button` + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + &:hover { background: var(--vscode-toolbar-hoverBackground); } +`; + +const Content = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; +`; + +const SummaryCard = styled.div<{ $success: boolean }>` + padding: 16px; + border-radius: 8px; + border: 1px solid ${(p) => + p.$success + ? "var(--vscode-charts-green, #89d185)" + : "var(--vscode-charts-red, #f48771)"}; + background: ${(p) => + p.$success ? "rgba(137,209,133,0.08)" : "rgba(244,135,113,0.08)"}; + margin-bottom: 16px; +`; + +const SummaryRow = styled.div` + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +`; + +const Stat = styled.div<{ $color?: string }>` + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +`; + +const StatValue = styled.span<{ $color?: string }>` + font-size: 22px; + font-weight: 700; + color: ${(p) => p.$color ?? "var(--vscode-foreground)"}; +`; + +const StatLabel = styled.span` + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +`; + +const Meta = styled.div` + margin-top: 12px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + display: flex; + gap: 12px; + flex-wrap: wrap; +`; + +const FailureList = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const FailureCard = styled.div` + padding: 12px; + border-radius: 6px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border); +`; + +const FailureName = styled.div` + font-weight: 600; + font-size: 12px; + color: var(--vscode-foreground); + margin-bottom: 4px; +`; + +const FailureFile = styled.div` + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; +`; + +const FailureMessage = styled.pre` + font-size: 11px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + color: var(--vscode-charts-red, #f48771); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + max-height: 120px; + overflow-y: auto; +`; + +const SectionTitle = styled.h3` + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground); + margin: 0 0 10px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const EmptyState = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground); + font-size: 13px; + padding: 24px; + text-align: center; +`; + +const ActionBar = styled.div` + padding: 12px 16px; + border-top: 1px solid var(--vscode-widget-border); + display: flex; + gap: 8px; +`; + +const RunButton = styled.button<{ $running?: boolean }>` + flex: 1; + background: ${(p) => + p.$running + ? "var(--vscode-button-secondaryBackground)" + : "rgba(255, 255, 255, 0.08)"}; + color: ${(p) => + p.$running + ? "var(--vscode-button-secondaryForeground)" + : "var(--vscode-foreground)"}; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + padding: 8px 14px; + font-size: 12px; + font-weight: 500; + cursor: ${(p) => (p.$running ? "default" : "pointer")}; + opacity: ${(p) => (p.$running ? 0.7 : 1)}; + &:hover { + background: ${(p) => + p.$running + ? "var(--vscode-button-secondaryBackground)" + : "rgba(255, 255, 255, 0.14)"}; + } +`; + +const ErrorBanner = styled.div` + padding: 10px 14px; + border-radius: 6px; + font-size: 11px; + font-family: "Menlo", "Monaco", "Courier New", monospace; + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); + color: var(--vscode-errorForeground, #f48771); + margin-bottom: 16px; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +`; + +const Spinner = styled.div` + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--vscode-foreground); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 8px; + + @keyframes spin { + to { transform: rotate(360deg); } + } +`; + +/* ─── Component ─── */ + +const TestIcon = () => ( + + + + + +); + +export function TestRunnerPanel({ isOpen, onClose }: TestRunnerPanelProps) { + const { isRunning, result, error, runTests, clear } = useTestRunnerStore(); + + return ( + + e.stopPropagation()}> +
+ + <TestIcon /> + Test Runner + + + + + + +
+ + + {error && {error}} + + {isRunning && ( + +
+ + Running tests… +
+
+ )} + + {!isRunning && !result && !error && ( + + Click "Run Tests" to detect your test framework and execute your test suite. + + )} + + {!isRunning && result && ( + <> + + + + + {result.passed} + + Passed + + + + {result.failed} + + Failed + + + + {result.skipped} + + Skipped + + + {result.total} + Total + + + + Framework: {result.framework} + Duration: {result.duration} + + {result.parseWarning && ( + + + ⚠ {result.parseWarning} + + + )} + + + {result.failures.length > 0 && ( + <> + Failures ({result.failures.length}) + + {result.failures.map((f: TestFailureInfo, i: number) => ( + + {f.testName} + {f.file && {f.file}} + {f.message} + + ))} + + + )} + + )} +
+ + + !isRunning && runTests()} + disabled={isRunning} + > + {isRunning ? "Running…" : "Run Tests"} + + {result && ( + + Clear + + )} + +
+
+ ); +} diff --git a/webviewUi/src/components/webview.styles.tsx b/webviewUi/src/components/webview.styles.tsx index 65c5a8ed..4fa2e992 100644 --- a/webviewUi/src/components/webview.styles.tsx +++ b/webviewUi/src/components/webview.styles.tsx @@ -18,7 +18,9 @@ export const SidebarButton = styled.button` background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; - padding: 5px; + padding: 4px; + width: 26px; + height: 26px; cursor: pointer; color: rgba(255, 255, 255, 0.85); display: flex; @@ -49,6 +51,7 @@ export const CoWorkerToggleButton = SidebarButton; export const TeamToggleButton = SidebarButton; export const CostToggleButton = SidebarButton; export const TerminalToggleButton = SidebarButton; +export const TestRunnerToggleButton = SidebarButton; // ── Font Size Controls ── @@ -224,3 +227,21 @@ export const TerminalIcon = ({ size = 14 }: { size?: number }) => ( ); + +export const TestRunnerIcon = ({ size = 14 }: { size?: number }) => ( + + + + + + +); diff --git a/webviewUi/src/components/webview.tsx b/webviewUi/src/components/webview.tsx index a67c9960..2c1498bc 100644 --- a/webviewUi/src/components/webview.tsx +++ b/webviewUi/src/components/webview.tsx @@ -28,6 +28,9 @@ import AttachmentIcon from "./attachmentIcon"; import ChatInput from "./ChatInput"; import { CostDisplay } from "./CostDisplay"; import { ProviderHealthIndicator } from "./ProviderHealthIndicator"; +import { GitStatusBar } from "./GitStatusBar"; +import { ModelSelector } from "./ModelSelector"; +import { ModeSelector } from "./ModeSelector"; import { CommandFeedbackLoader } from "./commandFeedbackLoader"; import MessageRenderer from "./MessageRenderer"; import { PendingChangesPanel } from "./PendingChangesPanel"; @@ -45,6 +48,7 @@ import { TeamPanel } from "./team/TeamPanel"; import { CostDashboardPanel } from "./cost/CostDashboardPanel"; import { TerminalViewerPanel } from "./terminal/TerminalViewerPanel"; import { BrowserPanel } from "./browser/BrowserPanel"; +import { TestRunnerPanel } from "./test-runner/TestRunnerPanel"; import { PanelErrorBoundary } from "./PanelErrorBoundary"; import { OnboardingWizard } from "./onboarding/OnboardingWizard"; import { useOnboardingStore } from "../stores/onboarding.store"; @@ -60,6 +64,7 @@ import { TeamToggleButton, CostToggleButton, TerminalToggleButton, + TestRunnerToggleButton, FontSizeGroup, FontSizeButton, SessionsIcon, @@ -71,6 +76,7 @@ import { TeamIcon, CostIcon, TerminalIcon, + TestRunnerIcon, } from "./webview.styles"; const hljsApi = window["hljs" as any] as unknown as typeof hljs; @@ -105,6 +111,7 @@ export const WebviewUI = () => { const isTeamPanelOpen = usePanelStore((s) => s.isTeamPanelOpen); const isCostDashboardOpen = usePanelStore((s) => s.isCostDashboardOpen); const isTerminalViewerOpen = usePanelStore((s) => s.isTerminalViewerOpen); + const isTestRunnerOpen = usePanelStore((s) => s.isTestRunnerOpen); // Onboarding const onboardingVisible = useOnboardingStore((s) => s.isVisible); @@ -281,7 +288,7 @@ export const WebviewUI = () => { aria-label="Open settings" title="Settings" > - + { aria-label="Open sessions" title="Sessions" > - + { aria-label="Open notifications" title="Notifications" > - + {unreadNotificationCount > 0 && ( { aria-label="Open updates" title="Updates" > - + { aria-label="Open observability" title="Observability" > - + { aria-label="Open browser" title="Browser" > - + { aria-label="Open co-worker" title="Co-Worker" > - + { aria-label="Open team graph" title="Team Graph" > - + { aria-label="Open cost dashboard" title="Cost Dashboard" > - + { aria-label="Open terminal viewer" title="Terminal Activity" > - + + usePanelStore.getState().openTestRunner()} + aria-label="Open test runner" + title="Test Runner" + > + + + useSettingsStore.getState().handleIncreaseFontSize()} title="Increase Font Size"> A+ @@ -506,6 +521,14 @@ export const WebviewUI = () => { /> + {/* Test Runner Panel */} + + usePanelStore.getState().closeTestRunner()} + /> + + CHAT FAQ @@ -630,6 +653,9 @@ export const WebviewUI = () => { )}
+ + +
diff --git a/webviewUi/src/hooks/useMessageDispatcher.ts b/webviewUi/src/hooks/useMessageDispatcher.ts index 4062e261..b07b57dc 100644 --- a/webviewUi/src/hooks/useMessageDispatcher.ts +++ b/webviewUi/src/hooks/useMessageDispatcher.ts @@ -14,6 +14,8 @@ import { useOnboardingStore } from "../stores/onboarding.store"; import { useTeamStore } from "../stores/team.store"; import { useCostStore } from "../stores/cost.store"; import { useTerminalStore } from "../stores/terminal.store"; +import { useGitStore } from "../stores/git.store"; +import { useTestRunnerStore } from "../stores/testRunner.store"; import type { IWebviewMessage } from "./useStreamingChat"; interface ConfigData { @@ -558,6 +560,33 @@ export function useMessageDispatcher(streamingChat: StreamingChatAPI) { ); break; + // ── Git Status ── + case "git-status-result": + useGitStore.getState().setStatus({ + branch: message.branch ?? null, + upstream: message.upstream ?? null, + changedFiles: message.changedFiles ?? 0, + staged: message.staged ?? 0, + ahead: message.ahead ?? 0, + behind: message.behind ?? 0, + }); + break; + + // ── Test Runner ── + case "test-run-started": + useTestRunnerStore.getState().setRunning(); + break; + + case "test-run-result": + useTestRunnerStore.getState().setResult(message.result); + break; + + case "test-run-error": + useTestRunnerStore + .getState() + .setError(message.error ?? "Test run failed"); + break; + default: break; } diff --git a/webviewUi/src/stores/git.store.ts b/webviewUi/src/stores/git.store.ts new file mode 100644 index 00000000..ab912be6 --- /dev/null +++ b/webviewUi/src/stores/git.store.ts @@ -0,0 +1,71 @@ +import { create } from "zustand"; +import { vscode } from "../utils/vscode"; + +const GIT_POLL_INTERVAL_MS = 15_000; + +interface GitStatusState { + branch: string | null; + upstream: string | null; + changedFiles: number; + staged: number; + ahead: number; + behind: number; + + // Actions + requestStatus: () => void; + startPolling: () => void; + stopPolling: () => void; + + // Setters ← dispatcher + setStatus: (data: { + branch: string | null; + upstream: string | null; + changedFiles: number; + staged: number; + ahead: number; + behind: number; + }) => void; + + // Internal + _pollId: ReturnType | null; +} + +export const useGitStore = create()((set, get) => ({ + branch: null, + upstream: null, + changedFiles: 0, + staged: 0, + ahead: 0, + behind: 0, + _pollId: null, + + requestStatus: () => { + vscode.postMessage({ command: "git-status-request" }); + }, + + startPolling: () => { + const { _pollId, requestStatus } = get(); + if (_pollId) return; // already polling + requestStatus(); + const id = setInterval(requestStatus, GIT_POLL_INTERVAL_MS); + set({ _pollId: id }); + }, + + stopPolling: () => { + const { _pollId } = get(); + if (_pollId) { + clearInterval(_pollId); + set({ _pollId: null }); + } + }, + + setStatus: (data) => + set({ + branch: data.branch, + upstream: data.upstream, + changedFiles: data.changedFiles, + staged: data.staged, + ahead: data.ahead, + behind: data.behind, + }), +})); diff --git a/webviewUi/src/stores/index.ts b/webviewUi/src/stores/index.ts index 1c752b61..412bc9c7 100644 --- a/webviewUi/src/stores/index.ts +++ b/webviewUi/src/stores/index.ts @@ -13,3 +13,5 @@ export { useChatStore } from "./chat.store"; export { useTeamStore } from "./team.store"; export { useCostStore } from "./cost.store"; export { useTerminalStore } from "./terminal.store"; +export { useGitStore } from "./git.store"; +export { useTestRunnerStore } from "./testRunner.store"; diff --git a/webviewUi/src/stores/panels.store.ts b/webviewUi/src/stores/panels.store.ts index 2a0d2568..439f25d3 100644 --- a/webviewUi/src/stores/panels.store.ts +++ b/webviewUi/src/stores/panels.store.ts @@ -11,6 +11,7 @@ interface PanelState { isTeamPanelOpen: boolean; isCostDashboardOpen: boolean; isTerminalViewerOpen: boolean; + isTestRunnerOpen: boolean; openSettings: () => void; closeSettings: () => void; @@ -32,6 +33,8 @@ interface PanelState { closeCostDashboard: () => void; openTerminalViewer: () => void; closeTerminalViewer: () => void; + openTestRunner: () => void; + closeTestRunner: () => void; } export const usePanelStore = create()((set) => ({ @@ -45,6 +48,7 @@ export const usePanelStore = create()((set) => ({ isTeamPanelOpen: false, isCostDashboardOpen: false, isTerminalViewerOpen: false, + isTestRunnerOpen: false, openSettings: () => set({ isSettingsOpen: true }), closeSettings: () => set({ isSettingsOpen: false }), @@ -67,4 +71,6 @@ export const usePanelStore = create()((set) => ({ closeCostDashboard: () => set({ isCostDashboardOpen: false }), openTerminalViewer: () => set({ isTerminalViewerOpen: true }), closeTerminalViewer: () => set({ isTerminalViewerOpen: false }), + openTestRunner: () => set({ isTestRunnerOpen: true }), + closeTestRunner: () => set({ isTestRunnerOpen: false }), })); diff --git a/webviewUi/src/stores/testRunner.store.ts b/webviewUi/src/stores/testRunner.store.ts new file mode 100644 index 00000000..3f9c5865 --- /dev/null +++ b/webviewUi/src/stores/testRunner.store.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; +import { vscode } from "../utils/vscode"; + +export interface TestFailureInfo { + testName: string; + file?: string; + message: string; + expected?: string; + actual?: string; +} + +export interface TestRunResult { + framework: string; + command: string; + passed: number; + failed: number; + skipped: number; + total: number; + duration: string; + success: boolean; + failures: TestFailureInfo[]; + parseWarning: string | null; +} + +interface TestRunnerState { + isRunning: boolean; + result: TestRunResult | null; + error: string | null; + + // Actions + runTests: (testPath?: string, testName?: string) => void; + + // Setters ← dispatcher + setRunning: () => void; + setResult: (result: TestRunResult) => void; + setError: (error: string) => void; + clear: () => void; +} + +export const useTestRunnerStore = create()((set) => ({ + isRunning: false, + result: null, + error: null, + + runTests: (testPath, testName) => { + set({ isRunning: true, error: null }); + vscode.postMessage({ + command: "test-run", + ...(testPath ? { testPath } : {}), + ...(testName ? { testName } : {}), + }); + }, + + setRunning: () => set({ isRunning: true, error: null }), + setResult: (result) => set({ result, isRunning: false, error: null }), + setError: (error) => set({ error, isRunning: false }), + clear: () => set({ result: null, error: null, isRunning: false }), +}));