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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # Prepended to Reline::LineEditor: while a Screen is attached, Reline
8
+ # keeps doing all the input work (keys, history, completion dialogs,
9
+ # multiline, paste) but draws nothing itself. Its rendered rows go to the
10
+ # Screen's editor slot, and a finished prompt goes to scrollback through
11
+ # the Screen. With no Screen attached every method is Reline's own.
12
+ #
13
+ # This overrides private methods of reline 0.6.x (the Gemfile pins it).
14
+ # .supported? checks that they are all still there; without them attached
15
+ # mode falls back to plain output. What each override relies on:
16
+ # - render_differential(lines, cursor_x, cursor_y): called by #render
17
+ # with the rows it laid out; the seam hands them to the Screen and keeps
18
+ # @rendered_screen as if Reline had drawn them.
19
+ # - reset: asks the terminal for the cursor row (base_y), which decides
20
+ # whether a dialog opens below the prompt. The region's editor rows
21
+ # start at 0.
22
+ # - screen_height: the rows Reline may draw in, cut to the Screen's
23
+ # editor budget so the region fits (Reline scrolls the input inside it).
24
+ # Overriding the method, not @screen_size, survives reset and resizes.
25
+ # - update(key): every key a read takes, for the key handler.
26
+ # - render_finished (Enter), handle_interrupted (Ctrl-C), finalize (every
27
+ # read, including one dropped by Thread#raise), ed_clear_screen and its
28
+ # alias clear_screen (Ctrl-L), handle_resized (SIGWINCH, SIGCONT).
29
+ module RelineSeam
30
+ # Reline's methods the seam overrides or calls, with their arities.
31
+ METHODS = { reset: -1, finalize: 0, render_finished: 0, screen_height: 0, screen_width: 0,
32
+ prompt_list: 0, modified_lines: 0, clear_dialogs: 0, scroll_into_view: 0, render: 0,
33
+ render_differential: 3, handle_interrupted: 0, handle_resized: 0,
34
+ clear_rendered_screen_cache: 0, ed_clear_screen: 1, clear_screen: 1,
35
+ split_line_by_width: -3, update: 1 }.freeze
36
+
37
+ class << self
38
+ # @return [Screen, nil] where Reline draws, nil for Reline's own drawing
39
+ attr_reader :screen
40
+
41
+ # Asked first on Ctrl-C during a read, with or without a screen: a
42
+ # truthy answer means it handled the key (the REPL cancelled a running
43
+ # turn) and the read goes on with the typed text as it is.
44
+ # @return [#call, nil]
45
+ attr_accessor :interrupt_handler
46
+
47
+ # Called with no arguments for each key a read takes (typing is
48
+ # activity for the REPL's idle clock).
49
+ # @return [#call, nil]
50
+ attr_accessor :key_handler
51
+
52
+ # @return [Boolean] a Reline read is open (it owns stdin)
53
+ def reading? = @reading == true
54
+
55
+ # Run a read (on this thread) whose submitted line leaves nothing in
56
+ # the scrollback: the answer to a question, which commits its own
57
+ # summary line instead.
58
+ def without_echo
59
+ was = Thread.current[:samagotchi_reline_no_echo]
60
+ Thread.current[:samagotchi_reline_no_echo] = true
61
+ yield
62
+ ensure
63
+ Thread.current[:samagotchi_reline_no_echo] = was
64
+ end
65
+
66
+ # @api private
67
+ attr_writer :reading
68
+
69
+ # @return [Boolean] this Reline has every method the seam relies on
70
+ def supported?
71
+ defined?(Reline::LineEditor::RenderedScreen) &&
72
+ (%i[base_y lines cursor_y] - Reline::LineEditor::RenderedScreen.members).empty? &&
73
+ METHODS.all? { |name, arity| reline_method(name)&.arity == arity }
74
+ end
75
+
76
+ # Reline draws into +screen+ from its next render on.
77
+ def attach(screen)
78
+ # Measuring a character like … or │ makes Reline print one and ask
79
+ # the terminal where the cursor went, the first time only. Do it
80
+ # now, before the Screen measures rows from other threads while a
81
+ # read owns stdin.
82
+ Reline.ambiguous_width
83
+ install
84
+ @screen = screen
85
+ end
86
+
87
+ # Put the seam in Reline without a screen: Reline draws as usual, and
88
+ # reading? and the interrupt handler work.
89
+ def install
90
+ Reline::LineEditor.prepend(self) unless Reline::LineEditor.include?(self)
91
+ end
92
+
93
+ def detach(screen)
94
+ @screen = nil if @screen.equal?(screen)
95
+ end
96
+
97
+ private
98
+
99
+ # Reline's own implementation, past this module once it is prepended.
100
+ def reline_method(name)
101
+ method = Reline::LineEditor.instance_method(name)
102
+ method = method.super_method while method&.owner == self
103
+ method
104
+ rescue NameError
105
+ nil
106
+ end
107
+ end
108
+
109
+ def reset(...)
110
+ super
111
+ RelineSeam.reading = true
112
+ @rendered_screen.base_y = 0 if RelineSeam.screen
113
+ end
114
+
115
+ def update(key)
116
+ RelineSeam.key_handler&.call
117
+ super
118
+ end
119
+
120
+ def screen_height
121
+ screen = RelineSeam.screen
122
+ screen ? [super, screen.editor_budget].min : super
123
+ end
124
+
125
+ def render_finished
126
+ screen = RelineSeam.screen
127
+ return super unless screen
128
+
129
+ screen.finish_editor(Thread.current[:samagotchi_reline_no_echo] ? nil : seam_final_lines)
130
+ clear_rendered_screen_cache
131
+ end
132
+
133
+ # A read that ends without render_finished (dropped by Thread#raise,
134
+ # or an I/O error) leaves its prompt in the region: take it out.
135
+ def finalize
136
+ screen = RelineSeam.screen
137
+ if screen && !@rendered_screen.lines.empty?
138
+ screen.finish_editor
139
+ clear_rendered_screen_cache
140
+ end
141
+ RelineSeam.reading = false
142
+ super
143
+ end
144
+
145
+ private
146
+
147
+ def render_differential(new_lines, cursor_x, cursor_y)
148
+ screen = RelineSeam.screen
149
+ return super unless screen
150
+
151
+ cursor_y = cursor_y.clamp(0, [screen_height - 1, 0].max)
152
+ screen.draw_editor(new_lines, cursor_x, cursor_y)
153
+ @rendered_screen.lines = new_lines
154
+ @rendered_screen.cursor_y = cursor_y
155
+ end
156
+
157
+ # Ctrl-C: the interrupt handler's if it takes it. Otherwise the typed
158
+ # text stays in scrollback with ^C, then Reline's own handling of the
159
+ # trap it replaced (raise Interrupt by default).
160
+ def handle_interrupted
161
+ return unless @interrupted
162
+
163
+ if RelineSeam.interrupt_handler&.call
164
+ @interrupted = false
165
+ return
166
+ end
167
+ screen = RelineSeam.screen
168
+ return super unless screen
169
+
170
+ @interrupted = false
171
+ clear_dialogs
172
+ screen.finish_editor(seam_final_lines.tap { |lines| lines[-1] = "#{lines[-1]}^C" })
173
+ clear_rendered_screen_cache
174
+ case @old_trap
175
+ when "DEFAULT", "SYSTEM_DEFAULT" then raise Interrupt
176
+ when "IGNORE" then nil
177
+ when "EXIT" then exit
178
+ else @old_trap.call if @old_trap.respond_to?(:call)
179
+ end
180
+ end
181
+
182
+ def handle_resized
183
+ screen = RelineSeam.screen
184
+ return super unless screen
185
+ return unless @resized
186
+
187
+ @screen_size = Reline::IOGate.get_screen_size
188
+ @resized = false
189
+ scroll_into_view
190
+ clear_rendered_screen_cache
191
+ render
192
+ end
193
+
194
+ def ed_clear_screen(key)
195
+ screen = RelineSeam.screen
196
+ return super unless screen
197
+
198
+ screen.clear_screen
199
+ @screen_size = Reline::IOGate.get_screen_size
200
+ clear_rendered_screen_cache
201
+ end
202
+
203
+ # Reline's alias still points at its own ed_clear_screen.
204
+ def clear_screen(key) = ed_clear_screen(key)
205
+
206
+ # The prompt and the text as render_finished writes them: one line per
207
+ # input line, with a trailing space when a line fills the width exactly.
208
+ def seam_final_lines
209
+ @buffer_of_lines.size.times.map do |i|
210
+ line = Reline::Unicode.strip_non_printing_start_end(prompt_list[i]) + modified_lines[i]
211
+ split_line_by_width(line, screen_width).last.empty? ? "#{line} " : line
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "line_reader"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The REPL's input on a terminal: one LineReader keeps a prompt open for
9
+ # the whole session, turns included, and this routes what it reads. A
10
+ # line goes to a question waiting at the prompt (asked at ? ), to
11
+ # the running turn when its handler takes it (steering), else to the
12
+ # inbox the REPL loop takes its next line from.
13
+ class ReplInput
14
+ # @param prompt [#call] -> the prompt when no question waits
15
+ # @param read [#call] (prompt, prefill) -> line (nil = Ctrl-D)
16
+ # @param surface [Surface] the editor slot a dropped read leaves is
17
+ # cleared under its lock
18
+ def initialize(prompt:, read:, surface:)
19
+ @prompt = prompt
20
+ @read = read
21
+ @surface = surface
22
+ @inbox = Thread::Queue.new
23
+ # Lines a turn left over: they come before the inbox.
24
+ @front = []
25
+ @turn = nil
26
+ @lock = Monitor.new
27
+ @answers = nil
28
+ end
29
+
30
+ def start(prefill: nil)
31
+ @reader = LineReader.new(self, prompt: method(:prompt_text), read: @read, prefill: prefill).start
32
+ self
33
+ end
34
+
35
+ def open? = @reader&.alive? || false
36
+
37
+ def stop
38
+ @reader&.stop
39
+ @surface.clear_slot(:editor)
40
+ end
41
+
42
+ # From the reader thread: [:line, text] or [:interrupt, info].
43
+ def <<(item)
44
+ @lock.synchronize do
45
+ next @answers << item if @answers
46
+ taken = @turn && item.first == :line && @turn.call(item.last)
47
+ # Not now: the line goes back into the prompt.
48
+ next @reader&.prefill_next(item.last) if taken == :back
49
+ next if taken
50
+
51
+ @inbox << item
52
+ end
53
+ self
54
+ end
55
+
56
+ # @return [Array, nil] the next item, nil after +timeout+ seconds
57
+ def pop(timeout:)
58
+ @lock.synchronize { return @front.shift unless @front.empty? }
59
+ @inbox.pop(timeout: timeout)
60
+ end
61
+
62
+ # While a turn runs, +handler+ (line -> truthy when it took the line,
63
+ # :back to put it back into the prompt) sees each line first. Once it ends, +leftovers+ (-> lines it took that the
64
+ # turn never merged) come next, in order, before anything in the inbox.
65
+ def during_turn(handler, leftovers:)
66
+ @lock.synchronize { @turn = handler }
67
+ yield
68
+ ensure
69
+ @lock.synchronize do
70
+ @turn = nil
71
+ @front.concat(Array(leftovers.call).map { |line| [:line, line] })
72
+ end
73
+ end
74
+
75
+ def prompt_text
76
+ @lock.synchronize { @answers ? @choice_prompt : @prompt.call }
77
+ end
78
+
79
+ # Start the open read again when its prompt changed (a continue offer
80
+ # came or went); what was typed is kept.
81
+ def sync_prompt
82
+ return unless open?
83
+
84
+ with_surface_lock do
85
+ next if @reader.current == prompt_text
86
+
87
+ @surface.clear_slot(:editor)
88
+ @reader.reprompt(keep_text: true)
89
+ end
90
+ end
91
+
92
+ # Put +text+ into the open prompt, unless something is typed there.
93
+ # @return [Boolean] whether it went in
94
+ def prefill(text) = open? && @reader.prefill(text)
95
+
96
+ # A question answered at the open prompt: the lines submitted from now
97
+ # on go to it, at the ? prompt. What was typed at the prompt is put aside
98
+ # and comes back once the question closes.
99
+ # @yield [Thread::Queue] the answers ([:line, text] or [:interrupt, info])
100
+ # A prompt closed by Ctrl-D mid-turn (the REPL exits after the turn)
101
+ # opens for the question and closes again after it.
102
+ def ask(choice_prompt)
103
+ was_open = open?
104
+ typed = was_open ? @reader.typed_text : nil
105
+ answers = Thread::Queue.new
106
+ @lock.synchronize do
107
+ @answers = answers
108
+ @choice_prompt = choice_prompt
109
+ end
110
+ was_open ? reprompt : start
111
+ yield answers
112
+ ensure
113
+ @lock.synchronize { @answers = nil }
114
+ if !was_open
115
+ stop
116
+ elsif open?
117
+ reprompt(prefill: typed)
118
+ else
119
+ # Ctrl-D at the question ended the reader: start it again.
120
+ start(prefill: typed)
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def reprompt(prefill: nil)
127
+ with_surface_lock do
128
+ @surface.clear_slot(:editor)
129
+ @reader.reprompt(prefill: prefill)
130
+ end
131
+ end
132
+
133
+ def with_surface_lock(&block)
134
+ @surface.respond_to?(:synchronize) ? @surface.synchronize(&block) : yield
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "monitor"
5
+ require "reline"
6
+ require "stringio"
7
+ require_relative "surface"
8
+
9
+ module Samagotchi
10
+ class TerminalUI
11
+ # The only writer to the terminal while a live region is on (attached
12
+ # mode). The bottom of the screen is the live region, its slots top down:
13
+ # activity (spinner, thinking tail), editor (the Reline prompt and its
14
+ # completion dialog, fed by RelineSeam), then status, notes and hints.
15
+ # Everything above it is normal scrollback: no alternate screen, so
16
+ # scrolling and copy/paste stay the terminal's own.
17
+ #
18
+ # Every change is one frame under the lock: move up to the top of the
19
+ # region, clear to the end of the screen (ESC[J), print any committed
20
+ # text, and draw the region again with the cursor back in the editor.
21
+ # Committed text may wrap; the region's rows are clipped to the width,
22
+ # so the move up is exact and the region can't drift.
23
+ class Screen
24
+ include Surface
25
+
26
+ # The slots below the editor, and the order they give way in when
27
+ # the terminal is short: hints first, then notes, then status.
28
+ BELOW = %i[status notes hints].freeze
29
+ SYNC_BEGIN = "\e[?2026h\e[?25l"
30
+ SYNC_END = "\e[?25h\e[?2026l"
31
+
32
+ # @param out [IO]
33
+ # @param size [#call] -> [rows, columns] of the terminal
34
+ def initialize(out:, size: -> { IO.console&.winsize || [24, 80] })
35
+ @out = out
36
+ @size = size
37
+ @lock = Monitor.new
38
+ @slots = {}
39
+ @editor = []
40
+ @editor_cursor = [0, 0]
41
+ @cursor_row = 0
42
+ end
43
+
44
+ def synchronize(&) = @lock.synchronize(&)
45
+
46
+ # @return [Integer] terminal width in columns
47
+ def columns = [@size.call.last.to_i, 1].max
48
+
49
+ # @return [Integer] terminal height in rows
50
+ def rows = [@size.call.first.to_i, 1].max
51
+
52
+ # Print permanent output (may hold several lines) above the region.
53
+ def commit(text)
54
+ lines = text.to_s.split("\n", -1)
55
+ synchronize { frame { |buffer| lines.each { |line| buffer << line << "\r\n" } } }
56
+ nil
57
+ end
58
+
59
+ # The editor slot is Reline's: RelineSeam feeds it with #draw_editor.
60
+ def set_slot(name, rows) = set_slots(name => rows)
61
+
62
+ # One frame for all the slots given, none when nothing changed.
63
+ def set_slots(**rows_by_slot)
64
+ rows_by_slot = rows_by_slot.to_h do |name, rows|
65
+ check_slot!(name)
66
+ raise ArgumentError, "Reline draws the editor slot" if name == :editor
67
+
68
+ # Fitted content is laid out at each frame (#region).
69
+ [name, rows.respond_to?(:fit) ? rows : Array(rows).map { |row| one_row(row) }]
70
+ end
71
+ synchronize do
72
+ next if rows_by_slot.all? { |name, rows| slot(name).equal?(rows) || slot(name) == rows }
73
+
74
+ rows_by_slot.each do |name, rows|
75
+ rows.respond_to?(:empty?) && rows.empty? ? @slots.delete(name) : (@slots[name] = rows)
76
+ end
77
+ frame
78
+ end
79
+ nil
80
+ end
81
+
82
+ # Clearing the editor slot drops the prompt from the region (its read
83
+ # is about to be dropped and started again).
84
+ # @return [Boolean] whether the slot showed anything
85
+ def clear_slot(name)
86
+ check_slot!(name)
87
+ synchronize do
88
+ next finish_editor if name == :editor
89
+ next false unless @slots.key?(name)
90
+
91
+ @slots.delete(name)
92
+ frame
93
+ true
94
+ end
95
+ end
96
+
97
+ # Rows the editor may take (its prompt, wrapped input and completion
98
+ # dialog), so that the region fits on the screen with the activity and
99
+ # status rows. Notes and hints give way to the editor instead.
100
+ def editor_budget
101
+ synchronize { [rows - 1 - fitted(:activity, columns).size - fitted(:status, columns).size, 1].max }
102
+ end
103
+
104
+ # Reline rendered: +lines+ are its rows of [x, width, content] layers
105
+ # (prompt, input, dialog), the cursor is at (+cursor_x+, +cursor_y+)
106
+ # inside them.
107
+ def draw_editor(lines, cursor_x, cursor_y)
108
+ synchronize do
109
+ @editor = lines.map { |layers| compose(layers) }
110
+ @editor_cursor = [cursor_x, cursor_y]
111
+ frame
112
+ end
113
+ nil
114
+ end
115
+
116
+ # The prompt ended. +lines+ (the submitted text, or the text with ^C)
117
+ # go to scrollback; without them the prompt just disappears.
118
+ # @return [Boolean] whether a prompt was shown
119
+ def finish_editor(lines = nil)
120
+ synchronize do
121
+ shown = !@editor.empty?
122
+ next false unless shown || lines
123
+
124
+ @editor = []
125
+ @editor_cursor = [0, 0]
126
+ frame { |buffer| lines&.each { |line| buffer << line << "\r\n" } }
127
+ shown
128
+ end
129
+ end
130
+
131
+ # Ctrl-L: clear the screen and draw the region at the top.
132
+ def clear_screen
133
+ synchronize do
134
+ @cursor_row = 0
135
+ frame(clear: true)
136
+ end
137
+ end
138
+
139
+ # Draw the region again (after a resize).
140
+ def redraw
141
+ synchronize { frame }
142
+ end
143
+
144
+ # Take over what else writes to the terminal while the screen is on:
145
+ # $stderr (warnings from background threads would cross the region)
146
+ # and SIGWINCH outside a read (Reline traps it only during one, and
147
+ # chains to this trap then). #close gives both back.
148
+ # @return [self]
149
+ def start
150
+ @stderr_was = $stderr
151
+ $stderr = ErrorOutput.new(self)
152
+ # A trap handler can't take the lock: redraw from a thread.
153
+ @winch_was = Signal.trap("WINCH") { Thread.new { redraw } }
154
+ self
155
+ rescue ArgumentError
156
+ self # no SIGWINCH on this platform
157
+ end
158
+
159
+ # Erase the region and leave the cursor where it began; put back what
160
+ # #start took over.
161
+ def close
162
+ if @stderr_was
163
+ $stderr.flush
164
+ $stderr = @stderr_was
165
+ @stderr_was = nil
166
+ end
167
+ Signal.trap("WINCH", @winch_was) if @winch_was
168
+ @winch_was = nil
169
+ synchronize do
170
+ @slots.clear
171
+ @editor = []
172
+ frame
173
+ end
174
+ end
175
+
176
+ # $stderr while a Screen is on: each line written goes above the region
177
+ # as committed output. A line without its newline waits for the rest.
178
+ class ErrorOutput
179
+ def initialize(screen)
180
+ @screen = screen
181
+ @pending = +""
182
+ @lock = Mutex.new
183
+ end
184
+
185
+ def write(*parts)
186
+ text = parts.join
187
+ lines = @lock.synchronize do
188
+ @pending << text
189
+ *done, @pending = @pending.split("\n", -1)
190
+ @pending = +@pending.to_s
191
+ done
192
+ end
193
+ lines.each { |line| @screen.commit(line) }
194
+ text.bytesize
195
+ end
196
+
197
+ def print(*parts)
198
+ write(*parts)
199
+ nil
200
+ end
201
+
202
+ def puts(*items)
203
+ io = StringIO.new
204
+ io.puts(*items)
205
+ write(io.string)
206
+ nil
207
+ end
208
+
209
+ def <<(item)
210
+ write(item)
211
+ self
212
+ end
213
+
214
+ # Commit a line still waiting for its newline.
215
+ def flush
216
+ line = @lock.synchronize { @pending.empty? ? nil : @pending.dup.tap { @pending.clear } }
217
+ @screen.commit(line) if line
218
+ self
219
+ end
220
+
221
+ def sync = true
222
+ def sync=(_value); end
223
+ def tty? = false
224
+ alias isatty tty?
225
+ def fileno = nil
226
+ end
227
+
228
+ private
229
+
230
+ def slot(name) = @slots.fetch(name, [])
231
+
232
+ # A slot's rows at +width+; fitted content gets at most +height+ rows.
233
+ def fitted(name, width, height = nil)
234
+ content = slot(name)
235
+ return content unless content.respond_to?(:fit)
236
+
237
+ lay_out(content, width: width, height: height && [height, 0].max).map { |row| one_row(row) }
238
+ end
239
+
240
+ # One frame: erase the region, let the block add scrollback text, draw
241
+ # the region. Written in one piece and never cut short by
242
+ # Thread#raise (LineReader drops a read that way).
243
+ def frame(clear: false)
244
+ Thread.handle_interrupt(Object => :never) do
245
+ buffer = +SYNC_BEGIN
246
+ buffer << "\e[2J\e[H" if clear
247
+ buffer << "\r"
248
+ buffer << "\e[#{@cursor_row}A" if @cursor_row.positive?
249
+ buffer << "\e[J"
250
+ @cursor_row = 0
251
+ yield buffer if block_given?
252
+ buffer << region
253
+ buffer << SYNC_END
254
+ @out.write(buffer)
255
+ @out.flush
256
+ end
257
+ end
258
+
259
+ # The region's rows, leaving the cursor in the editor (or on the row
260
+ # below the region when no prompt is open). Sets @cursor_row.
261
+ def region
262
+ width = columns
263
+ above = fitted(:activity, width)
264
+ spare = [rows - 1 - above.size - @editor.size, 0].max
265
+ # Each slot below gets the rows the ones above it left.
266
+ below = BELOW.each_with_object([]) do |name, taken|
267
+ taken.concat(fitted(name, width, spare - taken.size).first([spare - taken.size, 0].max))
268
+ end
269
+ above = above.last([rows - 1 - @editor.size, 0].max)
270
+ all = above + @editor + below
271
+ return +"" if all.empty?
272
+
273
+ text = all.map { |row| clip(row, width) }.join("\r\n")
274
+ last = all.size - 1
275
+ if @editor.empty?
276
+ text << "\r\n"
277
+ @cursor_row = last + 1
278
+ else
279
+ target = above.size + @editor_cursor[1].clamp(0, @editor.size - 1)
280
+ text << "\e[#{last - target}A" if last > target
281
+ text << "\r"
282
+ text << "\e[#{@editor_cursor[0]}C" if @editor_cursor[0].positive?
283
+ @cursor_row = target
284
+ end
285
+ text
286
+ end
287
+
288
+ # A slot row is one line: no line breaks or tabs.
289
+ def one_row(row) = row.to_s.tr("\r\n\t", " ")
290
+
291
+ # Cut a row to the terminal width, so it takes exactly one row.
292
+ def clip(row, width)
293
+ return row if Reline::Unicode.calculate_width(row, true) <= width
294
+
295
+ "#{Reline::Unicode.take_mbchar_range(row, 0, width, padding: false).first}\e[0m"
296
+ end
297
+
298
+ # Lay Reline's layers (prompt, input, dialog rows) over each other into
299
+ # one row, as Reline's own render_line_differential would draw them.
300
+ def compose(layers)
301
+ base = +""
302
+ layers.each do |layer|
303
+ next unless layer
304
+
305
+ x, w, content = layer
306
+ base_width = Reline::Unicode.calculate_width(base, true)
307
+ base << (" " * (x - base_width)) if base_width < x
308
+ head, = Reline::Unicode.take_mbchar_range(base, 0, x, padding: true)
309
+ tail, = Reline::Unicode.take_mbchar_range(base, x + w, [base_width - x - w, 0].max, padding: true)
310
+ base = "#{head}\e[0m#{content}\e[0m#{tail}"
311
+ end
312
+ base
313
+ end
314
+ end
315
+ end
316
+ end