Public: anyone can read this; the crew can change it.

The explorer's filters and ranking are the same words on the page, the API and the MCP `explore` tool #10409

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10387-the-explorer-s-filters-and-ranking-are-t → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/10387-the-explorer-s-filters-and-ranking-are-t was merged into main as 748254a.

Review

Sign in to review this change.
7 files changed +258 −25
docs/features/explore.md +17 −1
@@ -22,6 +22,22 @@ A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An a
2222
2323 In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships (2). The weight is a constant in `explore.ts`, not one of the front page's admin weights.
2424
25 An unknown `sort:` word is ignored with a note naming the sorts above.
25 An unknown `sort:` word is a 422 naming the sorts above (see the end of this page).
2626
2727 When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the whole line with that word mended. Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up.
28
29 ## One word on every surface (#10387)
30
31 What a person filters on `/explore` an agent filters with the same words: the `q` line. The page's chips are plain links to `/explore?q=<line>`; the API takes the same line as `?q=`, the MCP tool as the argument `q`.
32
33 | the page offers | the line | API `GET /api/v1/explore` | MCP `explore` |
34 |---|---|---|---|
35 | Best / Forks / Shipped / Liked / Use / Fresh | `sort:score` `forks` `ships` `likes` `use` `fresh` (`name` by hand) | `?q=sort:forks` | `{ q: "sort:forks" }` |
36 | All / Apps / People / Forges (admin) | `kind:app` `kind:person` `kind:forge` | `?q=kind:app` | `{ q: "kind:app" }` |
37 | Any time / Today / This week / This month | `updated:1d` `7d` `30d` | `?q=updated:7d` | `{ q: "updated:7d" }` |
38 | Show archived | `archived:yes` | `?q=archived:yes` | `{ q: "archived:yes" }` |
39 | (owner link on a row) | `owner:name` | `?q=owner:name` | `{ q: "owner:name" }` |
40
41 The three return the same ordered rows (`test/explore-parity.test.ts`).
42
43 An unknown filter word is refused the same way everywhere: `422` from the API (`{ "error": "unknown_filter", "message": "sort:bogus is not a sort — use score, forks, …", "suggest"? }`), an `unknown_filter` error from the MCP tool, and `422` on the page, which still draws the note, the did-you-mean link and the results. This replaces the earlier note-only answer (#7595).
src/http/api.ts +2
@@ -3375,6 +3375,8 @@ export function apiRoutes(router: Router<Handler>): void {
33753375 const line = ctx.url.searchParams.get("q") ?? "";
33763376 const { query, unknown } = readExploreLine(line);
33773377 const suggest = exploreSuggestion(line);
3378 if (unknown.length > 0)
3379 return json({ error: "unknown_filter", message: exploreNote(unknown), ...(suggest === undefined ? {} : { suggest: suggest.line }) }, 422);
33783380 const mine = runRepos(ctx.app, ctx.auth);
33793381 // A run token is shown only the rows of its own repositories; the cut
33803382 // and the cursor are made from those rows alone.
src/http/mcp/tools/explore.ts +2 −3
@@ -12,7 +12,7 @@ export const tool: Tool = {
1212 q: {
1313 type: "string",
1414 maxLength: 200,
15 description: "Words; kind: sort: updated:7d owner: archived:",
15 description: "kind: sort: updated:7d owner: archived:. Unknown word: error",
1616 },
1717 query: {
1818 type: "string",
@@ -30,13 +30,12 @@ export const tool: Tool = {
3030 const line = q !== "" ? q : str(args["query"]);
3131 const { query, unknown } = readExploreLine(line);
3232 const suggest = exploreSuggestion(line);
33 if (unknown.length > 0) return bad("unknown_filter", exploreNote(unknown) + (suggest === undefined ? "" : `; did you mean "${suggest.line}"?`));

The q description in the tool schema still lists the words without saying an unknown one now fails. Agents read that schema, so say it errors with unknown_filter and names the allowed words.

3334 const cursor = str(args["cursor"]);
3435 const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor);
3536 if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before");
3637 return good({
3738 query: exploreLine(query),
38 ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }),
39 ...(suggest === undefined ? {} : { suggest: suggest.line }),
4039 items: page.items,
4140 next_cursor: page.nextCursor,
4241 });
src/web/studio/explore.ts +3 −1
@@ -218,7 +218,9 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
218218 ${rows.length === 0
219219 ? emptyState(ctx, query)
220220 : card(html`<ul class="list">${rows.map(rowHtml)}</ul>`, { pad: false, extra: "bare" })}</div>`;
221 const response = page({ ctx, title: "Explore", body });
221 const drawn = page({ ctx, title: "Explore", body });
222 // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results.
223 const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers });
222224 notePhase(ctx.request, "render", performance.now() - rendering);
223225 return response;
224226 }
test/explore-parity.test.ts added +209
@@ -0,0 +1,209 @@
1 /** One filter, one word: the page, the API and the MCP `explore` tool return the same ordered ids (#10387). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { promote } from "./helpers/members.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let app: App;
21 let servers: Servers;
22 let token: string;
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-explore-parity-"));
26 app = createApp({ dataDir: dir, port: 0 });
27 for (const name of ["olive", "pine", "birch"]) {
28 const made = await createUser(app.db, { origin: "seed", username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: false });
29 if (!made.ok) throw new Error(made.error);
outdated

The fixture has no forks, ships or likes, so sort:forks, ships, likes, use and fresh all tie and fall back to the same order. A broken sort passes. Seed signals that make each sort differ and assert the distinct expected order for each.

30 proveAll(app);
31 promote(app.db, made.value);
32 if (name === "olive") token = createToken(app.db, made.value.id, "ro", "ro").token;
33 for (const r of ["garden", "kiln", "loom"]) {
34 const repo = await createRepo(app, made.value, { name: `${r}-${name}`, visibility: "public" });
35 if (!repo.ok) throw new Error(String(repo.error));
36 const rec = insertApp(app.db, { repoId: repo.value.id, slug: `${name}-${r}`, access: "public" });
37 if (!rec.ok) throw new Error(rec.error);
38 }
39 }
40 seedSignals();
41 servers = await serve(app);
42 });
43 afterAll(async () => {
44 await servers.stop();
45 app.db.close();
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 const DAY = 86_400_000;
50
51 /** Signals that make every sort a different order: who is forked, shipped, liked, awake, fresh, and archived. */
52 function seedSignals(): void {
53 const repoId = (owner: string, name: string): string => {
54 const row = app.db.query<{ id: string }, [string, string]>("SELECT repo.id AS id FROM repo JOIN user ON user.id = repo.owner_id WHERE user.username = ?1 AND repo.name = ?2").get(owner, name);
55 if (row === null) throw new Error(`no ${owner}/${name}`);
56 return row.id;
57 };
58 const owner = app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?1").get("olive")!.id;
59 const now = Date.now();
60 // forks: loom-olive 2, garden-olive 1
61 const fork = (child: [string, string], parent: [string, string]) =>
62 app.db.run("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2", [repoId(...parent), repoId(...child)]);
63 fork(["pine", "kiln-pine"], ["olive", "loom-olive"]);
64 fork(["pine", "garden-pine"], ["olive", "loom-olive"]);
65 fork(["birch", "kiln-birch"], ["olive", "garden-olive"]);
66 // ships (merged pulls): garden-pine 3 (latest 5d ago), loom-birch 2 (latest 1d ago), kiln-olive 1 (45d ago, outside the window)
67 const ship = (r: [string, string], ago: number) => {
68 const issue = createIssue(app.db, { repoId: repoId(...r), authorId: owner, title: "change", kind: "pull" });
69 if (!issue.ok) throw new Error(issue.error);
70 app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'b', '', '', ?2)", [issue.value.id, new Date(now - ago).toISOString()]);
71 };
72 ship(["pine", "garden-pine"], 5 * DAY);
73 ship(["pine", "garden-pine"], 6 * DAY);
74 ship(["pine", "garden-pine"], 7 * DAY);
75 ship(["birch", "loom-birch"], 1 * DAY);
76 ship(["birch", "loom-birch"], 2 * DAY);
77 ship(["olive", "kiln-olive"], 45 * DAY);
78 // likes: garden-birch 3, kiln-olive 2, loom-pine 1
79 const like = (r: [string, string], n: number) => {
80 for (let i = 0; i < n; i++) app.db.run("INSERT INTO feed_like (viewer_id, item_id, repo_id) VALUES (?1, ?2, ?3)", [owner, `${r[1]}-${i}`, repoId(...r)]);
81 };
82 like(["birch", "garden-birch"], 3);
83 like(["olive", "kiln-olive"], 2);
84 like(["pine", "loom-pine"], 1);
85 // use (hours awake): kiln-pine 10, garden-olive 5, kiln-birch 2
86 const awake = (r: [string, string], hours: number) =>
outdated

indexOf(">name<") matches any element with that text, such as a chip or the owner link, so it can pass with the rows in the wrong order. Anchor to the row markup (the <ul class="list"> items) instead.

87 app.db.run("INSERT INTO meter (repo_id, env, placement, started_at, beat_at, ended_at) VALUES (?1, 'production', 'owned', ?2, ?3, ?3)", [
88 repoId(...r),
89 new Date(now - 2 * DAY - hours * 3_600_000).toISOString(),
90 new Date(now - 2 * DAY).toISOString(),
91 ]);
92 awake(["pine", "kiln-pine"], 10);
93 awake(["olive", "garden-olive"], 5);
94 awake(["birch", "kiln-birch"], 2);
95 // put away: loom-pine is only in archived:yes
96 app.db.run("UPDATE repo SET archived_at = ?1 WHERE id = ?2", [new Date(now).toISOString(), repoId("pine", "loom-pine")]);
97 }
98
99 const LINES = [
100 "",
101 "kind:app",
102 "kind:person",
103 "sort:forks",
104 "sort:ships",
105 "sort:likes",
106 "sort:use",
107 "sort:fresh",
108 "sort:name",
109 "updated:7d",
110 "owner:pine",
111 "archived:yes",
112 "kind:app sort:name garden",
113 ];
114
115 /** The row links inside the page's `<ul class="list">`, in order: each row's title is the one link to its own url. */
116 function pageOrder(html: string): string[] {
117 const list = /<ul class="list">([\s\S]*?)<\/ul>/.exec(html);
118 if (list === null) return [];
119 return [...list[1]!.matchAll(/<li[^>]*>[\s\S]*?<a href="(\/[^"?]*)">[^<]*<\/a>/g)].map((m) => m[1]!);
120 }
121
122 const ids = (items: { kind: string; name: string }[]) => items.map((i) => `${i.kind}:${i.name}`);
123
124 async function mcp(line: string): Promise<{ ok: boolean; text: string }> {
125 const res = await fetch(`${servers.url}/mcp`, {
126 method: "POST",
127 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
128 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { q: line } } }),
129 });
130 const out = ((await res.json()) as any).result;
131 return { ok: out.isError !== true, text: out.content[0].text };
132 }
133
134 describe("one filter, three surfaces", () => {
135 // The orders the seeded signals must give, so a sort that ignores its signal (and falls back to name) fails.
136 const EXPECTED: Record<string, string[]> = {
137 "kind:app sort:forks": ["olive/loom-olive", "olive/garden-olive"],
138 "kind:app sort:ships": ["pine/garden-pine", "birch/loom-birch"],
139 "kind:app sort:likes": ["birch/garden-birch", "olive/kiln-olive"],
140 "kind:app sort:use": ["pine/kiln-pine", "olive/garden-olive", "birch/kiln-birch"],
141 "kind:app sort:fresh": ["birch/loom-birch", "pine/garden-pine", "olive/kiln-olive"],
142 "kind:app sort:name": ["birch/garden-birch", "birch/kiln-birch", "birch/loom-birch", "olive/garden-olive", "olive/kiln-olive", "olive/loom-olive", "pine/garden-pine", "pine/kiln-pine"],
143 "kind:person sort:forks": ["olive"],
144 "kind:app updated:7d": ["pine/garden-pine", "birch/loom-birch"],
145 "kind:app archived:yes sort:name": ["birch/garden-birch", "birch/kiln-birch", "birch/loom-birch", "olive/garden-olive", "olive/kiln-olive", "olive/loom-olive", "pine/garden-pine", "pine/kiln-pine", "pine/loom-pine"],
146 };
147 for (const [line, want] of Object.entries(EXPECTED)) {
148 test(`"${line}" puts the seeded signals in their order, the same on the API, the MCP and the page`, async () => {
149 const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).json()) as { items: { name: string; url: string }[] };
150 // Only the leading rows are pinned: the rest tie at zero and fall to name.
151 expect(api.items.map((i) => i.name).slice(0, want.length)).toEqual(want);
152 const tool = await mcp(line);
153 expect(tool.ok).toBe(true);
154 expect((JSON.parse(tool.text) as typeof api).items.map((i) => i.name)).toEqual(api.items.map((i) => i.name));
155 const html = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent(line)}`)).text();
156 expect(pageOrder(html)).toEqual(api.items.map((i) => i.url));
157 });
158 }
159
160 test("the sorts are different orders, not one order under seven names", async () => {
161 const seen = new Set<string>();
162 for (const sort of ["score", "forks", "ships", "likes", "use", "fresh", "name"]) {
163 const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(`kind:app sort:${sort}`)}`)).json()) as { items: { name: string }[] };
164 seen.add(api.items.map((i) => i.name).join(","));
165 }
166 expect(seen.size).toBe(7);
167 });
168
169 for (const line of LINES) {
170 test(`"${line}" gives the same ordered rows on the API and the MCP, and the page lists them in that order`, async () => {
171 const api = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`, { headers: { authorization: `Bearer ${token}` } })).json()) as { items: { kind: string; name: string; url: string }[] };
172 const tool = await mcp(line);
173 expect(tool.ok).toBe(true);
174 const viaMcp = JSON.parse(tool.text) as { items: { kind: string; name: string; url: string }[] };
175 expect(ids(viaMcp.items)).toEqual(ids(api.items));
176 if (line === "") expect(api.items.length).toBeGreaterThan(0);
177 // The page, anonymous, sees the same public rows; its order is the API's order.
178 const anon = (await (await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).json()) as { items: { kind: string; name: string; url: string }[] };
179 const html = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent(line)}`)).text();
180 expect(pageOrder(html)).toEqual(anon.items.map((i) => i.url));
181 });
182 }
183
184 test("the page's chips are links whose q is a line the API takes", async () => {
185 const html = await (await fetch(`${servers.url}/explore`)).text();
186 for (const line of ["kind:app", "sort:forks", "sort:ships", "sort:likes", "sort:use", "sort:fresh", "updated:7d", "kind:person"]) {
187 expect(html).toContain(`href="/explore?q=${encodeURIComponent(line)}"`);
188 expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(line)}`)).status).toBe(200);
189 }
190 });
191
192 test("an unknown filter is a 422 naming the allowed words, on all three", async () => {
193 const q = encodeURIComponent("sort:bogus");
194 const api = await fetch(`${servers.url}/api/v1/explore?q=${q}`);
195 expect(api.status).toBe(422);
196 const body = (await api.json()) as { error: string; message: string };
197 expect(body.error).toBe("unknown_filter");
198 expect(body.message).toContain("use score, forks, ships, likes, use, fresh, name");
199 const tool = await mcp("sort:bogus");
200 expect(tool.ok).toBe(false);
201 expect(tool.text).toContain("use score, forks, ships, likes, use, fresh, name");
202 const page = await fetch(`${servers.url}/explore?q=${q}`);
203 expect(page.status).toBe(422);
204 expect(await page.text()).toContain("use score, forks, ships, likes, use, fresh, name");
205 for (const bad of ["kind:x", "updated:2y", "archived:maybe"]) {
206 expect((await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent(bad)}`)).status).toBe(422);
207 }
208 });
209 });
test/explore-unknown-words.test.ts +24 −19
@@ -62,9 +62,10 @@ describe("readExploreLine", () => {
6262 const s = await serve(a);
6363 try {
6464 const q = encodeURIComponent("owner:trevato updated:2y archived:maybe");
65 const body = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { note?: string; query: string };
66 expect(body.note).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no");
67 expect(body.query).toBe("owner:trevato");
65 const res = await fetch(`${s.url}/api/v1/explore?q=${q}`);
66 expect(res.status).toBe(422);
67 const body = (await res.json()) as { message: string };
68 expect(body.message).toBe("updated:2y is not a window — use 1d, 7d, 30d; archived:maybe is not an answer — use yes, no");
6869 } finally {
6970 await s.stop();
7071 a.db.close();
@@ -127,15 +128,14 @@ describe("the page and the API", () => {
127128 expect(out).not.toContain("<b>");
128129 });
129130
130 test("the API answers a note in a field, not an error", async () => {
131 test("the API answers 422 naming the allowed words (#10387)", async () => {
131132 const res = await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent("kind:nope")}`);
132 expect(res.status).toBe(200);
133 const body = (await res.json()) as { note?: string; query: string; items: unknown[] };
134 expect(body.note).toContain("kind:nope is not a kind");
135 expect(body.query).toBe("");
136 expect(Array.isArray(body.items)).toBe(true);
137 const fine = (await (await fetch(`${servers.url}/api/v1/explore?q=kind:app`)).json()) as { note?: string };
138 expect(fine.note).toBeUndefined();
133 expect(res.status).toBe(422);
134 const body = (await res.json()) as { error: string; message: string };
135 expect(body.error).toBe("unknown_filter");
136 expect(body.message).toContain("kind:nope is not a kind — use app, person, forge");
137 const fine = await fetch(`${servers.url}/api/v1/explore?q=kind:app`);
138 expect(fine.status).toBe(200);
139139 });
140140
141141 test("the MCP tool answers the same note", async () => {
@@ -152,13 +152,18 @@ describe("the page and the API", () => {
152152 },
153153 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { query } } }),
154154 });
155 return JSON.parse(((await res.json()) as any).result.content[0].text) as { note?: string };
155 const out = ((await res.json()) as any).result as { isError?: boolean; content: { text: string }[] };
outdated

This helper guesses an error from text.startsWith("{") and a substring. It can pass for the wrong reason. Return isError and the text, and assert both.

156 return { isError: out.isError === true, text: out.content[0]!.text };
156157 };
157 expect((await call("sort:bogus")).note).toContain("sort:bogus is not a sort");
158 expect((await call("sort:forks")).note).toBeUndefined();
159 expect((await call("archived:maybe")).note).toBe("archived:maybe is not an answer — use yes, no");
160 expect((await call("updated:2y")).note).toBe("updated:2y is not a window — use 1d, 7d, 30d");
161 expect((await call("owner:a/b")).note).toBe("owner:a/b is not a name");
158 const sort = await call("sort:bogus");
159 expect(sort.isError).toBe(true);
160 expect(sort.text).toContain("unknown_filter: sort:bogus is not a sort — use score, forks, ships, likes, use, fresh, name");
161 const fine = await call("sort:forks");
162 expect(fine.isError).toBe(false);
163 expect(JSON.parse(fine.text)).toHaveProperty("items");
164 expect(await call("archived:maybe")).toEqual({ isError: true, text: "unknown_filter: archived:maybe is not an answer — use yes, no" });
165 expect(await call("updated:2y")).toEqual({ isError: true, text: "unknown_filter: updated:2y is not a window — use 1d, 7d, 30d" });
166 expect(await call("owner:a/b")).toEqual({ isError: true, text: "unknown_filter: owner:a/b is not a name" });
162167 });
163168 });
164169
@@ -203,9 +208,9 @@ describe("did you mean", () => {
203208 const { tool } = await import("../src/http/mcp/tools/explore.ts");
204209 const out = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:froks" });
205210 expect(JSON.stringify(out)).toContain("garden sort:forks");
206 expect(JSON.stringify(out)).toContain("suggest");
211 expect(JSON.stringify(out)).toContain("did you mean");
207212 const quiet = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:forks" });
208 expect(JSON.stringify(quiet)).not.toContain("suggest");
213 expect(JSON.stringify(quiet)).not.toContain("did you mean");
209214 const far = await tool.run({ app: a } as never, undefined as never, { q: "sort:zzzzzzzz" });
210215 expect(JSON.stringify(far)).not.toContain("suggest");
211216 expect(JSON.stringify(far)).toContain("is not a sort");
test/explore.test.ts +1 −1
@@ -265,7 +265,7 @@ describe("explore", () => {
265265 expect(names((await api("kind:app owner:bob")).items)).toEqual(["bob/busy"]);
266266 expect(names((await api("owner:bob")).items).sort()).toEqual(["bob", "bob/busy"]);
267267 const bad = await api("kind:app owner:..");
268 expect((bad as { note?: string }).note).toBe("owner:.. is not a name");
268 expect((bad as { message?: string }).message).toBe("owner:.. is not a name");
269269 const page = await (await fetch(`${base}/explore?q=kind:person`)).text();
270270 expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`);
271271 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs