anima-core 0.2.1 → 1.0.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -17,7 +17,7 @@ module TUI
17
17
  # heartbeat monitoring.
18
18
  #
19
19
  # @example
20
- # client = TUI::CableClient.new(host: "localhost:42134", session_id: 1)
20
+ # client = TUI::CableClient.new(host: "localhost:42134")
21
21
  # client.connect
22
22
  # client.speak("Hello!")
23
23
  # messages = client.drain_messages
@@ -31,23 +31,39 @@ module TUI
31
31
  BACKOFF_CAP = 30.0 # maximum backoff delay
32
32
  PING_STALE_THRESHOLD = 6.0 # seconds without ping before connection is stale
33
33
 
34
+ # Message types queued for the TUI render loop via @message_queue
35
+ MSG_TYPE_CONNECTION = "connection"
36
+
37
+ # Connection status values sent as MSG_TYPE_CONNECTION messages.
38
+ # These are message-level concepts for the TUI — distinct from the
39
+ # internal @status state machine (:disconnected, :connecting, etc.).
40
+ STATUS_SUBSCRIBING = "subscribing"
41
+ STATUS_SUBSCRIBED = "subscribed"
42
+ STATUS_REJECTED = "rejected"
43
+ STATUS_DISCONNECTED = "disconnected"
44
+ STATUS_RECONNECTING = "reconnecting"
45
+ STATUS_FAILED = "failed"
46
+
34
47
  # @return [String] brain server host:port
35
48
  attr_reader :host
36
49
 
37
50
  # @return [Integer] current session ID
38
51
  attr_reader :session_id
39
52
 
40
- # @return [Symbol] connection status (:disconnected, :connecting, :connected, :subscribed, :reconnecting)
53
+ # @return [Symbol] connection status (:disconnected, :connecting, :connected, :subscribed, :reconnecting).
54
+ # Note: the "subscribing" concept exists only as a message-level status
55
+ # (see {STATUS_SUBSCRIBING}) queued for the TUI, not as an internal state.
41
56
  attr_reader :status
42
57
 
43
58
  # @return [Integer] current reconnection attempt (0 when connected)
44
59
  attr_reader :reconnect_attempt
45
60
 
46
61
  # @param host [String] brain server address (e.g. "localhost:42134")
47
- # @param session_id [Integer] session to subscribe to
48
- def initialize(host:, session_id:)
62
+ # @param session_id [Integer, nil] session to subscribe to (nil for server-side resolution)
63
+ def initialize(host:, session_id: nil)
49
64
  @host = host
50
65
  @session_id = session_id
66
+ @subscribed_session_id = session_id
51
67
  @status = :disconnected
52
68
  @message_queue = Thread::Queue.new
53
69
  @mutex = Mutex.new
@@ -110,6 +126,23 @@ module TUI
110
126
  send_action("change_view_mode", {"view_mode" => mode})
111
127
  end
112
128
 
129
+ # Requests the brain to recall (delete) a pending message so the user
130
+ # can edit it before the LLM sees it.
131
+ #
132
+ # @param event_id [Integer] database ID of the pending user_message event
133
+ def recall_pending(event_id)
134
+ send_action("recall_pending", {"event_id" => event_id})
135
+ end
136
+
137
+ # Sends an Anthropic subscription token to the brain for validation and storage.
138
+ # The token flows directly from TUI input to encrypted credentials — never
139
+ # enters the LLM context window.
140
+ #
141
+ # @param token [String] Anthropic subscription token (sk-ant-oat01-...)
142
+ def save_token(token)
143
+ send_action("save_token", {"token" => token})
144
+ end
145
+
113
146
  # Updates the local session ID reference after a server-side session switch.
114
147
  #
115
148
  # @param new_id [Integer] the new session ID
@@ -131,17 +164,6 @@ module TUI
131
164
  messages
132
165
  end
133
166
 
134
- # Unsubscribes from the current session and subscribes to a new one.
135
- #
136
- # @deprecated Use {#create_session} or {#switch_session} instead.
137
- # The server now handles stream switching via the session protocol.
138
- # @param new_session_id [Integer] session to switch to
139
- def resubscribe(new_session_id)
140
- unsubscribe_current
141
- @mutex.synchronize { @session_id = new_session_id }
142
- subscribe
143
- end
144
-
145
167
  # Closes the WebSocket connection and cleans up the background thread.
146
168
  # Prevents automatic reconnection.
147
169
  def disconnect
@@ -264,8 +286,8 @@ module TUI
264
286
  if attempt > MAX_RECONNECT_ATTEMPTS
265
287
  @mutex.synchronize { @status = :disconnected }
266
288
  @message_queue << {
267
- "type" => "connection",
268
- "status" => "failed",
289
+ "type" => MSG_TYPE_CONNECTION,
290
+ "status" => STATUS_FAILED,
269
291
  "message" => "Reconnection failed after #{MAX_RECONNECT_ATTEMPTS} attempts"
270
292
  }
271
293
  return false
@@ -274,8 +296,8 @@ module TUI
274
296
  delay = backoff_delay(attempt)
275
297
  @mutex.synchronize { @status = :reconnecting }
276
298
  @message_queue << {
277
- "type" => "connection",
278
- "status" => "reconnecting",
299
+ "type" => MSG_TYPE_CONNECTION,
300
+ "status" => STATUS_RECONNECTING,
279
301
  "attempt" => attempt,
280
302
  "max_attempts" => MAX_RECONNECT_ATTEMPTS,
281
303
  "delay" => delay.round(1)
@@ -324,17 +346,17 @@ module TUI
324
346
  @status = :subscribed
325
347
  @reconnect_attempt = 0
326
348
  end
327
- @message_queue << {"type" => "connection", "status" => "subscribed"}
349
+ @message_queue << {"type" => MSG_TYPE_CONNECTION, "status" => STATUS_SUBSCRIBED}
328
350
  when "reject_subscription"
329
351
  on_disconnected
330
- @message_queue << {"type" => "connection", "status" => "rejected"}
352
+ @message_queue << {"type" => MSG_TYPE_CONNECTION, "status" => STATUS_REJECTED}
331
353
  when "disconnect"
332
354
  if data["reconnect"] == false
333
355
  @mutex.synchronize do
334
356
  @intentional_disconnect = true
335
357
  @status = :disconnected
336
358
  end
337
- @message_queue << {"type" => "connection", "status" => "disconnected"}
359
+ @message_queue << {"type" => MSG_TYPE_CONNECTION, "status" => STATUS_DISCONNECTED}
338
360
  else
339
361
  on_disconnected
340
362
  end
@@ -353,30 +375,46 @@ module TUI
353
375
  return if @status == :disconnected || @status == :reconnecting
354
376
  @status = :disconnected
355
377
  end
356
- @message_queue << {"type" => "connection", "status" => "disconnected"}
378
+ @message_queue << {"type" => MSG_TYPE_CONNECTION, "status" => STATUS_DISCONNECTED}
357
379
  end
358
380
 
381
+ # Captures the current session ID under mutex, queues a "subscribing"
382
+ # status for the TUI, then sends the Action Cable subscribe command.
383
+ #
384
+ # The "subscribing" message must be queued _before_ the subscribe command
385
+ # so the TUI clears stale state before history arrives. Action Cable
386
+ # transmits history (via +subscribed+ callback) before sending
387
+ # +confirm_subscription+, so the TUI would see history first, then
388
+ # the "subscribed" status.
389
+ #
390
+ # @see handle_protocol_message called on "welcome" to trigger this
359
391
  def subscribe
360
- identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
392
+ sid = @mutex.synchronize do
393
+ @subscribed_session_id = @session_id || 0
394
+ end
395
+ @message_queue << {"type" => MSG_TYPE_CONNECTION, "status" => STATUS_SUBSCRIBING}
396
+ identifier = {channel: "SessionChannel", session_id: sid}.to_json
361
397
  send_command("subscribe", identifier)
362
398
  end
363
399
 
364
- def unsubscribe_current
365
- identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
366
- send_command("unsubscribe", identifier)
367
- end
368
-
369
400
  def send_action(action, data = {})
370
- identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
371
401
  payload = data.merge("action" => action).to_json
372
402
 
373
403
  @ws&.send({
374
404
  command: "message",
375
- identifier: identifier,
405
+ identifier: subscription_identifier,
376
406
  data: payload
377
407
  }.to_json)
378
408
  end
379
409
 
410
+ # Returns the identifier matching the active ActionCable subscription.
411
+ # After session switches, @session_id changes but the subscription
412
+ # identifier must match the one used during subscribe.
413
+ def subscription_identifier
414
+ sid = @mutex.synchronize { @subscribed_session_id }
415
+ {channel: "SessionChannel", session_id: sid}.to_json
416
+ end
417
+
380
418
  def send_command(command, identifier)
381
419
  @ws&.send({
382
420
  command: command,
@@ -6,8 +6,8 @@ module TUI
6
6
  # TUI, with no dependency on Rails or the Events module.
7
7
  #
8
8
  # Accepts Action Cable event payloads and stores typed entries:
9
- # - `{type: :rendered, data:, event_type:}` for events with structured decorator output
10
- # - `{type: :message, role:, content:}` for user/agent messages (fallback)
9
+ # - `{type: :rendered, data:, event_type:, id:}` for events with structured decorator output
10
+ # - `{type: :message, role:, content:, id:}` for user/agent messages (fallback)
11
11
  # - `{type: :tool_counter, calls:, responses:}` for tool activity
12
12
  #
13
13
  # Structured data takes priority when available. Events with nil
@@ -17,6 +17,9 @@ module TUI
17
17
  # Tool counters aggregate per agent turn: a new counter starts when a
18
18
  # tool_call arrives after a message entry. Consecutive tool events
19
19
  # increment the same counter until the next message breaks the chain.
20
+ #
21
+ # When an event arrives with `"action" => "update"` and a known `"id"`,
22
+ # the existing entry is replaced in-place, preserving display order.
20
23
  class MessageStore
21
24
  MESSAGE_TYPES = %w[user_message agent_message].freeze
22
25
 
@@ -27,6 +30,7 @@ module TUI
27
30
 
28
31
  def initialize
29
32
  @entries = []
33
+ @entries_by_id = {}
30
34
  @mutex = Mutex.new
31
35
  end
32
36
 
@@ -39,14 +43,23 @@ module TUI
39
43
  # Uses structured decorator data when available; falls back to
40
44
  # role/content extraction for messages and tool counter aggregation.
41
45
  #
46
+ # Events with `"action" => "update"` and a matching `"id"` replace
47
+ # the existing entry's data in-place rather than appending.
48
+ #
42
49
  # @param event_data [Hash] Action Cable event payload with "type", "content",
43
- # and optionally "rendered" (hash of mode => lines)
50
+ # and optionally "rendered" (hash of mode => lines), "id", "action"
44
51
  # @return [Boolean] true if the event type was recognized and handled
45
52
  def process_event(event_data)
53
+ event_id = event_data["id"]
54
+
55
+ if event_data["action"] == "update" && event_id
56
+ return update_existing(event_data, event_id)
57
+ end
58
+
46
59
  rendered = extract_rendered(event_data)
47
60
 
48
61
  if rendered
49
- record_rendered(rendered, event_type: event_data["type"])
62
+ record_rendered(rendered, event_type: event_data["type"], id: event_id)
50
63
  else
51
64
  case event_data["type"]
52
65
  when "tool_call" then record_tool_call
@@ -61,11 +74,87 @@ module TUI
61
74
  # to prepare for re-decorated viewport events from the server.
62
75
  # @return [void]
63
76
  def clear
64
- @mutex.synchronize { @entries = [] }
77
+ @mutex.synchronize do
78
+ @entries = []
79
+ @entries_by_id = {}
80
+ end
81
+ end
82
+
83
+ # Returns the last pending user message for recall editing.
84
+ # Walks entries backwards and returns the first pending user_message found.
85
+ #
86
+ # @return [Hash, nil] `{id: Integer, content: String}` or nil if none pending
87
+ def last_pending_user_message
88
+ @mutex.synchronize do
89
+ @entries.reverse_each do |entry|
90
+ next unless entry[:event_type] == "user_message"
91
+
92
+ if entry[:type] == :rendered && entry.dig(:data, "status") == "pending"
93
+ return {id: entry[:id], content: entry.dig(:data, "content")}
94
+ end
95
+
96
+ # Only check the most recent user message
97
+ break
98
+ end
99
+ nil
100
+ end
101
+ end
102
+
103
+ # Removes an entry by its event ID. Used when a pending message is
104
+ # recalled for editing or deleted by another client.
105
+ #
106
+ # @param event_id [Integer] database ID of the event to remove
107
+ # @return [Boolean] true if the entry was found and removed
108
+ def remove_by_id(event_id)
109
+ @mutex.synchronize do
110
+ entry = @entries_by_id.delete(event_id)
111
+ return false unless entry
112
+
113
+ @entries.delete(entry)
114
+ true
115
+ end
116
+ end
117
+
118
+ # Removes entries by their event IDs. Used when the brain reports
119
+ # that events have left the LLM's viewport (context window eviction).
120
+ # Acquires the mutex once for the entire batch.
121
+ #
122
+ # @param event_ids [Array<Integer>] database IDs of events to remove
123
+ # @return [Integer] count of entries actually removed
124
+ def remove_by_ids(event_ids)
125
+ @mutex.synchronize do
126
+ removed = 0
127
+ event_ids.each do |event_id|
128
+ entry = @entries_by_id.delete(event_id)
129
+ next unless entry
130
+
131
+ @entries.delete(entry)
132
+ removed += 1
133
+ end
134
+ removed
135
+ end
65
136
  end
66
137
 
67
138
  private
68
139
 
140
+ # Replaces data on an existing entry matched by event ID.
141
+ # Only updates rendered entries — tool counters and plain messages
142
+ # are not individually addressable by ID.
143
+ #
144
+ # @return [Boolean] true if the entry was found and updated
145
+ def update_existing(event_data, event_id)
146
+ rendered = extract_rendered(event_data)
147
+ return false unless rendered
148
+
149
+ @mutex.synchronize do
150
+ entry = @entries_by_id[event_id]
151
+ return false unless entry
152
+
153
+ entry[:data] = rendered
154
+ true
155
+ end
156
+ end
157
+
69
158
  # Extracts the first non-nil structured data hash from the rendered payload.
70
159
  # The "rendered" hash is keyed by view mode — the server includes only the
71
160
  # session's current mode, so there is always at most one entry.
@@ -76,9 +165,11 @@ module TUI
76
165
  event_data.dig("rendered")&.values&.compact&.first
77
166
  end
78
167
 
79
- def record_rendered(data, event_type: nil)
168
+ def record_rendered(data, event_type: nil, id: nil)
80
169
  @mutex.synchronize do
81
- @entries << {type: :rendered, data: data, event_type: event_type}
170
+ entry = {type: :rendered, data: data, event_type: event_type, id: id}
171
+ @entries << entry
172
+ @entries_by_id[id] = entry if id
82
173
  end
83
174
  true
84
175
  end
@@ -107,8 +198,12 @@ module TUI
107
198
  content = event_data["content"]
108
199
  return false if content.nil?
109
200
 
201
+ event_id = event_data["id"]
202
+
110
203
  @mutex.synchronize do
111
- @entries << {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content}
204
+ entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_id}
205
+ @entries << entry
206
+ @entries_by_id[event_id] = entry if event_id
112
207
  end
113
208
  true
114
209
  end