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,1992 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "json"
5
+ require "fileutils"
6
+ require "io/console"
7
+ require "reline"
8
+
9
+ require_relative "model_profile"
10
+ require_relative "turn_note"
11
+ require_relative "config"
12
+ require_relative "context_note"
13
+ require_relative "cancellation_controller"
14
+ require_relative "llm/errors"
15
+ require_relative "host_registry"
16
+ require_relative "context_usage"
17
+ require_relative "context_window"
18
+ require_relative "kernel_loop"
19
+ require_relative "session"
20
+ require_relative "owner_lock"
21
+ require_relative "engine"
22
+ require_relative "tools/memory"
23
+ require_relative "output_formatter"
24
+ require_relative "turn_flow"
25
+ require_relative "turn_tally"
26
+ require_relative "session_commands"
27
+ require_relative "terminal_ui/event_renderer"
28
+ require_relative "terminal_ui/formatting"
29
+ require_relative "terminal_ui/input_support"
30
+ require_relative "terminal_ui/image_input"
31
+ require_relative "terminal_ui/legacy_surface"
32
+ require_relative "terminal_ui/live_region"
33
+ require_relative "terminal_ui/question_prompt"
34
+ require_relative "terminal_ui/repl_input"
35
+ require_relative "terminal_ui/thinking_line"
36
+ require_relative "log"
37
+
38
+ module Samagotchi
39
+ # Loaded on first use: session_manager requires this file (its worker
40
+ # process loads the whole UI stack through it), a require cycle otherwise.
41
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
42
+
43
+ # TerminalUI encapsulates the single operating mode of the harness.
44
+ #
45
+ # assist mode — interactive REPL: user types, model responds, tools execute inline.
46
+ class TerminalUI
47
+ include Formatting
48
+ include InputSupport
49
+
50
+ AGENT_DESCRIPTION_FILE = "AGENT.md"
51
+ SKIP_AGENT_DESCRIPTION_ENV = "SAMAGOTCHI_SKIP_AGENT_MD"
52
+ STATS_COMMAND = "/stats"
53
+ # /exit --delete: delete the session on the way out.
54
+ EXIT_DELETE_FLAG = "--delete"
55
+ RECAP_COMMAND = "/recap"
56
+ # The prompt while a question waits for its answer (its choices are in
57
+ # the notes slot).
58
+ QUESTION_PROMPT = "? "
59
+ THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
60
+ THINKING_UI_SPINNER = "spinner"
61
+ THINKING_UI_OFF = "off"
62
+ THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
63
+ MEMORY_SPINNER_COLOR = "38;5;208"
64
+ TOOL_SPINNER_COLOR = 32
65
+ NETWORK_RETRY_SPINNER_COLOR = 31
66
+ MEMORY_SPINNER_PREVIEW_LIMIT = 3
67
+ MEMORY_STICKY_PREVIEW_LIMIT = 8
68
+ THINKING_PREVIEW_WIDTH = 120
69
+ # Columns a sentence needs on the spinner row; with less, it reads "thinking...".
70
+ MIN_SENTENCE_ROOM = 12
71
+ THINKING_TOOL_PREVIEW_LIMIT = 56
72
+ THINKING_RENDER_MIN_INTERVAL = 0.08
73
+ # The spinner also turns with time: a ticker redraws it when no chunk
74
+ # did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
75
+ # chunk it says how long the first token has taken.
76
+ THINKING_TICK_INTERVAL = 0.25
77
+ THINKING_WAIT_NOTICE_AFTER = 2.0
78
+ THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
79
+ STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
80
+ STATUS_LINE_ON = "on"
81
+ STATUS_LINE_OFF = "off"
82
+ STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
83
+ STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
84
+ STATUS_WIDTH_MODE_FIXED = "fixed"
85
+ STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
86
+ STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
87
+ STATUS_MAX_WIDTH_DEFAULT = 160
88
+ REMINDER_PENDING_POLL_INTERVAL = 0.5
89
+ # What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
90
+ COMMAND_BUSY = "busy: wait for the turn to end"
91
+ # /detach is the attached terminal's; the REPL owns its session.
92
+ REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
93
+ IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
94
+
95
+ # Raised when another process (a `chi web` worker or another chi) owns the
96
+ # session this TUI was asked to run.
97
+ class SessionBusy < StandardError; end
98
+ # --resume with an id that has no saved session.
99
+ class SessionNotFound < StandardError; end
100
+
101
+ # ── System prompts (delegated to Engine) ─────────────────────────────────
102
+ def self.system_prompt_for(profile)
103
+ Engine.system_prompt_for(profile)
104
+ end
105
+
106
+ def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
107
+ spinner_tick_interval: THINKING_TICK_INTERVAL)
108
+ @mode = mode.to_sym
109
+ # nil: no ticker thread (specs that compare exact frames).
110
+ @spinner_tick_interval = spinner_tick_interval
111
+ @spinner_lock = Monitor.new
112
+ @turn_tally = TurnTally.new
113
+ @thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
114
+ @prompt = prompt
115
+ @default_model_name = ModelProfile.required_model_name(nil)
116
+ aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
117
+ flag_model_name = aliased_model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(aliased_model_name)
118
+ @effective_model_name = flag_model_name || @default_model_name
119
+ @host_registry = host_registry || Samagotchi::HostRegistry.new
120
+ # An injected client (specs) stands in for every host's client.
121
+ @host_registry.client_override = client if client
122
+ @client = @host_registry.resolve(@effective_model_name).client
123
+ # Own the session before loading it, so a worker can't write a turn
124
+ # between the load and the lock that this TUI would later save over.
125
+ # An unknown id is refused before the claim, which would leave an
126
+ # empty session dir behind.
127
+ if session_id && !Session.exist?(session_id)
128
+ raise SessionNotFound, "Session not found: #{session_id}"
129
+ end
130
+ claim_session!(session_id) if session_id
131
+ @resume_session = session_id ? Session.load(session_id) : nil
132
+ if @resume_session
133
+ # --model overrides resumed session's model (runtime only, default unchanged)
134
+ if flag_model_name
135
+ @effective_model_name = flag_model_name
136
+ else
137
+ @effective_model_name = @resume_session.model_name.to_s.strip.empty? ? @default_model_name : @resume_session.model_name
138
+ end
139
+ # Re-resolve client after resume may change effective model
140
+ @client = @host_registry.resolve(@effective_model_name).client
141
+ end
142
+ # Only a caller's profile goes in; otherwise the Engine resolves one
143
+ # for the effective model and hands it to the kernel.
144
+ @kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
145
+ @no_default_input = no_default_input
146
+ @non_interactive = non_interactive
147
+ # --memory and --mute, with a resumed session's own lists first: a
148
+ # session started attached with mutes keeps them here.
149
+ @requested_memories = Array(memories)
150
+ @muted_memory_names = Array(muted_memories)
151
+ if @resume_session
152
+ @requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
153
+ @muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
154
+ end
155
+ # A recap written while idle, printed by the main thread at the open
156
+ # prompt (#flush_pending_recap).
157
+ @pending_recap = nil
158
+ # Cards and notices announced between turns, printed by the main
159
+ # thread at the open prompt (#flush_pending_cards).
160
+ @pending_cards = Queue.new
161
+ # Every terminal write goes through the surface. The REPL swaps in a
162
+ # live region when the terminal can show one (#assist_loop), unless it
163
+ # was given a surface to draw on.
164
+ @surface = surface || LegacySurface.new
165
+ @surface_given = !surface.nil?
166
+ @renderer = EventRenderer.new(self)
167
+ @render_event = ->(event) { handle_stream_event(event) }
168
+ @engine = Engine.new(
169
+ mode: :assist,
170
+ client: client,
171
+ host_registry: @host_registry,
172
+ profile: profile,
173
+ session_id: session_id,
174
+ no_interrupt: no_interrupt,
175
+ model_name: @default_model_name,
176
+ memories: @requested_memories,
177
+ muted_memories: @muted_memory_names,
178
+ kernel: @kernel,
179
+ recap: recap_config,
180
+ reminders: {
181
+ callback: lambda { |due_names|
182
+ # When a reminder is due, queue a synthetic turn that
183
+ # run_assist_loop checks before read_input. The synthetic turn
184
+ # uses an empty prompt so the agent can see
185
+ # [SYSTEM: REMINDERS DUE] and act on them.
186
+ @engine.note_due_reminders(due_names)
187
+ }
188
+ }
189
+ )
190
+ # -p without --non-interactive drops into the REPL, which can answer
191
+ # an approval; --non-interactive can't, so an approval there denies.
192
+ @engine.interface = @non_interactive ? :non_interactive : :repl
193
+ @turn_flow = TurnFlow.new(engine: @engine)
194
+ # A recap written while a continue offer waits says the turn stopped
195
+ # unfinished. (Answering at the prompt is typing: activity already.)
196
+ @engine.recap&.awaiting_continue = -> { @turn_flow.awaiting_continue? }
197
+ @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow, default_model: @default_model_name,
198
+ registry: @engine.command_registry)
199
+ # Runtime --model flag or resumed session: switch the Engine (client,
200
+ # kernel profile) without persisting the default.
201
+ @engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
202
+ # Render an idle session-recap via the cursor-safe background writer; the
203
+ # detector itself is Engine-owned (see Engine#recap) and opt-in.
204
+ @recap_handle = @engine.subscribe(observer: ->(event) { handle_recap_ready(event) })
205
+ @question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
206
+ @card_handle = @engine.subscribe(observer: ->(event) { handle_card_event(event) })
207
+ # The REPL renders events from here on: the load warnings and notices
208
+ # now (at the first prompt), and the plugins' slow setup in the
209
+ # background. A --non-interactive run leaves both to its turn.
210
+ unless @non_interactive
211
+ @engine.announce_load_events!
212
+ @engine.start_init_tasks!
213
+ end
214
+ # Synchronous TUI handler for in-turn ask_user_question: the turn thread IS the
215
+ # REPL thread (run_engine_turn runs Engine#run_turn inline), so we must render
216
+ # and collect input on the SAME thread without parking on a second thread.
217
+ @engine.set_question_sync_handler do |pending|
218
+ # render_question_widget records the choice via Engine#answer_question,
219
+ # which Engine.request_question reads back once this handler returns.
220
+ render_question_widget(pending)
221
+ nil
222
+ end
223
+ end
224
+
225
+ # Single dispatch for all entrypoints (interactive REPL, --prompt,
226
+ # --non-interactive, --resume). Builds the working session and its seed
227
+ # messages once, runs a single prompt turn when -p/--prompt is given
228
+ # (auto-executing it, then saving), then either exits when there is no
229
+ # follow-up REPL (--non-interactive) or drops into the REPL carrying the
230
+ # post-turn conversation.
231
+ def run
232
+ unless @mode == :assist
233
+ raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
234
+ end
235
+
236
+ # --non-interactive with no --prompt is a harmless no-op exit: build
237
+ # nothing and return (no transient session, no banner).
238
+ return if @non_interactive && @prompt.nil?
239
+
240
+ session = @resume_session || Session.new_session(
241
+ mode: @mode.to_s,
242
+ model_name: @effective_model_name,
243
+ working_directory: Dir.pwd,
244
+ preloaded_memory_names: @requested_memories,
245
+ muted_memory_names: @muted_memory_names
246
+ )
247
+ claim_session!(session.id) unless @owner_lock
248
+ # Attach before building the prompt so it can name the session id.
249
+ @engine.session = session
250
+ messages = messages_for(session)
251
+
252
+ if @prompt && @non_interactive
253
+ # Headless / CI mode: run directly without TTY rendering.
254
+ result = @engine.run_turn(
255
+ session,
256
+ @prompt,
257
+ on_event: nil,
258
+ max_iterations: 1000,
259
+ cancel_controller: nil,
260
+ images: ImageInput.extract(@prompt)
261
+ )
262
+ @surface.commit(result.output)
263
+ session.save
264
+ return
265
+ end
266
+
267
+ assist_loop(session: session, messages: messages)
268
+ # After the idle layer has stopped: nothing writes the session now.
269
+ if @delete_on_exit
270
+ delete_after_exit(session)
271
+ elsif @discard_on_exit
272
+ discard_after_exit(session)
273
+ else
274
+ keep_after_exit(session)
275
+ end
276
+ ensure
277
+ # However it ends (the early returns too; the Engine was built in
278
+ # #initialize): the anytime commands finish, the plugins' services
279
+ # stop (a server process).
280
+ @engine&.shutdown
281
+ end
282
+
283
+ # The session stays: the recap first, then the resume line, last.
284
+ def keep_after_exit(session)
285
+ recap_after_exit
286
+ @surface.commit("\nContinue session: chi --resume #{session.id}")
287
+ end
288
+
289
+ # The recap for coming back: written before the REPL ends (it has to
290
+ # wait, same process), when there is something new to recap. Ctrl-C
291
+ # gives up on it.
292
+ def recap_after_exit
293
+ @engine.write_recap_now(on_start: -> { @surface.commit("writing a recap…") })
294
+ rescue Interrupt
295
+ nil
296
+ end
297
+
298
+ # Nothing happened in the session (SessionManager.empty_session?). The
299
+ # REPL saves only after a turn and keeps /model in its Engine, so a
300
+ # session never saved is judged from memory, and one on another model
301
+ # than the default (/model, --model) is kept.
302
+ def discard_on_exit?(session)
303
+ return false unless SessionManager.discard_empty?
304
+ return false unless @effective_model_name == @default_model_name && Array(@engine.used_memory_names).empty?
305
+ if File.exist?(File.join(Session.default_state_dir, "#{session.id}#{Session::FILE_EXT}"))
306
+ return SessionManager.empty_session?(session.id, default_model: @default_model_name)
307
+ end
308
+
309
+ SessionManager.no_conversation?(session.messages) && session.last_prompt.to_s.strip.empty? &&
310
+ SessionManager.empty_session_dir?(Session.session_dir(session.id))
311
+ end
312
+
313
+ # A session left empty: give it up and delete it, one quiet line.
314
+ def discard_after_exit(session)
315
+ @owner_lock&.release
316
+ @owner_lock = nil
317
+ SessionManager.delete_session(session.id)
318
+ @surface.commit("The session was empty, so it is discarded.")
319
+ rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
320
+ @surface.commit("Continue session: chi --resume #{session.id} (the empty session was not discarded: #{e.message})")
321
+ end
322
+
323
+ # /exit --delete: give up the session, then delete it for good.
324
+ def delete_after_exit(session)
325
+ @owner_lock&.release
326
+ @owner_lock = nil
327
+ SessionManager.delete_session(session.id)
328
+ @surface.commit("Deleted session #{session.id}.")
329
+ rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
330
+ @surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
331
+ end
332
+
333
+ # Take the session's OwnerLock for this process's lifetime: the TUI runs
334
+ # its own Engine, so no worker may run the session meanwhile.
335
+ # @raise [SessionBusy] when a worker or another TUI owns it
336
+ def claim_session!(session_id)
337
+ session_dir = Session.session_dir(session_id)
338
+ @owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
339
+ return @owner_lock if @owner_lock
340
+
341
+ owner = OwnerLock.owner(session_dir) || {}
342
+ where = owner["kind"] == "tui" ? "another chi" : "a `chi web` worker"
343
+ raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner["pid"] || "unknown"}). " \
344
+ "Close it there first."
345
+ end
346
+
347
+ # Build the seed messages for the working session.
348
+ #
349
+ # Resumed sessions keep their prior conversation (only the system-prompt
350
+ # slot is replaced); fresh sessions start with just the system prompt.
351
+ # Prints a one-line banner so the user sees which session they're in.
352
+ def messages_for(session)
353
+ system_message = { role: "system", content: seed_system_prompt }
354
+ if @resume_session
355
+ messages = ContextNote.with_system_head(session.messages.dup, system_message)
356
+ @surface.commit("Resumed session: #{session.id}")
357
+ saved = @engine.saved_recap
358
+ @surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
359
+ else
360
+ messages = [system_message]
361
+ @surface.commit("Session: #{session.id}")
362
+ end
363
+ messages
364
+ end
365
+
366
+ # Public entrypoint for background session workers.
367
+ # Keeps worker call sites out of TerminalUI private API details.
368
+ def process_background_prompt(session:, prompt:)
369
+ @engine.process_background_prompt(session: session, prompt: prompt)
370
+ end
371
+
372
+ private
373
+
374
+ # Generate profile-aware tool calling hint.
375
+ # Kept as a one-line delegator to Engine (single source of truth).
376
+ def tool_call_hint
377
+ return @engine.tool_call_hint
378
+ end
379
+
380
+ # Assist system prompt — delegates wholesale to Engine so the two can
381
+ # never drift apart. This single delegator fixes the 4-line regression.
382
+ def assist_system_prompt
383
+ return @engine.assist_system_prompt
384
+ end
385
+
386
+ # Interactive REPL loop. Session seed + messages are built by #run and
387
+ # threaded in here (so --prompt / --resume share one code path). The
388
+ # working session is persisted at the end of every turn.
389
+ def assist_loop(session:, messages:)
390
+ load_persistent_history
391
+
392
+ # queue_default_input self-guards on @resume_session, so calling it
393
+ # unconditionally preserves the original fresh-session prefill behavior.
394
+ # Skip the default input prefill when a user-provided --prompt was used —
395
+ # the explicit prompt means the user is in command and shouldn't see the
396
+ # default input ("Please " etc.) on the next REPL prompt.
397
+ queue_default_input if @prompt.nil?
398
+
399
+ # The idle layer (reminders + optional recap) is Engine-owned; start the
400
+ # shared scheduler for this REPL and stop it on exit. with_activity_hook
401
+ # resets the shared inactivity clock when a prompt opens and on each key.
402
+ with_live_region do
403
+ with_interrupt_arbiter do
404
+ @engine.start_idle
405
+ with_activity_hook do
406
+ run_assist_loop(session: session, messages: messages)
407
+ ensure
408
+ @engine.stop_idle
409
+ end
410
+ end
411
+ end
412
+ end
413
+
414
+ # Ctrl-C while a Reline read owns stdin: Reline's INT trap catches it, so
415
+ # the REPL hears of it through RelineSeam. A running turn is cancelled and
416
+ # the prompt stays as typed; with no turn running it is Reline's Ctrl-C.
417
+ # (With no read open, Ctrl-C stays an Interrupt on the turn's thread.)
418
+ def with_interrupt_arbiter
419
+ return yield unless RelineSeam.supported?
420
+
421
+ RelineSeam.install
422
+ previous = RelineSeam.interrupt_handler
423
+ RelineSeam.interrupt_handler = method(:cancel_turn_from_prompt)
424
+ begin
425
+ yield
426
+ ensure
427
+ RelineSeam.interrupt_handler = previous
428
+ end
429
+ end
430
+
431
+ # @return [Boolean] a running turn was cancelled
432
+ def cancel_turn_from_prompt
433
+ controller = @active_cancel_controller
434
+ return false unless controller
435
+
436
+ controller.cancel!(:ctrl_c)
437
+ true
438
+ end
439
+
440
+ # Draw the REPL on a live region (a Screen, with Reline's prompt in it)
441
+ # when the terminal can show one; plain output otherwise, or on the
442
+ # surface the UI was given.
443
+ def with_live_region
444
+ screen = LiveRegion.open unless @surface_given
445
+ return yield unless screen
446
+
447
+ plain = @surface
448
+ @surface = screen
449
+ begin
450
+ yield
451
+ ensure
452
+ @surface = plain
453
+ LiveRegion.close(screen)
454
+ end
455
+ end
456
+
457
+ # Interactive REPL loop. Session seed + messages are built by #run and
458
+ # threaded in here (so --prompt / --resume share one code path). The
459
+ # session's conversation is the single working copy: turns go through
460
+ # Engine#run_turn and out-of-turn edits through Engine's messages API.
461
+ # The checkpoint and continue state live in @turn_flow (shared with
462
+ # session workers). The session is persisted at the end of every turn.
463
+ def run_assist_loop(session:, messages:)
464
+ session.messages = messages
465
+ @engine.session = session
466
+ # Steering: lines submitted at the open prompt during a turn
467
+ # (#steer_line), merged by the kernel (#drain_steering).
468
+ @pending_input_queue = PendingInputQueue.new
469
+ open_repl_input
470
+
471
+ loop do
472
+ if @exit_after_turn
473
+ # Ctrl-D or exit came during a turn: the lines sent before it still
474
+ # run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
475
+ input = queued_line
476
+ break if input.nil?
477
+ else
478
+ # Drain any pending ask_user_question first — it has priority over reminders and
479
+ # must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
480
+ drain_pending_question?
481
+
482
+ # A due reminder runs its turn now, with the prompt open (on a
483
+ # terminal): the turn's output commits above it and whatever is typed
484
+ # there stays.
485
+ unless @engine.due_reminder_names.empty?
486
+ run_reminder_turn(session)
487
+ @turn_flow.after_reminder_turn
488
+ next
489
+ end
490
+ input = @prompt
491
+ @prompt = nil if input
492
+ if input.nil?
493
+ input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
494
+ # A reminder fell due: run it at the top, with the prompt still open.
495
+ next if input == :due
496
+ end
497
+ end
498
+ break if input.nil?
499
+ if exit_command?(input)
500
+ @delete_on_exit ||= delete_on_exit?(input)
501
+ break
502
+ end
503
+ # Not an answer to a continue offer either.
504
+ next detach_note if detach_command?(input)
505
+
506
+ # /stats and /recap run; the offer stays open.
507
+ if @turn_flow.awaiting_continue? && !stats_command?(input) && !recap_command?(input)
508
+ answer_continue_offer(session, input)
509
+ else
510
+ # The ? read left no echo: show what ran.
511
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
512
+ run_input_line(session, input)
513
+ end
514
+ end
515
+
516
+ close_repl_input
517
+ @discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
518
+ end
519
+
520
+ # An answer at the ? prompt of a continue offer. A valid one closes the
521
+ # offer's choices and leaves one line; an invalid one gets its error.
522
+ def answer_continue_offer(session, input)
523
+ answer = @commands.continue_answer(input)
524
+ if answer.decision == :invalid
525
+ # The read left no echo: the line shows above its error.
526
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}")
527
+ else
528
+ sync_continue_slot(false)
529
+ @surface.commit(QuestionSlot.continue_summary(input.to_s.strip.empty? ? "yes" : input.to_s.strip, paint: method(:paint)))
530
+ end
531
+ return show_command_result(answer) unless answer.resume
532
+
533
+ @turn_flow.before_continue_turn
534
+ begin
535
+ result = run_engine_turn(session, nil, continue: true)
536
+ rescue LLM::ProviderError => e
537
+ @surface.commit("\nmodel> #{e.summary}; continue prompt preserved")
538
+ return
539
+ end
540
+ finish_turn(session, result, continue: true)
541
+ end
542
+
543
+ # A line at the main prompt: a command, or a prompt for a turn.
544
+ def run_input_line(session, input)
545
+ return if input.empty?
546
+
547
+ # /model, /models, !rollback, !cmd, /continue (shared with workers);
548
+ # an anytime command's cards print as it shows them (btw's
549
+ # "thinking…" before the answer).
550
+ command = if command_registry.lookup(input)&.anytime
551
+ @engine.running_anytime { @commands.run(input) }
552
+ else
553
+ @commands.run(input)
554
+ end
555
+ if command
556
+ show_command_result(command)
557
+ persist_recent_history(input) if command.shell
558
+ return
559
+ end
560
+ return @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}") if stats_command?(input)
561
+ return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
562
+
563
+ @turn_flow.before_prompt_turn
564
+ persist_recent_history(input)
565
+ begin
566
+ # Engine#run_turn injects due reminders as a tail message, appends
567
+ # the prompt, and renders through @renderer via on_event.
568
+ result = run_engine_turn(session, normalize_model_input(input), images: ImageInput.extract(input))
569
+ rescue LLM::ProviderError, ImageStore::Error => e
570
+ # Engine closed the turn (:turn_failed); show its duration.
571
+ # Retries were already tallied via generation_retrying events.
572
+ # An @path image that can't be used fails the turn the same way.
573
+ emit_interactive_turn_duration(canceled: false)
574
+ summary = e.respond_to?(:summary) ? e.summary : e.message
575
+ # The model reads why on its next turn. An image that couldn't be
576
+ # used, or that the host refused, never reached it: no note for that
577
+ # (a first turn refused that way leaves the session empty, as before).
578
+ note = TurnNote.failed(summary, restored: true) if e.is_a?(LLM::ProviderError) && !e.is_a?(LLM::VisionUnsupported)
579
+ @turn_flow.prompt_turn_failed(note: note)
580
+ # The Engine saved the failed turn; the file follows the rollback.
581
+ save_session(session) if note
582
+ @surface.commit("\nmodel> #{summary}; #{restore_prompt_for_retry(input)}")
583
+ return
584
+ end
585
+ finish_turn(session, result, continue: false)
586
+ end
587
+
588
+ # After a prompt or continue turn: TurnFlow keeps the checkpoint and the
589
+ # continue offer; the REPL saves and tells the user.
590
+ def finish_turn(session, result, continue:)
591
+ if result.respond_to?(:canceled?) && result.canceled?
592
+ outcome = @turn_flow.after_turn(result, continue: continue)
593
+ # A cancelled continue is back where it started, the offer still open.
594
+ return if outcome == :continue_cancelled
595
+
596
+ # Ctrl-C on a fresh turn. The kernel salvaged completed tool calls
597
+ # and the partial assistant reply (marked [interrupted]) into
598
+ # result.conversation, so progress is preserved by default — the
599
+ # user's next message continues from it. !rollback restores the
600
+ # pre-turn checkpoint for an explicit full discard.
601
+ emit_interactive_turn_duration(canceled: true)
602
+ save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
603
+ @surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
604
+ return
605
+ end
606
+
607
+ # The REPL keeps the kernel's conversation as-is (no [No response]
608
+ # placeholder), so /continue resumes from the tool results.
609
+ session.messages = result.conversation
610
+ @turn_flow.after_turn(result, continue: continue)
611
+ save_session(session)
612
+ end
613
+
614
+ def save_session(session)
615
+ session.model_name = @effective_model_name
616
+ session.save
617
+ end
618
+
619
+ # A !cmd shows its own output; everything else is the model> line.
620
+ def show_command_result(result)
621
+ if result.shell
622
+ @surface.commit(result.output)
623
+ @surface.commit("")
624
+ elsif !result.output.nil?
625
+ @surface.commit("\nmodel> #{result.output}")
626
+ end
627
+ sync_model_mirrors if result.changed.include?(:model)
628
+ end
629
+
630
+ # The status line and the next session save read these.
631
+ def sync_model_mirrors
632
+ @effective_model_name = @engine.effective_model_name
633
+ @default_model_name = @commands.default_model
634
+ end
635
+
636
+ def status_server_segment
637
+ # Show per-host info when multi-host is configured
638
+ if @host_registry && @host_registry.entries.size > 1
639
+ active = @host_registry.resolve(@effective_model_name).entry rescue nil
640
+ if active
641
+ host = active.host
642
+ port = active.port
643
+ total = @host_registry.entries.size
644
+ return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
645
+ # When multiple hosts, always show active + count
646
+ return "server=#{host}:#{port} (#{total} hosts)"
647
+ end
648
+ end
649
+ host = Samagotchi::Config.get("server.host")
650
+ return "" if ["localhost", "127.0.0.1"].include?(host)
651
+
652
+ port = Samagotchi::Config.get("server.port")
653
+ "server=#{host}:#{port}"
654
+ end
655
+
656
+ # Engine's built-once system prompt (the one run_turn sends), plus the
657
+ # --memory activations it recorded for the sticky status line.
658
+ def seed_system_prompt
659
+ prompt = @engine.system_prompt
660
+ sync_engine_activated_memories
661
+ prompt
662
+ end
663
+
664
+ # Appends the current memory index to the base system prompt so the agent
665
+ # is always aware of stored memories without needing to call a tool first.
666
+ # Delegates wholesale to Engine (single source of truth).
667
+ def system_prompt_with_index(base)
668
+ result = @engine.system_prompt_with_index(base)
669
+ # Mirror any --memory activations the Engine performed so the sticky
670
+ # status line can surface them. The prompt body injection moved into
671
+ # Engine; echoing the activated names here is purely a UI concern.
672
+ sync_engine_activated_memories
673
+ result
674
+ end
675
+
676
+ # Engine owns the system prompt (including --memory activation), but the
677
+ # sticky status line is a UI concern. Mirror the activated names so they
678
+ # appear in the status line.
679
+ def sync_engine_activated_memories
680
+ @engine.activated_memory_names.each do |name|
681
+ add_unique_memory_name(:@session_memory_names, name)
682
+ end
683
+ end
684
+
685
+ # Render a result from a turn that bypassed Engine#run_turn (continue,
686
+ # reminder turns) exactly as a :turn_completed would be rendered.
687
+ def emit_result(result)
688
+ @renderer.render_turn_summary(@engine.turn_summary(result))
689
+ end
690
+
691
+ # ── Turn view: the drawing surface EventRenderer calls ──────────────────
692
+ public
693
+
694
+ def emit_active_memories_line
695
+ lines = sticky_status_lines(width: status_effective_width)
696
+ return if lines.empty?
697
+
698
+ lines.each { |line| @surface.commit(line) }
699
+ end
700
+
701
+ def print_line(text)
702
+ @surface.commit(text)
703
+ end
704
+
705
+ def reset_turn_feedback
706
+ @spinner_lock.synchronize { @turn_tally.reset }
707
+ clear_retry_spinner_status
708
+ reset_thinking_memory_notification
709
+ reset_thinking_memory_names
710
+ reset_thinking_tool_notification
711
+ end
712
+
713
+ # A turn waits for plugins' slow setup (chi.init) before its first
714
+ # request: the spinner says what for, until the model starts.
715
+ def init_wait_feedback(event)
716
+ labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
717
+ @spinner_lock.synchronize do
718
+ @init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
719
+ start_thinking_spinner
720
+ refresh_thinking_spinner_status
721
+ end
722
+ end
723
+
724
+ def generation_feedback_started(event = {})
725
+ @init_wait_status = nil
726
+ @context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
727
+ clear_retry_spinner_status
728
+ @latest_server_context_status = nil
729
+ @spinner_lock.synchronize do
730
+ @thinking_line.reset
731
+ start_thinking_spinner
732
+ end
733
+ end
734
+
735
+ def generation_feedback_retrying(event)
736
+ @spinner_lock.synchronize do
737
+ # The retry streams from the start: wait for its first token again.
738
+ @thinking_waiting_since = monotonic_time if @thinking_spinner_active
739
+ set_retry_spinner_status(event)
740
+ refresh_thinking_spinner_status
741
+ end
742
+ end
743
+
744
+ def generation_feedback_chunk(event)
745
+ @spinner_lock.synchronize do
746
+ @thinking_waiting_since = nil
747
+ clear_retry_spinner_status if retry_spinner_status_active?
748
+ capture_server_context_status_from_payload(event[:payload])
749
+ # A new sentence shows at once (the line changes at most once a dwell).
750
+ next refresh_thinking_spinner_status if @thinking_line.chunk(event)
751
+
752
+ tick_thinking_spinner
753
+ end
754
+ end
755
+
756
+ def tool_call_feedback_started(event)
757
+ @spinner_lock.synchronize do
758
+ @thinking_waiting_since = nil
759
+ clear_retry_spinner_status
760
+ @turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
761
+ memory_loaded = capture_memory_tool_call(event)
762
+ capture_thinking_tool_call(event) if memory_loaded
763
+ refresh_thinking_spinner_status
764
+ end
765
+ end
766
+
767
+ def tool_call_feedback_completed(event)
768
+ @spinner_lock.synchronize do
769
+ @turn_tally.completed(key: tally_key(event), tool: event[:tool],
770
+ status: event.dig(:activity, :status), params: event.dig(:activity, :params))
771
+ end
772
+ end
773
+
774
+ def clear_generation_retry
775
+ @spinner_lock.synchronize { clear_retry_spinner_status }
776
+ end
777
+
778
+ def generation_feedback_finished
779
+ @spinner_lock.synchronize do
780
+ clear_retry_spinner_status
781
+ @thinking_line.reset
782
+ finish_thinking_spinner
783
+ end
784
+ end
785
+
786
+ # The kernel reports the last emitted context status on the result; keep
787
+ # the previous one when a turn reports none.
788
+ def capture_context_status(status)
789
+ return unless status
790
+
791
+ @latest_context_status = {
792
+ est_pct: status[:est_pct],
793
+ bucket: status[:bucket]
794
+ }
795
+ end
796
+
797
+ private
798
+
799
+ # The last turn's line: completed, canceled or failed, as the Engine
800
+ # closed it (a provider error closes it with :turn_failed).
801
+ def emit_interactive_turn_duration(canceled:)
802
+ record = Array(@engine.metrics.snapshot[:turn_records]).last
803
+ return unless record && record[:duration_ms]
804
+
805
+ state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
806
+ @surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
807
+ end
808
+
809
+ def split_memory_scope(raw)
810
+ value = raw.to_s.strip
811
+ if value.include?("/")
812
+ scope, name = value.split("/", 2)
813
+ return [scope, name] if Tools::VALID_SCOPES.include?(scope)
814
+ end
815
+
816
+ [nil, value]
817
+ end
818
+
819
+ def read_input(awaiting_continue:)
820
+ emit_idle_status_line
821
+
822
+ if awaiting_continue
823
+ sync_continue_slot(true)
824
+ input = Reline.readline(paint(QUESTION_PROMPT, 33), true)
825
+ return nil if input.nil?
826
+
827
+ return input.strip
828
+ end
829
+
830
+ read_prompt_line(paint("> ", 92))
831
+ rescue Interrupt
832
+ nil
833
+ end
834
+
835
+ # The synthetic turn for due reminders (Engine#run_turn injects them as
836
+ # a tail message). The hints row says which, while it runs.
837
+ def run_reminder_turn(session)
838
+ names = @engine.due_reminder_names
839
+ @engine.clear_due_reminder_names!
840
+ # A normal turn may already have injected them (a stale latch): no
841
+ # empty synthetic turn then (it duplicated output).
842
+ return unless @engine.reminders_due?
843
+
844
+ hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
845
+ @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
846
+ result = nil
847
+ begin
848
+ result = run_engine_turn(session, nil, continue: true)
849
+ @prompt = nil
850
+ rescue LLM::ProviderError => e
851
+ # like other failed turns: back to the prompt
852
+ @surface.commit("\nmodel> #{e.summary}")
853
+ ensure
854
+ @surface.clear_slot(:hints)
855
+ end
856
+ canceled = result.respond_to?(:canceled?) && result.canceled?
857
+ emit_interactive_turn_duration(canceled: canceled)
858
+ if result && !canceled
859
+ session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
860
+ session.model_name = @effective_model_name
861
+ session.save
862
+ end
863
+ # The synthetic turn is activity: the next reminder waits a full
864
+ # interval (without it they fired back to back).
865
+ @engine.record_activity
866
+ end
867
+
868
+ # The next line from the prompt, or :due when a reminder falls due first.
869
+ # On a terminal the prompt stays open the whole session (#open_repl_input):
870
+ # a reminder turn runs with it (and anything typed in it) still there, and
871
+ # a line submitted meanwhile comes next. Ctrl-C during a turn reaches
872
+ # #cancel_turn_from_prompt.
873
+ def poll_input_with_reminder_check(awaiting_continue:)
874
+ return :due unless @engine.due_reminder_names.empty?
875
+ # Specs and pipes: a plain blocking read.
876
+ unless @repl_input
877
+ flush_pending_cards
878
+ return read_input(awaiting_continue: awaiting_continue)
879
+ end
880
+
881
+ if @idle_status_due
882
+ @idle_status_due = false
883
+ emit_idle_status_line
884
+ end
885
+ sync_continue_slot(awaiting_continue)
886
+ @repl_input.sync_prompt
887
+ loop do
888
+ flush_pending_recap
889
+ flush_pending_cards
890
+ kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
891
+ if kind
892
+ # What the line does may change the status (/model, a turn).
893
+ @idle_status_due = true
894
+ # Ctrl-C at an idle prompt ends the REPL, as Ctrl-D does.
895
+ return kind == :line ? line : nil
896
+ end
897
+ return :due unless @engine.due_reminder_names.empty?
898
+ end
899
+ end
900
+
901
+ # On a terminal: one read for the whole session, on a LineReader.
902
+ def open_repl_input
903
+ return unless STDIN.tty? && $stdin.tty?
904
+
905
+ @idle_status_due = false
906
+ emit_idle_status_line
907
+ @repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
908
+ end
909
+
910
+ def close_repl_input
911
+ @repl_input&.stop
912
+ @repl_input = nil
913
+ end
914
+
915
+ # The main prompt, or ? when a continue offer waits and no turn runs.
916
+ def repl_prompt_text
917
+ return paint("> ", 92) if @active_cancel_controller || !@turn_flow.awaiting_continue?
918
+
919
+ paint(QUESTION_PROMPT, 33)
920
+ end
921
+
922
+ # The notes slot shows a continue offer's choices while it waits.
923
+ def sync_continue_slot(shown)
924
+ if shown
925
+ return if @continue_slot
926
+
927
+ @continue_slot = true
928
+ @surface.set_slot(:notes, QuestionSlot.continue_offer(@turn_flow.offer&.dig(:context), paint: method(:paint)))
929
+ elsif @continue_slot
930
+ @continue_slot = false
931
+ @surface.clear_slot(:notes)
932
+ end
933
+ end
934
+
935
+ # One read on the reader thread (ReplInput): the multiline read with Tab
936
+ # completion at the main prompt, a plain line for the continue offer and
937
+ # a question's ? prompt. +prefill+ is typed in first.
938
+ def read_repl_line(prompt, prefill)
939
+ queue_input_prefill(prefill) if prefill
940
+ return read_prompt_line(prompt) if prompt == paint("> ", 92)
941
+
942
+ read = -> { with_next_input_prefill { Reline.readline(prompt, true) }&.strip }
943
+ # A question's answer leaves only its summary line.
944
+ prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
945
+ end
946
+
947
+ # A failed prompt goes back into the input for a retry.
948
+ # @return [String] what the failed-turn line says about it
949
+ def restore_prompt_for_retry(input)
950
+ unless @repl_input
951
+ queue_input_prefill(input)
952
+ return "prompt restored for retry"
953
+ end
954
+ @repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
955
+ end
956
+
957
+ def shell_bang_command?(input)
958
+ input.to_s.match?(/\A!\s*\S/)
959
+ end
960
+
961
+ def stats_command?(input)
962
+ input.to_s.strip == STATS_COMMAND
963
+ end
964
+
965
+ def recap_command?(input)
966
+ input.to_s.strip == RECAP_COMMAND
967
+ end
968
+
969
+ # SessionCommands runs /model; the REPL mirrors the result.
970
+ def handle_model_command(input)
971
+ result = @commands.run(input)
972
+ sync_model_mirrors
973
+ result.output
974
+ end
975
+
976
+ def handle_models_command
977
+ @commands.run(SessionCommands::MODELS_COMMAND).output
978
+ end
979
+
980
+ # /recap: the saved recap, and a new one asked for at once when the
981
+ # chat moved on (it prints when it arrives, #flush_pending_recap).
982
+ def handle_recap_command
983
+ recap = @engine.recap
984
+ return recap_command_text(enabled: false) unless recap
985
+
986
+ saved = @engine.saved_recap
987
+ request = @engine.turn_running? ? :busy : @engine.request_recap
988
+ recap_command_text(enabled: true, saved: saved, request: request, min_user_turns: recap.min_user_turns)
989
+ end
990
+
991
+ # A resumed session doesn't get the default input either.
992
+ def default_input_wanted? = !@resume_session && !@no_default_input
993
+
994
+ # exit or /exit, with --delete to delete the session on the way out.
995
+ def exit_command?(input)
996
+ words = input.to_s.strip.downcase.split
997
+ %w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
998
+ end
999
+
1000
+ def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
1001
+
1002
+ def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
1003
+
1004
+ def clone_messages(messages)
1005
+ Array(messages).map(&:dup)
1006
+ end
1007
+
1008
+ def skip_agent_description?
1009
+ value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
1010
+ value == "1" || value&.casecmp?("true")
1011
+ end
1012
+
1013
+ def rg_available?
1014
+ system("command -v rg", out: File::NULL, err: File::NULL)
1015
+ end
1016
+
1017
+ # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
1018
+ # Engine#run_turn, rendering via @renderer.
1019
+ # @param images [Array<Hash>] the prompt's `@path` images ({path:})
1020
+ def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
1021
+ cancellation_controller = CancellationController.new
1022
+ @active_cancel_controller = cancellation_controller
1023
+ @renderer.begin_turn
1024
+ # Build the (memoized) prompt now so --memory activations show in this
1025
+ # turn's status lines, including after /model rebuilt it.
1026
+ seed_system_prompt
1027
+ result = with_steering do
1028
+ @engine.run_turn(
1029
+ session,
1030
+ prompt,
1031
+ on_event: @render_event,
1032
+ max_iterations: max_iterations,
1033
+ cancel_controller: cancellation_controller,
1034
+ pending_input: method(:drain_steering),
1035
+ continue: continue,
1036
+ images: images
1037
+ )
1038
+ end
1039
+ emit_cancellation_notice(result)
1040
+ result
1041
+ rescue Interrupt
1042
+ # Engine kept the prompt in the session and emitted :turn_canceled.
1043
+ cancellation_controller&.cancel!(:ctrl_c)
1044
+ result = cancelled_result_from(session.messages, reason: :ctrl_c)
1045
+ emit_cancellation_notice(result)
1046
+ result
1047
+ ensure
1048
+ @active_cancel_controller = nil
1049
+ finish_thinking_spinner
1050
+ end
1051
+
1052
+ # While a turn runs, a line submitted at the open prompt steers it: it
1053
+ # merges at the next iteration boundary (Kernel), and one that comes after
1054
+ # the last runs as the next turn. Reminder turns too.
1055
+ def with_steering(&block)
1056
+ return yield unless @repl_input
1057
+
1058
+ @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &block)
1059
+ end
1060
+
1061
+ # On the reader thread, from ReplInput: takes a line for the running turn.
1062
+ # A line sent after Ctrl-C waits for the next turn (the kernel would not
1063
+ # merge it into the cancelled one). Ctrl-D or exit ends the REPL after
1064
+ # the turn. /stats and /recap run now, an anytime command (/help, a
1065
+ # plugin's /btw) starts now on its own thread; other commands wait, back
1066
+ # in the prompt.
1067
+ # @return [Boolean, :back] whether the turn took it, :back to put it back
1068
+ def steer_line(line)
1069
+ return exit_after_turn(delete: delete_on_exit?(line)) if line.nil? || exit_command?(line)
1070
+ return detach_note if detach_command?(line)
1071
+ return false if @active_cancel_controller&.cancelled?
1072
+ return start_anytime_command(line) if command_registry.lookup(line)&.anytime
1073
+ return command_during_turn(line) if command_line?(line)
1074
+ return true if line.strip.empty?
1075
+ # Steering merges text only: a line with images runs as the next turn.
1076
+ return image_line_waits if ImageInput.extract(line).any?
1077
+
1078
+ @pending_input_queue.push(line.strip)
1079
+ true
1080
+ end
1081
+
1082
+ def image_line_waits
1083
+ @surface.commit(IMAGE_LINE_WAITS)
1084
+ false
1085
+ end
1086
+
1087
+ def detach_note
1088
+ @surface.commit(REPL_DETACH_NOTE)
1089
+ true
1090
+ end
1091
+
1092
+ def exit_after_turn(delete: false)
1093
+ @exit_after_turn = true
1094
+ @delete_on_exit = true if delete
1095
+ @surface.commit(delete ? "(exits after this turn and deletes the session; Ctrl-C cancels the turn)" : "(exits after this turn; Ctrl-C cancels it)")
1096
+ true
1097
+ end
1098
+
1099
+ # A line already queued (none waits for input), or nil.
1100
+ def queued_line
1101
+ kind, line = @repl_input&.pop(timeout: 0)
1102
+ kind == :line ? line : nil
1103
+ end
1104
+
1105
+ # @return [true, :back]
1106
+ def command_during_turn(line)
1107
+ if stats_command?(line)
1108
+ @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
1109
+ elsif recap_command?(line)
1110
+ @surface.commit("\nmodel> #{handle_recap_command}")
1111
+ else
1112
+ @surface.commit(COMMAND_BUSY)
1113
+ return :back
1114
+ end
1115
+ true
1116
+ end
1117
+
1118
+ # D8: an anytime command runs on its own thread while the turn goes on
1119
+ # (it reads copies, and shows things through its ctx). What it prints
1120
+ # while the turn runs goes above the live region now, its cards as it
1121
+ # shows them (#handle_card_event); once the turn has ended it waits for
1122
+ # the prompt's flush.
1123
+ # @return [true]
1124
+ def start_anytime_command(line)
1125
+ @engine.spawn_anytime do
1126
+ output = @engine.running_anytime { @commands.run(line) }&.output
1127
+ items = output.nil? ? [] : [{ type: :command_output, text: "\nmodel> #{output}" }]
1128
+ items.each { |item| @engine.turn_running? ? show_pending_item(item) : @pending_cards << item }
1129
+ rescue StandardError => e
1130
+ @pending_cards << { type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" }
1131
+ end
1132
+ true
1133
+ end
1134
+
1135
+ def command_line?(line)
1136
+ command_registry.command?(line) || stats_command?(line) || recap_command?(line) || exit_command?(line)
1137
+ end
1138
+
1139
+ # The kernel's drain at an iteration boundary: queued lines, #memory
1140
+ # shorthand made words as for a prompt, and saved in the history.
1141
+ def drain_steering
1142
+ @pending_input_queue.drain.map do |line|
1143
+ persist_recent_history(line)
1144
+ normalize_model_input(line)
1145
+ end
1146
+ end
1147
+
1148
+ # The REPL's on_event sink for Engine#run_turn: render one event. Engine
1149
+ # swallows on_event errors to protect the turn, so log ours instead.
1150
+ def handle_stream_event(event)
1151
+ @renderer.call(event)
1152
+ rescue StandardError => e
1153
+ Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
1154
+ end
1155
+
1156
+ def emit_cancellation_notice(result)
1157
+ return unless result.respond_to?(:canceled?) && result.canceled?
1158
+
1159
+ reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
1160
+ label = cancellation_reason_label(reason)
1161
+ @surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
1162
+ end
1163
+
1164
+ def cancelled_result_from(messages, reason:)
1165
+ KernelLoop::Result.new(
1166
+ output: "",
1167
+ conversation: clone_messages(messages),
1168
+ exhausted: false,
1169
+ pending_tool_calls: false,
1170
+ tool_activity: [],
1171
+ canceled: true,
1172
+ cancellation_reason: reason
1173
+ )
1174
+ end
1175
+
1176
+ def cancellation_reason_label(reason)
1177
+ return "" if reason.nil?
1178
+
1179
+ case reason.to_sym
1180
+ when :ctrl_c
1181
+ "ctrl-c"
1182
+ else
1183
+ reason.to_s
1184
+ end
1185
+ end
1186
+
1187
+ def start_thinking_spinner
1188
+ return unless thinking_spinner_enabled?
1189
+
1190
+ @thinking_spinner_active = true
1191
+ @thinking_spinner_index = 0 if @thinking_spinner_index.nil?
1192
+ @thinking_spinner_last_render_at = nil
1193
+ @thinking_waiting_since = monotonic_time
1194
+ render_thinking_spinner
1195
+ start_thinking_ticker
1196
+ end
1197
+
1198
+ # One thread per spinner; it ends when the spinner does.
1199
+ def start_thinking_ticker
1200
+ return unless @spinner_tick_interval
1201
+ return if @thinking_ticker&.alive?
1202
+
1203
+ @thinking_ticker = Thread.new do
1204
+ loop do
1205
+ sleep(@spinner_tick_interval)
1206
+ break unless tick_thinking_spinner_on_timer
1207
+ end
1208
+ rescue StandardError
1209
+ nil
1210
+ end
1211
+ @thinking_ticker.report_on_exception = false
1212
+ end
1213
+
1214
+ # Turn the spinner when no chunk did for a tick.
1215
+ # @return [Boolean] whether the spinner is still shown
1216
+ def tick_thinking_spinner_on_timer
1217
+ @spinner_lock.synchronize do
1218
+ return false unless @thinking_spinner_active
1219
+
1220
+ last = @thinking_spinner_last_render_at
1221
+ if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
1222
+ @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1223
+ render_thinking_spinner
1224
+ end
1225
+ true
1226
+ end
1227
+ end
1228
+
1229
+ def tick_thinking_spinner
1230
+ return unless @thinking_spinner_active
1231
+
1232
+ @thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
1233
+ render_thinking_spinner_if_due
1234
+ end
1235
+
1236
+ def refresh_thinking_spinner_status
1237
+ return unless @thinking_spinner_active
1238
+
1239
+ render_thinking_spinner
1240
+ end
1241
+
1242
+ def render_thinking_spinner_if_due
1243
+ last = @thinking_spinner_last_render_at
1244
+ return render_thinking_spinner if last.nil?
1245
+ return if (monotonic_time - last) < thinking_render_min_interval
1246
+
1247
+ render_thinking_spinner
1248
+ end
1249
+
1250
+ public
1251
+
1252
+ # Erase the spinner rows; nothing to do when none are shown.
1253
+ def finish_thinking_spinner
1254
+ @spinner_lock.synchronize do
1255
+ # Off even when the rows are gone already, so the ticker ends.
1256
+ @thinking_spinner_active = false
1257
+ @init_wait_status = nil
1258
+ @thinking_waiting_since = nil
1259
+ next unless @surface.clear_slot(:activity)
1260
+
1261
+ @thinking_spinner_last_render_at = nil
1262
+ end
1263
+ end
1264
+
1265
+ private
1266
+
1267
+ def retry_spinner_status_line(frame, width)
1268
+ data = @retry_spinner_status || {}
1269
+ attempt = data[:attempt].to_i
1270
+ max_retries = data[:max_retries].to_i
1271
+ total_attempts = max_retries + 1
1272
+ delay = format("%.1f", data[:next_delay].to_f)
1273
+ error_class = data[:error_class].to_s
1274
+ message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
1275
+ message += " #{error_class}" unless error_class.empty?
1276
+ capped = cap_preview_line(message, width)
1277
+ color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
1278
+ end
1279
+
1280
+ def cap_preview_line(text, width)
1281
+ cap_preview_text(text, thinking_preview_width(width))
1282
+ end
1283
+
1284
+ def thinking_preview_width(width)
1285
+ return THINKING_PREVIEW_WIDTH if width <= 0
1286
+
1287
+ [width, THINKING_PREVIEW_WIDTH].min
1288
+ end
1289
+
1290
+
1291
+ def thinking_render_min_interval
1292
+ value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
1293
+ return THINKING_RENDER_MIN_INTERVAL unless value.positive?
1294
+
1295
+ value
1296
+ end
1297
+
1298
+ def monotonic_time
1299
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1300
+ end
1301
+
1302
+ def capture_memory_tool_call(event)
1303
+ call = event[:call].is_a?(Hash) ? event[:call] : {}
1304
+ memory_name = memory_name_from_tool_call(call)
1305
+ return false if memory_name.nil? || memory_name.empty?
1306
+
1307
+ added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
1308
+ add_unique_memory_name(:@session_memory_names, memory_name)
1309
+ @thinking_recent_memory_loaded = memory_name if added_to_thinking
1310
+ added_to_thinking
1311
+ end
1312
+
1313
+ def capture_thinking_tool_call(event)
1314
+ call = event[:call].is_a?(Hash) ? event[:call] : {}
1315
+ name = call[:name].to_s.strip
1316
+ return if name.empty?
1317
+
1318
+ params = thinking_tool_params_preview(call, event[:params])
1319
+ text = params.empty? ? name : "#{name}(#{params})"
1320
+ @thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
1321
+ end
1322
+
1323
+ def thinking_tool_params_preview(call, raw_params)
1324
+ compact = raw_params.to_s.gsub(/\s+/, " ").strip
1325
+ return compact unless compact.empty?
1326
+
1327
+ tool_name = call[:name].to_s
1328
+ case tool_name
1329
+ when Tools::Execute::NAME
1330
+ "command=#{preview_value_for_spinner(call[:content])}"
1331
+ when Tools::Read::NAME
1332
+ "path=#{preview_value_for_spinner(call[:content])}"
1333
+ when Tools::Write::NAME, Tools::Edit::NAME
1334
+ "path=#{preview_value_for_spinner(call[:path])}"
1335
+ when Tools::MemoryRead::NAME
1336
+ parts = []
1337
+ name = call[:content].to_s.strip
1338
+ parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
1339
+ scope = call[:scope].to_s.strip
1340
+ parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1341
+ parts.join(" ")
1342
+ when Tools::MemoryWrite::NAME
1343
+ parts = []
1344
+ path = call[:path].to_s.strip
1345
+ parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
1346
+ scope = call[:scope].to_s.strip
1347
+ parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
1348
+ parts.join(" ")
1349
+ else
1350
+ ""
1351
+ end
1352
+ end
1353
+
1354
+ def preview_value_for_spinner(value)
1355
+ text = value.to_s.gsub(/\s+/, " ").strip
1356
+ return '""' if text.empty?
1357
+
1358
+ text.inspect
1359
+ end
1360
+
1361
+ def add_unique_memory_name(ivar_name, value)
1362
+ names = instance_variable_get(ivar_name) || []
1363
+ return false if names.include?(value)
1364
+
1365
+ names << value
1366
+ instance_variable_set(ivar_name, names)
1367
+ true
1368
+ end
1369
+
1370
+ def memory_name_from_tool_call(call)
1371
+ tool_name = call[:name].to_s
1372
+ case tool_name
1373
+ when Tools::MemoryRead::NAME
1374
+ normalize_memory_name(call[:content])
1375
+ when Tools::Read::NAME
1376
+ memory_name_from_read_path(call[:content])
1377
+ else
1378
+ nil
1379
+ end
1380
+ end
1381
+
1382
+ def normalize_memory_name(raw)
1383
+ value = raw.to_s.strip
1384
+ return nil if value.empty?
1385
+
1386
+ File.basename(value, ".md")
1387
+ end
1388
+
1389
+ def memory_name_from_read_path(raw_path)
1390
+ path = raw_path.to_s.strip.tr("\\", "/")
1391
+ return nil if path.empty?
1392
+ return nil unless path.match?(/memories[\/].+\.md\z/)
1393
+
1394
+ normalize_memory_name(path)
1395
+ end
1396
+
1397
+ def memory_spinner_segment
1398
+ segment = memory_spinner_segment_plain
1399
+ return "" if segment.empty?
1400
+
1401
+ color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
1402
+ end
1403
+
1404
+ def memory_spinner_segment_plain
1405
+ names = Array(@thinking_memory_names)
1406
+ return "" if names.empty?
1407
+
1408
+ visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
1409
+ suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1410
+ " mem: #{visible.join(', ')}#{suffix}"
1411
+ end
1412
+
1413
+ def memory_sticky_line
1414
+ names = Array(@session_memory_names)
1415
+ muted = @engine.muted_memory_names
1416
+ return "" if names.empty? && muted.empty?
1417
+
1418
+ parts = []
1419
+ parts << "active this session: #{preview_names(names)}" unless names.empty?
1420
+ parts << "muted: #{preview_names(muted)}" unless muted.empty?
1421
+ body = "memories> #{parts.join(' · ')}"
1422
+ color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
1423
+ end
1424
+
1425
+ def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
1426
+ visible = names.first(limit)
1427
+ suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
1428
+ "#{visible.join(', ')}#{suffix}"
1429
+ end
1430
+
1431
+ def reset_thinking_memory_names
1432
+ @thinking_memory_names = []
1433
+ end
1434
+
1435
+ def reset_thinking_memory_notification
1436
+ @thinking_recent_memory_loaded = nil
1437
+ end
1438
+
1439
+ def reset_thinking_tool_notification
1440
+ @thinking_recent_tool_call = nil
1441
+ end
1442
+
1443
+ def capture_server_context_status_from_payload(payload)
1444
+ # The window the kernel resolved for this generation (see
1445
+ # :generation_started); the configured one before the first generation.
1446
+ window_tokens = @context_window_tokens || ContextWindow.configured.tokens
1447
+ normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
1448
+ return unless normalized
1449
+
1450
+ @latest_server_context_status = normalized
1451
+ end
1452
+
1453
+
1454
+ def emit_idle_status_line
1455
+ return unless status_line_enabled?
1456
+
1457
+ lines = idle_status_lines(width: status_effective_width)
1458
+ return if lines.empty?
1459
+
1460
+ @surface.set_slot(:status, lines)
1461
+ end
1462
+
1463
+ # ── Idle session recap ───────────────────────────────────────────────────
1464
+ #
1465
+ # The Engine's idle job emits :recap_ready once the session has been idle
1466
+ # for its inactivity threshold (or /recap asked). On the scheduler thread:
1467
+ # kept for the main thread to print at the open prompt. One collected
1468
+ # just as a turn started describes the chat before it.
1469
+ def handle_recap_ready(event)
1470
+ return unless event[:type] == :recap_ready
1471
+ return if event[:recap].to_s.strip.empty? || @engine.turn_running?
1472
+
1473
+ @pending_recap = event[:recap].to_s
1474
+ end
1475
+
1476
+ # Print a recap written while idle (main thread, at the open prompt).
1477
+ def flush_pending_recap
1478
+ recap = @pending_recap
1479
+ return unless recap
1480
+
1481
+ @pending_recap = nil
1482
+ @surface.commit(recap_block(recap))
1483
+ end
1484
+
1485
+ # ── Cards and notices between turns ─────────────────────────────────────
1486
+ #
1487
+ # A card or a plugin's notice shown outside a turn is announced; on the
1488
+ # announcing thread it is only kept, for the main thread to print at the
1489
+ # open prompt (after the command that showed it). One shown during a
1490
+ # turn is a turn event: the turn's sink prints it where it happens
1491
+ # (EventRenderer; the Screen draws it above the live region).
1492
+ #
1493
+ # An anytime command's (event[:anytime]) print as it shows them: on the
1494
+ # main thread (the command runs at the prompt) or beside a running turn
1495
+ # (above the live region), else at the next flush.
1496
+ #
1497
+ # A plugin's init task (chi.init) prints a line as it starts and one
1498
+ # when it is done; a load warning announced before the first turn, one.
1499
+ # Beside a running turn they print at once; a --non-interactive run
1500
+ # prints only its answer.
1501
+ def handle_card_event(event)
1502
+ if INIT_EVENTS.include?(event[:type])
1503
+ return if @non_interactive
1504
+ return show_pending_item(event) if @engine.turn_running?
1505
+
1506
+ return @pending_cards << event
1507
+ end
1508
+ return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
1509
+ return show_pending_item(event) if event[:anytime] && (Thread.current == Thread.main || @engine.turn_running?)
1510
+
1511
+ @pending_cards << event
1512
+ end
1513
+
1514
+ # Print the cards, notices and anytime commands' output kept since the
1515
+ # last flush (main thread). A card replaced later in the same batch (the
1516
+ # same id: btw's "thinking…", then its answer) prints once, as its last.
1517
+ def flush_pending_cards
1518
+ items = []
1519
+ loop { items << @pending_cards.pop(true) }
1520
+ rescue ThreadError
1521
+ items.each_with_index do |item, index|
1522
+ replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
1523
+ show_pending_item(item) unless replaced
1524
+ end
1525
+ nil
1526
+ end
1527
+
1528
+ INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
1529
+
1530
+ # A card, a notice, an anytime command's output (:command_output), a
1531
+ # plugin init task's line or a load warning.
1532
+ def show_pending_item(item)
1533
+ case item[:type]
1534
+ when :card then @renderer.render_card(item)
1535
+ when :command_output then @surface.commit(item[:text])
1536
+ when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
1537
+ when :plugin_init_started, :plugin_init_finished
1538
+ line = EventRenderer.init_line(item)
1539
+ @surface.commit(line) if line
1540
+ else @surface.commit(EventRenderer.hook_notice_line(item))
1541
+ end
1542
+ end
1543
+
1544
+ # Resolve the recap config (on by default). Returns false when explicitly
1545
+ # disabled, nil when nothing is configured, or a Hash when enabled so the
1546
+ # Engine can build the detector. Single precedence path via the Config
1547
+ # registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
1548
+ def recap_config
1549
+ ConfigFile.recap_config
1550
+ end
1551
+
1552
+ def bare_model_for(full_ref)
1553
+ @host_registry.bare_name(full_ref)
1554
+ end
1555
+
1556
+ def spinner_status_line
1557
+ return "" unless status_line_enabled?
1558
+
1559
+ lines = spinner_status_lines
1560
+ lines.empty? ? "" : lines.first
1561
+ end
1562
+
1563
+ def spinner_status_lines(width: status_effective_width)
1564
+ return [] unless status_line_enabled?
1565
+
1566
+ build_status_lines(scope: :spinner, width: width)
1567
+ end
1568
+
1569
+ def sticky_status_line
1570
+ return "" unless status_line_enabled?
1571
+
1572
+ lines = sticky_status_lines
1573
+ lines.empty? ? "" : lines.first
1574
+ end
1575
+
1576
+ def sticky_status_lines(width: status_effective_width)
1577
+ return [] unless status_line_enabled?
1578
+
1579
+ build_status_lines(scope: :sticky, width: width)
1580
+ end
1581
+
1582
+ def idle_status_line
1583
+ return "" unless status_line_enabled?
1584
+
1585
+ lines = idle_status_lines
1586
+ lines.empty? ? "" : lines.first
1587
+ end
1588
+
1589
+ def idle_status_lines(width: status_effective_width)
1590
+ return [] unless status_line_enabled?
1591
+
1592
+ build_status_lines(scope: :idle, width: width)
1593
+ end
1594
+
1595
+ def build_status_line(scope:)
1596
+ lines = build_status_lines(scope: scope)
1597
+ lines.empty? ? "" : lines.first
1598
+ end
1599
+
1600
+ # The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
1601
+ def build_status_lines(scope:, width: status_effective_width)
1602
+ status_rows(status_segments(scope), width)
1603
+ end
1604
+
1605
+
1606
+ def status_width_mode
1607
+ mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
1608
+ return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
1609
+
1610
+ STATUS_WIDTH_MODE_TERMINAL_CAP
1611
+ end
1612
+
1613
+ def status_effective_width
1614
+ mode = status_width_mode
1615
+ width = if mode == STATUS_WIDTH_MODE_FIXED
1616
+ status_fixed_width
1617
+ else
1618
+ [terminal_columns, status_max_width].min
1619
+ end
1620
+ width = status_fixed_width unless width.positive?
1621
+ width
1622
+ end
1623
+
1624
+ def status_fixed_width
1625
+ env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
1626
+ end
1627
+
1628
+ def status_max_width
1629
+ env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
1630
+ end
1631
+
1632
+ def terminal_columns
1633
+ columns = begin
1634
+ io = IO.console
1635
+ io&.winsize&.[](1).to_i
1636
+ rescue StandardError
1637
+ 0
1638
+ end
1639
+ return columns if columns.positive?
1640
+
1641
+ env_positive_int("COLUMNS", status_max_width)
1642
+ end
1643
+
1644
+ def env_positive_int(key, default)
1645
+ value = ENV.fetch(key, default.to_s).to_i
1646
+ value.positive? ? value : default
1647
+ end
1648
+
1649
+ def status_segments(scope)
1650
+ segments = [status_model_segment, status_server_segment].reject(&:empty?)
1651
+ context_segment = status_context_segment
1652
+ memory_segment = status_memory_segment(scope)
1653
+ segments << context_segment unless context_segment.empty?
1654
+ segments << memory_segment unless memory_segment.empty?
1655
+ # The session's --mute list, on the sticky and idle rows (not the spinner).
1656
+ muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
1657
+ segments << muted_segment unless muted_segment.empty?
1658
+ segments
1659
+ end
1660
+
1661
+ def status_model_segment
1662
+ served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
1663
+ status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
1664
+ end
1665
+
1666
+ def status_context_segment
1667
+ status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
1668
+ end
1669
+
1670
+ def status_memory_segment(scope)
1671
+ names, limit = case scope
1672
+ when :spinner
1673
+ [Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
1674
+ else
1675
+ [Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
1676
+ end
1677
+ status_memory_text(names, limit)
1678
+ end
1679
+
1680
+ def thinking_memory_notification_suffix
1681
+ memory_name = @thinking_recent_memory_loaded.to_s.strip
1682
+ return "" if memory_name.empty?
1683
+
1684
+ " memory_loaded: #{memory_name}"
1685
+ end
1686
+
1687
+ def thinking_tool_notification_suffix
1688
+ tool_call = @thinking_recent_tool_call.to_s.strip
1689
+ return "" if tool_call.empty?
1690
+
1691
+ " last_tool: #{tool_call}"
1692
+ end
1693
+
1694
+ def thinking_notification_segments(width)
1695
+ return ["", ""] if width <= 0
1696
+
1697
+ memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
1698
+ remaining = [width - memory_suffix.length, 0].max
1699
+ tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
1700
+ [memory_suffix, tool_suffix]
1701
+ end
1702
+
1703
+ def paint_if_present(text, code)
1704
+ return "" if text.to_s.empty?
1705
+
1706
+ paint(text, code)
1707
+ end
1708
+
1709
+ def thinking_spinner_enabled?
1710
+ return false unless $stdout.tty?
1711
+
1712
+ mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
1713
+ return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
1714
+
1715
+ mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
1716
+ end
1717
+
1718
+ def set_retry_spinner_status(event)
1719
+ @retry_spinner_status = {
1720
+ attempt: event[:attempt],
1721
+ max_retries: event[:max_retries],
1722
+ next_delay: event[:next_delay],
1723
+ error_class: event[:error_class]
1724
+ }
1725
+ end
1726
+
1727
+ def clear_retry_spinner_status
1728
+ @retry_spinner_status = nil
1729
+ end
1730
+
1731
+ def retry_spinner_status_active?
1732
+ @retry_spinner_status.is_a?(Hash)
1733
+ end
1734
+
1735
+ # Reset the Engine's shared inactivity clock when a prompt opens (a
1736
+ # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1737
+ # single seam the Engine-owned idle recap detector reads, so the idle clock
1738
+ # is identical for the REPL and any other Engine-backed UI. Chains onto any
1739
+ # previously-installed hook (e.g. the input prefill hook).
1740
+ def with_activity_hook
1741
+ previous_hook = Reline.pre_input_hook
1742
+ previous_key_handler = RelineSeam.key_handler
1743
+ Reline.pre_input_hook = proc do
1744
+ @engine.record_activity
1745
+ previous_hook.call if previous_hook
1746
+ end
1747
+ # Typing is activity too: a reminder waits while you type.
1748
+ RelineSeam.key_handler = -> { @engine.record_activity }
1749
+ yield
1750
+ ensure
1751
+ Reline.pre_input_hook = previous_hook
1752
+ RelineSeam.key_handler = previous_key_handler
1753
+ end
1754
+
1755
+ def render_thinking_spinner
1756
+ frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
1757
+ width = status_effective_width
1758
+ @thinking_line.tick
1759
+ # The spinner goes above the prompt, the status rows below it.
1760
+ @surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
1761
+ @thinking_spinner_last_render_at = monotonic_time
1762
+ end
1763
+
1764
+ # "model> waiting for the first token... 5s |" once a generation has
1765
+ # shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
1766
+ def first_token_wait_status(frame)
1767
+ return nil unless @thinking_waiting_since
1768
+
1769
+ waited = monotonic_time - @thinking_waiting_since
1770
+ return nil if waited < THINKING_WAIT_NOTICE_AFTER
1771
+
1772
+ "model> waiting for the first token... #{waited.floor}s #{frame}"
1773
+ end
1774
+
1775
+ def thinking_spinner_status_line(frame)
1776
+ lines = thinking_spinner_status_lines(frame)
1777
+ lines.empty? ? "" : lines.first
1778
+ end
1779
+
1780
+ # The spinner row, then (from a turn's 3rd tool call) its tool tally.
1781
+ # The spinner stops while tools run, so the tally shows while the model
1782
+ # generates between tool rounds.
1783
+ def thinking_spinner_status_lines(frame, width: status_effective_width)
1784
+ spinner_row_lines(frame, width: width) + tally_status_lines(width)
1785
+ end
1786
+
1787
+ def tally_status_lines(width)
1788
+ tally = @turn_tally.text(width: width)
1789
+ return [] unless tally
1790
+
1791
+ [color_output? ? paint(tally, 90) : tally]
1792
+ end
1793
+
1794
+ def tally_key(event)
1795
+ [event[:iteration].to_i, event[:call_index].to_i]
1796
+ end
1797
+
1798
+ def spinner_row_lines(frame, width:)
1799
+ if retry_spinner_status_active?
1800
+ return [retry_spinner_status_line(frame, width)]
1801
+ end
1802
+
1803
+ return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
1804
+
1805
+ base = first_token_wait_status(frame) || "model> thinking... #{frame}"
1806
+ available_for_notification = [width - base.length, 0].max
1807
+ memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
1808
+ notification = "#{memory_notification}#{tool_notification}"
1809
+ base = thinking_sentence_base(frame, width - notification.length) || base
1810
+
1811
+ return ["#{base}#{notification}"] unless color_output?
1812
+
1813
+ ["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
1814
+ end
1815
+
1816
+ # "model> thinking · <the newest sentence> |" within +room+ columns (the
1817
+ # web's thinking ticker, ThinkingLine), or nil before the first sentence.
1818
+ def thinking_sentence_base(frame, room)
1819
+ return nil if @thinking_waiting_since || @thinking_line.empty?
1820
+
1821
+ prefix = "model> #{@thinking_line.label} · "
1822
+ room -= prefix.length + frame.length + 1
1823
+ return nil if room < MIN_SENTENCE_ROOM
1824
+
1825
+ "#{prefix}#{@thinking_line.fit(room)} #{frame}"
1826
+ end
1827
+
1828
+ # ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
1829
+
1830
+ # Non-blocking observer: the turn thread emits :question_requested; we stash
1831
+ # it so the REPL thread (the only one that may touch Reline) can drain it
1832
+ # at the top of run_assist_loop without racing the completion.
1833
+ def handle_question_event(event)
1834
+ return unless event.is_a?(Hash)
1835
+
1836
+ type = event[:type] || event["type"]
1837
+ return unless type.to_s == "question_requested"
1838
+
1839
+ pq = event[:pending_question] || event["pending_question"] || event[:pendingQuestion]
1840
+ return unless pq
1841
+
1842
+ @pending_question_event = pq
1843
+ rescue StandardError
1844
+ nil
1845
+ end
1846
+
1847
+ def pending_question_event?
1848
+ !!@pending_question_event
1849
+ end
1850
+
1851
+ # Called from REPL thread (run_assist_loop top) — renders widget, blocks
1852
+ # until user selects, then answers via Engine#answer_question which wakes
1853
+ # the parked turn thread.
1854
+ def drain_pending_question?
1855
+ pq = @pending_question_event
1856
+ # Also check Engine's persisted pending (covers resume)
1857
+ pq ||= @engine.pending_question
1858
+ return false unless pq
1859
+
1860
+ # If the pending no longer matches the engine's current pending (stale
1861
+ # event after sync handler already answered and cleared), discard.
1862
+ current = @engine.pending_question
1863
+ if current && (pq[:id] || pq["id"]).to_s != (current[:id] || current["id"]).to_s
1864
+ @pending_question_event = nil
1865
+ return false
1866
+ end
1867
+ if current.nil? && @pending_question_event
1868
+ # Stale stash where engine already cleared (sync path completed)
1869
+ @pending_question_event = nil
1870
+ return false
1871
+ end
1872
+
1873
+ @pending_question_event = nil
1874
+ result = render_question_widget(pq)
1875
+ return false unless result
1876
+
1877
+ # result is already answered via Engine#answer_question in render_question_widget
1878
+ true
1879
+ rescue StandardError => e
1880
+ Log.warn(:repl, "question_drain_failed", echo: "[ask_user_question] drain failed: #{e.class}: #{e.message}", error: e.class.name)
1881
+ false
1882
+ end
1883
+
1884
+ # The choices wait in the notes slot, laid out for the rows it gets, and
1885
+ # the answer is a line at the ? prompt. Once the question closes, one
1886
+ # line (the question and what became of it) stays in the scrollback.
1887
+ def render_question_widget(pending)
1888
+ prompt = QuestionPrompt.new(pending)
1889
+
1890
+ # Ensure spinner cleared and terminal in known state (same as reminder mute handling)
1891
+ finish_thinking_spinner rescue nil
1892
+ choices = prompt.slot(paint: method(:paint))
1893
+
1894
+ question_prompt = paint(QUESTION_PROMPT, 33)
1895
+ unless @repl_input
1896
+ @surface.set_slot(:notes, choices)
1897
+ return answer_question_widget(prompt) { read_choice_line(question_prompt) }
1898
+ end
1899
+
1900
+ # On a terminal the prompt is open (a turn runs with it): it turns into
1901
+ # the ? prompt for the answer. Only a line submitted there answers,
1902
+ # never one typed before the question came. The choices show once the
1903
+ # typed text is out of the prompt.
1904
+ @repl_input.ask(question_prompt) do |answers|
1905
+ @surface.set_slot(:notes, choices)
1906
+ answer_question_widget(prompt) { take_open_prompt_answer(answers) }
1907
+ end
1908
+ ensure
1909
+ @surface.clear_slot(:notes)
1910
+ end
1911
+
1912
+ # Loop until a valid selection or a cancel. The block reads one answer:
1913
+ # a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
1914
+ # was cancelled).
1915
+ def answer_question_widget(prompt)
1916
+ loop do
1917
+ raw = begin
1918
+ yield
1919
+ rescue Interrupt
1920
+ nil
1921
+ end
1922
+ raw = raw.to_s.strip unless raw.nil? || raw == :canceled
1923
+ if raw.nil? || raw == :canceled || raw.empty?
1924
+ # Empty, EOF / Ctrl-D / Ctrl-C -> cancel (an approval: denied)
1925
+ @engine.cancel_question("user") rescue nil
1926
+ outcome = if raw == :canceled then "(turn cancelled)"
1927
+ elsif prompt.approval? then "(denied)"
1928
+ else "(cancelled)"
1929
+ end
1930
+ close_question_widget(prompt, outcome)
1931
+ return false
1932
+ end
1933
+
1934
+ answer = prompt.parse(raw)
1935
+ @surface.commit(answer.note) if answer.note
1936
+ unless answer.ok?
1937
+ # The read left no echo: the line shows above its error.
1938
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}")
1939
+ @surface.commit(answer.error)
1940
+ next
1941
+ end
1942
+
1943
+ begin
1944
+ @engine.answer_question(id: prompt.id, selected: answer.selected, freeform: answer.freeform)
1945
+ close_question_widget(prompt, prompt.answer_text(answer))
1946
+ return true
1947
+ rescue ArgumentError => e
1948
+ @surface.commit("Invalid: #{e.message}. Try again.")
1949
+ next
1950
+ rescue StandardError => e
1951
+ close_question_widget(prompt, "(error: #{e.message})")
1952
+ return false
1953
+ end
1954
+ end
1955
+ end
1956
+
1957
+ def close_question_widget(prompt, outcome)
1958
+ @surface.clear_slot(:notes)
1959
+ @surface.commit(prompt.summary(outcome, paint: method(:paint)))
1960
+ end
1961
+
1962
+ # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1963
+ def read_choice_line(question_prompt)
1964
+ @surface.set_slot(:editor, [question_prompt])
1965
+ $stdin.gets
1966
+ end
1967
+
1968
+ # The next line submitted at the open prompt (?), nil for Ctrl-D
1969
+ # or a Ctrl-C with no turn running, or :canceled when Ctrl-C cancels the
1970
+ # running turn first (the read goes on; the question closes).
1971
+ def take_open_prompt_answer(answers)
1972
+ controller = @active_cancel_controller
1973
+ loop do
1974
+ kind, line = answers.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
1975
+ return kind == :line ? line : nil if kind
1976
+ return :canceled if controller&.cancelled?
1977
+ end
1978
+ end
1979
+
1980
+ # Process a prompt through the kernel loop and return the model response.
1981
+ # Delegates to the internal Engine instance.
1982
+ def process_prompt_through_kernel(session, prompt)
1983
+ @engine.process_prompt_through_kernel(session, prompt)
1984
+ end
1985
+
1986
+ # Write an agent response to the session output directory.
1987
+ def write_session_output(output_dir, response)
1988
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
1989
+ File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
1990
+ end
1991
+ end
1992
+ end