samagotchi 0.3.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- 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 +112 -9
- 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/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- 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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- 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 +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// The Spotlight-like panel: a message line on top, the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
5
6
|
import AppKit
|
|
6
7
|
import SwiftUI
|
|
7
8
|
|
|
@@ -15,6 +16,8 @@ final class PanelModel: ObservableObject {
|
|
|
15
16
|
/// The selection or clipboard: the quoted context of a message, or a note.
|
|
16
17
|
@Published var text = ""
|
|
17
18
|
@Published var source = ""
|
|
19
|
+
/// Sent as `--image` with a message; notes are text only.
|
|
20
|
+
@Published var images: [PanelImage] = []
|
|
18
21
|
@Published var sessions: [LiveSession] = []
|
|
19
22
|
@Published var selected: Set<String> = []
|
|
20
23
|
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
@@ -34,6 +37,19 @@ final class PanelModel: ObservableObject {
|
|
|
34
37
|
canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
|
|
35
38
|
}
|
|
36
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
|
+
}
|
|
37
53
|
var recent: [LiveSession] { sessions.filter(\.recent) }
|
|
38
54
|
|
|
39
55
|
/// The last choice, where still live; else the only live session; else,
|
|
@@ -87,13 +103,16 @@ struct PanelView: View {
|
|
|
87
103
|
|
|
88
104
|
var body: some View {
|
|
89
105
|
VStack(spacing: 0) {
|
|
90
|
-
|
|
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)
|
|
91
108
|
.textFieldStyle(.plain)
|
|
92
109
|
.font(.system(size: 17))
|
|
93
110
|
.lineLimit(1...4)
|
|
94
111
|
.focused($promptFocused)
|
|
95
112
|
.padding(.horizontal, 19).padding(.top, 14).padding(.bottom, 8)
|
|
96
113
|
|
|
114
|
+
imageStrip
|
|
115
|
+
|
|
97
116
|
ZStack(alignment: .topLeading) {
|
|
98
117
|
if model.text.isEmpty {
|
|
99
118
|
Text("Context, quoted above the message (or the note)…")
|
|
@@ -121,6 +140,21 @@ struct PanelView: View {
|
|
|
121
140
|
.onAppear { promptFocused = true }
|
|
122
141
|
}
|
|
123
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
|
+
|
|
124
158
|
@ViewBuilder var sessionList: some View {
|
|
125
159
|
VStack(alignment: .leading, spacing: 2) {
|
|
126
160
|
switch model.phase {
|
|
@@ -242,6 +276,43 @@ struct PanelView: View {
|
|
|
242
276
|
}
|
|
243
277
|
}
|
|
244
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
|
+
|
|
245
316
|
final class KeyPanel: NSPanel {
|
|
246
317
|
var onKey: ((NSEvent) -> Bool)?
|
|
247
318
|
/// Return with these modifiers; true when handled.
|
|
@@ -273,6 +344,8 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
273
344
|
let runner = ChiRunner()
|
|
274
345
|
private var panel: KeyPanel!
|
|
275
346
|
private var returnTo: NSRunningApplication?
|
|
347
|
+
/// Images a running `chi send` still reads: not deleted on close.
|
|
348
|
+
private var inFlight: Set<UUID> = []
|
|
276
349
|
|
|
277
350
|
override init() {
|
|
278
351
|
super.init()
|
|
@@ -290,7 +363,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
290
363
|
panel.backgroundColor = .clear
|
|
291
364
|
panel.delegate = self
|
|
292
365
|
|
|
293
|
-
let effect =
|
|
366
|
+
let effect = DropEffectView()
|
|
294
367
|
effect.material = .popover
|
|
295
368
|
effect.blendingMode = .behindWindow
|
|
296
369
|
effect.state = .active
|
|
@@ -303,6 +376,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
303
376
|
hosting.topAnchor.constraint(equalTo: effect.topAnchor),
|
|
304
377
|
hosting.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
|
|
305
378
|
])
|
|
379
|
+
effect.onDrop = { [weak self] images in self?.model.add(images) }
|
|
306
380
|
panel.contentView = effect
|
|
307
381
|
panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
|
|
308
382
|
panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
|
|
@@ -330,9 +404,13 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
330
404
|
}
|
|
331
405
|
|
|
332
406
|
/// @param returnTo the app to give focus back to on close
|
|
333
|
-
func show(text: String, source: String, returnTo app: NSRunningApplication?) {
|
|
407
|
+
func show(text: String, images: [PanelImage] = [], source: String, returnTo app: NSRunningApplication?) {
|
|
334
408
|
returnTo = app
|
|
409
|
+
// A show on an open panel resets it without a close.
|
|
410
|
+
dropImages()
|
|
335
411
|
model.prompt = ""
|
|
412
|
+
model.images = []
|
|
413
|
+
model.add(images)
|
|
336
414
|
model.text = text
|
|
337
415
|
model.source = source
|
|
338
416
|
model.message = ""
|
|
@@ -371,6 +449,11 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
371
449
|
func send(_ kind: SendKind) {
|
|
372
450
|
let ids = model.selectedIds
|
|
373
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
|
+
}
|
|
374
457
|
// A note into a session that doesn't exist yet makes no sense.
|
|
375
458
|
guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
|
|
376
459
|
model.phase = .sending
|
|
@@ -388,6 +471,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
388
471
|
command = "chi send"
|
|
389
472
|
args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
|
|
390
473
|
if !prompt.isEmpty { args += ["-m", prompt] }
|
|
474
|
+
args += model.images.flatMap { ["--image", $0.url.path] }
|
|
391
475
|
stdin = model.hasContext ? Data(model.text.utf8) : nil
|
|
392
476
|
case .note:
|
|
393
477
|
command = "chi note"
|
|
@@ -398,8 +482,19 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
398
482
|
stdin = Data(parts.joined(separator: "\n\n").utf8)
|
|
399
483
|
}
|
|
400
484
|
if !new { args += ids }
|
|
401
|
-
|
|
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
|
|
402
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) })
|
|
403
498
|
switch result {
|
|
404
499
|
case .failure(let error):
|
|
405
500
|
self.model.phase = .failed
|
|
@@ -410,7 +505,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
410
505
|
if r.timedOut {
|
|
411
506
|
self.model.phase = .failed
|
|
412
507
|
let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
|
|
413
|
-
self.model.message = "\(command) took over \(Int(
|
|
508
|
+
self.model.message = "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"
|
|
414
509
|
} else if r.status == 0 {
|
|
415
510
|
self.model.phase = .sent
|
|
416
511
|
// --new prints "<full id> started".
|
|
@@ -429,7 +524,15 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
429
524
|
}
|
|
430
525
|
|
|
431
526
|
func windowWillClose(_ notification: Notification) {
|
|
527
|
+
dropImages()
|
|
432
528
|
returnTo?.activate()
|
|
433
529
|
returnTo = nil
|
|
434
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
|
+
}
|
|
435
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]}\")"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text_diff"
|
|
4
|
+
require_relative "tools/edit"
|
|
5
|
+
require_relative "tools/tool_path"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What chi's own edit/write tools change, as a TextDiff. Used twice:
|
|
9
|
+
#
|
|
10
|
+
# - EditPreview.for(call): a dry run before the call runs (the approval
|
|
11
|
+
# card). It reads the file once and never writes.
|
|
12
|
+
# - EditPreview.snapshot + change: the file before and after the call ran
|
|
13
|
+
# (the tool row), so the row shows what really happened.
|
|
14
|
+
#
|
|
15
|
+
# A diff is {text:, added:, removed:, truncated:, new_file:}; a preview can
|
|
16
|
+
# also be {error: "old text not found in …"} (the call would fail) or
|
|
17
|
+
# {skipped: "binary file" | "file over 1 MB"}.
|
|
18
|
+
module EditPreview
|
|
19
|
+
TOOLS = %w[edit write].freeze
|
|
20
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
21
|
+
SNIFF_BYTES = 8 * 1024
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def tool?(name) = TOOLS.include?(name.to_s)
|
|
26
|
+
|
|
27
|
+
# @return [Hash, nil] nil for a tool that isn't edit/write
|
|
28
|
+
def for(call)
|
|
29
|
+
return nil unless tool?(call[:name])
|
|
30
|
+
|
|
31
|
+
path = Tools::ToolPath.normalize(call[:path])
|
|
32
|
+
before = snapshot(path)
|
|
33
|
+
return before if before.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
if call[:name].to_s == "edit"
|
|
36
|
+
result = Tools::Edit.apply(call[:content], path: path, start_line: call[:start_line],
|
|
37
|
+
end_line: call[:end_line], read: ->(_) { before })
|
|
38
|
+
return { error: result.delete_prefix("Error: ") } if result.is_a?(String)
|
|
39
|
+
|
|
40
|
+
after = result.first
|
|
41
|
+
else
|
|
42
|
+
after = call[:content]
|
|
43
|
+
return { error: "missing content" } unless after.is_a?(String)
|
|
44
|
+
|
|
45
|
+
skip = skip_reason(after)
|
|
46
|
+
return { skipped: skip } if skip
|
|
47
|
+
end
|
|
48
|
+
# Unlike change, a no-op still gets a (0/0) diff: nil means "not edit/write".
|
|
49
|
+
TextDiff.unified(before.to_s, after).merge(new_file: before.nil?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The file as the tools read it: nil when it doesn't exist, the text, or
|
|
53
|
+
# {skipped:} / {error:} when it can't be diffed.
|
|
54
|
+
def snapshot(path)
|
|
55
|
+
return nil unless File.file?(path)
|
|
56
|
+
return { skipped: "file over 1 MB" } if File.size(path) > MAX_FILE_BYTES
|
|
57
|
+
|
|
58
|
+
text = File.read(path)
|
|
59
|
+
skip = skip_reason(text)
|
|
60
|
+
skip ? { skipped: skip } : text
|
|
61
|
+
rescue SystemCallError, IOError => e
|
|
62
|
+
{ error: e.message }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param before [String, nil] nil for a file that didn't exist
|
|
66
|
+
# @return [Hash, nil] nil when there's nothing to show (same text, or
|
|
67
|
+
# the after side isn't a readable text file)
|
|
68
|
+
def change(before, after)
|
|
69
|
+
return nil unless after.is_a?(String)
|
|
70
|
+
return nil unless before.nil? || before.is_a?(String)
|
|
71
|
+
return nil if before == after
|
|
72
|
+
|
|
73
|
+
TextDiff.unified(before.to_s, after).merge(new_file: before.nil?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def skip_reason(text)
|
|
77
|
+
return "file over 1 MB" if text.bytesize > MAX_FILE_BYTES
|
|
78
|
+
|
|
79
|
+
"binary file" if text.byteslice(0, SNIFF_BYTES).include?("\0")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|