samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,42 @@
1
+ // The global hotkey (⌃⌥⌘N): opens the panel with the clipboard, for apps
2
+ // whose Services menu doesn't offer "Send to chi". Carbon hotkeys need no
3
+ // Accessibility permission. A clash with a system shortcut isn't reported
4
+ // (RegisterEventHotKey only knows other apps' hotkeys).
5
+ import Carbon.HIToolbox
6
+ import Foundation
7
+
8
+ final class Hotkey {
9
+ static let label = "⌃⌥⌘N"
10
+ static let statePath = ChiRunner.supportDir + "/hotkey.json"
11
+
12
+ private var ref: EventHotKeyRef?
13
+ private let action: () -> Void
14
+
15
+ init(action: @escaping () -> Void) {
16
+ self.action = action
17
+ }
18
+
19
+ /// Registers the hotkey and writes whether it worked for `chi desktop status`.
20
+ @discardableResult
21
+ func register() -> Bool {
22
+ var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
23
+ let me = Unmanaged.passUnretained(self).toOpaque()
24
+ InstallEventHandler(GetApplicationEventTarget(), { _, _, userData in
25
+ let hotkey = Unmanaged<Hotkey>.fromOpaque(userData!).takeUnretainedValue()
26
+ DispatchQueue.main.async { hotkey.action() }
27
+ return noErr
28
+ }, 1, &spec, me, nil)
29
+ let id = EventHotKeyID(signature: OSType(0x4348_4950), id: 1) // "CHIP"
30
+ let status = RegisterEventHotKey(UInt32(kVK_ANSI_N), UInt32(controlKey | optionKey | cmdKey), id,
31
+ GetApplicationEventTarget(), 0, &ref)
32
+ writeState(registered: status == noErr, status: status)
33
+ return status == noErr
34
+ }
35
+
36
+ private func writeState(registered: Bool, status: OSStatus) {
37
+ let state: [String: Any] = ["keys": Self.label, "registered": registered, "status": Int(status)]
38
+ guard let data = try? JSONSerialization.data(withJSONObject: state) else { return }
39
+ try? FileManager.default.createDirectory(atPath: ChiRunner.supportDir, withIntermediateDirectories: true)
40
+ FileManager.default.createFile(atPath: Self.statePath, contents: data)
41
+ }
42
+ }
@@ -0,0 +1,42 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string><%= bundle_id %></string>
7
+ <key>CFBundleName</key>
8
+ <string><%= app_name %></string>
9
+ <key>CFBundleExecutable</key>
10
+ <string><%= executable %></string>
11
+ <key>CFBundlePackageType</key>
12
+ <string>APPL</string>
13
+ <key>CFBundleShortVersionString</key>
14
+ <string><%= version %></string>
15
+ <key>CFBundleVersion</key>
16
+ <string><%= version %></string>
17
+ <key>LSMinimumSystemVersion</key>
18
+ <string>13.0</string>
19
+ <key>LSUIElement</key>
20
+ <true/>
21
+ <key>NSServices</key>
22
+ <array>
23
+ <dict>
24
+ <key>NSMenuItem</key>
25
+ <dict>
26
+ <key>default</key>
27
+ <string>Send to chi</string>
28
+ </dict>
29
+ <key>NSMessage</key>
30
+ <string>sendToChi</string>
31
+ <key>NSPortName</key>
32
+ <string><%= app_name %></string>
33
+ <key>NSSendTypes</key>
34
+ <array>
35
+ <string>public.utf8-plain-text</string>
36
+ </array>
37
+ <key>NSRequiredContext</key>
38
+ <dict/>
39
+ </dict>
40
+ </array>
41
+ </dict>
42
+ </plist>
@@ -0,0 +1,383 @@
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.
4
+ import AppKit
5
+ import SwiftUI
6
+
7
+ let noteCap = 16 * 1024
8
+
9
+ final class PanelModel: ObservableObject {
10
+ enum Phase: Equatable { case loading, ready, sending, sent, failed }
11
+
12
+ /// The message line; `-m` of `chi send`.
13
+ @Published var prompt = ""
14
+ /// The selection or clipboard: the quoted context of a message, or a note.
15
+ @Published var text = ""
16
+ @Published var source = ""
17
+ @Published var sessions: [LiveSession] = []
18
+ @Published var selected: Set<String> = []
19
+ @Published var phase: Phase = .loading
20
+ @Published var message = ""
21
+
22
+ static let lastChoiceKey = "lastSessionIds"
23
+
24
+ var bytes: Int { prompt.utf8.count + text.utf8.count }
25
+ var canSend: Bool {
26
+ phase == .ready || phase == .failed
27
+ }
28
+ var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
29
+ var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
30
+ var sendEnabled: Bool {
31
+ canSend && !selected.isEmpty && (!trimmedPrompt.isEmpty || hasContext)
32
+ }
33
+ var live: [LiveSession] { sessions.filter { !$0.recent } }
34
+ var recent: [LiveSession] { sessions.filter(\.recent) }
35
+
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.
38
+ func preselect() {
39
+ let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
40
+ selected = last.intersection(Set(live.map(\.id)))
41
+ if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
42
+ }
43
+
44
+ func toggle(_ id: String) {
45
+ if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
46
+ }
47
+
48
+ /// In list order, only ids that are live and UUID-shaped.
49
+ var selectedIds: [String] {
50
+ sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
51
+ }
52
+ }
53
+
54
+ enum SendKind { case message, note }
55
+
56
+ struct PanelView: View {
57
+ @ObservedObject var model: PanelModel
58
+ let send: (SendKind) -> Void
59
+ @FocusState private var promptFocused: Bool
60
+
61
+ var body: some View {
62
+ VStack(spacing: 0) {
63
+ TextField("Ask chi…", text: $model.prompt, axis: .vertical)
64
+ .textFieldStyle(.plain)
65
+ .font(.system(size: 17))
66
+ .lineLimit(1...4)
67
+ .focused($promptFocused)
68
+ .padding(.horizontal, 19).padding(.top, 14).padding(.bottom, 8)
69
+
70
+ ZStack(alignment: .topLeading) {
71
+ if model.text.isEmpty {
72
+ Text("Context, quoted above the message (or the note)…")
73
+ .font(.system(size: 13))
74
+ .foregroundColor(.secondary)
75
+ .padding(.horizontal, 5).padding(.vertical, 1)
76
+ }
77
+ TextEditor(text: $model.text)
78
+ .font(.system(size: 13))
79
+ .foregroundColor(.secondary)
80
+ .scrollContentBackground(.hidden)
81
+ }
82
+ .frame(minHeight: 44, maxHeight: 150)
83
+ .padding(.leading, 14).padding(.trailing, 14).padding(.bottom, 8)
84
+ .overlay(alignment: .leading) {
85
+ Rectangle().fill(Color.secondary.opacity(0.35)).frame(width: 2).padding(.leading, 12).padding(.bottom, 8)
86
+ }
87
+
88
+ Divider().opacity(0.5)
89
+ sessionList
90
+ Divider().opacity(0.5)
91
+ footer
92
+ }
93
+ .frame(width: 600)
94
+ .onAppear { promptFocused = true }
95
+ }
96
+
97
+ @ViewBuilder var sessionList: some View {
98
+ VStack(alignment: .leading, spacing: 2) {
99
+ switch model.phase {
100
+ case .loading:
101
+ HStack(spacing: 8) {
102
+ ProgressView().controlSize(.small)
103
+ Text("Looking for live sessions…").foregroundColor(.secondary)
104
+ }.padding(10)
105
+ 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
+ }
110
+ ForEach(Array(model.sessions.prefix(9).enumerated()), id: \.element.id) { index, session in
111
+ if session.recent && index == model.live.count {
112
+ HStack(spacing: 6) {
113
+ Text("recent").font(.system(size: 11)).foregroundColor(.secondary)
114
+ Rectangle().fill(Color.secondary.opacity(0.25)).frame(height: 1)
115
+ }
116
+ .padding(.horizontal, 8).padding(.top, index == 0 ? 2 : 6)
117
+ }
118
+ row(session, index: index)
119
+ }
120
+ }
121
+ }
122
+ .padding(6)
123
+ .frame(maxWidth: .infinity, alignment: .leading)
124
+ }
125
+
126
+ func row(_ session: LiveSession, index: Int) -> some View {
127
+ let on = model.selected.contains(session.id)
128
+ return Button(action: { model.toggle(session.id) }) {
129
+ HStack(spacing: 10) {
130
+ Image(systemName: on ? "checkmark.circle.fill" : "circle")
131
+ .font(.system(size: 16))
132
+ .foregroundColor(on ? .accentColor : .secondary)
133
+ VStack(alignment: .leading, spacing: 1) {
134
+ Text(session.desc?.isEmpty == false ? session.desc! : String(session.id.prefix(8)))
135
+ .font(.system(size: 13, weight: .medium)).lineLimit(1)
136
+ Text(session.shortCwd).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
137
+ }
138
+ Spacer()
139
+ if session.recent {
140
+ Text(session.age()).font(.system(size: 11)).foregroundColor(.secondary)
141
+ .help("stopped: a message starts its worker; a note waits for its next start")
142
+ }
143
+ if session.busy == true {
144
+ Circle().fill(Color.orange).frame(width: 7, height: 7).help("working on a turn")
145
+ }
146
+ Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
147
+ }
148
+ .padding(.horizontal, 8).padding(.vertical, 6)
149
+ .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
150
+ .contentShape(Rectangle())
151
+ .opacity(session.recent ? 0.6 : 1)
152
+ }
153
+ .buttonStyle(.plain)
154
+ }
155
+
156
+ var footer: some View {
157
+ HStack(spacing: 10) {
158
+ HStack(spacing: 4) {
159
+ Image(systemName: "arrow.down.doc").font(.system(size: 11)).foregroundColor(.secondary)
160
+ TextField("source", text: $model.source)
161
+ .textFieldStyle(.plain)
162
+ .font(.system(size: 12))
163
+ .frame(width: 90)
164
+ }
165
+ .padding(.horizontal, 8).padding(.vertical, 4)
166
+ .background(Capsule().fill(Color.primary.opacity(0.07)))
167
+
168
+ Text(byteLabel)
169
+ .font(.system(size: 11).monospacedDigit())
170
+ .foregroundColor(model.bytes > noteCap ? .red : .secondary)
171
+
172
+ Text(model.message)
173
+ .font(.system(size: 11))
174
+ .foregroundColor(model.phase == .failed ? .red : .secondary)
175
+ .lineLimit(3)
176
+ .textSelection(.enabled)
177
+ .frame(maxWidth: .infinity, alignment: .leading)
178
+
179
+ // ⏎ and ⌘⏎ are handled by the panel (KeyPanel.sendEvent), whatever
180
+ // has the focus; the buttons are for the mouse.
181
+ Button("Note ⌘⏎") { send(.note) }
182
+ .disabled(!model.sendEnabled)
183
+ .help("add it as background the model sees on its next turn; starts no turn")
184
+ Button(action: { send(.message) }) {
185
+ if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
186
+ }
187
+ .disabled(!model.sendEnabled)
188
+ .help("send it as your message: a turn runs")
189
+ }
190
+ .padding(.horizontal, 14).padding(.vertical, 10)
191
+ }
192
+
193
+ var byteLabel: String {
194
+ let kib = Double(model.bytes) / 1024
195
+ return model.bytes < 1024 ? "\(model.bytes) B / 16 KB" : String(format: "%.1f KB / 16 KB", kib)
196
+ }
197
+ }
198
+
199
+ final class KeyPanel: NSPanel {
200
+ var onKey: ((NSEvent) -> Bool)?
201
+ /// Return with these modifiers; true when handled.
202
+ var onReturn: ((NSEvent.ModifierFlags) -> Bool)?
203
+
204
+ /// Return is taken here, before the text views see it, so ⏎ sends from
205
+ /// either field. An input method composing text keeps its Return.
206
+ override func sendEvent(_ event: NSEvent) {
207
+ if event.type == .keyDown, event.keyCode == 36 || event.keyCode == 76,
208
+ (firstResponder as? NSTextView)?.hasMarkedText() != true,
209
+ onReturn?(event.modifierFlags.intersection([.shift, .command, .option, .control])) == true {
210
+ return
211
+ }
212
+ super.sendEvent(event)
213
+ }
214
+
215
+ override var canBecomeKey: Bool { true }
216
+ override var canBecomeMain: Bool { false }
217
+ override func cancelOperation(_ sender: Any?) { close() }
218
+
219
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
220
+ if onKey?(event) == true { return true }
221
+ return super.performKeyEquivalent(with: event)
222
+ }
223
+ }
224
+
225
+ final class PanelController: NSObject, NSWindowDelegate {
226
+ let model = PanelModel()
227
+ let runner = ChiRunner()
228
+ private var panel: KeyPanel!
229
+ private var returnTo: NSRunningApplication?
230
+
231
+ override init() {
232
+ super.init()
233
+ panel = KeyPanel(contentRect: NSRect(x: 0, y: 0, width: 600, height: 340),
234
+ styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],
235
+ backing: .buffered, defer: true)
236
+ panel.titlebarAppearsTransparent = true
237
+ panel.titleVisibility = .hidden
238
+ [.closeButton, .miniaturizeButton, .zoomButton].forEach { panel.standardWindowButton($0)?.isHidden = true }
239
+ panel.isMovableByWindowBackground = true
240
+ panel.level = .floating
241
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
242
+ panel.isReleasedWhenClosed = false
243
+ panel.hidesOnDeactivate = false
244
+ panel.backgroundColor = .clear
245
+ panel.delegate = self
246
+
247
+ let effect = NSVisualEffectView()
248
+ effect.material = .popover
249
+ effect.blendingMode = .behindWindow
250
+ effect.state = .active
251
+ let hosting = NSHostingView(rootView: PanelView(model: model, send: { [weak self] kind in self?.send(kind) }))
252
+ hosting.translatesAutoresizingMaskIntoConstraints = false
253
+ effect.addSubview(hosting)
254
+ NSLayoutConstraint.activate([
255
+ hosting.leadingAnchor.constraint(equalTo: effect.leadingAnchor),
256
+ hosting.trailingAnchor.constraint(equalTo: effect.trailingAnchor),
257
+ hosting.topAnchor.constraint(equalTo: effect.topAnchor),
258
+ hosting.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
259
+ ])
260
+ panel.contentView = effect
261
+ panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
262
+ panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
263
+ }
264
+
265
+ /// ⏎ message, ⌘⏎ note, ⇧⏎ a newline in the focused field.
266
+ private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
267
+ switch flags {
268
+ case []: send(.message)
269
+ case .command: send(.note)
270
+ case .shift:
271
+ (panel.firstResponder as? NSTextView)?.insertNewlineIgnoringFieldEditor(nil)
272
+ default: return false
273
+ }
274
+ return true
275
+ }
276
+
277
+ /// ⌘1…⌘9 toggle a session.
278
+ private func handleKey(_ event: NSEvent) -> Bool {
279
+ guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
280
+ let chars = event.charactersIgnoringModifiers, let n = Int(chars), (1...9).contains(n),
281
+ n <= model.sessions.count else { return false }
282
+ model.toggle(model.sessions[n - 1].id)
283
+ return true
284
+ }
285
+
286
+ /// @param returnTo the app to give focus back to on close
287
+ func show(text: String, source: String, returnTo app: NSRunningApplication?) {
288
+ returnTo = app
289
+ model.prompt = ""
290
+ model.text = text
291
+ model.source = source
292
+ model.message = ""
293
+ model.phase = .loading
294
+ placeOnActiveScreen()
295
+ panel.makeKeyAndOrderFront(nil)
296
+ loadSessions()
297
+ }
298
+
299
+ private func placeOnActiveScreen() {
300
+ let mouse = NSEvent.mouseLocation
301
+ let screen = NSScreen.screens.first { NSMouseInRect(mouse, $0.frame, false) } ?? NSScreen.main
302
+ guard let frame = screen?.visibleFrame else { return }
303
+ panel.layoutIfNeeded()
304
+ let size = panel.frame.size
305
+ panel.setFrameOrigin(NSPoint(x: frame.midX - size.width / 2, y: frame.minY + frame.height * 0.62 - size.height / 2))
306
+ }
307
+
308
+ private func loadSessions() {
309
+ runner.sessions { [weak self] result in
310
+ guard let self else { return }
311
+ switch result {
312
+ case .success(let sessions):
313
+ self.model.sessions = sessions
314
+ self.model.preselect()
315
+ self.model.phase = .ready
316
+ case .failure(let error):
317
+ self.model.sessions = []
318
+ self.model.phase = .failed
319
+ self.model.message = error.description
320
+ }
321
+ }
322
+ }
323
+
324
+ func send(_ kind: SendKind) {
325
+ let ids = model.selectedIds
326
+ guard model.sendEnabled, !ids.isEmpty else { NSSound.beep(); return }
327
+ model.phase = .sending
328
+ model.message = ""
329
+ UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
330
+ let prompt = model.trimmedPrompt
331
+ let command: String
332
+ var args: [String]
333
+ let stdin: Data?
334
+ switch kind {
335
+ case .message:
336
+ // stdin is the quoted context; without -m, chi takes it as the message.
337
+ command = "chi send"
338
+ args = ["send"]
339
+ if !prompt.isEmpty { args += ["-m", prompt] }
340
+ stdin = model.hasContext ? Data(model.text.utf8) : nil
341
+ case .note:
342
+ command = "chi note"
343
+ args = ["note"]
344
+ let source = model.source.trimmingCharacters(in: .whitespacesAndNewlines)
345
+ if !source.isEmpty { args += ["--source", source] }
346
+ let parts = [prompt, model.hasContext ? model.text : ""].filter { !$0.isEmpty }
347
+ stdin = Data(parts.joined(separator: "\n\n").utf8)
348
+ }
349
+ args += ids
350
+ runner.run(args, stdin: stdin) { [weak self] result in
351
+ guard let self else { return }
352
+ switch result {
353
+ case .failure(let error):
354
+ self.model.phase = .failed
355
+ self.model.message = error.description
356
+ case .success(let r):
357
+ let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
358
+ let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
359
+ if r.timedOut {
360
+ self.model.phase = .failed
361
+ 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)"
363
+ } else if r.status == 0 {
364
+ self.model.phase = .sent
365
+ self.model.message = out.isEmpty ? "Sent." : out
366
+ // 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
368
+ DispatchQueue.main.asyncAfter(deadline: .now() + linger) { [weak self] in
369
+ if self?.model.phase == .sent { self?.panel.close() }
370
+ }
371
+ } else {
372
+ self.model.phase = .failed
373
+ self.model.message = [out, err].filter { !$0.isEmpty }.joined(separator: "\n")
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ func windowWillClose(_ notification: Notification) {
380
+ returnTo?.activate()
381
+ returnTo = nil
382
+ }
383
+ }