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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- 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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
37
|
-
/// recent (stopped) one is never
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 =
|
|
159
|
-
argv =
|
|
160
|
-
env = launch
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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]}\")"
|