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,1037 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "event_renderer"
5
+ require_relative "formatting"
6
+ require_relative "attached_view"
7
+ require_relative "input_support"
8
+ require_relative "image_input"
9
+ require_relative "line_reader"
10
+ require_relative "question_prompt"
11
+ require_relative "reline_seam"
12
+ require_relative "../bridge_client"
13
+ require_relative "../log"
14
+ require_relative "../context_note"
15
+ require_relative "../model_profile"
16
+ require_relative "../output_formatter"
17
+ require_relative "../session_commands"
18
+ require_relative "../session_manager"
19
+
20
+ module Samagotchi
21
+ class TerminalUI
22
+ # The TUI attached to a session that a worker owns (`chi --attach`):
23
+ # a thin Bridge client, with no Engine and no OwnerLock of its own.
24
+ #
25
+ # #handle_event turns the worker's event stream into terminal output:
26
+ # the snapshot a join starts with (the last exchange, queued prompts,
27
+ # the turn so far), prompts other clients sent, and each turn through the
28
+ # same EventRenderer the local REPL uses, drawn by an AttachedView.
29
+ class AttachedLoop
30
+ include Formatting
31
+ include InputSupport
32
+
33
+ STATS_COMMAND = "/stats"
34
+ RECAP_COMMAND = "/recap"
35
+ PROMPT = "> "
36
+ # Detach and leave the worker up.
37
+ DETACH_COMMANDS = %w[/detach].freeze
38
+ # Detach and ask the worker to exit (bare `exit` too, as in the REPL).
39
+ EXIT_COMMANDS = %w[/exit /quit].freeze
40
+ # After an exit command: delete the session once the worker has gone.
41
+ DELETE_FLAG = "--delete"
42
+ # How long /exit --delete waits for the worker to let go.
43
+ DELETE_WAIT = 10
44
+ # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
45
+ DETACH_WINDOW = 2.0
46
+ DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
47
+ # Why a request the Bridge read after its deadline didn't go through.
48
+ LATE = "worker not answering in time"
49
+ # Why the worker stayed up after /exit, by the reason it gave.
50
+ HELD_REASONS = {
51
+ "turn_running" => "a turn is running", "input_queued" => "prompts are queued",
52
+ "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
53
+ "reminders" => "reminders are set", "starting" => "the worker is still starting"
54
+ }.freeze
55
+ ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
56
+ # How much of the last answer a join shows.
57
+ JOIN_ANSWER_LINES = 12
58
+ JOIN_ANSWER_CHARS = 1200
59
+
60
+ # Prompt labels by the sender's client_id prefix.
61
+ CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
62
+
63
+ attr_reader :client_id
64
+
65
+ # @return [String, nil] the model the worker's turns run on, as the
66
+ # last command said
67
+ attr_reader :model_name
68
+
69
+ # @return [QuestionPrompt, nil] the question waiting for an answer
70
+ attr_reader :question
71
+
72
+ # @param client [BridgeClient]
73
+ # @param screen [Surface] with #synchronize and #columns (a Screen, or a
74
+ # PlainSurface when the terminal can't show a live region)
75
+ # @param client_id [String] this UI's id in the events ("tui:<pid>")
76
+ # @param first_prompt [String, nil] sent once joined (`chi -p`)
77
+ # @param first_command [String, nil] run before the first prompt
78
+ # (`--model` on a resumed session: "/model X"); if it doesn't go
79
+ # through, the launch stops
80
+ # @param no_interrupt [Boolean] post every turn with no_interrupt
81
+ # @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
82
+ # the first read (a new session with no -p, as the REPL)
83
+ # @param clock [#call] monotonic seconds (the Ctrl-C detach window)
84
+ # @param delete_session [#call] session id -> deletes it (/exit --delete)
85
+ def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
86
+ default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
87
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) })
88
+ @client = client
89
+ @delete_session = delete_session
90
+ @screen = screen
91
+ @client_id = client_id
92
+ @view = AttachedView.new(screen)
93
+ @renderer = EventRenderer.new(@view)
94
+ @shown_enqueued = Set.new
95
+ @running = false
96
+ @joined_mid_turn = false
97
+ @attached = false
98
+ @question = nil
99
+ @answered_ids = Set.new
100
+ @reader = nil
101
+ @first_prompt = first_prompt
102
+ @first_command = first_command
103
+ @first_command_id = nil
104
+ @no_interrupt = no_interrupt
105
+ @no_default_input = !default_input
106
+ @clock = clock
107
+ @last_idle_interrupt_at = nil
108
+ @next_input_prefill = nil
109
+ # Prompts this run sent, by enqueued_id: only those come back into
110
+ # the input when their turn fails (a replayed event must not).
111
+ @sent_ids = Set.new
112
+ # A continue offer is pending: the prompt asks for the answer.
113
+ @continue_offer = nil
114
+ @model_name = nil
115
+ @turn_continues = false
116
+ # The idle status line's data (the REPL's segments).
117
+ @memory_names = []
118
+ # The session's --memory list (shown with the used memories until the
119
+ # first turn records them) and its --mute list, from the join.
120
+ @preloaded_names = []
121
+ @muted_names = []
122
+ # The session that delegated this one, shown as ↳ <short id>.
123
+ @parent_id = nil
124
+ @context_estimate = nil
125
+ @status_rows = nil
126
+ end
127
+
128
+ def running? = @running
129
+
130
+ # Follow the session and read input until Ctrl-D, /detach, /exit or
131
+ # the worker goes away. The worker keeps running after a detach; /exit
132
+ # asks it to exit too, and it does unless something still needs it.
133
+ # @return [Symbol] :detached, :closed when the worker went away, or
134
+ # :failed when the first command (--model) didn't go through
135
+ # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
136
+ # Interrupt = Ctrl-C); defaults to Reline
137
+ def run(input: nil)
138
+ queue = @queue = Thread::Queue.new
139
+ load_persistent_history
140
+ # Reline's seam asks this on Ctrl-C while the typed text is still
141
+ # there; declining lets the read end with Interrupt as before.
142
+ previous_interrupt_handler = RelineSeam.interrupt_handler
143
+ RelineSeam.interrupt_handler = method(:note_interrupted_line)
144
+ # Tagged, so the worker doesn't count this stream as another UI
145
+ # when this UI asks it to exit.
146
+ stream = @client.follow(client_id: @client_id) { |event| queue << [:event, event] }
147
+ @reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
148
+ prefill: default_input_text).start
149
+ loop do
150
+ kind, payload = next_item(queue)
151
+ case kind
152
+ when :event
153
+ ended = safely_handle(payload)
154
+ return ended if ended == :closed || ended == :failed
155
+ when :line then return :detached if submit(payload) == :detach
156
+ when :interrupt then return :detached if interrupt(payload) == :detach
157
+ end
158
+ end
159
+ ensure
160
+ RelineSeam.interrupt_handler = previous_interrupt_handler
161
+ # The loop is over: end the read the reader still has open. Not under
162
+ # the screen's lock: the reader may be waiting for it to draw. The
163
+ # read takes its prompt away as it ends; clearing the editor slot
164
+ # covers a reader that had to be killed.
165
+ @reader&.stop
166
+ @view.stop
167
+ @screen.clear_slot(:editor)
168
+ stream&.close
169
+ end
170
+
171
+ # Render one Bridge event (string keys).
172
+ # @return [Symbol, nil] :closed when the stream ended, :failed when the
173
+ # launch's first command didn't go through
174
+ def handle_event(event)
175
+ event = EventRenderer.symbolize(event)
176
+ case event[:type]
177
+ when :snapshot, :reset
178
+ take_session_state(event[:session_state_snapshot] || {})
179
+ return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
180
+ when :turn_enqueued then show_enqueued(event)
181
+ when :turn_started then start_turn(event)
182
+ when :turn_completed then complete_turn(event)
183
+ when :turn_canceled
184
+ continued = @turn_continues
185
+ end_turn("turn cancelled (#{event[:cancellation_reason]})")
186
+ # A cancelled continue is back where it started; a prompt turn's
187
+ # partial progress stays, as in the REPL.
188
+ @screen.commit(ROLLBACK_HINT) unless continued
189
+ when :turn_failed
190
+ end_turn("turn failed: #{event[:summary] || "#{event[:message]} (#{event[:error_class]})"}")
191
+ when :prompt_restored then restore_prompt(event)
192
+ when :context_status
193
+ @context_estimate = { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] }
194
+ refresh_status
195
+ when :used_memories_updated
196
+ @memory_names = Array(event[:used_memory_names])
197
+ refresh_status
198
+ # Another client's anytime command: its line now, before the cards
199
+ # it shows (its command_ran comes when it's done).
200
+ when :command_queued
201
+ @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
202
+ when :command_ran
203
+ command_ran(event)
204
+ return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
205
+ when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
206
+ # A context note joined the conversation (between turns). One dim
207
+ # line in the scrollback; the notes slot is for question choices.
208
+ when :context_added then @screen.commit(context_note_line(event[:label], event[:text]))
209
+ when :continue_offered then offer_continue(event)
210
+ when :continue_resolved then continue_resolved(event)
211
+ # The note comes with the kernel's :pending_input_merged (EventRenderer),
212
+ # after the answer the merge follows.
213
+ when :input_merged then nil
214
+ when :question_requested then ask(event[:pending_question])
215
+ when :question_answered then question_answered(event)
216
+ when :question_cancelled
217
+ close_question("(question cancelled)") if @question
218
+ # Written while the session sat idle: shown at the open prompt. One
219
+ # collected just as a turn started describes the chat before it.
220
+ when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
221
+ when :guardrail_warning then @screen.commit(EventRenderer.load_warning_line(event))
222
+ # A plugin's slow setup: the activity row turns while it runs; a
223
+ # line when it is done (a failure is a warn card).
224
+ when :plugin_init_started then @view.init_started(event)
225
+ when :plugin_init_finished
226
+ @view.init_finished(event)
227
+ line = EventRenderer.init_line(event)
228
+ @screen.commit(line) if line
229
+ when :generation_completed
230
+ take_served_model(event[:served_model], event[:requested_model])
231
+ @renderer.call(event)
232
+ when :stream_closed
233
+ @view.finish_thinking_spinner
234
+ @screen.commit("Lost the session's worker (#{event[:reason]}). " \
235
+ "Resume it with: chi --shared --resume #{@client.session_id}")
236
+ return :closed
237
+ else @renderer.call(event)
238
+ end
239
+ nil
240
+ end
241
+
242
+ private
243
+
244
+ def next_item(queue)
245
+ queue.pop
246
+ rescue Interrupt
247
+ # Ctrl-C while no prompt is open (between two reads).
248
+ [:interrupt]
249
+ end
250
+
251
+ # A rendering bug must not end the session's UI.
252
+ def safely_handle(event)
253
+ handle_event(event)
254
+ rescue StandardError => e
255
+ @screen.commit("(could not render #{event["type"] || event[:type]}: #{e.class}: #{e.message})")
256
+ nil
257
+ end
258
+
259
+ # @return [Symbol, nil] :detach to end the loop
260
+ def submit(line)
261
+ return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
262
+
263
+ text = line.strip
264
+ return answer_question(text) if @question
265
+ # Before the continue offer: neither is an answer to it.
266
+ return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
267
+ return exit_worker if exit_command?(text)
268
+ return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
269
+ command = text.split(/\s+/, 2).first
270
+ if @continue_offer
271
+ return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
272
+
273
+ # Not an answer: the read left no echo, so show what ran.
274
+ echo_answer(text)
275
+ end
276
+ return if text.empty?
277
+
278
+ if command == STATS_COMMAND
279
+ show_stats
280
+ elsif command == RECAP_COMMAND
281
+ show_recap
282
+ elsif command_registry.command?(text)
283
+ # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
284
+ persist_recent_history(text) if shell_line?(text)
285
+ send_command(text)
286
+ else
287
+ send_prompt(text)
288
+ end
289
+ nil
290
+ end
291
+
292
+ # @return [Symbol] :detach
293
+ def detach(line)
294
+ @view.finish_thinking_spinner
295
+ @screen.commit(line)
296
+ :detach
297
+ end
298
+
299
+ # Detach, and ask the worker to exit now. The worker decides: it stays
300
+ # up while anything still needs it (a turn, queued input, a continue
301
+ # offer, another UI, reminders), and the line says which.
302
+ # @return [Symbol] :detach
303
+ def exit_worker
304
+ detach(exit_line(@client.request_exit(client_id: @client_id)))
305
+ rescue SystemCallError, IOError => e
306
+ detach(exit_failed_line(e.message))
307
+ end
308
+
309
+ def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
310
+
311
+ # /exit --delete: the worker exits as for /exit, then the session is
312
+ # deleted. When the worker stays up (another UI, queued input, ...),
313
+ # nothing is deleted.
314
+ def exit_and_delete
315
+ reply = @client.request_exit(client_id: @client_id, delete: true)
316
+ id = @client.session_id
317
+ unless reply.status == 200
318
+ line = exit_line(reply)
319
+ return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
320
+
321
+ return detach("#{line} Not deleted.")
322
+ end
323
+ # An empty session: the worker deletes it as it leaves.
324
+ @delete_session.call(id) unless discards?(reply)
325
+ detach("Detached; deleted session #{id}.")
326
+ rescue SystemCallError, IOError => e
327
+ detach("#{exit_failed_line(e.message)} Not deleted.")
328
+ rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
329
+ detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
330
+ end
331
+
332
+ def exit_line(reply)
333
+ id = @client.session_id
334
+ error = reply.json&.fetch("error", nil)
335
+ case reply.status
336
+ when 200
337
+ # Said as the worker agreed to leave; a note coming in before it
338
+ # does keeps the session after all (rare, left as is).
339
+ return "Detached; the session was empty, so it is discarded." if discards?(reply)
340
+
341
+ "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
342
+ when 409
343
+ reason = reply.json&.fetch("reason", nil).to_s
344
+ "Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
345
+ when 0 then exit_failed_line("no reply")
346
+ else
347
+ # A worker from before the route answers the Bridge's plain 404.
348
+ return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
349
+
350
+ exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
351
+ end
352
+ end
353
+
354
+ # An older worker leaves the field out.
355
+ def discards?(reply) = reply.json&.fetch("discard", nil) == true
356
+
357
+ def exit_failed_line(why)
358
+ "Detached (could not ask the worker to stop: #{why}). Re-attach with: chi --attach #{@client.session_id}"
359
+ end
360
+
361
+ # The worker says what became of it (:continue_resolved); an invalid
362
+ # answer gets its error and the offer stays.
363
+ def answer_continue(text)
364
+ echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
365
+ @continue_answer = text.empty? ? "yes" : text
366
+ send_command(continue_line(text))
367
+ nil
368
+ end
369
+
370
+ # A bare answer at the continue prompt, as the REPL reads it (an empty
371
+ # one is yes).
372
+ def continue_line(text)
373
+ text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
374
+ end
375
+
376
+ # The worker runs it and every UI renders its :command_ran, this one
377
+ # too, so nothing waits here.
378
+ def send_command(line)
379
+ reply = @client.post_command(line: line, client_id: @client_id)
380
+ return if reply.status == 202
381
+ return @screen.commit(stale_worker("run commands")) if reply.status == 404
382
+ return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
383
+
384
+ detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
385
+ @screen.commit("could not run the command (#{[reply.status, detail].compact.join(" ")})")
386
+ rescue SystemCallError, IOError => e
387
+ @screen.commit("could not run the command (#{worker_down(e)})")
388
+ end
389
+
390
+ # A request the Bridge read after its deadline and dropped
391
+ # (BridgeClient::DEADLINE_SHARE): it didn't go through.
392
+ def too_late?(reply) = reply.status == 408 && reply.json&.fetch("error", nil) == "deadline_passed"
393
+
394
+ # Why a Bridge request raised, for the line that says what didn't go
395
+ # through: a stopped (or wedged) worker times out, a gone one refuses.
396
+ def worker_down(error)
397
+ head, detail = error.message.split(" - ", 2)
398
+ return "worker not answering: #{(detail || head).sub(/\Abridge \S+: /, "")}" if error.is_a?(Errno::ETIMEDOUT)
399
+
400
+ "worker unreachable: #{head.downcase}"
401
+ end
402
+
403
+ def command_ran(event)
404
+ @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
405
+ output = event[:output].to_s
406
+ if output.empty?
407
+ nil
408
+ elsif event[:status] == "busy" || shell_line?(event[:line])
409
+ @screen.commit(output)
410
+ put_back_busy_command(event) if event[:status] == "busy"
411
+ else
412
+ @screen.commit("#{paint("model>", 36)} #{output}")
413
+ end
414
+ return unless event[:model_name]
415
+
416
+ # After the output, so the status row changes with the line saying why.
417
+ @served_model = nil if @model_name != event[:model_name]
418
+ @model_name = event[:model_name]
419
+ refresh_status
420
+ end
421
+
422
+ # Our command the worker couldn't run yet goes back into the prompt, for
423
+ # Enter once the turn ends (not the launch's --model: that ends the launch).
424
+ def put_back_busy_command(event)
425
+ return unless own?(event[:client_id]) && event[:command_id] != @first_command_id
426
+
427
+ @screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
428
+ end
429
+
430
+ def shell_line?(line)
431
+ line.to_s.start_with?(SessionCommands::SHELL_BANG_PREFIX) && line.to_s.strip != SessionCommands::ROLLBACK_COMMAND
432
+ end
433
+
434
+ def offer_continue(event)
435
+ @continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
436
+ sync_prompt
437
+ sync_continue_slot
438
+ end
439
+
440
+ CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason" }.freeze
441
+
442
+ # The offer's choices go; one line says what became of it. Another
443
+ # UI's answer shows as its command line (web> /continue yes) instead.
444
+ def continue_resolved(event)
445
+ @continue_offer = nil
446
+ sync_continue_slot
447
+ who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
448
+ outcome = if event[:decision] == "dropped"
449
+ "(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
450
+ elsif own?(event[:client_id])
451
+ @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
452
+ end
453
+ @continue_answer = nil
454
+ @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
455
+ sync_prompt
456
+ end
457
+
458
+ # The notes slot shows the offer's choices while it waits (a question
459
+ # has the slot while it is open).
460
+ def sync_continue_slot
461
+ return if @question
462
+
463
+ if @continue_offer
464
+ @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
465
+ else
466
+ @screen.clear_slot(:notes)
467
+ end
468
+ end
469
+
470
+ def send_prompt(text)
471
+ persist_recent_history(text)
472
+ # #memory shorthand becomes words the model reads, as in the REPL.
473
+ prompt = normalize_model_input(text)
474
+ images = attach_images(text)
475
+ return if images.nil?
476
+
477
+ options = { prompt: prompt, client_id: @client_id }
478
+ options[:no_interrupt] = true if @no_interrupt
479
+ options[:images] = images unless images.empty?
480
+ reply = @client.post_turn(**options)
481
+ if reply.status == 202
482
+ enqueued_id = reply.json&.fetch("enqueued_id", nil)
483
+ @sent_ids << enqueued_id if enqueued_id
484
+ return
485
+ end
486
+
487
+ detail = reply.json&.fetch("error", nil)
488
+ # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
489
+ return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
490
+
491
+ explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
492
+ @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
493
+ rescue SystemCallError, IOError => e
494
+ @screen.commit("could not send the prompt (#{worker_down(e)})")
495
+ end
496
+
497
+ # The prompt's `@path` images, stored in the session's images/ here
498
+ # (this machine, the worker's state dir) and sent as refs: the Bridge
499
+ # never takes a path. nil (after a line saying why) when one can't be.
500
+ def attach_images(text)
501
+ paths = ImageInput.extract(text)
502
+ return [] if paths.empty?
503
+
504
+ session_dir = Session.session_dir(@client.session_id)
505
+ paths.map do |image|
506
+ ref = ImageStore.ingest(session_dir, path: image[:path])
507
+ { file: ref[:file], name: ref[:name] }
508
+ end
509
+ rescue ImageStore::Error => e
510
+ @screen.commit("could not attach the image: #{e.message}")
511
+ nil
512
+ end
513
+
514
+ def show_stats
515
+ # A worker from before the stats route answers only /state.
516
+ metrics = @client.get_json("stats")&.dig("metrics") || @client.get_json("state")&.dig("session_state_snapshot", "metrics")
517
+ @screen.commit(metrics ? format_session_metrics(EventRenderer.deep_symbolize_keys(metrics)) : "(no metrics: the worker did not answer)")
518
+ end
519
+
520
+ # One read on the reader thread: the REPL's multiline read with Tab
521
+ # completion at the main prompt, a plain line for a question's choice
522
+ # or the continue offer's answer.
523
+ def read_input_line(prompt, prefill)
524
+ return $stdin.gets&.chomp unless $stdin.tty?
525
+ # A question's answer leaves only its summary line.
526
+ return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
527
+
528
+ queue_input_prefill(prefill) if prefill
529
+ read_prompt_line(prompt)
530
+ end
531
+
532
+ # The attached TUI's commands, for Tab.
533
+ def slash_commands = command_registry.completions(:attached)
534
+
535
+ # The session's commands as its worker's snapshot names them (its
536
+ # plugins' too); the built-ins until one arrives, or from a worker too
537
+ # old to name them. An unknown /foo is a prompt, as in the REPL.
538
+ def command_registry = @command_registry || SessionCommands.builtin_registry
539
+
540
+ # The saved recap, and a new one asked for at once (it arrives as
541
+ # :recap_ready), with the REPL's words.
542
+ def show_recap
543
+ reply = @client.request_recap
544
+ answer = reply.status == 200 ? reply.json : nil
545
+ return @screen.commit("(no recap: the worker did not answer)") unless answer
546
+
547
+ @screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
548
+ request: answer["request"], min_user_turns: answer["min_user_turns"]))
549
+ rescue SystemCallError, IOError
550
+ @screen.commit("(no recap: the worker did not answer)")
551
+ end
552
+
553
+ def prompt_text
554
+ return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
555
+
556
+ paint(PROMPT, 92)
557
+ end
558
+
559
+ # Switch the open prompt to ? for the answer and show the choices in
560
+ # the notes slot, fitted to the terminal. Once the question closes,
561
+ # one line (the question and what became of it) stays.
562
+ def ask(pending)
563
+ return unless pending
564
+
565
+ @view.finish_thinking_spinner
566
+ # What was typed at the prompt waits for the question to close.
567
+ @set_aside = @reader&.typed_text unless @question
568
+ @question = QuestionPrompt.new(pending)
569
+ # The prompt first, so the choices never show under the typed text.
570
+ sync_prompt(keep_text: false)
571
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
572
+ end
573
+
574
+ def answer_question(text)
575
+ return dismiss_question if text.empty?
576
+
577
+ answer = @question.parse(text)
578
+ @screen.commit(answer.note) if answer.note
579
+ unless answer.ok?
580
+ echo_answer(text)
581
+ return @screen.commit(answer.error)
582
+ end
583
+
584
+ reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform)
585
+ case reply.status
586
+ when 200
587
+ @answered_ids << @question.id
588
+ close_question(@question.answer_text(answer))
589
+ when 409 then close_question("(already answered in another UI)")
590
+ when 408 then @screen.commit("could not answer (#{LATE})")
591
+ else
592
+ detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
593
+ @screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
594
+ end
595
+ nil
596
+ rescue SystemCallError, IOError => e
597
+ @screen.commit("could not answer (#{worker_down(e)})")
598
+ nil
599
+ end
600
+
601
+ # An empty answer dismisses the question, as in the REPL: the tool
602
+ # returns unanswered (an approval: denied) and the turn goes on. The
603
+ # :question_cancelled every UI gets finds it already closed here.
604
+ def dismiss_question
605
+ reply = @client.dismiss_question(id: @question.id)
606
+ case reply.status
607
+ when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
608
+ when 409 then close_question("(question already closed in another UI)")
609
+ when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
610
+ when 408 then @screen.commit("could not dismiss the question (#{LATE}); Ctrl-C cancels the turn")
611
+ else
612
+ # The question stays open (after a 404 too).
613
+ detail = reply.json&.fetch("error", nil)
614
+ @screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
615
+ end
616
+ nil
617
+ rescue SystemCallError, IOError => e
618
+ @screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
619
+ nil
620
+ end
621
+
622
+ # A line read at ? that isn't a (valid) answer: the read left no echo,
623
+ # so it shows above what it got.
624
+ def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
625
+
626
+ def stale_worker(cant)
627
+ BridgeClient.stale_worker_message(@client.session_id, cant: cant)
628
+ end
629
+
630
+ # The worker rolled a failed turn back and handed its prompt back: ours
631
+ # goes back into the input, as the REPL restores it for a retry.
632
+ def restore_prompt(event)
633
+ origin = event[:origin] || {}
634
+ return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
635
+
636
+ if @reader&.prefill(event[:prompt].to_s)
637
+ @screen.commit("(prompt restored for retry)")
638
+ else
639
+ @screen.commit("(the failed prompt is in the input history: ↑)")
640
+ end
641
+ end
642
+
643
+ def question_answered(event)
644
+ return unless @question
645
+ return close_question(nil) if @answered_ids.include?(event[:id])
646
+
647
+ answer = event[:answer] || {}
648
+ picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
649
+ close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
650
+ end
651
+
652
+ # @param outcome [String, nil] what became of it, for the line that
653
+ # stays in the scrollback (nil: none, it was closed already)
654
+ def close_question(outcome)
655
+ @screen.clear_slot(:notes)
656
+ @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
657
+ @question = nil
658
+ set_aside = @set_aside
659
+ @set_aside = nil
660
+ sync_prompt(keep_text: false, prefill: set_aside)
661
+ sync_continue_slot if @continue_offer
662
+ end
663
+
664
+ # Restart the open read when its prompt no longer fits (a question or a
665
+ # continue offer opened or closed), first erasing the prompt Reline drew.
666
+ # What is typed there goes along (+keep_text+), or +prefill+ goes in.
667
+ def sync_prompt(keep_text: true, prefill: nil)
668
+ return unless @reader
669
+
670
+ @screen.synchronize do
671
+ next if @reader.current == prompt_text
672
+
673
+ @screen.clear_slot(:editor)
674
+ @reader.reprompt(keep_text: keep_text, prefill: prefill)
675
+ end
676
+ end
677
+
678
+ # Ctrl-C cancels the running turn (whoever started it). At an idle
679
+ # prompt it clears the line; on an empty one it says how to detach, and
680
+ # a second press within DETACH_WINDOW detaches (D4).
681
+ # @param pressed [Hash, nil] {text:, at:} from the read the press ended
682
+ # (nil: pressed with no read open)
683
+ # @return [Symbol, nil] :detach
684
+ def interrupt(pressed = nil)
685
+ typed = pressed&.fetch(:text, nil).to_s
686
+ if @running
687
+ begin
688
+ @client.cancel(reason: "ctrl_c")
689
+ rescue SystemCallError, IOError => e
690
+ @screen.commit("could not cancel the turn (#{worker_down(e)})")
691
+ end
692
+ return nil
693
+ end
694
+ unless typed.strip.empty?
695
+ @last_idle_interrupt_at = nil
696
+ return nil
697
+ end
698
+
699
+ now = pressed&.fetch(:at, nil) || @clock.call
700
+ return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
701
+
702
+ @last_idle_interrupt_at = now
703
+ @screen.commit(DETACH_HINT)
704
+ nil
705
+ end
706
+
707
+ # On the reader thread, from Reline's Ctrl-C: what the line held, and
708
+ # when. During a turn the press goes to the loop (it cancels the turn)
709
+ # and the read goes on with the typed text still in it, as in the REPL;
710
+ # otherwise it is noted for the Interrupt the LineReader queues next.
711
+ # @return [Boolean] true: handled, the read goes on; false: Reline ends
712
+ # the read with Interrupt
713
+ def note_interrupted_line
714
+ pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
715
+ if @running
716
+ @queue << [:interrupt, pressed]
717
+ return true
718
+ end
719
+
720
+ Thread.current[:interrupted_line] = pressed
721
+ false
722
+ rescue StandardError
723
+ false
724
+ end
725
+
726
+ def render_snapshot(snapshot, reset:)
727
+ @view.finish_thinking_spinner
728
+ if @attached
729
+ @screen.commit("(resynced with the session)") if reset
730
+ else
731
+ @attached = true
732
+ # The recap first: what the session was about, then where it stopped.
733
+ saved = snapshot[:saved_recap]
734
+ @screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved && !saved[:text].to_s.strip.empty?
735
+ render_join_header(Array(snapshot[:messages]))
736
+ @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
737
+ @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
738
+ end
739
+ if snapshot[:commands]
740
+ @command_registry = Commands::Registry.from_listing(snapshot[:commands], base: SessionCommands.builtin_registry)
741
+ end
742
+ Array(snapshot[:init_tasks]).each { |task| @view.init_started(task) }
743
+ cards = Array(snapshot[:cards])
744
+ render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
745
+ offer = snapshot[:continue_offer]
746
+ had_offer = !@continue_offer.nil?
747
+ @continue_offer = offer && { context: offer[:context], no_interrupt: offer[:no_interrupt] }
748
+ if had_offer != !@continue_offer.nil?
749
+ sync_prompt
750
+ sync_continue_slot
751
+ end
752
+ render_current_turn(snapshot[:current_turn])
753
+ render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
754
+ Array(snapshot[:queued]).each do |entry|
755
+ next if own?(entry[:client_id])
756
+
757
+ @shown_enqueued << entry[:enqueued_id]
758
+ @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
759
+ end
760
+ return send_first_command if @first_command
761
+
762
+ send_first_prompt
763
+ nil
764
+ end
765
+
766
+ # --model on a resumed session: switch its worker before the first
767
+ # prompt goes; its :command_ran decides (#first_command_ran).
768
+ # @return [Symbol, nil] :failed when the worker didn't take it
769
+ def send_first_command
770
+ line = @first_command
771
+ @first_command = nil
772
+ reply = @client.post_command(line: line, client_id: @client_id)
773
+ if reply.status == 202
774
+ @first_command_id = reply.json&.fetch("command_id", nil)
775
+ return nil if @first_command_id
776
+ end
777
+
778
+ why =
779
+ if reply.status == 404 then stale_worker("run commands")
780
+ elsif too_late?(reply) then LATE
781
+ else "the worker answered #{reply.status}"
782
+ end
783
+ @screen.commit("could not switch to the --model: #{why}")
784
+ :failed
785
+ rescue SystemCallError, IOError => e
786
+ @screen.commit("could not switch to the --model: #{worker_down(e)}")
787
+ :failed
788
+ end
789
+
790
+ # @return [Symbol, nil] :failed unless the switch went through
791
+ def first_command_ran(event)
792
+ @first_command_id = nil
793
+ if event[:status] == "ok"
794
+ send_first_prompt
795
+ return nil
796
+ end
797
+
798
+ @screen.commit("could not switch to the --model: #{event[:output]}")
799
+ :failed
800
+ end
801
+
802
+ # After the join, so it lands below what the session already had. Our
803
+ # own prompts are never echoed from events (a typed one stays on screen),
804
+ # so it is shown here as if typed.
805
+ def send_first_prompt
806
+ text = @first_prompt.to_s.strip
807
+ @first_prompt = nil
808
+ return if text.empty?
809
+
810
+ @screen.commit("#{paint(PROMPT, 92)}#{text}")
811
+ send_prompt(text)
812
+ end
813
+
814
+ def render_join_header(messages)
815
+ exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
816
+ # The id stays findable: the detach line and chi sessions list show it.
817
+ Log.session_id = @client.session_id
818
+ Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
819
+ last_user = exchange.rindex { |m| m[:role].to_s == "user" }
820
+ render_join_notes(messages)
821
+ return unless last_user
822
+
823
+ @screen.commit(prompt_line(nil, exchange[last_user][:content]))
824
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
825
+ # The saved answer is raw: the latest keeps its thinking, and one may
826
+ # be only a tool call.
827
+ answer = exchange[(last_user + 1)..].reverse_each
828
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
829
+ .find { |text| !text.empty? }
830
+ @screen.commit(last_lines(answer)) if answer
831
+ render_join_notes(messages, after_exchange: true)
832
+ end
833
+
834
+ # The context notes that came since the last prompt (all of them in a
835
+ # session with none), after the exchange the header shows.
836
+ def render_join_notes(messages, after_exchange: false)
837
+ last_user = messages.rindex { |m| m[:role].to_s == "user" }
838
+ return if after_exchange != !last_user.nil?
839
+
840
+ messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
841
+ @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
842
+ end
843
+ end
844
+
845
+ # The end of a long answer; the whole of it is in the session.
846
+ def last_lines(text)
847
+ lines = text.split("\n", -1)
848
+ if lines.size > JOIN_ANSWER_LINES
849
+ text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
850
+ end
851
+ return text if text.length <= JOIN_ANSWER_CHARS
852
+
853
+ "(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
854
+ end
855
+
856
+ # The snapshot's cards and between-turns notices (Bridge CardStore):
857
+ # on join the ones since the last turn (and the running turn's), as
858
+ # the join header shows the last exchange; on a resync only cards not
859
+ # shown here yet. A turn's own notices are left out: the running
860
+ # turn's come with its parts, and the join shows no older turn's steps.
861
+ def render_snapshot_cards(entries, joining:)
862
+ entries.each do |entry|
863
+ if entry[:type].to_s == "hook_notice"
864
+ next if entry[:in_turn]
865
+
866
+ @screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
867
+ elsif joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
868
+ @renderer.render_card(entry, updated: entry[:updated] == true)
869
+ end
870
+ end
871
+ end
872
+
873
+ def render_current_turn(turn)
874
+ @running = !turn.nil?
875
+ @joined_mid_turn = @running
876
+ return unless turn
877
+
878
+ @turn_continues = turn[:prompt].nil?
879
+ unless @turn_continues && reminder_origin?(turn[:origin] || {})
880
+ @screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
881
+ end
882
+ Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
883
+ tail = nil
884
+ lane = :writing
885
+ running_tool = nil
886
+ Array(turn[:parts]).each do |part|
887
+ case part[:kind]
888
+ when "thinking" then tail, lane = part[:text], :thinking
889
+ when "text" then tail, lane = part[:text], :writing
890
+ when "tool"
891
+ if part[:status] == "running"
892
+ running_tool = part[:tool]
893
+ else
894
+ @screen.commit(snapshot_tool_line(part))
895
+ end
896
+ when "input" then @screen.commit("input> #{part[:text]}")
897
+ when "reminder" then @screen.commit(reminder_line(part[:reminders]))
898
+ when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
899
+ end
900
+ end
901
+ @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
902
+ ask(turn[:pending_question]) if turn[:pending_question]
903
+ end
904
+
905
+ def show_enqueued(event)
906
+ return if own?(event[:client_id])
907
+
908
+ @shown_enqueued << event[:enqueued_id]
909
+ @screen.commit(prompt_line(event[:client_id], event[:prompt]))
910
+ end
911
+
912
+ def start_turn(event)
913
+ @running = true
914
+ @joined_mid_turn = false
915
+ @turn_continues = event[:prompt].nil?
916
+ origin = event[:origin] || {}
917
+ if @turn_continues
918
+ # A continue turn (after the offer's yes) has no prompt to show; a
919
+ # reminder turn shows its reminders (:reminder_injected).
920
+ @screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
921
+ elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
922
+ @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
923
+ end
924
+ @renderer.call(event)
925
+ end
926
+
927
+ def complete_turn(event)
928
+ # Tools from before the join were shown from the snapshot; the ones
929
+ # after it streamed. Either way the summary must not list them again.
930
+ if @joined_mid_turn && event[:turn_summary]
931
+ event = event.merge(turn_summary: event[:turn_summary].merge(tool_activity: []))
932
+ end
933
+ @renderer.call(event)
934
+ # The turn's summary carries its last context estimate.
935
+ @context_estimate = @view.context_status if @view.context_status
936
+ refresh_status
937
+ @running = false
938
+ @joined_mid_turn = false
939
+ close_question("(the turn ended)") if @question
940
+ end
941
+
942
+ def end_turn(message)
943
+ @view.finish_thinking_spinner
944
+ @screen.commit(message)
945
+ @running = false
946
+ @joined_mid_turn = false
947
+ close_question("(the turn ended)") if @question
948
+ end
949
+
950
+ def own?(client_id) = !client_id.nil? && client_id == @client_id
951
+
952
+ # The joining snapshot's session state: the worker's model and the
953
+ # memories the session used.
954
+ def take_session_state(state)
955
+ @model_name = state[:model_name] if state[:model_name]
956
+ @served_model = nil
957
+ take_served_model(state[:served_model], state[:served_model_for], refresh: false)
958
+ @memory_names = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
959
+ @preloaded_names = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
960
+ @muted_names = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
961
+ @parent_id = state[:parent_id] if state.key?(:parent_id)
962
+ # The last turn's ctx, so it shows before this client's first turn.
963
+ @context_estimate = state[:context_status] if state[:context_status].is_a?(Hash)
964
+ refresh_status
965
+ end
966
+
967
+ # The REPL's idle status line (model · ctx · memories) in the status
968
+ # row, redrawn when its text changes.
969
+ def refresh_status
970
+ return unless status_line_enabled?
971
+
972
+ served, served_for = @served_model
973
+ segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
974
+ @parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
975
+ status_context_text(estimate: @context_estimate),
976
+ status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
977
+ status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
978
+ rows = status_rows(segments, @screen.columns - 1)
979
+ return if rows == @status_rows
980
+
981
+ @status_rows = rows
982
+ rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
983
+ end
984
+
985
+ # What the worker's server said it served for a name (a generation, or
986
+ # the join's session state); dropped when /model switches.
987
+ def take_served_model(served, served_for, refresh: true)
988
+ return unless served
989
+
990
+ @served_model = [served, served_for]
991
+ refresh_status if refresh
992
+ end
993
+
994
+ # The config's default model, as the worker's /model names it.
995
+ def default_model_name
996
+ @default_model_name ||= ModelProfile.required_model_name(nil)
997
+ rescue StandardError
998
+ nil
999
+ end
1000
+
1001
+ def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
1002
+
1003
+ CONTEXT_NOTE_PREVIEW = 60
1004
+
1005
+ # "note from slack: <first line>", cut to one line.
1006
+ def context_note_line(label, text)
1007
+ lines = text.to_s.strip.split("\n")
1008
+ first = lines.first.to_s
1009
+ if first.length > CONTEXT_NOTE_PREVIEW
1010
+ first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}…"
1011
+ elsif lines.size > 1
1012
+ first += " …"
1013
+ end
1014
+ paint("note from #{label || "?"}: #{first}", 2)
1015
+ end
1016
+
1017
+ def reminder_line(reminders)
1018
+ names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
1019
+ names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
1020
+ end
1021
+
1022
+ def prompt_line(client_id, prompt)
1023
+ label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
1024
+ "#{paint("#{label}>", 35)} #{prompt}"
1025
+ end
1026
+
1027
+ # A tool call from the snapshot: it has no action text, only the tool.
1028
+ def snapshot_tool_line(part)
1029
+ params = part[:params].to_s.strip
1030
+ params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1031
+ status = part[:status].to_s
1032
+ "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status == "ok" ? 32 : 31)}" \
1033
+ "#{format_tool_image_suffix(part[:images])}"
1034
+ end
1035
+ end
1036
+ end
1037
+ end