samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -0,0 +1,113 @@
1
+ // Images for the panel: what a pasteboard (the clipboard, the Service's,
2
+ // a drop) carries, as files `chi send --image` can read. Image files are
3
+ // passed as they are; image data (a ⌃⇧⌘4 screenshot, Preview, Safari's
4
+ // Copy Image) is written to a temp file first, deleted after the send.
5
+ import AppKit
6
+ import UniformTypeIdentifiers
7
+
8
+ /// The Bridge's cap on one turn's images.
9
+ let maxImages = 20
10
+
11
+ struct PanelImage: Identifiable, Equatable {
12
+ let id = UUID()
13
+ let url: URL
14
+ /// Written by the helper (image data), so the helper deletes it.
15
+ let temp: Bool
16
+ let thumbnail: NSImage?
17
+
18
+ var name: String { url.lastPathComponent }
19
+
20
+ init(url: URL, temp: Bool) {
21
+ self.url = url
22
+ self.temp = temp
23
+ thumbnail = NSImage(contentsOf: url)
24
+ }
25
+
26
+ static func == (a: PanelImage, b: PanelImage) -> Bool { a.id == b.id }
27
+ }
28
+
29
+ enum ImageIntake {
30
+ static let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("chi-helper", isDirectory: true)
31
+
32
+ /// A pasteboard's content for the panel, in this order: image files
33
+ /// (Finder ⌘C, a Service on files) with no text; else its text, as before
34
+ /// (an office app's copy carries a picture of the cells too: the text
35
+ /// wins); else image data (png, then tiff). A non-image file is skipped.
36
+ static func read(_ pb: NSPasteboard) -> (text: String, images: [PanelImage]) {
37
+ let files = imageFiles(pb)
38
+ if !files.isEmpty { return ("", files) }
39
+ if let text = pb.string(forType: .string), !text.isEmpty { return (text, []) }
40
+ return ("", imageData(pb))
41
+ }
42
+
43
+ /// A drop adds images only: text dropped on the fields is theirs.
44
+ static func dropped(_ pb: NSPasteboard) -> [PanelImage] {
45
+ let files = imageFiles(pb)
46
+ return files.isEmpty ? imageData(pb) : files
47
+ }
48
+
49
+ static func imageFiles(_ pb: NSPasteboard) -> [PanelImage] {
50
+ let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
51
+ return urls.filter(isImage).prefix(maxImages).map { PanelImage(url: $0, temp: false) }
52
+ }
53
+
54
+ static func isImage(_ url: URL) -> Bool {
55
+ (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType?.conforms(to: .image) == true
56
+ }
57
+
58
+ static func imageData(_ pb: NSPasteboard) -> [PanelImage] {
59
+ for (type, ext) in [(NSPasteboard.PasteboardType.png, "png"), (.tiff, "tiff")] {
60
+ guard let data = pb.data(forType: type) else { continue }
61
+ // A folder per image, so the name chi shows is "clipboard.png".
62
+ let dir = tempDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
63
+ do {
64
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
65
+ let url = dir.appendingPathComponent("clipboard.\(ext)")
66
+ try data.write(to: url)
67
+ return [PanelImage(url: url, temp: true)]
68
+ } catch {
69
+ return []
70
+ }
71
+ }
72
+ return []
73
+ }
74
+
75
+ static func delete(_ images: [PanelImage]) {
76
+ for image in images where image.temp {
77
+ try? FileManager.default.removeItem(at: image.url.deletingLastPathComponent())
78
+ }
79
+ }
80
+
81
+ /// At launch: what a helper that quit mid-send left.
82
+ static func sweep() {
83
+ try? FileManager.default.removeItem(at: tempDir)
84
+ }
85
+ }
86
+
87
+ /// The panel's background, taking dropped images anywhere on the panel.
88
+ final class DropEffectView: NSVisualEffectView {
89
+ var onDrop: (([PanelImage]) -> Void)?
90
+
91
+ override init(frame: NSRect) {
92
+ super.init(frame: frame)
93
+ registerForDraggedTypes([.fileURL, .png, .tiff])
94
+ }
95
+
96
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
97
+
98
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
99
+ carriesImages(sender.draggingPasteboard) ? .copy : []
100
+ }
101
+
102
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
103
+ let images = ImageIntake.dropped(sender.draggingPasteboard)
104
+ guard !images.isEmpty else { return false }
105
+ onDrop?(images)
106
+ return true
107
+ }
108
+
109
+ private func carriesImages(_ pb: NSPasteboard) -> Bool {
110
+ let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
111
+ return urls.contains(where: ImageIntake.isImage) || pb.availableType(from: [.png, .tiff]) != nil
112
+ }
113
+ }
@@ -33,6 +33,12 @@
33
33
  <key>NSSendTypes</key>
34
34
  <array>
35
35
  <string>public.utf8-plain-text</string>
36
+ <string>public.png</string>
37
+ <string>public.tiff</string>
38
+ </array>
39
+ <key>NSSendFileTypes</key>
40
+ <array>
41
+ <string>public.image</string>
36
42
  </array>
37
43
  <key>NSRequiredContext</key>
38
44
  <dict/>
@@ -1,6 +1,8 @@
1
- // The Spotlight-like panel: a message line on top, the text (selection or
2
- // clipboard) below it as quoted context, then the sessions. ⏎ sends a
3
- // message (`chi send`), ⌘⏎ a note (`chi note`), ⇧⏎ is a newline.
1
+ // The Spotlight-like panel: a message line on top, the images (a screenshot,
2
+ // Finder files, drops) as thumbnails, the text (selection or clipboard)
3
+ // below as quoted context, then a "New session" row and the sessions. ⏎
4
+ // sends a message (`chi send`, or `chi send --new` on the new row, with
5
+ // `--image` per image), ⌘⏎ a note (`chi note`, text only), ⇧⏎ a newline.
4
6
  import AppKit
5
7
  import SwiftUI
6
8
 
@@ -14,8 +16,12 @@ final class PanelModel: ObservableObject {
14
16
  /// The selection or clipboard: the quoted context of a message, or a note.
15
17
  @Published var text = ""
16
18
  @Published var source = ""
19
+ /// Sent as `--image` with a message; notes are text only.
20
+ @Published var images: [PanelImage] = []
17
21
  @Published var sessions: [LiveSession] = []
18
22
  @Published var selected: Set<String> = []
23
+ /// The "New session" row; never together with sessions (`--new` takes no ids).
24
+ @Published var newSelected = false
19
25
  @Published var phase: Phase = .loading
20
26
  @Published var message = ""
21
27
 
@@ -28,21 +34,58 @@ final class PanelModel: ObservableObject {
28
34
  var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
29
35
  var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
30
36
  var sendEnabled: Bool {
31
- canSend && !selected.isEmpty && (!trimmedPrompt.isEmpty || hasContext)
37
+ canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
32
38
  }
33
39
  var live: [LiveSession] { sessions.filter { !$0.recent } }
40
+
41
+ /// Up to maxImages; the rest is dropped (and its temp files deleted).
42
+ func add(_ new: [PanelImage]) {
43
+ let room = max(0, maxImages - images.count)
44
+ images += new.prefix(room)
45
+ ImageIntake.delete(Array(new.dropFirst(room)))
46
+ if new.count > room { message = "At most \(maxImages) images" }
47
+ }
48
+
49
+ func remove(_ image: PanelImage) {
50
+ images.removeAll { $0 == image }
51
+ ImageIntake.delete([image])
52
+ }
34
53
  var recent: [LiveSession] { sessions.filter(\.recent) }
35
54
 
36
- /// The last choice, where still live; else the only live session. A
37
- /// recent (stopped) one is never preselected: a message would wake it.
55
+ /// The last choice, where still live; else the only live session; else,
56
+ /// with none live, the new row. A recent (stopped) one is never
57
+ /// preselected: a message would wake it.
38
58
  func preselect() {
39
59
  let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
40
60
  selected = last.intersection(Set(live.map(\.id)))
41
61
  if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
62
+ newSelected = live.isEmpty
42
63
  }
43
64
 
44
65
  func toggle(_ id: String) {
45
- if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
66
+ if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
67
+ }
68
+
69
+ func toggleNew() {
70
+ newSelected.toggle()
71
+ if newSelected { selected = [] }
72
+ }
73
+
74
+ /// Where a new session starts: the helper runs in $HOME, which is in no
75
+ /// project, so the folder of the most recently updated live session, else
76
+ /// of the newest recent one, else home.
77
+ var newSessionDir: String {
78
+ let exists = { (s: LiveSession) -> Bool in
79
+ var dir: ObjCBool = false
80
+ return s.cwd.map { FileManager.default.fileExists(atPath: $0, isDirectory: &dir) && dir.boolValue } ?? false
81
+ }
82
+ let newest = { (list: [LiveSession]) in list.filter(exists).max { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) } }
83
+ return (newest(live) ?? newest(recent))?.cwd ?? NSHomeDirectory()
84
+ }
85
+
86
+ var newSessionFolder: String {
87
+ let dir = newSessionDir
88
+ return dir == NSHomeDirectory() ? "~" : (dir as NSString).lastPathComponent
46
89
  }
47
90
 
48
91
  /// In list order, only ids that are live and UUID-shaped.
@@ -60,13 +103,16 @@ struct PanelView: View {
60
103
 
61
104
  var body: some View {
62
105
  VStack(spacing: 0) {
63
- TextField("Ask chi…", text: $model.prompt, axis: .vertical)
106
+ // The message is required with images, as in the web.
107
+ TextField(model.images.isEmpty ? "Ask chi…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
64
108
  .textFieldStyle(.plain)
65
109
  .font(.system(size: 17))
66
110
  .lineLimit(1...4)
67
111
  .focused($promptFocused)
68
112
  .padding(.horizontal, 19).padding(.top, 14).padding(.bottom, 8)
69
113
 
114
+ imageStrip
115
+
70
116
  ZStack(alignment: .topLeading) {
71
117
  if model.text.isEmpty {
72
118
  Text("Context, quoted above the message (or the note)…")
@@ -94,6 +140,21 @@ struct PanelView: View {
94
140
  .onAppear { promptFocused = true }
95
141
  }
96
142
 
143
+ @ViewBuilder var imageStrip: some View {
144
+ if !model.images.isEmpty {
145
+ ScrollView(.horizontal, showsIndicators: false) {
146
+ HStack(spacing: 8) {
147
+ ForEach(model.images) { image in
148
+ Thumbnail(image: image, removable: model.phase != .sending) { model.remove(image) }
149
+ }
150
+ }
151
+ .padding(.horizontal, 19).padding(.vertical, 2)
152
+ }
153
+ .frame(height: 52)
154
+ .padding(.bottom, 8)
155
+ }
156
+ }
157
+
97
158
  @ViewBuilder var sessionList: some View {
98
159
  VStack(alignment: .leading, spacing: 2) {
99
160
  switch model.phase {
@@ -103,10 +164,7 @@ struct PanelView: View {
103
164
  Text("Looking for live sessions…").foregroundColor(.secondary)
104
165
  }.padding(10)
105
166
  default:
106
- if model.sessions.isEmpty {
107
- Text(model.phase == .failed && !model.message.isEmpty ? "" : "No live or recent sessions. Start one with `chi` in a terminal.")
108
- .foregroundColor(.secondary).padding(10)
109
- }
167
+ newRow
110
168
  ForEach(Array(model.sessions.prefix(9).enumerated()), id: \.element.id) { index, session in
111
169
  if session.recent && index == model.live.count {
112
170
  HStack(spacing: 6) {
@@ -123,6 +181,28 @@ struct PanelView: View {
123
181
  .frame(maxWidth: .infinity, alignment: .leading)
124
182
  }
125
183
 
184
+ var newRow: some View {
185
+ let on = model.newSelected
186
+ return Button(action: { model.toggleNew() }) {
187
+ HStack(spacing: 10) {
188
+ Image(systemName: on ? "plus.circle.fill" : "plus.circle")
189
+ .font(.system(size: 16))
190
+ .foregroundColor(on ? .accentColor : .secondary)
191
+ VStack(alignment: .leading, spacing: 1) {
192
+ Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
193
+ Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
194
+ }
195
+ Spacer()
196
+ Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
197
+ }
198
+ .padding(.horizontal, 8).padding(.vertical, 6)
199
+ .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
200
+ .contentShape(Rectangle())
201
+ }
202
+ .buttonStyle(.plain)
203
+ .help("start a session with the message, as the web does (chi send --new); a note needs a session")
204
+ }
205
+
126
206
  func row(_ session: LiveSession, index: Int) -> some View {
127
207
  let on = model.selected.contains(session.id)
128
208
  return Button(action: { model.toggle(session.id) }) {
@@ -196,6 +276,43 @@ struct PanelView: View {
196
276
  }
197
277
  }
198
278
 
279
+ /// One image in the strip: 48 px high, its name as a tooltip, ✕ on hover.
280
+ struct Thumbnail: View {
281
+ let image: PanelImage
282
+ let removable: Bool
283
+ let remove: () -> Void
284
+ @State private var hover = false
285
+
286
+ var body: some View {
287
+ Group {
288
+ if let thumb = image.thumbnail {
289
+ Image(nsImage: thumb).resizable().aspectRatio(contentMode: .fit)
290
+ } else {
291
+ Image(systemName: "photo").font(.system(size: 20)).foregroundColor(.secondary).frame(width: 48)
292
+ }
293
+ }
294
+ .frame(height: 48)
295
+ .frame(maxWidth: 120)
296
+ .clipShape(RoundedRectangle(cornerRadius: 6))
297
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.15), lineWidth: 0.5))
298
+ .overlay(alignment: .topTrailing) {
299
+ if hover && removable {
300
+ Button(action: remove) {
301
+ Image(systemName: "xmark.circle.fill")
302
+ .font(.system(size: 14))
303
+ .symbolRenderingMode(.palette)
304
+ .foregroundStyle(.white, Color.black.opacity(0.55))
305
+ }
306
+ .buttonStyle(.plain)
307
+ .padding(3)
308
+ .help("remove")
309
+ }
310
+ }
311
+ .onHover { hover = $0 }
312
+ .help(image.name)
313
+ }
314
+ }
315
+
199
316
  final class KeyPanel: NSPanel {
200
317
  var onKey: ((NSEvent) -> Bool)?
201
318
  /// Return with these modifiers; true when handled.
@@ -227,6 +344,8 @@ final class PanelController: NSObject, NSWindowDelegate {
227
344
  let runner = ChiRunner()
228
345
  private var panel: KeyPanel!
229
346
  private var returnTo: NSRunningApplication?
347
+ /// Images a running `chi send` still reads: not deleted on close.
348
+ private var inFlight: Set<UUID> = []
230
349
 
231
350
  override init() {
232
351
  super.init()
@@ -244,7 +363,7 @@ final class PanelController: NSObject, NSWindowDelegate {
244
363
  panel.backgroundColor = .clear
245
364
  panel.delegate = self
246
365
 
247
- let effect = NSVisualEffectView()
366
+ let effect = DropEffectView()
248
367
  effect.material = .popover
249
368
  effect.blendingMode = .behindWindow
250
369
  effect.state = .active
@@ -257,6 +376,7 @@ final class PanelController: NSObject, NSWindowDelegate {
257
376
  hosting.topAnchor.constraint(equalTo: effect.topAnchor),
258
377
  hosting.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
259
378
  ])
379
+ effect.onDrop = { [weak self] images in self?.model.add(images) }
260
380
  panel.contentView = effect
261
381
  panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
262
382
  panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
@@ -274,22 +394,27 @@ final class PanelController: NSObject, NSWindowDelegate {
274
394
  return true
275
395
  }
276
396
 
277
- /// ⌘1…⌘9 toggle a session.
397
+ /// ⌘1…⌘9 toggle a session, ⌘0 the new row.
278
398
  private func handleKey(_ event: NSEvent) -> Bool {
279
399
  guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
280
- let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n),
400
+ let chars = event.charactersIgnoringModifiers, let n = Int(chars),
281
401
  n <= model.sessions.count else { return false }
282
- model.toggle(model.sessions[n - 1].id)
402
+ if n == 0 { model.toggleNew() } else { model.toggle(model.sessions[n - 1].id) }
283
403
  return true
284
404
  }
285
405
 
286
406
  /// @param returnTo the app to give focus back to on close
287
- func show(text: String, source: String, returnTo app: NSRunningApplication?) {
407
+ func show(text: String, images: [PanelImage] = [], source: String, returnTo app: NSRunningApplication?) {
288
408
  returnTo = app
409
+ // A show on an open panel resets it without a close.
410
+ dropImages()
289
411
  model.prompt = ""
412
+ model.images = []
413
+ model.add(images)
290
414
  model.text = text
291
415
  model.source = source
292
416
  model.message = ""
417
+ model.newSelected = false
293
418
  model.phase = .loading
294
419
  placeOnActiveScreen()
295
420
  panel.makeKeyAndOrderFront(nil)
@@ -323,10 +448,19 @@ final class PanelController: NSObject, NSWindowDelegate {
323
448
 
324
449
  func send(_ kind: SendKind) {
325
450
  let ids = model.selectedIds
326
- guard model.sendEnabled, !ids.isEmpty else { NSSound.beep(); return }
451
+ let new = model.newSelected
452
+ if kind == .note, !model.images.isEmpty {
453
+ NSSound.beep()
454
+ model.message = "Notes are text only: ⏎ sends the image as a message"
455
+ return
456
+ }
457
+ // A note into a session that doesn't exist yet makes no sense.
458
+ guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
327
459
  model.phase = .sending
328
460
  model.message = ""
329
- UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
461
+ if !new {
462
+ UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
463
+ }
330
464
  let prompt = model.trimmedPrompt
331
465
  let command: String
332
466
  var args: [String]
@@ -335,8 +469,9 @@ final class PanelController: NSObject, NSWindowDelegate {
335
469
  case .message:
336
470
  // stdin is the quoted context; without -m, chi takes it as the message.
337
471
  command = "chi send"
338
- args = ["send"]
472
+ args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
339
473
  if !prompt.isEmpty { args += ["-m", prompt] }
474
+ args += model.images.flatMap { ["--image", $0.url.path] }
340
475
  stdin = model.hasContext ? Data(model.text.utf8) : nil
341
476
  case .note:
342
477
  command = "chi note"
@@ -346,9 +481,20 @@ final class PanelController: NSObject, NSWindowDelegate {
346
481
  let parts = [prompt, model.hasContext ? model.text : ""].filter { !$0.isEmpty }
347
482
  stdin = Data(parts.joined(separator: "\n\n").utf8)
348
483
  }
349
- args += ids
350
- runner.run(args, stdin: stdin) { [weak self] result in
484
+ if !new { args += ids }
485
+ let images = kind == .message ? model.images : []
486
+ inFlight.formUnion(images.map(\.id))
487
+ // Reading, converting and a new worker's start take longer than text.
488
+ let timeout = images.isEmpty ? runner.timeout : 30
489
+ runner.run(args, stdin: stdin, timeout: timeout) { [weak self] result in
351
490
  guard let self else { return }
491
+ self.inFlight.subtract(images.map(\.id))
492
+ // Sent: the temp files go (and the thumbnails). Else they stay for
493
+ // another try, unless the panel was closed or reopened meanwhile.
494
+ var sent = false
495
+ if case .success(let r) = result { sent = r.status == 0 && !r.timedOut }
496
+ if sent { self.model.images.removeAll { images.contains($0) } }
497
+ ImageIntake.delete(images.filter { !self.model.images.contains($0) })
352
498
  switch result {
353
499
  case .failure(let error):
354
500
  self.model.phase = .failed
@@ -359,12 +505,13 @@ final class PanelController: NSObject, NSWindowDelegate {
359
505
  if r.timedOut {
360
506
  self.model.phase = .failed
361
507
  let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
362
- self.model.message = "\(command) took over \(Int(self.runner.timeout)) s and was stopped; \(what). \(out)"
508
+ self.model.message = "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"
363
509
  } else if r.status == 0 {
364
510
  self.model.phase = .sent
365
- self.model.message = out.isEmpty ? "Sent." : out
511
+ // --new prints "<full id> started".
512
+ self.model.message = out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out
366
513
  // Long enough to read a "waits for the session's next start" line.
367
- let linger = out.contains("waits for") || out.contains("started its worker") ? 3.0 : 1.0
514
+ let linger = out.contains("waits for") || out.contains("started") ? 3.0 : 1.0
368
515
  DispatchQueue.main.asyncAfter(deadline: .now() + linger) { [weak self] in
369
516
  if self?.model.phase == .sent { self?.panel.close() }
370
517
  }
@@ -377,7 +524,15 @@ final class PanelController: NSObject, NSWindowDelegate {
377
524
  }
378
525
 
379
526
  func windowWillClose(_ notification: Notification) {
527
+ dropImages()
380
528
  returnTo?.activate()
381
529
  returnTo = nil
382
530
  }
531
+
532
+ /// The panel's images leave it; their temp files go unless a send still
533
+ /// reads them (its completion deletes them).
534
+ private func dropImages() {
535
+ ImageIntake.delete(model.images.filter { !inFlight.contains($0.id) })
536
+ model.images = []
537
+ }
383
538
  }
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "erb"
4
5
  require "fileutils"
5
6
  require "json"
@@ -81,7 +82,42 @@ module Samagotchi
81
82
  def launch_config
82
83
  env = { "LANG" => LANG }
83
84
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
84
- { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env }
85
+ { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
86
+ end
87
+
88
+ # A digest of what a build compiles: the Swift sources and the
89
+ # Info.plist template. The launch file records the one it was built
90
+ # from, so a new chi rebuilds only when these changed.
91
+ def sources_sha
92
+ files = Dir[File.join(@sources_dir, "*.swift")].sort + [File.join(SOURCES_DIR, "Info.plist.erb")]
93
+ Digest::SHA256.hexdigest(files.map { |f| "#{File.basename(f)}\0#{File.binread(f)}" }.join("\0"))
94
+ end
95
+
96
+ # Whether the installed app needs a rebuild (chi desktop upgrade): its
97
+ # sources changed since the build (a launch file without a digest
98
+ # counts as changed), or the launch file's ruby or chi is gone (a Ruby
99
+ # upgrade moved them).
100
+ def stale?
101
+ return false unless installed?
102
+
103
+ launch = read_launch
104
+ launch["sources_sha"] != sources_sha || !launch_ok?(launch)
105
+ end
106
+
107
+ # The launch file names another chi version or path than this one:
108
+ # refresh_launch_file fixes that without a rebuild.
109
+ def launch_outdated?
110
+ launch = read_launch
111
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"]
112
+ end
113
+
114
+ # Rewrite the launch file for this chi, keeping the env it was
115
+ # installed with. The app reads it at each send: no restart.
116
+ def refresh_launch_file
117
+ config = launch_config
118
+ env = read_launch["env"]
119
+ config["env"] = env if env.is_a?(Hash)
120
+ write_launch_file(config)
85
121
  end
86
122
 
87
123
  def warnings
@@ -155,9 +191,9 @@ module Samagotchi
155
191
  result = { installed: installed?, app_path: app_path, chi_version: @version }
156
192
  return result unless result[:installed]
157
193
 
158
- launch = (JSON.parse(File.read(launch_path)) rescue nil)
159
- argv = launch.is_a?(Hash) ? Array(launch["argv"]) : []
160
- env = launch.is_a?(Hash) && launch["env"].is_a?(Hash) ? launch["env"] : {}
194
+ launch = read_launch
195
+ argv = Array(launch["argv"])
196
+ env = launch["env"].is_a?(Hash) ? launch["env"] : {}
161
197
  dump, = @runner.run([PBS, "-dump"])
162
198
  _, running = @runner.run(["pgrep", "-x", EXECUTABLE])
163
199
  hotkey = (JSON.parse(File.read(hotkey_path)) rescue nil)
@@ -166,7 +202,8 @@ module Samagotchi
166
202
  login: login,
167
203
  hotkey: hotkey.is_a?(Hash) ? hotkey : nil,
168
204
  launch_argv: argv,
169
- launch_ok: !argv.empty? && argv.all? { |path| File.exist?(path) },
205
+ launch_ok: launch_ok?(launch),
206
+ stale: stale?,
170
207
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
171
208
  service: dump.include?(BUNDLE_ID),
172
209
  running: running
@@ -232,18 +269,32 @@ module Samagotchi
232
269
  @arch ||= RbConfig::CONFIG["host_cpu"] == "arm64" ? "arm64" : `uname -m`.strip
233
270
  end
234
271
 
235
- def write_launch_file
272
+ def read_launch
273
+ launch = JSON.parse(File.read(launch_path))
274
+ launch.is_a?(Hash) ? launch : {}
275
+ rescue SystemCallError, JSON::ParserError
276
+ {}
277
+ end
278
+
279
+ def launch_ok?(launch)
280
+ argv = Array(launch["argv"])
281
+ !argv.empty? && argv.all? { |path| File.exist?(path) }
282
+ end
283
+
284
+ def write_launch_file(config = launch_config)
236
285
  FileUtils.mkdir_p(@support_dir)
237
286
  tmp = "#{launch_path}.tmp"
238
- File.write(tmp, JSON.pretty_generate(launch_config) + "\n")
287
+ File.write(tmp, JSON.pretty_generate(config) + "\n")
239
288
  File.rename(tmp, launch_path)
240
289
  end
241
290
 
242
291
  def register_app
243
292
  return unless @register
244
293
 
245
- @runner.run([PBS, "-update"])
294
+ # The app first, so pbs reads the Service types it has now (a new
295
+ # type may otherwise show only after a re-login).
246
296
  @runner.run([LSREGISTER, "-f", app_path])
297
+ @runner.run([PBS, "-update"])
247
298
  @runner.run(["open", "-g", app_path])
248
299
  end
249
300
 
@@ -13,7 +13,8 @@ module Samagotchi
13
13
  and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
14
14
  sends text to a live session as a context note. --login: start it
15
15
  at login too (else it runs until you log out)
16
- upgrade rebuild it for this chi and restart it
16
+ upgrade rebuild it for this chi and restart it (chi update does this only
17
+ when its sources changed)
17
18
  uninstall remove it, its settings and its launch file
18
19
  status installed version, how it runs chi, Service and process state
19
20
  TEXT
@@ -96,11 +97,13 @@ module Samagotchi
96
97
 
97
98
  version = if s[:app_version] == s[:chi_version]
98
99
  "#{s[:app_version]} (matches chi)"
100
+ elsif s[:stale]
101
+ "#{s[:app_version] || "?"} (chi is #{s[:chi_version]}: chi update)"
99
102
  else
100
- "#{s[:app_version] || "?"} (chi is #{s[:chi_version]}: chi desktop upgrade)"
103
+ "#{s[:app_version] || "?"} (up to date: chi #{s[:chi_version]} changed nothing in it)"
101
104
  end
102
105
  launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
103
- launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop upgrade)"
106
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi update)"
104
107
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
105
108
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
106
109
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"