Browse .blaze/<change_id>/ metadata (plans, tickets) for the jj working copy and its ancestors

hooks/register.tsx 522 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type {
5 BlazeArtifact,
6 BlazeChange,
7 BlazeDoc,
8 BlazeTab,
9 BlazeView,
10} from "../types";
11import {
12 LOG_TEMPLATE,
13 artifactTitle,
14 chunkMarkdown,
15 compareTypes,
16 field,
17 parseFrontmatter,
18 parseLog,
19 statusColor,
20} from "./lib";
21
22const PANE = "blaze";
23const TITLE = "Blaze";
24const TABS: { tab: BlazeTab; label: string; type: string | null }[] = [
25 { tab: "plan", label: "PLAN", type: "plans" },
26 { tab: "tickets", label: "TICKETS", type: "tickets" },
27 { tab: "diff", label: "DIFF", type: null },
28];
29
30const view = atom(
31 { plugin: "blaze", key: "view" } as const,
32 {
33 root: null,
34 changes: [],
35 error: null,
36 } as BlazeView,
37);
38const selectedChange = atom(
39 { plugin: "blaze", key: "selectedChange" } as const,
40 null as string | null,
41);
42const tab = atom({ plugin: "blaze", key: "tab" } as const, "plan" as BlazeTab);
43const selectedArtifact = atom(
44 { plugin: "blaze", key: "selectedArtifact" } as const,
45 null as string | null,
46);
47const doc = atom(
48 { plugin: "blaze", key: "doc" } as const,
49 null as BlazeDoc | null,
50);
51
52let inFlight: Promise<void> | null = null;
53let queued = false;
54
55async function jj($: EngineInterface, args: string[]): Promise<string> {
56 const ran = await $.process.run([
57 "jj",
58 "--ignore-working-copy",
59 "--color",
60 "never",
61 ...args,
62 ]);
63 if (ran.exitCode !== 0)
64 throw new Error(ran.stderr.trim() || `jj exited ${ran.exitCode}`);
65 return ran.stdout;
66}
67
68async function listDir($: EngineInterface, path: string) {
69 try {
70 return await $.fs.list(path);
71 } catch {
72 return [];
73 }
74}
75
76async function loadArtifacts(
77 $: EngineInterface,
78 dir: string,
79): Promise<BlazeArtifact[]> {
80 const types = (await listDir($, dir))
81 .filter((entry) => entry.kind === "dir")
82 .map((entry) => entry.name)
83 .sort(compareTypes);
84
85 const artifacts: BlazeArtifact[] = [];
86 for (const type of types) {
87 const files = (await listDir($, `${dir}/${type}`))
88 .filter((entry) => entry.kind === "file" && /\.md$/i.test(entry.name))
89 .map((entry) => entry.name)
90 .sort();
91 for (const name of files) {
92 const path = `${dir}/${type}/${name}`;
93 const parsed = parseFrontmatter(await $.fs.read(path).catch(() => ""));
94 artifacts.push({
95 type,
96 name,
97 path,
98 title: artifactTitle(type, name, parsed),
99 status: field(parsed, "status"),
100 });
101 }
102 }
103 return artifacts;
104}
105
106async function scan($: EngineInterface): Promise<BlazeView> {
107 // .blaze lives in the root workspace so every secondary workspace shares the same metadata.
108 let root: string;
109 try {
110 root = (await jj($, ["workspace", "root", "--name", "default"])).trim();
111 } catch {
112 try {
113 root = (await jj($, ["workspace", "root"])).trim();
114 } catch (err) {
115 return {
116 root: null,
117 changes: [],
118 error: `Not a jj repo: ${(err as Error).message}`,
119 };
120 }
121 }
122
123 const blazeDir = `${root}/.blaze`;
124 const dirs = new Set(
125 (await listDir($, blazeDir))
126 .filter((entry) => entry.kind === "dir")
127 .map((entry) => entry.name),
128 );
129
130 let log;
131 try {
132 log = parseLog(
133 await jj($, [
134 "log",
135 "--no-graph",
136 "-r",
137 "::@ & mutable()",
138 "-T",
139 LOG_TEMPLATE,
140 ]),
141 );
142 } catch (err) {
143 return { root, changes: [], error: (err as Error).message };
144 }
145
146 const changes: BlazeChange[] = [];
147 for (const entry of log) {
148 const artifacts = dirs.has(entry.changeId)
149 ? await loadArtifacts($, `${blazeDir}/${entry.changeId}`)
150 : [];
151 changes.push({ ...entry, artifacts });
152 }
153 return { root, changes, error: null };
154}
155
156function tabType(t: BlazeTab): string | null {
157 return TABS.find((x) => x.tab === t)?.type ?? null;
158}
159
160function defaultArtifact(
161 change: BlazeChange | undefined,
162 type: string | null,
163): string | null {
164 if (!change || type === null) return null;
165 return change.artifacts.find((a) => a.type === type)?.path ?? null;
166}
167
168function resolveArtifact(
169 change: BlazeChange | undefined,
170 t: BlazeTab,
171 current: string | null,
172): string | null {
173 const type = tabType(t);
174 const hit = change?.artifacts.find((a) => a.path === current);
175 if (hit !== undefined && (type === null || hit.type === type))
176 return hit.path;
177 return defaultArtifact(change, type);
178}
179
180async function loadDoc($: EngineInterface, path: string | null) {
181 if (path === null) {
182 await update($, doc, () => null);
183 return;
184 }
185 const text = await $.fs
186 .read(path)
187 .catch((err) => `_Could not read ${path}: ${(err as Error).message}_`);
188 await update($, doc, () => ({ path, text }));
189}
190
191async function refreshOnce($: EngineInterface) {
192 const next = await scan($);
193 await update($, view, () => next);
194
195 const currentChange = await read($, selectedChange);
196 const change =
197 next.changes.find((c) => c.changeId === currentChange) ?? next.changes[0];
198 await update($, selectedChange, () => change?.changeId ?? null);
199
200 const artifact = resolveArtifact(
201 change,
202 await read($, tab),
203 await read($, selectedArtifact),
204 );
205 await update($, selectedArtifact, () => artifact);
206 await loadDoc($, artifact);
207}
208
209function refresh($: EngineInterface): Promise<void> {
210 if (inFlight) {
211 queued = true;
212 return inFlight;
213 }
214 inFlight = (async () => {
215 try {
216 do {
217 queued = false;
218 await refreshOnce($);
219 } while (queued);
220 } finally {
221 inFlight = null;
222 }
223 })();
224 return inFlight;
225}
226
227async function selectChange($: EngineInterface, changeId: string) {
228 await update($, selectedChange, () => changeId);
229 const { changes } = await read($, view);
230 const artifact = defaultArtifact(
231 changes.find((c) => c.changeId === changeId),
232 tabType(await read($, tab)),
233 );
234 await update($, selectedArtifact, () => artifact);
235 await loadDoc($, artifact);
236}
237
238async function selectTab($: EngineInterface, next: BlazeTab) {
239 await update($, tab, () => next);
240 const { changes } = await read($, view);
241 const changeId = await read($, selectedChange);
242 const change = changes.find((c) => c.changeId === changeId) ?? changes[0];
243 const artifact = resolveArtifact(
244 change,
245 next,
246 await read($, selectedArtifact),
247 );
248 await update($, selectedArtifact, () => artifact);
249 await loadDoc($, artifact);
250}
251
252async function selectArtifact($: EngineInterface, path: string) {
253 await update($, selectedArtifact, () => path);
254 await loadDoc($, path);
255}
256
257export const register: Register = (on) => {
258 on("session.start", async ($, e, next) => {
259 const started = await next(e);
260 await $.command.register({
261 name: "blaze",
262 description:
263 "Show .blaze change metadata for the working copy and its ancestors",
264 });
265 await refresh($);
266 if ((await read($, view)).changes.length > 0)
267 void $.ui.open({ id: PANE, title: TITLE });
268 return started;
269 });
270
271 on("command.run", { command: "blaze" }, async ($) => {
272 await refresh($);
273 await $.ui.open({ id: PANE, title: TITLE });
274 return { text: "Blaze pane opened." };
275 });
276
277 on("tool.call", { tool: "Write" }, async ($, e, next) => {
278 const ran = await next(e);
279 if (e.file_path.includes("/.blaze/")) void refresh($);
280 return ran;
281 });
282
283 on("tool.call", { tool: "Edit" }, async ($, e, next) => {
284 const ran = await next(e);
285 if (e.file_path.includes("/.blaze/")) void refresh($);
286 return ran;
287 });
288
289 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
290 const ran = await next(e);
291 if (/\bjj\b|\.blaze/.test(e.command)) void refresh($);
292 return ran;
293 });
294
295 on("turn.complete", async ($, e, next) => {
296 const done = await next(e);
297 void refresh($);
298 return done;
299 });
300
301 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
302 const ui = $.ui.resolve(e);
303 const { Box, Text, Button, Markdown } = ui;
304 // The mobile runtime table carries a Select that draws nothing; its documented table has none.
305 const Select =
306 e.surface !== "mobile" && "Select" in ui ? ui.Select : undefined;
307 const current = await read($, view);
308 const changeId = await read($, selectedChange);
309 const activeTab = await read($, tab);
310 const artifactPath = await read($, selectedArtifact);
311 const shown = await read($, doc);
312
313 const refreshButton = (
314 <Button key="refresh" label="↻" plain onPress={() => void refresh($)} />
315 );
316
317 if (current.error !== null) {
318 return (
319 <Box flexDirection="column" gap={1}>
320 <Text color="error">{current.error}</Text>
321 {refreshButton}
322 </Box>
323 );
324 }
325 if (current.changes.length === 0) {
326 return (
327 <Box flexDirection="column" gap={1}>
328 <Text dimColor>No mutable changes at @.</Text>
329 {refreshButton}
330 </Box>
331 );
332 }
333
334 const change =
335 current.changes.find((c) => c.changeId === changeId) ??
336 current.changes[0];
337 if (change === undefined) return <Text dimColor>No change selected.</Text>;
338 const artifact = change.artifacts.find((a) => a.path === artifactPath);
339 const ofType = (type: string) =>
340 change.artifacts.filter((a) => a.type === type);
341 const shortId = change.shortId;
342
343 const changePicker =
344 Select === undefined ? (
345 <Text bold>{label(change)}</Text>
346 ) : (
347 <Select
348 key="change"
349 value={change.changeId}
350 options={current.changes.map((c) => ({
351 value: c.changeId,
352 label: label(c),
353 }))}
354 onSelect={(value: string) => void selectChange($, value)}
355 />
356 );
357
358 const tabBar = (
359 <Box flexDirection="row" gap={1}>
360 {TABS.map((t) => {
361 const active = t.tab === activeTab;
362 const count = t.type === null ? 0 : ofType(t.type).length;
363 const text = count > 0 ? `${t.label} ${count}` : t.label;
364 // Brackets drawn by hand: the Button chrome pads them as `[ label ]` and can't be coloured.
365 return active ? (
366 <Text
367 key={`tab-${t.tab}`}
368 color="suggestion"
369 bold
370 >{`>${text}<`}</Text>
371 ) : (
372 <Button
373 key={`tab-${t.tab}`}
374 label={`[${text}]`}
375 plain
376 dimColor
377 onPress={() => void selectTab($, t.tab)}
378 />
379 );
380 })}
381 </Box>
382 );
383
384 const header = (
385 <Box flexDirection="row" gap={2}>
386 {refreshButton}
387 {changePicker}
388 </Box>
389 );
390 if (change.artifacts.length === 0) return header;
391
392 const body =
393 activeTab === "plan"
394 ? renderPlans()
395 : activeTab === "tickets"
396 ? renderTickets()
397 : renderDiff();
398
399 return (
400 <Box flexDirection="column" gap={1}>
401 {header}
402 {tabBar}
403 {body}
404 </Box>
405 );
406
407 function renderPlans() {
408 const plans = ofType("plans");
409 if (plans.length === 0)
410 return <Text dimColor>No plans/ for {shortId}</Text>;
411 return (
412 <Box flexDirection="column" gap={1}>
413 {plans.length > 1 &&
414 (Select === undefined ? (
415 <Box flexDirection="column">{plans.map(row)}</Box>
416 ) : (
417 <Select
418 key="plan"
419 label="PLAN"
420 value={
421 artifact?.type === "plans"
422 ? artifact.path
423 : (plans[0]?.path ?? "")
424 }
425 options={plans.map((p) => ({ value: p.path, label: p.title }))}
426 onSelect={(value: string) => void selectArtifact($, value)}
427 />
428 ))}
429 {selectedDoc("plans")}
430 </Box>
431 );
432 }
433
434 function renderTickets() {
435 const tickets = ofType("tickets");
436 if (tickets.length === 0)
437 return <Text dimColor>No tickets/ for {shortId}</Text>;
438 return (
439 <Box flexDirection="column" gap={1}>
440 <Box flexDirection="column">{tickets.map(row)}</Box>
441 {selectedDoc("tickets")}
442 </Box>
443 );
444 }
445
446 function renderDiff() {
447 return <Text dimColor>Not implemented yet.</Text>;
448 }
449
450 function row(a: BlazeArtifact) {
451 return (
452 <Box key={`row-${a.path}`} flexDirection="row" gap={1}>
453 <Text color={a.path === artifactPath ? "suggestion" : undefined}>
454 {a.path === artifactPath ? "›" : " "}
455 </Text>
456 <Button
457 key={`open-${a.path}`}
458 label={a.title}
459 plain
460 onPress={() => void selectArtifact($, a.path)}
461 />
462 {a.status !== null && (
463 <Text color={statusColor(a.status)}>[{a.status}]</Text>
464 )}
465 </Box>
466 );
467 }
468
469 function selectedDoc(type: string) {
470 if (
471 artifact === undefined ||
472 artifact.type !== type ||
473 shown === null ||
474 shown.path !== artifact.path
475 )
476 return null;
477 return (
478 <Box
479 flexDirection="column"
480 borderStyle="single"
481 borderDimColor
482 paddingX={1}
483 >
484 <Box position="absolute" top={-1} left={0}>
485 <Text dimColor>{` ${artifact.type}/${artifact.name} `}</Text>
486 </Box>
487 {renderDoc(artifact, shown.text)}
488 </Box>
489 );
490 }
491
492 function label(c: BlazeChange): string {
493 const desc = c.description || "(no description)";
494 return `${c.isWorkingCopy ? "@ " : ""}${c.shortId} ${desc}`;
495 }
496
497 function renderDoc(a: BlazeArtifact, text: string) {
498 const parsed = parseFrontmatter(text);
499 const extra = parsed.fields.filter(
500 ([k]) => !(a.type === "tickets" && (k === "title" || k === "status")),
501 );
502 return (
503 <Box flexDirection="column" gap={1}>
504 {extra.length > 0 && (
505 <Box flexDirection="column">
506 {extra.map(([k, v]) => (
507 <Text key={`field-${k}`}>
508 <Text dimColor>{k}: </Text>
509 {Array.isArray(v) ? v.join(", ") : v}
510 </Text>
511 ))}
512 </Box>
513 )}
514 {chunkMarkdown(parsed.body).map((chunk, i) => (
515 <Markdown key={`md-${i}`} text={chunk} />
516 ))}
517 </Box>
518 );
519 }
520 });
521};
522hooks/lib.ts 138 lines1export type LogEntry = { changeId: string; shortId: string; isWorkingCopy: boolean; description: string }
2
3export type FieldValue = string | string[]
4
5export type Parsed = { fields: [string, FieldValue][]; body: string }
6
7export const LOG_TEMPLATE =
8 'change_id ++ "\\t" ++ change_id.shortest(8) ++ "\\t" ++ if(current_working_copy, "@", "") ++ "\\t" ++ description.first_line() ++ "\\n"'
9
10const TYPE_ORDER = ['plans', 'tickets']
11
12export function parseLog(stdout: string): LogEntry[] {
13 return stdout
14 .split('\n')
15 .filter(line => line.length > 0)
16 .map(line => {
17 const [changeId = '', shortId = '', mark = '', ...rest] = line.split('\t')
18 return { changeId, shortId, isWorkingCopy: mark === '@', description: rest.join('\t') }
19 })
20 .filter(entry => entry.changeId.length > 0)
21}
22
23function scalar(raw: string): string {
24 const v = raw.trim()
25 if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
26 return v.slice(1, -1)
27 }
28 return v
29}
30
31export function parseFrontmatter(text: string): Parsed {
32 const normalized = text.replace(/\r\n/g, '\n')
33 const match = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(normalized)
34 if (!match) return { fields: [], body: normalized }
35
36 const fields: [string, FieldValue][] = []
37 for (const line of (match[1] ?? '').split('\n')) {
38 const item = /^\s+-\s+(.*)$/.exec(line)
39 if (item) {
40 const value = scalar(item[1] ?? '')
41 const last = fields[fields.length - 1]
42 if (last) {
43 const prev = last[1]
44 last[1] = Array.isArray(prev)
45 ? [...prev, value]
46 : prev === ''
47 ? [value]
48 : [prev, value]
49 }
50 continue
51 }
52 const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
53 const key = kv?.[1]
54 if (kv === null || key === undefined) continue
55 const raw = (kv[2] ?? '').trim()
56 if (raw.startsWith('[') && raw.endsWith(']')) {
57 const inner = raw.slice(1, -1).trim()
58 fields.push([key, inner === '' ? [] : inner.split(',').map(scalar)])
59 } else {
60 fields.push([key, scalar(raw)])
61 }
62 }
63 return { fields, body: normalized.slice(match[0].length) }
64}
65
66export function field(parsed: Parsed, key: string): string | null {
67 const hit = parsed.fields.find(([k]) => k === key)
68 if (!hit) return null
69 return Array.isArray(hit[1]) ? hit[1].join(', ') : hit[1]
70}
71
72export function stem(name: string): string {
73 return name.replace(/\.md$/i, '')
74}
75
76export function artifactTitle(type: string, name: string, parsed: Parsed): string {
77 if (type === 'plans') return name
78 return field(parsed, 'title') ?? stem(name)
79}
80
81export function compareTypes(a: string, b: string): number {
82 const ia = TYPE_ORDER.indexOf(a)
83 const ib = TYPE_ORDER.indexOf(b)
84 if (ia !== -1 || ib !== -1) return (ia === -1 ? TYPE_ORDER.length : ia) - (ib === -1 ? TYPE_ORDER.length : ib)
85 return a.localeCompare(b)
86}
87
88// Markdown elements cap at 10000 characters; prefer cutting at blank lines outside code fences,
89// and close/reopen a fence when a forced cut lands inside one.
90export function chunkMarkdown(body: string, soft = 8000, hard = 9500): string[] {
91 const chunks: string[] = []
92 let current: string[] = []
93 let size = 0
94 let fence: string | null = null
95
96 const flush = () => {
97 if (current.length === 0) return
98 if (fence !== null) current.push(fence)
99 chunks.push(current.join('\n'))
100 current = fence !== null ? [fence] : []
101 size = fence !== null ? fence.length + 1 : 0
102 }
103
104 for (const whole of body.split('\n')) {
105 const pieces: string[] = []
106 for (let i = 0; i < Math.max(whole.length, 1); i += hard) pieces.push(whole.slice(i, i + hard))
107 for (const line of pieces) {
108 if (size + line.length + 1 > hard) flush()
109 const marker = /^\s*(```+|~~~+)/.exec(line)?.[1]
110 if (marker !== undefined) fence = fence === null ? marker : null
111 current.push(line)
112 size += line.length + 1
113 if (fence === null && line.trim() === '' && size >= soft) flush()
114 }
115 }
116 const tail = current.join('\n')
117 if (tail.trim().length > 0 || chunks.length === 0) chunks.push(tail)
118 return chunks
119}
120
121export function statusColor(status: string): string | undefined {
122 switch (status.toLowerCase()) {
123 case 'done':
124 case 'complete':
125 case 'completed':
126 return 'success'
127 case 'doing':
128 case 'in_progress':
129 case 'in-progress':
130 case 'active':
131 return 'warning'
132 case 'blocked':
133 return 'error'
134 default:
135 return undefined
136 }
137}
138types/index.d.ts 41 lines1export type BlazeArtifact = {
2 type: string
3 name: string
4 path: string
5 title: string
6 status: string | null
7}
8
9export type BlazeChange = {
10 changeId: string
11 shortId: string
12 isWorkingCopy: boolean
13 description: string
14 artifacts: BlazeArtifact[]
15}
16
17export type BlazeView = {
18 root: string | null
19 changes: BlazeChange[]
20 error: string | null
21}
22
23export type BlazeTab = 'plan' | 'tickets' | 'diff'
24
25export type BlazeDoc = {
26 path: string
27 text: string
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 blaze: {
33 view: BlazeView
34 selectedChange: string | null
35 tab: BlazeTab
36 selectedArtifact: string | null
37 doc: BlazeDoc | null
38 }
39 }
40}
41