@join-pantheon/cli 0.26.4 → 0.26.5
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 +75 -2
- package/dist/history-view.js +2 -1
- package/dist/ledger.js +3 -0
- package/dist/mcp.js +1 -0
- package/dist/relay.js +8 -3
- package/dist/tray.js +2 -2
- package/dist/workdir.js +30 -0
- package/helper/macos/menubar.swift +286 -38
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -326,6 +326,20 @@ async function guidedSetup(cfg, project, opts = {}) {
|
|
|
326
326
|
rl?.close();
|
|
327
327
|
}
|
|
328
328
|
}
|
|
329
|
+
async function workdirFor(cfg, hud) {
|
|
330
|
+
const { loadAll, findMembership } = await import("./config.js");
|
|
331
|
+
const { pickWorkdir } = await import("./workdir.js");
|
|
332
|
+
const all = loadAll();
|
|
333
|
+
const m = all ? findMembership(all, hud.space || cfg.space) : undefined;
|
|
334
|
+
const team = m?.name ?? cfg.space;
|
|
335
|
+
const bindings = all && m ? Object.values(all.bindings ?? {}).filter((b) => b.space === m.id) : [];
|
|
336
|
+
let hints = [];
|
|
337
|
+
if (bindings.length > 1 && hud.goal) {
|
|
338
|
+
const acts = await fetchHistory(cfg).catch(() => []);
|
|
339
|
+
hints = acts.filter((x) => x.goal === hud.goal).flatMap((x) => [x.body, JSON.stringify(x.meta ?? {})]);
|
|
340
|
+
}
|
|
341
|
+
return pickWorkdir(bindings, hints, team);
|
|
342
|
+
}
|
|
329
343
|
async function fetchHistory(cfg, since = 0) {
|
|
330
344
|
const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
331
345
|
const res = await fetch(`${base}/history?space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}&since=${since}`, { signal: AbortSignal.timeout(8000) });
|
|
@@ -2262,6 +2276,46 @@ async function main() {
|
|
|
2262
2276
|
}
|
|
2263
2277
|
return;
|
|
2264
2278
|
}
|
|
2279
|
+
if (hud?.action === "reply") {
|
|
2280
|
+
const notSent = (why) => { console.log(`not sent: ${why.split("\n")[0]}`); process.exit(1); };
|
|
2281
|
+
const kind = hud.kind ?? "NOTE";
|
|
2282
|
+
const body = (hud.body ?? "").trim();
|
|
2283
|
+
if (!body)
|
|
2284
|
+
notSent("nothing to send");
|
|
2285
|
+
const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2286
|
+
await c2.connect().catch((e) => notSent(e.message));
|
|
2287
|
+
const o = hud.decision ? c2.obligations.find((x) => x.id === hud.decision) : undefined;
|
|
2288
|
+
const acts = await fetchHistory(cfg).catch(() => []);
|
|
2289
|
+
const target = acts.find((x) => x.id === (o?.ref ?? hud.act));
|
|
2290
|
+
const goal = o?.goal ?? target?.goal ?? hud.goal ?? null;
|
|
2291
|
+
if (kind === "ANSWER" && !o && !target) {
|
|
2292
|
+
c2.close();
|
|
2293
|
+
notSent("that decision is no longer waiting — nothing to answer");
|
|
2294
|
+
}
|
|
2295
|
+
const ref = kind === "ANSWER" ? (o?.ref ?? target?.id ?? null) : kind === "ASK" ? (target?.id ?? null) : null;
|
|
2296
|
+
const to = kind === "ASK" ? (target && target.from !== cfg.member ? [target.from] : null) : null;
|
|
2297
|
+
if (kind === "ASK" && !to) {
|
|
2298
|
+
c2.close();
|
|
2299
|
+
notSent("cannot tell who raised this — use Show the thread and reply from there");
|
|
2300
|
+
}
|
|
2301
|
+
const sent = await c2.send({ act: kind, body, goal, ref, to, after_seq: c2.readSeq, meta: { surface: "menubar" } })
|
|
2302
|
+
.catch((e) => notSent(e.message));
|
|
2303
|
+
c2.close();
|
|
2304
|
+
console.log(`sent ${kind} id=${sent.id} seq=${sent.seq}`);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
if (hud?.action === "retract") {
|
|
2308
|
+
const notSent = (why) => { console.log(`not sent: ${why.split("\n")[0]}`); process.exit(1); };
|
|
2309
|
+
if (!hud.act)
|
|
2310
|
+
notSent("nothing to retract");
|
|
2311
|
+
const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2312
|
+
await c2.connect().catch((e) => notSent(e.message));
|
|
2313
|
+
const sent = await c2.send({ act: "RETRACT", body: "Retracted from the menu bar.", goal: null, ref: hud.act, to: null, meta: { surface: "menubar" } })
|
|
2314
|
+
.catch((e) => notSent(e.message));
|
|
2315
|
+
c2.close();
|
|
2316
|
+
console.log(`sent RETRACT id=${sent.id} seq=${sent.seq}`);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2265
2319
|
if (hud?.action === "done") {
|
|
2266
2320
|
const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2267
2321
|
await c2.connect().catch((e) => die(e.message));
|
|
@@ -2278,6 +2332,15 @@ async function main() {
|
|
|
2278
2332
|
return;
|
|
2279
2333
|
}
|
|
2280
2334
|
if (hud?.action === "details") {
|
|
2335
|
+
const wd = await workdirFor(cfg, hud).catch(() => null);
|
|
2336
|
+
if (wd?.path) {
|
|
2337
|
+
try {
|
|
2338
|
+
process.chdir(wd.path);
|
|
2339
|
+
}
|
|
2340
|
+
catch { }
|
|
2341
|
+
}
|
|
2342
|
+
if (wd)
|
|
2343
|
+
console.error(D(` ${wd.note}`));
|
|
2281
2344
|
const acts = await fetchHistory(cfg).catch(() => []);
|
|
2282
2345
|
const a = hud.act ? acts.find((x) => x.id === hud.act) : undefined;
|
|
2283
2346
|
let o;
|
|
@@ -2377,6 +2440,10 @@ async function main() {
|
|
|
2377
2440
|
}
|
|
2378
2441
|
if (hud?.action === "open") {
|
|
2379
2442
|
const p = loadProfile(cfg);
|
|
2443
|
+
const wd = await workdirFor(cfg, hud).catch(() => null);
|
|
2444
|
+
const cwd = wd?.path ?? undefined;
|
|
2445
|
+
if (wd)
|
|
2446
|
+
console.error(D(` ${wd.note}`));
|
|
2380
2447
|
if (hud.tool && isAgentSurface(hud.tool) && p.agent.primary_surface !== hud.tool) {
|
|
2381
2448
|
saveProfile({ ...p, agent: { ...p.agent, primary_surface: hud.tool }, updated_at: Date.now() });
|
|
2382
2449
|
console.error(D(`${hud.tool} is now where your decisions open`));
|
|
@@ -2388,7 +2455,7 @@ async function main() {
|
|
|
2388
2455
|
const { spawnSync } = await import("node:child_process");
|
|
2389
2456
|
{
|
|
2390
2457
|
const sp = agentSpawn(bin, [hud.prompt]);
|
|
2391
|
-
spawnSync(sp.command, sp.args, { stdio: "inherit", ...sp.options });
|
|
2458
|
+
spawnSync(sp.command, sp.args, { stdio: "inherit", ...(cwd ? { cwd } : {}), ...sp.options });
|
|
2392
2459
|
}
|
|
2393
2460
|
return;
|
|
2394
2461
|
}
|
|
@@ -2400,12 +2467,18 @@ async function main() {
|
|
|
2400
2467
|
const { spawnSync } = await import("node:child_process");
|
|
2401
2468
|
{
|
|
2402
2469
|
const sp = agentSpawn(agent.binary, launchArgs(agent.id, hud.prompt, sessionName(hud.prompt)));
|
|
2403
|
-
spawnSync(sp.command, sp.args, { stdio: "inherit", ...sp.options });
|
|
2470
|
+
spawnSync(sp.command, sp.args, { stdio: "inherit", ...(cwd ? { cwd } : {}), ...sp.options });
|
|
2404
2471
|
}
|
|
2405
2472
|
return;
|
|
2406
2473
|
}
|
|
2407
2474
|
if (hud.decision)
|
|
2408
2475
|
id = hud.decision;
|
|
2476
|
+
if (cwd) {
|
|
2477
|
+
try {
|
|
2478
|
+
process.chdir(cwd);
|
|
2479
|
+
}
|
|
2480
|
+
catch { }
|
|
2481
|
+
}
|
|
2409
2482
|
}
|
|
2410
2483
|
const client = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
2411
2484
|
await client.connect().catch((e) => die(e.message));
|
package/dist/history-view.js
CHANGED
|
@@ -8,7 +8,8 @@ export function selectActs(acts, opts = {}) {
|
|
|
8
8
|
}
|
|
9
9
|
const when = (ts) => new Date(ts).toISOString().slice(0, 19).replace("T", " ") + "Z";
|
|
10
10
|
export function formatAct(a, opts = {}) {
|
|
11
|
-
const
|
|
11
|
+
const surface = a.meta?.surface;
|
|
12
|
+
const who = `${a.from}${surface === "menubar" ? " (from the menu bar)" : a.role && a.role !== "interactive" ? ` (${a.role})` : ""}`;
|
|
12
13
|
const head = `[${a.seq}] ${a.act} from ${who}${a.to?.length ? ` to ${a.to.join(",")}` : ""} ${when(a.ts)} id=${a.id}${a.ref ? ` ref=${a.ref}` : ""}${a.goal ? ` goal=${a.goal}${opts.goalTitle ? ` "${opts.goalTitle}"` : ""}` : ""}`;
|
|
13
14
|
if (opts.short)
|
|
14
15
|
return `${head}\n ${(a.summary ?? a.body).split("\n")[0].slice(0, 160)}`;
|
package/dist/ledger.js
CHANGED
|
@@ -35,6 +35,9 @@ export class Ledger {
|
|
|
35
35
|
since(seq, limit = 500) {
|
|
36
36
|
return this.records.filter((r) => r.seq > seq).slice(0, limit);
|
|
37
37
|
}
|
|
38
|
+
latest(limit = 500) {
|
|
39
|
+
return this.records.slice(Math.max(0, this.records.length - limit));
|
|
40
|
+
}
|
|
38
41
|
byGoal(goal, limit = 200) {
|
|
39
42
|
const hits = this.records.filter((r) => r.goal === goal);
|
|
40
43
|
return hits.slice(Math.max(0, hits.length - limit));
|
package/dist/mcp.js
CHANGED
|
@@ -473,6 +473,7 @@ export async function runMcp() {
|
|
|
473
473
|
url.pathname = "/history";
|
|
474
474
|
url.searchParams.set("space", cfg.space);
|
|
475
475
|
url.searchParams.set("token", cfg.token);
|
|
476
|
+
url.searchParams.set("goal", goal);
|
|
476
477
|
return fetch(url).then(async (res) => {
|
|
477
478
|
const { acts } = (await res.json());
|
|
478
479
|
const hits = acts.filter((a) => a.goal === goal).slice(-(limit ?? 60));
|
package/dist/relay.js
CHANGED
|
@@ -288,7 +288,10 @@ export class Relay {
|
|
|
288
288
|
const token = url.searchParams.get("token") ?? "";
|
|
289
289
|
if (!space || space.token !== token)
|
|
290
290
|
return this.json(res, 404, { error: "no such space" });
|
|
291
|
-
|
|
291
|
+
const goal = url.searchParams.get("goal");
|
|
292
|
+
const since = Number(url.searchParams.get("since") ?? 0);
|
|
293
|
+
const acts = goal ? space.ledger.byGoal(goal) : since > 0 ? space.ledger.since(since) : space.ledger.latest();
|
|
294
|
+
return this.json(res, 200, { acts, head: space.ledger.head });
|
|
292
295
|
}
|
|
293
296
|
if (url.pathname === "/obligations") {
|
|
294
297
|
const space = this.spaces.get(url.searchParams.get("space") ?? "");
|
|
@@ -2231,11 +2234,13 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2231
2234
|
return { code: "GOAL_CLOSED", message: `goal "${goal.title}" is closed` };
|
|
2232
2235
|
if (!goal.members.includes(me.id))
|
|
2233
2236
|
return { code: "NOT_IN_GOAL", message: `you are not a member of "${goal.title}"` };
|
|
2234
|
-
|
|
2237
|
+
const settlesOwnHuman = act === "ANSWER" && !!ref
|
|
2238
|
+
&& [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === ref && o.owner === `human:${me.id}`);
|
|
2239
|
+
if (goal.acts >= goal.budget && !settlesOwnHuman) {
|
|
2235
2240
|
return { code: "BUDGET_SPENT", message: `goal "${goal.title}" spent its ${goal.budget}-act budget and needs a human` };
|
|
2236
2241
|
}
|
|
2237
2242
|
const run = sp.lastActor.get(goalId);
|
|
2238
|
-
if (goal.kind !== "worklist" && run && run.member === me.id && run.run >= LIMITS.consecutive && act !== "ESCALATE" && act !== "CLOSE") {
|
|
2243
|
+
if (!settlesOwnHuman && goal.kind !== "worklist" && run && run.member === me.id && run.run >= LIMITS.consecutive && act !== "ESCALATE" && act !== "CLOSE") {
|
|
2239
2244
|
return {
|
|
2240
2245
|
code: "PEER_TURN",
|
|
2241
2246
|
message: `you have sent ${run.run} acts in a row in "${goal.title}". Wait for a peer (pantheon_wait), or ESCALATE if nobody can move it.`,
|
package/dist/tray.js
CHANGED
|
@@ -131,14 +131,14 @@ async function trayStatusOne(cfg) {
|
|
|
131
131
|
const items = [
|
|
132
132
|
...mineHuman.map((o) => {
|
|
133
133
|
const from = senderOf(o);
|
|
134
|
-
return row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body)));
|
|
134
|
+
return { ...row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body))), decider: cfg.member };
|
|
135
135
|
}),
|
|
136
136
|
...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
|
|
137
137
|
...waiting.map((o) => {
|
|
138
138
|
const who = o.owner.replace(/^human:/, "");
|
|
139
139
|
const r = row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
|
|
140
140
|
return o.owner.startsWith("human:")
|
|
141
|
-
? { ...r, title: `${who} decides: ${r.title}`, headline: `${who} decides: ${r.headline}
|
|
141
|
+
? { ...r, title: `${who} decides: ${r.title}`, headline: `${who} decides: ${r.headline}`, decider: who }
|
|
142
142
|
: r;
|
|
143
143
|
}),
|
|
144
144
|
];
|
package/dist/workdir.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
export function gitActivity(path) {
|
|
4
|
+
for (const f of ["index", "FETCH_HEAD", "HEAD"]) {
|
|
5
|
+
try {
|
|
6
|
+
return statSync(join(path, ".git", f)).mtimeMs;
|
|
7
|
+
}
|
|
8
|
+
catch { }
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
export function pickWorkdir(bindings, hints, team, activity = gitActivity, exists = existsSync) {
|
|
13
|
+
const live = bindings.filter((b) => exists(b.path));
|
|
14
|
+
if (!live.length) {
|
|
15
|
+
return {
|
|
16
|
+
path: null,
|
|
17
|
+
note: `no folder on this machine is bound to "${team}", so this opens in your home folder. To fix it, run pantheon use ${team} --here inside the repo.`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const named = live.filter((b) => hints.some((h) => (b.origin ? h.includes(b.origin) : false) || h.includes(b.path)));
|
|
21
|
+
const pool = named.length ? named : live;
|
|
22
|
+
const chosen = [...pool].sort((x, y) => (activity(y.path) - activity(x.path)) || (y.bound_at - x.bound_at))[0];
|
|
23
|
+
const others = live.filter((b) => b.path !== chosen.path).map((b) => basename(b.path));
|
|
24
|
+
const why = named.length ? "named in this goal" : live.length > 1 ? "your most recent git activity" : "";
|
|
25
|
+
return {
|
|
26
|
+
path: chosen.path,
|
|
27
|
+
note: `in ${basename(chosen.path)}${why ? ` (${why})` : ""}${others.length ? ` — "${team}" is also bound to ${others.join(", ")}` : ""}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=workdir.js.map
|
|
@@ -68,6 +68,8 @@ struct Item {
|
|
|
68
68
|
let goal: String
|
|
69
69
|
let ref: String
|
|
70
70
|
let space: String
|
|
71
|
+
/// whose human must decide this: this member for their own decision, the teammate for theirs, "" when it is not a decision
|
|
72
|
+
let decider: String
|
|
71
73
|
|
|
72
74
|
var isDecision: Bool { kind == "decision" }
|
|
73
75
|
var heading: String {
|
|
@@ -152,7 +154,8 @@ func fetchState() -> TrayState {
|
|
|
152
154
|
when: (i["when"] as? Double) ?? Double(i["when"] as? Int ?? 0),
|
|
153
155
|
goal: i["goal"] as? String ?? "",
|
|
154
156
|
ref: i["ref"] as? String ?? "",
|
|
155
|
-
space: i["space"] as? String ?? ""
|
|
157
|
+
space: i["space"] as? String ?? "",
|
|
158
|
+
decider: i["decider"] as? String ?? ""
|
|
156
159
|
))
|
|
157
160
|
}
|
|
158
161
|
for d in (j["decisions"] as? [[String: Any]] ?? []) {
|
|
@@ -840,7 +843,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
840
843
|
private var panelRendered: NSAttributedString? // the act laid out for a person
|
|
841
844
|
private var panelRaw: String = "" // the act exactly as it was sent
|
|
842
845
|
private var panelShowingRaw = false
|
|
843
|
-
private var
|
|
846
|
+
private var panelRawButton: NSButton? // "Original text" / "Laid out", shown only when the two differ
|
|
847
|
+
private var panelButtons: NSStackView? // the action row; hidden while replying
|
|
848
|
+
private var panelReplyRow: NSStackView? // the reply box; shown only in reply mode
|
|
849
|
+
private var panelCancel: NSButton?
|
|
850
|
+
private var panelReplyButton: NSButton? // the primary action, hidden while replying
|
|
851
|
+
private var panelStatusRow: NSStackView? // collapses when there is nothing to say
|
|
852
|
+
private var panelReply: NSTextView? // the person's own words
|
|
853
|
+
private var panelReplyKind: NSPopUpButton? // Answer / Note / Ask back
|
|
854
|
+
private var panelSendButton: NSButton?
|
|
855
|
+
private var panelRetract: NSButton? // the undo, for a minute after a send
|
|
856
|
+
private var panelLastSent: String = "" // act id of what was just sent
|
|
844
857
|
|
|
845
858
|
private func label(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor, width: CGFloat) -> NSTextField {
|
|
846
859
|
let l = NSTextField(labelWithString: text)
|
|
@@ -951,7 +964,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
951
964
|
guard self.panelItem?.id == item.id else { return }
|
|
952
965
|
self.panelRendered = rendered
|
|
953
966
|
self.panelRaw = raw
|
|
954
|
-
self.
|
|
967
|
+
self.panelRawButton?.isHidden = raw.isEmpty
|
|
955
968
|
if !headline.isEmpty { self.panelHeadline?.stringValue = headline }
|
|
956
969
|
if !self.panelShowingRaw && rendered.length > 0 { self.showBody(rendered, width: width) }
|
|
957
970
|
}
|
|
@@ -962,7 +975,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
962
975
|
@objc private func panelToggleRaw() {
|
|
963
976
|
guard let item = panelItem else { return }
|
|
964
977
|
panelShowingRaw.toggle()
|
|
965
|
-
|
|
978
|
+
panelRawButton?.title = panelShowingRaw ? "Laid out" : "Original text"
|
|
966
979
|
let width: CGFloat = 620 - 52
|
|
967
980
|
if panelShowingRaw { showBody(plain(panelRaw.isEmpty ? item.detail : panelRaw), width: width) }
|
|
968
981
|
else if let r = panelRendered { showBody(r, width: width) }
|
|
@@ -1037,42 +1050,161 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
1037
1050
|
if !item.goal.isEmpty { meta += " · goal \(item.goal)" }
|
|
1038
1051
|
stack.addArrangedSubview(label(meta, size: 11, weight: .regular, color: .tertiaryLabelColor, width: inner))
|
|
1039
1052
|
|
|
1040
|
-
|
|
1053
|
+
// Whose decision this is, said before the box that answers it. Your own
|
|
1054
|
+
// answer to a teammate's decision lands as an act and settles nothing, so the
|
|
1055
|
+
// panel offers Answer only to the person it is waiting on.
|
|
1056
|
+
let owner: String = item.isDecision ? "Yours to decide."
|
|
1057
|
+
: !item.decider.isEmpty ? "\(item.decider)'s to decide. You can add a note or ask back."
|
|
1058
|
+
: item.kind == "owed" ? "Your agent owes this. You can add a note or ask back."
|
|
1059
|
+
: "Waiting on \(item.who). You can add a note or ask back."
|
|
1060
|
+
let ownerRow = NSStackView()
|
|
1061
|
+
ownerRow.orientation = .horizontal
|
|
1062
|
+
ownerRow.alignment = .centerY
|
|
1063
|
+
ownerRow.spacing = 10
|
|
1064
|
+
ownerRow.translatesAutoresizingMaskIntoConstraints = false
|
|
1065
|
+
let ownerLabel = label(owner, size: 12, weight: .medium, color: .secondaryLabelColor, width: inner - 170)
|
|
1066
|
+
ownerLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1067
|
+
ownerRow.addArrangedSubview(ownerLabel)
|
|
1068
|
+
let ownerSpacer = NSView()
|
|
1069
|
+
ownerSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
1070
|
+
ownerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1071
|
+
ownerRow.addArrangedSubview(ownerSpacer)
|
|
1072
|
+
// The one action that is the person's own voice, set apart from the rest and
|
|
1073
|
+
// reachable with Return. Pressing it only opens the box; nothing is sent.
|
|
1074
|
+
let replyBtn = NSButton(title: "Reply manually", target: self, action: #selector(panelReplyManually))
|
|
1075
|
+
replyBtn.bezelStyle = .rounded
|
|
1076
|
+
replyBtn.keyEquivalent = "\r"
|
|
1077
|
+
replyBtn.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
1078
|
+
replyBtn.toolTip = "Type your own answer, note or question. Nothing is drafted for you, and nothing is sent until you press Send."
|
|
1079
|
+
panelReplyButton = replyBtn
|
|
1080
|
+
ownerRow.addArrangedSubview(replyBtn)
|
|
1081
|
+
ownerRow.widthAnchor.constraint(equalToConstant: inner).isActive = true
|
|
1082
|
+
if let idLine = stack.arrangedSubviews.last { stack.setCustomSpacing(6, after: idLine) }
|
|
1083
|
+
stack.addArrangedSubview(ownerRow)
|
|
1084
|
+
// Clear air between the person's own action and the rest: a rule, with room
|
|
1085
|
+
// on both sides, so the blue button is not read as part of the row below.
|
|
1086
|
+
stack.setCustomSpacing(22, after: ownerRow)
|
|
1087
|
+
let rule = NSBox()
|
|
1088
|
+
rule.boxType = .separator
|
|
1089
|
+
rule.translatesAutoresizingMaskIntoConstraints = false
|
|
1090
|
+
rule.widthAnchor.constraint(equalToConstant: inner).isActive = true
|
|
1091
|
+
stack.addArrangedSubview(rule)
|
|
1092
|
+
stack.setCustomSpacing(22, after: rule)
|
|
1093
|
+
|
|
1094
|
+
// The reply, in the person's own words: one popup for what it is, the box,
|
|
1095
|
+
// one Send. Return sends; Shift-Return makes a new line.
|
|
1096
|
+
let replyRow = NSStackView()
|
|
1097
|
+
replyRow.orientation = .horizontal
|
|
1098
|
+
replyRow.alignment = .top
|
|
1099
|
+
replyRow.spacing = 8
|
|
1100
|
+
replyRow.translatesAutoresizingMaskIntoConstraints = false
|
|
1101
|
+
let kinds = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
1102
|
+
for k in replyKinds(item) { kinds.addItem(withTitle: k) }
|
|
1103
|
+
kinds.toolTip = "Answer settles it and the agent continues. Note goes on the record and settles nothing. Ask back puts a question to the agent that raised it."
|
|
1104
|
+
kinds.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1105
|
+
panelReplyKind = kinds
|
|
1106
|
+
replyRow.addArrangedSubview(kinds)
|
|
1107
|
+
let replyScroll = NSScrollView()
|
|
1108
|
+
replyScroll.hasVerticalScroller = false
|
|
1109
|
+
replyScroll.borderType = .bezelBorder
|
|
1110
|
+
replyScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
1111
|
+
let reply = NSTextView()
|
|
1112
|
+
reply.isEditable = true
|
|
1113
|
+
reply.isRichText = false
|
|
1114
|
+
reply.font = .systemFont(ofSize: 13)
|
|
1115
|
+
reply.textContainerInset = NSSize(width: 4, height: 6)
|
|
1116
|
+
reply.isVerticallyResizable = true
|
|
1117
|
+
reply.isHorizontallyResizable = false
|
|
1118
|
+
reply.autoresizingMask = [.width]
|
|
1119
|
+
reply.textContainer?.widthTracksTextView = true
|
|
1120
|
+
reply.delegate = self
|
|
1121
|
+
replyScroll.documentView = reply
|
|
1122
|
+
replyScroll.heightAnchor.constraint(equalToConstant: 58).isActive = true
|
|
1123
|
+
replyScroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1124
|
+
panelReply = reply
|
|
1125
|
+
replyRow.addArrangedSubview(replyScroll)
|
|
1126
|
+
let send = NSButton(title: "Send", target: self, action: #selector(panelSend(_:)))
|
|
1127
|
+
send.bezelStyle = .rounded
|
|
1128
|
+
send.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1129
|
+
panelSendButton = send
|
|
1130
|
+
replyRow.addArrangedSubview(send)
|
|
1131
|
+
let cancel = NSButton(title: "Cancel", target: self, action: #selector(panelCancelReply))
|
|
1132
|
+
cancel.bezelStyle = .rounded
|
|
1133
|
+
cancel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1134
|
+
cancel.toolTip = "Back to the buttons. Your words stay in the box."
|
|
1135
|
+
panelCancel = cancel
|
|
1136
|
+
replyRow.addArrangedSubview(cancel)
|
|
1137
|
+
replyRow.widthAnchor.constraint(equalToConstant: inner).isActive = true
|
|
1138
|
+
panelReplyRow = replyRow
|
|
1139
|
+
stack.addArrangedSubview(replyRow)
|
|
1140
|
+
replyRow.isHidden = true
|
|
1141
|
+
|
|
1142
|
+
// What happened, and the undo for a slip: Retract shows for a minute after a send.
|
|
1143
|
+
let statusRow = NSStackView()
|
|
1144
|
+
statusRow.orientation = .horizontal
|
|
1145
|
+
statusRow.spacing = 10
|
|
1146
|
+
statusRow.translatesAutoresizingMaskIntoConstraints = false
|
|
1147
|
+
let status = label("", size: 11, weight: .medium, color: .secondaryLabelColor, width: inner - 90)
|
|
1041
1148
|
status.isHidden = true
|
|
1042
1149
|
panelStatus = status
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1150
|
+
statusRow.addArrangedSubview(status)
|
|
1151
|
+
let retract = NSButton(title: "Retract", target: self, action: #selector(panelRetractAction))
|
|
1152
|
+
retract.bezelStyle = .rounded
|
|
1153
|
+
retract.controlSize = .small
|
|
1154
|
+
retract.toolTip = "Withdraws what you just sent. Your words stay in the record as retracted."
|
|
1155
|
+
retract.isHidden = true
|
|
1156
|
+
panelRetract = retract
|
|
1157
|
+
statusRow.addArrangedSubview(retract)
|
|
1158
|
+
panelStatusRow = statusRow
|
|
1159
|
+
stack.addArrangedSubview(statusRow)
|
|
1160
|
+
statusRow.isHidden = true
|
|
1161
|
+
|
|
1162
|
+
// Two rows: what to do about it, then what to do with the text. Every
|
|
1163
|
+
// button refuses to compress, so the labels are always whole.
|
|
1164
|
+
func button(_ title: String, _ sel: Selector, _ tip: String) -> NSButton {
|
|
1165
|
+
let b = NSButton(title: title, target: self, action: sel)
|
|
1166
|
+
b.bezelStyle = .rounded
|
|
1167
|
+
b.toolTip = tip
|
|
1168
|
+
b.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
1169
|
+
return b
|
|
1170
|
+
}
|
|
1171
|
+
let rows = NSStackView()
|
|
1172
|
+
rows.orientation = .vertical
|
|
1173
|
+
rows.alignment = .leading
|
|
1174
|
+
rows.spacing = 8
|
|
1175
|
+
rows.translatesAutoresizingMaskIntoConstraints = false
|
|
1176
|
+
|
|
1177
|
+
let row1 = NSStackView()
|
|
1178
|
+
row1.orientation = .horizontal
|
|
1179
|
+
row1.spacing = 10
|
|
1180
|
+
row1.addArrangedSubview(button("Explain in \(state.surfaceName)", #selector(panelOpen),
|
|
1181
|
+
"Opens \(state.surfaceName) in the team's folder and asks it to lay out your options. It will not decide or reply for you."))
|
|
1056
1182
|
if item.isDecision {
|
|
1057
|
-
|
|
1058
|
-
done.bezelStyle = .rounded
|
|
1059
|
-
done.toolTip = "Answer it as handled, so it stops asking"
|
|
1060
|
-
row.addArrangedSubview(done)
|
|
1183
|
+
row1.addArrangedSubview(button("Mark done", #selector(panelDone), "Tells the agent you settled this in person, so it stops asking."))
|
|
1061
1184
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
let
|
|
1066
|
-
|
|
1067
|
-
|
|
1185
|
+
row1.addArrangedSubview(button("Show the thread", #selector(panelThread), "Every act in this goal, in Terminal, from the team's folder."))
|
|
1186
|
+
rows.addArrangedSubview(row1)
|
|
1187
|
+
|
|
1188
|
+
let row2 = NSStackView()
|
|
1189
|
+
row2.orientation = .horizontal
|
|
1190
|
+
row2.spacing = 10
|
|
1191
|
+
row2.addArrangedSubview(button("Copy the question", #selector(panelCopy), "Copies the question and its context as text, for an email or a message."))
|
|
1192
|
+
let rawToggle = button("Original text", #selector(panelToggleRaw), "Exactly as the agent wrote it, before Pantheon laid it out.")
|
|
1068
1193
|
rawToggle.isHidden = true
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
let
|
|
1072
|
-
|
|
1194
|
+
panelRawButton = rawToggle
|
|
1195
|
+
row2.addArrangedSubview(rawToggle)
|
|
1196
|
+
let spacer = NSView()
|
|
1197
|
+
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
1198
|
+
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1199
|
+
row2.addArrangedSubview(spacer)
|
|
1200
|
+
let close = button("Close", #selector(panelClose), "Nothing changes.")
|
|
1073
1201
|
close.keyEquivalent = "\u{1b}"
|
|
1074
|
-
|
|
1075
|
-
|
|
1202
|
+
row2.addArrangedSubview(close)
|
|
1203
|
+
row2.widthAnchor.constraint(equalToConstant: inner).isActive = true
|
|
1204
|
+
rows.addArrangedSubview(row2)
|
|
1205
|
+
|
|
1206
|
+
panelButtons = rows
|
|
1207
|
+
stack.addArrangedSubview(rows)
|
|
1076
1208
|
|
|
1077
1209
|
NSLayoutConstraint.activate([
|
|
1078
1210
|
stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: 26),
|
|
@@ -1080,7 +1212,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
1080
1212
|
stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: 36),
|
|
1081
1213
|
stack.bottomAnchor.constraint(equalTo: effect.bottomAnchor, constant: -22),
|
|
1082
1214
|
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
|
1083
|
-
|
|
1215
|
+
rows.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
|
|
1084
1216
|
])
|
|
1085
1217
|
let bh = scroll.heightAnchor.constraint(equalToConstant: bodyHeight(plain(item.detail), width: inner))
|
|
1086
1218
|
bh.isActive = true
|
|
@@ -1104,6 +1236,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
1104
1236
|
private func setStatus(_ text: String) {
|
|
1105
1237
|
panelStatus?.stringValue = text
|
|
1106
1238
|
panelStatus?.isHidden = text.isEmpty
|
|
1239
|
+
panelStatusRow?.isHidden = text.isEmpty && (panelRetract?.isHidden ?? true)
|
|
1107
1240
|
}
|
|
1108
1241
|
|
|
1109
1242
|
@objc private func panelClose() {
|
|
@@ -1114,14 +1247,120 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
1114
1247
|
panelItem = nil
|
|
1115
1248
|
panelHeadline = nil
|
|
1116
1249
|
panelRendered = nil
|
|
1117
|
-
|
|
1250
|
+
panelRawButton = nil
|
|
1251
|
+
panelButtons = nil
|
|
1252
|
+
panelReplyRow = nil
|
|
1253
|
+
panelCancel = nil
|
|
1254
|
+
panelReplyButton = nil
|
|
1255
|
+
panelStatusRow = nil
|
|
1256
|
+
panelReply = nil
|
|
1257
|
+
panelReplyKind = nil
|
|
1258
|
+
panelSendButton = nil
|
|
1259
|
+
panelRetract = nil
|
|
1260
|
+
panelLastSent = ""
|
|
1118
1261
|
panelRaw = ""
|
|
1119
1262
|
panelShowingRaw = false
|
|
1120
1263
|
}
|
|
1264
|
+
/// The question as text — what a person pasting into an email or a message
|
|
1265
|
+
/// wants. The agent prompt still rides with Explain, which copies it as a
|
|
1266
|
+
/// fallback when the agent cannot be opened.
|
|
1121
1267
|
@objc private func panelCopy() {
|
|
1122
1268
|
guard let item = panelItem else { return }
|
|
1123
|
-
|
|
1124
|
-
|
|
1269
|
+
let pb = NSPasteboard.general
|
|
1270
|
+
pb.clearContents()
|
|
1271
|
+
pb.setString(item.headline + "\n\n" + plain(panelRaw.isEmpty ? item.detail : panelRaw).string, forType: .string)
|
|
1272
|
+
setStatus("Copied the question.")
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/// Reply mode: the buttons step aside and only the box remains, with Cancel to
|
|
1276
|
+
/// bring them back. The body gives up a little height so the panel stays put.
|
|
1277
|
+
@objc private func panelReplyManually() {
|
|
1278
|
+
panelButtons?.isHidden = true
|
|
1279
|
+
panelReplyButton?.isHidden = true
|
|
1280
|
+
panelReplyButton?.keyEquivalent = ""
|
|
1281
|
+
panelSendButton?.keyEquivalent = "\r"
|
|
1282
|
+
panelReplyRow?.isHidden = false
|
|
1283
|
+
if let c = panelBodyHeight, c.constant > 140 { c.constant -= 20 }
|
|
1284
|
+
panelRetract?.isHidden = true
|
|
1285
|
+
setStatus("")
|
|
1286
|
+
if let tv = panelReply { detailPanel?.makeFirstResponder(tv) }
|
|
1287
|
+
}
|
|
1288
|
+
@objc private func panelCancelReply() { exitReplyMode() }
|
|
1289
|
+
private func exitReplyMode() {
|
|
1290
|
+
panelReplyRow?.isHidden = true
|
|
1291
|
+
panelSendButton?.keyEquivalent = ""
|
|
1292
|
+
panelButtons?.isHidden = false
|
|
1293
|
+
panelReplyButton?.isHidden = false
|
|
1294
|
+
panelReplyButton?.keyEquivalent = "\r"
|
|
1295
|
+
if let c = panelBodyHeight, c.constant > 0 { c.constant += 20 }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/// What a person may send on this item. Answer only on their own decision:
|
|
1299
|
+
/// anyone else's answer would land as an act and settle nothing.
|
|
1300
|
+
private func replyKinds(_ item: Item) -> [String] {
|
|
1301
|
+
item.isDecision ? ["Answer", "Note", "Ask back"] : ["Note", "Ask back"]
|
|
1302
|
+
}
|
|
1303
|
+
private func kindCode(_ title: String?) -> String {
|
|
1304
|
+
switch title { case "Answer": return "ANSWER"; case "Ask back": return "ASK"; default: return "NOTE" }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/// Send the words in the box as the chosen act. The CLI does the sending and
|
|
1308
|
+
/// prints "sent KIND id=… seq=…" or "not sent: why" on stdout; both are shown.
|
|
1309
|
+
@objc private func panelSend(_ sender: Any?) {
|
|
1310
|
+
guard let item = panelItem, let tv = panelReply else { return }
|
|
1311
|
+
let text = tv.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1312
|
+
if text.isEmpty { setStatus("Type your reply first."); return }
|
|
1313
|
+
let kind = kindCode(panelReplyKind?.titleOfSelectedItem)
|
|
1314
|
+
var payload: [String: Any] = ["action": "reply", "kind": kind, "body": text, "goal": item.goal, "space": item.space]
|
|
1315
|
+
if item.isDecision { payload["decision"] = item.id }
|
|
1316
|
+
if !item.ref.isEmpty { payload["act"] = item.ref }
|
|
1317
|
+
guard writeAction(payload) else { setStatus("Could not write the action. Use Show the thread instead."); return }
|
|
1318
|
+
setStatus("Sending…")
|
|
1319
|
+
panelSendButton?.isEnabled = false
|
|
1320
|
+
DispatchQueue.global().async {
|
|
1321
|
+
let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
|
|
1322
|
+
DispatchQueue.main.async {
|
|
1323
|
+
self.panelSendButton?.isEnabled = true
|
|
1324
|
+
let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1325
|
+
if r?.code == 0, let idTok = out.split(separator: " ").first(where: { $0.hasPrefix("id=") }) {
|
|
1326
|
+
self.panelLastSent = String(idTok.dropFirst(3))
|
|
1327
|
+
tv.string = ""
|
|
1328
|
+
let what = kind == "ANSWER" ? "Answered — it is no longer waiting on you." : kind == "ASK" ? "Asked — the agent that raised this owes you a reply." : "Noted — it stays waiting."
|
|
1329
|
+
self.setStatus(what)
|
|
1330
|
+
self.exitReplyMode()
|
|
1331
|
+
self.panelRetract?.isHidden = false
|
|
1332
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in self?.panelRetract?.isHidden = true }
|
|
1333
|
+
self.refresh()
|
|
1334
|
+
} else {
|
|
1335
|
+
let why = out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached"
|
|
1336
|
+
self.setStatus("Not sent — \(why) Your words are still in the box.")
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/// The undo. RETRACT withdraws the act; the record keeps it as retracted.
|
|
1343
|
+
@objc private func panelRetractAction() {
|
|
1344
|
+
guard !panelLastSent.isEmpty else { return }
|
|
1345
|
+
let id = panelLastSent
|
|
1346
|
+
guard writeAction(["action": "retract", "act": id]) else { setStatus("Could not write the action."); return }
|
|
1347
|
+
setStatus("Retracting…")
|
|
1348
|
+
panelRetract?.isEnabled = false
|
|
1349
|
+
DispatchQueue.global().async {
|
|
1350
|
+
let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
|
|
1351
|
+
DispatchQueue.main.async {
|
|
1352
|
+
self.panelRetract?.isEnabled = true
|
|
1353
|
+
if r?.code == 0 {
|
|
1354
|
+
self.panelLastSent = ""
|
|
1355
|
+
self.panelRetract?.isHidden = true
|
|
1356
|
+
self.setStatus("Retracted.")
|
|
1357
|
+
self.refresh()
|
|
1358
|
+
} else {
|
|
1359
|
+
let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1360
|
+
self.setStatus("Could not retract — \(out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached")")
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1125
1364
|
}
|
|
1126
1365
|
@objc private func panelOpen() {
|
|
1127
1366
|
guard let item = panelItem else { return }
|
|
@@ -1328,3 +1567,12 @@ let app = NSApplication.shared
|
|
|
1328
1567
|
let delegate = AppDelegate()
|
|
1329
1568
|
app.delegate = delegate
|
|
1330
1569
|
app.run()
|
|
1570
|
+
|
|
1571
|
+
extension AppDelegate: NSTextViewDelegate {
|
|
1572
|
+
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
1573
|
+
guard textView === panelReply, commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
|
|
1574
|
+
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { return false }
|
|
1575
|
+
panelSend(nil)
|
|
1576
|
+
return true
|
|
1577
|
+
}
|
|
1578
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.5",
|
|
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": {
|