samagotchi 0.122.0 → 0.124.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- metadata +7 -1
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// only; `chi broadcast` on the Everyone row, Broadcast.swift), ⌥⏎ a paste
|
|
9
9
|
// without Enter (agents in terminals only), ⇧⏎ a newline. ⌘M (or a click on
|
|
10
10
|
// the new row's model) opens the model chooser for a new session in place of
|
|
11
|
-
// the targets (Models.swift).
|
|
11
|
+
// the targets (Models.swift). Remotes' sessions (`chi remote ls`) follow in
|
|
12
|
+
// a section per remote, "on studio", sent with `chi --remote studio …`, each
|
|
13
|
+
// with its own new row (`chi --remote studio send --new`, the remote's model).
|
|
12
14
|
import AppKit
|
|
13
15
|
import SwiftUI
|
|
14
16
|
|
|
@@ -31,7 +33,12 @@ final class PanelModel: ObservableObject {
|
|
|
31
33
|
/// @State: some Command Line Tools SDKs want the SwiftUIMacros plugin for
|
|
32
34
|
/// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
|
|
33
35
|
@Published var hoveredImage: UUID?
|
|
36
|
+
/// This Mac's sessions.
|
|
34
37
|
@Published var sessions: [LiveSession] = []
|
|
38
|
+
/// The remotes' sections, in `chi remote ls` order, each filled in as it
|
|
39
|
+
/// answers; never the previous open's (a stale row could send to a
|
|
40
|
+
/// stopped worker unseen).
|
|
41
|
+
@Published var remoteSections: [RemoteSection] = []
|
|
35
42
|
/// Agents in terminals, and the terminals launch.json switches on
|
|
36
43
|
/// (none: no agent rows and no note).
|
|
37
44
|
@Published var agentTargets: [AgentTarget] = []
|
|
@@ -41,12 +48,19 @@ final class PanelModel: ObservableObject {
|
|
|
41
48
|
@Published var agentNote: String?
|
|
42
49
|
/// Why a terminal can't be asked, with or without rows; " · " between two.
|
|
43
50
|
@Published var agentErrors: String?
|
|
44
|
-
///
|
|
51
|
+
/// Session keys (LiveSession.key: a remote's carry its name) and agent
|
|
52
|
+
/// keys (never alike: UUIDs vs paths).
|
|
45
53
|
@Published var selected: Set<String> = []
|
|
46
54
|
/// The user changed the selection: a late agent listing keeps it.
|
|
47
55
|
var touched = false
|
|
48
56
|
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
49
57
|
@Published var newSelected = false
|
|
58
|
+
/// A remote's new row, by the remote's name: `chi --remote NAME send
|
|
59
|
+
/// --new` in that remote's folder (newSessionDir(remote:)), on its
|
|
60
|
+
/// default model. Ticked alone, as the new row; never preselected.
|
|
61
|
+
@Published var newRemote: String?
|
|
62
|
+
/// Either new row: a new session, here or on a remote.
|
|
63
|
+
var startsNew: Bool { newSelected || newRemote != nil }
|
|
50
64
|
/// The "Everyone it concerns" row: ⌘⏎ broadcasts (`chi broadcast`);
|
|
51
65
|
/// never together with the new row or picked targets.
|
|
52
66
|
@Published var everyoneSelected = false
|
|
@@ -74,6 +88,8 @@ final class PanelModel: ObservableObject {
|
|
|
74
88
|
@Published var message = ""
|
|
75
89
|
|
|
76
90
|
static let lastChoiceKey = "lastSessionIds"
|
|
91
|
+
/// The remembered keys in place of UserDefaults' (a headless seam's).
|
|
92
|
+
var lastChoice: [String]?
|
|
77
93
|
|
|
78
94
|
var bytes: Int { prompt.utf8.count + text.utf8.count }
|
|
79
95
|
var canSend: Bool {
|
|
@@ -84,15 +100,41 @@ final class PanelModel: ObservableObject {
|
|
|
84
100
|
/// A note's text (stdin of `chi note`): the message line, then the context.
|
|
85
101
|
var noteText: String { [trimmedPrompt, hasContext ? text : ""].filter { !$0.isEmpty }.joined(separator: "\n\n") }
|
|
86
102
|
var sendEnabled: Bool {
|
|
87
|
-
canSend && (
|
|
103
|
+
canSend && (startsNew || everyoneSelected || !selectedSessions.isEmpty || !selectedAgents.isEmpty)
|
|
88
104
|
&& (!trimmedPrompt.isEmpty || hasContext)
|
|
89
105
|
}
|
|
90
106
|
var live: [LiveSession] { sessions.filter { !$0.recent } }
|
|
91
107
|
|
|
92
|
-
///
|
|
93
|
-
///
|
|
94
|
-
///
|
|
95
|
-
var
|
|
108
|
+
/// This Mac's rows: live sessions (oldest started first: their numbers
|
|
109
|
+
/// stay put), then agents in terminals, then recent sessions; the panel
|
|
110
|
+
/// shows the first nine.
|
|
111
|
+
var localTargets: [Target] { live.map(Target.chi) + agentTargets.map(Target.agent) + recent.map(Target.chi) }
|
|
112
|
+
|
|
113
|
+
/// One list for rows, ⌘ numbers and selection: this Mac's nine, then
|
|
114
|
+
/// the remotes' rows, so ⌘1…⌘9 number this Mac's first.
|
|
115
|
+
var targets: [Target] { Array(localTargets.prefix(9)) + remoteSessions.map(Target.chi) }
|
|
116
|
+
|
|
117
|
+
/// A remote's section as shown: its rows without a session listed
|
|
118
|
+
/// before (this Mac's, an earlier remote's: one Studio by two addresses,
|
|
119
|
+
/// or a remote that is this Mac), and the ⌘ index of its first row.
|
|
120
|
+
struct RemoteGroup: Identifiable {
|
|
121
|
+
let section: RemoteSection
|
|
122
|
+
let rows: [LiveSession]
|
|
123
|
+
let firstIndex: Int
|
|
124
|
+
var id: String { section.name }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
var remoteGroups: [RemoteGroup] {
|
|
128
|
+
var seen = Set(sessions.map(\.id))
|
|
129
|
+
var index = min(localTargets.count, 9)
|
|
130
|
+
return remoteSections.map { section in
|
|
131
|
+
let rows = section.sessions.filter { seen.insert($0.id).inserted }
|
|
132
|
+
defer { index += rows.count }
|
|
133
|
+
return RemoteGroup(section: section, rows: rows, firstIndex: index)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
var remoteSessions: [LiveSession] { remoteGroups.flatMap(\.rows) }
|
|
96
138
|
|
|
97
139
|
/// Up to maxImages; the rest is dropped (and its temp files deleted).
|
|
98
140
|
func add(_ new: [PanelImage]) {
|
|
@@ -142,10 +184,25 @@ final class PanelModel: ObservableObject {
|
|
|
142
184
|
row.kind == .standard ? modelForSend == nil : pickedModel.map { ModelPick.same($0, row.name) } ?? false
|
|
143
185
|
}
|
|
144
186
|
|
|
187
|
+
/// D4 of the remote sessions plan: `chi models` answers for this Mac.
|
|
188
|
+
static let remoteModelRefusal = "Model choice is for this Mac's sessions"
|
|
189
|
+
|
|
190
|
+
/// ⌘M: opens or closes the chooser; on a remote's new row it says why
|
|
191
|
+
/// not instead (false: the caller beeps).
|
|
192
|
+
func pressModel() -> Bool {
|
|
193
|
+
if newRemote != nil {
|
|
194
|
+
message = Self.remoteModelRefusal
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
if pickerOpen { closePicker() } else { openPicker() }
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
145
201
|
/// Opens the chooser on the new row, the current pick highlighted.
|
|
146
202
|
func openPicker() {
|
|
147
203
|
touched = true
|
|
148
204
|
newSelected = true
|
|
205
|
+
newRemote = nil
|
|
149
206
|
everyoneSelected = false
|
|
150
207
|
selected = []
|
|
151
208
|
modelQuery = ""
|
|
@@ -168,6 +225,7 @@ final class PanelModel: ObservableObject {
|
|
|
168
225
|
func choose(_ row: ModelRow) {
|
|
169
226
|
pickTouched = true
|
|
170
227
|
newSelected = true
|
|
228
|
+
newRemote = nil
|
|
171
229
|
everyoneSelected = false
|
|
172
230
|
selected = []
|
|
173
231
|
if row.kind == .standard {
|
|
@@ -194,32 +252,56 @@ final class PanelModel: ObservableObject {
|
|
|
194
252
|
}
|
|
195
253
|
}
|
|
196
254
|
|
|
197
|
-
/// The last choice, where still live or listed
|
|
198
|
-
///
|
|
199
|
-
///
|
|
255
|
+
/// The last choice, where still live or listed (a remote's live row
|
|
256
|
+
/// too: the hint names its machine); else this Mac's only live session
|
|
257
|
+
/// (never a remote's); else, with none live here and nothing chosen, the
|
|
258
|
+
/// new row. A recent (stopped) one is never preselected: a message would
|
|
259
|
+
/// wake it.
|
|
200
260
|
func preselect() {
|
|
201
|
-
let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
|
|
202
|
-
|
|
203
|
-
|
|
261
|
+
let last = Set(lastChoice ?? UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
|
|
262
|
+
let remoteLive = remoteSessions.filter { !$0.recent }
|
|
263
|
+
selected = last.intersection(Set(live.map(\.key) + remoteLive.map(\.key) + agentTargets.map(\.key)))
|
|
264
|
+
if selected.isEmpty, live.count == 1 { selected = [live[0].key] }
|
|
204
265
|
newSelected = live.isEmpty && selected.isEmpty
|
|
205
266
|
}
|
|
206
267
|
|
|
207
268
|
func toggle(_ id: String) {
|
|
208
269
|
touched = true
|
|
209
|
-
if selected.contains(id) {
|
|
270
|
+
if selected.contains(id) {
|
|
271
|
+
selected.remove(id)
|
|
272
|
+
} else {
|
|
273
|
+
selected.insert(id)
|
|
274
|
+
newSelected = false
|
|
275
|
+
newRemote = nil
|
|
276
|
+
everyoneSelected = false
|
|
277
|
+
}
|
|
210
278
|
}
|
|
211
279
|
|
|
212
280
|
func toggleNew() {
|
|
213
281
|
touched = true
|
|
214
282
|
newSelected.toggle()
|
|
215
|
-
if newSelected { selected = []; everyoneSelected = false }
|
|
283
|
+
if newSelected { selected = []; newRemote = nil; everyoneSelected = false }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// A remote's new row; nothing for a remote without one (no folder).
|
|
287
|
+
func toggleNew(remote name: String) {
|
|
288
|
+
guard newSessionDir(remote: name) != nil else { return }
|
|
289
|
+
touched = true
|
|
290
|
+
if newRemote == name {
|
|
291
|
+
newRemote = nil
|
|
292
|
+
} else {
|
|
293
|
+
newRemote = name
|
|
294
|
+
selected = []
|
|
295
|
+
newSelected = false
|
|
296
|
+
everyoneSelected = false
|
|
297
|
+
}
|
|
216
298
|
}
|
|
217
299
|
|
|
218
300
|
/// The preselected session is dropped: a broadcast picks its own.
|
|
219
301
|
func toggleEveryone() {
|
|
220
302
|
touched = true
|
|
221
303
|
everyoneSelected.toggle()
|
|
222
|
-
if everyoneSelected { selected = []; newSelected = false }
|
|
304
|
+
if everyoneSelected { selected = []; newSelected = false; newRemote = nil }
|
|
223
305
|
}
|
|
224
306
|
|
|
225
307
|
/// Where a new session starts: the helper runs in $HOME, which is in no
|
|
@@ -234,14 +316,71 @@ final class PanelModel: ObservableObject {
|
|
|
234
316
|
return (newest(live) ?? newest(recent))?.cwd ?? NSHomeDirectory()
|
|
235
317
|
}
|
|
236
318
|
|
|
319
|
+
/// Where a new session on a remote starts: the folder of its most
|
|
320
|
+
/// recently updated live session, else of its newest recent one (as
|
|
321
|
+
/// listed, before any dedupe). Not checked here: the path is that
|
|
322
|
+
/// machine's. nil (no session with an absolute folder): no new row, never
|
|
323
|
+
/// a guess at its home.
|
|
324
|
+
func newSessionDir(remote name: String) -> String? {
|
|
325
|
+
guard let section = remoteSections.first(where: { $0.name == name }) else { return nil }
|
|
326
|
+
let placed = section.sessions.filter { $0.cwd?.hasPrefix("/") == true }
|
|
327
|
+
let newest = { (list: [LiveSession]) in list.max { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) } }
|
|
328
|
+
return (newest(placed.filter { !$0.recent }) ?? newest(placed.filter(\.recent)))?.cwd
|
|
329
|
+
}
|
|
330
|
+
|
|
237
331
|
var newSessionFolder: String {
|
|
238
332
|
let dir = newSessionDir
|
|
239
333
|
return dir == NSHomeDirectory() ? "~" : (dir as NSString).lastPathComponent
|
|
240
334
|
}
|
|
241
335
|
|
|
242
|
-
///
|
|
243
|
-
|
|
244
|
-
|
|
336
|
+
/// This Mac's listing answered: its rows, and the preselection unless
|
|
337
|
+
/// the user already chose (⌘0, ⌘M while it listed).
|
|
338
|
+
func showSessions(_ result: Result<[LiveSession], ChiError>) {
|
|
339
|
+
switch result {
|
|
340
|
+
case .success(let sessions):
|
|
341
|
+
self.sessions = sessions
|
|
342
|
+
if !touched { preselect() }
|
|
343
|
+
phase = .ready
|
|
344
|
+
case .failure(let error):
|
|
345
|
+
sessions = []
|
|
346
|
+
phase = .failed
|
|
347
|
+
message = error.description
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/// `chi remote ls` answered: a section per remote, each asking.
|
|
352
|
+
func startRemotes(_ names: [String]) {
|
|
353
|
+
remoteSections = names.map { RemoteSection(name: $0) }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// One remote answered: its rows or its line. It preselects only when
|
|
357
|
+
/// nothing is ticked yet and the panel is ready: a late answer never
|
|
358
|
+
/// changes a selection (the user may be typing to it, and the "→ studio"
|
|
359
|
+
/// hint would come too late), nor one being sent or failed.
|
|
360
|
+
func showRemote(_ name: String, _ result: Result<[LiveSession], ChiError>) {
|
|
361
|
+
guard let index = remoteSections.firstIndex(where: { $0.name == name }) else { return }
|
|
362
|
+
var section = RemoteSection(name: name, loading: false)
|
|
363
|
+
switch result {
|
|
364
|
+
case .success(let sessions): section.sessions = sessions
|
|
365
|
+
case .failure(let error): section.error = error.description
|
|
366
|
+
}
|
|
367
|
+
remoteSections[index] = section
|
|
368
|
+
if phase == .ready, !touched, selected.isEmpty, !startsNew, !everyoneSelected { preselect() }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// The ticked sessions, this Mac's then the remotes', in list order;
|
|
372
|
+
/// only UUID-shaped ids.
|
|
373
|
+
var selectedSessions: [LiveSession] {
|
|
374
|
+
(sessions + remoteSessions).filter { selected.contains($0.key) && $0.valid }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/// The message field's hint while a remote's row (or new row) is
|
|
378
|
+
/// ticked: " → studio" (both machines likely have the same paths); ""
|
|
379
|
+
/// otherwise.
|
|
380
|
+
var machineHint: String {
|
|
381
|
+
var names: [String] = newRemote.map { [$0] } ?? []
|
|
382
|
+
for name in selectedSessions.compactMap(\.remote) where !names.contains(name) { names.append(name) }
|
|
383
|
+
return names.isEmpty ? "" : " → " + names.joined(separator: ", ")
|
|
245
384
|
}
|
|
246
385
|
|
|
247
386
|
/// A listing's answer: the rows, "no agents in kitty" when there are
|
|
@@ -269,7 +408,7 @@ final class PanelModel: ObservableObject {
|
|
|
269
408
|
var addressee: String {
|
|
270
409
|
if everyoneSelected { return "everyone it concerns" }
|
|
271
410
|
let agents = Set(selectedAgents.map(\.agent))
|
|
272
|
-
return agents.count == 1 &&
|
|
411
|
+
return agents.count == 1 && selectedSessions.isEmpty && !startsNew ? agents.first! : "chi"
|
|
273
412
|
}
|
|
274
413
|
}
|
|
275
414
|
|
|
@@ -280,7 +419,7 @@ enum Target: Identifiable {
|
|
|
280
419
|
|
|
281
420
|
var id: String {
|
|
282
421
|
switch self {
|
|
283
|
-
case .chi(let session): return session.
|
|
422
|
+
case .chi(let session): return session.key
|
|
284
423
|
case .agent(let target): return target.key
|
|
285
424
|
}
|
|
286
425
|
}
|
|
@@ -336,7 +475,7 @@ struct PanelView: View {
|
|
|
336
475
|
/// The message is required with images, as in the web.
|
|
337
476
|
var placeholder: String {
|
|
338
477
|
if model.everyoneSelected { return "Tell everyone it concerns… (⌘⏎)" }
|
|
339
|
-
return model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…"
|
|
478
|
+
return (model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…") + model.machineHint
|
|
340
479
|
}
|
|
341
480
|
|
|
342
481
|
@ViewBuilder var imageStrip: some View {
|
|
@@ -373,7 +512,7 @@ struct PanelView: View {
|
|
|
373
512
|
newRow
|
|
374
513
|
everyoneRow
|
|
375
514
|
let live = model.live.count, agents = model.agentTargets.count
|
|
376
|
-
ForEach(Array(model.
|
|
515
|
+
ForEach(Array(model.localTargets.prefix(9).enumerated()), id: \.element.id) { index, target in
|
|
377
516
|
if index == live && agents > 0 { groupHeader(agentHeader, first: index == 0) }
|
|
378
517
|
if index == live + agents && !model.recent.isEmpty {
|
|
379
518
|
agentNoteLines
|
|
@@ -385,6 +524,14 @@ struct PanelView: View {
|
|
|
385
524
|
}
|
|
386
525
|
}
|
|
387
526
|
if model.recent.isEmpty { agentNoteLines }
|
|
527
|
+
ForEach(model.remoteGroups) { group in
|
|
528
|
+
groupHeader("on \(group.section.name)", first: false)
|
|
529
|
+
remoteNoteLine(group)
|
|
530
|
+
if let dir = model.newSessionDir(remote: group.section.name) { remoteNewRow(group.section.name, dir: dir) }
|
|
531
|
+
ForEach(Array(group.rows.enumerated()), id: \.element.key) { offset, session in
|
|
532
|
+
row(session, index: group.firstIndex + offset)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
388
535
|
}
|
|
389
536
|
}
|
|
390
537
|
}
|
|
@@ -422,6 +569,27 @@ struct PanelView: View {
|
|
|
422
569
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
423
570
|
}
|
|
424
571
|
|
|
572
|
+
/// Under a remote's divider: asking, why it has no rows, or none.
|
|
573
|
+
@ViewBuilder func remoteNoteLine(_ group: PanelModel.RemoteGroup) -> some View {
|
|
574
|
+
if group.section.loading {
|
|
575
|
+
remoteLine("asking \(group.section.name)…")
|
|
576
|
+
} else if let error = group.section.error {
|
|
577
|
+
remoteLine(error).help(error)
|
|
578
|
+
} else if group.rows.isEmpty {
|
|
579
|
+
// All of them listed above: the same machine by another name.
|
|
580
|
+
remoteLine(group.section.sessions.isEmpty ? "no live or recent sessions" : "its sessions are listed above")
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
func remoteLine(_ text: String) -> some View {
|
|
585
|
+
HStack(spacing: 6) {
|
|
586
|
+
Image(systemName: "network").font(.system(size: 11))
|
|
587
|
+
Text(text).font(.system(size: 11)).lineLimit(1)
|
|
588
|
+
}
|
|
589
|
+
.foregroundColor(.secondary.opacity(0.7))
|
|
590
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
591
|
+
}
|
|
592
|
+
|
|
425
593
|
func agentRow(_ target: AgentTarget, index: Int) -> some View {
|
|
426
594
|
let on = model.selected.contains(target.key)
|
|
427
595
|
return Button(action: { model.toggle(target.key) }) {
|
|
@@ -475,6 +643,44 @@ struct PanelView: View {
|
|
|
475
643
|
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
476
644
|
}
|
|
477
645
|
|
|
646
|
+
/// A remote's new row: its name and folder, whole (that machine's home
|
|
647
|
+
/// may differ), the remote's default model and no ⌘ number (⌘0 is this
|
|
648
|
+
/// Mac's new row).
|
|
649
|
+
func remoteNewRow(_ name: String, dir: String) -> some View {
|
|
650
|
+
let on = model.newRemote == name
|
|
651
|
+
return HStack(spacing: 10) {
|
|
652
|
+
Button(action: { model.toggleNew(remote: name) }) {
|
|
653
|
+
HStack(spacing: 10) {
|
|
654
|
+
Image(systemName: on ? "plus.circle.fill" : "plus.circle")
|
|
655
|
+
.font(.system(size: 16))
|
|
656
|
+
.foregroundColor(on ? .accentColor : .secondary)
|
|
657
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
658
|
+
Text("New session on \(name) in \((dir as NSString).lastPathComponent)")
|
|
659
|
+
.font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
660
|
+
Text(dir).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
661
|
+
}
|
|
662
|
+
Spacer(minLength: 0)
|
|
663
|
+
}
|
|
664
|
+
.contentShape(Rectangle())
|
|
665
|
+
}
|
|
666
|
+
.buttonStyle(.plain)
|
|
667
|
+
.help("start a session on \(name) with the message (chi --remote \(name) send --new), in its newest session's folder")
|
|
668
|
+
Button(action: {
|
|
669
|
+
NSSound.beep()
|
|
670
|
+
model.message = PanelModel.remoteModelRefusal
|
|
671
|
+
}) {
|
|
672
|
+
Text("remote default").font(.system(size: 11)).foregroundColor(.secondary).contentShape(Rectangle())
|
|
673
|
+
}
|
|
674
|
+
.buttonStyle(.plain)
|
|
675
|
+
.help("the model \(name)'s chi starts a new session on: model choice is for this Mac's sessions")
|
|
676
|
+
Text(name).font(.system(size: 10)).foregroundColor(.secondary)
|
|
677
|
+
.padding(.horizontal, 5).padding(.vertical, 1)
|
|
678
|
+
.background(Capsule().fill(Color.primary.opacity(0.07)))
|
|
679
|
+
}
|
|
680
|
+
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
681
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
682
|
+
}
|
|
683
|
+
|
|
478
684
|
/// `chi broadcast` instead of picked sessions (⌘B): ⌘⏎ shares the note
|
|
479
685
|
/// with the sessions chi finds it concerns.
|
|
480
686
|
var everyoneRow: some View {
|
|
@@ -486,7 +692,8 @@ struct PanelView: View {
|
|
|
486
692
|
.foregroundColor(on ? .accentColor : .secondary)
|
|
487
693
|
.frame(width: 18)
|
|
488
694
|
VStack(alignment: .leading, spacing: 1) {
|
|
489
|
-
|
|
695
|
+
// chi broadcast runs on this Mac only.
|
|
696
|
+
Text("Everyone it concerns (this Mac)").font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
490
697
|
Text("a note for the sessions chi finds it concerns: ⌘⏎").font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
491
698
|
}
|
|
492
699
|
Spacer()
|
|
@@ -581,9 +788,11 @@ struct PanelView: View {
|
|
|
581
788
|
.help(row.kind == .typed ? "not in any host's list: sent as typed" : row.name)
|
|
582
789
|
}
|
|
583
790
|
|
|
791
|
+
/// A session's row; a remote's names its machine (both likely have the
|
|
792
|
+
/// same paths). ⌘ numbers stop at nine.
|
|
584
793
|
func row(_ session: LiveSession, index: Int) -> some View {
|
|
585
|
-
let on = model.selected.contains(session.
|
|
586
|
-
return Button(action: { model.toggle(session.
|
|
794
|
+
let on = model.selected.contains(session.key)
|
|
795
|
+
return Button(action: { model.toggle(session.key) }) {
|
|
587
796
|
HStack(spacing: 10) {
|
|
588
797
|
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
|
589
798
|
.font(.system(size: 16))
|
|
@@ -601,7 +810,15 @@ struct PanelView: View {
|
|
|
601
810
|
if session.busy == true {
|
|
602
811
|
Circle().fill(Color.orange).frame(width: 7, height: 7).help("working on a turn")
|
|
603
812
|
}
|
|
604
|
-
|
|
813
|
+
if let remote = session.remote {
|
|
814
|
+
Text(remote).font(.system(size: 10)).foregroundColor(.secondary)
|
|
815
|
+
.padding(.horizontal, 5).padding(.vertical, 1)
|
|
816
|
+
.background(Capsule().fill(Color.primary.opacity(0.07)))
|
|
817
|
+
.help("a session on \(remote): sent with chi --remote \(remote)")
|
|
818
|
+
}
|
|
819
|
+
if index < 9 {
|
|
820
|
+
Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
821
|
+
}
|
|
605
822
|
}
|
|
606
823
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
607
824
|
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
@@ -648,7 +865,7 @@ struct PanelView: View {
|
|
|
648
865
|
// ⏎, ⌘⏎ and ⌥⏎ are handled by the panel (KeyPanel.sendEvent),
|
|
649
866
|
// whatever has the focus; the buttons are for the mouse. Only agents
|
|
650
867
|
// in terminals chosen: Paste instead of Note.
|
|
651
|
-
if !model.selectedAgents.isEmpty && model.
|
|
868
|
+
if !model.selectedAgents.isEmpty && model.selectedSessions.isEmpty && !model.startsNew {
|
|
652
869
|
Button("Paste ⌥⏎") { send(.paste) }
|
|
653
870
|
.disabled(!model.sendEnabled)
|
|
654
871
|
.help("paste it into the agent's input without Enter: add more, then press Enter there")
|
|
@@ -658,10 +875,18 @@ struct PanelView: View {
|
|
|
658
875
|
.help("share it as a note with every session it concerns (chi broadcast); starts no turn")
|
|
659
876
|
} else {
|
|
660
877
|
Button("Note ⌘⏎") { send(.note) }
|
|
661
|
-
.disabled(!model.sendEnabled || !model.selectedAgents.isEmpty || model.
|
|
662
|
-
.help(model.
|
|
878
|
+
.disabled(!model.sendEnabled || !model.selectedAgents.isEmpty || model.startsNew)
|
|
879
|
+
.help(model.startsNew ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
|
|
663
880
|
: "add it as background the model sees on its next turn; starts no turn")
|
|
664
881
|
}
|
|
882
|
+
// Where it goes while a remote's row is ticked: stays when the
|
|
883
|
+
// placeholder's hint is gone under typed text.
|
|
884
|
+
if !model.machineHint.isEmpty {
|
|
885
|
+
Text(model.machineHint.trimmingCharacters(in: .whitespaces))
|
|
886
|
+
.font(.system(size: 11, weight: .medium)).foregroundColor(.accentColor)
|
|
887
|
+
.lineLimit(1).fixedSize()
|
|
888
|
+
.help("sent to that machine's sessions (chi --remote)")
|
|
889
|
+
}
|
|
665
890
|
// A broadcast is a note: no Send.
|
|
666
891
|
if model.everyoneSelected {
|
|
667
892
|
if model.phase == .sending { ProgressView().controlSize(.small) }
|
|
@@ -847,7 +1072,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
847
1072
|
let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command
|
|
848
1073
|
// By key code: ⌘M in any keyboard layout (a Cyrillic one types "ь").
|
|
849
1074
|
if command, event.keyCode == 46 {
|
|
850
|
-
if model.
|
|
1075
|
+
if !model.pressModel() { NSSound.beep() }
|
|
851
1076
|
return true
|
|
852
1077
|
}
|
|
853
1078
|
if model.pickerOpen {
|
|
@@ -883,6 +1108,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
883
1108
|
model.source = source
|
|
884
1109
|
model.message = ""
|
|
885
1110
|
model.newSelected = false
|
|
1111
|
+
model.newRemote = nil
|
|
886
1112
|
model.everyoneSelected = false
|
|
887
1113
|
model.selected = []
|
|
888
1114
|
model.touched = false
|
|
@@ -894,6 +1120,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
894
1120
|
placeOnActiveScreen()
|
|
895
1121
|
panel.makeKeyAndOrderFront(nil)
|
|
896
1122
|
loadSessions()
|
|
1123
|
+
loadRemotes()
|
|
897
1124
|
loadAgents()
|
|
898
1125
|
loadModel()
|
|
899
1126
|
loadModels()
|
|
@@ -909,18 +1136,26 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
909
1136
|
}
|
|
910
1137
|
|
|
911
1138
|
private func loadSessions() {
|
|
912
|
-
runner.sessions { [weak self] result in
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1139
|
+
runner.sessions { [weak self] result in self?.model.showSessions(result) }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private var remotesGeneration = 0
|
|
1143
|
+
|
|
1144
|
+
/// The remotes' sections, beside this Mac's listing and never blocking
|
|
1145
|
+
/// it: each fills in as it answers (or fails, D5); PanelModel.showRemote
|
|
1146
|
+
/// says when one may preselect.
|
|
1147
|
+
private func loadRemotes() {
|
|
1148
|
+
remotesGeneration += 1
|
|
1149
|
+
let generation = remotesGeneration
|
|
1150
|
+
model.remoteSections = []
|
|
1151
|
+
runner.remotes { [weak self] names in
|
|
1152
|
+
guard let self, generation == self.remotesGeneration else { return }
|
|
1153
|
+
self.model.startRemotes(names)
|
|
1154
|
+
for name in names {
|
|
1155
|
+
self.runner.sessions(remote: name) { [weak self] result in
|
|
1156
|
+
guard let self, generation == self.remotesGeneration else { return }
|
|
1157
|
+
self.model.showRemote(name, result)
|
|
1158
|
+
}
|
|
924
1159
|
}
|
|
925
1160
|
}
|
|
926
1161
|
}
|
|
@@ -969,21 +1204,22 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
969
1204
|
}
|
|
970
1205
|
}
|
|
971
1206
|
|
|
972
|
-
/// Fans out: one `chi send` / `chi note` for the chi sessions
|
|
973
|
-
/// row) and one paste per agent,
|
|
974
|
-
///
|
|
975
|
-
///
|
|
1207
|
+
/// Fans out: one `chi send` / `chi note` per machine for the chi sessions
|
|
1208
|
+
/// (or one for a new row, this Mac's or a remote's) and one paste per agent, all at once, with
|
|
1209
|
+
/// one combined line when all finished. chi's send reads the images and
|
|
1210
|
+
/// leaves them; the helper deletes the ones it wrote after every job, so
|
|
1211
|
+
/// those are copied for the agents first (their paths are read later).
|
|
976
1212
|
func send(_ kind: SendKind) {
|
|
977
1213
|
if model.everyoneSelected { broadcast(kind); return }
|
|
978
|
-
let
|
|
1214
|
+
let chosen = model.selectedSessions
|
|
979
1215
|
let windows = model.selectedAgents
|
|
980
|
-
let new = model.
|
|
1216
|
+
let new = model.startsNew
|
|
981
1217
|
if kind == .note, !windows.isEmpty {
|
|
982
1218
|
NSSound.beep()
|
|
983
1219
|
model.message = "Notes are for chi sessions"
|
|
984
1220
|
return
|
|
985
1221
|
}
|
|
986
|
-
if kind == .paste, windows.isEmpty || !
|
|
1222
|
+
if kind == .paste, windows.isEmpty || !chosen.isEmpty || new {
|
|
987
1223
|
NSSound.beep()
|
|
988
1224
|
model.message = model.pasteOnlyRefusal
|
|
989
1225
|
return
|
|
@@ -999,12 +1235,12 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
999
1235
|
model.message = "Notes are text only: ⏎ sends the image as a message"
|
|
1000
1236
|
return
|
|
1001
1237
|
}
|
|
1002
|
-
guard model.sendEnabled, new || !(
|
|
1238
|
+
guard model.sendEnabled, new || !(chosen.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
|
|
1003
1239
|
model.phase = .sending
|
|
1004
1240
|
model.message = ""
|
|
1005
1241
|
if !new {
|
|
1006
|
-
let
|
|
1007
|
-
UserDefaults.standard.set(
|
|
1242
|
+
let liveKeys = chosen.filter { !$0.recent }.map(\.key)
|
|
1243
|
+
UserDefaults.standard.set(liveKeys + windows.map(\.key), forKey: PanelModel.lastChoiceKey)
|
|
1008
1244
|
}
|
|
1009
1245
|
let images = kind == .note ? [] : model.images
|
|
1010
1246
|
inFlight.formUnion(images.map(\.id))
|
|
@@ -1016,9 +1252,9 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1016
1252
|
var chiOutcome: SendOutcome?
|
|
1017
1253
|
var agentOutcomes: [(AgentTarget, AgentTerminalError?)] = []
|
|
1018
1254
|
let terminals = agents ?? AgentTerminals(launchPath: runner.launchPath)
|
|
1019
|
-
if new || !
|
|
1255
|
+
if new || !chosen.isEmpty {
|
|
1020
1256
|
group.enter()
|
|
1021
|
-
sendToChi(kind,
|
|
1257
|
+
sendToChi(kind, sessions: chosen, new: new, images: images) { outcome in chiOutcome = outcome; group.leave() }
|
|
1022
1258
|
}
|
|
1023
1259
|
for window in windows {
|
|
1024
1260
|
guard let terminal = terminals.terminal(for: window.key) else {
|
|
@@ -1097,33 +1333,32 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1097
1333
|
var linger = 1.0
|
|
1098
1334
|
}
|
|
1099
1335
|
|
|
1100
|
-
|
|
1336
|
+
/// Runs the SendPlan's calls (Sessions.swift: one per machine) at once
|
|
1337
|
+
/// and words their outcome as one line, this Mac's first, a remote's
|
|
1338
|
+
/// named ("sent · studio: sent").
|
|
1339
|
+
private func sendToChi(_ kind: SendKind, sessions: [LiveSession], new: Bool, images: [PanelImage],
|
|
1101
1340
|
completion: @escaping (SendOutcome) -> Void) {
|
|
1102
|
-
let
|
|
1103
|
-
|
|
1104
|
-
var
|
|
1105
|
-
let
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
stdin = model.hasContext ? Data(model.text.utf8) : nil
|
|
1116
|
-
case .note:
|
|
1117
|
-
command = "chi note"
|
|
1118
|
-
args = ["note"]
|
|
1119
|
-
let source = model.source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1120
|
-
if !source.isEmpty { args += ["--source", source] }
|
|
1121
|
-
stdin = Data(model.noteText.utf8)
|
|
1341
|
+
let calls = SendPlan.from(model, kind: kind, sessions: sessions, new: new, images: images).calls
|
|
1342
|
+
guard !calls.isEmpty else { completion(SendOutcome(ok: false, message: "No session to send to")); return }
|
|
1343
|
+
var outcomes = [SendOutcome?](repeating: nil, count: calls.count)
|
|
1344
|
+
let group = DispatchGroup()
|
|
1345
|
+
for (index, call) in calls.enumerated() {
|
|
1346
|
+
group.enter()
|
|
1347
|
+
run(call, kind: kind, new: new) { outcome in outcomes[index] = outcome; group.leave() }
|
|
1348
|
+
}
|
|
1349
|
+
group.notify(queue: .main) {
|
|
1350
|
+
let done = outcomes.compactMap { $0 }
|
|
1351
|
+
let parts = zip(calls, done).map { call, outcome in call.remote.map { "\($0): \(outcome.message)" } ?? outcome.message }
|
|
1352
|
+
completion(SendOutcome(ok: done.allSatisfy(\.ok), message: parts.joined(separator: " · "),
|
|
1353
|
+
linger: done.map(\.linger).max() ?? 1.0))
|
|
1122
1354
|
}
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/// One machine's `chi send` / `chi note`, its outcome worded.
|
|
1358
|
+
private func run(_ call: SendPlan.Call, kind: SendKind, new: Bool, completion: @escaping (SendOutcome) -> Void) {
|
|
1359
|
+
let command = call.command
|
|
1360
|
+
let timeout = call.timeout
|
|
1361
|
+
runner.run(call.args, stdin: call.stdin, timeout: timeout) { result in
|
|
1127
1362
|
switch result {
|
|
1128
1363
|
case .failure(let error):
|
|
1129
1364
|
completion(SendOutcome(ok: false, message: error.description))
|