agentix-cli 0.59.0 → 0.61.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-voice/Sources/AgentXVoice/AgentClient.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/AnswerView.swift +228 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +36 -9
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +126 -55
- package/apps/mac-voice/Sources/AgentXVoice/PanelAnswer.swift +113 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +28 -17
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +90 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -3
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +9 -0
- package/apps/mac-voice/Sources/AgentXVoice/Surface.swift +105 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +32 -1
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +23 -10
- package/dist/{App-QEJGXZTY.js → App-LQXWKBVY.js} +2 -2
- package/dist/{ChatApp-VKWQN5GM.js → ChatApp-75TL4LKW.js} +2 -2
- package/dist/{agent-MJKLI2L5.js → agent-DH4FMMMY.js} +2 -2
- package/dist/{board-dashboard-2KVT72J7.js → board-dashboard-C6AZHDHU.js} +657 -188
- package/dist/board-dashboard-C6AZHDHU.js.map +1 -0
- package/dist/{chunk-WFPRVZBA.js → chunk-27P5NLTC.js} +2 -2
- package/dist/{chunk-3EVCHRJF.js → chunk-2VQIPJH5.js} +2 -2
- package/dist/{chunk-YRB75GMN.js → chunk-3XVTFNYM.js} +2 -2
- package/dist/{chunk-K62U5EJJ.js → chunk-B3DCYBP4.js} +2 -2
- package/dist/{chunk-ILXW3ERG.js → chunk-BCXKRD5L.js} +159 -153
- package/dist/chunk-BCXKRD5L.js.map +1 -0
- package/dist/{chunk-EJQOKSKP.js → chunk-CRSXKQLZ.js} +2 -2
- package/dist/{chunk-YCIZ2WHX.js → chunk-DTEQNXK3.js} +2 -2
- package/dist/{chunk-N5GOBLUI.js → chunk-E2BJVHBR.js} +2 -2
- package/dist/{chunk-6DUTIPTY.js → chunk-E2PWCU2N.js} +2 -2
- package/dist/{chunk-2XZGHIVP.js → chunk-JO3PSLWL.js} +5 -5
- package/dist/{chunk-YQU74AOL.js → chunk-JORXRF4E.js} +2 -2
- package/dist/{chunk-DAAWUFO4.js → chunk-LXUG2X26.js} +2 -2
- package/dist/{chunk-JUY77L7C.js → chunk-N5KD3TVN.js} +2 -2
- package/dist/{chunk-5OF3EGCZ.js → chunk-NBGNPKAF.js} +2 -2
- package/dist/chunk-RVEMQCWW.js +130 -0
- package/dist/chunk-RVEMQCWW.js.map +1 -0
- package/dist/chunk-S7TCVIET.js +15 -0
- package/dist/chunk-S7TCVIET.js.map +1 -0
- package/dist/{chunk-WI5R64WO.js → chunk-WBGC7MWX.js} +2 -2
- package/dist/{chunk-6ZV2QFXE.js → chunk-WIBMNQKJ.js} +2 -2
- package/dist/{chunk-L5OCF33B.js → chunk-WOPFHKBF.js} +2 -2
- package/dist/{chunk-SDBU6747.js → chunk-Y76THZUQ.js} +178 -276
- package/dist/chunk-Y76THZUQ.js.map +1 -0
- package/dist/{chunk-OIDMPPV5.js → chunk-YQCOX4R3.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-QVZIZFV4.js +2 -0
- package/dist/config-3XC7EYB7.js +2 -0
- package/dist/config-mutator-HKU63FJM.js +2 -0
- package/dist/doctor-YFA4ZSRH.js +2 -0
- package/dist/index.d.ts +49 -7
- package/dist/index.js +1 -1
- package/dist/program-L6VAFALE.js +2 -0
- package/dist/{promote-GBR6YSQW.js → promote-UR2GSHKC.js} +2 -2
- package/dist/query-XUP3BMVS.js +2 -0
- package/dist/{registry-P535FNLB.js → registry-PGALUW5L.js} +2 -2
- package/dist/{request-planner-KHTCHF7N.js → request-planner-NHE3IRJN.js} +2 -2
- package/dist/{review-candidates-Y5KCRUNU.js → review-candidates-6GKRSRBQ.js} +2 -2
- package/dist/{routing-RWCCDJ4D.js → routing-AQKHAU3Z.js} +2 -2
- package/dist/schedule-tool-6NMCLTIH.js +2 -0
- package/dist/scheduler-RAYGNDWT.js +2 -0
- package/dist/{seat-PJPKLH6L.js → seat-6FMW2QI4.js} +2 -2
- package/dist/{select-VMTJAX5X.js → select-5VONJMLH.js} +2 -2
- package/dist/{set-enabled-LPXH3ZQP.js → set-enabled-YHD3IJJZ.js} +2 -2
- package/dist/{tool-MWXG44OT.js → tool-MXJGLJZ5.js} +2 -2
- package/dist/wiki-ZQLEYY24.js +2 -0
- package/package.json +1 -1
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +0 -198
- package/dist/board-dashboard-2KVT72J7.js.map +0 -1
- package/dist/chunk-4ZTV6JV7.js +0 -29
- package/dist/chunk-4ZTV6JV7.js.map +0 -1
- package/dist/chunk-ILXW3ERG.js.map +0 -1
- package/dist/chunk-IW4XIZAZ.js +0 -15
- package/dist/chunk-IW4XIZAZ.js.map +0 -1
- package/dist/chunk-SDBU6747.js.map +0 -1
- package/dist/client-X2PTYKVX.js +0 -2
- package/dist/config-XB2ORKWS.js +0 -2
- package/dist/config-mutator-KMXR3XDG.js +0 -2
- package/dist/doctor-6BIMNEGU.js +0 -2
- package/dist/program-DHDC6XYB.js +0 -2
- package/dist/query-672RGMXO.js +0 -2
- package/dist/schedule-tool-H46AK53H.js +0 -2
- package/dist/scheduler-3XLWBCX7.js +0 -2
- package/dist/wiki-QHMHVFWU.js +0 -2
- /package/dist/{App-QEJGXZTY.js.map → App-LQXWKBVY.js.map} +0 -0
- /package/dist/{ChatApp-VKWQN5GM.js.map → ChatApp-75TL4LKW.js.map} +0 -0
- /package/dist/{agent-MJKLI2L5.js.map → agent-DH4FMMMY.js.map} +0 -0
- /package/dist/{chunk-WFPRVZBA.js.map → chunk-27P5NLTC.js.map} +0 -0
- /package/dist/{chunk-3EVCHRJF.js.map → chunk-2VQIPJH5.js.map} +0 -0
- /package/dist/{chunk-YRB75GMN.js.map → chunk-3XVTFNYM.js.map} +0 -0
- /package/dist/{chunk-K62U5EJJ.js.map → chunk-B3DCYBP4.js.map} +0 -0
- /package/dist/{chunk-EJQOKSKP.js.map → chunk-CRSXKQLZ.js.map} +0 -0
- /package/dist/{chunk-YCIZ2WHX.js.map → chunk-DTEQNXK3.js.map} +0 -0
- /package/dist/{chunk-N5GOBLUI.js.map → chunk-E2BJVHBR.js.map} +0 -0
- /package/dist/{chunk-6DUTIPTY.js.map → chunk-E2PWCU2N.js.map} +0 -0
- /package/dist/{chunk-2XZGHIVP.js.map → chunk-JO3PSLWL.js.map} +0 -0
- /package/dist/{chunk-YQU74AOL.js.map → chunk-JORXRF4E.js.map} +0 -0
- /package/dist/{chunk-DAAWUFO4.js.map → chunk-LXUG2X26.js.map} +0 -0
- /package/dist/{chunk-JUY77L7C.js.map → chunk-N5KD3TVN.js.map} +0 -0
- /package/dist/{chunk-5OF3EGCZ.js.map → chunk-NBGNPKAF.js.map} +0 -0
- /package/dist/{chunk-WI5R64WO.js.map → chunk-WBGC7MWX.js.map} +0 -0
- /package/dist/{chunk-6ZV2QFXE.js.map → chunk-WIBMNQKJ.js.map} +0 -0
- /package/dist/{chunk-L5OCF33B.js.map → chunk-WOPFHKBF.js.map} +0 -0
- /package/dist/{chunk-OIDMPPV5.js.map → chunk-YQCOX4R3.js.map} +0 -0
- /package/dist/{client-X2PTYKVX.js.map → client-QVZIZFV4.js.map} +0 -0
- /package/dist/{config-XB2ORKWS.js.map → config-3XC7EYB7.js.map} +0 -0
- /package/dist/{config-mutator-KMXR3XDG.js.map → config-mutator-HKU63FJM.js.map} +0 -0
- /package/dist/{doctor-6BIMNEGU.js.map → doctor-YFA4ZSRH.js.map} +0 -0
- /package/dist/{program-DHDC6XYB.js.map → program-L6VAFALE.js.map} +0 -0
- /package/dist/{promote-GBR6YSQW.js.map → promote-UR2GSHKC.js.map} +0 -0
- /package/dist/{query-672RGMXO.js.map → query-XUP3BMVS.js.map} +0 -0
- /package/dist/{registry-P535FNLB.js.map → registry-PGALUW5L.js.map} +0 -0
- /package/dist/{request-planner-KHTCHF7N.js.map → request-planner-NHE3IRJN.js.map} +0 -0
- /package/dist/{review-candidates-Y5KCRUNU.js.map → review-candidates-6GKRSRBQ.js.map} +0 -0
- /package/dist/{routing-RWCCDJ4D.js.map → routing-AQKHAU3Z.js.map} +0 -0
- /package/dist/{schedule-tool-H46AK53H.js.map → schedule-tool-6NMCLTIH.js.map} +0 -0
- /package/dist/{scheduler-3XLWBCX7.js.map → scheduler-RAYGNDWT.js.map} +0 -0
- /package/dist/{seat-PJPKLH6L.js.map → seat-6FMW2QI4.js.map} +0 -0
- /package/dist/{select-VMTJAX5X.js.map → select-5VONJMLH.js.map} +0 -0
- /package/dist/{set-enabled-LPXH3ZQP.js.map → set-enabled-YHD3IJJZ.js.map} +0 -0
- /package/dist/{tool-MWXG44OT.js.map → tool-MXJGLJZ5.js.map} +0 -0
- /package/dist/{wiki-QHMHVFWU.js.map → wiki-ZQLEYY24.js.map} +0 -0
|
@@ -213,7 +213,11 @@ enum AgentClient {
|
|
|
213
213
|
/// "#RRGGBB": presence.color, else derived from the id. Older
|
|
214
214
|
/// daemons send none, and the app derives it the same way.
|
|
215
215
|
let color: String?
|
|
216
|
+
/// The orb's nature palette. Older daemons send none, and the orb
|
|
217
|
+
/// uses shades of `color`.
|
|
218
|
+
let palette: Palette?
|
|
216
219
|
var label: String { name ?? id }
|
|
220
|
+
struct Palette: Decodable { let id: String; let colors: [String] }
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
/// The daemon's agents, or nil when the daemon cannot be reached.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// What the agent said, in a form you can read, select and copy, inside
|
|
5
|
+
/// the pill.
|
|
6
|
+
///
|
|
7
|
+
/// Speech is lossy in one specific way that matters: a URL cannot be
|
|
8
|
+
/// spoken usefully, an image cannot be spoken at all, and a name you have
|
|
9
|
+
/// not seen written is a name you cannot act on. The spoken answer is
|
|
10
|
+
/// deliberately short and stripped of exactly those things, so without a
|
|
11
|
+
/// visual surface everything worth keeping from a turn is the part that
|
|
12
|
+
/// gets thrown away.
|
|
13
|
+
///
|
|
14
|
+
/// Rich content arrives through the same `agentx:ui` directive Telegram
|
|
15
|
+
/// and WhatsApp already use, parsed server-side, so an agent has one way to
|
|
16
|
+
/// attach a link or a picture regardless of where it is speaking.
|
|
17
|
+
final class AnswerView: NSView {
|
|
18
|
+
// A web view, because agents write markdown: headings, bullets, fenced
|
|
19
|
+
// code, tables. An NSTextView shows that as a mess of asterisks and
|
|
20
|
+
// hashes, which defeats the answer's only purpose: being MORE readable
|
|
21
|
+
// than the spoken answer, not less. Text in it is selectable, and it
|
|
22
|
+
// scrolls once the answer is taller than the pill lets it grow.
|
|
23
|
+
private let web = WKWebView()
|
|
24
|
+
private let links = NSStackView()
|
|
25
|
+
private let thumb = NSImageView()
|
|
26
|
+
private let actions = NSStackView()
|
|
27
|
+
private var source = ""
|
|
28
|
+
|
|
29
|
+
/// The answer's natural height, measured after it renders. The pill
|
|
30
|
+
/// grows to it, up to its maximum.
|
|
31
|
+
var onHeight: ((CGFloat) -> Void)?
|
|
32
|
+
/// "Open in chat". Set by the app.
|
|
33
|
+
var onOpenChat: (() -> Void)?
|
|
34
|
+
|
|
35
|
+
private static let linkRow: CGFloat = 34
|
|
36
|
+
private static let imageHeight: CGFloat = 120
|
|
37
|
+
|
|
38
|
+
override init(frame: NSRect) {
|
|
39
|
+
super.init(frame: frame)
|
|
40
|
+
web.setValue(false, forKey: "drawsBackground")
|
|
41
|
+
web.navigationDelegate = self
|
|
42
|
+
addSubview(web)
|
|
43
|
+
|
|
44
|
+
thumb.imageScaling = .scaleProportionallyUpOrDown
|
|
45
|
+
thumb.isHidden = true
|
|
46
|
+
addSubview(thumb)
|
|
47
|
+
|
|
48
|
+
links.orientation = .horizontal
|
|
49
|
+
links.spacing = 8
|
|
50
|
+
links.edgeInsets = NSEdgeInsets(top: 4, left: 12, bottom: 8, right: 12)
|
|
51
|
+
addSubview(links)
|
|
52
|
+
|
|
53
|
+
// Copy and Open in chat, shown while the pointer is over the answer.
|
|
54
|
+
actions.orientation = .horizontal
|
|
55
|
+
actions.spacing = 2
|
|
56
|
+
actions.addArrangedSubview(actionButton("doc.on.doc", "Copy the answer", #selector(copyAnswer)))
|
|
57
|
+
actions.addArrangedSubview(actionButton("bubble.left.and.bubble.right", "Open in chat", #selector(openChat)))
|
|
58
|
+
actions.isHidden = true
|
|
59
|
+
actions.edgeInsets = NSEdgeInsets(top: 1, left: 3, bottom: 1, right: 3)
|
|
60
|
+
// A backing of its own, so the buttons read over the text below.
|
|
61
|
+
actions.wantsLayer = true
|
|
62
|
+
actions.layer?.cornerRadius = 7
|
|
63
|
+
actions.layer?.cornerCurve = .continuous
|
|
64
|
+
addSubview(actions)
|
|
65
|
+
|
|
66
|
+
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
|
|
67
|
+
owner: self, userInfo: nil))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@available(*, unavailable)
|
|
71
|
+
required init?(coder: NSCoder) { fatalError("not from a nib") }
|
|
72
|
+
|
|
73
|
+
private func actionButton(_ symbol: String, _ label: String, _ action: Selector) -> NSButton {
|
|
74
|
+
let b = NSButton()
|
|
75
|
+
b.isBordered = false
|
|
76
|
+
b.bezelStyle = .regularSquare
|
|
77
|
+
b.imagePosition = .imageOnly
|
|
78
|
+
b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)
|
|
79
|
+
b.contentTintColor = .secondaryLabelColor
|
|
80
|
+
b.toolTip = label
|
|
81
|
+
b.setAccessibilityLabel(label)
|
|
82
|
+
b.target = self
|
|
83
|
+
b.action = action
|
|
84
|
+
b.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
85
|
+
b.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
86
|
+
return b
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
|
90
|
+
let w = bounds.width, h = bounds.height
|
|
91
|
+
let linkH: CGFloat = links.arrangedSubviews.isEmpty ? 0 : Self.linkRow
|
|
92
|
+
let imgH: CGFloat = thumb.isHidden ? 0 : Self.imageHeight
|
|
93
|
+
thumb.frame = NSRect(x: 12, y: h - imgH - 10, width: w - 24, height: imgH)
|
|
94
|
+
web.frame = NSRect(x: 0, y: linkH, width: w, height: max(0, h - linkH - imgH - (imgH > 0 ? 16 : 0)))
|
|
95
|
+
links.frame = NSRect(x: 0, y: 0, width: w, height: linkH)
|
|
96
|
+
actions.frame = NSRect(x: w - 64, y: h - 32, width: 56, height: 26)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Does this turn have anything the spoken answer did not already
|
|
100
|
+
/// deliver?
|
|
101
|
+
///
|
|
102
|
+
/// Opening on every turn, including "Ok.", shows one word you have
|
|
103
|
+
/// already heard. A surface that adds nothing trains you to ignore it,
|
|
104
|
+
/// so the turn where it DOES carry a link gets ignored too.
|
|
105
|
+
///
|
|
106
|
+
/// Worth showing when there is rich content, a URL you could click, or
|
|
107
|
+
/// materially more written than was spoken. Otherwise the speech was
|
|
108
|
+
/// the whole answer.
|
|
109
|
+
static func isWorthShowing(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) -> Bool {
|
|
110
|
+
if !buttons.isEmpty || imageURL != nil { return true }
|
|
111
|
+
let text = (written?.isEmpty == false ? written! : spoken)
|
|
112
|
+
if text.range(of: #"https?://"#, options: .regularExpression) != nil { return true }
|
|
113
|
+
// Speech drops formatting, so written is often a little longer for
|
|
114
|
+
// the same content. Only a real difference counts.
|
|
115
|
+
let spokenLen = spoken.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
116
|
+
let writtenLen = text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
|
117
|
+
if writtenLen > spokenLen + 80 { return true }
|
|
118
|
+
// Long enough that re-reading it beats re-hearing it.
|
|
119
|
+
return writtenLen > 280
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// `spoken` is what was said; `written` is the fuller answer with the
|
|
123
|
+
/// URLs and formatting the spoken form had to drop. `onHeight` follows
|
|
124
|
+
/// once it has rendered.
|
|
125
|
+
@MainActor
|
|
126
|
+
func show(spoken: String, written: String?, buttons: [(String, String)], imageURL: String?) {
|
|
127
|
+
source = (written?.isEmpty == false ? written! : spoken)
|
|
128
|
+
web.loadHTMLString(Markdown.page(Markdown.toHTML(source), css: Brand.cardCSS), baseURL: nil)
|
|
129
|
+
|
|
130
|
+
links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
|
|
131
|
+
for (label, url) in buttons.prefix(4) {
|
|
132
|
+
let b = NSButton(title: label, target: self, action: #selector(openLink(_:)))
|
|
133
|
+
b.bezelStyle = .push
|
|
134
|
+
b.controlSize = .small
|
|
135
|
+
b.toolTip = url
|
|
136
|
+
b.identifier = NSUserInterfaceItemIdentifier(url)
|
|
137
|
+
links.addArrangedSubview(b)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
thumb.isHidden = true
|
|
141
|
+
thumb.image = nil
|
|
142
|
+
if let s = imageURL, let u = URL(string: s) {
|
|
143
|
+
// Off the main thread: a slow image must never stall the UI,
|
|
144
|
+
// and the answer is useful without it.
|
|
145
|
+
URLSession.shared.dataTask(with: u) { [weak self] data, _, _ in
|
|
146
|
+
guard let data, let img = NSImage(data: data) else { return }
|
|
147
|
+
Task { @MainActor in
|
|
148
|
+
guard let self, self.source.isEmpty == false else { return }
|
|
149
|
+
self.thumb.image = img
|
|
150
|
+
self.thumb.isHidden = false
|
|
151
|
+
self.measure()
|
|
152
|
+
}
|
|
153
|
+
}.resume()
|
|
154
|
+
}
|
|
155
|
+
resizeSubviews(withOldSize: bounds.size)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Empty again, once the pill has collapsed.
|
|
159
|
+
@MainActor
|
|
160
|
+
func clear() {
|
|
161
|
+
source = ""
|
|
162
|
+
web.loadHTMLString("", baseURL: nil)
|
|
163
|
+
thumb.isHidden = true
|
|
164
|
+
links.arrangedSubviews.forEach { links.removeArrangedSubview($0); $0.removeFromSuperview() }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// The rendered text's height plus the image and links around it.
|
|
168
|
+
@MainActor
|
|
169
|
+
private func measure() {
|
|
170
|
+
let js = "(() => { const b = document.body, s = getComputedStyle(b);"
|
|
171
|
+
+ " return b.scrollHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom) })()"
|
|
172
|
+
web.evaluateJavaScript(js) { [weak self] value, _ in
|
|
173
|
+
MainActor.assumeIsolated {
|
|
174
|
+
guard let self, !self.source.isEmpty else { return }
|
|
175
|
+
let text = CGFloat((value as? NSNumber)?.doubleValue ?? 120)
|
|
176
|
+
let extra = (self.links.arrangedSubviews.isEmpty ? 0 : Self.linkRow)
|
|
177
|
+
+ (self.thumb.isHidden ? 0 : Self.imageHeight + 16)
|
|
178
|
+
self.resizeSubviews(withOldSize: self.bounds.size)
|
|
179
|
+
self.onHeight?(ceil(text + extra))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override func mouseEntered(with event: NSEvent) {
|
|
185
|
+
effectiveAppearance.performAsCurrentDrawingAppearance {
|
|
186
|
+
actions.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
|
187
|
+
actions.layer?.borderColor = NSColor.separatorColor.cgColor
|
|
188
|
+
actions.layer?.borderWidth = 1 / (window?.backingScaleFactor ?? 2)
|
|
189
|
+
}
|
|
190
|
+
actions.isHidden = source.isEmpty
|
|
191
|
+
}
|
|
192
|
+
override func mouseExited(with event: NSEvent) { actions.isHidden = true }
|
|
193
|
+
|
|
194
|
+
@objc private func copyAnswer() {
|
|
195
|
+
NSPasteboard.general.clearContents()
|
|
196
|
+
NSPasteboard.general.setString(source, forType: .string)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@objc private func openChat() { onOpenChat?() }
|
|
200
|
+
|
|
201
|
+
/// Select-and-⌘C: the pill has no Edit menu to route the shortcut.
|
|
202
|
+
func copySelection() { NSApp.sendAction(#selector(NSText.copy(_:)), to: web, from: nil) }
|
|
203
|
+
|
|
204
|
+
@objc private func openLink(_ sender: NSButton) {
|
|
205
|
+
guard let s = sender.identifier?.rawValue, let u = URL(string: s) else { return }
|
|
206
|
+
NSWorkspace.shared.open(u)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
extension AnswerView: WKNavigationDelegate {
|
|
211
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
212
|
+
MainActor.assumeIsolated { if !source.isEmpty { measure() } }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Links open in the real browser. A pill is not a place to read a web
|
|
216
|
+
/// page, and navigating away would replace the answer it exists to hold.
|
|
217
|
+
func webView(_ webView: WKWebView,
|
|
218
|
+
decidePolicyFor navigationAction: WKNavigationAction,
|
|
219
|
+
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
220
|
+
if navigationAction.navigationType == .linkActivated,
|
|
221
|
+
let url = navigationAction.request.url {
|
|
222
|
+
NSWorkspace.shared.open(url)
|
|
223
|
+
decisionHandler(.cancel)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
decisionHandler(.allow)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import SwiftUI
|
|
3
3
|
|
|
4
|
-
/// The Siri-style orb at the head of the pill: the agent's
|
|
5
|
-
/// listens, thinks and speaks.
|
|
4
|
+
/// The Siri-style orb at the head of the pill: the agent's nature palette
|
|
5
|
+
/// (lagoon, forest, dusk…), and it listens, thinks and speaks.
|
|
6
6
|
///
|
|
7
7
|
/// Built from SwiftUI's own gradients, no animation library. It only moves
|
|
8
8
|
/// while it has something to show and is on screen: idle or hidden, its
|
|
@@ -18,6 +18,9 @@ final class OrbModel: ObservableObject {
|
|
|
18
18
|
/// 0…1: the microphone while listening.
|
|
19
19
|
@Published var level: Double = 0
|
|
20
20
|
@Published var tint = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1)
|
|
21
|
+
/// The agent's palette, five stops deep to light, from the daemon.
|
|
22
|
+
/// Nil: shades of `tint` (an error, held notifications, an older daemon).
|
|
23
|
+
@Published var colors: [NSColor]?
|
|
21
24
|
/// The pill is on screen. Off, nothing moves.
|
|
22
25
|
@Published var onScreen = false
|
|
23
26
|
@Published var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
|
@@ -43,8 +46,8 @@ struct PillOrbView: View {
|
|
|
43
46
|
let paused = model.paused
|
|
44
47
|
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: paused)) { context in
|
|
45
48
|
OrbBody(t: paused ? 0 : context.date.timeIntervalSinceReferenceDate,
|
|
46
|
-
phase: model.phase, level: model.level, tint: model.tint,
|
|
47
|
-
ringWidth: 5)
|
|
49
|
+
phase: model.phase, level: model.level, tint: model.tint, colors: model.colors,
|
|
50
|
+
still: model.still, ringWidth: 5)
|
|
48
51
|
.frame(width: Self.designSize, height: Self.designSize)
|
|
49
52
|
.scaleEffect(diameter / Self.designSize)
|
|
50
53
|
}
|
|
@@ -89,11 +92,12 @@ final class PillOrb: NSHostingView<PillOrbView> {
|
|
|
89
92
|
|
|
90
93
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
91
94
|
|
|
92
|
-
/// Show `phase` in `
|
|
93
|
-
/// redraws what changed.
|
|
94
|
-
func show(_ phase: OrbModel.Phase, tint: NSColor) {
|
|
95
|
+
/// Show `phase` in `colors`, or in shades of `tint` without them.
|
|
96
|
+
/// Cheap to call on every render: SwiftUI only redraws what changed.
|
|
97
|
+
func show(_ phase: OrbModel.Phase, tint: NSColor, colors: [NSColor]?) {
|
|
95
98
|
let model = driver.model
|
|
96
99
|
if model.tint != tint { model.tint = tint }
|
|
100
|
+
if model.colors != colors { model.colors = colors }
|
|
97
101
|
if model.phase != phase {
|
|
98
102
|
model.phase = phase
|
|
99
103
|
if phase != .listening { model.level = 0 }
|
|
@@ -160,6 +164,8 @@ struct OrbBody: View {
|
|
|
160
164
|
let phase: OrbModel.Phase
|
|
161
165
|
let level: Double
|
|
162
166
|
let tint: NSColor
|
|
167
|
+
/// The agent's palette, deep to light; nil for shades of `tint`.
|
|
168
|
+
var colors: [NSColor]? = nil
|
|
163
169
|
/// Reduce Motion: a still orb, sized and shaded by state alone.
|
|
164
170
|
let still: Bool
|
|
165
171
|
/// The thinking ring's stroke at the 96-point design size. The pill
|
|
@@ -190,12 +196,17 @@ struct OrbBody: View {
|
|
|
190
196
|
let scale = 0.78 + 0.22 * energy
|
|
191
197
|
ZStack {
|
|
192
198
|
Circle()
|
|
193
|
-
.fill(
|
|
199
|
+
.fill(glow.opacity(0.28 + 0.32 * energy))
|
|
194
200
|
.blur(radius: 10 + 12 * energy)
|
|
195
201
|
.scaleEffect(scale * 1.08)
|
|
196
202
|
fill
|
|
197
203
|
.clipShape(Circle())
|
|
198
204
|
.scaleEffect(scale)
|
|
205
|
+
// Glass: a soft light from the top left, as on a marble.
|
|
206
|
+
Circle()
|
|
207
|
+
.fill(RadialGradient(colors: [.white.opacity(0.42), .white.opacity(0)],
|
|
208
|
+
center: UnitPoint(x: 0.34, y: 0.26), startRadius: 0, endRadius: 34))
|
|
209
|
+
.scaleEffect(scale)
|
|
199
210
|
// Thinking reads as a slow ring going round; the other states
|
|
200
211
|
// as a soft rim.
|
|
201
212
|
if phase == .thinking {
|
|
@@ -247,9 +258,25 @@ struct OrbBody: View {
|
|
|
247
258
|
]
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
/// The halo's colour: the palette's middle stop, or the tint.
|
|
262
|
+
private var glow: Color {
|
|
263
|
+
if let colors, colors.count == 5 { return Color(nsColor: colors[2]) }
|
|
264
|
+
return Color(nsColor: tint)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Five colours for the mesh: the agent's palette when it has one,
|
|
268
|
+
/// with its light stop brightening into a core as the voice grows.
|
|
269
|
+
private var palette: [Color] {
|
|
270
|
+
guard let colors, colors.count == 5 else { return shades }
|
|
271
|
+
let core = CGFloat(0.1 + 0.35 * energy)
|
|
272
|
+
let light = colors[4].blended(withFraction: core, of: .white) ?? colors[4]
|
|
273
|
+
// Spread so neighbouring mesh points differ: deep beside light.
|
|
274
|
+
return [colors[1], colors[3], colors[0], colors[2], light].map { Color(nsColor: $0) }
|
|
275
|
+
}
|
|
276
|
+
|
|
250
277
|
/// Five shades around the agent's colour: lighter, deeper and two
|
|
251
278
|
/// neighbouring hues, and a bright core that grows with the voice.
|
|
252
|
-
private var
|
|
279
|
+
private var shades: [Color] {
|
|
253
280
|
let base = tint.usingColorSpace(.sRGB) ?? tint
|
|
254
281
|
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
255
282
|
base.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
|
|
@@ -8,8 +8,10 @@ import AppKit
|
|
|
8
8
|
/// normal windows, joins every Space, and is deliberately small.
|
|
9
9
|
///
|
|
10
10
|
/// It is the one floating widget: a small live orb at its head (Orb.swift)
|
|
11
|
-
/// in the answering agent's
|
|
12
|
-
/// a close button that shows on hover.
|
|
11
|
+
/// in the answering agent's palette, the state or the words beside it, and
|
|
12
|
+
/// a close button that shows on hover. When an answer has more to read
|
|
13
|
+
/// than was spoken, the pill grows into it (PanelAnswer.swift) and
|
|
14
|
+
/// collapses again after a while. Drag it anywhere; the place is
|
|
13
15
|
/// remembered across launches.
|
|
14
16
|
final class Panel: NSPanel {
|
|
15
17
|
static let size = NSSize(width: 284, height: 54)
|
|
@@ -20,10 +22,35 @@ final class Panel: NSPanel {
|
|
|
20
22
|
private let label = NSTextField(labelWithString: "")
|
|
21
23
|
let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
|
|
22
24
|
private let closeButton = NSButton()
|
|
25
|
+
/// The orb, the words and the close button: the pill itself, which
|
|
26
|
+
/// stays where it is while the widget grows into an answer.
|
|
27
|
+
let row = RowView(frame: NSRect(origin: .zero, size: Panel.size))
|
|
28
|
+
/// The answer, below or above the row once the pill has grown.
|
|
29
|
+
let answer = AnswerView(frame: .zero)
|
|
30
|
+
let separator = NSBox()
|
|
23
31
|
/// Hidden by close, Esc or the menu until the next talk key.
|
|
24
|
-
|
|
25
|
-
///
|
|
26
|
-
|
|
32
|
+
var dismissed = false
|
|
33
|
+
/// Above zero while the app moves or resizes the pill, so only a drag
|
|
34
|
+
/// is remembered.
|
|
35
|
+
var placing = 0
|
|
36
|
+
|
|
37
|
+
// MARK: The answer (PanelAnswer.swift)
|
|
38
|
+
|
|
39
|
+
/// Width of the pill grown into an answer.
|
|
40
|
+
static let expandedWidth: CGFloat = 360
|
|
41
|
+
/// Tallest the answer grows, in points, before it scrolls. From
|
|
42
|
+
/// voice.card.maxHeight.
|
|
43
|
+
var cardMaxHeight: CGFloat = 320
|
|
44
|
+
/// Seconds the answer stays open once spoken; 0 until closed. From
|
|
45
|
+
/// voice.card.timeout.
|
|
46
|
+
var cardTimeout: TimeInterval = 30
|
|
47
|
+
/// Grown into an answer.
|
|
48
|
+
var expanded = false
|
|
49
|
+
/// Which way it grew, so it collapses back to the same place.
|
|
50
|
+
var growth = (above: true, alignRight: true)
|
|
51
|
+
var collapseTimer: Timer?
|
|
52
|
+
/// The pointer is over the widget: the answer stays open.
|
|
53
|
+
var hovering = false
|
|
27
54
|
|
|
28
55
|
/// Scrolls text too long for the pill instead of truncating it.
|
|
29
56
|
///
|
|
@@ -66,6 +93,9 @@ final class Panel: NSPanel {
|
|
|
66
93
|
/// The colour of the agent shown, for the orb.
|
|
67
94
|
var agentTint: () -> NSColor = { Brand.accent }
|
|
68
95
|
|
|
96
|
+
/// The palette of the agent shown, for the orb; nil for shades of its tint.
|
|
97
|
+
var agentPalette: () -> [NSColor]? = { nil }
|
|
98
|
+
|
|
69
99
|
/// Stay on screen when idle. Off: the pill shows only while active.
|
|
70
100
|
var alwaysVisible = false
|
|
71
101
|
|
|
@@ -75,18 +105,18 @@ final class Panel: NSPanel {
|
|
|
75
105
|
/// Close button or Esc. The app stops speech and calls `dismiss()`.
|
|
76
106
|
var onDismiss: (() -> Void)?
|
|
77
107
|
|
|
78
|
-
/// The pill moved, by a drag or a reset. The answer card follows.
|
|
79
|
-
var onMove: (() -> Void)?
|
|
80
|
-
|
|
81
108
|
/// The last state rendered, to draw again after a dismiss or a move.
|
|
82
|
-
|
|
109
|
+
var current = State.idle
|
|
83
110
|
|
|
111
|
+
/// A click on the pill's row talks. A click in the answer does not:
|
|
112
|
+
/// that is for reading and selecting.
|
|
84
113
|
override func mouseUp(with event: NSEvent) {
|
|
85
114
|
defer { pressedAt = nil }
|
|
86
115
|
if let start = pressedAt {
|
|
87
116
|
let now = NSEvent.mouseLocation
|
|
88
117
|
let moved = hypot(now.x - start.x, now.y - start.y)
|
|
89
|
-
|
|
118
|
+
let onRow = convertToScreen(row.convert(row.bounds, to: nil)).contains(start)
|
|
119
|
+
if moved < 4 && onRow { onClick?() }
|
|
90
120
|
}
|
|
91
121
|
super.mouseUp(with: event)
|
|
92
122
|
}
|
|
@@ -168,40 +198,21 @@ final class Panel: NSPanel {
|
|
|
168
198
|
isMovableByWindowBackground = true
|
|
169
199
|
hidesOnDeactivate = false
|
|
170
200
|
|
|
171
|
-
let
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
contentView = blur
|
|
180
|
-
|
|
181
|
-
// An opaque tint over the blur, and it is not a style preference.
|
|
182
|
-
//
|
|
183
|
-
// `blendingMode = .behindWindow` composites whatever is behind the
|
|
184
|
-
// pill, so the editor's own panel dividers were coming through it
|
|
185
|
-
// as a faint rectangle around the widget — it read as a border the
|
|
186
|
-
// widget was drawing, when it was the window behind. Translucency
|
|
187
|
-
// is pleasant over wallpaper and actively confusing over ruled UI.
|
|
188
|
-
//
|
|
189
|
-
// This keeps enough blur to feel native while making the pill read
|
|
190
|
-
// as one solid object wherever it is parked.
|
|
191
|
-
let tint = NSView(frame: blur.bounds)
|
|
192
|
-
tint.wantsLayer = true
|
|
193
|
-
tint.layer?.backgroundColor = NSColor.windowBackgroundColor
|
|
194
|
-
.withAlphaComponent(0.72).cgColor
|
|
195
|
-
tint.layer?.cornerRadius = Brand.Radius.lg
|
|
196
|
-
tint.autoresizingMask = [.width, .height]
|
|
197
|
-
blur.addSubview(tint)
|
|
201
|
+
let surface = Surface(frame: NSRect(origin: .zero, size: Self.size))
|
|
202
|
+
contentView = surface
|
|
203
|
+
surface.addSubview(row)
|
|
204
|
+
separator.boxType = .separator
|
|
205
|
+
separator.isHidden = true
|
|
206
|
+
surface.addSubview(separator)
|
|
207
|
+
surface.addSubview(answer)
|
|
208
|
+
answer.onHeight = { [weak self] height in self?.grow(answerHeight: height) }
|
|
198
209
|
|
|
199
210
|
// The orb, centred 28 points from the left edge; its square is
|
|
200
211
|
// larger than the orb so the glow is not cut off.
|
|
201
212
|
let h = Self.size.height
|
|
202
213
|
orb.frame = NSRect(x: 28 - Self.orbFrame / 2, y: (h - Self.orbFrame) / 2,
|
|
203
214
|
width: Self.orbFrame, height: Self.orbFrame)
|
|
204
|
-
|
|
215
|
+
row.addSubview(orb)
|
|
205
216
|
|
|
206
217
|
// A clipping window the label slides behind. Clicks pass through
|
|
207
218
|
// it, so pressing on the words drags the pill too.
|
|
@@ -213,7 +224,7 @@ final class Panel: NSPanel {
|
|
|
213
224
|
label.lineBreakMode = .byClipping
|
|
214
225
|
label.wantsLayer = true
|
|
215
226
|
clipView.addSubview(label)
|
|
216
|
-
|
|
227
|
+
row.addSubview(clipView)
|
|
217
228
|
clip = clipView
|
|
218
229
|
|
|
219
230
|
// Close, shown while the pointer is over the pill. Its space is
|
|
@@ -229,8 +240,10 @@ final class Panel: NSPanel {
|
|
|
229
240
|
closeButton.target = self
|
|
230
241
|
closeButton.action = #selector(closeClicked)
|
|
231
242
|
closeButton.isHidden = true
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
row.addSubview(closeButton)
|
|
244
|
+
surface.layoutContent = { [weak self] bounds in self?.layoutContent(bounds) }
|
|
245
|
+
MainActor.assumeIsolated { layoutContent(surface.bounds) }
|
|
246
|
+
surface.addTrackingArea(NSTrackingArea(rect: .zero,
|
|
234
247
|
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
|
|
235
248
|
owner: self, userInfo: nil))
|
|
236
249
|
|
|
@@ -246,10 +259,32 @@ final class Panel: NSPanel {
|
|
|
246
259
|
|
|
247
260
|
// MARK: Place
|
|
248
261
|
|
|
262
|
+
/// The row on the edge it grew from, full width; the answer in the
|
|
263
|
+
/// rest. The answer is always laid out at its full width, so its text
|
|
264
|
+
/// does not reflow while the pill grows around it.
|
|
265
|
+
@MainActor
|
|
266
|
+
func layoutContent(_ bounds: NSRect) {
|
|
267
|
+
let h = Self.size.height
|
|
268
|
+
let answerHeight = max(0, bounds.height - h)
|
|
269
|
+
let above = growth.above
|
|
270
|
+
row.frame = NSRect(x: 0, y: above ? 0 : answerHeight, width: bounds.width, height: h)
|
|
271
|
+
closeButton.frame.origin.x = bounds.width - 28
|
|
272
|
+
let clipWidth = bounds.width - 88
|
|
273
|
+
if let clip, clip.frame.width != clipWidth {
|
|
274
|
+
clip.frame.size.width = clipWidth
|
|
275
|
+
// Text that scrolled may fit now, and the other way round.
|
|
276
|
+
marqueeText = ""
|
|
277
|
+
if !current.isMeta { setText(label.stringValue) }
|
|
278
|
+
}
|
|
279
|
+
answer.frame = NSRect(x: 0, y: above ? h : 0, width: Self.expandedWidth, height: answerHeight)
|
|
280
|
+
separator.frame = NSRect(x: 0, y: above ? h - 1 : answerHeight, width: bounds.width, height: 1)
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
/// Where it was left, kept on a screen that still exists; the
|
|
250
284
|
/// bottom-right corner the first time.
|
|
251
285
|
@MainActor
|
|
252
286
|
func restorePosition() {
|
|
287
|
+
collapse(animated: false)
|
|
253
288
|
let screens = NSScreen.screens.map(\.visibleFrame)
|
|
254
289
|
let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
255
290
|
place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
|
|
@@ -264,19 +299,18 @@ final class Panel: NSPanel {
|
|
|
264
299
|
|
|
265
300
|
@MainActor
|
|
266
301
|
private func place(_ origin: NSPoint) {
|
|
267
|
-
placing
|
|
302
|
+
placing += 1
|
|
268
303
|
setFrameOrigin(origin)
|
|
269
|
-
placing
|
|
270
|
-
onMove?()
|
|
304
|
+
placing -= 1
|
|
271
305
|
}
|
|
272
306
|
|
|
273
307
|
/// Remember a drag. Moves the app makes itself are not saved, so a
|
|
274
|
-
/// reset or a clamp never overwrites where the user put it.
|
|
308
|
+
/// reset or a clamp never overwrites where the user put it. Dragged
|
|
309
|
+
/// while grown, the pill is remembered where it will collapse to.
|
|
275
310
|
@objc private func moved() {
|
|
276
311
|
MainActor.assumeIsolated {
|
|
277
|
-
guard
|
|
278
|
-
Config.pillOrigin = frame.origin
|
|
279
|
-
onMove?()
|
|
312
|
+
guard placing == 0 else { return }
|
|
313
|
+
Config.pillOrigin = expanded ? collapsedFrame().origin : frame.origin
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
316
|
|
|
@@ -291,6 +325,7 @@ final class Panel: NSPanel {
|
|
|
291
325
|
@MainActor
|
|
292
326
|
func dismiss() {
|
|
293
327
|
dismissed = true
|
|
328
|
+
collapse(animated: false)
|
|
294
329
|
hide()
|
|
295
330
|
}
|
|
296
331
|
|
|
@@ -303,7 +338,7 @@ final class Panel: NSPanel {
|
|
|
303
338
|
}
|
|
304
339
|
|
|
305
340
|
@MainActor
|
|
306
|
-
|
|
341
|
+
func show() {
|
|
307
342
|
orb.setOnScreen(true)
|
|
308
343
|
if !isVisible { orderFrontRegardless() }
|
|
309
344
|
}
|
|
@@ -317,8 +352,16 @@ final class Panel: NSPanel {
|
|
|
317
352
|
|
|
318
353
|
@objc private func closeClicked() { onDismiss?() }
|
|
319
354
|
|
|
320
|
-
override func mouseEntered(with event: NSEvent) {
|
|
321
|
-
|
|
355
|
+
override func mouseEntered(with event: NSEvent) {
|
|
356
|
+
closeButton.isHidden = false
|
|
357
|
+
hovering = true
|
|
358
|
+
MainActor.assumeIsolated { armCollapse() }
|
|
359
|
+
}
|
|
360
|
+
override func mouseExited(with event: NSEvent) {
|
|
361
|
+
closeButton.isHidden = true
|
|
362
|
+
hovering = false
|
|
363
|
+
MainActor.assumeIsolated { armCollapse() }
|
|
364
|
+
}
|
|
322
365
|
|
|
323
366
|
/// Esc, once the pill has been clicked and so holds the keyboard.
|
|
324
367
|
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
|
@@ -327,6 +370,17 @@ final class Panel: NSPanel {
|
|
|
327
370
|
if event.keyCode == 53 { onDismiss?() } else { super.keyDown(with: event) }
|
|
328
371
|
}
|
|
329
372
|
|
|
373
|
+
/// ⌘C copies what is selected in the answer. The app has no Edit menu
|
|
374
|
+
/// to route the shortcut there.
|
|
375
|
+
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
376
|
+
if expanded, event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
|
|
377
|
+
event.charactersIgnoringModifiers == "c" {
|
|
378
|
+
answer.copySelection()
|
|
379
|
+
return true
|
|
380
|
+
}
|
|
381
|
+
return super.performKeyEquivalent(with: event)
|
|
382
|
+
}
|
|
383
|
+
|
|
330
384
|
/// AppKit is not thread-safe. Marking this explicitly means a stray
|
|
331
385
|
/// call from a delegate queue is a compile error rather than heap
|
|
332
386
|
/// corruption that traps somewhere unrelated an hour later.
|
|
@@ -341,7 +395,13 @@ final class Panel: NSPanel {
|
|
|
341
395
|
case .idle where Hold.isOn: tint = Brand.warn
|
|
342
396
|
default: tint = agentTint()
|
|
343
397
|
}
|
|
344
|
-
|
|
398
|
+
let colors: [NSColor]?
|
|
399
|
+
switch state {
|
|
400
|
+
case .error: colors = nil
|
|
401
|
+
case .idle where Hold.isOn: colors = nil
|
|
402
|
+
default: colors = agentPalette()
|
|
403
|
+
}
|
|
404
|
+
orb.show(state.orbPhase, tint: tint, colors: colors)
|
|
345
405
|
|
|
346
406
|
if state.isMeta {
|
|
347
407
|
stopMarquee()
|
|
@@ -354,7 +414,9 @@ final class Panel: NSPanel {
|
|
|
354
414
|
setText(named(state))
|
|
355
415
|
}
|
|
356
416
|
|
|
357
|
-
|
|
417
|
+
// Grown into an answer, it stays until it collapses, idle or not.
|
|
418
|
+
if dismissed || (state.isMeta && !alwaysVisible && !expanded) { hide() } else { show() }
|
|
419
|
+
armCollapse()
|
|
358
420
|
onRender?(state)
|
|
359
421
|
}
|
|
360
422
|
|
|
@@ -371,7 +433,7 @@ final class Panel: NSPanel {
|
|
|
371
433
|
/// Fits, or scrolls. Identical text is left alone so a per-second tick
|
|
372
434
|
/// cannot restart the animation and make it stutter in place.
|
|
373
435
|
@MainActor
|
|
374
|
-
|
|
436
|
+
func setText(_ text: String) {
|
|
375
437
|
guard let clipView = clip else { label.stringValue = text; return }
|
|
376
438
|
let font = label.font ?? NSFont.systemFont(ofSize: 12)
|
|
377
439
|
let width = (text as NSString).size(withAttributes: [.font: font]).width
|
|
@@ -411,7 +473,7 @@ final class Panel: NSPanel {
|
|
|
411
473
|
}
|
|
412
474
|
|
|
413
475
|
@MainActor
|
|
414
|
-
|
|
476
|
+
func stopMarquee() {
|
|
415
477
|
label.layer?.removeAnimation(forKey: "marquee")
|
|
416
478
|
marqueeText = ""
|
|
417
479
|
}
|
|
@@ -426,3 +488,12 @@ final class Panel: NSPanel {
|
|
|
426
488
|
private final class PassThroughView: NSView {
|
|
427
489
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
428
490
|
}
|
|
491
|
+
|
|
492
|
+
/// The pill's row: its background passes clicks through, so pressing
|
|
493
|
+
/// anywhere on it drags the pill, while its close button still clicks.
|
|
494
|
+
final class RowView: NSView {
|
|
495
|
+
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
496
|
+
let hit = super.hitTest(point)
|
|
497
|
+
return hit === self ? nil : hit
|
|
498
|
+
}
|
|
499
|
+
}
|