samagotchi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "formatting"
5
+ require_relative "../output_formatter"
6
+ require_relative "../turn_tally"
7
+ require_relative "thinking_line"
8
+
9
+ module Samagotchi
10
+ class TerminalUI
11
+ # EventRenderer's view in attached mode, drawn on a Screen: the
12
+ # thinking feedback is the activity slot, one row right above the prompt
13
+ # (spinner frame, then the running tool or the newest sentence of the
14
+ # model's thinking or text, ThinkingLine),
15
+ # and every finished line is committed output. The local REPL's
16
+ # multi-line spinner redraws in place, which can't share the terminal
17
+ # with an open prompt.
18
+ #
19
+ # The spinner turns with time, not per chunk: while the slot is shown a
20
+ # ticker thread redraws it every TICK_INTERVAL, so a turn that gets no
21
+ # chunks (a queued remote request, a long prompt eval) still looks alive,
22
+ # and after WAIT_NOTICE_AFTER seconds with no chunk it says how long it
23
+ # has been waiting for the first token.
24
+ #
25
+ # From a turn's TurnTally::MIN_CALLS-th tool call, a second, dim row under
26
+ # it tallies the turn's calls (TurnTally).
27
+ class AttachedView
28
+ include Formatting
29
+
30
+ FRAMES = ["|", "/", "-", "\\"].freeze
31
+ # Chunks arrive faster than a status line is worth redrawing.
32
+ MIN_REDRAW_INTERVAL = 0.08
33
+ # Seconds per spinner frame.
34
+ FRAME_INTERVAL = 0.25
35
+ # Seconds between the ticker's redraws (specs stub it to nil: no thread).
36
+ TICK_INTERVAL = FRAME_INTERVAL
37
+ WAIT_NOTICE_AFTER = 2.0
38
+
39
+ attr_reader :context_status
40
+
41
+ # @param screen [Surface] with #columns (a Screen)
42
+ # @param tick_interval [Numeric, nil] seconds between the ticker's
43
+ # redraws; nil: no ticker thread (specs call #tick)
44
+ def initialize(screen, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, tick_interval: TICK_INTERVAL)
45
+ @screen = screen
46
+ @clock = clock
47
+ @tick_interval = tick_interval
48
+ @origin = clock.call
49
+ @lock = Monitor.new
50
+ @ticker = nil
51
+ @tally = TurnTally.new
52
+ @line = ThinkingLine.new(clock: clock)
53
+ # Plugins' init tasks running now (chi.init), id => "bundle: label".
54
+ @init_tasks = {}
55
+ reset_turn_feedback
56
+ end
57
+
58
+ def print_line(text)
59
+ @screen.commit(text)
60
+ end
61
+
62
+ # A new turn: its tally starts over too.
63
+ def reset_turn_feedback
64
+ @lock.synchronize do
65
+ reset_feedback_state
66
+ @tally.reset
67
+ end
68
+ end
69
+
70
+ # @param _event [Hash] :generation_started (its context window isn't
71
+ # shown: the status line is one row)
72
+ def generation_feedback_started(_event = {})
73
+ @lock.synchronize do
74
+ @thinking = true
75
+ @line.reset
76
+ @tool = nil
77
+ @waiting_since = @clock.call
78
+ redraw_status
79
+ end
80
+ end
81
+
82
+ def generation_feedback_retrying(event)
83
+ @lock.synchronize do
84
+ @retry = "retrying (attempt #{event[:attempt]})"
85
+ # The retry streams from the start: wait for its first token again.
86
+ @waiting_since = @clock.call if @thinking
87
+ redraw_status
88
+ end
89
+ end
90
+
91
+ def generation_feedback_chunk(event)
92
+ @lock.synchronize do
93
+ @retry = nil
94
+ @waiting_since = nil
95
+ @line.chunk(event)
96
+ redraw_status(throttle: true)
97
+ end
98
+ end
99
+
100
+ def tool_call_feedback_started(event)
101
+ @lock.synchronize do
102
+ @retry = nil
103
+ @waiting_since = nil
104
+ @tool = "running #{event[:tool]}"
105
+ @tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
106
+ redraw_status
107
+ end
108
+ end
109
+
110
+ def tool_call_feedback_completed(event)
111
+ @lock.synchronize do
112
+ @tally.completed(key: tally_key(event), tool: event[:tool],
113
+ status: event.dig(:activity, :status), params: event.dig(:activity, :params))
114
+ end
115
+ end
116
+
117
+ def clear_generation_retry
118
+ @lock.synchronize { @retry = nil }
119
+ end
120
+
121
+ def generation_feedback_finished
122
+ @lock.synchronize do
123
+ @thinking = false
124
+ @line.reset
125
+ @waiting_since = nil
126
+ redraw_status
127
+ end
128
+ end
129
+
130
+ # The turn's answer (or a merge's) is out: the slot goes (unless a
131
+ # plugin's init task still runs). The tally keeps counting until the
132
+ # next turn starts (a merge goes on).
133
+ def finish_thinking_spinner
134
+ @lock.synchronize do
135
+ reset_feedback_state
136
+ @init_tasks.empty? ? @screen.clear_slot(:activity) : redraw_status
137
+ end
138
+ end
139
+
140
+ # A plugin's init task runs (between turns too): the slot turns with
141
+ # its label until it ends.
142
+ # @param task [Hash] {bundle:, id:, label:} (an event or a snapshot's)
143
+ def init_started(task)
144
+ @lock.synchronize do
145
+ @init_tasks[task[:id].to_s] = "#{task[:bundle]}: #{task[:label]}"
146
+ redraw_status
147
+ end
148
+ end
149
+
150
+ def init_finished(task)
151
+ @lock.synchronize do
152
+ @init_tasks.delete(task[:id].to_s)
153
+ redraw_status
154
+ end
155
+ end
156
+
157
+ # The turn waits for them: the slot already shows them.
158
+ def init_wait_feedback(_event); end
159
+
160
+ # Pick up a turn joined mid-way (from the Bridge snapshot): the model's
161
+ # thinking or text so far (+lane+ :thinking or :writing), or the tool it
162
+ # is running, and the tally of its tool parts.
163
+ def resume(tail: nil, lane: :writing, tool: nil, parts: nil)
164
+ @lock.synchronize do
165
+ @tally.reset.seed(parts) if parts
166
+ @thinking = true
167
+ @line.reset
168
+ @line.resume(lane, tail)
169
+ @tool = tool && "running #{tool}"
170
+ @waiting_since = nil
171
+ redraw_status
172
+ end
173
+ end
174
+
175
+ # Redraw the slot for the time that passed (the ticker calls it).
176
+ # @return [Boolean] whether the slot is still shown
177
+ def tick
178
+ @lock.synchronize do
179
+ return false unless status_text
180
+
181
+ redraw_status
182
+ true
183
+ end
184
+ end
185
+
186
+ # End the ticker (the loop is over; nothing may draw after it).
187
+ def stop
188
+ ticker = @lock.synchronize { @ticker.tap { @ticker = nil } }
189
+ ticker&.kill
190
+ ticker&.join(0.5)
191
+ end
192
+
193
+ def capture_context_status(status)
194
+ @context_status = status if status
195
+ end
196
+
197
+ # The REPL's sticky memories line has no source in attached mode.
198
+ def emit_active_memories_line; end
199
+
200
+ private
201
+
202
+ def reset_feedback_state
203
+ @thinking = false
204
+ @line.reset
205
+ @tool = nil
206
+ @retry = nil
207
+ @waiting_since = nil
208
+ end
209
+
210
+ def tally_key(event)
211
+ [event[:iteration].to_i, event[:call_index].to_i]
212
+ end
213
+
214
+ def redraw_status(throttle: false)
215
+ now = @clock.call
216
+ return if throttle && @last_redraw && (now - @last_redraw) < MIN_REDRAW_INTERVAL
217
+
218
+ @last_redraw = now
219
+ @line.tick
220
+ text = status_text
221
+ if text
222
+ tally = @tally.text(width: @screen.columns - 1)
223
+ @screen.set_slot(:activity, tally ? [text, paint(tally, 90)] : [text])
224
+ start_ticker
225
+ else
226
+ @screen.clear_slot(:activity)
227
+ end
228
+ end
229
+
230
+ # One thread per shown slot; it ends when the slot goes (see #tick).
231
+ def start_ticker
232
+ return unless @tick_interval
233
+ return if @ticker&.alive?
234
+
235
+ @ticker = Thread.new do
236
+ loop do
237
+ sleep(@tick_interval)
238
+ break unless tick
239
+ end
240
+ rescue StandardError
241
+ nil
242
+ end
243
+ @ticker.report_on_exception = false
244
+ end
245
+
246
+ def status_text
247
+ now = @clock.call
248
+ frame = FRAMES[((now - @origin) / FRAME_INTERVAL).floor % FRAMES.length]
249
+ return "#{frame} #{@retry}" if @retry
250
+ return "#{frame} #{@tool}…" if @tool
251
+ return "#{frame} #{@init_tasks.values.join(" · ")}…" if !@thinking && @init_tasks.any?
252
+ return nil unless @thinking
253
+
254
+ waited = @waiting_since ? now - @waiting_since : 0
255
+ return "#{frame} waiting for the first token… #{waited.floor}s" if waited >= WAIT_NOTICE_AFTER
256
+
257
+ return "#{frame} thinking…" if @line.empty?
258
+
259
+ prefix = "#{frame} #{@line.label} · "
260
+ prefix + paint(@line.fit(@screen.columns - 1 - prefix.length), 90)
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # Renders one UI's view of Engine turn events: the thinking spinner while
8
+ # the model generates, a `tool>` line per completed tool call, and the
9
+ # answer (with any tool activity not already shown) from :turn_completed's
10
+ # turn_summary. It is the `on_event:` sink the REPL hands Engine#run_turn.
11
+ #
12
+ # Drawing is delegated to a +view+ (the TerminalUI), so the same dispatch
13
+ # can later render events that arrive over the Bridge. The renderer keeps
14
+ # only per-turn bookkeeping that must come from the events themselves:
15
+ # which tool lines were already streamed, and when each tool call started.
16
+ # Events that came over the Bridge as JSON (string keys) are symbolized first.
17
+ class EventRenderer
18
+ def initialize(view, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @view = view
20
+ @clock = clock
21
+ @streamed_tool_activity = Hash.new(0)
22
+ @tool_started_at = {}
23
+ # Card ids shown so far (across turns): one shown again is updated.
24
+ @card_ids = Set.new
25
+ end
26
+
27
+ # Deep-convert a string-keyed event (JSON from the Bridge) to the local
28
+ # shape: symbol keys and a symbol :type. Other values stay as they are.
29
+ def self.symbolize(event)
30
+ event = deep_symbolize_keys(event)
31
+ event[:type] = event[:type].to_sym if event[:type].is_a?(String)
32
+ event
33
+ end
34
+
35
+ def self.deep_symbolize_keys(value)
36
+ case value
37
+ when Hash then value.to_h { |k, v| [k.is_a?(String) ? k.to_sym : k, deep_symbolize_keys(v)] }
38
+ when Array then value.map { |v| deep_symbolize_keys(v) }
39
+ else value
40
+ end
41
+ end
42
+
43
+ def call(event)
44
+ event = self.class.symbolize(event) if event.key?("type")
45
+ case event[:type]
46
+ when :turn_started
47
+ begin_turn
48
+ Array(event[:images]).each { |ref| @view.print_line(@view.format_image_line(ref)) }
49
+ when :generation_started
50
+ @view.generation_feedback_started(event)
51
+ when :generation_retrying
52
+ @view.generation_feedback_retrying(event)
53
+ when :generation_chunk
54
+ @view.generation_feedback_chunk(event)
55
+ when :tool_call_started
56
+ @tool_started_at[tool_call_key(event)] = @clock.call
57
+ @view.tool_call_feedback_started(event)
58
+ when :tool_call_completed
59
+ @view.clear_generation_retry
60
+ @view.tool_call_feedback_completed(event)
61
+ render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images])
62
+ when :generation_completed, :generation_cancelled, :tool_dispatch_started
63
+ @view.generation_feedback_finished
64
+ when :pending_input_merged
65
+ render_merge(event)
66
+ when :turn_completed
67
+ render_turn_summary(event[:turn_summary]) if event[:turn_summary]
68
+ when :guardrail_warning
69
+ @view.print_line(self.class.load_warning_line(event))
70
+ when :hook_notice
71
+ @view.print_line(self.class.hook_notice_line(event))
72
+ when :card
73
+ render_card(event)
74
+ # A turn waits for plugins' slow setup before its first request.
75
+ when :plugin_init_wait
76
+ @view.init_wait_feedback(event)
77
+ end
78
+ end
79
+
80
+ # Print a card (a :card event or a snapshot's card) as a framed block;
81
+ # one whose id was shown before is printed again, marked (updated).
82
+ # @param updated [Boolean] marked (updated) anyway (a snapshot's card)
83
+ def render_card(card, updated: false)
84
+ id = card[:id].to_s
85
+ updated ||= !id.empty? && @card_ids.include?(id)
86
+ @card_ids << id unless id.empty?
87
+ @view.print_line(@view.card_block(card, updated: updated))
88
+ end
89
+
90
+ # @return [Boolean] whether a card with this id was shown here
91
+ def card_shown?(id) = @card_ids.include?(id.to_s)
92
+
93
+ # What failed to load, labelled by what it is: guardrails (the
94
+ # default) or plugins.
95
+ def self.load_warning_line(event)
96
+ "#{event[:label] || "guardrails"}> #{event[:message]}"
97
+ end
98
+
99
+ # A hook's notice as one line: "<bundle>> text" for a bundle hook,
100
+ # "hook> text" for a config or turn hook (a warn one says so).
101
+ # @param event [Hash] :hook_notice event or a snapshot part with
102
+ # hook:, text:, level:
103
+ def self.hook_notice_line(event)
104
+ label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || "hook"
105
+ text = event[:text].to_s
106
+ text = "warning: #{text}" if event[:level].to_s == "warn"
107
+ "#{label}> #{text}"
108
+ end
109
+
110
+ # A plugin's init task (chi.init) as one line: "<bundle>> <label>…"
111
+ # as it starts, "<bundle>> ✓ <summary>" when it is done. A failed
112
+ # one is a warn card, not a line.
113
+ # @return [String, nil]
114
+ def self.init_line(event)
115
+ case event[:type].to_s
116
+ when "plugin_init_started" then "#{event[:bundle]}> #{event[:label]}…"
117
+ when "plugin_init_finished"
118
+ return nil unless event[:ok]
119
+
120
+ "#{event[:bundle]}> ✓ #{event[:summary] || "#{event[:label]}: done"}"
121
+ end
122
+ end
123
+
124
+ # Start a turn's bookkeeping and reset the view's per-turn feedback.
125
+ # Also called directly by REPL paths that drive the kernel without
126
+ # Engine#run_turn (so no :turn_started).
127
+ def begin_turn
128
+ @streamed_tool_activity = Hash.new(0)
129
+ @tool_started_at = {}
130
+ @view.reset_turn_feedback
131
+ end
132
+
133
+ # Render a finished turn: tool activity the stream did not already show,
134
+ # the status lines, the answer, and the iteration-limit notice.
135
+ # @param summary [Hash] Engine#turn_summary
136
+ def render_turn_summary(summary)
137
+ @view.finish_thinking_spinner
138
+ @view.capture_context_status(summary[:context_status])
139
+ Array(summary[:tool_activity]).each do |activity|
140
+ next if consume_streamed_tool_activity(activity)
141
+
142
+ @view.print_line(@view.format_tool_activity_line(activity))
143
+ end
144
+ @view.emit_active_memories_line
145
+ @view.print_line(summary[:output])
146
+ @view.print_line("iteration limit reached") if summary[:resumable]
147
+ end
148
+
149
+ # Steering merged into the running turn: the answer it follows first
150
+ # (the turn summary shows only the last one), then the note.
151
+ def render_merge(event)
152
+ @view.finish_thinking_spinner
153
+ @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
154
+ count = event[:count].to_i
155
+ @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)")
156
+ end
157
+
158
+ private
159
+
160
+ def render_streamed_tool_activity(activity, duration_ms:, images: nil)
161
+ return if activity.nil?
162
+
163
+ @streamed_tool_activity[tool_activity_key(activity)] += 1
164
+ line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
165
+ line += @view.format_tool_image_suffix(images) if images&.any?
166
+ @view.print_line(line)
167
+ end
168
+
169
+ def consume_streamed_tool_activity(activity)
170
+ key = tool_activity_key(activity)
171
+ count = @streamed_tool_activity[key]
172
+ return false unless count.positive?
173
+
174
+ @streamed_tool_activity[key] = count - 1
175
+ true
176
+ end
177
+
178
+ def tool_activity_key(activity)
179
+ [activity[:action], activity[:tool], activity[:params], activity[:status]].map(&:to_s).join("|")
180
+ end
181
+
182
+ def tool_call_key(event)
183
+ [event[:iteration].to_i, event[:call_index].to_i, event[:tool].to_s]
184
+ end
185
+
186
+ def tool_duration_ms(event)
187
+ started_at = @tool_started_at.delete(tool_call_key(event))
188
+ started_at && ((@clock.call - started_at) * 1000.0)
189
+ end
190
+ end
191
+ end
192
+ end