anima-core 0.3.0 → 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 (269) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  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 +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +168 -0
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -1
@@ -28,7 +28,8 @@ module TUI
28
28
  VIEW_MODES = %w[basic verbose debug].freeze
29
29
 
30
30
  attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
31
- :authentication_required, :token_save_result
31
+ :authentication_required, :token_save_result, :parent_session_id,
32
+ :chat_focused
32
33
 
33
34
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
34
35
  # @param message_store [TUI::MessageStore, nil] injectable for testing
@@ -43,10 +44,15 @@ module TUI
43
44
  @max_scroll = 0
44
45
  @input_scroll_offset = 0
45
46
  @view_mode = "basic"
46
- @session_info = {id: cable_client.session_id || 0, message_count: 0}
47
+ @session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: []}
47
48
  @sessions_list = nil
49
+ @parent_session_id = nil
48
50
  @authentication_required = false
49
51
  @token_save_result = nil
52
+ @chat_focused = false
53
+ @input_history = []
54
+ @history_index = nil
55
+ @saved_input = nil
50
56
  end
51
57
 
52
58
  def messages
@@ -81,28 +87,40 @@ module TUI
81
87
  render_input(frame, input_area, tui)
82
88
  end
83
89
 
84
- # Input is always active users can type and send messages even while
85
- # the agent is processing. Messages sent during processing are queued
86
- # as "pending" on the server and delivered after the current loop.
87
- # Arrow-up recalls the last pending message for editing.
90
+ # Dispatches keyboard, mouse, and paste events. Supports two focus
91
+ # modes: input mode (default) where arrows navigate the input buffer
92
+ # with bash-style history overflow, and chat-focused mode where arrows
93
+ # scroll the chat pane.
94
+ #
95
+ # Page Up/Down and mouse scroll always control the chat pane
96
+ # regardless of focus mode.
88
97
  def handle_event(event)
89
98
  return handle_mouse_event(event) if event.mouse?
90
99
  return handle_paste_event(event) if event.paste?
91
100
  return handle_scroll_key(event) if event.page_up? || event.page_down?
92
101
 
102
+ return handle_chat_focused_event(event) if @chat_focused
103
+
93
104
  if event.up?
105
+ return true if @input_buffer.move_up
94
106
  return true if @input_buffer.text.empty? && recall_pending_message
95
- return handle_scroll_key(event)
107
+ return navigate_history_back
108
+ end
109
+
110
+ if event.down?
111
+ return true if @input_buffer.move_down
112
+ return navigate_history_forward
96
113
  end
97
- return handle_scroll_key(event) if event.down?
98
114
 
99
115
  if event.enter?
100
116
  submit_message
101
117
  true
102
118
  elsif event.backspace?
119
+ reset_history_browsing
103
120
  @input_buffer.backspace
104
121
  true
105
122
  elsif event.delete?
123
+ reset_history_browsing
106
124
  @input_buffer.delete
107
125
  true
108
126
  elsif event.left?
@@ -114,6 +132,7 @@ module TUI
114
132
  elsif event.end?
115
133
  @input_buffer.move_end
116
134
  elsif printable_char?(event) && !@input_buffer.full?
135
+ reset_history_browsing
117
136
  @input_buffer.insert(event.code)
118
137
  else
119
138
  false
@@ -167,6 +186,18 @@ module TUI
167
186
  @loading
168
187
  end
169
188
 
189
+ # Switches focus to the chat pane for keyboard scrolling.
190
+ # @return [void]
191
+ def focus_chat
192
+ @chat_focused = true
193
+ end
194
+
195
+ # Returns focus from the chat pane to the input field.
196
+ # @return [void]
197
+ def unfocus_chat
198
+ @chat_focused = false
199
+ end
200
+
170
201
  private
171
202
 
172
203
  # Drains the WebSocket message queue and feeds events to the message store
@@ -182,6 +213,14 @@ module TUI
182
213
  handle_view_mode_changed(msg)
183
214
  when "view_mode"
184
215
  @view_mode = msg["view_mode"] if msg["view_mode"]
216
+ when "session_name_updated"
217
+ handle_session_name_updated(msg)
218
+ when "active_skills_updated"
219
+ handle_active_skills_updated(msg)
220
+ when "active_workflow_updated"
221
+ handle_active_workflow_updated(msg)
222
+ when "goals_updated"
223
+ handle_goals_updated(msg)
185
224
  when "sessions_list"
186
225
  @sessions_list = msg["sessions"]
187
226
  when "user_message_recalled"
@@ -215,9 +254,23 @@ module TUI
215
254
  @message_store.process_event(msg)
216
255
  end
217
256
  end
257
+
258
+ handle_viewport_evictions(msg)
218
259
  end
219
260
  end
220
261
 
262
+ # Removes messages that left the LLM's context window. Event broadcasts
263
+ # include `evicted_event_ids` when old events are pushed out of the
264
+ # viewport by new ones.
265
+ #
266
+ # @param msg [Hash] incoming WebSocket message
267
+ def handle_viewport_evictions(msg)
268
+ evicted_ids = msg["evicted_event_ids"]
269
+ return unless evicted_ids.is_a?(Array) && evicted_ids.any?
270
+
271
+ @message_store.remove_by_ids(evicted_ids)
272
+ end
273
+
221
274
  # Reacts to connection lifecycle changes from the WebSocket client.
222
275
  # Clears stale state when subscription begins so the store is empty
223
276
  # before history arrives. Action Cable sends confirm_subscription
@@ -239,12 +292,49 @@ module TUI
239
292
  @cable_client.update_session_id(new_id)
240
293
  @message_store.clear
241
294
  @view_mode = msg["view_mode"] if msg["view_mode"]
242
- @session_info = {id: new_id, message_count: msg["message_count"] || 0}
295
+ @session_info = {id: new_id, name: msg["name"], message_count: msg["message_count"] || 0,
296
+ active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
297
+ goals: msg["goals"] || []}
298
+ @parent_session_id = msg["parent_session_id"]
243
299
  @input_buffer.clear
244
300
  @loading = false
245
301
  @scroll_offset = 0
246
302
  @auto_scroll = true
247
303
  @input_scroll_offset = 0
304
+ @chat_focused = false
305
+ reset_history_browsing
306
+ end
307
+
308
+ # Updates the session name when a background job generates one.
309
+ # Only applies to the current session.
310
+ def handle_session_name_updated(msg)
311
+ return unless msg["session_id"] == @session_info[:id]
312
+
313
+ @session_info[:name] = msg["name"]
314
+ end
315
+
316
+ # Updates the active skills list when the analytical brain activates or
317
+ # deactivates skills. Only applies to the current session.
318
+ def handle_active_skills_updated(msg)
319
+ return unless msg["session_id"] == @session_info[:id]
320
+
321
+ @session_info[:active_skills] = msg["active_skills"] || []
322
+ end
323
+
324
+ # Updates the active workflow when the analytical brain activates or
325
+ # deactivates a workflow. Only applies to the current session.
326
+ def handle_active_workflow_updated(msg)
327
+ return unless msg["session_id"] == @session_info[:id]
328
+
329
+ @session_info[:active_workflow] = msg["active_workflow"]
330
+ end
331
+
332
+ # Updates the goals list when the analytical brain creates or
333
+ # completes goals. Only applies to the current session.
334
+ def handle_goals_updated(msg)
335
+ return unless msg["session_id"] == @session_info[:id]
336
+
337
+ @session_info[:goals] = msg["goals"] || []
248
338
  end
249
339
 
250
340
  # Handles server broadcast of view mode change. Clears the message store
@@ -285,15 +375,19 @@ module TUI
285
375
  @scroll_offset = @max_scroll if @auto_scroll
286
376
  @scroll_offset = @scroll_offset.clamp(0, @max_scroll)
287
377
 
288
- widget = base_widget.with(
289
- scroll: [@scroll_offset, 0],
290
- block: tui.block(
291
- title: "Chat",
292
- borders: [:all],
293
- border_type: :rounded,
294
- border_style: {fg: "cyan"}
295
- )
296
- )
378
+ chat_block = {
379
+ title: "Chat",
380
+ borders: [:all],
381
+ border_type: :rounded,
382
+ border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
383
+ }
384
+ if @chat_focused
385
+ chat_block[:titles] = [
386
+ {content: "\u2191\u2193 scroll Esc return", position: :bottom, alignment: :center}
387
+ ]
388
+ end
389
+
390
+ widget = base_widget.with(scroll: [@scroll_offset, 0], block: tui.block(**chat_block))
297
391
  frame.render_widget(widget, area)
298
392
 
299
393
  return unless @max_scroll > 0
@@ -539,7 +633,7 @@ module TUI
539
633
  )
540
634
  frame.render_widget(widget, area)
541
635
 
542
- return if disabled
636
+ return if disabled || @chat_focused
543
637
 
544
638
  cursor_x = area.x + 1 + @cursor_visual_col
545
639
  cursor_y = area.y + 1 + @cursor_visual_row - input_scroll
@@ -550,9 +644,15 @@ module TUI
550
644
  end
551
645
 
552
646
  def input_styles(tui, disabled)
647
+ border_color = if disabled || @chat_focused
648
+ "dark_gray"
649
+ else
650
+ "green"
651
+ end
652
+
553
653
  {
554
654
  text: disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white"),
555
- border: disabled ? {fg: "dark_gray"} : {fg: "green"}
655
+ border: {fg: border_color}
556
656
  }
557
657
  end
558
658
 
@@ -661,6 +761,8 @@ module TUI
661
761
  return unless connected?
662
762
 
663
763
  text = @input_buffer.consume
764
+ save_to_history(text)
765
+ reset_history_browsing
664
766
  @input_scroll_offset = 0
665
767
  @cable_client.speak(text)
666
768
  end
@@ -681,6 +783,90 @@ module TUI
681
783
  true
682
784
  end
683
785
 
786
+ # Handles keyboard events when the chat pane has focus.
787
+ # Up/Down scroll the chat; all other keys are ignored.
788
+ #
789
+ # @return [Boolean] true if the event was handled
790
+ def handle_chat_focused_event(event)
791
+ if event.up?
792
+ scroll_up(SCROLL_STEP)
793
+ true
794
+ elsif event.down?
795
+ scroll_down(SCROLL_STEP)
796
+ true
797
+ else
798
+ false
799
+ end
800
+ end
801
+
802
+ # Navigates backward through input history (older entries).
803
+ # On first invocation, saves the current input buffer so it can be
804
+ # restored when the user navigates past the newest entry.
805
+ #
806
+ # @return [Boolean] true if a history entry was loaded
807
+ def navigate_history_back
808
+ return false if @input_history.empty?
809
+
810
+ if @history_index.nil?
811
+ @saved_input = @input_buffer.text
812
+ @history_index = @input_history.size - 1
813
+ elsif @history_index > 0
814
+ @history_index -= 1
815
+ else
816
+ return false
817
+ end
818
+
819
+ load_history_entry(@input_history[@history_index])
820
+ true
821
+ end
822
+
823
+ # Navigates forward through input history (newer entries).
824
+ # When navigating past the newest entry, restores the text that was
825
+ # in the input buffer before history browsing started.
826
+ #
827
+ # @return [Boolean] true if navigated, false if not browsing history
828
+ def navigate_history_forward
829
+ return false if @history_index.nil?
830
+
831
+ @history_index += 1
832
+
833
+ if @history_index >= @input_history.size
834
+ load_history_entry(@saved_input)
835
+ reset_history_browsing
836
+ else
837
+ load_history_entry(@input_history[@history_index])
838
+ end
839
+
840
+ true
841
+ end
842
+
843
+ # Replaces the input buffer content with a history entry.
844
+ # Cursor is placed at the end of the text.
845
+ #
846
+ # @param text [String] history entry or saved input to load
847
+ # @return [void]
848
+ def load_history_entry(text)
849
+ @input_buffer.clear
850
+ @input_buffer.insert(text)
851
+ end
852
+
853
+ # Exits history browsing mode without changing the input buffer.
854
+ # @return [void]
855
+ def reset_history_browsing
856
+ @history_index = nil
857
+ @saved_input = nil
858
+ end
859
+
860
+ # Appends a message to input history, skipping consecutive duplicates.
861
+ #
862
+ # @param text [String] submitted message text
863
+ # @return [void]
864
+ def save_to_history(text)
865
+ return if @input_history.last == text
866
+
867
+ @input_history << text
868
+ end
869
+
684
870
  # Dispatches arrow and page keys to {#scroll_up} or {#scroll_down}.
685
871
  # @return [true] always redraws after scrolling
686
872
  def handle_scroll_key(event)
@@ -737,6 +923,7 @@ module TUI
737
923
  def handle_paste_event(event)
738
924
  return false if @input_buffer.full?
739
925
 
926
+ reset_history_browsing
740
927
  @input_buffer.insert(event.content)
741
928
  end
742
929
 
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Workflows
6
+ class InvalidDefinitionError < StandardError; end
7
+
8
+ # A workflow parsed from a Markdown definition file.
9
+ # YAML frontmatter holds metadata; the Markdown body contains free-form
10
+ # instructions that the analytical brain reads and converts into goals.
11
+ #
12
+ # Workflows are operational recipes — they describe WHAT to do step by
13
+ # step. The analytical brain uses judgment to decompose workflow prose
14
+ # into tracked goals based on the user's specific context.
15
+ #
16
+ # @example Workflow file format
17
+ # ---
18
+ # name: feature
19
+ # description: "Implement a GitHub issue end-to-end."
20
+ # ---
21
+ #
22
+ # ## Context
23
+ # Create and complete a new feature...
24
+ class Definition
25
+ # @return [String] unique workflow identifier used in read_workflow(name: "...")
26
+ attr_reader :name
27
+
28
+ # @return [String] description shown to the analytical brain for relevance matching
29
+ attr_reader :description
30
+
31
+ # @return [String] workflow content (Markdown body) — free-form instructions
32
+ attr_reader :content
33
+
34
+ # @return [String] file path this definition was loaded from
35
+ attr_reader :source_path
36
+
37
+ def initialize(name:, description:, content:, source_path: "")
38
+ @name = name
39
+ @description = description
40
+ @content = content
41
+ @source_path = source_path
42
+ end
43
+
44
+ # Parses a Markdown file with YAML frontmatter into a Definition.
45
+ #
46
+ # @param path [String, Pathname] path to the .md file
47
+ # @return [Definition]
48
+ # @raise [InvalidDefinitionError] if required fields are missing or frontmatter is malformed
49
+ def self.from_file(path)
50
+ raw = File.read(path)
51
+ frontmatter, body = parse_frontmatter(raw)
52
+
53
+ validate_required_fields!(frontmatter, path)
54
+
55
+ new(
56
+ name: frontmatter["name"].to_s.strip,
57
+ description: frontmatter["description"].to_s.strip,
58
+ content: body.strip,
59
+ source_path: path.to_s
60
+ )
61
+ end
62
+
63
+ # @param raw [String] raw file content with YAML frontmatter
64
+ # @return [Array(Hash, String)] parsed frontmatter and body text
65
+ # @raise [InvalidDefinitionError] if frontmatter is missing or malformed
66
+ def self.parse_frontmatter(raw)
67
+ # Opening "---" must be followed by a newline (not just whitespace).
68
+ # Non-greedy (.*?\n) captures YAML lines up to the closing "---".
69
+ # Closing "---" may optionally be followed by a newline before the body.
70
+ # The /m flag lets (.*) in the body capture across newlines.
71
+ match = raw.match(/\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m)
72
+ raise InvalidDefinitionError, "Missing YAML frontmatter" unless match
73
+
74
+ frontmatter = YAML.safe_load(match[1])
75
+ raise InvalidDefinitionError, "Frontmatter is not a valid YAML mapping" unless frontmatter.is_a?(Hash)
76
+
77
+ [frontmatter, match[2]]
78
+ end
79
+
80
+ NAME_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
81
+
82
+ def self.validate_required_fields!(frontmatter, path)
83
+ %w[name description].each do |field|
84
+ value = frontmatter[field].to_s.strip
85
+ raise InvalidDefinitionError, "Missing required field '#{field}' in #{path}" if value.empty?
86
+ end
87
+
88
+ name = frontmatter["name"].to_s.strip
89
+ unless name.match?(NAME_FORMAT)
90
+ raise InvalidDefinitionError,
91
+ "Invalid workflow name '#{name}' in #{path} — must be lowercase alphanumeric with hyphens/underscores"
92
+ end
93
+ end
94
+
95
+ private_class_method :parse_frontmatter, :validate_required_fields!
96
+ end
97
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Workflows
4
+ # Loads workflow definitions from Markdown files and provides lookup.
5
+ # Scans two directories:
6
+ # 1. Built-in workflows shipped with Anima (workflows/ in the gem root)
7
+ # 2. User-defined workflows (~/.anima/workflows/)
8
+ # User workflows override built-in ones when names collide.
9
+ class Registry
10
+ # @return [Hash{String => Definition}] loaded definitions keyed by name
11
+ attr_reader :workflows
12
+
13
+ BUILTIN_DIR = File.expand_path("../../workflows", __dir__).freeze
14
+ USER_DIR = File.expand_path("~/.anima/workflows").freeze
15
+
16
+ def initialize
17
+ @workflows = {}
18
+ end
19
+
20
+ # Returns the global registry, lazily loaded on first access.
21
+ #
22
+ # @return [Registry]
23
+ def self.instance
24
+ @instance ||= new.load_all
25
+ end
26
+
27
+ # Reloads the global registry from disk.
28
+ #
29
+ # @return [Registry]
30
+ def self.reload!
31
+ @instance = new.load_all
32
+ end
33
+
34
+ # Loads definitions from both built-in and user directories.
35
+ # User definitions override built-in ones with the same name.
36
+ #
37
+ # @return [self]
38
+ def load_all
39
+ load_directory(BUILTIN_DIR)
40
+ load_directory(USER_DIR)
41
+ self
42
+ end
43
+
44
+ # Loads workflow definitions from a single directory (flat .md files only).
45
+ #
46
+ # @param dir [String] directory path to scan for workflow definitions
47
+ # @return [void]
48
+ def load_directory(dir)
49
+ return unless Dir.exist?(dir)
50
+
51
+ Dir.glob(File.join(dir, "*.md")).sort.each do |path|
52
+ definition = Definition.from_file(path)
53
+ @workflows[definition.name] = definition
54
+ rescue InvalidDefinitionError => error
55
+ Rails.logger.warn("Skipping invalid workflow definition #{path}: #{error.message}")
56
+ end
57
+ end
58
+
59
+ # Looks up a named workflow definition.
60
+ #
61
+ # @param name [String] workflow name
62
+ # @return [Definition, nil]
63
+ def find(name)
64
+ @workflows[name]
65
+ end
66
+
67
+ # Workflow names and descriptions for inclusion in the analytical brain's context.
68
+ #
69
+ # @return [Hash{String => String}] name => description
70
+ def catalog
71
+ @workflows.transform_values(&:description)
72
+ end
73
+
74
+ # @return [Array<String>] registered workflow names
75
+ def available_names
76
+ @workflows.keys
77
+ end
78
+
79
+ # @return [Boolean]
80
+ def any?
81
+ @workflows.any?
82
+ end
83
+
84
+ # @return [Integer]
85
+ def size
86
+ @workflows.size
87
+ end
88
+ end
89
+ end