A side panel of the files this session created, with Open, Reveal in Finder and Copy path.

Collects new files created during the session in a panel with copy, open and reveal actions.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/output-tray
claude plugin test plugins/output-trayhooks/output-tray.mjs 755 lines1// Output Tray: the files this session CREATED, in a side pane, with Open, Reveal
2// in Finder and Copy path. Nothing is ever opened unless the person presses a
3// button or types a /tray command; the model can show or list the tray, never
4// open a file.
5//
6// Tracking, three ways, all honest about what they saw:
7// - Write / NotebookEdit: the path is checked BEFORE the call (`$.fs.exists`);
8// after `next(e)` it counts only when the call was not denied, not errored,
9// not staged, and the tool itself did not report an update. A Write over an
10// existing file goes to a separate "edited existing" count.
11// - Bash: a snapshot of new files under the session's folder (`git status
12// --porcelain -z --untracked-files=all`, or a bounded `find` outside git)
13// before and after each call; the difference is tagged "shell".
14// - Turn sweep: the same snapshot at turn start and end catches what neither
15// saw (a background command, an MCP tool), tagged "other".
16// A call with `e.agentId` came from a subagent and is tagged so.
17
18import { update } from "claude-code";
19
20const PANE = "output-tray";
21const TITLE = "Output Tray";
22const TOOL = "output_tray";
23const TOOL_FULL = "mcp__output-tray__output_tray";
24const MAX_ITEMS = 300;
25const MAX_MODIFIED = 100;
26const TICK_MS = 15000;
27const SHEBANG_READ_MAX = 1024 * 1024;
28
29const itemsRef = { plugin: "output-tray", key: "items" };
30const metaRef = { plugin: "output-tray", key: "meta" };
31
32const EMPTY_META = { cwd: "", seq: 0, selected: "", modified: [], lastAction: "" };
33
34// Opening these with `open` could run, install, mount or launch something, so
35// Open reveals them in Finder instead.
36const REVEAL_ONLY = new Set([
37 "app", "pkg", "mpkg", "dmg", "iso", "jar", "workflow", "action", "prefpane", "kext", "plugin", "bundle",
38 "saver", "mobileconfig", "shortcut", "exe", "msi", "appex", "framework", "scpt", "scptd", "osax", "service",
39 "qlgenerator", "mdimporter", "xpc", "dylib", "so", "bin", "run", "out", "elf", "deb", "rpm", "appimage",
40]);
41// Scripts and link files: `open` might run them (a .command or .sh opens in
42// Terminal and runs; Python Launcher runs a .py), so Open shows them as text.
43const TEXT_ONLY = new Set([
44 "sh", "bash", "zsh", "fish", "ksh", "csh", "tcsh", "command", "tool", "py", "pyw", "rb", "pl", "php", "lua",
45 "js", "mjs", "cjs", "ts", "tsx", "jsx", "ps1", "psm1", "bat", "cmd", "vbs", "applescript", "terminal",
46 "webloc", "inetloc", "url", "fileloc", "desktop", "swift", "r", "tcl", "awk", "expect",
47]);
48
49const VIA_LABEL = { write: "Write", notebook: "Notebook", shell: "shell", other: "other" };
50
51const HELP = [
52 "Output Tray: the files Claude created in this session.",
53 " /tray open or close the tray panel",
54 " /tray list list the files as text",
55 " /tray open <n> open file n (scripts open as text, apps and installers are revealed instead)",
56 " /tray reveal <n> show file n in Finder",
57 " /tray copy <n> copy file n's full path",
58 " /tray clear forget the list (files on disk are untouched)",
59 " /tray close close the panel",
60 "In the panel: 1-9 pick a row, o Open, r Reveal, c Copy path, f Refresh, x Close. Esc gives the keys back.",
61 "Nothing is ever opened automatically. Claude can show or list the tray, never open a file.",
62].join("\n");
63
64export function register(on) {
65 // Module memory: reset on a hot reload, which only costs one turn sweep.
66 const mem = { inflight: new Set(), roots: new Map(), turnBase: null, ticker: null };
67
68 on("session.start", async ($, e, next) => {
69 const started = await next(e);
70 try {
71 await $.command.register({
72 name: "tray",
73 description: "Output Tray: the files Claude created this session, with Open, Reveal and Copy path",
74 argumentHint: "[list|open <n>|reveal <n>|copy <n>|clear|close|help]",
75 immediate: true,
76 });
77 } catch {
78 // registered by an earlier load
79 }
80 try {
81 await $.tool.register({
82 name: TOOL,
83 description:
84 "Show the user their Output Tray (a side panel listing the files created in this session, newest first, " +
85 "with buttons the USER presses to open or reveal them), or list those files as text. Use when the user asks " +
86 "to see the files you made, the outputs, or the deliverables. action 'show' opens the panel; 'list' returns " +
87 "the list. This tool cannot open files: opening is the user's own action in the panel.",
88 inputSchema: {
89 type: "object",
90 properties: {
91 action: { type: "string", enum: ["show", "list"], description: "show: open the panel. list: return the files as text." },
92 },
93 required: ["action"],
94 },
95 });
96 } catch {
97 // registered by an earlier load
98 }
99 const { value: m = EMPTY_META } = await $.state.get(metaRef);
100 if (e.cwd && m.cwd !== e.cwd) await $.state.set(metaRef, { ...m, cwd: e.cwd });
101 return started;
102 });
103
104 // A /clear ends the conversation; the tray starts over with the next one.
105 on("session.end", async ($, e, next) => {
106 if (e.reason === "clear") {
107 await $.state.set(itemsRef, []);
108 const { value: m = EMPTY_META } = await $.state.get(metaRef);
109 await $.state.set(metaRef, { ...m, selected: "", modified: [], lastAction: "" });
110 }
111 return next(e);
112 });
113
114 // ---- tracking ------------------------------------------------------------------
115
116 on("tool.call", { tool: "Write" }, async ($, e, next) => trackFileTool($, e, next, e.file_path, "write", mem));
117 on("tool.call", { tool: "NotebookEdit" }, async ($, e, next) => trackFileTool($, e, next, e.notebook_path, "notebook", mem));
118
119 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
120 const cwd = await cwdOf($);
121 const before = await snapshot($, cwd, mem.roots);
122 const ran = await next(e);
123 if (!before || ran.deny !== undefined) return ran;
124 try {
125 const after = await snapshot($, cwd, mem.roots);
126 if (after) {
127 const fresh = await newPaths($, before, after, mem.inflight);
128 if (fresh.length) await addItems($, fresh, "shell", e.agentId ?? "");
129 }
130 } catch {
131 // ignore
132 }
133 return ran;
134 });
135
136 on("turn.start", async ($, e, next) => {
137 if (e.agentId == null) {
138 try {
139 mem.turnBase = await snapshot($, await cwdOf($), mem.roots);
140 } catch {
141 mem.turnBase = null;
142 }
143 }
144 return next(e);
145 });
146
147 on("turn.complete", async ($, e, next) => {
148 const result = await next(e);
149 if (e.agentId != null || !mem.turnBase) return result;
150 const base = mem.turnBase;
151 mem.turnBase = null;
152 try {
153 const after = await snapshot($, await cwdOf($), mem.roots);
154 if (after) {
155 const fresh = await newPaths($, base, after, mem.inflight);
156 if (fresh.length) await addItems($, fresh, "other", "");
157 }
158 } catch {
159 // ignore
160 }
161 return result;
162 });
163
164 // ---- the model's tool: show or list, never open ------------------------------------
165
166 on("tool.call", { tool: TOOL_FULL }, async ($, e) => {
167 const action = String(e.action ?? "show").toLowerCase();
168 const { value: items = [] } = await $.state.get(itemsRef);
169 if (action === "list") return { result: await listing($, items) };
170 if (action !== "show") return { result: `Unknown action "${action}". Use "show" or "list".` };
171 let placed = false;
172 try {
173 placed = (await $.ui.open({ id: PANE, title: TITLE })).isPlaced;
174 } catch {
175 placed = false;
176 }
177 ensureTicker($, mem);
178 const count = `${items.length} file${items.length === 1 ? "" : "s"}`;
179 if (!placed) {
180 return { result: `The Output Tray panel could not be placed here, so here is the list instead.\n${await listing($, items)}` };
181 }
182 return {
183 result:
184 `Output Tray is open beside the conversation with ${count}. The user opens or reveals files with its buttons ` +
185 "(or /tray open <n>); you cannot open files with this tool.",
186 };
187 });
188
189 // ---- /tray ---------------------------------------------------------------------------
190
191 on("command.run", { command: "tray" }, async ($, e) => {
192 const args = (e.args ?? "").trim();
193 const [verbRaw = "", nRaw = ""] = args.split(/\s+/);
194 const verb = verbRaw.toLowerCase();
195 const { value: items = [] } = await $.state.get(itemsRef);
196
197 if (verb === "help" || verb === "?") return { text: HELP };
198 if (verb === "list" || verb === "ls") return { text: await listing($, items) };
199
200 if (verb === "clear") {
201 await $.state.set(itemsRef, []);
202 const { value: m = EMPTY_META } = await $.state.get(metaRef);
203 await $.state.set(metaRef, { ...m, selected: "", modified: [], lastAction: "" });
204 return { text: `Output Tray cleared (${items.length} entr${items.length === 1 ? "y" : "ies"} forgotten; files on disk untouched).` };
205 }
206
207 if (verb === "close") {
208 await $.ui.close({ id: PANE });
209 return { text: "Output Tray closed." };
210 }
211
212 if ((verb === "open" || verb === "reveal" || verb === "copy") && nRaw) {
213 const n = Number.parseInt(nRaw, 10);
214 const item = Number.isFinite(n) ? items[n - 1] : undefined;
215 if (!item) return { text: `No file ${nRaw} in the tray (it has ${items.length}). Try /tray list.` };
216 if (verb === "copy") {
217 const copied = await $.ui.copy({ text: item.abs });
218 return { text: copied.isCopied ? `Copied: ${item.abs}` : `Could not copy (${copied.reason}). Path: ${item.abs}` };
219 }
220 const done = verb === "open" ? await openItem($, item) : await revealItem($, item);
221 return { text: done };
222 }
223
224 if (verb !== "" && verb !== "show" && verb !== "open") {
225 return { text: `Unknown /tray option "${verbRaw}".\n${HELP}` };
226 }
227
228 const pane = (await $.ui.panes()).find((p) => p.id === PANE);
229 if (verb === "" && pane && pane.isShown) {
230 await $.ui.close({ id: PANE });
231 return { text: "Output Tray closed." };
232 }
233 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true });
234 ensureTicker($, mem);
235 if (!opened.isPlaced) {
236 return { text: `Output Tray could not be placed (${opened.reason}).\n${await listing($, items)}` };
237 }
238 return {
239 text: `Output Tray open: ${items.length} file${items.length === 1 ? "" : "s"}. Keys 1-9 pick, o open, r reveal, c copy path, x close.`,
240 };
241 });
242
243 // ---- the pane ------------------------------------------------------------------------
244
245 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
246 const { Box, Text, Button } = $.ui.resolve(e);
247 const { value: items = [] } = await $.state.get(itemsRef);
248 const { value: m = EMPTY_META } = await $.state.get(metaRef);
249 const width = Math.max(30, Math.floor(e.props.bodyColumns ?? 60));
250 const now = await $.clock.now();
251 const dim = (children, extra = {}) => Text({ dimColor: true, wrap: "truncate", children, ...extra });
252 ensureTicker($, mem);
253
254 const rows = [];
255 const created = `${items.length} file${items.length === 1 ? "" : "s"} created this session`;
256 const edited = m.modified.length ? ` · ${m.modified.length} existing edited` : "";
257 rows.push(Text({ bold: true, wrap: "truncate", children: "OUTPUT TRAY" }));
258 rows.push(dim(clip(created + edited, width)));
259
260 if (items.length === 0) {
261 rows.push(Text({ children: " " }));
262 rows.push(dim("Nothing created yet. Files Claude makes with Write, notebooks or shell commands show up here, newest first."));
263 rows.push(Box({ flexDirection: "row", gap: 1, children: [closeButton($, Button)] }));
264 return Box({ flexDirection: "column", children: rows });
265 }
266
267 const selectedId = items.some((x) => x.id === m.selected) ? m.selected : items[0].id;
268 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 40) - 12) / 3));
269 const shown = items.slice(0, room);
270 const stats = await Promise.all(shown.map((x) => statOf($, x.abs)));
271
272 rows.push(
273 Box({
274 flexDirection: "row",
275 gap: 1,
276 children: [
277 Button({ key: "act:open", label: "o Open", hotkey: "o", variant: "primary", onPress: () => actOnSelected($, "open") }),
278 Button({ key: "act:reveal", label: "r Reveal", hotkey: "r", onPress: () => actOnSelected($, "reveal") }),
279 Button({ key: "act:copy", label: "c Copy path", hotkey: "c", onPress: (p) => actOnSelected($, "copy", p.surface) }),
280 Button({ key: "act:refresh", label: "f Refresh", hotkey: "f", onPress: () => refresh($) }),
281 closeButton($, Button),
282 ],
283 }),
284 );
285
286 shown.forEach((item, i) => {
287 const st = stats[i];
288 const isSel = item.id === selectedId;
289 const hotkey = i < 9 ? String(i + 1) : undefined;
290 const tag = tagOf(item);
291 const meta = st ? `${sizeOf(st.size)} ${ago(now - item.at)}` : "missing: deleted or moved";
292 rows.push(
293 Box({
294 key: `row:${item.id}`,
295 flexDirection: "row",
296 gap: 1,
297 marginTop: 1,
298 children: [
299 Button({
300 key: `pick:${item.id}`,
301 label: isSel ? "▶" : "·",
302 hotkey,
303 plain: true,
304 onPress: () => select($, item.id),
305 }),
306 Text({ bold: true, color: isSel ? "cyan" : undefined, strikethrough: !st, wrap: "truncate", children: clip(item.name, Math.max(10, width - 34)) }),
307 Text({ color: st ? undefined : "yellow", dimColor: Boolean(st), wrap: "truncate", children: meta }),
308 Text({ color: tagColor(item), wrap: "truncate", children: tag }),
309 ],
310 }),
311 );
312 const buttons = st
313 ? [
314 Button({ key: `open:${item.id}`, label: "Open", dimColor: !isSel, onPress: () => select($, item.id).then(() => openItem($, item)) }),
315 Button({ key: `reveal:${item.id}`, label: "Reveal", dimColor: !isSel, onPress: () => select($, item.id).then(() => revealItem($, item)) }),
316 ]
317 : [];
318 rows.push(
319 Box({
320 flexDirection: "row",
321 gap: 1,
322 children: [Text({ children: " " }), dim(clip(item.folder, Math.max(8, width - 24))), ...buttons],
323 }),
324 );
325 });
326
327 if (items.length > shown.length) rows.push(dim(`+${items.length - shown.length} more: /tray list`));
328
329 const sel = shown.find((x) => x.id === selectedId);
330 if (sel && openPlanByName(sel.abs) !== "open") {
331 rows.push(Text({ children: " " }));
332 rows.push(
333 Text({
334 color: "yellow",
335 wrap: "wrap",
336 children: `${sel.name} is ${openPlanByName(sel.abs) === "text" ? "a script: Open shows it as text and never runs it" : "an app, installer or package: Open reveals it in Finder instead"}.`,
337 }),
338 );
339 }
340 if (m.lastAction) {
341 rows.push(Text({ children: " " }));
342 rows.push(dim(clip(m.lastAction, width * 2), { wrap: "wrap" }));
343 }
344 return Box({ flexDirection: "column", children: rows });
345 });
346
347 on("ui.close", async ($, e, next) => {
348 const r = await next(e);
349 if (e.id === PANE && mem.ticker) {
350 mem.ticker.cancel();
351 mem.ticker = null;
352 }
353 return r;
354 });
355}
356
357// ---- tracking a file tool -----------------------------------------------------------
358
359async function trackFileTool($, e, next, path, via, mem) {
360 if (typeof path !== "string" || !path) return next(e);
361 let existed = true;
362 try {
363 existed = await $.fs.exists(path);
364 } catch {
365 existed = true; // unknown: never claim it was created
366 }
367 const key = keyOf(path);
368 if (!existed) mem.inflight.add(key);
369 let ran;
370 try {
371 ran = await next(e);
372 } catch (err) {
373 mem.inflight.delete(key);
374 throw err;
375 }
376 try {
377 const verdict = writeVerdict(existed, ran);
378 if (verdict === "created") {
379 // NotebookEdit has no create flag: the file must be there now.
380 const there = via === "write" || (await $.fs.exists(path).catch(() => false));
381 if (there) await addItems($, [path], via, e.agentId ?? "");
382 } else if (verdict === "modified") {
383 await noteModified($, path);
384 }
385 } catch {
386 // the tray is a convenience; never fail the tool over it
387 }
388 mem.inflight.delete(key);
389 return ran;
390}
391
392function ensureTicker($, mem) {
393 if (mem.ticker) return;
394 mem.ticker = $.clock.every(TICK_MS, async () => {
395 let up = false;
396 try {
397 up = (await $.ui.panes()).some((p) => p.id === PANE);
398 } catch {
399 up = false;
400 }
401 if (up) $.ui.invalidate("ui.render");
402 else if (mem.ticker) {
403 mem.ticker.cancel();
404 mem.ticker = null;
405 }
406 });
407}
408
409// ---- actions (only ever from a press or a /tray command) -------------------------------
410
411function closeButton($, Button) {
412 return Button({ key: "act:close", label: "x Close", hotkey: "x", role: "dismiss", onPress: () => $.ui.close({ id: PANE }) });
413}
414
415async function select($, id) {
416 await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), selected: id }));
417}
418
419async function refresh($) {
420 $.ui.invalidate("ui.render");
421}
422
423async function actOnSelected($, action, surface) {
424 const { value: items = [] } = await $.state.get(itemsRef);
425 const { value: m = EMPTY_META } = await $.state.get(metaRef);
426 const item = items.find((x) => x.id === m.selected) ?? items[0];
427 if (!item) return;
428 if (action === "open") return openItem($, item);
429 if (action === "reveal") return revealItem($, item);
430 const copied = await $.ui.copy(surface ? { text: item.abs, surface } : { text: item.abs });
431 await noteAction($, copied.isCopied ? `Copied path of ${item.name}` : `Could not copy (${copied.reason}): ${item.abs}`);
432}
433
434async function openItem($, item) {
435 const st = await statOf($, item.abs, true);
436 if (!st) return noteAction($, `${item.name} is missing: deleted or moved.`);
437 const plan = await openPlan($, item.abs, st);
438 const argv = plan === "open" ? ["open", item.abs] : plan === "text" ? ["open", "-t", item.abs] : ["open", "-R", item.abs];
439 const code = await run($, argv);
440 const how =
441 plan === "open"
442 ? `Opened ${item.name}`
443 : plan === "text"
444 ? `Opened ${item.name} as text (a script is never run from the tray)`
445 : `Revealed ${item.name} in Finder (an app, installer or executable is never launched from the tray)`;
446 return noteAction($, code === 0 ? how : `open failed for ${item.name} (exit ${code})`);
447}
448
449async function revealItem($, item) {
450 const st = await statOf($, item.abs);
451 if (!st) return noteAction($, `${item.name} is missing: deleted or moved.`);
452 const code = await run($, ["open", "-R", item.abs]);
453 return noteAction($, code === 0 ? `Revealed ${item.name} in Finder` : `Reveal failed for ${item.name} (exit ${code})`);
454}
455
456async function run($, argv) {
457 let code = -1;
458 try {
459 const r = await $.process.run(argv, { timeoutMs: 10000 });
460 code = r.exitCode;
461 } catch (err) {
462 $.ui.log(`output-tray: ${JSON.stringify(argv)} could not start: ${String(err?.message ?? err)}`, { to: "debug" });
463 return -1;
464 }
465 $.ui.log(`output-tray: ran ${JSON.stringify(argv)} exit ${code}`, { to: "debug" });
466 return code;
467}
468
469async function noteAction($, text) {
470 await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), lastAction: text }));
471 return text;
472}
473
474// What Open does for a path: "open" (its default app), "text" (`open -t`), or
475// "reveal" (`open -R`). Decided at press time from the name, the kind, the
476// execute bit and a shebang.
477async function openPlan($, abs, st) {
478 const byName = openPlanByName(abs);
479 if (byName !== "open") return byName;
480 if (st.realPath && openPlanByName(st.realPath) !== "open") return openPlanByName(st.realPath);
481 if (st.kind !== "file") return "reveal";
482 let isExec = true;
483 try {
484 isExec = (await $.process.run(["test", "-x", abs], { timeoutMs: 5000 })).exitCode === 0;
485 } catch {
486 isExec = true; // unknown: be careful
487 }
488 if (!isExec) return "open";
489 if (st.size <= SHEBANG_READ_MAX) {
490 try {
491 const head = await $.fs.read(abs);
492 if (typeof head === "string" && head.startsWith("#!")) return "text";
493 } catch {
494 // unreadable
495 }
496 }
497 return "reveal";
498}
499
500// The outermost bundle (Hello.app, a .pkg folder) a path sits in, or the path.
501export function bundleOf(abs) {
502 const parts = String(abs).split("/");
503 for (let i = 1; i < parts.length; i++) {
504 const seg = parts[i].toLowerCase();
505 const dot = seg.lastIndexOf(".");
506 if (dot > 0 && REVEAL_ONLY.has(seg.slice(dot + 1))) return parts.slice(0, i + 1).join("/");
507 }
508 return abs;
509}
510
511export function openPlanByName(abs) {
512 const base = basename(abs).toLowerCase();
513 const dot = base.lastIndexOf(".");
514 const ext = dot > 0 ? base.slice(dot + 1) : "";
515 if (REVEAL_ONLY.has(ext)) return "reveal";
516 if (TEXT_ONLY.has(ext)) return "text";
517 return "open";
518}
519
520// ---- tracking helpers -------------------------------------------------------------------
521
522export function writeVerdict(existed, ran) {
523 if (!ran || ran.deny !== undefined || ran.isError) return "none";
524 const r = ran.result;
525 if (r && typeof r === "object" && r.staged) return "none";
526 if (existed) return "modified";
527 if (r && typeof r === "object" && r.type === "update") return "modified";
528 return "created";
529}
530
531async function addItems($, paths, via, agentId) {
532 const { value: m0 = EMPTY_META } = await $.state.get(metaRef);
533 const cwd = m0.cwd || (await $.session.cwd().catch(() => ""));
534 const now = await $.clock.now();
535 const fresh = [];
536 let seq = m0.seq;
537 for (const abs of paths) {
538 const st = await statOf($, abs);
539 if (st && st.kind !== "file" && bundleOf(abs) !== abs) continue;
540 seq += 1;
541 fresh.push(makeItem(abs, cwd, { id: `f${seq}`, via, agentId, at: now, size: st ? st.size : 0 }));
542 }
543 if (!fresh.length) return;
544 await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), seq: Math.max(seq, (m ?? EMPTY_META).seq), cwd: (m ?? EMPTY_META).cwd || cwd }));
545 const keys = new Set(fresh.map((x) => x.key));
546 const newestFirst = [...fresh].reverse();
547 await update($, itemsRef, (list) => [...newestFirst, ...(list ?? []).filter((x) => !keys.has(x.key))].slice(0, MAX_ITEMS));
548 await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), selected: newestFirst[0].id }));
549}
550
551async function noteModified($, path) {
552 const { value: m0 = EMPTY_META } = await $.state.get(metaRef);
553 const cwd = m0.cwd || "";
554 const shown = relOf(path, cwd) ?? shortHome(path);
555 await update($, metaRef, (m) => {
556 const cur = m ?? EMPTY_META;
557 if (cur.modified.includes(shown)) return cur;
558 return { ...cur, modified: [...cur.modified, shown].slice(-MAX_MODIFIED) };
559 });
560}
561
562export function makeItem(abs, cwd, { id, via, agentId, at, size }) {
563 const rel = relOf(abs, cwd);
564 const name = basename(abs);
565 const dir = rel === null ? dirname(shortHome(abs)) : dirname(rel);
566 const folder = rel === null ? `${dir}/` : dir ? `${dir}/` : "./ (project root)";
567 return { id, key: keyOf(abs), abs, rel: rel ?? shortHome(abs), name, folder, via, agentId, at, size };
568}
569
570export function tagOf(item) {
571 const base = VIA_LABEL[item.via] ?? item.via;
572 return item.agentId ? `subagent ${base}` : base;
573}
574
575function tagColor(item) {
576 if (item.agentId) return "magenta";
577 if (item.via === "shell") return "yellow";
578 if (item.via === "other") return "gray";
579 return "green";
580}
581
582// New file paths in `after` that were not in `before`, not a Write in flight,
583// and not already in the tray.
584async function newPaths($, before, after, inflight) {
585 const { value: items = [] } = await $.state.get(itemsRef);
586 const known = new Set(items.map((x) => x.key));
587 const out = [];
588 for (const [key, abs] of after) {
589 if (before.has(key) || inflight.has(key) || known.has(key)) continue;
590 if (/(^|\/)\.DS_Store$/.test(abs)) continue;
591 const top = bundleOf(abs);
592 if (top !== abs && (known.has(keyOf(top)) || out.includes(top))) continue;
593 out.push(top);
594 }
595 return out.sort();
596}
597
598// A snapshot of the files under `cwd` a creation would add: git's untracked and
599// newly added files, or a bounded listing outside git. Map of key -> path, or
600// null when it cannot be taken cheaply (then nothing is guessed).
601async function snapshot($, cwd, roots) {
602 if (!cwd) return null;
603 try {
604 let root = roots.get(cwd);
605 if (root === undefined) {
606 const r = await $.process.run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 5000 });
607 root = r.exitCode === 0 ? r.stdout.trim() : "";
608 roots.set(cwd, root);
609 }
610 if (root) {
611 const r = await $.process.run(
612 ["git", "-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
613 { cwd, timeoutMs: 8000 },
614 );
615 if (r.exitCode !== 0 || r.isStdoutTruncated) return null;
616 return parsePorcelain(r.stdout, root);
617 }
618 } catch {
619 return null;
620 }
621 const home = (await $.env.get("HOME").catch(() => "")) ?? "";
622 if (cwd === "/" || (home && keyOf(cwd) === keyOf(home))) return null;
623 try {
624 const r = await $.process.run(
625 ["find", cwd, "-maxdepth", "6", "(", "-name", ".git", "-o", "-name", "node_modules", ")", "-prune", "-o", "-type", "f", "-print0"],
626 { cwd, timeoutMs: 5000 },
627 );
628 if (r.exitCode !== 0 || r.isStdoutTruncated) return null;
629 const out = new Map();
630 for (const p of r.stdout.split("\0")) if (p) out.set(keyOf(p), p);
631 return out;
632 } catch {
633 return null;
634 }
635}
636
637export function parsePorcelain(stdout, root) {
638 const out = new Map();
639 const parts = stdout.split("\0");
640 for (let i = 0; i < parts.length; i++) {
641 const entry = parts[i];
642 if (entry.length < 4) continue;
643 const xy = entry.slice(0, 2);
644 const p = entry.slice(3);
645 if (xy[0] === "R" || xy[0] === "C") i += 1; // the original path follows
646 if (xy === "??" || xy[0] === "A") {
647 const abs = `${root.replace(/\/$/, "")}/${p}`;
648 out.set(keyOf(abs), abs);
649 }
650 }
651 return out;
652}
653
654async function cwdOf($) {
655 const { value: m = EMPTY_META } = await $.state.get(metaRef);
656 if (m.cwd) return m.cwd;
657 try {
658 return await $.session.cwd();
659 } catch {
660 return "";
661 }
662}
663
664async function statOf($, abs, resolve = false) {
665 try {
666 return await $.fs.stat(abs, resolve ? { resolve: true } : undefined);
667 } catch {
668 return null;
669 }
670}
671
672async function listing($, items) {
673 const { value: m = EMPTY_META } = await $.state.get(metaRef);
674 if (items.length === 0) {
675 const edited = m.modified.length ? ` (${m.modified.length} existing file${m.modified.length === 1 ? "" : "s"} edited)` : "";
676 return `Output Tray: no files created yet this session${edited}.`;
677 }
678 const now = await $.clock.now();
679 const lines = [`Output Tray: ${items.length} file${items.length === 1 ? "" : "s"} created this session (newest first)`];
680 let i = 0;
681 for (const item of items) {
682 i += 1;
683 const st = await statOf($, item.abs);
684 const state = st ? `${sizeOf(st.size)}, ${ago(now - item.at)}` : "missing: deleted or moved";
685 lines.push(`${String(i).padStart(2)}. ${item.rel} [${tagOf(item)}] ${state}`);
686 }
687 if (m.modified.length) lines.push(`Also edited ${m.modified.length} existing file${m.modified.length === 1 ? "" : "s"}: ${m.modified.slice(-8).join(", ")}`);
688 lines.push("Open one with /tray open <n> or /tray reveal <n>.");
689 return lines.join("\n");
690}
691
692// ---- paths and formatting ---------------------------------------------------------------
693
694// A comparison key: NFC, macOS /private aliases folded, segments normalized.
695export function keyOf(path) {
696 let s = String(path).normalize("NFC");
697 s = s.replace(/^\/private(?=\/(tmp|var|etc)(\/|$))/, "");
698 const abs = s.startsWith("/");
699 const parts = [];
700 for (const seg of s.split("/")) {
701 if (seg === "" || seg === ".") continue;
702 if (seg === "..") parts.pop();
703 else parts.push(seg);
704 }
705 return (abs ? "/" : "") + parts.join("/");
706}
707
708export function relOf(abs, cwd) {
709 if (!cwd) return null;
710 const a = keyOf(abs);
711 const c = keyOf(cwd);
712 if (a === c) return "";
713 if (!a.startsWith(`${c}/`)) return null;
714 // keep the original spelling of the tail (only the prefix was folded)
715 return a.slice(c.length + 1);
716}
717
718function basename(p) {
719 const s = String(p).replace(/\/+$/, "");
720 return s.slice(s.lastIndexOf("/") + 1);
721}
722
723function dirname(p) {
724 const s = String(p).replace(/\/+$/, "");
725 const i = s.lastIndexOf("/");
726 return i < 0 ? "" : i === 0 ? "/" : s.slice(0, i);
727}
728
729function shortHome(p) {
730 return String(p).replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
731}
732
733export function sizeOf(n) {
734 if (!Number.isFinite(n)) return "?";
735 if (n < 1024) return `${n} B`;
736 if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
737 return `${(n / 1024 / 1024).toFixed(1)} MB`;
738}
739
740export function ago(ms) {
741 const s = Math.max(0, Math.round(ms / 1000));
742 if (s < 10) return "just now";
743 if (s < 60) return `${s}s ago`;
744 const m = Math.round(s / 60);
745 if (m < 60) return `${m}m ago`;
746 const h = Math.round(m / 60);
747 if (h < 48) return `${h}h ago`;
748 return `${Math.round(h / 24)}d ago`;
749}
750
751function clip(s, n) {
752 const t = String(s);
753 return t.length <= n ? t : `${t.slice(0, Math.max(0, n - 1))}…`;
754}
755types/index.d.ts 32 lines1export type OutputTrayVia = "write" | "notebook" | "shell" | "other";
2
3export type OutputTrayItem = {
4 id: string;
5 key: string;
6 abs: string;
7 rel: string;
8 name: string;
9 folder: string;
10 via: OutputTrayVia;
11 agentId: string;
12 at: number;
13 size: number;
14};
15
16export type OutputTrayMeta = {
17 cwd: string;
18 seq: number;
19 selected: string;
20 modified: string[];
21 lastAction: string;
22};
23
24declare module "claude-code" {
25 interface PluginState {
26 "output-tray": {
27 items: OutputTrayItem[];
28 meta: OutputTrayMeta;
29 };
30 }
31}
32