import { LaunchType, closeMainWindow, launchCommand, showHUD, type LaunchProps } from "@vicinae/api"; import { captureRegion } from "./lib/capture"; import { loadPreferences } from "./lib/preferences"; import { describeCaptureFailure, readSnipContext, snipDirectory } from "./lib/snip"; import { visionSupport } from "./lib/vision"; /** * Vicinae's `closeMainWindow` defers the actual hide to a 50ms timer * (`closeWindow(settings, 50ms)` in `ui-service.hpp`), so a capture started the * instant the call resolves can still find the window on screen and select a * region around it. Waiting a little past that timer is the difference between * "the window is closed" and "the window is usually closed". */ const WINDOW_SETTLE_MS = 100; function settleWindow(): Promise { return new Promise((resolve) => setTimeout(resolve, WINDOW_SETTLE_MS)); } /** * Entrypoint for the "snip" command. * * `no-view`, and it has to be. Vicinae calls `showWindow()` when it activates a * command from a closed launcher, so the window is up by the time any extension * code runs; capturing from a view means selecting a region around a panel * sitting in the middle of the screen. This command closes the window first, * captures with nothing in the way, and only then hands the image to the chat, * which is the command that brings the window back. * * It is also the *only* capture path. The chat's own action launches this command * rather than capturing inline, so there is one implementation, one capability * check, and one place where the window is dealt with. * * Bind it to a global shortcut for one-key "snip and ask". */ export default async function SnipCommand(props: LaunchProps) { // Awaited because the model can come from a pick in the Models command, which // lives in storage. The vision check below is about whichever model is in use, // so it has to be the resolved one. const preferences = await loadPreferences(); const context = readSnipContext(props.launchContext); // Before anything else. Everything below is pointless if the selector is // competing with a window for the middle of the screen. await closeMainWindow(); await settleWindow(); // Settled before the capture, because taking a screenshot for a model that // cannot read it throws away the selection gesture the user just performed. // "unknown" says so in those words: a dropped check is not a verdict on the // model, and reporting it as one would send the user shopping for another. const vision = await visionSupport({ host: preferences.host, apiKey: preferences.apiKey, model: preferences.model, }); if (vision !== "yes") { await showHUD( vision === "no" ? `${preferences.model} cannot see images. Pick a vision model in preferences.` : `Could not check whether ${preferences.model} can see images. Run Set Up Lam Agent.`, ); return; } let snipPath: string | undefined; let snipError: string | undefined; try { const capture = await captureRegion({ outputDir: snipDirectory() }); snipPath = capture?.path; } catch (thrown) { snipError = describeCaptureFailure(thrown); } // An abandoned selection says nothing at all: the user pressed Escape. But a // snip started from an open conversation has to come back, because the window // is now closed and the hotkey is otherwise the only way back. if (!snipPath && !snipError && !context.sessionId) return; // Only the fields that exist are sent. `launchCommand` types its context as a // string map, and a key left undefined carries no information anyway. const payload: Record = {}; if (context.sessionId) payload.sessionId = context.sessionId; if (snipPath) payload.snipPath = snipPath; if (snipError) payload.snipError = snipError; await launchCommand({ name: "chat", type: LaunchType.UserInitiated, context: payload, }); }