mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
a92c859d07
## What Adds `argent lens` — open Argent Lens bound to a fresh `claude` session. ## Why The Lens preview window and the agent communicate through a shared, process-wide tool-server that carries **no per-agent identity** (only a coarse telemetry header). So when the user requests changes in the window, there's no reliable way to route them to the *specific* agent that opened it. `argent lens` sidesteps the problem by inverting ownership: the human launches Lens, and Lens spawns (and therefore owns) exactly one `claude` terminal. The binding is 1:1 by construction — no identity plumbing required. ## How it works 1. `argent lens` ensures a tool-server and **begins a CLI Lens session**, which opens the preview window up front (no `await_user_selection` needed) and keeps it open across rounds. 2. It spawns a tracked `claude` terminal in the current directory — a TypeScript port of the macOS applet's `osascript` spawn/track mechanism — seeded to propose variants without blocking. 3. A watcher polls `GET /preview/outcome`; on each submitted round it types a one-line feedback summary into that terminal, queuing it to the agent as its next prompt. Requesting changes therefore "talks back" to the bound agent instead of closing the window. macOS only (drives Terminal/iTerm via `osascript`); off-darwin it exits with a clear message. ## Changes - **CLI** `lens-terminal.ts` — `osascript` spawn/track + a new write-into-running-session path (the "outputting" channel; injects input into the running TUI, which a tty write cannot do). - **CLI** `lens.ts` — the command + feedback formatter. - **tool-server** — a `cliSession` flag in the variant store (opens the window, suppresses auto-close on submit); `POST /preview/cli-session` and `GET /preview/outcome` routes. - **UI** — relabels the submit action to "Request changes" while a CLI session is active. Behind the existing `argent-lens` feature flag. ## Verification - Build + typecheck across the workspace; ESLint (`--max-warnings 0`) + Prettier clean. - 34 new unit tests; full `@argent/cli` suite **118 passing**, tool-server variant/preview suites **46 passing**. - **E2E (observed on this machine):** - **tool-server HTTP** — `cli-session` toggles `cliSession` true/false; submitted feedback round-trips through `/preview/outcome`. - **terminal injection** — spawned a window running `cat`, confirmed `writeToSession` lands text in the running program's **stdin** (both iTerm and Terminal), with embedded newlines flattened. - **full relay** — the real `watchAndRelay` loop against a real tool-server typed two distinct submission rounds' feedback into a stand-in agent terminal (dedup on `completedAt` verified). - `argent lens --help` and the feature-flag gate (exit 1 with guidance, no spawn). Note: a real end-to-end with a live `claude` agent was not run (it would drive a paid interactive session); the agent-facing channel is proven by injecting into a stand-in process's stdin via the exact same code path. --------- Co-authored-by: Filip131311 <contact@filipkaminski.com>
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
escapeAppleScript,
|
|
shellQuote,
|
|
flattenLine,
|
|
buildSpawnScript,
|
|
buildWriteScript,
|
|
buildReadScript,
|
|
buildEnterScript,
|
|
parseCapture,
|
|
parseAliveTtys,
|
|
shortTty,
|
|
resolveTerminal,
|
|
isITermInstalled,
|
|
type TerminalSession,
|
|
} from "../src/lens-terminal.js";
|
|
|
|
describe("escapeAppleScript", () => {
|
|
it("escapes backslashes then double-quotes", () => {
|
|
expect(escapeAppleScript('a"b\\c')).toBe('a\\"b\\\\c');
|
|
});
|
|
it("leaves a plain string untouched", () => {
|
|
expect(escapeAppleScript("hello world")).toBe("hello world");
|
|
});
|
|
});
|
|
|
|
describe("shellQuote", () => {
|
|
it("single-quotes and escapes embedded single quotes", () => {
|
|
expect(shellQuote("it's a path")).toBe("'it'\\''s a path'");
|
|
});
|
|
it("wraps a normal path", () => {
|
|
expect(shellQuote("/Users/me/dev")).toBe("'/Users/me/dev'");
|
|
});
|
|
});
|
|
|
|
describe("flattenLine", () => {
|
|
it("collapses newlines and surrounding whitespace into single spaces", () => {
|
|
expect(flattenLine("a\n b\r\n c")).toBe("a b c");
|
|
});
|
|
it("trims the result", () => {
|
|
expect(flattenLine(" \n hi \n ")).toBe("hi");
|
|
});
|
|
it("collapses a lone CR (composer submit key) so it can't submit early", () => {
|
|
// A bare \r without a following \n must not survive — a TUI composer reads
|
|
// it as Enter and would submit the partial prompt.
|
|
expect(flattenLine("make it blue\rand rounded")).toBe("make it blue and rounded");
|
|
expect(flattenLine("a\r\rb")).toBe("a b");
|
|
});
|
|
it("preserves interior tabs and single spaces", () => {
|
|
expect(flattenLine("a\tb c")).toBe("a\tb c");
|
|
});
|
|
});
|
|
|
|
describe("buildSpawnScript", () => {
|
|
it("iTerm: creates a window, writes the command, returns wid|sid|tty", () => {
|
|
const s = buildSpawnScript("iterm", "echo hi");
|
|
expect(s).toContain('tell application "iTerm"');
|
|
expect(s).toContain("create window with default profile");
|
|
expect(s).toContain('write text "echo hi"');
|
|
expect(s).toContain('return _wid & "|" & _sid & "|" & _tty');
|
|
});
|
|
it("Terminal: do script + tty of tab, empty middle field", () => {
|
|
const s = buildSpawnScript("terminal", "echo hi");
|
|
expect(s).toContain('tell application "Terminal"');
|
|
expect(s).toContain('do script "echo hi"');
|
|
expect(s).toContain("tty of _tab");
|
|
expect(s).toContain('return _wid & "||" & _tty');
|
|
});
|
|
it("escapes quotes in the command", () => {
|
|
const s = buildSpawnScript("iterm", 'claude "$(cat x)"');
|
|
expect(s).toContain('write text "claude \\"$(cat x)\\""');
|
|
});
|
|
});
|
|
|
|
describe("buildWriteScript", () => {
|
|
const iterm: TerminalSession = {
|
|
app: "iterm",
|
|
windowId: "1",
|
|
sessionId: "GUID-9",
|
|
tty: "/dev/ttys003",
|
|
};
|
|
const term: TerminalSession = {
|
|
app: "terminal",
|
|
windowId: "42",
|
|
sessionId: "",
|
|
tty: "/dev/ttys004",
|
|
};
|
|
|
|
it("iTerm sends a leading Esc, then the flattened text, then a separate Enter", () => {
|
|
const s = buildWriteScript(iterm, "line one\nline two");
|
|
expect(s).toContain('if (id of s) is "GUID-9" then');
|
|
// Leading Esc (raw, no newline) interrupts any blocked turn / clears the composer.
|
|
expect(s).toContain("tell s to write text (character id 27) newline no");
|
|
expect(s).toContain('tell s to write text "line one line two"');
|
|
// A standalone empty write is the submit Enter — a TUI composer ignores the
|
|
// first chunk's trailing newline, so the message stays unsent without it.
|
|
expect(s).toContain('tell s to write text ""');
|
|
expect(s).toContain("delay 0.2");
|
|
expect(s).toContain('error "session gone"');
|
|
});
|
|
it("Terminal sends a leading Esc, the text via do script, then a separate Enter", () => {
|
|
const s = buildWriteScript(term, "do it");
|
|
expect(s).toContain('if (id of w as string) is "42" then');
|
|
expect(s).toContain("do script (character id 27) in (selected tab of w)");
|
|
expect(s).toContain('do script "do it" in (selected tab of w)');
|
|
expect(s).toContain('do script "" in (selected tab of w)');
|
|
expect(s).toContain("delay 0.2");
|
|
expect(s).toContain('error "window gone"');
|
|
});
|
|
});
|
|
|
|
describe("buildReadScript", () => {
|
|
it("iTerm returns the session text by id", () => {
|
|
const s = buildReadScript({ app: "iterm", windowId: "1", sessionId: "G", tty: "" });
|
|
expect(s).toContain('if (id of s) is "G" then return (text of s)');
|
|
});
|
|
it("Terminal returns the selected tab contents by window id", () => {
|
|
const s = buildReadScript({ app: "terminal", windowId: "42", sessionId: "", tty: "" });
|
|
expect(s).toContain('if (id of w as string) is "42"');
|
|
expect(s).toContain("contents of selected tab of w");
|
|
});
|
|
});
|
|
|
|
describe("buildEnterScript", () => {
|
|
it("iTerm sends a lone newline to the matching session", () => {
|
|
const s = buildEnterScript({ app: "iterm", windowId: "1", sessionId: "G", tty: "" });
|
|
expect(s).toContain('if (id of s) is "G" then tell s to write text ""');
|
|
});
|
|
it("Terminal sends a lone return into the selected tab", () => {
|
|
const s = buildEnterScript({ app: "terminal", windowId: "42", sessionId: "", tty: "" });
|
|
expect(s).toContain('do script "" in (selected tab of w)');
|
|
});
|
|
});
|
|
|
|
describe("parseCapture", () => {
|
|
it("splits wid|sid|tty", () => {
|
|
expect(parseCapture("123|GUID|/dev/ttys001\n")).toEqual({
|
|
windowId: "123",
|
|
sessionId: "GUID",
|
|
tty: "/dev/ttys001",
|
|
});
|
|
});
|
|
it("handles Terminal's empty middle field", () => {
|
|
expect(parseCapture("42||/dev/ttys009")).toEqual({
|
|
windowId: "42",
|
|
sessionId: "",
|
|
tty: "/dev/ttys009",
|
|
});
|
|
});
|
|
it("degrades missing fields to empty strings", () => {
|
|
expect(parseCapture("only")).toEqual({ windowId: "only", sessionId: "", tty: "" });
|
|
});
|
|
});
|
|
|
|
describe("parseAliveTtys", () => {
|
|
it("collects ttys and drops '??' and blanks", () => {
|
|
const set = parseAliveTtys("ttys001\n??\n ttys002 \n\nttys003\n");
|
|
expect([...set].sort()).toEqual(["ttys001", "ttys002", "ttys003"]);
|
|
expect(set.has("??")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shortTty", () => {
|
|
it("strips the /dev/ prefix", () => {
|
|
expect(shortTty("/dev/ttys016")).toBe("ttys016");
|
|
});
|
|
it("leaves an already-short tty", () => {
|
|
expect(shortTty("ttys016")).toBe("ttys016");
|
|
});
|
|
});
|
|
|
|
describe("resolveTerminal", () => {
|
|
it("uses iTerm when installed", () => {
|
|
expect(resolveTerminal("iterm", () => true)).toBe("iterm");
|
|
});
|
|
it("falls back to Terminal when iTerm is absent", () => {
|
|
expect(resolveTerminal("iterm", () => false)).toBe("terminal");
|
|
});
|
|
it("honours an explicit terminal preference", () => {
|
|
expect(resolveTerminal("terminal", () => true)).toBe("terminal");
|
|
});
|
|
});
|
|
|
|
describe("isITermInstalled", () => {
|
|
it("is true when a known path exists", () => {
|
|
expect(isITermInstalled((p) => p === "/Applications/iTerm.app")).toBe(true);
|
|
});
|
|
it("is false when none exist", () => {
|
|
expect(isITermInstalled(() => false)).toBe(false);
|
|
});
|
|
});
|