feat(server): accept prompt input, shell out, open the editor

The three things the overlay cannot do from inside a page: normalise prompt input
before it reaches an agent, run a command, and open a file in the user's editor
at the line they picked.
This commit is contained in:
Nayan
2026-07-31 17:36:00 +05:30
parent 400d86a3c7
commit 5eab615279
4 changed files with 679 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
/**
* Open a project file in the user's editor.
*
* Two paths, because neither works alone. The CLI launchers (`code -g`) are the
* reliable ones *when they exist* — but `code` is only on `PATH` if the user
* ever ran VS Code's "Install 'code' command in PATH", which a fresh machine
* has not. The URL schemes always work if the app is installed but cannot carry
* a column and are awkward to detect. So: try the binary, fall back to the
* scheme.
*/
import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { resolve, sep } from "node:path";
import type { Editor } from "@airship/protocol";
export interface OpenRequest {
column?: number;
editor?: Editor;
/** Repo-relative. Resolved against `cwd` and required to stay inside it. */
file: string;
line?: number;
}
export interface OpenResult {
editor?: string;
error?: string;
ok: boolean;
}
interface Launcher {
/** Argv for the CLI form, given an absolute `file:line:col` target. */
args: (target: string) => string[];
bin: string;
/** URL scheme host, e.g. `vscode` in `vscode://file/...`. */
scheme: string;
}
const LAUNCHERS: Record<Editor, Launcher> = {
cursor: { args: (t) => ["-g", t], bin: "cursor", scheme: "cursor" },
vscode: { args: (t) => ["-g", t], bin: "code", scheme: "vscode" },
windsurf: { args: (t) => ["-g", t], bin: "windsurf", scheme: "windsurf" },
// Zed takes the position inline with no flag.
zed: { args: (t) => [t], bin: "zed", scheme: "zed" },
};
/** Probe order when nothing is configured. */
const PREFERENCE: Editor[] = ["vscode", "cursor", "windsurf", "zed"];
/** Vite serves out-of-root files under `/@fs/<abs path>`. */
const VITE_FS_PREFIX = /^\/@fs(\/.*)$/;
const LEADING_SLASHES = /^\/+/;
export function openInEditor(cwd: string, req: OpenRequest): OpenResult {
const root = resolve(cwd);
const abs = resolve(root, projectPath(req.file));
// This socket is unauthenticated and local, and this handler spawns
// processes — without the containment check any page the browser loads could
// ask the daemon to open arbitrary files on disk.
if (abs !== root && !abs.startsWith(root + sep)) {
return { error: "path is outside the project", ok: false };
}
if (!existsSync(abs)) {
return { error: `no such file: ${req.file}`, ok: false };
}
const line = req.line ?? 1;
const column = req.column ?? 1;
const target = `${abs}:${line}:${column}`;
for (const editor of candidates(req.editor)) {
const launcher = LAUNCHERS[editor];
if (!onPath(launcher.bin)) {
continue;
}
try {
// Detached and unref'd: the editor outlives the daemon, and inheriting
// stdio would wire its output into ours. Never `shell: true` — argv
// arrays mean a path with a space or a quote is just a path.
spawn(launcher.bin, launcher.args(target), {
detached: true,
stdio: "ignore",
}).unref();
return { editor, ok: true };
} catch {
// Fall through to the next candidate, then to the URL scheme.
}
}
const wanted = req.editor ?? PREFERENCE[0];
if (openUrl(`${LAUNCHERS[wanted].scheme}://file/${abs}:${line}:${column}`)) {
return { editor: wanted, ok: true };
}
return {
error: `could not launch ${wanted} — is it installed?`,
ok: false,
};
}
/**
* Normalize a path that may have come from the *browser* rather than from a
* diff. Source locations are resolved from framework metadata, and what React
* and Vite hand back is a dev-server URL path — `/src/App.tsx`, or `/@fs/` +
* an absolute path for files outside the Vite root. Both are absolute as far
* as `resolve` is concerned, so without this the first one silently escapes
* `cwd` and gets rejected as "outside the project".
*
* Paths that are genuinely absolute and genuinely inside the project still
* work: they survive this untouched and the containment check below passes.
*/
function projectPath(file: string): string {
const fs = file.match(VITE_FS_PREFIX);
if (fs?.[1]) {
return fs[1];
}
// A real absolute path exists on disk; a URL path like `/src/App.tsx` does
// not, and is meant to be read relative to the project root.
if (file.startsWith("/") && !existsSync(file)) {
return file.replace(LEADING_SLASHES, "");
}
return file;
}
function candidates(explicit?: Editor): Editor[] {
if (explicit) {
return [explicit];
}
const configured = process.env.AIRSHIP_EDITOR as Editor | undefined;
if (configured && configured in LAUNCHERS) {
return [configured, ...PREFERENCE.filter((e) => e !== configured)];
}
return PREFERENCE;
}
/** Cached: a `which` per menu click is pointless, and PATH does not move. */
const pathCache = new Map<string, boolean>();
function onPath(bin: string): boolean {
const hit = pathCache.get(bin);
if (hit !== undefined) {
return hit;
}
const probe = platform() === "win32" ? "where" : "which";
let found = false;
try {
found = spawnSync(probe, [bin], { stdio: "ignore" }).status === 0;
} catch {
found = false;
}
pathCache.set(bin, found);
return found;
}
function openUrl(url: string): boolean {
const os = platform();
let bin = "xdg-open";
let args = [url];
if (os === "darwin") {
bin = "open";
} else if (os === "win32") {
bin = "cmd";
args = ["/c", "start", "", url];
}
try {
spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
return true;
} catch {
return false;
}
}
+312
View File
@@ -0,0 +1,312 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildEditPrompt } from "@airship/core";
import type { CreateJobRequest, ElementContext } from "@airship/protocol";
import { invalidateTokenCache } from "@airship/source/tokens";
import { describe, expect, it } from "vitest";
import { preparePromptInput } from "./prompt-input";
/** Build a throwaway project tree and return its root. */
function fixture(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "airship-prompt-"));
for (const [path, contents] of Object.entries(files)) {
const full = join(root, path);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, contents);
}
invalidateTokenCache();
return root;
}
const APP_TSX = `export function App() {
return (
<main>
<button className="btn btn-primary">Get Started</button>
<span className="tag">New</span>
</main>
);
}
`;
const TOKENS_CSS = `:root {
--pk-space-sm: 8px;
--pk-space-md: 16px;
--pk-radius-lg: 12px;
}
`;
/** A project with one component and one stylesheet, rooted for the scanner. */
function project(): string {
return fixture({
".git": "",
"src/App.tsx": APP_TSX,
"src/tokens.css": TOKENS_CSS,
});
}
function element(overrides: Partial<ElementContext> = {}): ElementContext {
return {
classes: ["btn", "btn-primary"],
displayName: "Button",
selector: ".btn.btn-primary",
tagName: "button",
textPreview: "Get Started",
...overrides,
};
}
/** The location the browser reports: a real file and line, no context yet. */
const BUTTON_AT = { file: "src/App.tsx", line: 4 };
function request(overrides: Partial<CreateJobRequest> = {}): CreateJobRequest {
return { prompt: "", ...overrides };
}
describe("preparePromptInput — source backfill", () => {
it("attaches surrounding code to a style target", () => {
const cwd = project();
const input = preparePromptInput(
cwd,
request({
visualChanges: [
{
changes: [{ from: "12px", property: "padding", to: "16px" }],
element: element(),
source: BUTTON_AT,
},
],
})
);
expect(input.visualChanges?.[0].source?.context).toContain(
'className="btn btn-primary"'
);
});
it("resolves all three locations on a move, not just the element", () => {
// The two easiest to forget: an agent told to relocate JSX needs the new
// parent and the anchor sibling as much as it needs the element itself.
const cwd = project();
const input = preparePromptInput(
cwd,
request({
moveChanges: [
{
before: element({
classes: ["tag"],
displayName: "Tag",
selector: ".tag",
tagName: "span",
textPreview: "New",
}),
beforeSource: { file: "src/App.tsx", line: 5 },
element: element(),
newParent: element({
classes: [],
displayName: undefined,
selector: "main",
tagName: "main",
textPreview: "",
}),
newParentSource: { file: "src/App.tsx", line: 3 },
source: BUTTON_AT,
},
],
})
);
const move = input.moveChanges?.[0];
expect(move?.source?.context).toContain("Get Started");
expect(move?.newParentSource?.context).toContain("<main>");
expect(move?.beforeSource?.context).toContain('className="tag"');
});
it("backfills structural, text and attribute targets too", () => {
const cwd = project();
const input = preparePromptInput(
cwd,
request({
attrChanges: [
{
changes: [{ attribute: "disabled", from: null, to: "true" }],
element: element(),
source: BUTTON_AT,
},
],
structuralChanges: [
{ element: element(), op: "duplicate", source: BUTTON_AT },
],
textChanges: [
{
element: element(),
from: "Get Started",
source: BUTTON_AT,
to: "Start now",
},
],
})
);
expect(input.structuralChanges?.[0].source?.context).toContain(
"Get Started"
);
expect(input.textChanges?.[0].source?.context).toContain("Get Started");
expect(input.attrChanges?.[0].source?.context).toContain("Get Started");
});
it("normalizes a root-relative path the dev server reported", () => {
// Vite reports `/src/App.tsx`; `resolve(cwd, …)` would read that as
// absolute and hand the agent a path outside the project.
const cwd = project();
const input = preparePromptInput(
cwd,
request({
element: element(),
source: { file: "/src/App.tsx", line: 4 },
})
);
expect(input.source?.file).toBe(join("src", "App.tsx"));
expect(input.source?.context).toContain("Get Started");
});
});
describe("preparePromptInput — the primary element", () => {
it("prefers the explicit selection", () => {
const cwd = project();
const input = preparePromptInput(
cwd,
request({
element: element({ displayName: "Selected" }),
source: BUTTON_AT,
textChanges: [
{
element: element({ displayName: "Retyped" }),
from: "a",
source: BUTTON_AT,
to: "b",
},
],
})
);
expect(input.element?.displayName).toBe("Selected");
});
it("falls back down the delta chain when nothing is selected", () => {
const cwd = project();
const fallback = (overrides: Partial<CreateJobRequest>) =>
preparePromptInput(cwd, request(overrides)).element?.displayName;
expect(
fallback({
moveChanges: [
{
before: null,
beforeSource: null,
element: element({ displayName: "Moved" }),
newParent: null,
newParentSource: null,
source: BUTTON_AT,
},
],
})
).toBe("Moved");
expect(
fallback({
structuralChanges: [
{
element: element({ displayName: "Deleted" }),
op: "delete",
source: BUTTON_AT,
},
],
})
).toBe("Deleted");
expect(
fallback({
attrChanges: [
{
changes: [{ attribute: "alt", from: null, to: "x" }],
element: element({ displayName: "Retagged" }),
source: BUTTON_AT,
},
],
})
).toBe("Retagged");
});
});
describe("preparePromptInput — comments and tokens", () => {
it("passes comments through untouched", () => {
// A comment already carries a repo-relative path and a real line; the
// element resolver has nothing to add and would only corrupt it.
const cwd = project();
const comments = [
{
body: "tighten this",
file: "src/App.tsx",
fromLine: 4,
jobId: "job-1",
snippet: "<button>",
toLine: 4,
},
];
expect(preparePromptInput(cwd, request({ comments })).comments).toEqual(
comments
);
});
it("carries the project's scanned design scale", () => {
const cwd = project();
const names = preparePromptInput(cwd, request()).tokens?.tokens.map(
(t) => t.name
);
expect(names).toContain("--pk-space-md");
});
});
describe("the rendered prompt", () => {
it("carries context and a token legend the browser could not know", () => {
// The premise of the whole preview round trip: these two blocks exist only
// because the daemon read the project off disk. If this ever passes against
// a client-side re-derivation, the server hop was unnecessary.
const cwd = project();
const text = buildEditPrompt(
preparePromptInput(
cwd,
request({
visualChanges: [
{
changes: [{ from: "12px", property: "padding", to: "16px" }],
element: element(),
source: BUTTON_AT,
},
],
})
)
);
expect(text).toContain("Source context:");
expect(text).toContain("src/App.tsx:4");
expect(text).toContain("--pk-space-md");
});
it("routes a comments-only turn to the review prompt", () => {
const cwd = project();
const text = buildEditPrompt(
preparePromptInput(
cwd,
request({
comments: [
{
body: "tighten this",
file: "src/App.tsx",
fromLine: 4,
jobId: "job-1",
snippet: "<button>",
toLine: 4,
},
],
})
)
);
expect(text).toContain("The user reviewed the edit you just made");
expect(text).toContain("tighten this");
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Resolving an edit request against the project on disk, so the prompt can name
* files, lines and design tokens the browser has no way to know.
*/
import type { EditPromptInput } from "@airship/core";
import type {
CreateJobRequest,
ElementContext,
SourceLocation,
} from "@airship/protocol";
import { resolveServerSource } from "@airship/source/server";
import { scanProjectTokens } from "@airship/source/tokens";
/**
* Backfill file/line/context for every edit that names a DOM element, the same
* way the single selection is resolved — the agent cannot find a JSX node it
* has no location for.
*/
function backfillSources(cwd: string, request: CreateJobRequest) {
const locate = (element: ElementContext, source?: SourceLocation | null) =>
resolveServerSource(cwd, { element, source: source ?? null });
return {
attrChanges: request.attrChanges?.map((target) => ({
...target,
source: locate(target.element, target.source),
})),
// Each move needs its element, its new parent, and its anchor sibling.
moveChanges: request.moveChanges?.map((move) => ({
...move,
beforeSource: move.before ? locate(move.before, move.beforeSource) : null,
newParentSource: move.newParent
? locate(move.newParent, move.newParentSource)
: null,
source: locate(move.element, move.source),
})),
structuralChanges: request.structuralChanges?.map((edit) => ({
...edit,
source: locate(edit.element, edit.source),
})),
textChanges: request.textChanges?.map((edit) => ({
...edit,
source: locate(edit.element, edit.source),
})),
visualChanges: request.visualChanges?.map((target) => ({
...target,
source: locate(target.element, target.source),
})),
};
}
/** The element the turn is *about*, and where it lives. */
function primaryLocation(cwd: string, request: CreateJobRequest) {
const primaryElement =
request.element ??
request.visualChanges?.[0]?.element ??
request.moveChanges?.[0]?.element ??
request.structuralChanges?.[0]?.element ??
request.textChanges?.[0]?.element ??
request.attrChanges?.[0]?.element;
const source = resolveServerSource(cwd, {
element: primaryElement,
source:
request.source ??
request.visualChanges?.[0]?.source ??
request.moveChanges?.[0]?.source ??
request.structuralChanges?.[0]?.source ??
request.textChanges?.[0]?.source ??
request.attrChanges?.[0]?.source ??
null,
});
return { primaryElement, source };
}
/**
* Everything the prompt is built from, resolved against the project on disk.
*
* Returns an `EditPromptInput` exactly, and that is the point: `startEdit`
* spreads it into `runEdit` (which renders it via `buildEditPrompt`) and the
* `prompt` handler renders it directly, so the preview the user reads and the
* instruction the agent receives are the same string by construction rather
* than by inspection. Anything prompt-relevant assembled outside this function
* is a drift bug.
*/
export function preparePromptInput(
cwd: string,
request: CreateJobRequest
): EditPromptInput {
const { primaryElement, source } = primaryLocation(cwd, request);
return {
...backfillSources(cwd, request),
// No `resolveServerSource` backfill: a comment already carries a
// repo-relative path and a real source line. That resolver maps DOM
// elements onto source, which a comment has already skipped past.
comments: request.comments,
element: primaryElement,
prompt: request.prompt,
source,
// The project's own design vocabulary, so the prompt can name tokens
// instead of advising the agent to go looking for some. Cached after the
// first walk, so this is a map lookup on every turn but the first.
tokens: scanProjectTokens(cwd),
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* The canvas shell document.
*
* In canvas mode the proxy stops serving the app's HTML at the top level and
* serves this instead. The app still runs — one full copy per frame, inside
* same-origin iframes the shell creates — but it no longer shares a document
* with the editor.
*
* That separation is the point. Until now the overlay lived inside the app and
* had to defend itself from it: scoped CSS variables so the app's `:root` didn't
* bleed in, a `__airship-` prefix on every class, and a `z-index` of 2147483600
* to stay on top. None of the app's CSS reaches this document, so the editor
* finally has a clean page of its own.
*
* Deliberately minimal: no app CSS, no framework, no build step. Fonts resolve
* same-origin from `/__airship/fonts/*` (see `serveAirshipAsset`), and every
* pixel of UI is drawn by the overlay bundle.
*/
import type { AirshipWindowConfig } from "@airship/protocol";
/**
* The tab icon: Airship's brand mark, inlined.
*
* A data URI rather than a route, for the same reason this file has no build
* step — the favicon is the first thing a browser asks for, and serving it
* would mean a second round trip and another branch in `serveAirshipAsset` for
* ~300 bytes. `assets/logo.svg` at the repo root is the geometry of record;
* this is the third copy of that path (with the overlay's `logo` glyph), and
* all three change together.
*
* Two differences from the in-UI glyph, both because a favicon is 16 physical
* pixels:
*
* - The viewBox is cropped to `3.6 3.6 16.8 16.8` instead of the full 24 box.
* The set's optical inset gives back a third of the tab icon as empty margin,
* which matters at 24px and is waste at 16.
* - Full opacity, not the set's 0.9. That step exists to seat a glyph among
* other glyphs; in a tab strip there is nothing to seat it against.
*
* The colour comes from a `prefers-color-scheme` rule because the tab strip
* follows the *browser's* theme, not this page's — the shell is always dark,
* the chrome around it is not. Black is the base so that a renderer which
* ignores the query still lands on the readable answer for a default light
* tab strip.
*/
const FAVICON =
"data:image/svg+xml," +
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='3.6 3.6 16.8 16.8'%3E" +
"%3Cstyle%3Epath{fill:%23000}" +
"@media(prefers-color-scheme:dark){path{fill:%23fff}}%3C/style%3E" +
"%3Cpath d='M12 5.1L20 18.9H15.47L9.73 9.01ZM7.47 12.92H12L8.53 18.9H4Z'/%3E" +
"%3C/svg%3E";
/**
* `</script>` inside a JSON string would close the tag early; U+2028 and U+2029
* are legal in JSON but are line terminators to a JS parser, so both have to be
* escaped before the config can be inlined into a `<script>`.
*
* Exported because the inline injection embeds the same config into the app's
* own HTML, and that config now carries a `pathname` taken from the request —
* i.e. a string the page's author does not control.
*/
export function escapeForScript(json: string): string {
return json.replace(/[<\u2028\u2029]/g, (c) => {
if (c === "<") {
return "\\u003c";
}
return c === "\u2028" ? "\\u2028" : "\\u2029";
});
}
export function shellHtml(config: AirshipWindowConfig): string {
const json = escapeForScript(JSON.stringify(config));
return `<!doctype html>
<html lang="en" data-airship-shell>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex">
<title>Airship</title>
<link rel="icon" href="${FAVICON}">
<style>
/* Painted before the bundle runs so the canvas never flashes white. */
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#141414;}
</style>
<script>window.__PIKA__=${json};</script>
</head>
<body>
<script src="/__airship/overlay.js"></script>
</body>
</html>
`;
}