Drafts Linear issues for approval from the You should know card (Learn more, 3) or /linear

Personal NixOS configuration using flakes, home-manager, and agenix for secret management.
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
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.
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.
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.
A Nix development shell is available with useful tools for working on this configuration:
# Enter the development shell
nix develop
Code is formatted with treefmt-nix; the formatters are configured in formatter.nix.
just fmt
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.
hooks/register.tsx 1129 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, RenderElement, RenderNode } from "claude-code";
3
4import type { Duplicate, Filed, Form, Last, Option } from "../types";
5
6// The built-in's card has no extension point, so the button is spliced into the
7// tree it draws, found by these labels. A rename only hides the button (see warnDrift).
8const YSK = "cc-plugin-you-should-know";
9const LEARN_MORE = "Learn more";
10const ANCHOR = "Chat in main session";
11const PANE = "linear-draft";
12const FALLBACK_TEAM = "MSC";
13const RECENT_MESSAGES = 8;
14const MESSAGE_CHARS = 2000;
15// Issues each search term finds, and candidates the duplicate check weighs at most.
16const SEARCH_HITS = 10;
17const CANDIDATES = 20;
18const CANDIDATE_CHARS = 300;
19// How long hasLinear trusts its last answer; a missing server is looked for again sooner.
20const LINEAR_SEEN_MS = 60_000;
21const LINEAR_MISSING_MS = 5_000;
22// Digits the button may take, first free wins; 8 and 9 belong to the draft chip.
23const HOTKEYS = ["3", "4", "5", "6", "7"];
24const CLOSED = ["completed", "canceled", "duplicate"];
25const SERVER_WAIT_S = 10;
26// A Select takes 1 to 64 options; labels are filtered by typing instead.
27const SELECT_MAX = 64;
28const COMPACT_ROWS = 18;
29const TITLE_LABEL = "Title: ";
30// The "⏎ submit" a focused one-row Input draws after its text.
31const HINT_WIDTH = 10;
32const DISCARD_ARM_MS = 5000;
33// An open Select shows 8 options and a "… n more" row.
34const OPEN_LIST_ROWS = 9;
35const HINTS = "Tab/Shift+Tab move · ↑↓ pick · Esc hide";
36const LINEAR_TOOL = /^mcp__(.*linear.*)__([^_].*)$/i;
37const PRIORITIES: Option[] = [
38 { value: "1", label: "Urgent" },
39 { value: "2", label: "High" },
40 { value: "3", label: "Medium" },
41 { value: "4", label: "Low" },
42 { value: "0", label: "No priority" },
43];
44
45// The card the button was pressed on, drawn as nothing until another replaces it.
46const hidden = atom({ plugin: "linear-issue", key: "hidden" } as const, null);
47const form = atom({ plugin: "linear-issue", key: "form" } as const, null);
48const filed = atom({ plugin: "linear-issue", key: "filed" } as const, []);
49const isPaneOpen = atom({ plugin: "linear-issue", key: "isPaneOpen" } as const, false);
50const focused = atom({ plugin: "linear-issue", key: "focused" } as const, null);
51const isDiscardArmed = atom({ plugin: "linear-issue", key: "isDiscardArmed" } as const, false);
52
53// Set by a Learn more press, cleared once the expanded card has been checked.
54let isExpanding = false;
55let linearSeen: { at: number; isSeen: boolean } | undefined;
56// Per team key: its statuses, labels and projects, fetched once a session.
57const teamOptions = new Map<string, Promise<TeamOptions>>();
58
59type TeamOptions = { statuses: Option[]; labels: Option[]; projects: Option[]; todo: string };
60
61/** What the drafting model answers, before names are resolved to Linear ids. */
62type Draft = {
63 team: string;
64 title: string;
65 description: string;
66 priority: number;
67 labels?: string[];
68 search?: string[];
69};
70
71/** What the duplicate check answers. */
72type Verdict = { duplicate: { issue: string; comment: string } | null };
73
74type Issue = { id: string; title: string; statusType: string; team: string; description?: string };
75
76// No tools and no subagent: a subagent's report would land in the session and start a turn.
77const SYSTEM = `You draft a Linear issue for the user to deal with later, from one of two seeds: an <insight> that Claude Code's "You should know" side agent flagged while it watched their session, or a <note> the user typed with /linear. The seed is what to file; the recent conversation is context for it. An empty <note> means: file what the recent conversation is about. The user reviews and edits your draft before it is created.
78
79- team: the key of the team from <teams> that the repository and topic belong to; when unsure, the <defaults> team.
80- title: short and specific, imperative, under 80 characters.
81- description: Linear markdown that reads at a glance. Prefer structure to prose: one sentence saying what to do and why, then the details as bullets, never as a paragraph. Use the richer forms only when the content has that shape:
82 - a table when comparing options, listing items that share attributes, or mapping before to after
83 - a checklist (- [ ]) when the work splits into separate steps
84 No diagrams: the draft pane cannot draw them.
85 Then, leaving out a line you have nothing concrete for:
86 **Where:** the file paths, symbols or commands named in the seed or conversation, in backticks
87 **Done when:** one line saying how to tell it is finished
88 No headings, no story of the session, no restating the title; about 120 words at most, a table not counted. Do not add a source or session line; it is appended for you.
89- priority: 1 urgent, 2 high, 3 medium, 4 low. Reserve 1 for things that are breaking now; when unsure, the <defaults> priority.
90- labels: names from <labels> that clearly fit, usually none.
91- search: two or three short phrases, one to three words each, that an existing issue about the same problem would contain in its title or description; used to look for duplicates.
92- Never copy secrets, tokens, credentials or .env contents into the draft.
93
94Answer with one JSON object and nothing else:
95{"team": "...", "title": "...", "description": "...", "priority": 4, "labels": [], "search": []}`;
96
97const DUPLICATE_SYSTEM = `You check whether an open Linear issue already covers a <draft> the user is about to file. The <candidates> came from a keyword search, so most are unrelated. Only call one a duplicate when it is about the same problem or change, not merely the same area.
98
99When one is, answer its key and a one-or-two-sentence comment for it adding what the draft knows that the issue does not. Never copy secrets, tokens, credentials or .env contents.
100
101Answer with one JSON object and nothing else:
102{"duplicate": null} or {"duplicate": {"issue": "KEY-1", "comment": "..."}}`;
103
104type Element = Exclude<RenderNode, string>;
105
106const isElement = (node: RenderNode | undefined): node is Element =>
107 typeof node === "object" && node !== null;
108
109const childrenOf = (node: Element): RenderNode[] =>
110 "children" in node && Array.isArray(node.children) ? node.children : [];
111
112const isButton = (node: RenderNode, label: string) =>
113 isElement(node) && node.type === "Button" && node.props.label === label;
114
115const isDismiss = (node: RenderNode) =>
116 isElement(node) && node.type === "Button" && node.props.role === "dismiss";
117
118/** The nodes from `root` down to the first node `match` accepts, both included; undefined when none does. */
119function pathTo(root: RenderNode, match: (node: RenderNode) => boolean): RenderNode[] | undefined {
120 if (match(root)) return [root];
121 if (!isElement(root)) return undefined;
122 for (const child of childrenOf(root)) {
123 const path = pathTo(child, match);
124 if (path) return [root, ...path];
125 }
126 return undefined;
127}
128
129/** `root` with `target` (by identity) swapped for `replacement`. */
130function replace(root: RenderNode, target: RenderNode, replacement: RenderNode): RenderNode {
131 if (root === target) return replacement;
132 if (!isElement(root) || childrenOf(root).length === 0) return root;
133 return {
134 ...root,
135 children: childrenOf(root).map((c) => replace(c, target, replacement)),
136 } as Element;
137}
138
139/**
140 * `tree` with `button` beside the anchor button: in a copy of the anchor's wrapper, before the
141 * dismiss button when one shares a row with it. Undefined when the tree holds no anchor.
142 */
143function insertButton(tree: RenderNode, button: RenderElement): RenderNode | undefined {
144 const toAnchor = pathTo(tree, (n) => isButton(n, ANCHOR));
145 const anchor = toAnchor?.at(-1);
146 if (!toAnchor || !anchor || toAnchor.length < 2) return undefined;
147 const toDismiss = pathTo(tree, isDismiss);
148
149 // The row is the deepest node above both buttons; without a dismiss, the anchor's parent.
150 let depth = toAnchor.length - 2;
151 if (toDismiss) {
152 depth = 0;
153 while (depth + 2 < toAnchor.length && toAnchor[depth + 1] === toDismiss[depth + 1]) depth++;
154 }
155 const row = toAnchor[depth] as Element;
156 const wrapper = toAnchor[depth + 1] as RenderNode;
157 const kids = childrenOf(row);
158 const dismissAt =
159 toDismiss?.[depth + 1] === undefined ? -1 : kids.indexOf(toDismiss[depth + 1] as RenderNode);
160 const at = dismissAt >= 0 ? dismissAt : kids.indexOf(wrapper) + 1;
161 const copy = replace(wrapper, anchor, button);
162 const newRow = { ...row, children: [...kids.slice(0, at), copy, ...kids.slice(at)] } as Element;
163 return replace(tree, row, newRow);
164}
165
166/** The card's text, one line per Text, Markdown or Code, buttons left out. */
167function cardText(node: RenderNode): string {
168 const lines: string[] = [];
169 const flat = (n: RenderNode): string =>
170 typeof n === "string" ? n : isElement(n) ? childrenOf(n).map(flat).join("") : "";
171 const walk = (n: RenderNode) => {
172 if (typeof n === "string") lines.push(n);
173 else if (!isElement(n) || n.type === "Button") return;
174 else if (n.type === "Text") lines.push(flat(n));
175 else if (n.type === "Markdown") lines.push(n.props.text);
176 else if (n.type === "Code") lines.push(n.props.source);
177 else childrenOf(n).forEach(walk);
178 };
179 walk(node);
180 return lines
181 .map((l) => l.trim())
182 .filter(Boolean)
183 .join("\n");
184}
185
186const hasButtons = (node: RenderNode) =>
187 pathTo(node, (n) => isElement(n) && n.type === "Button") !== undefined;
188
189/**
190 * True when any MCP server named like Linear has a tool here, its auth stub included.
191 * Remembered for a while, as the band asks on every render.
192 */
193async function hasLinear($: EngineInterface): Promise<boolean> {
194 const now = await $.clock.now();
195 const ttl = linearSeen?.isSeen ? LINEAR_SEEN_MS : LINEAR_MISSING_MS;
196 if (linearSeen && now - linearSeen.at < ttl) return linearSeen.isSeen;
197 const tools = await $.tool.list().catch(() => []);
198 linearSeen = { at: now, isSeen: tools.some((t) => /^mcp__.*linear.*__/i.test(t.name)) };
199 return linearSeen.isSeen;
200}
201
202/** Hotkeys the buttons in `node` already take. */
203function hotkeysIn(node: RenderNode): Set<string> {
204 const taken = new Set<string>();
205 const walk = (n: RenderNode) => {
206 if (!isElement(n)) return;
207 if (n.type === "Button" && typeof n.props.hotkey === "string") taken.add(n.props.hotkey);
208 childrenOf(n).forEach(walk);
209 };
210 walk(node);
211 return taken;
212}
213
214/** One toast per Claude Code version, when the expanded card no longer has the anchor. */
215async function warnDrift($: EngineInterface) {
216 const { version } = await $.session.version();
217 if ((await $.store.get("warnedVersion").catch(() => undefined)) === version) return;
218 await $.store.set("warnedVersion", version);
219 $.ui.toast(
220 `linear-issue: the You should know card no longer has "${ANCHOR}", so its Linear button is hidden; update mods/linear-issue`,
221 );
222}
223
224async function gitBranch($: EngineInterface, cwd: string): Promise<string> {
225 try {
226 const r = await $.process.run(["git", "-C", cwd, "branch", "--show-current"], {
227 timeoutMs: 2000,
228 });
229 return r.exitCode === 0 ? r.stdout.trim() || "(detached)" : "(none)";
230 } catch {
231 return "(none)";
232 }
233}
234
235/** `owner/repo` from a git remote URL, or undefined when it names none. */
236const repoName = (remote: string) => /[:/]([^/:]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1];
237
238/**
239 * The drafter's brief: `seed` (a card's `insight` or a /linear `note`) with where it came
240 * from, and the source line the filed description ends with.
241 */
242async function brief(
243 $: EngineInterface,
244 kind: "insight" | "note",
245 seed: string,
246): Promise<{ text: string; source: string }> {
247 const cwd = await $.session.cwd();
248 const [repo, branch, sessionId, messages] = await Promise.all([
249 $.session.repo().catch(() => null),
250 gitBranch($, cwd),
251 $.session.id(),
252 $.session.messages().catch(() => []),
253 ]);
254 const recent = messages
255 .filter((m) => m.text.trim() !== "")
256 .slice(-RECENT_MESSAGES)
257 .map((m) => {
258 const text = m.text.length > MESSAGE_CHARS ? `${m.text.slice(0, MESSAGE_CHARS)}…` : m.text;
259 return `<${m.role}>\n${text}\n</${m.role}>`;
260 })
261 .join("\n");
262 const name =
263 (repo?.remote && repoName(repo.remote)) ?? (repo?.root ?? cwd).split("/").pop() ?? cwd;
264 const text = [
265 `<${kind}>\n${seed}\n</${kind}>`,
266 `<source>\ndirectory: ${cwd}\nrepository: ${name}\nbranch: ${branch}\n</source>`,
267 `<recent_conversation>\n${recent}\n</recent_conversation>`,
268 ].join("\n\n");
269 return { text, source: `Source: \`${name}\` · ${branch} · session ${sessionId}` };
270}
271
272const oneLine = (text: string) => text.replace(/\s*\n\s*/g, " ");
273
274type Block = { kind: "markdown" | "table" | "code"; lines: string[] };
275
276/**
277 * The description cut into the blocks the preview draws itself, tables and fenced code,
278 * and the markdown between them. Fence markers are dropped; a fence's language is its
279 * first line.
280 */
281function blocks(text: string): Block[] {
282 const out: Block[] = [];
283 let fence: Block | undefined;
284 for (const line of text.split("\n")) {
285 const marker = /^\s*```(.*)$/.exec(line);
286 if (marker) {
287 if (fence) fence = undefined;
288 else out.push((fence = { kind: "code", lines: marker[1] ? [marker[1].trim()] : [] }));
289 continue;
290 }
291 if (fence) {
292 fence.lines.push(line);
293 continue;
294 }
295 const kind = /^\s*\|.*\|\s*$/.test(line) ? "table" : "markdown";
296 const last = out.at(-1);
297 if (last?.kind === kind) last.lines.push(line);
298 else out.push({ kind, lines: [line] });
299 }
300 return out;
301}
302
303/** A table row's cells, inline emphasis and code marks removed. */
304const cells = (line: string) =>
305 line
306 .trim()
307 .replace(/^\||\|$/g, "")
308 .split("|")
309 .map((c) => c.trim().replace(/\*\*|`/g, ""));
310
311/** Merges `patch` into the open form; nothing when no draft is open. */
312const patchForm = ($: EngineInterface, patch: Partial<Form>) =>
313 update($, form, (cur) => cur && { ...cur, ...patch });
314
315const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));
316
317/** The first connected Linear server that can write issues, in tool-name spelling. */
318async function linearServer($: EngineInterface): Promise<string | undefined> {
319 // MCP servers connect after the session starts, so a /linear right away waits for one.
320 for (let attempt = 0; attempt < SERVER_WAIT_S; attempt++) {
321 for (const { name } of await $.tool.list()) {
322 const m = LINEAR_TOOL.exec(name);
323 if (m?.[2] === "save_issue") return m[1];
324 }
325 await $.clock.sleep(1000);
326 }
327 return undefined;
328}
329
330/** Calls a Linear tool and parses its JSON answer; throws with the server's text on an error. */
331async function call<T>(
332 $: EngineInterface,
333 server: string,
334 tool: string,
335 args: Record<string, unknown>,
336) {
337 const result = await $.mcp.call(server, tool, args);
338 const text = result.content.map((c) => ("text" in c ? c.text : "")).join("\n");
339 if (result.isError) throw new Error(text.slice(0, 200) || `${tool} failed`);
340 try {
341 return JSON.parse(text) as T;
342 } catch {
343 return text as unknown as T;
344 }
345}
346
347function loadTeam($: EngineInterface, server: string, team: string): Promise<TeamOptions> {
348 let options = teamOptions.get(team);
349 if (!options) {
350 options = (async () => {
351 type Named = {
352 id: string;
353 name: string;
354 type?: string;
355 parent?: string | null;
356 archivedAt?: string | null;
357 };
358 const [statuses, labels, projects] = await Promise.all([
359 call<Named[]>($, server, "list_issue_statuses", { team }),
360 call<{ labels: Named[] }>($, server, "list_issue_labels", { team, limit: 250 }),
361 call<{ projects: Named[] }>($, server, "list_projects", { team, limit: 50 }),
362 ]);
363 const todo =
364 statuses.find((s) => s.name === "Todo") ?? statuses.find((s) => s.type === "unstarted");
365 return {
366 statuses: statuses.map((s) => ({ value: s.id, label: s.name })),
367 labels: labels.labels
368 .filter((l) => !l.archivedAt)
369 .map((l) => ({ value: l.id, label: l.parent ? `${l.parent}/${l.name}` : l.name })),
370 projects: [
371 { value: "", label: "No project" },
372 ...projects.projects.map((p) => ({ value: p.id, label: p.name })),
373 ],
374 todo: todo?.id ?? statuses[0]?.id ?? "",
375 };
376 })();
377 teamOptions.set(team, options);
378 options.catch(() => teamOptions.delete(team));
379 }
380 return options;
381}
382
383/** Rows that items of `widths` columns, gaps included, take wrapped to `width` columns. */
384function wrappedRows(widths: number[], width: number): number {
385 let rows = 1;
386 let used = 0;
387 for (const w of widths) {
388 if (used > 0 && used + w > width) {
389 rows++;
390 used = 0;
391 }
392 used += w;
393 }
394 return rows;
395}
396
397/** The JSON object in a model reply, which may wrap it in prose or a fence. */
398function replyJson<T>(text: string): T {
399 return JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)) as T;
400}
401
402/** Open issues anywhere in the workspace that any of `terms` finds, most hits first. */
403async function searchOpen($: EngineInterface, server: string, terms: string[]): Promise<Issue[]> {
404 const found = await Promise.all(
405 terms.map((query) =>
406 call<{ issues: Issue[] }>($, server, "list_issues", {
407 query,
408 limit: SEARCH_HITS,
409 fields: ["title", "statusType", "team", "description"],
410 }).then(
411 (r) => r.issues,
412 () => [] as Issue[],
413 ),
414 ),
415 );
416 const hits = new Map<string, { issue: Issue; count: number }>();
417 for (const issue of found.flat()) {
418 if (CLOSED.includes(issue.statusType)) continue;
419 const seen = hits.get(issue.id);
420 hits.set(issue.id, { issue, count: (seen?.count ?? 0) + 1 });
421 }
422 return [...hits.values()]
423 .sort((a, b) => b.count - a.count)
424 .slice(0, CANDIDATES)
425 .map((h) => h.issue);
426}
427
428/**
429 * Looks for an open issue covering the draft `draftId` and offers commenting on it.
430 * Runs after the form is ready, so a slow search never holds up Create; a result for a
431 * draft that has since been filed or discarded is dropped.
432 */
433async function checkDuplicates(
434 $: EngineInterface,
435 server: string,
436 draftId: string,
437 draft: Pick<Draft, "title" | "description" | "search">,
438) {
439 const settle = (duplicate: Duplicate | null) =>
440 update($, form, (cur) =>
441 cur?.draftId === draftId ? { ...cur, duplicate, isCheckingDuplicates: false } : cur,
442 );
443 try {
444 const terms = (draft.search ?? []).filter((t) => t.trim() !== "").slice(0, 3);
445 const candidates = await searchOpen($, server, terms.length > 0 ? terms : [draft.title]);
446 if (candidates.length === 0) return await settle(null);
447 const reply = await $.model.complete({
448 model: "sonnet",
449 system: DUPLICATE_SYSTEM,
450 maxTokens: 1000,
451 prompt: [
452 `<draft>\n${draft.title}\n\n${draft.description}\n</draft>`,
453 `<candidates>\n${candidates
454 .map((i) => {
455 const about = oneLine(i.description ?? "").slice(0, CANDIDATE_CHARS);
456 return `${i.id} [${i.team}] ${i.title}${about && `\n ${about}`}`;
457 })
458 .join("\n")}\n</candidates>`,
459 ].join("\n\n"),
460 });
461 if (!reply.isAnswered) return await settle(null);
462 const v = replyJson<Verdict>(reply.text).duplicate;
463 const dup = v && candidates.find((i) => i.id === v.issue);
464 await settle(dup && v ? { issue: dup.id, title: dup.title, comment: v.comment } : null);
465 } catch (error) {
466 $.ui.log(`linear-issue: duplicate check failed: ${errorText(error)}`, { to: "debug" });
467 await settle(null);
468 }
469}
470
471/** Drafts from `brief` and fills the open form. */
472async function fill($: EngineInterface, brief: string) {
473 const fail = (error: string) => patchForm($, { phase: "failed", error });
474 try {
475 const server = await linearServer($);
476 if (!server) return fail("Linear MCP unavailable or signed out, re-authenticate with /mcp");
477 type User = { teams: { name: string; key: string }[] };
478 const user = await call<User>($, server, "get_user", { query: "me" });
479 const teams = user.teams.map((t) => ({ value: t.key, label: `${t.name} (${t.key})` }));
480 const last = (await $.store.get("last").catch(() => undefined)) as Last | undefined;
481 const fallback =
482 [last?.team, FALLBACK_TEAM].find((k) => teams.some((t) => t.value === k)) ??
483 teams[0]?.value ??
484 "";
485 const usual = PRIORITIES.find((p) => p.value === String(last?.priority ?? 4))?.label ?? "Low";
486 const guess = await loadTeam($, server, fallback);
487
488 const reply = await $.model.complete({
489 model: "sonnet",
490 system: SYSTEM,
491 maxTokens: 4000,
492 prompt: [
493 brief,
494 `<defaults>\nteam: ${fallback}\npriority: ${usual}\n</defaults>`,
495 `<teams>\n${teams.map((t) => t.label).join("\n")}\n</teams>`,
496 `<labels>\n${guess.labels.map((l) => l.label).join("\n")}\n</labels>`,
497 ].join("\n\n"),
498 });
499 if (!reply.isAnswered) return fail(`drafting failed: ${reply.reason}`);
500 const d = replyJson<Draft>(reply.text);
501
502 const team = teams.some((t) => t.value === d.team) ? d.team : fallback;
503 const options = await loadTeam($, server, team);
504 const labelIds = (d.labels ?? []).flatMap((name) => {
505 const l = options.labels.find((o) => o.label === name || o.label.endsWith(`/${name}`));
506 return l ? [l.value] : [];
507 });
508 const draftId = (await read($, form))?.draftId;
509 if (!draftId) return;
510 await patchForm($, {
511 phase: "ready",
512 error: null,
513 teams,
514 ...options,
515 team,
516 title: d.title,
517 description: d.description,
518 priority: PRIORITIES.some((p) => p.value === String(d.priority)) ? d.priority : 0,
519 state: options.todo,
520 labelIds,
521 project: "",
522 isCheckingDuplicates: true,
523 });
524 void checkDuplicates($, server, draftId, d);
525 } catch (error) {
526 await fail(errorText(error));
527 }
528}
529
530async function openPane($: EngineInterface) {
531 const opened = await $.ui.open({
532 id: PANE,
533 title: "Linear draft",
534 focus: true,
535 closeOnEscape: true,
536 rows: 30,
537 });
538 await update($, isPaneOpen, () => true);
539 if (!opened.isPlaced) $.ui.toast(`The Linear draft pane can't show yet: ${opened.reason}`);
540}
541
542/**
543 * Opens the draft pane for `seed` and starts drafting into it; one draft at a time.
544 * Call it from the press or command hook itself: a pane opened after that hook returned
545 * counts as unasked and stays hidden below 144 columns.
546 */
547async function start($: EngineInterface, kind: "insight" | "note", seed: string) {
548 const open = await read($, form);
549 if (open && open.phase !== "failed") {
550 await openPane($);
551 $.ui.toast("Reopened your Linear draft; Discard it to start another");
552 return;
553 }
554 const f: Form = {
555 draftId: `${await $.clock.now()}-${Math.random().toString(36).slice(2)}`,
556 phase: "drafting",
557 error: null,
558 source: "",
559 teams: [],
560 statuses: [],
561 labels: [],
562 projects: [],
563 team: "",
564 title: "",
565 titleRevision: 0,
566 description: "",
567 priority: 0,
568 state: "",
569 labelIds: [],
570 project: "",
571 duplicate: null,
572 isCheckingDuplicates: false,
573 };
574 await update($, form, () => f);
575 await openPane($);
576 // Drafting outlives the hook, so it isn't awaited.
577 void brief($, kind, seed)
578 .then(async (b) => {
579 await patchForm($, { source: b.source });
580 await fill($, b.text);
581 })
582 .catch((error) => patchForm($, { phase: "failed", error: errorText(error) }));
583}
584
585async function switchTeam($: EngineInterface, team: string) {
586 const server = await linearServer($);
587 if (!server) return;
588 const options = await loadTeam($, server, team).catch(() => undefined);
589 if (!options) return;
590 await update($, form, (cur) => {
591 if (!cur) return cur;
592 const keep = (ids: string[]) =>
593 ids.flatMap((id) => {
594 const name = cur.labels.find((l) => l.value === id)?.label;
595 const same = options.labels.find((l) => l.label === name);
596 return same ? [same.value] : [];
597 });
598 return {
599 ...cur,
600 team,
601 ...options,
602 state: options.todo,
603 labelIds: keep(cur.labelIds),
604 project: "",
605 };
606 });
607}
608
609/**
610 * Writes the form to Linear (the issue, or the duplicate's comment) and echoes the result;
611 * `open` then opens the filed issue in the browser.
612 */
613async function submit($: EngineInterface, { asComment = false, open = false } = {}) {
614 const f = await read($, form);
615 if (!f || f.phase !== "ready") return;
616 await patchForm($, { phase: "filing", error: null });
617 try {
618 const server = await linearServer($);
619 if (!server) throw new Error("Linear MCP unavailable or signed out, re-authenticate with /mcp");
620 let echo: Filed;
621 if (asComment && f.duplicate) {
622 const r = await call<unknown>($, server, "save_comment", {
623 issueId: f.duplicate.issue,
624 body: f.duplicate.comment,
625 });
626 const url = /https:\/\/linear\.app\/[^"'\s)]+/.exec(JSON.stringify(r))?.[0] ?? "";
627 echo = { key: f.duplicate.issue, title: `commented: ${f.duplicate.title}`, url };
628 } else {
629 const r = await call<{ id?: string; identifier?: string; url?: string }>(
630 $,
631 server,
632 "save_issue",
633 {
634 team: f.team,
635 title: f.title,
636 description: f.source ? `${f.description.trimEnd()}\n\n---\n${f.source}` : f.description,
637 assignee: "me",
638 state: f.state,
639 priority: f.priority,
640 ...(f.labelIds.length > 0 && { labels: f.labelIds }),
641 ...(f.project && { project: f.project }),
642 },
643 );
644 const text = JSON.stringify(r);
645 const key = r.identifier ?? r.id ?? /\b[A-Z][A-Z0-9]+-\d+\b/.exec(text)?.[0] ?? "";
646 const url = r.url ?? /https:\/\/linear\.app\/[^"'\s)]+/.exec(text)?.[0] ?? "";
647 echo = { key, title: f.title, url };
648 await $.store.set("last", { team: f.team, priority: f.priority } satisfies Last);
649 }
650 await update($, filed, (list) => [...list.filter((x) => x.key !== echo.key), echo]);
651 await discard($);
652 if (open && echo.url) await openUrl($, echo.url);
653 } catch (error) {
654 await patchForm($, { phase: "ready", error: errorText(error) });
655 }
656}
657
658/** Drops the draft and closes its pane. */
659async function discard($: EngineInterface) {
660 await update($, isDiscardArmed, () => false);
661 await update($, form, () => null);
662 await $.ui.close({ id: PANE });
663}
664
665async function openUrl($: EngineInterface, url: string) {
666 for (const opener of ["xdg-open", "open"]) {
667 try {
668 if ((await $.process.run([opener, url], { timeoutMs: 5000 })).exitCode === 0) return;
669 } catch {}
670 }
671 $.ui.toast(`Couldn't open ${url}`);
672}
673
674export const register: Register = (on) => {
675 on("session.start", async ($, e, next) => {
676 await $.command
677 .register({
678 name: "linear",
679 description:
680 "Draft a Linear issue from the text after it (or the recent conversation) for approval",
681 argumentHint: "[what to file]",
682 // Filing a thought shouldn't wait for the running turn; nothing here touches it.
683 immediate: true,
684 })
685 .catch((error) =>
686 $.ui.log(`linear-issue: /linear not registered: ${errorText(error)}`, { to: "debug" }),
687 );
688 return next(e);
689 });
690
691 on("command.run", { command: "linear" }, async ($, e) => {
692 if (!(await hasLinear($)))
693 return { text: "No Linear MCP server is configured in this session." };
694 await start($, "note", e.args.trim());
695 return { text: "Drafting a Linear issue in the draft pane." };
696 });
697
698 // Closing only hides the pane: the draft stays for the chip or /linear to reopen.
699 on("ui.close", { id: PANE }, async ($, e, next) => {
700 await update($, isPaneOpen, () => false);
701 return next(e);
702 });
703
704 on("ui.focus", { requestId: PANE }, async ($, e, next) => {
705 await update($, focused, () => e.element ?? null);
706 return next(e);
707 });
708
709 on("ui.press", { plugin: YSK }, async (_$, e, next) => {
710 if (e.element === LEARN_MORE) isExpanding = true;
711 return next(e);
712 });
713
714 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
715 const { Box, Text, Button, Markdown } = $.ui.resolve(e);
716 const f = await read($, form);
717 const focus = await read($, focused);
718 const width = e.props.bodyColumns;
719 // Sized to the pane so the footer stays put; the description takes what is left.
720 const rows = e.props.scroll.bodyRows;
721 // Inline above the prompt a pane gets few rows: drop the title border, rule and source.
722 const compact = rows < COMPACT_ROWS;
723 const rule = compact ? null : (
724 <Text dimColor wrap="truncate-end">
725 {`─ ${HINTS} ${"─".repeat(Math.max(width - HINTS.length - 3, 0))}`}
726 </Text>
727 );
728 const discardButton = (
729 <Button key="discard" label="Discard" role="dismiss" onPress={() => discard($)} />
730 );
731 // A button draws "[ label ]", two columns from the next.
732 const buttonRows = (buttons: RenderElement[]) =>
733 wrappedRows(
734 buttons.map((b) => (isElement(b) && b.type === "Button" ? b.props.label : "").length + 6),
735 width,
736 );
737 // Buttons that need more than a row wrap, and the summary gives up its place to them.
738 const footer = (buttons: RenderElement[], summary: string) => (
739 <Box flexDirection="column">
740 {rule}
741 {buttonRows(buttons) > 1 ? (
742 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
743 {buttons}
744 </Box>
745 ) : (
746 <Box flexDirection="row" justifyContent="space-between">
747 <Box flexDirection="row" columnGap={2} flexShrink={0}>
748 {buttons}
749 </Box>
750 <Box flexShrink={1} marginLeft={2}>
751 <Text dimColor wrap="truncate-end">
752 {summary}
753 </Text>
754 </Box>
755 </Box>
756 )}
757 </Box>
758 );
759 const status = (line: RenderNode) => (
760 <Box flexDirection="column" height={rows}>
761 <Box flexGrow={1}>{line}</Box>
762 {footer([discardButton], "Esc hides the draft")}
763 </Box>
764 );
765 if (!f) return <Text dimColor>No Linear draft open.</Text>;
766 if (f.phase === "drafting") return status(<Text dimColor>Drafting the issue…</Text>);
767 if (f.phase === "failed")
768 return status(<Text color="error">Linear draft failed: {f.error}</Text>);
769
770 const set = (patch: Partial<Form>) => patchForm($, patch);
771 const priority = PRIORITIES.find((p) => p.value === String(f.priority))?.label ?? "No priority";
772 const statusName = f.statuses.find((s) => s.value === f.state)?.label ?? "";
773 const summary = [
774 f.team,
775 statusName,
776 priority,
777 f.labelIds.length > 0 && `${f.labelIds.length} label${f.labelIds.length === 1 ? "" : "s"}`,
778 f.isCheckingDuplicates && "checking for duplicates…",
779 ]
780 .filter(Boolean)
781 .join(" · ");
782 const createButton = (
783 <Button
784 key="create"
785 label={f.phase === "filing" ? "Creating…" : "Create"}
786 variant="primary"
787 onPress={() => submit($)}
788 />
789 );
790 const buttons = [
791 createButton,
792 <Button key="create-open" label="Create & open" onPress={() => submit($, { open: true })} />,
793 f.duplicate && (
794 <Button
795 key="comment"
796 label={`Comment on ${f.duplicate.issue}`}
797 onPress={() => submit($, { asComment: true })}
798 />
799 ),
800 discardButton,
801 ].filter((b): b is RenderElement => Boolean(b));
802 // No fixed height: an Input in a height-capped box stops wrapping when focused and
803 // hides what is typed past its width.
804 const field = (key: string, child: RenderNode, isBordered: boolean) => (
805 <Box
806 key={`field-${key}`}
807 flexDirection="column"
808 paddingX={1}
809 {...(isBordered && { borderStyle: "round" })}
810 {...(focus?.startsWith(key) ? { borderColor: "claude" } : { borderDimColor: true })}
811 >
812 {child}
813 </Box>
814 );
815
816 if (e.surface === "mobile") {
817 // No Input or Select here: the draft as written, to create or discard.
818 return (
819 <Box flexDirection="column">
820 <Text weight="semibold">{f.title}</Text>
821 <Text dimColor>{summary}</Text>
822 <Markdown text={f.description} />
823 {f.error && <Text color="error">{f.error}</Text>}
824 {footer(buttons, "")}
825 </Box>
826 );
827 }
828 const { Input, Select } = $.ui.resolve(e);
829 const chosen = f.labels.filter((l) => f.labelIds.includes(l.value));
830 const selects: [string, string, string, Option[], (v: string) => unknown][] = [
831 ["team", "Team", f.team, f.teams, (team) => switchTeam($, team)],
832 ["priority", "Priority", String(f.priority), PRIORITIES, (p) => set({ priority: Number(p) })],
833 ["status", "Status", f.state, f.statuses, (state) => set({ state })],
834 ["project", "Project", f.project, f.projects, (project) => set({ project })],
835 ];
836 // Each select draws "Label: value ▾", two columns from the next.
837 const selectRows = wrappedRows(
838 selects.map(
839 ([, label, value, options]) =>
840 label.length + (options.find((o) => o.value === value)?.label.length ?? 0) + 6,
841 ),
842 width,
843 );
844 // Columns inside a bordered, padded field.
845 const inner = Math.max(width - 4, 1);
846 // Focused, a title that no longer fits beside the engine's "⏎ submit" hint wraps a row.
847 const titleRows =
848 focus?.startsWith("title") && TITLE_LABEL.length + f.title.length + HINT_WIDTH > inner
849 ? 2
850 : 1;
851 const fixed =
852 (compact ? titleRows : titleRows + 2) + // title field
853 selectRows +
854 (chosen.length > 0 ? 1 : 0) + // labels
855 (f.duplicate ? 2 : 0) +
856 1 + // space above the description
857 (compact ? 0 : 1) + // source
858 (f.error ? 1 : 0) +
859 (compact ? 0 : 1) + // footer rule
860 buttonRows(buttons);
861 // Lines the description wraps to in its box, to say when the box clips it.
862 const descriptionLines = f.description
863 .split("\n")
864 .reduce((n, line) => n + Math.max(1, Math.ceil(line.length / width)), 0);
865 // A focused Select draws its option list under its row, pushing everything below it down.
866 const openList = Math.min(
867 selects.find(([key]) => key === focus)?.[3].length ?? 0,
868 OPEN_LIST_ROWS,
869 );
870 let descriptionRows = Math.max(3, rows - fixed - openList);
871 const isClipped = descriptionLines > descriptionRows;
872 // Compact has no source row, so the clipped note takes one from the description.
873 if (compact && isClipped) descriptionRows = Math.max(3, descriptionRows - 1);
874
875 return (
876 <Box flexDirection="column">
877 {field(
878 "title",
879 // Enter empties an Input unless the hook draws a different value, so a submit
880 // remounts it under a new key, keeping the text and the focus.
881 <Input
882 key={`title-${f.titleRevision ?? 0}`}
883 label="Title"
884 // Keep the default "⏎ submit": with a shorter hint the engine truncates a title just
885 // over one line instead of wrapping it, hiding what is typed (Claude Code 2.1.308).
886 value={f.title}
887 autoFocus
888 onInput={(title) => set({ title: oneLine(title) })}
889 onSubmit={(title) =>
890 set({ title: oneLine(title), titleRevision: (f.titleRevision ?? 0) + 1 })
891 }
892 />,
893 !compact,
894 )}
895 <Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingX={1}>
896 {selects.map(([key, label, value, options, onSelect]) => (
897 <Select
898 key={key}
899 label={label}
900 value={value}
901 options={options.slice(0, SELECT_MAX)}
902 onSelect={onSelect}
903 />
904 ))}
905 </Box>
906 {chosen.length > 0 && (
907 // The drafter's picks; adding is left to Linear, searching labels here worked poorly.
908 <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingX={1}>
909 <Text dimColor>Labels</Text>
910 {chosen.map((l) => (
911 <Button
912 key={`label-${l.value}`}
913 label={`× ${l.label}`}
914 plain
915 onPress={() => set({ labelIds: f.labelIds.filter((id) => id !== l.value) })}
916 />
917 ))}
918 </Box>
919 )}
920 {f.duplicate && (
921 <Box flexDirection="column" paddingX={1}>
922 <Text color="warning" wrap="truncate-end">
923 {f.duplicate.issue} ({f.duplicate.title}) may already cover this; the comment would
924 add:
925 </Text>
926 <Text dimColor wrap="truncate-end">
927 {f.duplicate.comment}
928 </Text>
929 </Box>
930 )}
931 {/* Read-only: in Claude Code 2.1.308 a focused multi-line Input collapses to one line
932 once a line is wider than the field. Make it an Input again when that is fixed. */}
933 {/* Unbordered: borders mark what is editable, and Markdown sizes tables to the pane's
934 width, so a border's columns would break every table row. */}
935 <Box flexDirection="column" marginTop={1} height={descriptionRows} overflow="hidden">
936 {blocks(f.description).map((b) => {
937 // Markdown sizes tables and fences to the terminal, not the pane, so those are
938 // drawn here to the pane's width; Linear renders the markdown itself.
939 if (b.kind === "markdown") return <Markdown text={b.lines.join("\n")} />;
940 if (b.kind === "code") {
941 return (
942 <Box flexDirection="column" paddingLeft={2}>
943 {b.lines.map((l) => (
944 <Text dimColor wrap="truncate-end">
945 {l || " "}
946 </Text>
947 ))}
948 </Box>
949 );
950 }
951 const rows = b.lines.map(cells).filter((r) => !r.every((c) => /^:?-+:?$/.test(c)));
952 const cols = Math.max(...rows.map((r) => r.length));
953 const natural = Array.from({ length: cols }, (_, i) =>
954 Math.max(...rows.map((r) => r[i]?.length ?? 0), 1),
955 );
956 const room = Math.max(width - (cols - 1) * 2, cols);
957 const total = natural.reduce((a, n) => a + n, 0);
958 const widths = natural.map((n) =>
959 total <= room ? n : Math.max(4, Math.floor((room * n) / total)),
960 );
961 return (
962 <Box flexDirection="column" marginY={1}>
963 {rows.map((r, ri) => (
964 <Box flexDirection="row" columnGap={2}>
965 {widths.map((w, ci) => (
966 <Box width={w}>
967 <Text
968 wrap="wrap"
969 dimColor={ri > 0 && ci > 0}
970 weight={ri === 0 ? "semibold" : undefined}
971 >
972 {r[ci] ?? ""}
973 </Text>
974 </Box>
975 ))}
976 </Box>
977 ))}
978 </Box>
979 );
980 })}
981 </Box>
982 {(!compact || isClipped) && (
983 <Box paddingX={1}>
984 <Text dimColor wrap="truncate-end">
985 {isClipped
986 ? "Description continues; it is filed whole, edit it in Linear."
987 : f.source}
988 </Text>
989 </Box>
990 )}
991 {f.error && (
992 <Box paddingX={1}>
993 <Text color="error">{f.error}</Text>
994 </Box>
995 )}
996 {footer(buttons, summary)}
997 </Box>
998 );
999 });
1000
1001 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1002 const tree = await next(e);
1003 if (e.props.hasSurvey) return tree;
1004 const { Box, Text, Button } = $.ui.resolve(e);
1005
1006 const issues = await read($, filed);
1007 const draft = (await read($, isPaneOpen)) ? null : await read($, form);
1008 const isArmed = await read($, isDiscardArmed);
1009 const withBanner = (card: RenderNode) => {
1010 if (issues.length === 0 && !draft) return card as RenderElement;
1011 return (
1012 <Box flexDirection="column">
1013 {card}
1014 {draft && (
1015 <Box key="draft" flexDirection="row">
1016 <Text color="claude">✎ </Text>
1017 <Text wrap="truncate-end">
1018 Linear draft:{" "}
1019 {draft.phase === "drafting" ? "drafting…" : draft.title || "untitled"}{" "}
1020 </Text>
1021 <Box marginLeft={1}>
1022 <Button
1023 key="draft-open"
1024 label="Open"
1025 hotkey="8"
1026 plain
1027 onPress={() => openPane($)}
1028 />
1029 </Box>
1030 <Box marginLeft={1}>
1031 {/* A digit in an empty prompt presses this, so a prompt starting "9" must not discard. */}
1032 <Button
1033 key="draft-discard"
1034 label={isArmed ? "Press 9 again to discard" : "Discard"}
1035 hotkey="9"
1036 plain
1037 onPress={async () => {
1038 if (await read($, isDiscardArmed)) return discard($);
1039 await update($, isDiscardArmed, () => true);
1040 $.clock.after(
1041 DISCARD_ARM_MS,
1042 () => void update($, isDiscardArmed, () => false),
1043 );
1044 }}
1045 />
1046 </Box>
1047 </Box>
1048 )}
1049 {issues.map((i) => (
1050 <Box key={`filed-${i.key}`} flexDirection="row">
1051 <Text color="success">✓ </Text>
1052 <Text wrap="truncate-end">
1053 {i.key} {i.title}{" "}
1054 </Text>
1055 {i.url !== "" && (
1056 <Box marginLeft={1}>
1057 <Button
1058 key={`open-${i.key}`}
1059 label="Open"
1060 plain
1061 onPress={() => openUrl($, i.url)}
1062 />
1063 </Box>
1064 )}
1065 {i.url !== "" && (
1066 <Box marginLeft={1}>
1067 <Button
1068 key={`copy-${i.key}`}
1069 label="Copy link"
1070 plain
1071 onPress={async () => {
1072 const r = await $.ui.copy({ text: i.url });
1073 $.ui.toast(r.isCopied ? `Copied ${i.url}` : `Couldn't copy: ${r.reason}`);
1074 }}
1075 />
1076 </Box>
1077 )}
1078 <Box marginLeft={1}>
1079 <Button
1080 key={`dismiss-${i.key}`}
1081 label="×"
1082 plain
1083 onPress={() => update($, filed, (list) => list.filter((x) => x.key !== i.key))}
1084 />
1085 </Box>
1086 </Box>
1087 ))}
1088 </Box>
1089 );
1090 };
1091
1092 const card = cardText(tree);
1093 if (card !== "" && card === (await read($, hidden))) return withBanner(<Box />);
1094
1095 const anchor = pathTo(tree, (n) => isButton(n, ANCHOR))?.at(-1);
1096 if (!anchor) {
1097 // Past the "One moment…" spinner (no buttons), a card without the anchor means it changed.
1098 if (
1099 isExpanding &&
1100 hasButtons(tree) &&
1101 pathTo(tree, (n) => isButton(n, LEARN_MORE)) === undefined
1102 ) {
1103 isExpanding = false;
1104 await warnDrift($);
1105 }
1106 return withBanner(tree);
1107 }
1108 isExpanding = false;
1109 if (!(await hasLinear($))) return withBanner(tree);
1110
1111 const style = isElement(anchor) && anchor.type === "Button" ? anchor.props : undefined;
1112 const taken = hotkeysIn(tree);
1113 const button = (
1114 <Button
1115 key="linear-issue"
1116 label="Linear issue"
1117 hotkey={HOTKEYS.find((k) => !taken.has(k))}
1118 plain={style?.plain}
1119 dimColor={style?.dimColor}
1120 onPress={async () => {
1121 await update($, hidden, () => card);
1122 await start($, "insight", card);
1123 }}
1124 />
1125 );
1126 return withBanner(insertButton(tree, button) ?? tree);
1127 });
1128};
1129types/index.d.ts 56 lines1/** The text of the card a Linear issue was drafted from, hidden until another card replaces it. */
2export type Hidden = string | null;
3
4/** One choice of a form Select: what Linear is sent, and what the person reads. */
5export type Option = { value: string; label: string };
6
7/** An open issue that already covers the seed, and the comment proposed for it. */
8export type Duplicate = { issue: string; title: string; comment: string };
9
10/** The draft pane's state; null while no draft is open. */
11export type Form = {
12 // Tells a late duplicate check whether its draft is still the one open.
13 draftId: string;
14 phase: "drafting" | "ready" | "filing" | "failed";
15 error: string | null;
16 // Appended to the description when filed: repository, branch and session.
17 source: string;
18 teams: Option[];
19 statuses: Option[];
20 labels: Option[];
21 projects: Option[];
22 team: string;
23 title: string;
24 // Bumped on Enter in the title field, which remounts it (see its render).
25 titleRevision: number;
26 description: string;
27 priority: number;
28 state: string;
29 labelIds: string[];
30 project: string;
31 duplicate: Duplicate | null;
32 isCheckingDuplicates: boolean;
33};
34
35/** What the last filed issue used, the fallback for the next draft. */
36export type Last = { team: string; priority: number };
37
38/** An issue filed this session, echoed above the prompt until dismissed. */
39export type Filed = { key: string; title: string; url: string };
40
41declare module "claude-code" {
42 interface PluginState {
43 "linear-issue": {
44 hidden: Hidden;
45 form: Form | null;
46 filed: Filed[];
47 // Whether the draft pane is shown; a hidden draft gets a chip above the prompt.
48 isPaneOpen: boolean;
49 // The key of the pane element holding the focus ring, for its border.
50 focused: string | null;
51 // Set by the chip's first Discard press; a second within a few seconds discards.
52 isDiscardArmed: boolean;
53 };
54 }
55}
56