agentix-cli 0.35.0 → 0.36.0
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/apps/mac-helper/Sources/AgentXHelper/Presence.swift +117 -89
- package/apps/mac-helper/Sources/AgentXHelper/PresenceTag.swift +147 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +3 -1
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +27 -18
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +9 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +111 -65
- package/dist/{App-K5J52F7O.js → App-WY6GOC3O.js} +2 -2
- package/dist/{ChatApp-LRRAPZH7.js → ChatApp-H7W5V2QL.js} +2 -2
- package/dist/{agent-XEXAYH5J.js → agent-6MW3J5FN.js} +2 -2
- package/dist/{board-dashboard-CBOESBMD.js → board-dashboard-FKT5GEYE.js} +4 -4
- package/dist/{chunk-WRBW4A2U.js → chunk-2IK2SIFN.js} +2 -2
- package/dist/{chunk-DXXWYYZI.js → chunk-3CGB43RZ.js} +207 -205
- package/dist/chunk-3CGB43RZ.js.map +1 -0
- package/dist/{chunk-JSZRGMSA.js → chunk-4LDSMWKQ.js} +2 -2
- package/dist/{chunk-FLNN3KSX.js → chunk-AOVH27BF.js} +2 -2
- package/dist/{chunk-LQFEH3SA.js → chunk-AXYREZLV.js} +2 -2
- package/dist/{chunk-3TNK7VGH.js → chunk-BTMNOTTO.js} +2 -2
- package/dist/{chunk-SYJUZ3F2.js → chunk-CHWSVTY7.js} +2 -2
- package/dist/{chunk-PBVEYPZB.js → chunk-COY76JUG.js} +2 -2
- package/dist/{chunk-ZSNTLADV.js → chunk-CRKOJZVK.js} +2 -2
- package/dist/{chunk-GYBYKREN.js → chunk-DPNBGANY.js} +2 -2
- package/dist/chunk-EBWWNNJF.js +13 -0
- package/dist/chunk-EBWWNNJF.js.map +1 -0
- package/dist/{chunk-HPY6OOFO.js → chunk-EXBIM6WZ.js} +124 -124
- package/dist/chunk-EXBIM6WZ.js.map +1 -0
- package/dist/{chunk-LLKILWWO.js → chunk-GM5PFIJU.js} +2 -2
- package/dist/{chunk-WEN2P4RS.js → chunk-TRAVSDZN.js} +2 -2
- package/dist/{chunk-3PL3WLIR.js → chunk-YAER4PD6.js} +2 -2
- package/dist/{chunk-4QNZI4WE.js → chunk-ZA7FEJKR.js} +5 -5
- package/dist/cli.js +1 -1
- package/dist/client-OC7COXM6.js +2 -0
- package/dist/config-KAMUYXO3.js +2 -0
- package/dist/config-mutator-SMGYCOYW.js +2 -0
- package/dist/doctor-RNQDJCCI.js +2 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/program-Q3URXEEE.js +2 -0
- package/dist/query-PINMYFMF.js +2 -0
- package/dist/{registry-GWN34G4J.js → registry-LANHP7WG.js} +2 -2
- package/dist/{request-planner-V7T6BQU7.js → request-planner-S4YWZEOG.js} +2 -2
- package/dist/{review-candidates-7QKNMRGU.js → review-candidates-3E3GW4K7.js} +2 -2
- package/dist/{routing-BHQXFUSH.js → routing-QT4Z2ORA.js} +2 -2
- package/dist/scheduler-JUYBJLLK.js +2 -0
- package/dist/{seat-U57NWHKC.js → seat-VFYCLEMH.js} +2 -2
- package/dist/{select-JL3JJJP4.js → select-SIJHCD54.js} +2 -2
- package/dist/wiki-NY6V6OAT.js +2 -0
- package/package.json +1 -1
- package/dist/chunk-DXXWYYZI.js.map +0 -1
- package/dist/chunk-HPY6OOFO.js.map +0 -1
- package/dist/chunk-LA6AKS4N.js +0 -13
- package/dist/chunk-LA6AKS4N.js.map +0 -1
- package/dist/client-APAWETTA.js +0 -2
- package/dist/config-S3VHHVYW.js +0 -2
- package/dist/config-mutator-5B2YWZMK.js +0 -2
- package/dist/doctor-SCECRUJ7.js +0 -2
- package/dist/program-XJEQAWN3.js +0 -2
- package/dist/query-EDW72Q4O.js +0 -2
- package/dist/scheduler-LY4RBPB6.js +0 -2
- package/dist/wiki-AQ4K3F7V.js +0 -2
- /package/dist/{App-K5J52F7O.js.map → App-WY6GOC3O.js.map} +0 -0
- /package/dist/{ChatApp-LRRAPZH7.js.map → ChatApp-H7W5V2QL.js.map} +0 -0
- /package/dist/{agent-XEXAYH5J.js.map → agent-6MW3J5FN.js.map} +0 -0
- /package/dist/{board-dashboard-CBOESBMD.js.map → board-dashboard-FKT5GEYE.js.map} +0 -0
- /package/dist/{chunk-WRBW4A2U.js.map → chunk-2IK2SIFN.js.map} +0 -0
- /package/dist/{chunk-JSZRGMSA.js.map → chunk-4LDSMWKQ.js.map} +0 -0
- /package/dist/{chunk-FLNN3KSX.js.map → chunk-AOVH27BF.js.map} +0 -0
- /package/dist/{chunk-LQFEH3SA.js.map → chunk-AXYREZLV.js.map} +0 -0
- /package/dist/{chunk-3TNK7VGH.js.map → chunk-BTMNOTTO.js.map} +0 -0
- /package/dist/{chunk-SYJUZ3F2.js.map → chunk-CHWSVTY7.js.map} +0 -0
- /package/dist/{chunk-PBVEYPZB.js.map → chunk-COY76JUG.js.map} +0 -0
- /package/dist/{chunk-ZSNTLADV.js.map → chunk-CRKOJZVK.js.map} +0 -0
- /package/dist/{chunk-GYBYKREN.js.map → chunk-DPNBGANY.js.map} +0 -0
- /package/dist/{chunk-LLKILWWO.js.map → chunk-GM5PFIJU.js.map} +0 -0
- /package/dist/{chunk-WEN2P4RS.js.map → chunk-TRAVSDZN.js.map} +0 -0
- /package/dist/{chunk-3PL3WLIR.js.map → chunk-YAER4PD6.js.map} +0 -0
- /package/dist/{chunk-4QNZI4WE.js.map → chunk-ZA7FEJKR.js.map} +0 -0
- /package/dist/{client-APAWETTA.js.map → client-OC7COXM6.js.map} +0 -0
- /package/dist/{config-S3VHHVYW.js.map → config-KAMUYXO3.js.map} +0 -0
- /package/dist/{config-mutator-5B2YWZMK.js.map → config-mutator-SMGYCOYW.js.map} +0 -0
- /package/dist/{doctor-SCECRUJ7.js.map → doctor-RNQDJCCI.js.map} +0 -0
- /package/dist/{program-XJEQAWN3.js.map → program-Q3URXEEE.js.map} +0 -0
- /package/dist/{query-EDW72Q4O.js.map → query-PINMYFMF.js.map} +0 -0
- /package/dist/{registry-GWN34G4J.js.map → registry-LANHP7WG.js.map} +0 -0
- /package/dist/{request-planner-V7T6BQU7.js.map → request-planner-S4YWZEOG.js.map} +0 -0
- /package/dist/{review-candidates-7QKNMRGU.js.map → review-candidates-3E3GW4K7.js.map} +0 -0
- /package/dist/{routing-BHQXFUSH.js.map → routing-QT4Z2ORA.js.map} +0 -0
- /package/dist/{scheduler-LY4RBPB6.js.map → scheduler-JUYBJLLK.js.map} +0 -0
- /package/dist/{seat-U57NWHKC.js.map → seat-VFYCLEMH.js.map} +0 -0
- /package/dist/{select-JL3JJJP4.js.map → select-SIJHCD54.js.map} +0 -0
- /package/dist/{wiki-AQ4K3F7V.js.map → wiki-NY6V6OAT.js.map} +0 -0
|
@@ -4,7 +4,7 @@ import AppKit
|
|
|
4
4
|
///
|
|
5
5
|
/// Drawn, never driven. `point` moves the person's real mouse, which is
|
|
6
6
|
/// right for a lesson the person watches and wrong for one they take part
|
|
7
|
-
/// in — their hand is on that mouse. This cursor is a click-through
|
|
7
|
+
/// in — their hand is on that mouse. This cursor is a click-through arrow
|
|
8
8
|
/// that glides to what the agent means, highlights it, and says a line in
|
|
9
9
|
/// a bubble, while the real pointer stays exactly where the person left it.
|
|
10
10
|
///
|
|
@@ -14,9 +14,19 @@ import AppKit
|
|
|
14
14
|
/// {"cmd":"say","text":"The export button is top right."}
|
|
15
15
|
/// {"cmd":"clear"} drop the highlight
|
|
16
16
|
/// {"cmd":"park"} rest in the bottom-right corner, no highlight
|
|
17
|
+
/// {"cmd":"ping"} nothing; keeps an idle overlay alive
|
|
17
18
|
///
|
|
18
19
|
/// Coordinates are accessibility coordinates (top-left origin, global),
|
|
19
|
-
/// the same as `read`, `ocr` and `point`.
|
|
20
|
+
/// the same as `read`, `ocr` and `point`.
|
|
21
|
+
///
|
|
22
|
+
/// Only the arrow and highlight are click-through. The name tag and bubble
|
|
23
|
+
/// (PresenceTag) can be dragged out of the way; with `--pos-file` the spot
|
|
24
|
+
/// is remembered for this agent and the tag stays there on later turns.
|
|
25
|
+
///
|
|
26
|
+
/// It never outlives its owner: EOF, the owner's death (`--parent`, or
|
|
27
|
+
/// being re-parented to launchd) or `--idle` seconds without a command
|
|
28
|
+
/// fade everything out and exit. One process draws one cursor, on one
|
|
29
|
+
/// screen; it follows across Spaces but is never duplicated per display.
|
|
20
30
|
enum Presence {
|
|
21
31
|
|
|
22
32
|
// MARK: Geometry
|
|
@@ -35,73 +45,37 @@ enum Presence {
|
|
|
35
45
|
|
|
36
46
|
// MARK: Windows
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
/// Floating, not screen-saver level: above the windows the person works
|
|
49
|
+
/// in, below menus, alerts and Spotlight.
|
|
50
|
+
fileprivate static func panel(_ rect: NSRect, clickThrough: Bool = true) -> NSPanel {
|
|
39
51
|
let p = NSPanel(contentRect: rect, styleMask: [.borderless, .nonactivatingPanel],
|
|
40
52
|
backing: .buffered, defer: false)
|
|
41
|
-
p.level = .
|
|
53
|
+
p.level = .floating
|
|
42
54
|
p.isOpaque = false
|
|
43
55
|
p.backgroundColor = .clear
|
|
44
56
|
p.hasShadow = false
|
|
45
|
-
//
|
|
46
|
-
|
|
57
|
+
// The arrow and the highlight are click-through: the person's clicks
|
|
58
|
+
// land on the app underneath. Only the name tag takes the mouse.
|
|
59
|
+
p.ignoresMouseEvents = clickThrough
|
|
47
60
|
p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
48
61
|
return p
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
/// The arrow
|
|
52
|
-
private final class
|
|
64
|
+
/// The arrow alone: the system cursor's silhouette, in the agent's colour.
|
|
65
|
+
private final class ArrowView: NSView {
|
|
53
66
|
let color: NSColor
|
|
54
|
-
let
|
|
55
|
-
let name: String
|
|
56
|
-
let bubble = NSTextField(wrappingLabelWithString: "")
|
|
57
|
-
/// The bubble's background, so the text gets padding.
|
|
58
|
-
let box = NSView()
|
|
59
|
-
/// Wide on both sides of the tip, so the bubble can sit to the left
|
|
60
|
-
/// when the target is near the right edge of the screen.
|
|
61
|
-
static let size = NSSize(width: 720, height: 170)
|
|
67
|
+
static let size = NSSize(width: 30, height: 40)
|
|
62
68
|
/// The arrow tip, in view coordinates (bottom-left origin).
|
|
63
|
-
static let tip = NSPoint(x:
|
|
64
|
-
var bubbleLeft = false { didSet { if bubbleLeft != oldValue { layoutBubble() } } }
|
|
69
|
+
static let tip = NSPoint(x: 4, y: size.height - 4)
|
|
65
70
|
|
|
66
|
-
init(color: NSColor
|
|
67
|
-
self.color = color
|
|
68
|
-
super.init(frame: NSRect(origin: .zero, size:
|
|
69
|
-
wantsLayer = true
|
|
70
|
-
bubble.font = Brand.body(size: 13)
|
|
71
|
-
bubble.textColor = Brand.ink
|
|
72
|
-
bubble.maximumNumberOfLines = 4
|
|
73
|
-
box.wantsLayer = true
|
|
74
|
-
box.layer?.backgroundColor = Brand.paper.cgColor
|
|
75
|
-
box.layer?.cornerRadius = Brand.Radius.md
|
|
76
|
-
box.layer?.borderWidth = 2
|
|
77
|
-
box.layer?.borderColor = color.cgColor
|
|
78
|
-
box.isHidden = true
|
|
79
|
-
box.addSubview(bubble)
|
|
80
|
-
addSubview(box)
|
|
71
|
+
init(color: NSColor) {
|
|
72
|
+
self.color = color
|
|
73
|
+
super.init(frame: NSRect(origin: .zero, size: ArrowView.size))
|
|
81
74
|
}
|
|
82
75
|
required init?(coder: NSCoder) { fatalError() }
|
|
83
76
|
|
|
84
|
-
/// Under the badge and name, never over them.
|
|
85
|
-
func setBubble(_ text: String) {
|
|
86
|
-
box.isHidden = text.isEmpty
|
|
87
|
-
bubble.stringValue = text
|
|
88
|
-
let textW: CGFloat = 300
|
|
89
|
-
bubble.preferredMaxLayoutWidth = textW
|
|
90
|
-
let fit = bubble.sizeThatFits(NSSize(width: textW, height: 90))
|
|
91
|
-
let w = min(textW, ceil(fit.width)), h = min(90, ceil(fit.height))
|
|
92
|
-
bubble.frame = NSRect(x: 10, y: 6, width: w, height: h)
|
|
93
|
-
box.setFrameSize(NSSize(width: w + 20, height: h + 12))
|
|
94
|
-
layoutBubble()
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
func layoutBubble() {
|
|
98
|
-
let t = AvatarView.tip, s = box.frame.size
|
|
99
|
-
box.setFrameOrigin(NSPoint(x: bubbleLeft ? t.x - s.width - 4 : t.x + 16, y: t.y - 58 - s.height))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
77
|
override func draw(_ dirtyRect: NSRect) {
|
|
103
|
-
let t =
|
|
104
|
-
// The arrow: the system cursor's silhouette, in the agent's colour.
|
|
78
|
+
let t = ArrowView.tip
|
|
105
79
|
let arrow = NSBezierPath()
|
|
106
80
|
arrow.move(to: t)
|
|
107
81
|
arrow.line(to: NSPoint(x: t.x, y: t.y - 26))
|
|
@@ -120,43 +94,58 @@ enum Presence {
|
|
|
120
94
|
color.setFill(); arrow.fill()
|
|
121
95
|
NSGraphicsContext.current?.restoreGraphicsState()
|
|
122
96
|
NSColor.white.setStroke(); arrow.lineWidth = 1.5; arrow.stroke()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
123
99
|
|
|
124
|
-
|
|
125
|
-
let badge = NSRect(x: t.x + 16, y: t.y - 50, width: 24, height: 24)
|
|
126
|
-
color.setFill(); NSBezierPath(ovalIn: badge).fill()
|
|
127
|
-
NSColor.white.setStroke()
|
|
128
|
-
let ring = NSBezierPath(ovalIn: badge.insetBy(dx: 0.75, dy: 0.75)); ring.lineWidth = 1.5; ring.stroke()
|
|
129
|
-
draw(initial, in: badge, size: 11, weight: .bold, color: .white)
|
|
100
|
+
// MARK: Pinned position
|
|
130
101
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
102
|
+
/// Where the person dragged this agent's tag: its top-left, AppKit
|
|
103
|
+
/// coordinates, as {"x":…,"y":…}. Ignored if no screen shows it any more.
|
|
104
|
+
static func loadPin(_ file: String?) -> NSPoint? {
|
|
105
|
+
guard let file, let data = FileManager.default.contents(atPath: file),
|
|
106
|
+
let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
107
|
+
let x = o["x"] as? Double, let y = o["y"] as? Double else { return nil }
|
|
108
|
+
let p = NSPoint(x: x, y: y)
|
|
109
|
+
// The badge row hangs below the top-left; it must be on a screen.
|
|
110
|
+
let row = NSPoint(x: x + 12, y: y - 12)
|
|
111
|
+
return NSScreen.screens.contains(where: { $0.frame.contains(row) }) ? p : nil
|
|
112
|
+
}
|
|
137
113
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
114
|
+
static func savePin(_ p: NSPoint?, to file: String?) {
|
|
115
|
+
guard let file else { return }
|
|
116
|
+
guard let p else { try? FileManager.default.removeItem(atPath: file); return }
|
|
117
|
+
let url = URL(fileURLWithPath: file)
|
|
118
|
+
do {
|
|
119
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
120
|
+
let data = try JSONSerialization.data(withJSONObject: ["x": Double(p.x), "y": Double(p.y)])
|
|
121
|
+
try data.write(to: url, options: .atomic)
|
|
122
|
+
} catch {
|
|
123
|
+
FileHandle.standardError.write("presence: could not save position: \(error)\n".data(using: .utf8)!)
|
|
142
124
|
}
|
|
143
125
|
}
|
|
144
126
|
|
|
145
127
|
// MARK: Behaviour
|
|
146
128
|
|
|
147
129
|
private final class Controller {
|
|
148
|
-
let
|
|
149
|
-
let
|
|
130
|
+
let arrow: NSPanel
|
|
131
|
+
let tagPanel: NSPanel
|
|
132
|
+
let tag: PresenceTag
|
|
150
133
|
let ring: NSPanel
|
|
151
134
|
let color: NSColor
|
|
135
|
+
let posFile: String?
|
|
152
136
|
var at: CGPoint // tip position, AppKit coordinates
|
|
137
|
+
/// The tag's top-left once the person has dragged it; nil rides with the arrow.
|
|
138
|
+
var pinned: NSPoint?
|
|
153
139
|
var timer: Timer?
|
|
154
140
|
|
|
155
|
-
init(color: NSColor, initial: String, name: String) {
|
|
141
|
+
init(color: NSColor, initial: String, name: String, posFile: String?) {
|
|
156
142
|
self.color = color
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
143
|
+
self.posFile = posFile
|
|
144
|
+
arrow = Presence.panel(NSRect(origin: .zero, size: ArrowView.size))
|
|
145
|
+
arrow.contentView = ArrowView(color: color)
|
|
146
|
+
tag = PresenceTag(color: color, initial: initial, name: name)
|
|
147
|
+
tagPanel = Presence.panel(NSRect(origin: .zero, size: tag.frame.size), clickThrough: false)
|
|
148
|
+
tagPanel.contentView = tag
|
|
160
149
|
ring = Presence.panel(.zero)
|
|
161
150
|
let ringView = NSView()
|
|
162
151
|
ringView.wantsLayer = true
|
|
@@ -165,11 +154,25 @@ enum Presence {
|
|
|
165
154
|
ringView.layer?.cornerRadius = 8
|
|
166
155
|
ringView.layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor
|
|
167
156
|
ring.contentView = ringView
|
|
157
|
+
pinned = Presence.loadPin(posFile)
|
|
168
158
|
at = Controller.parking()
|
|
159
|
+
tag.onPin = { [weak self] p in self?.pinned = p; Presence.savePin(p, to: posFile) }
|
|
160
|
+
tag.onUnpin = { [weak self] in
|
|
161
|
+
guard let self else { return }
|
|
162
|
+
self.pinned = nil
|
|
163
|
+
Presence.savePin(nil, to: posFile)
|
|
164
|
+
self.place(self.at)
|
|
165
|
+
}
|
|
169
166
|
place(at)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
for p in [arrow, tagPanel] {
|
|
168
|
+
p.alphaValue = 0
|
|
169
|
+
p.orderFrontRegardless()
|
|
170
|
+
}
|
|
171
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
172
|
+
ctx.duration = 0.2
|
|
173
|
+
arrow.animator().alphaValue = 1
|
|
174
|
+
tagPanel.animator().alphaValue = 1
|
|
175
|
+
}
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
static func parking() -> CGPoint {
|
|
@@ -178,9 +181,16 @@ enum Presence {
|
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
func place(_ tip: CGPoint) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
+
arrow.setFrameOrigin(NSPoint(x: tip.x - ArrowView.tip.x, y: tip.y - ArrowView.tip.y))
|
|
185
|
+
if tag.dragged { return }
|
|
186
|
+
let size = tagPanel.frame.size
|
|
187
|
+
var top = pinned ?? NSPoint(x: tip.x + 20, y: tip.y - 26)
|
|
188
|
+
if pinned == nil {
|
|
189
|
+
// Near the right edge the tag sits to the left of the arrow.
|
|
190
|
+
let screen = NSScreen.screens.first(where: { $0.frame.contains(tip) }) ?? NSScreen.main
|
|
191
|
+
if let edge = screen?.visibleFrame.maxX, top.x + size.width > edge { top.x = tip.x - 4 - size.width }
|
|
192
|
+
}
|
|
193
|
+
tagPanel.setFrameOrigin(NSPoint(x: top.x, y: top.y - size.height))
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
/// Glide with minimum-jerk easing; duration follows distance, as in
|
|
@@ -205,7 +215,8 @@ enum Presence {
|
|
|
205
215
|
guard let rect else { ring.orderOut(nil); return }
|
|
206
216
|
ring.setFrame(Presence.toAppKit(rect).insetBy(dx: -6, dy: -6), display: true)
|
|
207
217
|
ring.orderFrontRegardless()
|
|
208
|
-
|
|
218
|
+
arrow.orderFrontRegardless()
|
|
219
|
+
tagPanel.orderFrontRegardless()
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
func handle(_ obj: [String: Any]) {
|
|
@@ -220,7 +231,8 @@ enum Presence {
|
|
|
220
231
|
glide(to: Presence.toAppKit(aim))
|
|
221
232
|
highlight((obj["highlight"] as? Bool ?? false) && w > 0 && h > 0 ? rect : nil)
|
|
222
233
|
case "say":
|
|
223
|
-
|
|
234
|
+
tag.setBubble(obj["text"] as? String ?? "")
|
|
235
|
+
place(at)
|
|
224
236
|
case "clear":
|
|
225
237
|
highlight(nil)
|
|
226
238
|
case "park":
|
|
@@ -235,7 +247,8 @@ enum Presence {
|
|
|
235
247
|
ring.orderOut(nil)
|
|
236
248
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
237
249
|
ctx.duration = 0.25
|
|
238
|
-
|
|
250
|
+
arrow.animator().alphaValue = 0
|
|
251
|
+
tagPanel.animator().alphaValue = 0
|
|
239
252
|
} completionHandler: { done() }
|
|
240
253
|
}
|
|
241
254
|
}
|
|
@@ -248,18 +261,33 @@ enum Presence {
|
|
|
248
261
|
blue: CGFloat(v & 0xff) / 255, alpha: 1)
|
|
249
262
|
}
|
|
250
263
|
|
|
251
|
-
/// Read commands until stdin closes.
|
|
252
|
-
static func run(name: String, initial: String, colorHex: String
|
|
264
|
+
/// Read commands until stdin closes, the owner dies, or it goes idle.
|
|
265
|
+
static func run(name: String, initial: String, colorHex: String?, parent: pid_t?, idle: TimeInterval,
|
|
266
|
+
posFile: String? = nil) {
|
|
253
267
|
let app = NSApplication.shared
|
|
254
268
|
app.setActivationPolicy(.accessory)
|
|
255
|
-
let ctl = Controller(color: color(hex: colorHex), initial: initial, name: name)
|
|
269
|
+
let ctl = Controller(color: color(hex: colorHex), initial: initial, name: name, posFile: posFile)
|
|
270
|
+
var lastCommand = Date()
|
|
271
|
+
var quitting = false
|
|
272
|
+
// Main thread only. The fade is a courtesy: exit follows regardless.
|
|
273
|
+
func quit() {
|
|
274
|
+
if quitting { return }
|
|
275
|
+
quitting = true
|
|
276
|
+
ctl.fadeOut { exit(0) }
|
|
277
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { exit(0) }
|
|
278
|
+
}
|
|
256
279
|
DispatchQueue(label: "tn.acme.agentx.presence.stdin").async {
|
|
257
280
|
while let line = readLine(strippingNewline: true) {
|
|
258
281
|
guard let data = line.data(using: .utf8),
|
|
259
282
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
|
260
|
-
DispatchQueue.main.async { ctl.handle(obj) }
|
|
283
|
+
DispatchQueue.main.async { lastCommand = Date(); ctl.handle(obj) }
|
|
261
284
|
}
|
|
262
|
-
DispatchQueue.main.async {
|
|
285
|
+
DispatchQueue.main.async { quit() }
|
|
286
|
+
}
|
|
287
|
+
Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
288
|
+
let orphaned = getppid() == 1 || (parent.map { kill($0, 0) != 0 && errno == ESRCH } ?? false)
|
|
289
|
+
let idled = idle > 0 && Date().timeIntervalSince(lastCommand) > idle
|
|
290
|
+
if orphaned || idled { quit() }
|
|
263
291
|
}
|
|
264
292
|
app.run()
|
|
265
293
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// The part of an agent's presence the person can touch: the initial
|
|
4
|
+
/// badge, the name, and the speech bubble under them.
|
|
5
|
+
///
|
|
6
|
+
/// It sits in its own panel, apart from the click-through arrow, so it can
|
|
7
|
+
/// take the mouse without the arrow ever stealing a click:
|
|
8
|
+
///
|
|
9
|
+
/// drag anywhere move it; it stays there (pinned) for this agent
|
|
10
|
+
/// double-click bubble dismiss the bubble for the rest of this overlay
|
|
11
|
+
/// double-click name unpin; it rides next to the arrow again
|
|
12
|
+
///
|
|
13
|
+
/// The view is top-anchored: the badge row stays put as the bubble comes
|
|
14
|
+
/// and goes, and the window grows or shrinks underneath it.
|
|
15
|
+
final class PresenceTag: NSView {
|
|
16
|
+
let color: NSColor
|
|
17
|
+
let initial: String
|
|
18
|
+
let name: String
|
|
19
|
+
let bubble = NSTextField(wrappingLabelWithString: "")
|
|
20
|
+
/// The bubble's background, so the text gets padding.
|
|
21
|
+
let box = NSView()
|
|
22
|
+
|
|
23
|
+
static let rowHeight: CGFloat = 24
|
|
24
|
+
static let gap: CGFloat = 8
|
|
25
|
+
|
|
26
|
+
/// Called with the window's new top-left once a drag ends.
|
|
27
|
+
var onPin: ((NSPoint) -> Void)?
|
|
28
|
+
var onUnpin: (() -> Void)?
|
|
29
|
+
/// A double-click on the bubble hides it; later lines stay hidden too.
|
|
30
|
+
private(set) var dismissed = false
|
|
31
|
+
|
|
32
|
+
private var dragFrom: NSPoint?
|
|
33
|
+
private var windowFrom: NSPoint = .zero
|
|
34
|
+
/// True mid-drag, so a glide doesn't pull the tag out of the hand.
|
|
35
|
+
private(set) var dragged = false
|
|
36
|
+
|
|
37
|
+
init(color: NSColor, initial: String, name: String) {
|
|
38
|
+
self.color = color; self.initial = initial; self.name = name
|
|
39
|
+
super.init(frame: .zero)
|
|
40
|
+
wantsLayer = true
|
|
41
|
+
bubble.font = Brand.body(size: 13)
|
|
42
|
+
bubble.textColor = Brand.ink
|
|
43
|
+
bubble.maximumNumberOfLines = 4
|
|
44
|
+
box.wantsLayer = true
|
|
45
|
+
box.layer?.backgroundColor = Brand.paper.cgColor
|
|
46
|
+
box.layer?.cornerRadius = Brand.Radius.md
|
|
47
|
+
box.layer?.borderWidth = 2
|
|
48
|
+
box.layer?.borderColor = color.cgColor
|
|
49
|
+
box.isHidden = true
|
|
50
|
+
box.addSubview(bubble)
|
|
51
|
+
addSubview(box)
|
|
52
|
+
setFrameSize(fittingSize)
|
|
53
|
+
}
|
|
54
|
+
required init?(coder: NSCoder) { fatalError() }
|
|
55
|
+
|
|
56
|
+
private static let nameAttrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold)]
|
|
57
|
+
private var rowWidth: CGFloat { 24 + 4 + (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14 }
|
|
58
|
+
|
|
59
|
+
override var fittingSize: NSSize {
|
|
60
|
+
guard !box.isHidden else { return NSSize(width: rowWidth, height: PresenceTag.rowHeight) }
|
|
61
|
+
return NSSize(width: max(rowWidth, box.frame.width),
|
|
62
|
+
height: PresenceTag.rowHeight + PresenceTag.gap + box.frame.height)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func setBubble(_ text: String) {
|
|
66
|
+
box.isHidden = text.isEmpty || dismissed
|
|
67
|
+
bubble.stringValue = text
|
|
68
|
+
let textW: CGFloat = 300
|
|
69
|
+
bubble.preferredMaxLayoutWidth = textW
|
|
70
|
+
let fit = bubble.sizeThatFits(NSSize(width: textW, height: 90))
|
|
71
|
+
let w = min(textW, ceil(fit.width)), h = min(90, ceil(fit.height))
|
|
72
|
+
bubble.frame = NSRect(x: 10, y: 6, width: w, height: h)
|
|
73
|
+
box.frame = NSRect(x: 0, y: 0, width: w + 20, height: h + 12)
|
|
74
|
+
refit()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Resize the window to the content, keeping its top-left where it is.
|
|
78
|
+
private func refit() {
|
|
79
|
+
let size = fittingSize
|
|
80
|
+
setFrameSize(size)
|
|
81
|
+
needsDisplay = true
|
|
82
|
+
guard let w = window else { return }
|
|
83
|
+
let top = NSPoint(x: w.frame.minX, y: w.frame.maxY)
|
|
84
|
+
w.setFrame(NSRect(x: top.x, y: top.y - size.height, width: size.width, height: size.height), display: true)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
88
|
+
let badge = NSRect(x: 0, y: bounds.maxY - 24, width: 24, height: 24)
|
|
89
|
+
color.setFill(); NSBezierPath(ovalIn: badge).fill()
|
|
90
|
+
NSColor.white.setStroke()
|
|
91
|
+
let ring = NSBezierPath(ovalIn: badge.insetBy(dx: 0.75, dy: 0.75)); ring.lineWidth = 1.5; ring.stroke()
|
|
92
|
+
draw(initial, in: badge, size: 11, weight: .bold)
|
|
93
|
+
|
|
94
|
+
let nameW = (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14
|
|
95
|
+
let pill = NSRect(x: badge.maxX + 4, y: badge.minY + 3, width: nameW, height: 18)
|
|
96
|
+
color.setFill(); NSBezierPath(roundedRect: pill, xRadius: 9, yRadius: 9).fill()
|
|
97
|
+
draw(name, in: pill, size: 11, weight: .semibold)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func draw(_ s: String, in r: NSRect, size: CGFloat, weight: NSFont.Weight) {
|
|
101
|
+
let a: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: NSColor.white]
|
|
102
|
+
let sz = (s as NSString).size(withAttributes: a)
|
|
103
|
+
(s as NSString).draw(at: NSPoint(x: r.midX - sz.width / 2, y: r.midY - sz.height / 2), withAttributes: a)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: Mouse
|
|
107
|
+
|
|
108
|
+
/// The label would swallow clicks meant for the drag.
|
|
109
|
+
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
110
|
+
frame.contains(point) ? self : nil
|
|
111
|
+
}
|
|
112
|
+
/// The panel never activates this app, so the first click must count.
|
|
113
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
114
|
+
|
|
115
|
+
override func mouseDown(with event: NSEvent) {
|
|
116
|
+
if event.clickCount == 2 {
|
|
117
|
+
let p = convert(event.locationInWindow, from: nil)
|
|
118
|
+
if !box.isHidden && box.frame.contains(p) {
|
|
119
|
+
dismissed = true
|
|
120
|
+
box.isHidden = true
|
|
121
|
+
refit()
|
|
122
|
+
} else if p.y >= bounds.maxY - PresenceTag.rowHeight {
|
|
123
|
+
onUnpin?()
|
|
124
|
+
}
|
|
125
|
+
dragFrom = nil
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
dragFrom = NSEvent.mouseLocation
|
|
129
|
+
windowFrom = window?.frame.origin ?? .zero
|
|
130
|
+
dragged = false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override func mouseDragged(with event: NSEvent) {
|
|
134
|
+
guard let from = dragFrom, let w = window else { return }
|
|
135
|
+
let now = NSEvent.mouseLocation
|
|
136
|
+
let dx = now.x - from.x, dy = now.y - from.y
|
|
137
|
+
if !dragged && hypot(dx, dy) < 3 { return }
|
|
138
|
+
dragged = true
|
|
139
|
+
w.setFrameOrigin(NSPoint(x: windowFrom.x + dx, y: windowFrom.y + dy))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
override func mouseUp(with event: NSEvent) {
|
|
143
|
+
defer { dragFrom = nil; dragged = false }
|
|
144
|
+
guard dragged, let w = window else { return }
|
|
145
|
+
onPin?(NSPoint(x: w.frame.minX, y: w.frame.maxY))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -260,7 +260,9 @@ case "presence":
|
|
|
260
260
|
// Long-lived: the agent's own drawn cursor. Never moves the real mouse.
|
|
261
261
|
let name = flag("name") ?? "Agent"
|
|
262
262
|
Presence.run(name: name, initial: flag("initial") ?? String(name.prefix(1)).uppercased(),
|
|
263
|
-
colorHex: flag("color"))
|
|
263
|
+
colorHex: flag("color"), parent: flag("parent").flatMap { pid_t($0) },
|
|
264
|
+
idle: flag("idle").flatMap { TimeInterval($0) } ?? 60,
|
|
265
|
+
posFile: flag("pos-file"))
|
|
264
266
|
|
|
265
267
|
case "trusted":
|
|
266
268
|
let payload = ["ok": true, "trusted": AXTree.trusted()] as [String: Any]
|
|
@@ -54,28 +54,37 @@ enum AgentClient {
|
|
|
54
54
|
return (try? JSONDecoder().decode(Reply.self, from: data))?.say
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// MARK:
|
|
57
|
+
// MARK: The door
|
|
58
58
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
59
|
+
// Option-Space is one door for everything spoken: a talk, a live
|
|
60
|
+
// lesson, task narration, an agent's bubble. Pressing it hushes all of
|
|
61
|
+
// them at once; what is then said goes to the activity that was
|
|
62
|
+
// speaking, which answers it first. See src/daemon/voice-talk-api.ts.
|
|
62
63
|
|
|
63
|
-
///
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return (try? JSONDecoder().decode(Reply.self, from: data))?.active ?? false
|
|
64
|
+
/// What was speaking when the door opened.
|
|
65
|
+
struct Hushed {
|
|
66
|
+
/// "talk", "lesson" or "narration"; nil when nothing was.
|
|
67
|
+
let kind: String?
|
|
68
|
+
let agentID: String?
|
|
69
|
+
static let nothing = Hushed(kind: nil, agentID: nil)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
///
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
/// Silence everything the daemon is saying. Never throws: no daemon
|
|
73
|
+
/// means nothing was speaking.
|
|
74
|
+
static func hush() async -> Hushed {
|
|
75
|
+
guard let (data, _) = try? await post("/voice/hush", [:], timeout: 2) else { return .nothing }
|
|
76
|
+
struct Reply: Decodable { let kind: String?; let agentId: String? }
|
|
77
|
+
let r = try? JSONDecoder().decode(Reply.self, from: data)
|
|
78
|
+
return Hushed(kind: r?.kind, agentID: r?.agentId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Hand the listener's words through the door. True when an activity
|
|
82
|
+
/// took them (a talk or lesson answers; "stop" ends it); false means
|
|
83
|
+
/// nothing did, and they are an ordinary question.
|
|
84
|
+
static func door(_ text: String) async -> Bool {
|
|
85
|
+
guard let (_, response) = try? await post("/voice/door", ["text": text], timeout: 5),
|
|
86
|
+
let http = response as? HTTPURLResponse else { return false }
|
|
87
|
+
return (200..<300).contains(http.statusCode)
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
|
|
@@ -72,6 +72,9 @@ enum Speech {
|
|
|
72
72
|
|
|
73
73
|
// MARK: Text to speech
|
|
74
74
|
|
|
75
|
+
/// Silence whatever this app is saying (an answer or a step line).
|
|
76
|
+
static func stop() { Player.shared.stop() }
|
|
77
|
+
|
|
75
78
|
/// `voiceID` is the answering agent's voice; nil means the global default.
|
|
76
79
|
static func speak(_ text: String, voiceID: String? = nil) async {
|
|
77
80
|
guard !text.isEmpty else { return }
|
|
@@ -154,6 +157,12 @@ final class Player: NSObject, AVAudioPlayerDelegate {
|
|
|
154
157
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
155
158
|
finished?.resume(); finished = nil; self.player = nil
|
|
156
159
|
}
|
|
160
|
+
|
|
161
|
+
/// Cut the line off now; the `play` awaiting it returns.
|
|
162
|
+
func stop() {
|
|
163
|
+
player?.stop()
|
|
164
|
+
finished?.resume(); finished = nil; player = nil
|
|
165
|
+
}
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
/// Logs to stderr AND to a file.
|