samagotchi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -11,18 +11,185 @@
11
11
  # before_turn resets them (a new user message can make an old call right
12
12
  # again); steering merged mid-turn does not.
13
13
  #
14
+ # It also watches the model's thinking while it streams (the
15
+ # :generation_progress hook): thinking that goes round in the same few
16
+ # sentences is cut (stop_generation) and the model asked again; if the
17
+ # retry loops too, the turn is stopped with a card. See ThinkingWatch.
18
+ #
14
19
  # Settings (config.yml, bundles: loop-guard:):
15
20
  # deny_after: 2 same call + same result this many times -> deny the next
16
21
  # stop_after: 4 stop the turn at this many loop-guard denies
17
22
  # ignore_tools: [task_wait, task_get, delegate_result, list_sessions, list_reminders]
18
23
  # mode: deny deny | notify (warn once per call, never deny or stop)
24
+ # thinking: the thinking watch (ThinkingWatch::DEFAULTS)
25
+ # watch: true false: the tool-call guard only
26
+ # action: retry retry (cut, ask again; then stop) | stop | notify
19
27
  require "digest"
20
28
 
29
+ # Sees thinking repeat itself, from the deltas of one generation.
30
+ #
31
+ # The thinking is cut into sentences (at . ! ? and newlines; a run-on over
32
+ # MAX_CARRY chars at its last space). Each sentence of min_words or more is
33
+ # normalized (lower case, letters and digits). Two sentences are alike when
34
+ # the mean of their word-set and word-bigram-set overlaps (Jaccard) is at
35
+ # least `similarity`: a reworded one ("Hmm, …", a synonym, a swapped
36
+ # clause) scores 0.4-0.9, two different ones rarely over 0.15. Two ways to
37
+ # see a loop:
38
+ # - a cycle: for each period p up to max_period, run[p] counts sentences in
39
+ # a row alike the one p before. A cycle seen `repeats` times (run[p] >=
40
+ # p * (repeats - 1)) is a loop once the looping stretch (run[p] + p
41
+ # sentences) spans min_span_sentences and min_span_chars. Templated
42
+ # sentences ("Now I need to open the file <path> and …") are alike too,
43
+ # and an enumeration of them makes every period's run grow, so: a period
44
+ # of 2 or more needs a cycle of distinct sentences (not all alike), and a
45
+ # period of 1 (one sentence over and over) needs `similarity` +
46
+ # SAME_MARGIN (0.9 by default: near-identical);
47
+ # - the same sentence max_same times in the generation, in any order.
48
+ # Nothing triggers before min_chars of thinking. O(new chars) per feed.
49
+ class ThinkingWatch
50
+ DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
51
+ "similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
52
+ "min_words" => 5 }.freeze
53
+ ACTIONS = %w[retry stop notify].freeze
54
+ MAX_CARRY = 400
55
+ # One sentence again and again needs this much more alike than a cycle.
56
+ SAME_MARGIN = 0.4
57
+ MAX_DISTINCT = 5000
58
+ SPLIT = /(?<=[.!?])\s+|\n+/
59
+
60
+ Loop = Struct.new(:period, :times, :sentences, :chars, keyword_init: true)
61
+ Sentence = Struct.new(:text, :words, :bigrams, :key, :length, keyword_init: true)
62
+
63
+ attr_reader :thinking_chars
64
+
65
+ # @param settings [Hash] the thinking: settings (string keys), over DEFAULTS
66
+ def self.settings(raw)
67
+ raw = {} unless raw.is_a?(Hash)
68
+ out = DEFAULTS.dup
69
+ out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
70
+ out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
71
+ %w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words].each do |key|
72
+ number = Integer(raw[key].to_s, exception: false)
73
+ out[key] = number if number&.positive?
74
+ end
75
+ similarity = Float(raw["similarity"].to_s, exception: false)
76
+ out["similarity"] = similarity if similarity && similarity.positive? && similarity <= 1
77
+ out["repeats"] = 2 if out["repeats"] < 2
78
+ out
79
+ end
80
+
81
+ def initialize(settings = DEFAULTS)
82
+ @min_chars = settings["min_chars"]
83
+ @repeats = settings["repeats"]
84
+ @max_period = settings["max_period"]
85
+ @similarity = settings["similarity"]
86
+ @min_span_sentences = settings["min_span_sentences"]
87
+ @min_span_chars = settings["min_span_chars"]
88
+ @max_same = settings["max_same"]
89
+ @min_words = settings["min_words"]
90
+ @same_similarity = [@similarity + SAME_MARGIN, 0.95].min
91
+ @carry = +""
92
+ @window = [] # the last max_period + 1 sentences
93
+ @run = Array.new(@max_period + 1, 0)
94
+ @run_chars = Array.new(@max_period + 1, 0) # chars of the run's sentences plus the first cycle's
95
+ @counts = Hash.new(0)
96
+ @thinking_chars = 0
97
+ end
98
+
99
+ # @param delta [String] new thinking
100
+ # @return [Loop, nil] the loop, once it is seen
101
+ def feed(delta)
102
+ delta = delta.to_s
103
+ @thinking_chars += delta.length
104
+ @carry << delta
105
+ found = nil
106
+ sentences.each do |text|
107
+ loop_seen = add(text)
108
+ found ||= loop_seen
109
+ end
110
+ found
111
+ end
112
+
113
+ private
114
+
115
+ # The complete sentences in the carry; the unfinished tail stays.
116
+ def sentences
117
+ parts = @carry.split(SPLIT, -1)
118
+ @carry = parts.pop.to_s
119
+ if @carry.length > MAX_CARRY
120
+ cut = @carry.rindex(" ", MAX_CARRY) || MAX_CARRY
121
+ parts << @carry[0, cut]
122
+ @carry = @carry[cut..].to_s.lstrip
123
+ end
124
+ parts
125
+ end
126
+
127
+ def add(text)
128
+ words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
129
+ return nil if words.length < @min_words
130
+
131
+ key = words.join(" ")
132
+ sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
133
+ bigrams: words.each_cons(2).map { |pair| pair.join(" ").hash }.uniq, key: key, length: text.length)
134
+ same = count(key)
135
+ cycle = cycle_loop(sentence)
136
+ @window << sentence
137
+ @window.shift if @window.length > @max_period + 1
138
+ return nil if @thinking_chars < @min_chars
139
+
140
+ cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
141
+ end
142
+
143
+ def count(key)
144
+ return @counts[key] += 1 if @counts.key?(key) || @counts.size < MAX_DISTINCT
145
+
146
+ 0
147
+ end
148
+
149
+ def cycle_loop(sentence)
150
+ found = nil
151
+ (1..@max_period).each do |p|
152
+ before = @window[-p]
153
+ if before && similarity(sentence, before) >= (p == 1 ? @same_similarity : @similarity)
154
+ # The run starts after one whole cycle: its chars count too.
155
+ @run_chars[p] = @window.last(p).sum(&:length) if @run[p].zero?
156
+ @run[p] += 1
157
+ @run_chars[p] += sentence.length
158
+ else
159
+ @run[p] = 0
160
+ @run_chars[p] = 0
161
+ next
162
+ end
163
+ next if found || @run[p] < p * (@repeats - 1)
164
+ next if @run[p] + p < @min_span_sentences || @run_chars[p] < @min_span_chars
165
+
166
+ cycle = @window.last(p - 1) + [sentence]
167
+ next if p > 1 && cycle.each_cons(2).all? { |a, b| similarity(a, b) >= @similarity }
168
+
169
+ found = Loop.new(period: p, times: (@run[p] + p) / p, sentences: cycle.map(&:text), chars: @run_chars[p])
170
+ end
171
+ found
172
+ end
173
+
174
+ def similarity(a, b)
175
+ return 1.0 if a.key == b.key
176
+
177
+ (jaccard(a.words, b.words) + jaccard(a.bigrams, b.bigrams)) / 2
178
+ end
179
+
180
+ def jaccard(a, b)
181
+ shared = (a & b).size
182
+ all = a.size + b.size - shared
183
+ all.zero? ? 0.0 : shared.to_f / all
184
+ end
185
+ end
186
+
21
187
  class Plugin
22
188
  DEFAULT_IGNORE = %w[task_wait task_get delegate_result list_sessions list_reminders].freeze
23
189
  # Built-in tools carry flat fields; plugin and unknown tools carry args:.
24
190
  KEY_FIELDS = %i[content path start_line end_line cwd env scope].freeze
25
191
  SHORT_CHARS = 60
192
+ THOUGHT_CHARS = 80
26
193
  SOURCE = "bundle loop-guard"
27
194
 
28
195
  def initialize(settings = {})
@@ -31,6 +198,7 @@ class Plugin
31
198
  @stop_after = positive(settings["stop_after"]) || 4
32
199
  @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
33
200
  @mode = settings["mode"].to_s == "notify" ? :notify : :deny
201
+ @thinking = ThinkingWatch.settings(settings["thinking"])
34
202
  reset
35
203
  end
36
204
 
@@ -38,10 +206,52 @@ class Plugin
38
206
  chi.on(:before_turn) { |_event| reset }
39
207
  chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
40
208
  chi.on(:after_tool_call) { |event| after(event) }
209
+ return unless @thinking["watch"]
210
+
211
+ chi.on(:before_generation) do |_event|
212
+ @watch = ThinkingWatch.new(@thinking)
213
+ @watch_done = false
214
+ end
215
+ chi.on(:generation_progress) { |event, ctx| progress(event, ctx) }
41
216
  end
42
217
 
43
218
  private
44
219
 
220
+ # A stretch of streamed thinking. The first loop in a turn is cut and the
221
+ # model asked again (action retry); a second one, or action stop, stops
222
+ # the turn with a card; action notify only warns, once per generation.
223
+ def progress(event, ctx)
224
+ return if @watch.nil? || @watch_done
225
+
226
+ found = @watch.feed(event[:thinking])
227
+ return unless found
228
+
229
+ @watch_done = true
230
+ what = "#{found.period == 1 ? "one sentence" : "#{found.period} sentences"} ×#{found.times}, " \
231
+ "#{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
232
+ case @thinking["action"]
233
+ when "notify"
234
+ ctx.notify("thinking repeats itself (#{what})", level: :warn)
235
+ when "retry"
236
+ @thinking_loops += 1
237
+ return stop_thinking(event, ctx, found, what) if @thinking_loops > 1
238
+
239
+ ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if event[:stop_generation]&.call("its thinking kept repeating itself")
240
+ else
241
+ stop_thinking(event, ctx, found, what)
242
+ end
243
+ end
244
+
245
+ def stop_thinking(event, ctx, found, what)
246
+ return unless event[:stop_turn]&.call("the model's thinking kept repeating itself (#{what})")
247
+
248
+ lines = found.sentences.first(3).map { |sentence| "- \"#{cut(sentence, THOUGHT_CHARS)}\"" }
249
+ ctx.card(title: "loop-guard stopped the turn",
250
+ body: "The model's thinking kept repeating itself:\n\n#{lines.join("\n")}\n\n" \
251
+ "Try: ask for a smaller step, or change the model's sampling (`sampling:` in config.yml).",
252
+ level: :warn)
253
+ end
254
+
45
255
  def reset
46
256
  @last_result = {} # key => result hash
47
257
  @results = Hash.new(0) # [key, result hash] => times it ran with that result
@@ -50,6 +260,8 @@ class Plugin
50
260
  @warned = {} # key => true once notified this turn
51
261
  @denials = 0
52
262
  @pending = nil
263
+ @thinking_loops = 0
264
+ @watch = nil
53
265
  end
54
266
 
55
267
  def before(event, ctx)
@@ -147,8 +359,8 @@ class Plugin
147
359
  text.empty? ? "no output" : cut(text)
148
360
  end
149
361
 
150
- def cut(text)
151
- text.length > SHORT_CHARS ? "#{text[0, SHORT_CHARS - 1]}…" : text
362
+ def cut(text, max = SHORT_CHARS)
363
+ text.length > max ? "#{text[0, max - 1]}…" : text
152
364
  end
153
365
 
154
366
  def positive(value)
@@ -92,6 +92,9 @@ bundles:
92
92
  deny_after: 2 # same call + same result N times in a turn -> deny the next
93
93
  stop_after: 4 # stop the turn at this many denies
94
94
  mode: deny # deny | notify (warn only); ignore_tools: [task_wait, ...]
95
+ thinking: # the watch on thinking that repeats itself
96
+ watch: true # false: the tool-call guard only
97
+ action: retry # retry (cut, ask again; then stop) | stop | notify
95
98
  check-in:
96
99
  after: 50 # tool calls in one turn with no answer before the first check
97
100
  every: 50 # then again every N more
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.5.0
3
+ version: 0.6.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:b7d59a1e824f0918c28a38d40105e8abd1ff68ebab1810fd1dcf52cd3b72e7c0
8
- self_map.md: sha256:59ca83f92d11bc6bdc69f3111b3b8e1189280499ae955b10043ff993387b4550
9
- config_modification_protocol.md: sha256:71ff60549574c4be7d4c70801582a19d5d9ec6d5be87f5867f9aa113e5484f21
8
+ self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
+ config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
10
10
  memory_guide.md: sha256:43be77156562f1eac369c248045e35ccd89e87d1f7f95a9286be72521b058e87
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -32,7 +32,8 @@
32
32
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
33
33
  `mcp` (MCP server tools; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
34
34
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
35
- (denies a repeated tool call with the same result, stops the turn after a few), `check-in` (after N tool
35
+ (denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
36
+ repeats itself, asking me again), `check-in` (after N tool
36
37
  calls with no answer, a card asks the user to nudge me, let me go on or stop; `/checkin`), `skills` (`/skill save|list|show|diff`,
37
38
  keeps older versions of `skill_*` memories). `chi bundle list`
38
39
  shows installed + available; `chi bundle install <name>`. Settings: config.yml `bundles: <name>:`
@@ -5,22 +5,31 @@ module Samagotchi
5
5
  # calls every listener registered with #on_cancel (a listener added after
6
6
  # the cancel runs at once). The HTTP layer listens to abort an in-flight
7
7
  # request; the loops poll #cancelled? between steps.
8
+ #
9
+ # #generation gives one model request a child controller: cancelled with
10
+ # the turn, or alone by #cancel_generation! (a plugin cutting a looping
11
+ # generation while the turn goes on).
8
12
  class CancellationController
9
13
  def initialize
10
14
  @mutex = Mutex.new
11
15
  @cancelled = false
12
16
  @reason = nil
17
+ @detail = nil
13
18
  @listeners = {}
14
19
  @next_listener_id = 0
20
+ @generation = nil
15
21
  end
16
22
 
17
- def cancel!(reason = :manual)
23
+ # @param detail [Object, nil] what the canceller says about it (a cut
24
+ # generation's {by:, reason:})
25
+ def cancel!(reason = :manual, detail = nil)
18
26
  listeners = []
19
27
  @mutex.synchronize do
20
28
  return false if @cancelled
21
29
 
22
30
  @cancelled = true
23
31
  @reason = reason
32
+ @detail = detail
24
33
  listeners = @listeners.values
25
34
  @listeners = {}
26
35
  end
@@ -41,6 +50,38 @@ module Samagotchi
41
50
  @mutex.synchronize { @reason }
42
51
  end
43
52
 
53
+ def detail
54
+ @mutex.synchronize { @detail }
55
+ end
56
+
57
+ # Yields a child controller for one generation: it is cancelled when
58
+ # this one is, and #cancel_generation! cancels it alone. Gone after the
59
+ # block.
60
+ def generation
61
+ child = CancellationController.new
62
+ listener_id = on_cancel { |reason| child.cancel!(reason) }
63
+ @mutex.synchronize { @generation = child }
64
+ yield child
65
+ ensure
66
+ @mutex.synchronize { @generation = nil if @generation.equal?(child) }
67
+ remove_listener(listener_id)
68
+ end
69
+
70
+ # The running generation was cancelled (cut, or with the turn).
71
+ def generation_cancelled?
72
+ child = @mutex.synchronize { @generation }
73
+ child ? child.cancelled? : false
74
+ end
75
+
76
+ # Cancel the running generation only. False with none, or when it was
77
+ # already cancelled (cut before, or the turn was cancelled).
78
+ def cancel_generation!(reason = :hook, detail = nil)
79
+ child = @mutex.synchronize { @generation }
80
+ return false unless child
81
+
82
+ child.cancel!(reason, detail)
83
+ end
84
+
44
85
  def on_cancel(&block)
45
86
  raise ArgumentError, "block required" unless block
46
87
 
@@ -148,8 +148,8 @@ module Samagotchi
148
148
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
149
149
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
150
150
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
151
- # The page's view of a turn: turn (one block of generations, the live one at the bottom), stage (the running turn pinned above the composer) or chat (a row of bubbles); ?view= overrides it per page load.
152
- Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "turn", expose: %i[env config cli], enum_values: %w[turn stage chat]),
151
+ # The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
152
+ Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn]),
153
153
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
154
154
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
155
155
 
@@ -83,20 +83,23 @@ final class ChiRunner {
83
83
  static let launchPath = supportDir + "/launch.json"
84
84
 
85
85
  let timeout: TimeInterval
86
+ let launchPath: String
86
87
 
87
- init(timeout: TimeInterval = 10) {
88
+ /// @param launchPath another launch file (a test harness's), not the app's
89
+ init(timeout: TimeInterval = 10, launchPath: String = ChiRunner.launchPath) {
88
90
  self.timeout = timeout
91
+ self.launchPath = launchPath
89
92
  }
90
93
 
91
94
  func loadLaunch() throws -> LaunchConfig {
92
- guard let data = FileManager.default.contents(atPath: Self.launchPath) else {
93
- throw ChiError.noLaunchFile(Self.launchPath)
95
+ guard let data = FileManager.default.contents(atPath: launchPath) else {
96
+ throw ChiError.noLaunchFile(launchPath)
94
97
  }
95
98
  let config: LaunchConfig
96
99
  do { config = try JSONDecoder().decode(LaunchConfig.self, from: data) } catch {
97
- throw ChiError.failed("Unreadable launch file \(Self.launchPath): \(error.localizedDescription)")
100
+ throw ChiError.failed("Unreadable launch file \(launchPath): \(error.localizedDescription)")
98
101
  }
99
- guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(Self.launchPath)") }
102
+ guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(launchPath)") }
100
103
  for path in config.argv where path.hasPrefix("/") && !FileManager.default.fileExists(atPath: path) {
101
104
  throw ChiError.missing(path)
102
105
  }
@@ -188,6 +191,18 @@ final class ChiRunner {
188
191
  }
189
192
  }
190
193
 
194
+ /// The model a new session starts on (`chi self --model`: the config's
195
+ /// default, as chi resolves it), or nil when none is configured or chi
196
+ /// can't say. Only a single line counts: nothing that looks like a whole
197
+ /// `chi self` report ends up as the hint.
198
+ func defaultModel(completion: @escaping (String?) -> Void) {
199
+ run(["self", "--model"]) { result in
200
+ guard case .success(let r) = result, r.status == 0, !r.timedOut else { completion(nil); return }
201
+ let model = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
202
+ completion(model.isEmpty || model.contains("\n") ? nil : model)
203
+ }
204
+ }
205
+
191
206
  private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
192
207
  // Every project's sessions: the helper isn't in any one project (an
193
208
  // older chi ignores the flag).
@@ -18,10 +18,17 @@ final class PanelModel: ObservableObject {
18
18
  @Published var source = ""
19
19
  /// Sent as `--image` with a message; notes are text only.
20
20
  @Published var images: [PanelImage] = []
21
+ /// The thumbnail under the pointer (shows its ✕). Kept here, not in a
22
+ /// @State: some Command Line Tools SDKs want the SwiftUIMacros plugin for
23
+ /// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
24
+ @Published var hoveredImage: UUID?
21
25
  @Published var sessions: [LiveSession] = []
22
26
  @Published var selected: Set<String> = []
23
27
  /// The "New session" row; never together with sessions (`--new` takes no ids).
24
28
  @Published var newSelected = false
29
+ /// The model a new session starts on (`chi self --model`), for the new
30
+ /// row's hint; nil while unknown.
31
+ @Published var newModel: String?
25
32
  @Published var phase: Phase = .loading
26
33
  @Published var message = ""
27
34
 
@@ -48,8 +55,13 @@ final class PanelModel: ObservableObject {
48
55
 
49
56
  func remove(_ image: PanelImage) {
50
57
  images.removeAll { $0 == image }
58
+ if hoveredImage == image.id { hoveredImage = nil }
51
59
  ImageIntake.delete([image])
52
60
  }
61
+ /// Leaving one thumbnail may come after entering the next.
62
+ func hover(_ image: PanelImage, _ inside: Bool) {
63
+ if inside { hoveredImage = image.id } else if hoveredImage == image.id { hoveredImage = nil }
64
+ }
53
65
  var recent: [LiveSession] { sessions.filter(\.recent) }
54
66
 
55
67
  /// The last choice, where still live; else the only live session; else,
@@ -145,7 +157,10 @@ struct PanelView: View {
145
157
  ScrollView(.horizontal, showsIndicators: false) {
146
158
  HStack(spacing: 8) {
147
159
  ForEach(model.images) { image in
148
- Thumbnail(image: image, removable: model.phase != .sending) { model.remove(image) }
160
+ Thumbnail(image: image, removable: model.phase != .sending, hover: model.hoveredImage == image.id,
161
+ onHover: { model.hover(image, $0) }) {
162
+ model.remove(image)
163
+ }
149
164
  }
150
165
  }
151
166
  .padding(.horizontal, 19).padding(.vertical, 2)
@@ -193,6 +208,12 @@ struct PanelView: View {
193
208
  Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
194
209
  }
195
210
  Spacer()
211
+ if let name = model.newModel {
212
+ Text(name).font(.system(size: 11)).foregroundColor(.secondary)
213
+ .lineLimit(1).truncationMode(.middle)
214
+ .frame(maxWidth: 240, alignment: .trailing)
215
+ .help("the model a new session starts on: default.model in config.yml")
216
+ }
196
217
  Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
197
218
  }
198
219
  .padding(.horizontal, 8).padding(.vertical, 6)
@@ -280,8 +301,9 @@ struct PanelView: View {
280
301
  struct Thumbnail: View {
281
302
  let image: PanelImage
282
303
  let removable: Bool
304
+ let hover: Bool
305
+ let onHover: (Bool) -> Void
283
306
  let remove: () -> Void
284
- @State private var hover = false
285
307
 
286
308
  var body: some View {
287
309
  Group {
@@ -308,7 +330,7 @@ struct Thumbnail: View {
308
330
  .help("remove")
309
331
  }
310
332
  }
311
- .onHover { hover = $0 }
333
+ .onHover(perform: onHover)
312
334
  .help(image.name)
313
335
  }
314
336
  }
@@ -419,6 +441,7 @@ final class PanelController: NSObject, NSWindowDelegate {
419
441
  placeOnActiveScreen()
420
442
  panel.makeKeyAndOrderFront(nil)
421
443
  loadSessions()
444
+ loadModel()
422
445
  }
423
446
 
424
447
  private func placeOnActiveScreen() {
@@ -446,6 +469,11 @@ final class PanelController: NSObject, NSWindowDelegate {
446
469
  }
447
470
  }
448
471
 
472
+ /// Kept from the last look while it runs; config rarely changes.
473
+ private func loadModel() {
474
+ runner.defaultModel { [weak self] model in self?.model.newModel = model }
475
+ }
476
+
449
477
  func send(_ kind: SendKind) {
450
478
  let ids = model.selectedIds
451
479
  let new = model.newSelected