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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|
+
}
|