The snip was selecting a region around the launcher panel sitting in the middle of the screen. `activateEntrypoint` calls `showWindow()` when it activates a command from a closed launcher, so a view command's window is already up before any extension code runs. Capturing from inside the chat view could never have worked. - `snip` is now a `no-view` command. It calls `closeMainWindow()` first, waits out the 50ms hide that `ui-service.hpp` schedules, then captures with nothing on screen, then brings the window back by launching `chat` with the file. `settleWindow()` exists because a capture started the instant the close resolves could still find the window there. - The chat's action no longer captures inline. It launches `snip` and passes the session id, so there is one capture path, one capability check, and one place that deals with the window. - The hand-off relays through `snip` rather than going straight back to `chat` on purpose: `ActivateEntrypointOptions::toggleIfAlreadyActive` defaults to true, so launching the *already active* view command closes the window instead of reopening it. A launch cannot override that flag, but relaying through a different no-view command makes the toggle branch unreachable. - The action is hidden while streaming, since the hand-off unloads the view and would abandon an answer mid-sentence. - Vision is settled before the selector appears, so a model that cannot read images never costs a selection gesture. `no` and `unknown` get different HUD wording. - `lib/snip.ts` holds the hand-off contract; `lib/hooks/useSnip.ts` is gone, as nothing captures from a view any more. Verified: 252 tests, tsc, eslint, prettier, and vici build. The deployed manifest now reports `snip mode=no-view`.
58 lines
2.1 KiB
TypeScript
58 lines
2.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { readSnipContext, snipDirectory } from "../lib/snip";
|
|
|
|
/**
|
|
* The snip handoff crosses a command boundary as a serialized launch context, so
|
|
* every field arrives as unknown. This is the boundary where a wrong type would
|
|
* otherwise reach the file system as a path.
|
|
*/
|
|
|
|
describe("readSnipContext", () => {
|
|
it("reads the fields the snip command writes", () => {
|
|
expect(
|
|
readSnipContext({
|
|
snipPath: "/tmp/snip.png",
|
|
snipError: "no tool",
|
|
sessionId: "abc123",
|
|
}),
|
|
).toEqual({ snipPath: "/tmp/snip.png", snipError: "no tool", sessionId: "abc123" });
|
|
});
|
|
|
|
it("returns nothing for a launch with no context at all", () => {
|
|
expect(readSnipContext(undefined)).toEqual({});
|
|
expect(readSnipContext(null)).toEqual({});
|
|
});
|
|
|
|
it("ignores a context that is not an object", () => {
|
|
expect(readSnipContext("snip.png")).toEqual({});
|
|
expect(readSnipContext(42)).toEqual({});
|
|
expect(readSnipContext(["/tmp/snip.png"])).toEqual({});
|
|
});
|
|
|
|
it("drops a value that is not a string rather than passing it on", () => {
|
|
// A number or object here would reach `readAttachment` as a path.
|
|
expect(readSnipContext({ snipPath: 42, sessionId: { id: "x" } })).toEqual({});
|
|
});
|
|
|
|
it("treats an empty string as absent", () => {
|
|
expect(readSnipContext({ snipPath: "", sessionId: "" })).toEqual({});
|
|
});
|
|
|
|
it("ignores unknown keys, so a stray field cannot widen the contract", () => {
|
|
expect(readSnipContext({ snipPath: "/tmp/snip.png", somethingElse: "/etc/passwd" })).toEqual({
|
|
snipPath: "/tmp/snip.png",
|
|
});
|
|
});
|
|
|
|
it("reads a payload that only names a conversation, which is a cancelled snip", () => {
|
|
expect(readSnipContext({ sessionId: "abc123" })).toEqual({ sessionId: "abc123" });
|
|
});
|
|
});
|
|
|
|
describe("snipDirectory", () => {
|
|
it("keeps snips under the extension's support directory, not a temp directory", () => {
|
|
// A stored conversation holds the path, so it has to survive a reboot.
|
|
expect(snipDirectory()).toBe("/tmp/lam-agent-support/snips");
|
|
});
|
|
});
|