samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// sends a message (`chi send`, or `chi send --new` on the new row, with
|
|
6
6
|
// `--image` per image; pasted with Enter into a kitty window), ⌘⏎ a note
|
|
7
7
|
// (`chi note`, text only), ⌥⏎ a paste without Enter (kitty windows only),
|
|
8
|
-
// ⇧⏎ a newline.
|
|
8
|
+
// ⇧⏎ a newline. ⌘M (or a click on the new row's model) opens the model
|
|
9
|
+
// chooser for a new session in place of the targets (Models.swift).
|
|
9
10
|
import AppKit
|
|
10
11
|
import SwiftUI
|
|
11
12
|
|
|
@@ -40,6 +41,23 @@ final class PanelModel: ObservableObject {
|
|
|
40
41
|
/// The model a new session starts on (`chi self --model`), for the new
|
|
41
42
|
/// row's hint; nil while unknown.
|
|
42
43
|
@Published var newModel: String?
|
|
44
|
+
/// `chi models`' last answer, kept across opens and refreshed on each.
|
|
45
|
+
@Published var modelList: ModelList?
|
|
46
|
+
@Published var modelsLoading = false
|
|
47
|
+
/// Why the last refresh failed (the older list, if any, stays).
|
|
48
|
+
@Published var modelsError: String?
|
|
49
|
+
/// The new session's model; nil: the default (no `--model`).
|
|
50
|
+
@Published var pickedModel: String?
|
|
51
|
+
/// The user picked during this open: a late list doesn't undo it.
|
|
52
|
+
var pickTouched = false
|
|
53
|
+
@Published var recentModels: [String] = UserDefaults.standard.stringArray(forKey: ModelPick.recentKey) ?? []
|
|
54
|
+
/// The chooser replaces the targets while open.
|
|
55
|
+
@Published var pickerOpen = false
|
|
56
|
+
@Published var modelQuery = "" {
|
|
57
|
+
didSet { pickerIndex = modelQuery.trimmingCharacters(in: .whitespaces).isEmpty || chooserRows.count < 2 ? 0 : 1 }
|
|
58
|
+
}
|
|
59
|
+
/// The highlighted chooser row (↑/↓, ⏎ picks it).
|
|
60
|
+
@Published var pickerIndex = 0
|
|
43
61
|
@Published var phase: Phase = .loading
|
|
44
62
|
@Published var message = ""
|
|
45
63
|
|
|
@@ -79,6 +97,85 @@ final class PanelModel: ObservableObject {
|
|
|
79
97
|
}
|
|
80
98
|
var recent: [LiveSession] { sessions.filter(\.recent) }
|
|
81
99
|
|
|
100
|
+
/// The model a new session starts on without `--model`.
|
|
101
|
+
var defaultModelName: String? { modelList?.default ?? newModel }
|
|
102
|
+
|
|
103
|
+
/// What the new row shows: the pick, else the default as resolved (as
|
|
104
|
+
/// `chi self --model` names it before the list is in: no flip).
|
|
105
|
+
var shownModel: String? { pickedModel ?? defaultModelName }
|
|
106
|
+
|
|
107
|
+
/// `--model` for `chi send --new`, verbatim; nil for the default.
|
|
108
|
+
var modelForSend: String? {
|
|
109
|
+
guard let picked = pickedModel else { return nil }
|
|
110
|
+
if let standard = defaultModelName, ModelPick.same(picked, standard) { return nil }
|
|
111
|
+
return picked
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
var chooserRows: [ModelRow] {
|
|
115
|
+
ModelPick.chooserRows(list: modelList, fallbackDefault: newModel, recents: recentModels, query: modelQuery)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// A failed refresh, or the hosts that failed in the last list.
|
|
119
|
+
var modelsNote: String? {
|
|
120
|
+
if let modelsError { return modelsError }
|
|
121
|
+
let warnings = modelList?.warnings ?? []
|
|
122
|
+
return warnings.isEmpty ? nil : warnings.joined(separator: "; ")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func isCurrent(_ row: ModelRow) -> Bool {
|
|
126
|
+
row.kind == .standard ? modelForSend == nil : pickedModel.map { ModelPick.same($0, row.name) } ?? false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Opens the chooser on the new row, the current pick highlighted.
|
|
130
|
+
func openPicker() {
|
|
131
|
+
touched = true
|
|
132
|
+
newSelected = true
|
|
133
|
+
selected = []
|
|
134
|
+
modelQuery = ""
|
|
135
|
+
pickerIndex = chooserRows.firstIndex(where: isCurrent) ?? 0
|
|
136
|
+
pickerOpen = true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func closePicker() {
|
|
140
|
+
pickerOpen = false
|
|
141
|
+
modelQuery = ""
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func movePicker(_ delta: Int) {
|
|
145
|
+
let count = chooserRows.count
|
|
146
|
+
guard count > 0 else { return }
|
|
147
|
+
pickerIndex = min(max(pickerIndex + delta, 0), count - 1)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// The pick is remembered (global, D2): the last one and five recent.
|
|
151
|
+
func choose(_ row: ModelRow) {
|
|
152
|
+
pickTouched = true
|
|
153
|
+
newSelected = true
|
|
154
|
+
selected = []
|
|
155
|
+
if row.kind == .standard {
|
|
156
|
+
pickedModel = nil
|
|
157
|
+
UserDefaults.standard.removeObject(forKey: ModelPick.pickKey)
|
|
158
|
+
} else {
|
|
159
|
+
pickedModel = row.name
|
|
160
|
+
UserDefaults.standard.set(row.name, forKey: ModelPick.pickKey)
|
|
161
|
+
recentModels = ModelPick.pushRecent(recentModels, row.name)
|
|
162
|
+
UserDefaults.standard.set(recentModels, forKey: ModelPick.recentKey)
|
|
163
|
+
}
|
|
164
|
+
closePicker()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// The remembered pick against the list as it is now (ModelPick.keep);
|
|
168
|
+
/// one that isn't offered any more is forgotten, with a note.
|
|
169
|
+
func applyStoredPick() {
|
|
170
|
+
guard !pickTouched else { return }
|
|
171
|
+
let kept = ModelPick.keep(UserDefaults.standard.string(forKey: ModelPick.pickKey), in: modelList)
|
|
172
|
+
pickedModel = kept.pick
|
|
173
|
+
if let note = kept.note {
|
|
174
|
+
UserDefaults.standard.removeObject(forKey: ModelPick.pickKey)
|
|
175
|
+
if message.isEmpty { message = note }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
82
179
|
/// The last choice, where still live or listed; else the only live
|
|
83
180
|
/// session; else, with none live and no kitty window chosen, the new row.
|
|
84
181
|
/// A recent (stopped) one is never preselected: a message would wake it.
|
|
@@ -152,6 +249,7 @@ struct PanelView: View {
|
|
|
152
249
|
@ObservedObject var model: PanelModel
|
|
153
250
|
let send: (SendKind) -> Void
|
|
154
251
|
@FocusState private var promptFocused: Bool
|
|
252
|
+
@FocusState private var filterFocused: Bool
|
|
155
253
|
|
|
156
254
|
var body: some View {
|
|
157
255
|
VStack(spacing: 0) {
|
|
@@ -212,27 +310,32 @@ struct PanelView: View {
|
|
|
212
310
|
|
|
213
311
|
@ViewBuilder var sessionList: some View {
|
|
214
312
|
VStack(alignment: .leading, spacing: 2) {
|
|
215
|
-
|
|
216
|
-
case .loading:
|
|
217
|
-
HStack(spacing: 8) {
|
|
218
|
-
ProgressView().controlSize(.small)
|
|
219
|
-
Text("Looking for live sessions…").foregroundColor(.secondary)
|
|
220
|
-
}.padding(10)
|
|
221
|
-
default:
|
|
313
|
+
if model.pickerOpen {
|
|
222
314
|
newRow
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
315
|
+
modelChooser
|
|
316
|
+
} else {
|
|
317
|
+
switch model.phase {
|
|
318
|
+
case .loading:
|
|
319
|
+
HStack(spacing: 8) {
|
|
320
|
+
ProgressView().controlSize(.small)
|
|
321
|
+
Text("Looking for live sessions…").foregroundColor(.secondary)
|
|
322
|
+
}.padding(10)
|
|
323
|
+
default:
|
|
324
|
+
newRow
|
|
325
|
+
let live = model.live.count, kitty = model.kittyWindows.count
|
|
326
|
+
ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
|
|
327
|
+
if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
|
|
328
|
+
if index == live + kitty && !model.recent.isEmpty {
|
|
329
|
+
if kitty == 0 { kittyNoteLine }
|
|
330
|
+
groupHeader("recent", first: index == 0)
|
|
331
|
+
}
|
|
332
|
+
switch target {
|
|
333
|
+
case .chi(let session): row(session, index: index)
|
|
334
|
+
case .kitty(let window): kittyRow(window, index: index)
|
|
335
|
+
}
|
|
233
336
|
}
|
|
337
|
+
if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
|
|
234
338
|
}
|
|
235
|
-
if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
|
|
236
339
|
}
|
|
237
340
|
}
|
|
238
341
|
.padding(6)
|
|
@@ -286,30 +389,107 @@ struct PanelView: View {
|
|
|
286
389
|
|
|
287
390
|
var newRow: some View {
|
|
288
391
|
let on = model.newSelected
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
392
|
+
return HStack(spacing: 10) {
|
|
393
|
+
Button(action: { model.toggleNew() }) {
|
|
394
|
+
HStack(spacing: 10) {
|
|
395
|
+
Image(systemName: on ? "plus.circle.fill" : "plus.circle")
|
|
396
|
+
.font(.system(size: 16))
|
|
397
|
+
.foregroundColor(on ? .accentColor : .secondary)
|
|
398
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
399
|
+
Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
400
|
+
Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
401
|
+
}
|
|
402
|
+
Spacer(minLength: 0)
|
|
297
403
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
404
|
+
.contentShape(Rectangle())
|
|
405
|
+
}
|
|
406
|
+
.buttonStyle(.plain)
|
|
407
|
+
.help("start a session with the message, as the web does (chi send --new); a note needs a session")
|
|
408
|
+
modelLabel
|
|
409
|
+
Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
410
|
+
}
|
|
411
|
+
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
412
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/// The new session's model: grey for the default, accent for a pick; a
|
|
416
|
+
/// click (or ⌘M) opens the chooser.
|
|
417
|
+
var modelLabel: some View {
|
|
418
|
+
let picked = model.modelForSend != nil
|
|
419
|
+
return Button(action: { model.pickerOpen ? model.closePicker() : model.openPicker() }) {
|
|
420
|
+
HStack(spacing: 3) {
|
|
421
|
+
Text(model.shownModel ?? "model").font(.system(size: 11))
|
|
422
|
+
.foregroundColor(picked ? .accentColor : .secondary)
|
|
423
|
+
.lineLimit(1).truncationMode(.middle)
|
|
424
|
+
Image(systemName: "chevron.up.chevron.down").font(.system(size: 8)).foregroundColor(.secondary)
|
|
425
|
+
}
|
|
426
|
+
.frame(maxWidth: 240, alignment: .trailing)
|
|
427
|
+
.contentShape(Rectangle())
|
|
428
|
+
}
|
|
429
|
+
.buttonStyle(.plain)
|
|
430
|
+
.help(picked ? "the new session's model (chi send --new --model); ⌘M to change"
|
|
431
|
+
: "the model a new session starts on: default.model in config.yml; ⌘M to choose another")
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// A filter and up to nine rows: ↑/↓ and ⏎, or ⌘1…⌘9, pick; Esc closes.
|
|
435
|
+
@ViewBuilder var modelChooser: some View {
|
|
436
|
+
let rows = model.chooserRows
|
|
437
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
438
|
+
HStack(spacing: 6) {
|
|
439
|
+
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.secondary)
|
|
440
|
+
TextField("Search the models, or type a name…", text: $model.modelQuery)
|
|
441
|
+
.textFieldStyle(.plain)
|
|
442
|
+
.font(.system(size: 13))
|
|
443
|
+
.focused($filterFocused)
|
|
444
|
+
if model.modelsLoading { ProgressView().controlSize(.small) }
|
|
445
|
+
}
|
|
446
|
+
.padding(.horizontal, 8).padding(.vertical, 5)
|
|
447
|
+
.background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.06)))
|
|
448
|
+
.padding(.horizontal, 4).padding(.vertical, 3)
|
|
449
|
+
|
|
450
|
+
ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
|
|
451
|
+
modelRow(row, index: index)
|
|
452
|
+
}
|
|
453
|
+
if model.modelList == nil && model.modelsLoading {
|
|
454
|
+
Text("Loading models…").font(.system(size: 11)).foregroundColor(.secondary)
|
|
455
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
456
|
+
}
|
|
457
|
+
if let note = model.modelsNote {
|
|
458
|
+
HStack(spacing: 6) {
|
|
459
|
+
Image(systemName: "exclamationmark.triangle").font(.system(size: 10))
|
|
460
|
+
Text(note).font(.system(size: 11)).lineLimit(2)
|
|
304
461
|
}
|
|
305
|
-
|
|
462
|
+
.foregroundColor(.secondary)
|
|
463
|
+
.help(note)
|
|
464
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
306
465
|
}
|
|
307
|
-
|
|
308
|
-
|
|
466
|
+
}
|
|
467
|
+
.onAppear { DispatchQueue.main.async { filterFocused = true } }
|
|
468
|
+
.onDisappear { DispatchQueue.main.async { promptFocused = true } }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func modelRow(_ row: ModelRow, index: Int) -> some View {
|
|
472
|
+
let current = model.isCurrent(row)
|
|
473
|
+
let highlighted = index == model.pickerIndex
|
|
474
|
+
return Button(action: { model.choose(row) }) {
|
|
475
|
+
HStack(spacing: 10) {
|
|
476
|
+
Image(systemName: current ? "checkmark" : "cpu")
|
|
477
|
+
.font(.system(size: 11))
|
|
478
|
+
.foregroundColor(current ? .accentColor : .secondary.opacity(0.6))
|
|
479
|
+
.frame(width: 16)
|
|
480
|
+
Text(row.label)
|
|
481
|
+
.font(.system(size: 13, weight: row.kind == .standard ? .medium : .regular))
|
|
482
|
+
.foregroundColor(row.kind == .typed ? .secondary : .primary)
|
|
483
|
+
.lineLimit(1).truncationMode(.middle)
|
|
484
|
+
Spacer(minLength: 8)
|
|
485
|
+
Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
486
|
+
}
|
|
487
|
+
.padding(.horizontal, 8).padding(.vertical, 5)
|
|
488
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(highlighted ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
309
489
|
.contentShape(Rectangle())
|
|
310
490
|
}
|
|
311
491
|
.buttonStyle(.plain)
|
|
312
|
-
.help(
|
|
492
|
+
.help(row.kind == .typed ? "not in any host's list: sent as typed" : row.name)
|
|
313
493
|
}
|
|
314
494
|
|
|
315
495
|
func row(_ session: LiveSession, index: Int) -> some View {
|
|
@@ -434,6 +614,10 @@ final class KeyPanel: NSPanel {
|
|
|
434
614
|
var onKey: ((NSEvent) -> Bool)?
|
|
435
615
|
/// Return with these modifiers; true when handled.
|
|
436
616
|
var onReturn: ((NSEvent.ModifierFlags) -> Bool)?
|
|
617
|
+
/// ↑ (-1) or ↓ (+1) without modifiers; true when handled.
|
|
618
|
+
var onArrow: ((Int) -> Bool)?
|
|
619
|
+
/// Esc; true when handled (the panel stays open).
|
|
620
|
+
var onCancel: (() -> Bool)?
|
|
437
621
|
|
|
438
622
|
/// Return is taken here, before the text views see it, so ⏎ sends from
|
|
439
623
|
/// either field. An input method composing text keeps its Return.
|
|
@@ -443,12 +627,28 @@ final class KeyPanel: NSPanel {
|
|
|
443
627
|
onReturn?(event.modifierFlags.intersection([.shift, .command, .option, .control])) == true {
|
|
444
628
|
return
|
|
445
629
|
}
|
|
630
|
+
// An input method's candidate list keeps its arrows too.
|
|
631
|
+
if event.type == .keyDown, event.keyCode == 125 || event.keyCode == 126,
|
|
632
|
+
event.modifierFlags.intersection([.shift, .command, .option, .control]).isEmpty,
|
|
633
|
+
(firstResponder as? NSTextView)?.hasMarkedText() != true,
|
|
634
|
+
onArrow?(event.keyCode == 125 ? 1 : -1) == true {
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
// Esc closes an open chooser before a text view sees it (it could take
|
|
638
|
+
// it as completion); otherwise cancelOperation closes the panel.
|
|
639
|
+
if event.type == .keyDown, event.keyCode == 53,
|
|
640
|
+
(firstResponder as? NSTextView)?.hasMarkedText() != true, onCancel?() == true {
|
|
641
|
+
return
|
|
642
|
+
}
|
|
446
643
|
super.sendEvent(event)
|
|
447
644
|
}
|
|
448
645
|
|
|
449
646
|
override var canBecomeKey: Bool { true }
|
|
450
647
|
override var canBecomeMain: Bool { false }
|
|
451
|
-
override func cancelOperation(_ sender: Any?) {
|
|
648
|
+
override func cancelOperation(_ sender: Any?) {
|
|
649
|
+
if onCancel?() == true { return }
|
|
650
|
+
close()
|
|
651
|
+
}
|
|
452
652
|
|
|
453
653
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
454
654
|
if onKey?(event) == true { return true }
|
|
@@ -499,10 +699,27 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
499
699
|
panel.contentView = effect
|
|
500
700
|
panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
|
|
501
701
|
panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
|
|
702
|
+
panel.onArrow = { [weak self] delta in
|
|
703
|
+
guard let self, self.model.pickerOpen else { return false }
|
|
704
|
+
self.model.movePicker(delta)
|
|
705
|
+
return true
|
|
706
|
+
}
|
|
707
|
+
panel.onCancel = { [weak self] in
|
|
708
|
+
guard let self, self.model.pickerOpen else { return false }
|
|
709
|
+
self.model.closePicker()
|
|
710
|
+
return true
|
|
711
|
+
}
|
|
502
712
|
}
|
|
503
713
|
|
|
504
714
|
/// ⏎ message, ⌘⏎ note, ⌥⏎ paste, ⇧⏎ a newline in the focused field.
|
|
715
|
+
/// With the chooser open, ⏎ picks the highlighted row (other Returns do
|
|
716
|
+
/// nothing: no send from inside the chooser).
|
|
505
717
|
private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
|
|
718
|
+
if model.pickerOpen {
|
|
719
|
+
let rows = model.chooserRows
|
|
720
|
+
if flags.isEmpty, rows.indices.contains(model.pickerIndex) { model.choose(rows[model.pickerIndex]) }
|
|
721
|
+
return true
|
|
722
|
+
}
|
|
506
723
|
switch flags {
|
|
507
724
|
case []: send(.message)
|
|
508
725
|
case .command: send(.note)
|
|
@@ -514,11 +731,23 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
514
731
|
return true
|
|
515
732
|
}
|
|
516
733
|
|
|
517
|
-
/// ⌘1…⌘9 toggle a target, ⌘0 the new row
|
|
734
|
+
/// ⌘1…⌘9 toggle a target, ⌘0 the new row; ⌘M opens or closes the model
|
|
735
|
+
/// chooser, where ⌘1…⌘9 pick a row.
|
|
518
736
|
private func handleKey(_ event: NSEvent) -> Bool {
|
|
737
|
+
let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command
|
|
738
|
+
// By key code: ⌘M in any keyboard layout (a Cyrillic one types "ь").
|
|
739
|
+
if command, event.keyCode == 46 {
|
|
740
|
+
if model.pickerOpen { model.closePicker() } else { model.openPicker() }
|
|
741
|
+
return true
|
|
742
|
+
}
|
|
743
|
+
if model.pickerOpen {
|
|
744
|
+
let rows = model.chooserRows
|
|
745
|
+
guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars), n >= 1, n <= rows.count else { return false }
|
|
746
|
+
model.choose(rows[n - 1])
|
|
747
|
+
return true
|
|
748
|
+
}
|
|
519
749
|
let targets = model.targets
|
|
520
|
-
guard event.
|
|
521
|
-
let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
750
|
+
guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
522
751
|
n <= min(targets.count, 9) else { return false }
|
|
523
752
|
if n == 0 { model.toggleNew() } else { model.toggle(targets[n - 1].id) }
|
|
524
753
|
return true
|
|
@@ -538,12 +767,17 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
538
767
|
model.newSelected = false
|
|
539
768
|
model.selected = []
|
|
540
769
|
model.touched = false
|
|
770
|
+
model.pickerOpen = false
|
|
771
|
+
model.modelQuery = ""
|
|
772
|
+
model.pickTouched = false
|
|
773
|
+
model.applyStoredPick()
|
|
541
774
|
model.phase = .loading
|
|
542
775
|
placeOnActiveScreen()
|
|
543
776
|
panel.makeKeyAndOrderFront(nil)
|
|
544
777
|
loadSessions()
|
|
545
778
|
loadKitty()
|
|
546
779
|
loadModel()
|
|
780
|
+
loadModels()
|
|
547
781
|
}
|
|
548
782
|
|
|
549
783
|
private func placeOnActiveScreen() {
|
|
@@ -561,7 +795,8 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
561
795
|
switch result {
|
|
562
796
|
case .success(let sessions):
|
|
563
797
|
self.model.sessions = sessions
|
|
564
|
-
|
|
798
|
+
// A choice made while it listed (⌘0, ⌘M) stays.
|
|
799
|
+
if !self.model.touched { self.model.preselect() }
|
|
565
800
|
self.model.phase = .ready
|
|
566
801
|
case .failure(let error):
|
|
567
802
|
self.model.sessions = []
|
|
@@ -598,6 +833,28 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
598
833
|
runner.defaultModel { [weak self] model in self?.model.newModel = model }
|
|
599
834
|
}
|
|
600
835
|
|
|
836
|
+
private var modelsGeneration = 0
|
|
837
|
+
|
|
838
|
+
/// The chooser's list, refreshed on each open; the last one serves
|
|
839
|
+
/// meanwhile, and a failed refresh keeps it.
|
|
840
|
+
private func loadModels() {
|
|
841
|
+
modelsGeneration += 1
|
|
842
|
+
let generation = modelsGeneration
|
|
843
|
+
model.modelsLoading = true
|
|
844
|
+
runner.models { [weak self] result in
|
|
845
|
+
guard let self, generation == self.modelsGeneration else { return }
|
|
846
|
+
self.model.modelsLoading = false
|
|
847
|
+
switch result {
|
|
848
|
+
case .success(let list):
|
|
849
|
+
self.model.modelList = list
|
|
850
|
+
self.model.modelsError = nil
|
|
851
|
+
case .failure(let error):
|
|
852
|
+
self.model.modelsError = error.description
|
|
853
|
+
}
|
|
854
|
+
self.model.applyStoredPick()
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
601
858
|
/// Fans out: one `chi send` / `chi note` for the chi sessions (or the new
|
|
602
859
|
/// row) and one paste per kitty window, with one combined line when all
|
|
603
860
|
/// finished. Images the helper wrote are copied for kitty first (chi's
|
|
@@ -693,6 +950,8 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
693
950
|
// stdin is the quoted context; without -m, chi takes it as the message.
|
|
694
951
|
command = "chi send"
|
|
695
952
|
args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
|
|
953
|
+
// The row's name verbatim; none for the default.
|
|
954
|
+
if new, let name = model.modelForSend { args += ["--model", name] }
|
|
696
955
|
if !prompt.isEmpty { args += ["-m", prompt] }
|
|
697
956
|
args += images.flatMap { ["--image", $0.url.path] }
|
|
698
957
|
stdin = model.hasContext ? Data(model.text.utf8) : nil
|
|
@@ -6,7 +6,8 @@ require_relative "desktop"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
# `chi desktop install|upgrade|uninstall|status`: the native helper that
|
|
9
|
-
# sends selected text to
|
|
9
|
+
# sends selected text (or images) to sessions, live, stopped or new, as a
|
|
10
|
+
# message or a context note (macOS for now).
|
|
10
11
|
class DesktopCommand
|
|
11
12
|
include CLI::Command
|
|
12
13
|
|
|
@@ -15,8 +16,9 @@ module Samagotchi
|
|
|
15
16
|
install [--force] [--login]
|
|
16
17
|
build "Chi Helper" into ~/Applications (needs the Command Line Tools)
|
|
17
18
|
and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
|
|
18
|
-
sends text to a
|
|
19
|
-
|
|
19
|
+
sends text or images to a session (live, stopped or new) as a
|
|
20
|
+
message (⏎) or a context note (⌘⏎). --login: start it at login
|
|
21
|
+
too (else it runs until you log out)
|
|
20
22
|
upgrade rebuild it for this chi and restart it (chi update does this only
|
|
21
23
|
when its sources changed)
|
|
22
24
|
uninstall remove it, its settings and its launch file
|