@join-pantheon/cli 0.24.1 → 0.24.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +76 -0
- package/dist/decision-ui.js +14 -1
- package/dist/humanize.js +223 -0
- package/dist/notifier.js +19 -5
- package/dist/relay.js +11 -0
- package/dist/tray.js +29 -4
- package/helper/macos/main.swift +31 -1
- package/helper/macos/menubar.swift +577 -17
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -331,6 +331,42 @@ async function fetchHistory(cfg, since = 0) {
|
|
|
331
331
|
throw new Error(`relay answered ${res.status} for the ledger`);
|
|
332
332
|
return (await res.json()).acts ?? [];
|
|
333
333
|
}
|
|
334
|
+
async function fetchAct(cfg, id) {
|
|
335
|
+
const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
336
|
+
const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(8000) });
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
const body = await res.json();
|
|
341
|
+
if (body.act)
|
|
342
|
+
return { act: body.act, ...(body.goal?.title ? { goalTitle: body.goal.title } : {}) };
|
|
343
|
+
}
|
|
344
|
+
if (res.status === 404) {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch { }
|
|
348
|
+
let head = 0;
|
|
349
|
+
try {
|
|
350
|
+
const h = await (await fetch(`${base}/health?space=${encodeURIComponent(cfg.space)}`, { signal: AbortSignal.timeout(8000) })).json();
|
|
351
|
+
head = h.spaces?.[0]?.seq ?? 0;
|
|
352
|
+
}
|
|
353
|
+
catch { }
|
|
354
|
+
const windows = head > 0 ? [60, 200, 600].map((n) => Math.max(0, head - n)) : [0];
|
|
355
|
+
if (windows[windows.length - 1] !== 0)
|
|
356
|
+
windows.push(0);
|
|
357
|
+
for (const since of windows) {
|
|
358
|
+
try {
|
|
359
|
+
const acts = await fetchHistory(cfg, since);
|
|
360
|
+
const act = acts.find((a) => a.id === id || String(a.seq) === id);
|
|
361
|
+
if (act)
|
|
362
|
+
return { act };
|
|
363
|
+
if (since === 0)
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
334
370
|
function workerMidRun() {
|
|
335
371
|
try {
|
|
336
372
|
const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
|
|
@@ -2165,6 +2201,21 @@ async function main() {
|
|
|
2165
2201
|
hud = null;
|
|
2166
2202
|
}
|
|
2167
2203
|
}
|
|
2204
|
+
if (hud?.action === "done") {
|
|
2205
|
+
const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2206
|
+
await c2.connect().catch((e) => die(e.message));
|
|
2207
|
+
const o = hud.decision ? c2.obligations.find((x) => x.id === hud.decision) : undefined;
|
|
2208
|
+
if (!o) {
|
|
2209
|
+
c2.close();
|
|
2210
|
+
console.error(D("that decision is no longer waiting on you"));
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
await c2.send({ act: "ANSWER", ref: o.ref, goal: o.goal, after_seq: c2.readSeq,
|
|
2214
|
+
body: "Marked done from the alert — handled outside this thread." }).catch((e) => die(e.message));
|
|
2215
|
+
c2.close();
|
|
2216
|
+
console.error(D(`done — ${o.id} is no longer waiting on you`));
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2168
2219
|
if (hud?.action === "details") {
|
|
2169
2220
|
const acts = await fetchHistory(cfg).catch(() => []);
|
|
2170
2221
|
const a = hud.act ? acts.find((x) => x.id === hud.act) : undefined;
|
|
@@ -2460,6 +2511,31 @@ async function main() {
|
|
|
2460
2511
|
console.log(JSON.stringify(await trayStatus()));
|
|
2461
2512
|
return;
|
|
2462
2513
|
}
|
|
2514
|
+
case "tray-detail": {
|
|
2515
|
+
const key = argv[1] ?? die("usage: pantheon tray-detail <act id>");
|
|
2516
|
+
const cfgNow = load() ?? die("not joined to a team");
|
|
2517
|
+
const { humanize } = await import("./humanize.js");
|
|
2518
|
+
const found = await fetchAct(cfgNow, key);
|
|
2519
|
+
if (!found) {
|
|
2520
|
+
console.log(JSON.stringify({ ok: false }));
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const a = found.act;
|
|
2524
|
+
let goalTitle = found.goalTitle;
|
|
2525
|
+
if (!goalTitle && a.goal) {
|
|
2526
|
+
const c2 = new PantheonClient(cfgNow, undefined, { role: "cli" });
|
|
2527
|
+
goalTitle = await c2.connect().then(() => c2.goals.get(a.goal)?.title).catch(() => undefined);
|
|
2528
|
+
c2.close();
|
|
2529
|
+
}
|
|
2530
|
+
const { headline, blocks } = humanize(a.body, { ...(goalTitle ? { goalTitle } : {}), from: a.from });
|
|
2531
|
+
console.log(JSON.stringify({
|
|
2532
|
+
ok: true, id: a.id, act: a.act, from: a.from, ts: a.ts,
|
|
2533
|
+
...(a.summary ? { summary: a.summary } : {}),
|
|
2534
|
+
...(goalTitle ? { goal_title: goalTitle } : {}),
|
|
2535
|
+
headline, blocks, raw: a.body,
|
|
2536
|
+
}));
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2463
2539
|
case "menubar": {
|
|
2464
2540
|
need();
|
|
2465
2541
|
const { ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
|
package/dist/decision-ui.js
CHANGED
|
@@ -4,7 +4,14 @@ export function decisionTitle(body, max = 96) {
|
|
|
4
4
|
.replace(/^[a-z0-9._-]+\s+escalated:\s*/i, "")
|
|
5
5
|
.replace(/^[a-z0-9._-]+\s+needs a decision:\s*/i, "")
|
|
6
6
|
.replace(/^human (?:action|input) required\s*[—:-]\s*/i, "")
|
|
7
|
-
.replace(/^human (?:action|input) required\s*/i, "")
|
|
7
|
+
.replace(/^human (?:action|input) required\s*/i, "")
|
|
8
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
9
|
+
.replace(/\(goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
|
|
10
|
+
.replace(/\s*\b(?:of|in|for|on|from)?\s*\(?\bgoal\s+g_[a-z0-9]+\)?/gi, "")
|
|
11
|
+
.replace(/\s{2,}/g, " ")
|
|
12
|
+
.replace(/[\s,]+(?:of|in|for|on|at|to|from)$/i, "")
|
|
13
|
+
.replace(/[\s,;:]+$/, "")
|
|
14
|
+
.trim();
|
|
8
15
|
const blockedAsk = text.match(/^cannot answer (.+?)\s+ask\s*:/i);
|
|
9
16
|
if (blockedAsk) {
|
|
10
17
|
text = `Decide whether to share the information requested for ${blockedAsk[1]}`;
|
|
@@ -25,6 +32,12 @@ export function decisionPrompt(id) {
|
|
|
25
32
|
export function allDecisionsPrompt(count) {
|
|
26
33
|
return `Check my ${count} Pantheon decision${count === 1 ? "" : "s"}. Explain each one in plain language: what happened, why I am needed, my options, and your recommendation. Prioritize what needs attention first. Do not decide or act for me; wait for my answers.`;
|
|
27
34
|
}
|
|
35
|
+
export function owedPrompt(title, goal) {
|
|
36
|
+
return `Pantheon: my side owes this — "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me in plain language what is owed, what is already done, and exactly what you would send to discharge it. Draft the act, then wait for me before sending.`;
|
|
37
|
+
}
|
|
38
|
+
export function waitingPrompt(who, title, goal) {
|
|
39
|
+
return `Pantheon: I am waiting on ${who} for "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me whether it is genuinely blocked on them, how long it has been waiting, and whether anything on my side is holding it up. If a nudge is warranted, draft a short one and wait for me.`;
|
|
40
|
+
}
|
|
28
41
|
export function workerSummary(raw) {
|
|
29
42
|
const state = oneLine(raw);
|
|
30
43
|
if (/deferring.*turn is in flight|holding.*live session/i.test(state))
|
package/dist/humanize.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const PARA_CHARS = 340;
|
|
2
|
+
const flatten = (s) => s.replace(/[\u2018\u2019\u201B]/g, "'").replace(/[\u201C\u201D]/g, '"');
|
|
3
|
+
export function stripIds(text, goalTitle) {
|
|
4
|
+
let out = flatten(text);
|
|
5
|
+
if (goalTitle) {
|
|
6
|
+
const esc = flatten(goalTitle).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
out = out.replace(new RegExp(`\\s*\\(\\s*"${esc}"\\s*\\)`, "gi"), "");
|
|
8
|
+
out = out.replace(new RegExp(`\\s*,?\\s*"${esc}"`, "gi"), "");
|
|
9
|
+
}
|
|
10
|
+
return out
|
|
11
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
12
|
+
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
13
|
+
.replace(/\(\s*goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
|
|
14
|
+
.replace(/\s*\(\s*goal\s+g_[a-z0-9]+\s*\)/gi, "")
|
|
15
|
+
.replace(/\s*\bgoal\s+g_[a-z0-9]+/gi, "")
|
|
16
|
+
.replace(/\s*\bid=[ao]_[a-z0-9]+/gi, "")
|
|
17
|
+
.replace(/\(\s*\)/g, "")
|
|
18
|
+
.replace(/\s+([,.;:])/g, "$1")
|
|
19
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
const ABBREV = /\b(?:e\.g|i\.e|vs|etc|approx|no|fig|mr|ms|dr|jr|sr|[a-z])\.$/i;
|
|
23
|
+
export function sentences(text) {
|
|
24
|
+
const out = [];
|
|
25
|
+
let buf = "";
|
|
26
|
+
for (const piece of text.split(/(?<=[.!?])\s+/)) {
|
|
27
|
+
buf = buf ? `${buf} ${piece}` : piece;
|
|
28
|
+
if (ABBREV.test(buf.trim()))
|
|
29
|
+
continue;
|
|
30
|
+
if (/[.!?]["')\]]?$/.test(buf.trim())) {
|
|
31
|
+
out.push(buf.trim());
|
|
32
|
+
buf = "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (buf.trim())
|
|
36
|
+
out.push(buf.trim());
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
const VERBS = /^(?:uname|hostname|echo|ls|cat|grep|tail|head|node|npm|npx|pnpm|yarn|git|gh|pantheon|cowork|xcode-select|launchctl|systemctl|curl|wget|which|ps|kill|pkill|sudo|open|defaults|codesign|swiftc|swift|python3?|pip3?|docker|make|cd|mkdir|rm|cp|mv|chmod|export|brew|sw_vers|scutil|lsof|dig|ping|ssh|scp|awk|sed|sort|uniq|wc|jq|tar|zip|unzip|env|printenv|type|command)\b/;
|
|
40
|
+
const PROSE_WORDS = /\s(?:the|a|an|is|are|was|were|be|been|and|or|to|of|that|this|with|from|into|please|it|its|our|your|their|her|his|for|but|so|then|when|while|which|who|what|why|how|should|would|could|will|can|may|must|has|have|had|does|did|say|says|said|asks?|needs?|wants?)\s/i;
|
|
41
|
+
export function commandLike(segment) {
|
|
42
|
+
const t = segment.trim().replace(/[.,]$/, "").trim();
|
|
43
|
+
if (!t || t.length > 160)
|
|
44
|
+
return false;
|
|
45
|
+
if (PROSE_WORDS.test(` ${t} `))
|
|
46
|
+
return false;
|
|
47
|
+
const first = t.split(/\s+/)[0] ?? "";
|
|
48
|
+
if (!/^[a-z[$][\w.[\]$-]*$/.test(first))
|
|
49
|
+
return false;
|
|
50
|
+
const words = t.split(/\s+/).length;
|
|
51
|
+
return VERBS.test(t) || /\s-{1,2}[\w-]|[~/]|\||&&|\$\(|^\[/.test(t) || words <= 2;
|
|
52
|
+
}
|
|
53
|
+
function splitLeadIn(segment) {
|
|
54
|
+
const marks = [...segment.matchAll(/[:,]\s+/g)];
|
|
55
|
+
for (let i = marks.length - 1; i >= 0; i--) {
|
|
56
|
+
const at = (marks[i].index ?? 0) + marks[i][0].length;
|
|
57
|
+
const tail = segment.slice(at);
|
|
58
|
+
if (commandLike(tail))
|
|
59
|
+
return { lead: segment.slice(0, at).trim(), cmd: tail.trim() };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function asOptions(sentence) {
|
|
64
|
+
const marks = [...sentence.matchAll(/\((?:[a-z]|[ivx]{1,3})\)\s*/gi)];
|
|
65
|
+
if (marks.length < 2)
|
|
66
|
+
return null;
|
|
67
|
+
const lead = sentence.slice(0, marks[0].index ?? 0).trim().replace(/[,:]$/, "");
|
|
68
|
+
const items = [];
|
|
69
|
+
for (let i = 0; i < marks.length; i++) {
|
|
70
|
+
const from = (marks[i].index ?? 0) + marks[i][0].length;
|
|
71
|
+
const to = i + 1 < marks.length ? marks[i + 1].index ?? sentence.length : sentence.length;
|
|
72
|
+
const item = sentence.slice(from, to).trim()
|
|
73
|
+
.replace(/[,;.]\s*(?:or|and)?\s*$/i, "")
|
|
74
|
+
.replace(/^(?:or|and)\s+/i, "")
|
|
75
|
+
.trim();
|
|
76
|
+
if (item)
|
|
77
|
+
items.push(item);
|
|
78
|
+
}
|
|
79
|
+
return items.length >= 2 ? { lead, items } : null;
|
|
80
|
+
}
|
|
81
|
+
const ITEM = /^(?:[-*•]|\d+[.)])\s+/;
|
|
82
|
+
function asWrittenList(lines) {
|
|
83
|
+
const at = lines.findIndex((l) => ITEM.test(l));
|
|
84
|
+
if (at < 0 || lines.length - at < 2)
|
|
85
|
+
return null;
|
|
86
|
+
if (!lines.slice(at).every((l) => ITEM.test(l)))
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
lead: lines.slice(0, at).join(" ").trim(),
|
|
90
|
+
items: lines.slice(at).map((l) => l.replace(ITEM, "").trim()),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function extractCommands(sentence) {
|
|
94
|
+
if (!sentence.includes(";"))
|
|
95
|
+
return null;
|
|
96
|
+
const parts = sentence.split(";").map((p) => p.trim()).filter(Boolean);
|
|
97
|
+
if (parts.length < 2)
|
|
98
|
+
return null;
|
|
99
|
+
let lead = "";
|
|
100
|
+
let start = -1;
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (let i = 0; i < parts.length; i++) {
|
|
103
|
+
const part = parts[i];
|
|
104
|
+
if (commandLike(part)) {
|
|
105
|
+
if (start < 0)
|
|
106
|
+
start = i;
|
|
107
|
+
lines.push(part.replace(/\.$/, "").trim());
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (start < 0) {
|
|
111
|
+
const split = splitLeadIn(part);
|
|
112
|
+
if (split) {
|
|
113
|
+
lead = [lead, split.lead].filter(Boolean).join(" ");
|
|
114
|
+
start = i;
|
|
115
|
+
lines.push(split.cmd);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
lead = [lead, part].filter(Boolean).join("; ");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
return lines.length >= 2 ? { lead: lead.trim(), lines, tail: parts.slice(i).join("; ").trim() } : null;
|
|
122
|
+
}
|
|
123
|
+
return lines.length >= 2 ? { lead: lead.trim(), lines, tail: "" } : null;
|
|
124
|
+
}
|
|
125
|
+
function paragraphs(text) {
|
|
126
|
+
const parts = sentences(text);
|
|
127
|
+
const out = [];
|
|
128
|
+
let buf = "";
|
|
129
|
+
for (const s of parts) {
|
|
130
|
+
const next = buf ? `${buf} ${s}` : s;
|
|
131
|
+
if (next.length > PARA_CHARS && buf) {
|
|
132
|
+
out.push({ kind: "para", text: buf });
|
|
133
|
+
buf = s;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
buf = next;
|
|
137
|
+
}
|
|
138
|
+
if (buf)
|
|
139
|
+
out.push({ kind: "para", text: buf });
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
const asLeadIn = (text) => text.trim().replace(/[,;]$/, ":").replace(/([^:.!?])$/, "$1:");
|
|
143
|
+
const push = (blocks, text, lead = false) => {
|
|
144
|
+
let t = text.trim();
|
|
145
|
+
if (!t)
|
|
146
|
+
return;
|
|
147
|
+
if (/^[a-z]/.test(t))
|
|
148
|
+
t = t[0].toUpperCase() + t.slice(1);
|
|
149
|
+
if (lead) {
|
|
150
|
+
blocks.push({ kind: "para", text: asLeadIn(t) });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
blocks.push(...paragraphs(t));
|
|
154
|
+
};
|
|
155
|
+
export function humanize(body, opts = {}) {
|
|
156
|
+
const clean = stripIds(body.replace(/\r\n/g, "\n"), opts.goalTitle);
|
|
157
|
+
const blocks = [];
|
|
158
|
+
for (const chunk of clean.split(/\n{2,}/)) {
|
|
159
|
+
const lines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
160
|
+
if (!lines.length)
|
|
161
|
+
continue;
|
|
162
|
+
const written = asWrittenList(lines);
|
|
163
|
+
if (written) {
|
|
164
|
+
if (written.lead)
|
|
165
|
+
push(blocks, written.lead, true);
|
|
166
|
+
blocks.push({ kind: "bullets", items: written.items });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (lines.length >= 2 && lines.every((l) => commandLike(l))) {
|
|
170
|
+
blocks.push({ kind: "code", lines });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
let pending = "";
|
|
174
|
+
for (const sentence of sentences(lines.join(" "))) {
|
|
175
|
+
const options = asOptions(sentence);
|
|
176
|
+
if (options) {
|
|
177
|
+
push(blocks, `${pending} ${options.lead}`.trim(), true);
|
|
178
|
+
pending = "";
|
|
179
|
+
blocks.push({ kind: "bullets", items: options.items });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const cmds = extractCommands(sentence);
|
|
183
|
+
if (cmds) {
|
|
184
|
+
push(blocks, `${pending} ${cmds.lead}`.trim(), true);
|
|
185
|
+
pending = "";
|
|
186
|
+
blocks.push({ kind: "code", lines: cmds.lines });
|
|
187
|
+
if (cmds.tail)
|
|
188
|
+
pending = cmds.tail;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
pending = pending ? `${pending} ${sentence}` : sentence;
|
|
192
|
+
}
|
|
193
|
+
push(blocks, pending);
|
|
194
|
+
}
|
|
195
|
+
if (!blocks.length)
|
|
196
|
+
blocks.push({ kind: "para", text: clean });
|
|
197
|
+
const firstPara = blocks.find((b) => b.kind === "para");
|
|
198
|
+
let headline = sentences(firstPara?.text ?? clean)[0] ?? clean;
|
|
199
|
+
headline = headline
|
|
200
|
+
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
201
|
+
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
202
|
+
.trim();
|
|
203
|
+
if (headline)
|
|
204
|
+
headline = headline[0].toUpperCase() + headline.slice(1);
|
|
205
|
+
headline = headline.replace(/\.$/, "").trim();
|
|
206
|
+
const full = headline;
|
|
207
|
+
if (headline.length > 180)
|
|
208
|
+
headline = `${headline.slice(0, 179).trimEnd()}…`;
|
|
209
|
+
const first = blocks[0];
|
|
210
|
+
if (full && first && first.kind === "para") {
|
|
211
|
+
const [lead, ...rest] = sentences(first.text);
|
|
212
|
+
const same = (lead ?? "").replace(/\.$/, "").trim().toLowerCase() === full.toLowerCase();
|
|
213
|
+
if (same) {
|
|
214
|
+
const remainder = rest.join(" ").trim();
|
|
215
|
+
if (remainder)
|
|
216
|
+
blocks[0] = { kind: "para", text: remainder };
|
|
217
|
+
else
|
|
218
|
+
blocks.shift();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { headline: headline || "Open item", blocks };
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=humanize.js.map
|
package/dist/notifier.js
CHANGED
|
@@ -9,26 +9,37 @@ import { CONFIG_DIR } from "./config.js";
|
|
|
9
9
|
import { loadProfile } from "./profile.js";
|
|
10
10
|
import { APP_TOOLS, composeDecision, composeDelivery, TOOL_LABELS } from "./notice.js";
|
|
11
11
|
import { cliOnPath, detectAvailable } from "./agents.js";
|
|
12
|
+
import { whichPantheon } from "./service.js";
|
|
12
13
|
export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
|
|
14
|
+
export const MAX_REMINDERS = 4;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
13
16
|
export function humanObligations(obligations, member) {
|
|
14
17
|
return obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${member}`);
|
|
15
18
|
}
|
|
16
19
|
export function dueHumanObligations(obligations, member, state, now, reminderMs) {
|
|
17
20
|
return humanObligations(obligations, member).filter((o) => {
|
|
18
21
|
const last = state.notified_at[o.id];
|
|
19
|
-
|
|
22
|
+
if (last === undefined)
|
|
23
|
+
return true;
|
|
24
|
+
const n = state.reminded?.[o.id] ?? 0;
|
|
25
|
+
if (n >= MAX_REMINDERS)
|
|
26
|
+
return false;
|
|
27
|
+
const gap = Math.min(reminderMs * 2 ** n, Math.max(reminderMs, DAY_MS));
|
|
28
|
+
return now - last >= gap;
|
|
20
29
|
});
|
|
21
30
|
}
|
|
22
31
|
export function pruneNotificationState(state, obligations, member, now = Date.now()) {
|
|
23
32
|
const active = new Set(humanObligations(obligations, member).map((o) => o.id));
|
|
33
|
+
const reminded = Object.fromEntries(Object.entries(state.reminded ?? {}).filter(([id]) => active.has(id)));
|
|
24
34
|
return {
|
|
25
35
|
notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id, at]) => active.has(id) || (id.startsWith("act:") && now - at < 7 * 86_400_000))),
|
|
36
|
+
...(Object.keys(reminded).length ? { reminded } : {}),
|
|
26
37
|
};
|
|
27
38
|
}
|
|
28
39
|
function readState(file) {
|
|
29
40
|
try {
|
|
30
41
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
31
|
-
return { notified_at: parsed.notified_at ?? {} };
|
|
42
|
+
return { notified_at: parsed.notified_at ?? {}, ...(parsed.reminded ? { reminded: parsed.reminded } : {}) };
|
|
32
43
|
}
|
|
33
44
|
catch {
|
|
34
45
|
return { notified_at: {} };
|
|
@@ -111,7 +122,7 @@ export function sendDesktopNotification(notice, home = CONFIG_DIR) {
|
|
|
111
122
|
if (!existsSync(binary))
|
|
112
123
|
return false;
|
|
113
124
|
try {
|
|
114
|
-
const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin") })], { detached: true, stdio: "ignore" });
|
|
125
|
+
const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin"), pantheon: whichPantheon() ?? "pantheon" })], { detached: true, stdio: "ignore" });
|
|
115
126
|
child.unref();
|
|
116
127
|
return true;
|
|
117
128
|
}
|
|
@@ -139,7 +150,7 @@ export class HumanNotifier {
|
|
|
139
150
|
const pollMs = this.opts.pollMs ?? 5_000;
|
|
140
151
|
let state = readState(stateFile);
|
|
141
152
|
this.opts.log(`notifier online as ${this.cfg.member}`);
|
|
142
|
-
this.opts.log(` reminders
|
|
153
|
+
this.opts.log(` reminders after ${Math.round(this.opts.reminderMs / 60_000)} minutes, then doubling to daily, ${MAX_REMINDERS} at most — after that a decision waits on pantheon brief and the menu bar`);
|
|
143
154
|
while (!this.stopped) {
|
|
144
155
|
const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
|
|
145
156
|
let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
|
|
@@ -153,13 +164,16 @@ export class HumanNotifier {
|
|
|
153
164
|
const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
|
|
154
165
|
const withLabel = (n) => this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
|
|
155
166
|
for (const obligation of due) {
|
|
167
|
+
const isReminder = state.notified_at[obligation.id] !== undefined;
|
|
156
168
|
const act = client.seenAct(obligation.ref);
|
|
157
169
|
const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now() }));
|
|
158
170
|
const ok = send(notice);
|
|
159
171
|
if (ok) {
|
|
160
172
|
state.notified_at[obligation.id] = now();
|
|
173
|
+
if (isReminder)
|
|
174
|
+
state.reminded = { ...(state.reminded ?? {}), [obligation.id]: (state.reminded?.[obligation.id] ?? 0) + 1 };
|
|
161
175
|
dirty = true;
|
|
162
|
-
this.opts.log(`> notified for ${obligation.id} (${oneLine(obligation.body, 100)})`);
|
|
176
|
+
this.opts.log(`> ${isReminder ? `reminder ${state.reminded[obligation.id]}/${MAX_REMINDERS}` : "notified"} for ${obligation.id} (${oneLine(obligation.body, 100)})`);
|
|
163
177
|
}
|
|
164
178
|
else {
|
|
165
179
|
this.opts.log(`! could not show a desktop notification for ${obligation.id}`);
|
package/dist/relay.js
CHANGED
|
@@ -263,6 +263,17 @@ export class Relay {
|
|
|
263
263
|
: {}),
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
|
+
if (url.pathname === "/act") {
|
|
267
|
+
const space = this.spaces.get(url.searchParams.get("space") ?? "");
|
|
268
|
+
const token = url.searchParams.get("token") ?? "";
|
|
269
|
+
if (!space || space.token !== token)
|
|
270
|
+
return this.json(res, 404, { error: "no such space" });
|
|
271
|
+
const act = space.ledger.find(url.searchParams.get("id") ?? "");
|
|
272
|
+
if (!act)
|
|
273
|
+
return this.json(res, 404, { error: "no such act" });
|
|
274
|
+
const goal = act.goal ? space.goals.get(act.goal) : undefined;
|
|
275
|
+
return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
|
|
276
|
+
}
|
|
266
277
|
if (url.pathname === "/history") {
|
|
267
278
|
const space = this.spaces.get(url.searchParams.get("space") ?? "");
|
|
268
279
|
const token = url.searchParams.get("token") ?? "";
|
package/dist/tray.js
CHANGED
|
@@ -11,7 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
|
|
|
11
11
|
import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
13
|
import { LIMITS } from "./protocol.js";
|
|
14
|
-
import { allDecisionsPrompt, decisionPrompt, decisionTitle, workerSummary } from "./decision-ui.js";
|
|
14
|
+
import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, waitingPrompt, workerSummary } from "./decision-ui.js";
|
|
15
15
|
import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
|
|
@@ -93,6 +93,9 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
|
|
|
93
93
|
members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
|
|
94
94
|
decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
|
|
95
95
|
decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
|
|
96
|
+
items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))),
|
|
97
|
+
owed_count: ok.reduce((n, p) => n + (p.owed_count ?? 0), 0),
|
|
98
|
+
waiting_count: ok.reduce((n, p) => n + (p.waiting_count ?? 0), 0),
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
101
|
return trayStatusOne(cfg);
|
|
@@ -104,14 +107,33 @@ async function trayStatusOne(cfg) {
|
|
|
104
107
|
const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
|
|
105
108
|
const [health, obligations] = await Promise.all([
|
|
106
109
|
getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
|
|
107
|
-
getJson(`${base}/obligations?${q}
|
|
110
|
+
getJson(`${base}/obligations?${q}`),
|
|
108
111
|
]);
|
|
109
112
|
const space = health
|
|
110
113
|
?.spaces?.find((s) => s.name === cfg.space);
|
|
111
114
|
const pending = obligations?.obligations;
|
|
112
|
-
const
|
|
113
|
-
const
|
|
115
|
+
const ok = Array.isArray(pending) && pending.every((o) => o && typeof o.id === "string" && typeof o.body === "string" && typeof o.owner === "string");
|
|
116
|
+
const all = (ok ? pending : []);
|
|
117
|
+
const mineHuman = all.filter((o) => o.owner === `human:${cfg.member}`);
|
|
118
|
+
const decisionsOk = ok;
|
|
119
|
+
const decisions = mineHuman
|
|
114
120
|
.map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
|
|
121
|
+
const row = (o, kind, who, prompt) => ({
|
|
122
|
+
id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
|
|
123
|
+
when: o.created_at ?? 0,
|
|
124
|
+
...(o.goal ? { goal: o.goal } : {}),
|
|
125
|
+
...(o.ref ? { ref: o.ref } : {}),
|
|
126
|
+
});
|
|
127
|
+
const owed = all.filter((o) => o.owner === cfg.member);
|
|
128
|
+
const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
|
|
129
|
+
const items = [
|
|
130
|
+
...mineHuman.map((o) => row(o, "decision", "you", decisionPrompt(o.id))),
|
|
131
|
+
...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
|
|
132
|
+
...waiting.map((o) => {
|
|
133
|
+
const who = o.owner.replace(/^human:/, "");
|
|
134
|
+
return row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
|
|
135
|
+
}),
|
|
136
|
+
];
|
|
115
137
|
const profile = loadProfile(cfg);
|
|
116
138
|
const surface = profile.agent.primary_surface;
|
|
117
139
|
const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
|
|
@@ -137,6 +159,9 @@ async function trayStatusOne(cfg) {
|
|
|
137
159
|
surface_bundle_id: SURFACE_BUNDLE_IDS[surface],
|
|
138
160
|
decisions_prompt: allDecisionsPrompt(decisions.length),
|
|
139
161
|
decisions,
|
|
162
|
+
items,
|
|
163
|
+
owed_count: owed.length,
|
|
164
|
+
waiting_count: waiting.length,
|
|
140
165
|
};
|
|
141
166
|
}
|
|
142
167
|
//# sourceMappingURL=tray.js.map
|
package/helper/macos/main.swift
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* (the choice becomes the default), through a .command file
|
|
10
10
|
* Terminal runs — no automation permission needed
|
|
11
11
|
* Show details prints the act and the decision in Terminal
|
|
12
|
+
* Done (decisions) answers it as handled, silently — the reminders stop
|
|
12
13
|
* Dismiss / × closes
|
|
13
14
|
*
|
|
14
15
|
* It owns no state. Everything it needs arrives as one JSON document:
|
|
@@ -25,7 +26,7 @@ import AppKit
|
|
|
25
26
|
let args = CommandLine.arguments
|
|
26
27
|
|
|
27
28
|
if args.count == 2, args[1] == "version" {
|
|
28
|
-
print("
|
|
29
|
+
print("3")
|
|
29
30
|
exit(0)
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -45,6 +46,8 @@ struct Notice: Decodable {
|
|
|
45
46
|
let details: Details?
|
|
46
47
|
/** where to write the chosen action and which .command files to open — supplied by the CLI */
|
|
47
48
|
let bin_dir: String
|
|
49
|
+
/** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
|
|
50
|
+
let pantheon: String?
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
var notice: Notice
|
|
@@ -138,6 +141,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
138
141
|
act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
/// Done: write the action, run the CLI without a Terminal, close. The CLI answers the decision as handled.
|
|
145
|
+
@objc func markDone(_ sender: Any?) {
|
|
146
|
+
guard !notice.bin_dir.isEmpty, let decision = notice.details?.decision else { NSApp.terminate(nil); return }
|
|
147
|
+
let dir = URL(fileURLWithPath: notice.bin_dir)
|
|
148
|
+
let payload: [String: Any] = ["action": "done", "decision": decision, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }
|
|
149
|
+
if let data = try? JSONSerialization.data(withJSONObject: payload) {
|
|
150
|
+
try? data.write(to: dir.appendingPathComponent(".hud-action.json"), options: .atomic)
|
|
151
|
+
}
|
|
152
|
+
let bin = (notice.pantheon?.isEmpty == false) ? notice.pantheon! : "pantheon"
|
|
153
|
+
let quoted = "'" + bin.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
154
|
+
let p = Process()
|
|
155
|
+
p.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
156
|
+
p.arguments = ["-lc", "\(quoted) explain --queued >/dev/null 2>&1"] // login shell: PATH with node, as the user's own terminal has
|
|
157
|
+
try? p.run()
|
|
158
|
+
NSApp.terminate(nil)
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
@objc func legacyOpen(_ sender: Any?) {
|
|
142
162
|
if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
|
|
143
163
|
if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
|
|
@@ -260,6 +280,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
260
280
|
constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
261
281
|
trailing = d
|
|
262
282
|
}
|
|
283
|
+
if notice.details?.decision != nil {
|
|
284
|
+
// A decision can be settled from the banner itself: one click, no terminal, no more reminders.
|
|
285
|
+
let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
|
|
286
|
+
done.isBordered = false; done.font = .systemFont(ofSize: 12, weight: .medium); done.contentTintColor = .controlAccentColor
|
|
287
|
+
done.setAccessibilityLabel("Mark this decision handled")
|
|
288
|
+
done.translatesAutoresizingMaskIntoConstraints = false
|
|
289
|
+
root.addSubview(done)
|
|
290
|
+
constraints += [done.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), done.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
291
|
+
trailing = done
|
|
292
|
+
}
|
|
263
293
|
let dis = NSButton(title: "Dismiss", target: self, action: #selector(dismiss(_:)))
|
|
264
294
|
dis.isBordered = false; dis.font = .systemFont(ofSize: 12); dis.contentTintColor = .secondaryLabelColor
|
|
265
295
|
dis.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
* truth and this file never grows business logic. No network of its own, no
|
|
11
11
|
* state of its own, no credentials — it cannot leak what it never holds.
|
|
12
12
|
*
|
|
13
|
+
* The menu is also the daily review surface: every open thing — decisions for
|
|
14
|
+
* this human, what their side owes teammates, what teammates owe them — is a
|
|
15
|
+
* row with a copy button, and opens a centred panel with the whole text and the
|
|
16
|
+
* actions that settle it. Before that it was a status light that said "no
|
|
17
|
+
* decisions need your input" and left nothing to look at.
|
|
18
|
+
*
|
|
13
19
|
* pantheon-tray <absolute-path-to-pantheon> [poll-seconds]
|
|
14
20
|
*/
|
|
15
21
|
import AppKit
|
|
16
22
|
|
|
17
23
|
let argv = CommandLine.arguments
|
|
18
|
-
if argv.count == 2, argv[1] == "version" { print("
|
|
24
|
+
if argv.count == 2, argv[1] == "version" { print("2"); exit(0) }
|
|
19
25
|
guard argv.count >= 2 else {
|
|
20
26
|
fputs("usage: pantheon-tray <path-to-pantheon> [poll-seconds]\n", stderr)
|
|
21
27
|
exit(64)
|
|
@@ -43,9 +49,49 @@ struct TrayState {
|
|
|
43
49
|
var surfaceBundleID = ""
|
|
44
50
|
var decisionsPrompt = ""
|
|
45
51
|
var decisions: [(id: String, body: String, detail: String, prompt: String)] = []
|
|
52
|
+
var items: [Item] = []
|
|
46
53
|
var version = ""
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/// One open thing, as the menu shows it and the panel expands it.
|
|
57
|
+
struct Item {
|
|
58
|
+
let id: String
|
|
59
|
+
let kind: String // decision | owed | waiting
|
|
60
|
+
let title: String
|
|
61
|
+
let headline: String
|
|
62
|
+
let detail: String
|
|
63
|
+
let prompt: String
|
|
64
|
+
let who: String // member id, or "you"
|
|
65
|
+
let when: Double // ms since epoch
|
|
66
|
+
let goal: String
|
|
67
|
+
let ref: String
|
|
68
|
+
let space: String
|
|
69
|
+
|
|
70
|
+
var isDecision: Bool { kind == "decision" }
|
|
71
|
+
var heading: String {
|
|
72
|
+
switch kind {
|
|
73
|
+
case "decision": return "Needs your decision"
|
|
74
|
+
case "owed": return "Your side owes this"
|
|
75
|
+
default: return "Waiting on \(who)"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/// "praneet · 2d ago · pantheon"
|
|
79
|
+
var subtitle: String {
|
|
80
|
+
var parts: [String] = []
|
|
81
|
+
if kind == "owed" { parts.append("you owe") } else if kind == "waiting" { parts.append(who) }
|
|
82
|
+
if when > 0 { parts.append(Item.age(when)) }
|
|
83
|
+
if !space.isEmpty { parts.append(space) }
|
|
84
|
+
return parts.joined(separator: " · ")
|
|
85
|
+
}
|
|
86
|
+
static func age(_ ms: Double) -> String {
|
|
87
|
+
let secs = max(0, Date().timeIntervalSince1970 - ms / 1000)
|
|
88
|
+
if secs < 90 { return "just now" }
|
|
89
|
+
if secs < 5400 { return "\(Int(secs / 60))m ago" }
|
|
90
|
+
if secs < 172_800 { return "\(Int(secs / 3600))h ago" }
|
|
91
|
+
return "\(Int(secs / 86_400))d ago"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
49
95
|
/// Run a command with a hard timeout. The menu bar must never beachball
|
|
50
96
|
/// because a laptop's relay is asleep — tray-status has its own network
|
|
51
97
|
/// timeouts, this bounds the whole process as the backstop.
|
|
@@ -88,6 +134,21 @@ func fetchState() -> TrayState {
|
|
|
88
134
|
s.surfaceName = j["surface_name"] as? String ?? "agent"
|
|
89
135
|
s.surfaceBundleID = j["surface_bundle_id"] as? String ?? ""
|
|
90
136
|
s.decisionsPrompt = j["decisions_prompt"] as? String ?? ""
|
|
137
|
+
for i in (j["items"] as? [[String: Any]] ?? []) {
|
|
138
|
+
s.items.append(Item(
|
|
139
|
+
id: i["id"] as? String ?? "",
|
|
140
|
+
kind: i["kind"] as? String ?? "decision",
|
|
141
|
+
title: i["title"] as? String ?? "",
|
|
142
|
+
headline: i["headline"] as? String ?? i["title"] as? String ?? "",
|
|
143
|
+
detail: i["detail"] as? String ?? i["title"] as? String ?? "",
|
|
144
|
+
prompt: i["prompt"] as? String ?? "",
|
|
145
|
+
who: i["who"] as? String ?? "",
|
|
146
|
+
when: (i["when"] as? Double) ?? Double(i["when"] as? Int ?? 0),
|
|
147
|
+
goal: i["goal"] as? String ?? "",
|
|
148
|
+
ref: i["ref"] as? String ?? "",
|
|
149
|
+
space: i["space"] as? String ?? ""
|
|
150
|
+
))
|
|
151
|
+
}
|
|
91
152
|
for d in (j["decisions"] as? [[String: Any]] ?? []) {
|
|
92
153
|
s.decisions.append((
|
|
93
154
|
id: d["id"] as? String ?? "",
|
|
@@ -99,6 +160,111 @@ func fetchState() -> TrayState {
|
|
|
99
160
|
return s
|
|
100
161
|
}
|
|
101
162
|
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One open thing, as a menu row: title, a dim second line, and a copy control on
|
|
166
|
+
* the right. Clicking the row opens the detail panel; clicking the copy glyph
|
|
167
|
+
* puts the agent prompt on the clipboard and LEAVES THE MENU OPEN, so a person
|
|
168
|
+
* reviewing four things can take all four without reopening the menu each time.
|
|
169
|
+
*
|
|
170
|
+
* Drawn by hand rather than assembled from controls: a menu runs its own event
|
|
171
|
+
* loop, and hit-testing two zones in one view is far more predictable there than
|
|
172
|
+
* nesting buttons inside an NSMenuItem.
|
|
173
|
+
*/
|
|
174
|
+
final class RowView: NSView {
|
|
175
|
+
private let item: Item
|
|
176
|
+
private let onOpen: (Item) -> Void
|
|
177
|
+
private let onCopy: (Item) -> Void
|
|
178
|
+
private var hovering = false
|
|
179
|
+
private var overCopy = false
|
|
180
|
+
private var copied = false
|
|
181
|
+
|
|
182
|
+
init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
|
|
183
|
+
self.item = item
|
|
184
|
+
self.onOpen = onOpen
|
|
185
|
+
self.onCopy = onCopy
|
|
186
|
+
super.init(frame: NSRect(x: 0, y: 0, width: width, height: item.subtitle.isEmpty ? 30 : 42))
|
|
187
|
+
setAccessibilityRole(.button)
|
|
188
|
+
setAccessibilityLabel("\(item.title). \(item.subtitle). Return opens it; the button on the right copies the question.")
|
|
189
|
+
}
|
|
190
|
+
required init?(coder: NSCoder) { return nil }
|
|
191
|
+
|
|
192
|
+
private var copyRect: NSRect { NSRect(x: bounds.maxX - 42, y: bounds.midY - 12, width: 28, height: 24) }
|
|
193
|
+
|
|
194
|
+
override func updateTrackingAreas() {
|
|
195
|
+
super.updateTrackingAreas()
|
|
196
|
+
for a in trackingAreas { removeTrackingArea(a) }
|
|
197
|
+
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: ["zone": "row"]))
|
|
198
|
+
addTrackingArea(NSTrackingArea(rect: copyRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: ["zone": "copy"]))
|
|
199
|
+
}
|
|
200
|
+
private func zone(_ e: NSEvent) -> String { (e.trackingArea?.userInfo?["zone"] as? String) ?? "row" }
|
|
201
|
+
override func mouseEntered(with e: NSEvent) {
|
|
202
|
+
if zone(e) == "copy" { overCopy = true } else { hovering = true }
|
|
203
|
+
needsDisplay = true
|
|
204
|
+
}
|
|
205
|
+
override func mouseExited(with e: NSEvent) {
|
|
206
|
+
if zone(e) == "copy" { overCopy = false } else { hovering = false; overCopy = false }
|
|
207
|
+
needsDisplay = true
|
|
208
|
+
}
|
|
209
|
+
override func mouseUp(with e: NSEvent) {
|
|
210
|
+
if copyRect.contains(convert(e.locationInWindow, from: nil)) {
|
|
211
|
+
onCopy(item)
|
|
212
|
+
copied = true
|
|
213
|
+
needsDisplay = true
|
|
214
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in self?.copied = false; self?.needsDisplay = true }
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
enclosingMenuItem?.menu?.cancelTracking()
|
|
218
|
+
onOpen(item)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
222
|
+
let selected = hovering
|
|
223
|
+
if selected {
|
|
224
|
+
NSColor.selectedContentBackgroundColor.setFill()
|
|
225
|
+
NSBezierPath(roundedRect: bounds.insetBy(dx: 5, dy: 1), xRadius: 5, yRadius: 5).fill()
|
|
226
|
+
}
|
|
227
|
+
let fg: NSColor = selected ? .selectedMenuItemTextColor : .labelColor
|
|
228
|
+
let dim: NSColor = selected ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.7) : .secondaryLabelColor
|
|
229
|
+
let para = NSMutableParagraphStyle()
|
|
230
|
+
para.lineBreakMode = .byTruncatingTail
|
|
231
|
+
let textWidth = bounds.width - 14 - 48
|
|
232
|
+
let hasSub = !item.subtitle.isEmpty
|
|
233
|
+
let titleY = hasSub ? bounds.midY - 1 : bounds.midY - 8
|
|
234
|
+
NSAttributedString(string: item.title, attributes: [
|
|
235
|
+
.font: NSFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: fg, .paragraphStyle: para,
|
|
236
|
+
]).draw(in: NSRect(x: 14, y: titleY, width: textWidth, height: 18))
|
|
237
|
+
if hasSub {
|
|
238
|
+
NSAttributedString(string: item.subtitle, attributes: [
|
|
239
|
+
.font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: para,
|
|
240
|
+
]).draw(in: NSRect(x: 14, y: bounds.midY - 17, width: textWidth, height: 15))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// the copy control
|
|
244
|
+
if overCopy || copied {
|
|
245
|
+
(selected ? NSColor.white.withAlphaComponent(0.22) : NSColor.labelColor.withAlphaComponent(0.10)).setFill()
|
|
246
|
+
NSBezierPath(roundedRect: copyRect, xRadius: 5, yRadius: 5).fill()
|
|
247
|
+
}
|
|
248
|
+
let glyphName = copied ? "checkmark" : "doc.on.doc"
|
|
249
|
+
let tint: NSColor = copied ? (selected ? .selectedMenuItemTextColor : .systemGreen) : (selected ? .selectedMenuItemTextColor : .secondaryLabelColor)
|
|
250
|
+
if let sym = NSImage(systemSymbolName: glyphName, accessibilityDescription: copied ? "Copied" : "Copy the question") {
|
|
251
|
+
let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium)
|
|
252
|
+
let img = sym.withSymbolConfiguration(cfg) ?? sym
|
|
253
|
+
let size = img.size
|
|
254
|
+
let box = NSRect(x: copyRect.midX - size.width / 2, y: copyRect.midY - size.height / 2, width: size.width, height: size.height)
|
|
255
|
+
tint.set()
|
|
256
|
+
img.isTemplate = true
|
|
257
|
+
img.draw(in: box, from: .zero, operation: .sourceOver, fraction: 1.0)
|
|
258
|
+
NSColor.clear.set()
|
|
259
|
+
} else {
|
|
260
|
+
let p2 = NSMutableParagraphStyle(); p2.alignment = .center
|
|
261
|
+
NSAttributedString(string: copied ? "ok" : "copy", attributes: [
|
|
262
|
+
.font: NSFont.systemFont(ofSize: 10, weight: .medium), .foregroundColor: tint, .paragraphStyle: p2,
|
|
263
|
+
]).draw(in: NSRect(x: copyRect.minX - 6, y: copyRect.midY - 6, width: copyRect.width + 12, height: 13))
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
102
268
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
103
269
|
private var item: NSStatusItem!
|
|
104
270
|
private var state = TrayState()
|
|
@@ -222,21 +388,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
222
388
|
: "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
|
|
223
389
|
menu.addItem(.separator())
|
|
224
390
|
if !state.decisionsOk {
|
|
225
|
-
info("Some
|
|
226
|
-
info("Refresh to try again
|
|
391
|
+
info("Some of this could not be checked")
|
|
392
|
+
info("Refresh to try again — the list may be incomplete")
|
|
227
393
|
}
|
|
228
|
-
|
|
229
|
-
|
|
394
|
+
let decisions = state.items.filter { $0.kind == "decision" }
|
|
395
|
+
let owed = state.items.filter { $0.kind == "owed" }
|
|
396
|
+
let waiting = state.items.filter { $0.kind == "waiting" }
|
|
397
|
+
if state.items.isEmpty {
|
|
398
|
+
if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
|
|
230
399
|
} else {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
menu.
|
|
400
|
+
addRows(menu, decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", decisions)
|
|
401
|
+
addRows(menu, owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", owed)
|
|
402
|
+
// Grouped by person, because "waiting on praneet" is what someone
|
|
403
|
+
// actually wants to know before they chase it.
|
|
404
|
+
for who in orderedPeers(waiting) {
|
|
405
|
+
let theirs = waiting.filter { $0.who == who }
|
|
406
|
+
addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
|
|
407
|
+
}
|
|
408
|
+
if !decisions.isEmpty {
|
|
409
|
+
menu.addItem(makeItem("\(decisions.count > 1 ? "Review all \(decisions.count) decisions" : "Review the decision") in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
238
410
|
}
|
|
239
|
-
menu.addItem(makeItem("\(state.decisions.count > 5 ? "Review all \(state.decisions.count) decisions" : "Review decisions") in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
240
411
|
}
|
|
241
412
|
menu.addItem(.separator())
|
|
242
413
|
info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
|
|
@@ -274,6 +445,399 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
274
445
|
menu.addItem(makeItem("Quit pantheon menu bar", #selector(quit)))
|
|
275
446
|
}
|
|
276
447
|
|
|
448
|
+
|
|
449
|
+
// MARK: the detail panel — the centred window a row opens
|
|
450
|
+
|
|
451
|
+
private var detailPanel: NSPanel?
|
|
452
|
+
private var panelItem: Item?
|
|
453
|
+
private var panelStatus: NSTextField?
|
|
454
|
+
private var panelText: NSTextView?
|
|
455
|
+
private var panelBodyHeight: NSLayoutConstraint?
|
|
456
|
+
private var panelHeadline: NSTextField?
|
|
457
|
+
private var panelRendered: NSAttributedString? // the act laid out for a person
|
|
458
|
+
private var panelRaw: String = "" // the act exactly as it was sent
|
|
459
|
+
private var panelShowingRaw = false
|
|
460
|
+
private var panelRawToggle: NSButton?
|
|
461
|
+
|
|
462
|
+
private func label(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor, width: CGFloat) -> NSTextField {
|
|
463
|
+
let l = NSTextField(labelWithString: text)
|
|
464
|
+
l.font = .systemFont(ofSize: size, weight: weight)
|
|
465
|
+
l.textColor = color
|
|
466
|
+
l.lineBreakMode = .byWordWrapping
|
|
467
|
+
l.maximumNumberOfLines = 0
|
|
468
|
+
l.preferredMaxLayoutWidth = width
|
|
469
|
+
l.translatesAutoresizingMaskIntoConstraints = false
|
|
470
|
+
return l
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/// How tall the body should be for this much text, between a compact minimum
|
|
474
|
+
/// and a cap that keeps the panel on screen.
|
|
475
|
+
private func bodyHeight(_ text: NSAttributedString, width: CGFloat) -> CGFloat {
|
|
476
|
+
let measured = text.boundingRect(with: NSSize(width: width - 4, height: .greatestFiniteMagnitude),
|
|
477
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading]).height
|
|
478
|
+
return min(max(ceil(measured) + 22, 96), 400)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private func plain(_ text: String) -> NSAttributedString {
|
|
482
|
+
let para = NSMutableParagraphStyle()
|
|
483
|
+
para.paragraphSpacing = 10
|
|
484
|
+
para.lineSpacing = 1.5
|
|
485
|
+
return NSAttributedString(string: text, attributes: [
|
|
486
|
+
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: para,
|
|
487
|
+
])
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The act as blocks: paragraphs, the options as bullets, shell commands in a
|
|
492
|
+
* monospace run. The parsing is the CLI's (`pantheon tray-detail`), because it
|
|
493
|
+
* is testable there and this file stays a renderer.
|
|
494
|
+
*/
|
|
495
|
+
private func render(_ blocks: [[String: Any]]) -> NSAttributedString {
|
|
496
|
+
let out = NSMutableAttributedString()
|
|
497
|
+
let body = NSMutableParagraphStyle()
|
|
498
|
+
body.paragraphSpacing = 12
|
|
499
|
+
body.lineSpacing = 1.5
|
|
500
|
+
let bullet = NSMutableParagraphStyle()
|
|
501
|
+
bullet.paragraphSpacing = 5
|
|
502
|
+
bullet.lineSpacing = 1.5
|
|
503
|
+
bullet.headIndent = 20
|
|
504
|
+
bullet.firstLineHeadIndent = 6
|
|
505
|
+
let code = NSMutableParagraphStyle()
|
|
506
|
+
code.paragraphSpacing = 2
|
|
507
|
+
code.headIndent = 20
|
|
508
|
+
code.firstLineHeadIndent = 12
|
|
509
|
+
|
|
510
|
+
for block in blocks {
|
|
511
|
+
let kind = block["kind"] as? String ?? "para"
|
|
512
|
+
if kind == "para", let text = block["text"] as? String {
|
|
513
|
+
out.append(NSAttributedString(string: text + "\n", attributes: [
|
|
514
|
+
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: body,
|
|
515
|
+
]))
|
|
516
|
+
} else if kind == "bullets", let items = block["items"] as? [String] {
|
|
517
|
+
for item in items {
|
|
518
|
+
out.append(NSAttributedString(string: "• " + item + "\n", attributes: [
|
|
519
|
+
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: NSColor.labelColor, .paragraphStyle: bullet,
|
|
520
|
+
]))
|
|
521
|
+
}
|
|
522
|
+
out.append(NSAttributedString(string: "\n", attributes: [.font: NSFont.systemFont(ofSize: 5)]))
|
|
523
|
+
} else if kind == "code", let lines = block["lines"] as? [String] {
|
|
524
|
+
for line in lines {
|
|
525
|
+
out.append(NSAttributedString(string: line + "\n", attributes: [
|
|
526
|
+
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
|
|
527
|
+
.foregroundColor: NSColor.labelColor,
|
|
528
|
+
.backgroundColor: NSColor.labelColor.withAlphaComponent(0.06),
|
|
529
|
+
.paragraphStyle: code,
|
|
530
|
+
]))
|
|
531
|
+
}
|
|
532
|
+
out.append(NSAttributedString(string: "\n", attributes: [.font: NSFont.systemFont(ofSize: 5)]))
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return out
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private func showBody(_ text: NSAttributedString, width: CGFloat) {
|
|
539
|
+
guard let tv = panelText else { return }
|
|
540
|
+
tv.textStorage?.setAttributedString(text)
|
|
541
|
+
panelBodyHeight?.constant = bodyHeight(text, width: width)
|
|
542
|
+
if let p = detailPanel {
|
|
543
|
+
p.contentView?.layoutSubtreeIfNeeded()
|
|
544
|
+
let h = min(max((p.contentView?.fittingSize.height ?? 420) + 30, 300), 680)
|
|
545
|
+
let f = p.frame
|
|
546
|
+
p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The relay's obligation body is ONE clipped line — enough for a menu row, not
|
|
552
|
+
* for a panel that says "here is the whole thing". So fetch the act behind it
|
|
553
|
+
* and lay it out. On demand, only while a panel is open.
|
|
554
|
+
*/
|
|
555
|
+
private func loadDetail(_ item: Item, width: CGFloat) {
|
|
556
|
+
guard !item.ref.isEmpty else { return }
|
|
557
|
+
DispatchQueue.global().async {
|
|
558
|
+
let r = run(pantheonBin, ["tray-detail", item.ref], timeout: 20)
|
|
559
|
+
guard let out = r?.out, r?.code == 0,
|
|
560
|
+
let data = out.data(using: .utf8),
|
|
561
|
+
let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
562
|
+
j["ok"] as? Bool == true else { return }
|
|
563
|
+
let blocks = j["blocks"] as? [[String: Any]] ?? []
|
|
564
|
+
let rendered = self.render(blocks)
|
|
565
|
+
let raw = j["raw"] as? String ?? ""
|
|
566
|
+
let headline = j["headline"] as? String ?? ""
|
|
567
|
+
DispatchQueue.main.async {
|
|
568
|
+
guard self.panelItem?.id == item.id else { return }
|
|
569
|
+
self.panelRendered = rendered
|
|
570
|
+
self.panelRaw = raw
|
|
571
|
+
self.panelRawToggle?.isHidden = raw.isEmpty
|
|
572
|
+
if !headline.isEmpty { self.panelHeadline?.stringValue = headline }
|
|
573
|
+
if !self.panelShowingRaw && rendered.length > 0 { self.showBody(rendered, width: width) }
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// Reformatting someone else's words is only honest if the original is one click away.
|
|
579
|
+
@objc private func panelToggleRaw() {
|
|
580
|
+
guard let item = panelItem else { return }
|
|
581
|
+
panelShowingRaw.toggle()
|
|
582
|
+
panelRawToggle?.title = panelShowingRaw ? "Show it laid out" : "Show it as sent"
|
|
583
|
+
let width: CGFloat = 620 - 52
|
|
584
|
+
if panelShowingRaw { showBody(plain(panelRaw.isEmpty ? item.detail : panelRaw), width: width) }
|
|
585
|
+
else if let r = panelRendered { showBody(r, width: width) }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Everything about one open thing, in the middle of the screen: who it is
|
|
590
|
+
* from, the whole text (a menu row can only ever show a clipped line), and the
|
|
591
|
+
* actions that settle it. The menu answers "is anything waiting"; this answers
|
|
592
|
+
* "what is it, and what do I do about it" without a terminal.
|
|
593
|
+
*/
|
|
594
|
+
func showDetail(_ item: Item) {
|
|
595
|
+
detailPanel?.close()
|
|
596
|
+
panelItem = item
|
|
597
|
+
let width: CGFloat = 620
|
|
598
|
+
let inner = width - 52
|
|
599
|
+
let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: width, height: 470),
|
|
600
|
+
styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
|
|
601
|
+
panel.title = "Pantheon"
|
|
602
|
+
panel.titlebarAppearsTransparent = true
|
|
603
|
+
panel.isMovableByWindowBackground = true
|
|
604
|
+
panel.level = .floating
|
|
605
|
+
panel.isReleasedWhenClosed = false
|
|
606
|
+
panel.hidesOnDeactivate = false
|
|
607
|
+
|
|
608
|
+
let effect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: width, height: 470))
|
|
609
|
+
effect.material = .popover
|
|
610
|
+
effect.blendingMode = .behindWindow
|
|
611
|
+
effect.state = .active
|
|
612
|
+
effect.autoresizingMask = [.width, .height]
|
|
613
|
+
|
|
614
|
+
let stack = NSStackView()
|
|
615
|
+
stack.orientation = .vertical
|
|
616
|
+
stack.alignment = .leading
|
|
617
|
+
stack.spacing = 9
|
|
618
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
619
|
+
effect.addSubview(stack)
|
|
620
|
+
|
|
621
|
+
var eyebrow = item.heading.uppercased()
|
|
622
|
+
if item.when > 0 { eyebrow += " · " + Item.age(item.when) }
|
|
623
|
+
if !item.space.isEmpty { eyebrow += " · " + item.space }
|
|
624
|
+
let eye = label(eyebrow, size: 11, weight: .semibold, color: .secondaryLabelColor, width: inner)
|
|
625
|
+
stack.addArrangedSubview(eye)
|
|
626
|
+
let headline = label(item.headline, size: 17, weight: .semibold, color: .labelColor, width: inner)
|
|
627
|
+
panelHeadline = headline
|
|
628
|
+
stack.addArrangedSubview(headline)
|
|
629
|
+
|
|
630
|
+
// the whole text, selectable and scrollable — a decision is often several paragraphs
|
|
631
|
+
let scroll = NSScrollView()
|
|
632
|
+
scroll.hasVerticalScroller = true
|
|
633
|
+
scroll.drawsBackground = false
|
|
634
|
+
scroll.borderType = .noBorder
|
|
635
|
+
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
636
|
+
let tv = NSTextView()
|
|
637
|
+
tv.isEditable = false
|
|
638
|
+
tv.isSelectable = true
|
|
639
|
+
tv.drawsBackground = false
|
|
640
|
+
tv.font = .systemFont(ofSize: 13)
|
|
641
|
+
tv.textColor = .labelColor
|
|
642
|
+
tv.textStorage?.setAttributedString(plain(item.detail))
|
|
643
|
+
tv.textContainerInset = NSSize(width: 0, height: 6)
|
|
644
|
+
tv.isVerticallyResizable = true
|
|
645
|
+
tv.isHorizontallyResizable = false
|
|
646
|
+
tv.autoresizingMask = [.width]
|
|
647
|
+
tv.textContainer?.widthTracksTextView = true
|
|
648
|
+
scroll.documentView = tv
|
|
649
|
+
panelText = tv
|
|
650
|
+
stack.addArrangedSubview(scroll)
|
|
651
|
+
stack.setCustomSpacing(14, after: stack.arrangedSubviews[1])
|
|
652
|
+
|
|
653
|
+
var meta = item.isDecision ? "decision \(item.id)" : "obligation \(item.id)"
|
|
654
|
+
if !item.goal.isEmpty { meta += " · goal \(item.goal)" }
|
|
655
|
+
stack.addArrangedSubview(label(meta, size: 11, weight: .regular, color: .tertiaryLabelColor, width: inner))
|
|
656
|
+
|
|
657
|
+
let status = label("", size: 11, weight: .medium, color: .secondaryLabelColor, width: inner)
|
|
658
|
+
status.isHidden = true
|
|
659
|
+
panelStatus = status
|
|
660
|
+
stack.addArrangedSubview(status)
|
|
661
|
+
|
|
662
|
+
let row = NSStackView()
|
|
663
|
+
row.orientation = .horizontal
|
|
664
|
+
row.spacing = 10
|
|
665
|
+
row.translatesAutoresizingMaskIntoConstraints = false
|
|
666
|
+
let open = NSButton(title: "Open in \(state.surfaceName)", target: self, action: #selector(panelOpen))
|
|
667
|
+
open.bezelStyle = .rounded
|
|
668
|
+
open.keyEquivalent = "\r"
|
|
669
|
+
row.addArrangedSubview(open)
|
|
670
|
+
let copy = NSButton(title: "Copy the question", target: self, action: #selector(panelCopy))
|
|
671
|
+
copy.bezelStyle = .rounded
|
|
672
|
+
row.addArrangedSubview(copy)
|
|
673
|
+
if item.isDecision {
|
|
674
|
+
let done = NSButton(title: "Mark done", target: self, action: #selector(panelDone))
|
|
675
|
+
done.bezelStyle = .rounded
|
|
676
|
+
done.toolTip = "Answer it as handled, so it stops asking"
|
|
677
|
+
row.addArrangedSubview(done)
|
|
678
|
+
}
|
|
679
|
+
let thread = NSButton(title: "Show the thread", target: self, action: #selector(panelThread))
|
|
680
|
+
thread.bezelStyle = .rounded
|
|
681
|
+
row.addArrangedSubview(thread)
|
|
682
|
+
let rawToggle = NSButton(title: "Show it as sent", target: self, action: #selector(panelToggleRaw))
|
|
683
|
+
rawToggle.bezelStyle = .rounded
|
|
684
|
+
rawToggle.toolTip = "The act exactly as your teammate's agent wrote it"
|
|
685
|
+
rawToggle.isHidden = true
|
|
686
|
+
panelRawToggle = rawToggle
|
|
687
|
+
row.addArrangedSubview(rawToggle)
|
|
688
|
+
let close = NSButton(title: "Close", target: self, action: #selector(panelClose))
|
|
689
|
+
close.bezelStyle = .rounded
|
|
690
|
+
close.keyEquivalent = "\u{1b}"
|
|
691
|
+
row.addArrangedSubview(close)
|
|
692
|
+
stack.addArrangedSubview(row)
|
|
693
|
+
|
|
694
|
+
NSLayoutConstraint.activate([
|
|
695
|
+
stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 26),
|
|
696
|
+
stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -26),
|
|
697
|
+
stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: 36),
|
|
698
|
+
stack.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -22),
|
|
699
|
+
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
|
700
|
+
row.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
|
|
701
|
+
])
|
|
702
|
+
let bh = scroll.heightAnchor.constraint(equalToConstant: bodyHeight(plain(item.detail), width: inner))
|
|
703
|
+
bh.isActive = true
|
|
704
|
+
panelBodyHeight = bh
|
|
705
|
+
|
|
706
|
+
panel.contentView = effect
|
|
707
|
+
// Sized to what it holds: a one-line question should not open a half-empty window.
|
|
708
|
+
effect.layoutSubtreeIfNeeded()
|
|
709
|
+
let height = min(max(effect.fittingSize.height + 30, 300), 660)
|
|
710
|
+
panel.setContentSize(NSSize(width: width, height: height))
|
|
711
|
+
panel.center()
|
|
712
|
+
detailPanel = panel
|
|
713
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
714
|
+
panel.makeKeyAndOrderFront(nil)
|
|
715
|
+
panelRendered = nil
|
|
716
|
+
panelRaw = ""
|
|
717
|
+
panelShowingRaw = false
|
|
718
|
+
loadDetail(item, width: inner)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private func setStatus(_ text: String) {
|
|
722
|
+
panelStatus?.stringValue = text
|
|
723
|
+
panelStatus?.isHidden = text.isEmpty
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
@objc private func panelClose() {
|
|
727
|
+
detailPanel?.close()
|
|
728
|
+
detailPanel = nil
|
|
729
|
+
panelText = nil
|
|
730
|
+
panelBodyHeight = nil
|
|
731
|
+
panelItem = nil
|
|
732
|
+
panelHeadline = nil
|
|
733
|
+
panelRendered = nil
|
|
734
|
+
panelRawToggle = nil
|
|
735
|
+
panelRaw = ""
|
|
736
|
+
panelShowingRaw = false
|
|
737
|
+
}
|
|
738
|
+
@objc private func panelCopy() {
|
|
739
|
+
guard let item = panelItem else { return }
|
|
740
|
+
copyPrompt(item)
|
|
741
|
+
setStatus("Copied. Paste it into your agent.")
|
|
742
|
+
}
|
|
743
|
+
@objc private func panelOpen() {
|
|
744
|
+
guard let item = panelItem else { return }
|
|
745
|
+
panelClose()
|
|
746
|
+
openItem(item)
|
|
747
|
+
}
|
|
748
|
+
@objc private func panelThread() {
|
|
749
|
+
guard let item = panelItem else { return }
|
|
750
|
+
panelClose()
|
|
751
|
+
showThread(item)
|
|
752
|
+
}
|
|
753
|
+
/// Answer the decision as handled, with no terminal — the same action the alert's Done takes.
|
|
754
|
+
@objc private func panelDone() {
|
|
755
|
+
guard let item = panelItem, item.isDecision else { return }
|
|
756
|
+
setStatus("Marking it done…")
|
|
757
|
+
var payload: [String: Any] = ["action": "done", "decision": item.id]
|
|
758
|
+
if !item.ref.isEmpty { payload["act"] = item.ref }
|
|
759
|
+
guard writeAction(payload) else {
|
|
760
|
+
setStatus("Could not write the action. Use Show the thread instead.")
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
DispatchQueue.global().async {
|
|
764
|
+
let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
|
|
765
|
+
DispatchQueue.main.async {
|
|
766
|
+
self.setStatus(r?.code == 0
|
|
767
|
+
? "Done — it is no longer waiting on you."
|
|
768
|
+
: "Could not mark it done. Use Show the thread instead.")
|
|
769
|
+
self.refresh()
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// MARK: item actions, shared by the menu rows and the panel
|
|
775
|
+
|
|
776
|
+
private func binDir() -> String {
|
|
777
|
+
(Bundle.main.executablePath as NSString?)?.deletingLastPathComponent
|
|
778
|
+
?? URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().path
|
|
779
|
+
}
|
|
780
|
+
/// What `pantheon explain --queued` reads. Same file the desktop alert writes.
|
|
781
|
+
private func writeAction(_ payload: [String: Any]) -> Bool {
|
|
782
|
+
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return false }
|
|
783
|
+
return (try? data.write(to: URL(fileURLWithPath: binDir() + "/.hud-action.json"), options: .atomic)) != nil
|
|
784
|
+
}
|
|
785
|
+
func copyPrompt(_ item: Item) {
|
|
786
|
+
let pb = NSPasteboard.general
|
|
787
|
+
pb.clearContents()
|
|
788
|
+
pb.setString(item.prompt, forType: .string)
|
|
789
|
+
}
|
|
790
|
+
/// Open the agent with this item's question already written out.
|
|
791
|
+
func openItem(_ item: Item) {
|
|
792
|
+
copyPrompt(item)
|
|
793
|
+
if writeAction(["action": "open", "prompt": item.prompt]) {
|
|
794
|
+
openCommand("pantheon-explain.command")
|
|
795
|
+
} else {
|
|
796
|
+
reportFailure("Could not open your agent. The question is copied; paste it into your agent.")
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/// The act behind it, in Terminal, with the reply menu.
|
|
800
|
+
func showThread(_ item: Item) {
|
|
801
|
+
var payload: [String: Any] = ["action": "details"]
|
|
802
|
+
if !item.ref.isEmpty { payload["act"] = item.ref }
|
|
803
|
+
if item.isDecision { payload["decision"] = item.id }
|
|
804
|
+
if writeAction(payload) { openCommand("pantheon-details.command") }
|
|
805
|
+
else { reportFailure("Could not open Terminal. Run pantheon doctor to check your setup.") }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/// Peers in the order their oldest unanswered thing arrived — longest wait first.
|
|
809
|
+
private func orderedPeers(_ items: [Item]) -> [String] {
|
|
810
|
+
var seen: [String] = []
|
|
811
|
+
for i in items.sorted(by: { $0.when < $1.when }) where !seen.contains(i.who) { seen.append(i.who) }
|
|
812
|
+
return seen
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/// A titled group of rows. Six at most: a menu is for triage, and anything
|
|
816
|
+
/// deeper belongs in `pantheon brief`, which the last row opens.
|
|
817
|
+
private func addRows(_ menu: NSMenu, _ title: String, _ items: [Item]) {
|
|
818
|
+
guard !items.isEmpty else { return }
|
|
819
|
+
let header = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
820
|
+
header.isEnabled = false
|
|
821
|
+
header.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
822
|
+
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
|
823
|
+
.foregroundColor: NSColor.secondaryLabelColor,
|
|
824
|
+
])
|
|
825
|
+
menu.addItem(header)
|
|
826
|
+
for item in items.prefix(6) {
|
|
827
|
+
let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
|
|
828
|
+
mi.toolTip = item.detail
|
|
829
|
+
mi.view = RowView(item: item, width: 360,
|
|
830
|
+
onOpen: { [weak self] it in self?.showDetail(it) },
|
|
831
|
+
onCopy: { [weak self] it in self?.copyPrompt(it) })
|
|
832
|
+
menu.addItem(mi)
|
|
833
|
+
}
|
|
834
|
+
if items.count > 6 {
|
|
835
|
+
let more = NSMenuItem(title: "…and \(items.count - 6) more", action: nil, keyEquivalent: "")
|
|
836
|
+
more.isEnabled = false
|
|
837
|
+
menu.addItem(more)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
277
841
|
private func makeItem(_ title: String, _ sel: Selector) -> NSMenuItem {
|
|
278
842
|
let mi = NSMenuItem(title: title, action: sel, keyEquivalent: "")
|
|
279
843
|
mi.target = self
|
|
@@ -307,10 +871,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
307
871
|
@objc private func openStatus() { openCommand("pantheon-status.command") }
|
|
308
872
|
@objc private func openDoctor() { openCommand("pantheon-doctor.command") }
|
|
309
873
|
@objc private func openApprovals() { openCommand("pantheon-approvals.command") }
|
|
310
|
-
@objc private func openDecision(_ sender: NSMenuItem) {
|
|
311
|
-
let pair = sender.representedObject as? [String] ?? []
|
|
312
|
-
openAgent(id: pair.first ?? "all", prompt: pair.count > 1 ? pair[1] : "")
|
|
313
|
-
}
|
|
314
874
|
@objc private func openAllDecisions() { openAgent(id: "all", prompt: state.decisionsPrompt) }
|
|
315
875
|
/**
|
|
316
876
|
* The handoff: queue the chosen decision id for `pantheon explain --queued`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.4",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|