samagotchi 0.5.1 → 0.7.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 +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +11 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// The Spotlight-like panel: a message line on top, the images (a screenshot,
|
|
2
2
|
// Finder files, drops) as thumbnails, the text (selection or clipboard)
|
|
3
|
-
// below as quoted context, then a "New session" row and the
|
|
3
|
+
// below as quoted context, then a "New session" row and the targets: live
|
|
4
|
+
// sessions, agents in kitty windows (Kitty.swift), recent sessions. ⏎
|
|
4
5
|
// sends a message (`chi send`, or `chi send --new` on the new row, with
|
|
5
|
-
// `--image` per image
|
|
6
|
+
// `--image` per image; pasted with Enter into a kitty window), ⌘⏎ a note
|
|
7
|
+
// (`chi note`, text only), ⌥⏎ a paste without Enter (kitty windows only),
|
|
8
|
+
// ⇧⏎ a newline.
|
|
6
9
|
import AppKit
|
|
7
10
|
import SwiftUI
|
|
8
11
|
|
|
@@ -23,9 +26,20 @@ final class PanelModel: ObservableObject {
|
|
|
23
26
|
/// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
|
|
24
27
|
@Published var hoveredImage: UUID?
|
|
25
28
|
@Published var sessions: [LiveSession] = []
|
|
29
|
+
/// Agent windows in kitty; kittyOn when launch.json has a kitty section.
|
|
30
|
+
@Published var kittyWindows: [KittyWindow] = []
|
|
31
|
+
@Published var kittyOn = false
|
|
32
|
+
/// Set once the kitty listing answered: "no agents in kitty" or an error.
|
|
33
|
+
@Published var kittyNote: String?
|
|
34
|
+
/// Chi session ids and kitty window keys (never alike: UUIDs vs paths).
|
|
26
35
|
@Published var selected: Set<String> = []
|
|
36
|
+
/// The user changed the selection: a late kitty listing keeps it.
|
|
37
|
+
var touched = false
|
|
27
38
|
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
28
39
|
@Published var newSelected = false
|
|
40
|
+
/// The model a new session starts on (`chi self --model`), for the new
|
|
41
|
+
/// row's hint; nil while unknown.
|
|
42
|
+
@Published var newModel: String?
|
|
29
43
|
@Published var phase: Phase = .loading
|
|
30
44
|
@Published var message = ""
|
|
31
45
|
|
|
@@ -38,10 +52,14 @@ final class PanelModel: ObservableObject {
|
|
|
38
52
|
var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
39
53
|
var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
40
54
|
var sendEnabled: Bool {
|
|
41
|
-
canSend && (newSelected || !
|
|
55
|
+
canSend && (newSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
|
|
42
56
|
}
|
|
43
57
|
var live: [LiveSession] { sessions.filter { !$0.recent } }
|
|
44
58
|
|
|
59
|
+
/// One list for rows, ⌘ numbers and selection: live sessions, then kitty
|
|
60
|
+
/// windows, then recent sessions.
|
|
61
|
+
var targets: [Target] { live.map(Target.chi) + kittyWindows.map(Target.kitty) + recent.map(Target.chi) }
|
|
62
|
+
|
|
45
63
|
/// Up to maxImages; the rest is dropped (and its temp files deleted).
|
|
46
64
|
func add(_ new: [PanelImage]) {
|
|
47
65
|
let room = max(0, maxImages - images.count)
|
|
@@ -61,21 +79,23 @@ final class PanelModel: ObservableObject {
|
|
|
61
79
|
}
|
|
62
80
|
var recent: [LiveSession] { sessions.filter(\.recent) }
|
|
63
81
|
|
|
64
|
-
/// The last choice, where still live; else the only live
|
|
65
|
-
/// with none live, the new row.
|
|
66
|
-
/// preselected: a message would wake it.
|
|
82
|
+
/// The last choice, where still live or listed; else the only live
|
|
83
|
+
/// session; else, with none live and no kitty window chosen, the new row.
|
|
84
|
+
/// A recent (stopped) one is never preselected: a message would wake it.
|
|
67
85
|
func preselect() {
|
|
68
86
|
let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
|
|
69
|
-
selected = last.intersection(Set(live.map(\.id)))
|
|
87
|
+
selected = last.intersection(Set(live.map(\.id) + kittyWindows.map(\.key)))
|
|
70
88
|
if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
|
|
71
|
-
newSelected = live.isEmpty
|
|
89
|
+
newSelected = live.isEmpty && selected.isEmpty
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
func toggle(_ id: String) {
|
|
93
|
+
touched = true
|
|
75
94
|
if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
func toggleNew() {
|
|
98
|
+
touched = true
|
|
79
99
|
newSelected.toggle()
|
|
80
100
|
if newSelected { selected = [] }
|
|
81
101
|
}
|
|
@@ -101,9 +121,32 @@ final class PanelModel: ObservableObject {
|
|
|
101
121
|
var selectedIds: [String] {
|
|
102
122
|
sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
|
|
103
123
|
}
|
|
124
|
+
|
|
125
|
+
var selectedKitty: [KittyWindow] { kittyWindows.filter { selected.contains($0.key) } }
|
|
126
|
+
|
|
127
|
+
/// Who the message goes to, for the placeholder: chi, or the one agent
|
|
128
|
+
/// when only kitty windows running it are chosen.
|
|
129
|
+
var addressee: String {
|
|
130
|
+
let agents = Set(selectedKitty.map(\.agent))
|
|
131
|
+
return agents.count == 1 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
|
|
132
|
+
}
|
|
104
133
|
}
|
|
105
134
|
|
|
106
|
-
|
|
135
|
+
/// A row of the panel: a chi session or an agent in a kitty window.
|
|
136
|
+
enum Target: Identifiable {
|
|
137
|
+
case chi(LiveSession)
|
|
138
|
+
case kitty(KittyWindow)
|
|
139
|
+
|
|
140
|
+
var id: String {
|
|
141
|
+
switch self {
|
|
142
|
+
case .chi(let session): return session.id
|
|
143
|
+
case .kitty(let window): return window.key
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// ⏎ message, ⌘⏎ note, ⌥⏎ paste (kitty windows only: no Enter).
|
|
149
|
+
enum SendKind { case message, note, paste }
|
|
107
150
|
|
|
108
151
|
struct PanelView: View {
|
|
109
152
|
@ObservedObject var model: PanelModel
|
|
@@ -113,7 +156,7 @@ struct PanelView: View {
|
|
|
113
156
|
var body: some View {
|
|
114
157
|
VStack(spacing: 0) {
|
|
115
158
|
// The message is required with images, as in the web.
|
|
116
|
-
TextField(model.images.isEmpty ? "Ask
|
|
159
|
+
TextField(model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
|
|
117
160
|
.textFieldStyle(.plain)
|
|
118
161
|
.font(.system(size: 17))
|
|
119
162
|
.lineLimit(1...4)
|
|
@@ -177,22 +220,70 @@ struct PanelView: View {
|
|
|
177
220
|
}.padding(10)
|
|
178
221
|
default:
|
|
179
222
|
newRow
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
223
|
+
let live = model.live.count, kitty = model.kittyWindows.count
|
|
224
|
+
ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
|
|
225
|
+
if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
|
|
226
|
+
if index == live + kitty && !model.recent.isEmpty {
|
|
227
|
+
if kitty == 0 { kittyNoteLine }
|
|
228
|
+
groupHeader("recent", first: index == 0)
|
|
229
|
+
}
|
|
230
|
+
switch target {
|
|
231
|
+
case .chi(let session): row(session, index: index)
|
|
232
|
+
case .kitty(let window): kittyRow(window, index: index)
|
|
187
233
|
}
|
|
188
|
-
row(session, index: index)
|
|
189
234
|
}
|
|
235
|
+
if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
|
|
190
236
|
}
|
|
191
237
|
}
|
|
192
238
|
.padding(6)
|
|
193
239
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
194
240
|
}
|
|
195
241
|
|
|
242
|
+
func groupHeader(_ name: String, first: Bool) -> some View {
|
|
243
|
+
HStack(spacing: 6) {
|
|
244
|
+
Text(name).font(.system(size: 11)).foregroundColor(.secondary)
|
|
245
|
+
Rectangle().fill(Color.secondary.opacity(0.25)).frame(height: 1)
|
|
246
|
+
}
|
|
247
|
+
.padding(.horizontal, 8).padding(.top, first ? 2 : 6)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// "no agents in kitty" (or why kitty can't be asked) where the kitty
|
|
251
|
+
/// rows would be; nothing without a kitty section.
|
|
252
|
+
@ViewBuilder var kittyNoteLine: some View {
|
|
253
|
+
if model.kittyOn, let note = model.kittyNote {
|
|
254
|
+
HStack(spacing: 6) {
|
|
255
|
+
Image(systemName: "terminal").font(.system(size: 11))
|
|
256
|
+
Text(note).font(.system(size: 11)).lineLimit(1)
|
|
257
|
+
}
|
|
258
|
+
.foregroundColor(.secondary.opacity(0.7))
|
|
259
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func kittyRow(_ window: KittyWindow, index: Int) -> some View {
|
|
264
|
+
let on = model.selected.contains(window.key)
|
|
265
|
+
return Button(action: { model.toggle(window.key) }) {
|
|
266
|
+
HStack(spacing: 10) {
|
|
267
|
+
Image(systemName: on ? "checkmark.circle.fill" : "circle")
|
|
268
|
+
.font(.system(size: 16))
|
|
269
|
+
.foregroundColor(on ? .accentColor : .secondary)
|
|
270
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
271
|
+
Text(window.title.isEmpty ? window.label : "\(window.agent) · \(window.title)")
|
|
272
|
+
.font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
273
|
+
Text(window.shortCwd).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
274
|
+
}
|
|
275
|
+
Spacer()
|
|
276
|
+
Image(systemName: "terminal").font(.system(size: 12)).foregroundColor(.secondary)
|
|
277
|
+
.help("\(window.agent) in a kitty window: ⏎ pastes and presses Enter, ⌥⏎ only pastes")
|
|
278
|
+
Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
279
|
+
}
|
|
280
|
+
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
281
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
282
|
+
.contentShape(Rectangle())
|
|
283
|
+
}
|
|
284
|
+
.buttonStyle(.plain)
|
|
285
|
+
}
|
|
286
|
+
|
|
196
287
|
var newRow: some View {
|
|
197
288
|
let on = model.newSelected
|
|
198
289
|
return Button(action: { model.toggleNew() }) {
|
|
@@ -205,6 +296,12 @@ struct PanelView: View {
|
|
|
205
296
|
Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
206
297
|
}
|
|
207
298
|
Spacer()
|
|
299
|
+
if let name = model.newModel {
|
|
300
|
+
Text(name).font(.system(size: 11)).foregroundColor(.secondary)
|
|
301
|
+
.lineLimit(1).truncationMode(.middle)
|
|
302
|
+
.frame(maxWidth: 240, alignment: .trailing)
|
|
303
|
+
.help("the model a new session starts on: default.model in config.yml")
|
|
304
|
+
}
|
|
208
305
|
Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
209
306
|
}
|
|
210
307
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
@@ -268,11 +365,18 @@ struct PanelView: View {
|
|
|
268
365
|
.textSelection(.enabled)
|
|
269
366
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
270
367
|
|
|
271
|
-
//
|
|
272
|
-
// has the focus; the buttons are for the mouse.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
368
|
+
// ⏎, ⌘⏎ and ⌥⏎ are handled by the panel (KeyPanel.sendEvent),
|
|
369
|
+
// whatever has the focus; the buttons are for the mouse. Only kitty
|
|
370
|
+
// windows chosen: Paste instead of Note.
|
|
371
|
+
if !model.selectedKitty.isEmpty && model.selectedIds.isEmpty && !model.newSelected {
|
|
372
|
+
Button("Paste ⌥⏎") { send(.paste) }
|
|
373
|
+
.disabled(!model.sendEnabled)
|
|
374
|
+
.help("paste it into the agent's input without Enter: add more, then press Enter there")
|
|
375
|
+
} else {
|
|
376
|
+
Button("Note ⌘⏎") { send(.note) }
|
|
377
|
+
.disabled(!model.sendEnabled || !model.selectedKitty.isEmpty)
|
|
378
|
+
.help("add it as background the model sees on its next turn; starts no turn")
|
|
379
|
+
}
|
|
276
380
|
Button(action: { send(.message) }) {
|
|
277
381
|
if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
|
|
278
382
|
}
|
|
@@ -355,6 +459,8 @@ final class KeyPanel: NSPanel {
|
|
|
355
459
|
final class PanelController: NSObject, NSWindowDelegate {
|
|
356
460
|
let model = PanelModel()
|
|
357
461
|
let runner = ChiRunner()
|
|
462
|
+
/// This open's kitty runner (launch.json read at each open).
|
|
463
|
+
private var kitty: KittyRunner?
|
|
358
464
|
private var panel: KeyPanel!
|
|
359
465
|
private var returnTo: NSRunningApplication?
|
|
360
466
|
/// Images a running `chi send` still reads: not deleted on close.
|
|
@@ -395,11 +501,12 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
395
501
|
panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
|
|
396
502
|
}
|
|
397
503
|
|
|
398
|
-
/// ⏎ message, ⌘⏎ note, ⇧⏎ a newline in the focused field.
|
|
504
|
+
/// ⏎ message, ⌘⏎ note, ⌥⏎ paste, ⇧⏎ a newline in the focused field.
|
|
399
505
|
private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
|
|
400
506
|
switch flags {
|
|
401
507
|
case []: send(.message)
|
|
402
508
|
case .command: send(.note)
|
|
509
|
+
case .option: send(.paste)
|
|
403
510
|
case .shift:
|
|
404
511
|
(panel.firstResponder as? NSTextView)?.insertNewlineIgnoringFieldEditor(nil)
|
|
405
512
|
default: return false
|
|
@@ -407,12 +514,13 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
407
514
|
return true
|
|
408
515
|
}
|
|
409
516
|
|
|
410
|
-
/// ⌘1…⌘9 toggle a
|
|
517
|
+
/// ⌘1…⌘9 toggle a target, ⌘0 the new row.
|
|
411
518
|
private func handleKey(_ event: NSEvent) -> Bool {
|
|
519
|
+
let targets = model.targets
|
|
412
520
|
guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
|
|
413
521
|
let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
414
|
-
n <=
|
|
415
|
-
if n == 0 { model.toggleNew() } else { model.toggle(
|
|
522
|
+
n <= min(targets.count, 9) else { return false }
|
|
523
|
+
if n == 0 { model.toggleNew() } else { model.toggle(targets[n - 1].id) }
|
|
416
524
|
return true
|
|
417
525
|
}
|
|
418
526
|
|
|
@@ -428,10 +536,14 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
428
536
|
model.source = source
|
|
429
537
|
model.message = ""
|
|
430
538
|
model.newSelected = false
|
|
539
|
+
model.selected = []
|
|
540
|
+
model.touched = false
|
|
431
541
|
model.phase = .loading
|
|
432
542
|
placeOnActiveScreen()
|
|
433
543
|
panel.makeKeyAndOrderFront(nil)
|
|
434
544
|
loadSessions()
|
|
545
|
+
loadKitty()
|
|
546
|
+
loadModel()
|
|
435
547
|
}
|
|
436
548
|
|
|
437
549
|
private func placeOnActiveScreen() {
|
|
@@ -459,32 +571,130 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
459
571
|
}
|
|
460
572
|
}
|
|
461
573
|
|
|
574
|
+
/// Beside the chi listing, never blocking or failing it: a listing that
|
|
575
|
+
/// answers after the sessions preselects again unless the user chose.
|
|
576
|
+
private func loadKitty() {
|
|
577
|
+
let runner = KittyRunner(launchPath: self.runner.launchPath)
|
|
578
|
+
kitty = runner
|
|
579
|
+
model.kittyOn = runner.settings != nil
|
|
580
|
+
model.kittyWindows = []
|
|
581
|
+
model.kittyNote = nil
|
|
582
|
+
guard model.kittyOn else { return }
|
|
583
|
+
runner.windows { [weak self] result in
|
|
584
|
+
guard let self, self.kitty === runner else { return }
|
|
585
|
+
switch result {
|
|
586
|
+
case .success(let windows):
|
|
587
|
+
self.model.kittyWindows = windows
|
|
588
|
+
self.model.kittyNote = windows.isEmpty ? "no agents in kitty" : nil
|
|
589
|
+
case .failure(let error):
|
|
590
|
+
self.model.kittyNote = error.description
|
|
591
|
+
}
|
|
592
|
+
if self.model.phase != .loading && !self.model.touched { self.model.preselect() }
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// Kept from the last look while it runs; config rarely changes.
|
|
597
|
+
private func loadModel() {
|
|
598
|
+
runner.defaultModel { [weak self] model in self?.model.newModel = model }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/// Fans out: one `chi send` / `chi note` for the chi sessions (or the new
|
|
602
|
+
/// row) and one paste per kitty window, with one combined line when all
|
|
603
|
+
/// finished. Images the helper wrote are copied for kitty first (chi's
|
|
604
|
+
/// send deletes them) and deleted only after every job.
|
|
462
605
|
func send(_ kind: SendKind) {
|
|
463
606
|
let ids = model.selectedIds
|
|
607
|
+
let windows = model.selectedKitty
|
|
464
608
|
let new = model.newSelected
|
|
609
|
+
if kind == .note, !windows.isEmpty {
|
|
610
|
+
NSSound.beep()
|
|
611
|
+
model.message = "Notes are for chi sessions"
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
if kind == .paste, windows.isEmpty || !ids.isEmpty || new {
|
|
615
|
+
NSSound.beep()
|
|
616
|
+
model.message = "Paste only is for kitty windows"
|
|
617
|
+
return
|
|
618
|
+
}
|
|
465
619
|
if kind == .note, !model.images.isEmpty {
|
|
466
620
|
NSSound.beep()
|
|
467
621
|
model.message = "Notes are text only: ⏎ sends the image as a message"
|
|
468
622
|
return
|
|
469
623
|
}
|
|
470
624
|
// A note into a session that doesn't exist yet makes no sense.
|
|
471
|
-
guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
|
|
625
|
+
guard model.sendEnabled, new ? kind == .message : !(ids.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
|
|
472
626
|
model.phase = .sending
|
|
473
627
|
model.message = ""
|
|
474
628
|
if !new {
|
|
475
|
-
|
|
629
|
+
let liveIds = ids.filter { id in model.live.contains { $0.id == id } }
|
|
630
|
+
UserDefaults.standard.set(liveIds + windows.map(\.key), forKey: PanelModel.lastChoiceKey)
|
|
631
|
+
}
|
|
632
|
+
let images = kind == .note ? [] : model.images
|
|
633
|
+
inFlight.formUnion(images.map(\.id))
|
|
634
|
+
let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
|
|
635
|
+
imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
|
|
636
|
+
|
|
637
|
+
let group = DispatchGroup()
|
|
638
|
+
var chiOutcome: SendOutcome?
|
|
639
|
+
var kittyOutcomes: [(KittyWindow, KittyError?)] = []
|
|
640
|
+
if new || !ids.isEmpty {
|
|
641
|
+
group.enter()
|
|
642
|
+
sendToChi(kind, ids: ids, new: new, images: images) { outcome in chiOutcome = outcome; group.leave() }
|
|
643
|
+
}
|
|
644
|
+
for window in windows {
|
|
645
|
+
group.enter()
|
|
646
|
+
(kitty ?? KittyRunner(launchPath: runner.launchPath)).send(key: window.key, text: kittyText, pasteOnly: kind == .paste) { result in
|
|
647
|
+
if case .failure(let error) = result { kittyOutcomes.append((window, error)) } else { kittyOutcomes.append((window, nil)) }
|
|
648
|
+
group.leave()
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
group.notify(queue: .main) { [weak self] in
|
|
652
|
+
guard let self else { return }
|
|
653
|
+
self.inFlight.subtract(images.map(\.id))
|
|
654
|
+
let pasted = windows.filter { w in kittyOutcomes.contains { $0.0 == w && $0.1 == nil } }
|
|
655
|
+
let failed = kittyOutcomes.compactMap { outcome in outcome.1.map { "\(outcome.0.label): \($0.description)" } }
|
|
656
|
+
let ok = (chiOutcome?.ok ?? true) && failed.isEmpty
|
|
657
|
+
// Sent: the temp files go (and the thumbnails). Else they stay for
|
|
658
|
+
// another try, unless the panel was closed or reopened meanwhile.
|
|
659
|
+
if ok { self.model.images.removeAll { images.contains($0) } }
|
|
660
|
+
ImageIntake.delete(images.filter { !self.model.images.contains($0) })
|
|
661
|
+
var parts: [String] = []
|
|
662
|
+
if let chiOutcome { parts.append(chiOutcome.message) }
|
|
663
|
+
if !pasted.isEmpty {
|
|
664
|
+
parts.append("\(kind == .paste ? "pasted into" : "sent to") \(pasted.map(\.label).joined(separator: ", "))")
|
|
665
|
+
}
|
|
666
|
+
parts += failed
|
|
667
|
+
self.model.message = parts.filter { !$0.isEmpty }.joined(separator: " · ")
|
|
668
|
+
if ok {
|
|
669
|
+
self.model.phase = .sent
|
|
670
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + (chiOutcome?.linger ?? 1.0)) { [weak self] in
|
|
671
|
+
if self?.model.phase == .sent { self?.panel.close() }
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
self.model.phase = .failed
|
|
675
|
+
}
|
|
476
676
|
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
struct SendOutcome {
|
|
680
|
+
let ok: Bool
|
|
681
|
+
let message: String
|
|
682
|
+
var linger = 1.0
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private func sendToChi(_ kind: SendKind, ids: [String], new: Bool, images: [PanelImage],
|
|
686
|
+
completion: @escaping (SendOutcome) -> Void) {
|
|
477
687
|
let prompt = model.trimmedPrompt
|
|
478
688
|
let command: String
|
|
479
689
|
var args: [String]
|
|
480
690
|
let stdin: Data?
|
|
481
691
|
switch kind {
|
|
482
|
-
case .message:
|
|
692
|
+
case .message, .paste:
|
|
483
693
|
// stdin is the quoted context; without -m, chi takes it as the message.
|
|
484
694
|
command = "chi send"
|
|
485
695
|
args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
|
|
486
696
|
if !prompt.isEmpty { args += ["-m", prompt] }
|
|
487
|
-
args +=
|
|
697
|
+
args += images.flatMap { ["--image", $0.url.path] }
|
|
488
698
|
stdin = model.hasContext ? Data(model.text.utf8) : nil
|
|
489
699
|
case .note:
|
|
490
700
|
command = "chi note"
|
|
@@ -495,42 +705,25 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
495
705
|
stdin = Data(parts.joined(separator: "\n\n").utf8)
|
|
496
706
|
}
|
|
497
707
|
if !new { args += ids }
|
|
498
|
-
let images = kind == .message ? model.images : []
|
|
499
|
-
inFlight.formUnion(images.map(\.id))
|
|
500
708
|
// Reading, converting and a new worker's start take longer than text.
|
|
501
709
|
let timeout = images.isEmpty ? runner.timeout : 30
|
|
502
|
-
runner.run(args, stdin: stdin, timeout: timeout) {
|
|
503
|
-
guard let self else { return }
|
|
504
|
-
self.inFlight.subtract(images.map(\.id))
|
|
505
|
-
// Sent: the temp files go (and the thumbnails). Else they stay for
|
|
506
|
-
// another try, unless the panel was closed or reopened meanwhile.
|
|
507
|
-
var sent = false
|
|
508
|
-
if case .success(let r) = result { sent = r.status == 0 && !r.timedOut }
|
|
509
|
-
if sent { self.model.images.removeAll { images.contains($0) } }
|
|
510
|
-
ImageIntake.delete(images.filter { !self.model.images.contains($0) })
|
|
710
|
+
runner.run(args, stdin: stdin, timeout: timeout) { result in
|
|
511
711
|
switch result {
|
|
512
712
|
case .failure(let error):
|
|
513
|
-
|
|
514
|
-
self.model.message = error.description
|
|
713
|
+
completion(SendOutcome(ok: false, message: error.description))
|
|
515
714
|
case .success(let r):
|
|
516
715
|
let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
517
716
|
let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
518
717
|
if r.timedOut {
|
|
519
|
-
self.model.phase = .failed
|
|
520
718
|
let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
|
|
521
|
-
|
|
719
|
+
completion(SendOutcome(ok: false, message: "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"))
|
|
522
720
|
} else if r.status == 0 {
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
self.model.message = out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out
|
|
526
|
-
// Long enough to read a "waits for the session's next start" line.
|
|
721
|
+
// --new prints "<full id> started". Long enough to read a
|
|
722
|
+
// "waits for the session's next start" line.
|
|
527
723
|
let linger = out.contains("waits for") || out.contains("started") ? 3.0 : 1.0
|
|
528
|
-
|
|
529
|
-
if self?.model.phase == .sent { self?.panel.close() }
|
|
530
|
-
}
|
|
724
|
+
completion(SendOutcome(ok: true, message: out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out, linger: linger))
|
|
531
725
|
} else {
|
|
532
|
-
|
|
533
|
-
self.model.message = [out, err].filter { !$0.isEmpty }.joined(separator: "\n")
|
|
726
|
+
completion(SendOutcome(ok: false, message: [out, err].filter { !$0.isEmpty }.joined(separator: "\n")))
|
|
534
727
|
}
|
|
535
728
|
}
|
|
536
729
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Runs one outside program without a shell: stdin written from a
|
|
2
|
+
// background queue, stdout/stderr drained as they come (no pipe deadlock on
|
|
3
|
+
// big input or output), a time limit. ChiRunner and KittyRunner use it.
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
struct ChiResult {
|
|
7
|
+
let status: Int32
|
|
8
|
+
let stdout: String
|
|
9
|
+
let stderr: String
|
|
10
|
+
let timedOut: Bool
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
enum ProcessRunner {
|
|
14
|
+
/// Calls back on +queue+ (the main queue by default; a headless seam
|
|
15
|
+
/// waits on another one).
|
|
16
|
+
/// @param env the whole environment the program gets
|
|
17
|
+
static func run(executable: String, args: [String], env: [String: String], stdin: Data? = nil,
|
|
18
|
+
timeout: TimeInterval, queue: DispatchQueue = .main,
|
|
19
|
+
completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
20
|
+
let process = Process()
|
|
21
|
+
process.executableURL = URL(fileURLWithPath: executable)
|
|
22
|
+
process.arguments = args
|
|
23
|
+
process.environment = env
|
|
24
|
+
process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
|
|
25
|
+
let out = Pipe(), err = Pipe(), input = Pipe()
|
|
26
|
+
process.standardOutput = out
|
|
27
|
+
process.standardError = err
|
|
28
|
+
process.standardInput = stdin == nil ? FileHandle.nullDevice : input
|
|
29
|
+
|
|
30
|
+
let lock = NSLock()
|
|
31
|
+
var outData = Data(), errData = Data()
|
|
32
|
+
out.fileHandleForReading.readabilityHandler = { handle in
|
|
33
|
+
let chunk = handle.availableData
|
|
34
|
+
lock.lock(); outData.append(chunk); lock.unlock()
|
|
35
|
+
}
|
|
36
|
+
err.fileHandleForReading.readabilityHandler = { handle in
|
|
37
|
+
let chunk = handle.availableData
|
|
38
|
+
lock.lock(); errData.append(chunk); lock.unlock()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var timedOut = false
|
|
42
|
+
process.terminationHandler = { proc in
|
|
43
|
+
out.fileHandleForReading.readabilityHandler = nil
|
|
44
|
+
err.fileHandleForReading.readabilityHandler = nil
|
|
45
|
+
let restOut = out.fileHandleForReading.readDataToEndOfFile()
|
|
46
|
+
let restErr = err.fileHandleForReading.readDataToEndOfFile()
|
|
47
|
+
lock.lock()
|
|
48
|
+
outData.append(restOut); errData.append(restErr)
|
|
49
|
+
let result = ChiResult(status: proc.terminationStatus,
|
|
50
|
+
stdout: String(decoding: outData, as: UTF8.self),
|
|
51
|
+
stderr: String(decoding: errData, as: UTF8.self),
|
|
52
|
+
timedOut: timedOut)
|
|
53
|
+
lock.unlock()
|
|
54
|
+
queue.async { completion(.success(result)) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
do { try process.run() } catch {
|
|
58
|
+
completion(.failure(.failed("Could not start \(executable): \(error.localizedDescription)")))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if let stdin {
|
|
62
|
+
DispatchQueue.global().async {
|
|
63
|
+
try? input.fileHandleForWriting.write(contentsOf: stdin)
|
|
64
|
+
try? input.fileHandleForWriting.close()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
|
68
|
+
if process.isRunning {
|
|
69
|
+
lock.lock(); timedOut = true; lock.unlock()
|
|
70
|
+
process.terminate()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -9,6 +9,7 @@ require "rbconfig"
|
|
|
9
9
|
require_relative "../version"
|
|
10
10
|
require_relative "../self_report"
|
|
11
11
|
require_relative "../installed_gem"
|
|
12
|
+
require_relative "../config"
|
|
12
13
|
|
|
13
14
|
module Samagotchi
|
|
14
15
|
module Desktop
|
|
@@ -49,11 +50,21 @@ module Samagotchi
|
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# The kitty: settings the helper lists agent windows with (baked into
|
|
54
|
+
# the launch file), or nil when kitty.listen_on is unset.
|
|
55
|
+
def self.kitty_settings(get = Config.method(:get))
|
|
56
|
+
listen_on = get.call("kitty.listen_on").to_s.strip
|
|
57
|
+
return nil if listen_on.empty?
|
|
58
|
+
|
|
59
|
+
agents = get.call("kitty.agents").to_s.split("|").map(&:strip).reject(&:empty?)
|
|
60
|
+
{ "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
|
|
61
|
+
end
|
|
62
|
+
|
|
52
63
|
attr_reader :app_dir, :support_dir
|
|
53
64
|
|
|
54
65
|
def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
|
|
55
66
|
source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
|
|
56
|
-
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil)
|
|
67
|
+
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil)
|
|
57
68
|
home = env["HOME"] || Dir.home
|
|
58
69
|
@app_dir = app_dir || File.join(home, "Applications")
|
|
59
70
|
@support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
|
|
@@ -66,6 +77,7 @@ module Samagotchi
|
|
|
66
77
|
@arch = arch
|
|
67
78
|
@sources_dir = sources_dir
|
|
68
79
|
@register = register
|
|
80
|
+
@kitty = kitty
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
def app_path = File.join(@app_dir, "#{APP_NAME}.app")
|
|
@@ -78,11 +90,14 @@ module Samagotchi
|
|
|
78
90
|
# What the helper runs: absolute ruby + chi (no shims, which need the
|
|
79
91
|
# shell's PATH), and the allowlisted env. chi is an installed gem's
|
|
80
92
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
81
|
-
# `gem cleanup`), else this checkout's bin/chi.
|
|
93
|
+
# `gem cleanup`), else this checkout's bin/chi. The kitty section only
|
|
94
|
+
# when kitty.listen_on is set.
|
|
82
95
|
def launch_config
|
|
83
96
|
env = { "LANG" => LANG }
|
|
84
97
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
85
|
-
{ "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
98
|
+
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
99
|
+
config["kitty"] = @kitty if @kitty
|
|
100
|
+
config
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
# A digest of what a build compiles: the Swift sources and the
|
|
@@ -104,15 +119,15 @@ module Samagotchi
|
|
|
104
119
|
launch["sources_sha"] != sources_sha || !launch_ok?(launch)
|
|
105
120
|
end
|
|
106
121
|
|
|
107
|
-
# The launch file names another chi version or path than this one
|
|
108
|
-
# refresh_launch_file fixes that without a rebuild.
|
|
122
|
+
# The launch file names another chi version or path than this one, or
|
|
123
|
+
# other kitty settings: refresh_launch_file fixes that without a rebuild.
|
|
109
124
|
def launch_outdated?
|
|
110
125
|
launch = read_launch
|
|
111
|
-
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"]
|
|
126
|
+
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
|
|
112
127
|
end
|
|
113
128
|
|
|
114
|
-
# Rewrite the launch file for this chi
|
|
115
|
-
# installed with. The app reads it at each send: no restart.
|
|
129
|
+
# Rewrite the launch file for this chi and the kitty settings, keeping
|
|
130
|
+
# the env it was installed with. The app reads it at each send: no restart.
|
|
116
131
|
def refresh_launch_file
|
|
117
132
|
config = launch_config
|
|
118
133
|
env = read_launch["env"]
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
@argv = argv.dup
|
|
27
27
|
@stdout = stdout
|
|
28
28
|
@stderr = stderr
|
|
29
|
-
@platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
|
|
29
|
+
@platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
|
|
30
30
|
@supported = supported
|
|
31
31
|
end
|
|
32
32
|
|