@join-pantheon/cli 0.24.4 → 0.25.1
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/README.md +26 -1
- package/dist/admin-stats.js +106 -0
- package/dist/cli.js +77 -10
- package/dist/client.js +2 -0
- package/dist/config.js +28 -0
- package/dist/decision-ui.js +31 -9
- package/dist/doctor.js +118 -62
- package/dist/feedback.js +3 -0
- package/dist/grants.js +38 -17
- package/dist/humanize.js +2 -0
- package/dist/install.js +97 -21
- package/dist/machine.js +12 -5
- package/dist/mcp-instructions.js +85 -0
- package/dist/mcp-supervisor.js +2 -1
- package/dist/mcp.js +47 -82
- package/dist/measure.js +142 -0
- package/dist/notice.js +7 -2
- package/dist/relay.js +86 -2
- package/dist/roster-file.js +17 -0
- package/dist/run-failure.js +97 -0
- package/dist/service.js +9 -1
- package/dist/setup-guide.js +30 -2
- package/dist/tools.js +9 -2
- package/dist/tray.js +10 -3
- package/dist/upgrade.js +11 -1
- package/dist/worker-policy.js +3 -1
- package/dist/worker.js +107 -13
- package/helper/macos/main.swift +22 -1
- package/helper/macos/menubar.swift +51 -13
- package/package.json +1 -1
package/dist/worker.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadContinued, markContinued } from "./continued.js";
|
|
|
14
14
|
import { loadProfile, reservedDecisionsNote } from "./profile.js";
|
|
15
15
|
import { clip } from "./brief.js";
|
|
16
16
|
import { composeWorker } from "./notice.js";
|
|
17
|
+
import { diagnoseRun, gitProbe } from "./run-failure.js";
|
|
17
18
|
import { installedTools, preferredTool } from "./notifier.js";
|
|
18
19
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
19
20
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
@@ -180,6 +181,13 @@ Act, reply if a reply is owed, then finish.`;
|
|
|
180
181
|
}
|
|
181
182
|
let fleetLock = null;
|
|
182
183
|
const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
|
|
184
|
+
export function answerWindow(askSeq, hint, fullScanDue) {
|
|
185
|
+
if (askSeq !== null)
|
|
186
|
+
return Math.max(0, askSeq - 1);
|
|
187
|
+
if (fullScanDue)
|
|
188
|
+
return 0;
|
|
189
|
+
return Math.max(0, hint);
|
|
190
|
+
}
|
|
183
191
|
export class Worker {
|
|
184
192
|
cfg;
|
|
185
193
|
opts;
|
|
@@ -492,10 +500,13 @@ export class Worker {
|
|
|
492
500
|
this.done.push(job);
|
|
493
501
|
this.client?.reportWorkerState("idle");
|
|
494
502
|
if (candidate && !job.ok) {
|
|
495
|
-
|
|
503
|
+
const failures = (this.failedRuns.get(candidate.id) ?? 0) + 1;
|
|
504
|
+
this.failedRuns.set(candidate.id, failures);
|
|
496
505
|
if (this.failedRuns.size > 200) {
|
|
497
506
|
this.failedRuns.delete(this.failedRuns.keys().next().value);
|
|
498
507
|
}
|
|
508
|
+
if (failures >= 2 && job.diagnosis)
|
|
509
|
+
await this.reportStuck(client, candidate, job.diagnosis);
|
|
499
510
|
}
|
|
500
511
|
this.running = false;
|
|
501
512
|
if (fleetLock === this)
|
|
@@ -537,11 +548,22 @@ export class Worker {
|
|
|
537
548
|
const trigger = client.recent(act.id) ?? `(${act.act} ${act.id} is not in this session's buffer)`;
|
|
538
549
|
return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false);
|
|
539
550
|
}
|
|
551
|
+
commandGrant(persona) {
|
|
552
|
+
if (persona?.allowBash === false)
|
|
553
|
+
return false;
|
|
554
|
+
const policy = loadPolicy(this.opts.home);
|
|
555
|
+
if (!useGrant(policy, "run_commands"))
|
|
556
|
+
return false;
|
|
557
|
+
savePolicy(policy, this.opts.home);
|
|
558
|
+
this.opts.log(` bash on for this run — your human granted run_commands`);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
540
561
|
async runJob(client, o, persona, policy, makeBrief, requireReply) {
|
|
541
562
|
const goal = o.goal ?? "adhoc";
|
|
542
563
|
if (this.opts.allowBash && !policy.allowBash && persona) {
|
|
543
564
|
this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
|
|
544
565
|
}
|
|
566
|
+
const allowBash = policy.allowBash || this.commandGrant(persona);
|
|
545
567
|
let branch = branchName(this.cfg.space, goal, this.client?.goals.get(goal)?.title);
|
|
546
568
|
let worktree = join(this.opts.scratch, goal);
|
|
547
569
|
let baseRef = "", baseSha = "";
|
|
@@ -566,14 +588,14 @@ export class Worker {
|
|
|
566
588
|
const plan = headlessSpawn({
|
|
567
589
|
backend: { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
|
|
568
590
|
binary: this.opts.agentCmd,
|
|
569
|
-
prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
591
|
+
prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
|
|
570
592
|
});
|
|
571
593
|
materializeSpawnFiles(worktree, plan);
|
|
572
594
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
573
595
|
}
|
|
574
596
|
else {
|
|
575
597
|
const { backend, binary } = resolveAgent(policy.agent ?? this.opts.agent);
|
|
576
|
-
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
598
|
+
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome });
|
|
577
599
|
materializeSpawnFiles(worktree, plan);
|
|
578
600
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
579
601
|
this.opts.log(` spawn ${command} ${args.slice(0, 3).join(" ")}…`);
|
|
@@ -607,18 +629,56 @@ export class Worker {
|
|
|
607
629
|
}, this.opts.home);
|
|
608
630
|
const sent = client.mineSince(startedAt, o.goal);
|
|
609
631
|
const quiet = ok && !requireReply && sent.length === 0;
|
|
632
|
+
const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, runLog }, this.opts.probe ?? gitProbe);
|
|
610
633
|
if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
|
|
611
634
|
try {
|
|
612
635
|
const reply = sent[sent.length - 1] ?? null;
|
|
613
|
-
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
636
|
+
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref, ...(diagnosis ? { diagnosis } : {}) }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
614
637
|
}
|
|
615
638
|
catch { }
|
|
616
639
|
}
|
|
617
640
|
this.opts.log(quiet ? `< read ${o.id} on ${branch} — the thread was already settled, nothing sent`
|
|
618
641
|
: ok ? `< handled ${o.id} on ${branch}`
|
|
619
|
-
:
|
|
620
|
-
|
|
621
|
-
|
|
642
|
+
: `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
|
|
643
|
+
return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
|
|
644
|
+
}
|
|
645
|
+
async reportStuck(client, o, d) {
|
|
646
|
+
const key = `stuck:${o.id}`;
|
|
647
|
+
const space = this.spaceKey();
|
|
648
|
+
if (loadContinued(space, this.opts.home).has(key))
|
|
649
|
+
return;
|
|
650
|
+
const policy = loadPolicy(this.opts.home);
|
|
651
|
+
const asking = !!d.capability && mayAsk(policy, d.capability);
|
|
652
|
+
const what = clip(o.body, 120);
|
|
653
|
+
const lines = [
|
|
654
|
+
`Two runs on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
|
|
655
|
+
``,
|
|
656
|
+
`WHY IT STOPPED. ${d.says}`,
|
|
657
|
+
`WHAT UNBLOCKS IT. ${d.fix}`,
|
|
658
|
+
...(d.dir && d.lines?.length ? [``, `On this machine, in ${d.dir}:`, ...d.lines.map((l) => ` ${l}`)] : []),
|
|
659
|
+
``,
|
|
660
|
+
`Nothing was sent to the teammate who asked, and the request is still open on them.`,
|
|
661
|
+
`It will not be retried unattended — a live session here, or you, has to move it.`,
|
|
662
|
+
];
|
|
663
|
+
if (asking) {
|
|
664
|
+
lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. Allowing it lets a woken`, `run execute tests and git in its own isolated worktree — never in your checkout.`);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const sent = await client.send({
|
|
668
|
+
act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
|
|
669
|
+
summary: clip(`Stuck on you: ${d.says}`, 190),
|
|
670
|
+
body: lines.join("\n"),
|
|
671
|
+
});
|
|
672
|
+
markContinued(space, key, this.opts.home);
|
|
673
|
+
if (asking && d.capability) {
|
|
674
|
+
recordAsk(policy, d.capability, sent.id);
|
|
675
|
+
savePolicy(policy, this.opts.home);
|
|
676
|
+
}
|
|
677
|
+
this.opts.log(`* escalated ${o.id}: ${d.cause}${asking ? ` (and asked for ${d.capability})` : ""}`);
|
|
678
|
+
}
|
|
679
|
+
catch (e) {
|
|
680
|
+
this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
|
|
681
|
+
}
|
|
622
682
|
}
|
|
623
683
|
enqueueContinuations(client, arrived) {
|
|
624
684
|
this.loadContinued();
|
|
@@ -678,13 +738,18 @@ export class Worker {
|
|
|
678
738
|
const home = this.opts.home;
|
|
679
739
|
{
|
|
680
740
|
const policy = loadPolicy(home);
|
|
681
|
-
const open = policy.asks.self_upgrade;
|
|
682
741
|
let changed = false;
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
742
|
+
for (const cap of Object.keys(policy.asks)) {
|
|
743
|
+
const open = policy.asks[cap];
|
|
744
|
+
if (!open)
|
|
745
|
+
continue;
|
|
746
|
+
const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act);
|
|
747
|
+
const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
|
|
748
|
+
if (body === null)
|
|
749
|
+
continue;
|
|
686
750
|
const applied = applyAnswer(policy, open.act, body);
|
|
687
751
|
if (applied) {
|
|
752
|
+
this.askAt.delete(open.act);
|
|
688
753
|
changed = true;
|
|
689
754
|
this.opts.log(`* grant request answered: ${applied.cap} -> ${applied.verdict}`);
|
|
690
755
|
}
|
|
@@ -756,10 +821,39 @@ export class Worker {
|
|
|
756
821
|
}
|
|
757
822
|
}
|
|
758
823
|
}
|
|
759
|
-
|
|
824
|
+
askAt = new Map();
|
|
825
|
+
lastFullScan = 0;
|
|
826
|
+
async actSeq(id) {
|
|
827
|
+
try {
|
|
828
|
+
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
829
|
+
const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
|
|
830
|
+
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
|
|
831
|
+
if (!res.ok)
|
|
832
|
+
return null;
|
|
833
|
+
const j = await res.json();
|
|
834
|
+
return typeof j.act?.seq === "number" ? j.act.seq : null;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async fetchGrantAnswer(askActId, since = 0) {
|
|
841
|
+
let seq = this.askAt.get(askActId) ?? null;
|
|
842
|
+
if (seq === null) {
|
|
843
|
+
seq = await this.actSeq(askActId);
|
|
844
|
+
if (seq !== null)
|
|
845
|
+
this.askAt.set(askActId, seq);
|
|
846
|
+
}
|
|
847
|
+
let due = false;
|
|
848
|
+
if (seq === null) {
|
|
849
|
+
due = Date.now() - this.lastFullScan > 5 * 60_000;
|
|
850
|
+
if (due)
|
|
851
|
+
this.lastFullScan = Date.now();
|
|
852
|
+
}
|
|
853
|
+
const from = answerWindow(seq, since, due);
|
|
760
854
|
try {
|
|
761
855
|
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
762
|
-
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since
|
|
856
|
+
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
|
|
763
857
|
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
764
858
|
const j = await res.json();
|
|
765
859
|
const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId);
|
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
|
+
* Fix it here (a failed run) opens a Terminal where the work is, steps shown
|
|
12
13
|
* Done (decisions) answers it as handled, silently — the reminders stop
|
|
13
14
|
* Dismiss / × closes
|
|
14
15
|
*
|
|
@@ -26,13 +27,15 @@ import AppKit
|
|
|
26
27
|
let args = CommandLine.arguments
|
|
27
28
|
|
|
28
29
|
if args.count == 2, args[1] == "version" {
|
|
29
|
-
print("
|
|
30
|
+
print("4")
|
|
30
31
|
exit(0)
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
struct Tool: Decodable { let id: String; let label: String; let kind: String?; let bundle: String?; let deeplink: String? }
|
|
34
35
|
struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
|
|
35
36
|
struct Details: Decodable { let act: String?; let decision: String? }
|
|
37
|
+
/// The one thing a person could do about a failed run: where, and which steps.
|
|
38
|
+
struct Fix: Decodable { let label: String; let dir: String; let lines: [String]; let why: String }
|
|
36
39
|
struct Notice: Decodable {
|
|
37
40
|
let kind: String
|
|
38
41
|
let who: String
|
|
@@ -44,6 +47,7 @@ struct Notice: Decodable {
|
|
|
44
47
|
let sound: Bool
|
|
45
48
|
let primary: Primary?
|
|
46
49
|
let details: Details?
|
|
50
|
+
let fix: Fix?
|
|
47
51
|
/** where to write the chosen action and which .command files to open — supplied by the CLI */
|
|
48
52
|
let bin_dir: String
|
|
49
53
|
/** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
|
|
@@ -158,6 +162,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
158
162
|
NSApp.terminate(nil)
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
/// Fix it here: open a Terminal where the work is, with the steps shown. The
|
|
166
|
+
/// CLI asks before running anything — a run that just failed is the wrong
|
|
167
|
+
/// moment to take another unattended action on someone's machine.
|
|
168
|
+
@objc func fixIt(_ sender: Any?) {
|
|
169
|
+
guard let f = notice.fix else { NSApp.terminate(nil); return }
|
|
170
|
+
act(["action": "fix", "dir": f.dir, "lines": f.lines, "why": f.why], command: "pantheon-details.command")
|
|
171
|
+
}
|
|
172
|
+
|
|
161
173
|
@objc func legacyOpen(_ sender: Any?) {
|
|
162
174
|
if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
|
|
163
175
|
if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
|
|
@@ -280,6 +292,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
280
292
|
constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
281
293
|
trailing = d
|
|
282
294
|
}
|
|
295
|
+
if notice.fix != nil {
|
|
296
|
+
let fix = NSButton(title: notice.fix!.label, target: self, action: #selector(fixIt(_:)))
|
|
297
|
+
fix.isBordered = false; fix.font = .systemFont(ofSize: 12, weight: .medium); fix.contentTintColor = .controlAccentColor
|
|
298
|
+
fix.toolTip = notice.fix!.why
|
|
299
|
+
fix.translatesAutoresizingMaskIntoConstraints = false
|
|
300
|
+
root.addSubview(fix)
|
|
301
|
+
constraints += [fix.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), fix.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
302
|
+
trailing = fix
|
|
303
|
+
}
|
|
283
304
|
if notice.details?.decision != nil {
|
|
284
305
|
// A decision can be settled from the banner itself: one click, no terminal, no more reminders.
|
|
285
306
|
let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
|
|
@@ -78,7 +78,9 @@ struct Item {
|
|
|
78
78
|
/// "praneet · 2d ago · pantheon"
|
|
79
79
|
var subtitle: String {
|
|
80
80
|
var parts: [String] = []
|
|
81
|
-
|
|
81
|
+
// Who is asking, on every kind. A decision row used to say only "11h ago",
|
|
82
|
+
// which is the one fact a reader could already guess.
|
|
83
|
+
if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
|
|
82
84
|
if when > 0 { parts.append(Item.age(when)) }
|
|
83
85
|
if !space.isEmpty { parts.append(space) }
|
|
84
86
|
return parts.joined(separator: " · ")
|
|
@@ -179,17 +181,45 @@ final class RowView: NSView {
|
|
|
179
181
|
private var overCopy = false
|
|
180
182
|
private var copied = false
|
|
181
183
|
|
|
184
|
+
private let titleLines: Int
|
|
185
|
+
/// The menu is the board people read every day; 430 wrapped a plain sentence
|
|
186
|
+
/// onto two lines and truncated a decision. 520 carries ~40% more per line.
|
|
187
|
+
static let rowWidth: CGFloat = 520
|
|
188
|
+
private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
189
|
+
private static let lineHeight: CGFloat = 17
|
|
190
|
+
|
|
191
|
+
/// How many lines this title needs, at most three. A one-line row truncated
|
|
192
|
+
/// mid-word ("Sid asked: Sid is asking: is this work in producti…") hid the
|
|
193
|
+
/// only part worth reading; two lines carried most titles, and a decision
|
|
194
|
+
/// ("Praneet wants the unattended worker able to do everything overlapping
|
|
195
|
+
/// Pantheo…") still lost its point at the end of the second. Three lines,
|
|
196
|
+
/// taken only when needed, carry every real title whole.
|
|
197
|
+
static func lines(for text: String, width: CGFloat) -> Int {
|
|
198
|
+
let para = NSMutableParagraphStyle()
|
|
199
|
+
para.lineBreakMode = .byWordWrapping
|
|
200
|
+
let h = (text as NSString).boundingRect(
|
|
201
|
+
with: NSSize(width: width, height: .greatestFiniteMagnitude),
|
|
202
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
|
203
|
+
attributes: [.font: titleFont, .paragraphStyle: para]).height
|
|
204
|
+
return max(1, min(3, Int(ceil(h / lineHeight))))
|
|
205
|
+
}
|
|
206
|
+
|
|
182
207
|
init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
|
|
183
208
|
self.item = item
|
|
184
209
|
self.onOpen = onOpen
|
|
185
210
|
self.onCopy = onCopy
|
|
186
|
-
|
|
211
|
+
self.titleLines = RowView.lines(for: item.title, width: width - RowView.textInsets)
|
|
212
|
+
let textHeight = CGFloat(titleLines) * RowView.lineHeight
|
|
213
|
+
super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (item.subtitle.isEmpty ? 13 : 29)))
|
|
187
214
|
setAccessibilityRole(.button)
|
|
188
215
|
setAccessibilityLabel("\(item.title). \(item.subtitle). Return opens it; the button on the right copies the question.")
|
|
189
216
|
}
|
|
190
217
|
required init?(coder: NSCoder) { return nil }
|
|
191
218
|
|
|
192
|
-
|
|
219
|
+
/// Left inset plus what the copy control needs on the right: 28 for the control, 8 clear of the text, 8 to the edge.
|
|
220
|
+
static let textInsets: CGFloat = 12 + 44
|
|
221
|
+
private var copyRect: NSRect { NSRect(x: bounds.maxX - 36, y: bounds.midY - 12, width: 28, height: 24) }
|
|
222
|
+
|
|
193
223
|
|
|
194
224
|
override func updateTrackingAreas() {
|
|
195
225
|
super.updateTrackingAreas()
|
|
@@ -227,17 +257,25 @@ final class RowView: NSView {
|
|
|
227
257
|
let fg: NSColor = selected ? .selectedMenuItemTextColor : .labelColor
|
|
228
258
|
let dim: NSColor = selected ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.7) : .secondaryLabelColor
|
|
229
259
|
let para = NSMutableParagraphStyle()
|
|
230
|
-
para.lineBreakMode = .
|
|
231
|
-
|
|
260
|
+
para.lineBreakMode = .byWordWrapping // break between words, not inside one
|
|
261
|
+
para.lineSpacing = 0
|
|
262
|
+
let flat = NSMutableParagraphStyle()
|
|
263
|
+
flat.lineBreakMode = .byTruncatingTail
|
|
264
|
+
let textWidth = bounds.width - RowView.textInsets
|
|
232
265
|
let hasSub = !item.subtitle.isEmpty
|
|
233
|
-
let
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
266
|
+
let titleHeight = CGFloat(titleLines) * RowView.lineHeight
|
|
267
|
+
let titleY = bounds.maxY - 7 - titleHeight
|
|
268
|
+
let cell = NSTextFieldCell() // gives the last visible line its ellipsis
|
|
269
|
+
cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
|
|
270
|
+
.font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
|
|
271
|
+
])
|
|
272
|
+
cell.truncatesLastVisibleLine = true
|
|
273
|
+
cell.wraps = true
|
|
274
|
+
cell.drawInterior(withFrame: NSRect(x: 12, y: titleY, width: textWidth, height: titleHeight), in: self)
|
|
237
275
|
if hasSub {
|
|
238
276
|
NSAttributedString(string: item.subtitle, attributes: [
|
|
239
|
-
.font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle:
|
|
240
|
-
]).draw(in: NSRect(x: 14, y: bounds.
|
|
277
|
+
.font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
|
|
278
|
+
]).draw(in: NSRect(x: 14, y: bounds.minY + 6, width: textWidth, height: 14))
|
|
241
279
|
}
|
|
242
280
|
|
|
243
281
|
// the copy control
|
|
@@ -406,7 +444,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
406
444
|
addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
|
|
407
445
|
}
|
|
408
446
|
if !decisions.isEmpty {
|
|
409
|
-
menu.addItem(makeItem(
|
|
447
|
+
menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
|
|
410
448
|
}
|
|
411
449
|
}
|
|
412
450
|
menu.addItem(.separator())
|
|
@@ -826,7 +864,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
826
864
|
for item in items.prefix(6) {
|
|
827
865
|
let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
|
|
828
866
|
mi.toolTip = item.detail
|
|
829
|
-
mi.view = RowView(item: item, width:
|
|
867
|
+
mi.view = RowView(item: item, width: RowView.rowWidth,
|
|
830
868
|
onOpen: { [weak self] it in self?.showDetail(it) },
|
|
831
869
|
onCopy: { [weak self] it in self?.copyPrompt(it) })
|
|
832
870
|
menu.addItem(mi)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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": {
|