samagotchi 0.11.0 → 0.12.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. metadata +8 -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
- switch model.phase {
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
- 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)
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 Button(action: { model.toggleNew() }) {
290
- HStack(spacing: 10) {
291
- Image(systemName: on ? "plus.circle.fill" : "plus.circle")
292
- .font(.system(size: 16))
293
- .foregroundColor(on ? .accentColor : .secondary)
294
- VStack(alignment: .leading, spacing: 1) {
295
- Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
296
- Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
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
- 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")
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
- Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
462
+ .foregroundColor(.secondary)
463
+ .help(note)
464
+ .padding(.horizontal, 8).padding(.vertical, 4)
306
465
  }
307
- .padding(.horizontal, 8).padding(.vertical, 6)
308
- .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
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("start a session with the message, as the web does (chi send --new); a note needs a session")
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?) { close() }
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.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
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
- self.model.preselect()
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