samagotchi 0.123.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.
@@ -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
- /// Chi session ids and agent keys (never alike: UUIDs vs paths).
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 && (newSelected || everyoneSelected || !selectedIds.isEmpty || !selectedAgents.isEmpty)
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
- /// One list for rows, ⌘ numbers and selection: live sessions (oldest
93
- /// started first: their numbers stay put), then agents in terminals,
94
- /// then recent sessions.
95
- var targets: [Target] { live.map(Target.chi) + agentTargets.map(Target.agent) + recent.map(Target.chi) }
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; else the only live
198
- /// session; else, with none live and no agent chosen, the new row.
199
- /// A recent (stopped) one is never preselected: a message would wake it.
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
- selected = last.intersection(Set(live.map(\.id) + agentTargets.map(\.key)))
203
- if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
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) { selected.remove(id) } else { selected.insert(id); newSelected = false; everyoneSelected = false }
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
- /// In list order, only ids that are live and UUID-shaped.
243
- var selectedIds: [String] {
244
- sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
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 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
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.id
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.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
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
- Text("Everyone it concerns").font(.system(size: 13, weight: .medium)).lineLimit(1)
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.id)
586
- return Button(action: { model.toggle(session.id) }) {
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
- Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
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.selectedIds.isEmpty && !model.newSelected {
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.newSelected)
662
- .help(model.newSelected ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
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.pickerOpen { model.closePicker() } else { model.openPicker() }
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
- guard let self else { return }
914
- switch result {
915
- case .success(let sessions):
916
- self.model.sessions = sessions
917
- // A choice made while it listed (⌘0, ⌘M) stays.
918
- if !self.model.touched { self.model.preselect() }
919
- self.model.phase = .ready
920
- case .failure(let error):
921
- self.model.sessions = []
922
- self.model.phase = .failed
923
- self.model.message = error.description
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 (or the new
973
- /// row) and one paste per agent, with one combined line when all
974
- /// finished. Images the helper wrote are copied for the agents first (chi's
975
- /// send deletes them) and deleted only after every job.
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 ids = model.selectedIds
1214
+ let chosen = model.selectedSessions
979
1215
  let windows = model.selectedAgents
980
- let new = model.newSelected
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 || !ids.isEmpty || new {
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 || !(ids.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
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 liveIds = ids.filter { id in model.live.contains { $0.id == id } }
1007
- UserDefaults.standard.set(liveIds + windows.map(\.key), forKey: PanelModel.lastChoiceKey)
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 || !ids.isEmpty {
1255
+ if new || !chosen.isEmpty {
1020
1256
  group.enter()
1021
- sendToChi(kind, ids: ids, new: new, images: images) { outcome in chiOutcome = outcome; group.leave() }
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
- private func sendToChi(_ kind: SendKind, ids: [String], new: Bool, images: [PanelImage],
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 prompt = model.trimmedPrompt
1103
- let command: String
1104
- var args: [String]
1105
- let stdin: Data?
1106
- switch kind {
1107
- case .message, .paste:
1108
- // stdin is the quoted context; without -m, chi takes it as the message.
1109
- command = "chi send"
1110
- args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
1111
- // The row's name verbatim; none for the default.
1112
- if new, let name = model.modelForSend { args += ["--model", name] }
1113
- if !prompt.isEmpty { args += ["-m", prompt] }
1114
- args += images.flatMap { ["--image", $0.url.path] }
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
- if !new { args += ids }
1124
- // Reading, converting and a new worker's start take longer than text.
1125
- let timeout = images.isEmpty ? runner.timeout : 30
1126
- runner.run(args, stdin: stdin, timeout: timeout) { result in
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))