SLOPSHOPPER

git-menu

The checkout's git and folder menus from /git and /open: forge links, copies and desktop apps

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-menu
│ ┃ Open folder ✕ › fix the failing auth test and add an audit log call │ ┃ Open folder │ ┃ 1: VS Code ⏺ Read(src/auth.ts) │ ┃ 2: Sublime Merge ⎿ Read 6 lines │ ┃ 3: Files ⏺ Update(src/auth.ts) │ ┃ 4: Ghostty ⎿ Added 2 lines, removed 1 line │ ┃ 5: Copy path ⏺ Bash(bun test) │ ┃ 1-9 or Enter picks · Esc closes ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git │ ⎿ git-menu: Opened the git menu. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Open folder
Open folder 1: VS Code 2: Sublime Merge 3: Files 4: Ghostty 5: Copy path 1-9 or Enter picks · Esc closes
README

NixOS Configuration

Personal NixOS configuration using flakes, home-manager, and agenix for secret management.

Quick Start

All common tasks are managed via just; run just to list every recipe with its description.

just switch                  # Rebuild and switch this machine (any host class)
just switch desktop          # ...or a specific host
just switch host user@remote # ...or push a nixos host to a remote (nixos-rebuild --target-host)
just test                    # Apply until reboot
just build                   # Build without switching
just diff                    # ...and diff the build against the running system
just update                  # Update flake inputs
just fmt                     # Format everything
just gc                      # Collect garbage and delete old generations

Options Reference

This config exposes a custom fireproof.* options namespace. A generated Markdown reference — descriptions, types, defaults, and declaration links — is available at docs/fireproof-options.md. CI regenerates it after each push to main.

Installing on a New Machine

just new-host <hostname> <username>       # card, SSH key, rekey (YubiKey)
just deploy-remote <hostname> root@<ip>   # nixos-anywhere: formats the target's disks

deploy-remote installs onto a target already booted into a Linux with SSH: the generic installer image, any NixOS ISO, or a cloud VM. The target's disks come from the host's hosts/<hostname>/ cards (see the disko templates), and nixos-anywhere wipes whatever is on them.

The full procedure, including disk layouts, TPM2 unlock and installing to a fresh Mac, is in docs/hosts.md.

Secret Management

Secrets are managed with agenix + agenix-rekey, using a YubiKey as the master identity.

Layout, commands and YubiKey requirements are in docs/secrets.md; just lists the secret recipes.

Development

Development Shell

A Nix development shell is available with useful tools for working on this configuration:

# Enter the development shell
nix develop

Formatting

Code is formatted with treefmt-nix; the formatters are configured in formatter.nix.

just fmt

Theme

Heavily inspired by / stolen from Flexoki

The palette is the fireproof.theme.colors options in modules/base/theme.nix; defaults are listed in docs/fireproof-options.md.

Source 3 files
hooks/register.tsx 260 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, ProcessRunResult, Register, RenderSurface } from "claude-code";
3
4import type { FolderApp, Menu, MenuKind, Row } from "../types";
5import {
6  chooseRemote,
7  folderMenu,
8  gitMenu,
9  parsePr,
10  parseStatus,
11  type PrAnswer,
12  type Status,
13} from "./menus";
14
15const PANE = "git-menu";
16const TITLES: Record<MenuKind, string> = { git: "Git", folder: "Open folder" };
17const HINTS = "1-9 or Enter picks · Esc closes";
18const GIT_TIMEOUT_MS = 3000;
19const GH_TIMEOUT_MS = 5000;
20// Starts a GUI app without holding $.process.run, which waits for the output to close.
21const DETACH = [
22  "sh",
23  "-c",
24  'exec >/dev/null 2>&1 </dev/null; if command -v setsid >/dev/null; then exec setsid -f "$@"; fi; "$@" &',
25  "sh",
26];
27
28const menu = atom({ plugin: "git-menu", key: "menu" } as const, null);
29
30let isMacAnswer: Promise<boolean> | undefined;
31
32const run = async (
33  $: EngineInterface,
34  argv: string[],
35  timeoutMs = GIT_TIMEOUT_MS,
36): Promise<ProcessRunResult | undefined> => {
37  try {
38    return await $.process.run(argv, { timeoutMs });
39  } catch {
40    return undefined;
41  }
42};
43
44/** One read-only git command; anything it refuses is an empty answer. */
45const git = async ($: EngineInterface, cwd: string, args: string[]) => {
46  const r = await run($, ["git", "--no-optional-locks", "-C", cwd, ...args]);
47  return r?.exitCode === 0 ? r.stdout.trim() : "";
48};
49
50const isMac = ($: EngineInterface) =>
51  (isMacAnswer ??= run($, ["uname", "-s"]).then((r) => r?.stdout.trim() === "Darwin"));
52
53async function lookupPr($: EngineInterface, cwd: string, status: Status): Promise<PrAnswer> {
54  // A branch that was never pushed has no pull request yet, and that is an answer.
55  if (!status.branch || !status.upstream) return null;
56  let r: ProcessRunResult;
57  try {
58    r = await $.process.run(["gh", "pr", "view", "--json", "url,number,state,isDraft"], {
59      cwd,
60      timeoutMs: GH_TIMEOUT_MS,
61    });
62  } catch {
63    return "no-gh";
64  }
65  if (r.exitCode !== 0) {
66    // These two are answers; anything else (offline, auth, timeout) is not.
67    return /no pull requests found|no git remotes found/.test(r.stderr) ? null : "unknown";
68  }
69  return parsePr(r.stdout) ?? "unknown";
70}
71
72/** The remote's default branch, then the local main/master refs, then `main`. */
73async function defaultBranch($: EngineInterface, cwd: string, remote: string) {
74  const head = await git($, cwd, [
75    "symbolic-ref",
76    "--quiet",
77    "--short",
78    `refs/remotes/${remote}/HEAD`,
79  ]);
80  if (head.startsWith(`${remote}/`)) return head.slice(remote.length + 1);
81  for (const candidate of ["main", "master"]) {
82    const ref = `refs/remotes/${remote}/${candidate}`;
83    if (await git($, cwd, ["rev-parse", "--verify", "--quiet", ref])) return candidate;
84  }
85  return "main";
86}
87
88async function buildGit($: EngineInterface, cwd: string): Promise<Menu> {
89  const out = await git($, cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=no"]);
90  if (!out) return { kind: "git", title: TITLES.git, rows: [], error: "Not a git repository." };
91  const status = parseStatus(out);
92  const branch = status.branch;
93  const [config, branchRemote, mergeBase, pr] = await Promise.all([
94    git($, cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]),
95    branch ? git($, cwd, ["config", "--get", `branch.${branch}.remote`]) : "",
96    branch ? git($, cwd, ["config", "--get", `branch.${branch}.gh-merge-base`]) : "",
97    lookupPr($, cwd, status),
98  ]);
99  const remote = chooseRemote(config, branchRemote);
100  // Mirrors `gh pr create`: the branch's gh-merge-base, else the remote's default branch.
101  const base = mergeBase || (remote ? await defaultBranch($, cwd, remote.name) : "main");
102  return gitMenu({ status, remote, base, pr });
103}
104
105async function buildFolder($: EngineInterface, cwd: string): Promise<Menu> {
106  const mac = await isMac($);
107  const launchers: Partial<Record<FolderApp, string[]>> = {};
108  if (mac) {
109    const bundle = async (app: FolderApp, name: string, args: string[], fresh = false) => {
110      if (await $.fs.exists(`/Applications/${name}.app`))
111        launchers[app] = fresh
112          ? ["open", "-na", `${name}.app`, "--args", ...args]
113          : ["open", "-a", name, ...args];
114    };
115    await Promise.all([
116      bundle("code", "Visual Studio Code", [cwd]),
117      bundle("smerge", "Sublime Merge", [cwd]),
118      // Ghostty takes a directory only as a second instance.
119      bundle("ghostty", "Ghostty", [`--working-directory=${cwd}`], true),
120    ]);
121    launchers.files = ["open", cwd];
122  } else {
123    const probe = await run($, [
124      "sh",
125      "-c",
126      'for c; do command -v "$c" >/dev/null && echo "$c"; done',
127      "sh",
128      "code",
129      "smerge",
130      "nautilus",
131      "xdg-open",
132      "ghostty",
133    ]);
134    const found = new Set((probe?.stdout ?? "").split("\n").filter(Boolean));
135    if (found.has("code")) launchers.code = ["code", cwd];
136    if (found.has("smerge")) launchers.smerge = ["smerge", cwd];
137    if (found.has("nautilus")) launchers.files = ["nautilus", cwd];
138    else if (found.has("xdg-open")) launchers.files = ["xdg-open", cwd];
139    // A fresh shell in the checkout, not this session resumed.
140    if (found.has("ghostty")) launchers.ghostty = ["ghostty", `--working-directory=${cwd}`];
141  }
142  return folderMenu(cwd, launchers, mac);
143}
144
145const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some((p) => p.id === PANE);
146
147async function close($: EngineInterface) {
148  await update($, menu, () => null);
149  await $.ui.close({ id: PANE });
150}
151
152/**
153 * Opens a menu, or closes it when it is already up, as a second click on pi's
154 * footer did. The pane opens before git answers: one opened after the command
155 * hook returned counts as unasked and stays hidden on narrower terminals.
156 */
157async function show($: EngineInterface, kind: MenuKind) {
158  if ((await read($, menu))?.kind === kind && (await isOpen($))) return close($);
159  const cwd = await $.session.cwd();
160  const title = TITLES[kind];
161  await update($, menu, () => ({ kind, title, rows: null }));
162  const opened = await $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true, rows: 10 });
163  if (!opened.isPlaced) $.ui.toast(`The ${title} menu can't show yet: ${opened.reason}`);
164  const built = kind === "git" ? await buildGit($, cwd) : await buildFolder($, cwd);
165  // A close or another menu while git answered wins over this one.
166  await update($, menu, (m) => (m?.kind === kind && m.rows === null ? built : m));
167  const shown = await read($, menu);
168  if (shown?.kind === kind && shown.rows !== null && built.preferred)
169    // Cosmetic: a ring that cannot move leaves Tab and the hotkeys working.
170    await $.ui.focus({ requestId: PANE, key: built.preferred }).catch(() => undefined);
171}
172
173async function press($: EngineInterface, row: Row, surface: RenderSurface) {
174  if (!row.action) {
175    $.ui.toast(`${row.label}: ${row.unavailable ?? "not available"}`);
176    return;
177  }
178  const action = row.action;
179  if (action.kind === "copy") {
180    const r = await $.ui.copy({ text: action.text, surface });
181    $.ui.toast(r.isCopied ? `Copied the ${action.what}` : `Couldn't copy: ${r.reason}`);
182  } else {
183    const argv =
184      action.kind === "url" ? [(await isMac($)) ? "open" : "xdg-open", action.url] : action.argv;
185    const r = await run($, [...DETACH, ...argv]);
186    if (r?.exitCode !== 0) $.ui.toast(`Couldn't start ${argv[0]}`);
187  }
188  await close($);
189}
190
191export const register: Register = (on) => {
192  on("session.start", async ($, e, next) => {
193    for (const [name, description] of [
194      ["git", "Open the git menu: the repository, branch and pull request on the forge"],
195      ["open", "Open the folder menu: this checkout in an editor, file manager or terminal"],
196    ] as const) {
197      await $.command
198        .register({ name, description, immediate: true })
199        .catch((error) =>
200          $.ui.log(`git-menu: /${name} not registered: ${String(error)}`, { to: "debug" }),
201        );
202    }
203    return next(e);
204  });
205
206  on("command.run", { command: "git" }, async ($) => {
207    await show($, "git");
208    return { text: "Opened the git menu." };
209  });
210
211  on("command.run", { command: "open" }, async ($) => {
212    await show($, "folder");
213    return { text: "Opened the folder menu." };
214  });
215
216  on("ui.close", { id: PANE }, async ($, e, next) => {
217    await update($, menu, () => null);
218    return next(e);
219  });
220
221  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
222    const { Box, Text, Button } = $.ui.resolve(e);
223    const m = await read($, menu);
224    if (!m) return <Text dimColor>No menu open.</Text>;
225    const rows = m.rows ?? [];
226    // Notes share one column, so the rows read as a table.
227    const width = Math.max(0, ...rows.map((r) => r.label.length));
228    return (
229      <Box flexDirection="column">
230        <Box flexDirection="row">
231          <Text bold>{m.title}</Text>
232          {m.context ? <Text dimColor>{`  ${m.context}`}</Text> : null}
233        </Box>
234        {m.error ? (
235          <Text dimColor>{m.error}</Text>
236        ) : m.rows === null ? (
237          <Text dimColor>Asking git…</Text>
238        ) : (
239          rows.map((row, i) => {
240            const note = row.unavailable ?? row.description;
241            return (
242              <Button
243                key={row.id}
244                plain
245                hotkey={i < 9 ? String(i + 1) : undefined}
246                dimColor={row.action === undefined}
247                onPress={(p) => void press($, row, p.surface)}
248              >
249                {row.label.padEnd(width)}
250                {note ? <Text dimColor>{`  ${note}`}</Text> : null}
251              </Button>
252            );
253          })
254        )}
255        <Text dimColor>{HINTS}</Text>
256      </Box>
257    );
258  });
259};
260
hooks/menus.ts 307 lines
1/**
2 * The menus as data, ported from pi's footer (pi/extensions/parent/chrome):
3 * every git row is a link or a copy, so nothing here pushes, rebases or creates
4 * anything; a row that cannot run keeps its place and says why in a few words.
5 */
6import type { FolderApp, Menu, Row } from "../types";
7
8export type Forge = "github" | "gitlab" | "codeberg";
9
10export type Remote = {
11  name: string;
12  forge: Forge;
13  host: string;
14  /** Group path, which GitLab may nest. */
15  owner: string;
16  repo: string;
17  /** The repository's page, without a trailing slash. */
18  web: string;
19};
20
21export type Status = {
22  branch: string | null;
23  oid: string;
24  upstream: boolean;
25};
26
27export type PrKind = "open" | "draft" | "merged" | "closed";
28
29export type Pr = { url: string; number: number; state: PrKind };
30
31/** The branch's pull request: `null` for none, `unknown` when gh could not say. */
32export type PrAnswer = Pr | null | "unknown" | "no-gh";
33
34export const FOLDER_APPS: readonly FolderApp[] = ["code", "smerge", "files", "ghostty"];
35
36const SHORT_SHA = 7;
37
38// Self-hosted forges may share these layouts, but a guessed link that 404s is worse than none.
39const FORGES: Record<string, Forge> = {
40  "github.com": "github",
41  "gitlab.com": "gitlab",
42  "codeberg.org": "codeberg",
43};
44
45export const parseStatus = (output: string): Status => {
46  let branch: string | null = null;
47  let oid = "";
48  let upstream = false;
49  for (const line of output.split("\n")) {
50    if (line.startsWith("# branch.head ")) {
51      const head = line.slice("# branch.head ".length);
52      branch = head === "(detached)" ? null : head;
53    } else if (line.startsWith("# branch.oid ")) {
54      oid = line.slice("# branch.oid ".length);
55    } else if (line.startsWith("# branch.upstream ")) {
56      upstream = true;
57    }
58  }
59  return { branch, oid, upstream };
60};
61
62/** `gh pr view --json url,number,state,isDraft` as a Pr; undefined when it does not parse. */
63export const parsePr = (stdout: string): Pr | undefined => {
64  try {
65    const data = JSON.parse(stdout) as {
66      url: string;
67      number: number;
68      state: string;
69      isDraft?: boolean;
70    };
71    const state: PrKind = data.isDraft
72      ? "draft"
73      : data.state === "MERGED"
74        ? "merged"
75        : data.state === "CLOSED"
76          ? "closed"
77          : "open";
78    return { url: data.url, number: data.number, state };
79  } catch {
80    return undefined;
81  }
82};
83
84/** A remote URL as a forge link, or undefined for a host this does not know. */
85export const parseRemote = (name: string, raw: string): Remote | undefined => {
86  const trimmed = raw
87    .trim()
88    .replace(/\/+$/, "")
89    .replace(/\.git$/, "");
90  let host: string;
91  let path: string;
92  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
93    let url: URL;
94    try {
95      url = new URL(trimmed);
96    } catch {
97      return undefined;
98    }
99    host = url.hostname;
100    path = url.pathname;
101  } else {
102    // The scp-like form git writes for SSH: [user@]host:path.
103    const match = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(trimmed);
104    if (!match?.[1] || !match[2]) return undefined;
105    host = match[1];
106    path = match[2];
107  }
108  host = host.toLowerCase();
109  const forge = FORGES[host];
110  if (!forge) return undefined;
111  const segments = path.split("/").filter(Boolean);
112  if (segments.length < 2) return undefined;
113  const repo = segments[segments.length - 1] ?? "";
114  const owner = segments.slice(0, -1).join("/");
115  return { name, forge, host, owner, repo, web: `https://${host}/${owner}/${repo}` };
116};
117
118/**
119 * The remote links follow: the branch's upstream remote, then `origin`, then
120 * the only supported one. `config` is `git config --get-regexp '^remote\..*\.url$'`.
121 */
122export const chooseRemote = (config: string, branchRemote: string): Remote | undefined => {
123  const remotes = new Map<string, Remote>();
124  for (const line of config.split("\n")) {
125    const match = /^remote\.(.+)\.url\s+(.+)$/.exec(line.trim());
126    // A remote with separate fetch and push URLs lists both; the fetch URL is the page people read.
127    if (!match?.[1] || !match[2] || remotes.has(match[1])) continue;
128    const remote = parseRemote(match[1], match[2]);
129    if (remote) remotes.set(remote.name, remote);
130  }
131  const supported = [...remotes.values()];
132  return (
133    (branchRemote && branchRemote !== "." ? remotes.get(branchRemote) : undefined) ??
134    remotes.get("origin") ??
135    (supported.length === 1 ? supported[0] : undefined)
136  );
137};
138
139/** A branch name as URL path segments, so a `#` or a space still links. */
140const segment = (text: string) => text.split("/").map(encodeURIComponent).join("/");
141
142const branchUrl = (remote: Remote, branch: string) => {
143  const leaf = segment(branch);
144  switch (remote.forge) {
145    case "github":
146      return `${remote.web}/tree/${leaf}`;
147    case "gitlab":
148      return `${remote.web}/-/tree/${leaf}`;
149    case "codeberg":
150      return `${remote.web}/src/branch/${leaf}`;
151  }
152};
153
154const commitUrl = (remote: Remote, sha: string) =>
155  remote.forge === "gitlab" ? `${remote.web}/-/commit/${sha}` : `${remote.web}/commit/${sha}`;
156
157// The compare form rather than `gh pr create --web`, which may offer to push.
158const compareUrl = (remote: Remote, base: string, branch: string) =>
159  `${remote.web}/compare/${segment(base)}...${segment(branch)}?expand=1`;
160
161export type GitFacts = {
162  status: Status;
163  remote: Remote | undefined;
164  /** The branch a new pull request targets, as `gh pr create` picks it. */
165  base: string;
166  pr: PrAnswer;
167};
168
169export const gitMenu = ({ status, remote, base, pr }: GitFacts): Menu => {
170  const { branch } = status;
171  const found = typeof pr === "object" ? pr : null;
172  const noRemote = "no supported remote";
173  const rows: Row[] = [
174    remote
175      ? {
176          id: "repo",
177          label: "Open repository",
178          description: remote.host,
179          action: { kind: "url", url: remote.web },
180        }
181      : { id: "repo", label: "Open repository", unavailable: noRemote },
182  ];
183
184  if (branch) {
185    rows.push({
186      id: "branch",
187      label: "Open branch",
188      unavailable: remote ? undefined : noRemote,
189      action: remote ? { kind: "url", url: branchUrl(remote, branch) } : undefined,
190    });
191  } else {
192    const sha = status.oid || "HEAD";
193    rows.push({
194      id: "commit",
195      label: `Open commit ${sha.slice(0, SHORT_SHA)}`,
196      unavailable: remote ? undefined : noRemote,
197      action: remote ? { kind: "url", url: commitUrl(remote, sha) } : undefined,
198    });
199  }
200
201  if (found) {
202    rows.push({
203      id: "pr",
204      label: `Open pull request #${found.number}`,
205      description: found.state,
206      action: { kind: "url", url: found.url },
207    });
208  }
209
210  const create =
211    found && (found.state === "merged" || found.state === "closed")
212      ? "Create new pull request"
213      : "Create pull request";
214  const blocked = !remote
215    ? noRemote
216    : remote.forge !== "github"
217      ? "GitHub only"
218      : !branch
219        ? "detached HEAD"
220        : found && (found.state === "open" || found.state === "draft")
221          ? `PR #${found.number} is open`
222          : !status.upstream
223            ? "push the branch first"
224            : pr === "no-gh"
225              ? "gh not installed"
226              : pr === "unknown"
227                ? "gh could not say"
228                : undefined;
229  rows.push(
230    blocked || !remote || !branch
231      ? { id: "create", label: create, unavailable: blocked }
232      : {
233          id: "create",
234          label: create,
235          description: `into ${base}`,
236          action: { kind: "url", url: compareUrl(remote, base, branch) },
237        },
238  );
239
240  rows.push(
241    branch
242      ? {
243          id: "copy",
244          label: "Copy branch name",
245          action: { kind: "copy", text: branch, what: "branch name" },
246        }
247      : {
248          id: "copy",
249          label: "Copy commit SHA",
250          action: { kind: "copy", text: status.oid, what: "commit SHA" },
251        },
252  );
253  if (found) {
254    rows.push({
255      id: "copy-pr",
256      label: "Copy PR URL",
257      action: { kind: "copy", text: found.url, what: "pull request URL" },
258    });
259  }
260
261  // The pull request is what the menu is usually opened for, then the branch, then the repository.
262  const preferred = found ? "pr" : !remote ? "repo" : branch ? "branch" : "commit";
263  return {
264    kind: "git",
265    title: "Git",
266    context: remote ? `${remote.owner}/${remote.repo}` : undefined,
267    rows,
268    preferred,
269  };
270};
271
272export const appLabel = (app: FolderApp, isMac: boolean): string => {
273  switch (app) {
274    case "code":
275      return "VS Code";
276    case "smerge":
277      return "Sublime Merge";
278    case "files":
279      return isMac ? "Finder" : "Files";
280    case "ghostty":
281      return "Ghostty";
282  }
283};
284
285/**
286 * The checkout in the desktop's apps, each started by its argv in `launchers`;
287 * one missing keeps its row, so the order never moves.
288 */
289export const folderMenu = (
290  cwd: string,
291  launchers: Partial<Record<FolderApp, string[]>>,
292  isMac: boolean,
293): Menu => ({
294  kind: "folder",
295  title: "Open folder",
296  rows: [
297    ...FOLDER_APPS.map((app): Row => {
298      const argv = launchers[app];
299      return argv
300        ? { id: app, label: appLabel(app, isMac), action: { kind: "launch", argv } }
301        : { id: app, label: appLabel(app, isMac), unavailable: "not installed" };
302    }),
303    { id: "copy", label: "Copy path", action: { kind: "copy", text: cwd, what: "path" } },
304  ],
305  preferred: launchers.code ? "code" : undefined,
306});
307
types/index.d.ts 41 lines
1export type MenuKind = "git" | "folder";
2
3export type FolderApp = "code" | "smerge" | "files" | "ghostty";
4
5/** What a row does when pressed; plain data, so the menu survives a reload. */
6export type Action =
7  | { kind: "url"; url: string }
8  | { kind: "copy"; text: string; what: string }
9  | { kind: "launch"; argv: string[] };
10
11export type Row = {
12  id: string;
13  label: string;
14  /** A short note beside the label: a host, a state, a destination. */
15  description?: string;
16  /** Why the row cannot run; it keeps its place and says this when pressed. */
17  unavailable?: string;
18  action?: Action;
19};
20
21export type Menu = {
22  kind: MenuKind;
23  title: string;
24  /** Beside the title, e.g. the repository the rows act on. */
25  context?: string;
26  /** Null while git and gh are still answering. */
27  rows: Row[] | null;
28  /** The row the focus starts on. */
29  preferred?: string;
30  /** Why the menu has no rows, e.g. not a repository. */
31  error?: string;
32};
33
34declare module "claude-code" {
35  interface PluginState {
36    "git-menu": {
37      menu: Menu | null;
38    };
39  }
40}
41