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
@@ -16,6 +16,7 @@ module TUI
16
16
  MOUSE_SCROLL_STEP = 2
17
17
 
18
18
  TOOL_ICON = "\u{1F527}"
19
+ CLOCK_ICON = "\u{1F552}"
19
20
  CHECKMARK = "\u2713"
20
21
  RETURN_ARROW = "\u21A9"
21
22
  ERROR_ICON = "\u274C"
@@ -26,7 +27,9 @@ module TUI
26
27
  # independent of Rails. Must stay in sync when adding new modes.
27
28
  VIEW_MODES = %w[basic verbose debug].freeze
28
29
 
29
- attr_reader :message_store, :scroll_offset, :session_info, :view_mode
30
+ attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
31
+ :authentication_required, :token_save_result, :parent_session_id,
32
+ :chat_focused
30
33
 
31
34
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
32
35
  # @param message_store [TUI::MessageStore, nil] injectable for testing
@@ -41,7 +44,15 @@ module TUI
41
44
  @max_scroll = 0
42
45
  @input_scroll_offset = 0
43
46
  @view_mode = "basic"
44
- @session_info = {id: cable_client.session_id, message_count: 0}
47
+ @session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: []}
48
+ @sessions_list = nil
49
+ @parent_session_id = nil
50
+ @authentication_required = false
51
+ @token_save_result = nil
52
+ @chat_focused = false
53
+ @input_history = []
54
+ @history_index = nil
55
+ @saved_input = nil
45
56
  end
46
57
 
47
58
  def messages
@@ -76,23 +87,40 @@ module TUI
76
87
  render_input(frame, input_area, tui)
77
88
  end
78
89
 
79
- # Scrolling and cursor navigation bypass the loading guard so users can
80
- # read chat history during LLM calls.
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.
81
97
  def handle_event(event)
82
98
  return handle_mouse_event(event) if event.mouse?
83
99
  return handle_paste_event(event) if event.paste?
84
100
  return handle_scroll_key(event) if event.page_up? || event.page_down?
85
- return handle_scroll_key(event) if event.up? || event.down?
86
101
 
87
- return false if @loading
102
+ return handle_chat_focused_event(event) if @chat_focused
103
+
104
+ if event.up?
105
+ return true if @input_buffer.move_up
106
+ return true if @input_buffer.text.empty? && recall_pending_message
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
113
+ end
88
114
 
89
115
  if event.enter?
90
116
  submit_message
91
117
  true
92
118
  elsif event.backspace?
119
+ reset_history_browsing
93
120
  @input_buffer.backspace
94
121
  true
95
122
  elsif event.delete?
123
+ reset_history_browsing
96
124
  @input_buffer.delete
97
125
  true
98
126
  elsif event.left?
@@ -104,6 +132,7 @@ module TUI
104
132
  elsif event.end?
105
133
  @input_buffer.move_end
106
134
  elsif printable_char?(event) && !@input_buffer.full?
135
+ reset_history_browsing
107
136
  @input_buffer.insert(event.code)
108
137
  else
109
138
  false
@@ -118,13 +147,36 @@ module TUI
118
147
  @cable_client.create_session
119
148
  end
120
149
 
121
- # Cycles to the next view mode and requests the server to switch.
150
+ # Switches to an existing session through the WebSocket protocol.
151
+ # The brain switches the channel stream and sends a session_changed
152
+ # signal followed by chat history.
153
+ #
154
+ # @param session_id [Integer] target session to switch to
155
+ def switch_session(session_id)
156
+ @cable_client.switch_session(session_id)
157
+ end
158
+
159
+ # Sends an explicit view mode switch command to the server.
122
160
  # The server broadcasts the mode change and re-transmits the viewport
123
161
  # decorated in the new mode to all connected clients.
124
- def cycle_view_mode
125
- current_index = VIEW_MODES.index(@view_mode) || 0
126
- next_mode = VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
127
- @cable_client.change_view_mode(next_mode)
162
+ #
163
+ # @param mode [String] target view mode ("basic", "verbose", or "debug")
164
+ def switch_view_mode(mode)
165
+ @cable_client.change_view_mode(mode)
166
+ end
167
+
168
+ # Clears the authentication_required flag after the App has consumed it.
169
+ # @return [void]
170
+ def clear_authentication_required
171
+ @authentication_required = false
172
+ end
173
+
174
+ # Returns and clears the token save result for one-shot consumption by the App.
175
+ # @return [Hash, nil] {success: true} or {success: false, message: "..."}, or nil
176
+ def consume_token_save_result
177
+ result = @token_save_result
178
+ @token_save_result = nil
179
+ result
128
180
  end
129
181
 
130
182
  def finalize
@@ -134,6 +186,18 @@ module TUI
134
186
  @loading
135
187
  end
136
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
+
137
201
  private
138
202
 
139
203
  # Drains the WebSocket message queue and feeds events to the message store
@@ -149,8 +213,25 @@ module TUI
149
213
  handle_view_mode_changed(msg)
150
214
  when "view_mode"
151
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)
152
224
  when "sessions_list"
153
225
  @sessions_list = msg["sessions"]
226
+ when "user_message_recalled"
227
+ @message_store.remove_by_id(msg["event_id"]) if msg["event_id"]
228
+ when "authentication_required"
229
+ @authentication_required = true
230
+ when "token_saved"
231
+ @authentication_required = false
232
+ @token_save_result = {success: true}
233
+ when "token_error"
234
+ @token_save_result = {success: false, message: msg["message"]}
154
235
  when "error"
155
236
  # Silently ignored — no user-facing error display yet
156
237
  else
@@ -159,25 +240,45 @@ module TUI
159
240
  handle_connection_status(msg)
160
241
  when "user_message"
161
242
  @message_store.process_event(msg)
162
- @session_info[:message_count] += 1
163
- @loading = true
243
+ unless action == "update"
244
+ @session_info[:message_count] += 1
245
+ @loading = true
246
+ end
164
247
  when "agent_message"
165
248
  @message_store.process_event(msg)
166
- @session_info[:message_count] += 1
167
- @loading = false
249
+ unless action == "update"
250
+ @session_info[:message_count] += 1
251
+ @loading = false
252
+ end
168
253
  else # tool_call, tool_response, and other event types
169
254
  @message_store.process_event(msg)
170
255
  end
171
256
  end
257
+
258
+ handle_viewport_evictions(msg)
172
259
  end
173
260
  end
174
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
+
175
274
  # Reacts to connection lifecycle changes from the WebSocket client.
176
- # Clears stale state on (re)subscription so fresh history from the server
177
- # replaces any messages displayed before the disconnect.
275
+ # Clears stale state when subscription begins so the store is empty
276
+ # before history arrives. Action Cable sends confirm_subscription
277
+ # AFTER transmit calls in the subscribed callback, so clearing on
278
+ # "subscribed" would wipe history that already arrived.
178
279
  def handle_connection_status(msg)
179
280
  case msg["status"]
180
- when "subscribed"
281
+ when "subscribing"
181
282
  @message_store.clear
182
283
  @loading = false
183
284
  @session_info[:message_count] = 0
@@ -191,12 +292,49 @@ module TUI
191
292
  @cable_client.update_session_id(new_id)
192
293
  @message_store.clear
193
294
  @view_mode = msg["view_mode"] if msg["view_mode"]
194
- @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"]
195
299
  @input_buffer.clear
196
300
  @loading = false
197
301
  @scroll_offset = 0
198
302
  @auto_scroll = true
199
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"] || []
200
338
  end
201
339
 
202
340
  # Handles server broadcast of view mode change. Clears the message store
@@ -230,25 +368,26 @@ module TUI
230
368
  inner_width = [area.width - 2, 1].max
231
369
  @visible_height = [area.height - 2, 0].max
232
370
 
233
- content_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
234
- content_height = content_widget.line_count(inner_width)
371
+ base_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
372
+ content_height = base_widget.line_count(inner_width)
235
373
 
236
374
  @max_scroll = [content_height - @visible_height, 0].max
237
375
  @scroll_offset = @max_scroll if @auto_scroll
238
376
  @scroll_offset = @scroll_offset.clamp(0, @max_scroll)
239
377
 
240
- widget = tui.paragraph(
241
- text: lines,
242
- wrap: true,
243
- style: tui.style(fg: "white"),
244
- scroll: [@scroll_offset, 0],
245
- block: tui.block(
246
- title: "Chat",
247
- borders: [:all],
248
- border_type: :rounded,
249
- border_style: {fg: "cyan"}
250
- )
251
- )
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))
252
391
  frame.render_widget(widget, area)
253
392
 
254
393
  return unless @max_scroll > 0
@@ -325,14 +464,18 @@ module TUI
325
464
  end
326
465
 
327
466
  # Renders a user or assistant message with optional timestamp and token count.
467
+ # Pending messages are dimmed with a clock icon to indicate they haven't
468
+ # been sent to the LLM yet.
328
469
  # @param tui [RatatuiRuby] TUI rendering API
329
470
  # @param data [Hash] structured data with "role", "content", and optional
330
- # "timestamp", "tokens", "estimated"
471
+ # "timestamp", "tokens", "estimated", "status"
331
472
  # @param role [String] "user" or "assistant"
332
473
  # @return [Array<RatatuiRuby::Widgets::Line>]
333
474
  def render_conversation_entry(tui, data, role)
334
- color = ROLE_COLORS.fetch(role, "white")
475
+ pending = data["status"] == "pending"
476
+ color = pending ? "dark_gray" : ROLE_COLORS.fetch(role, "white")
335
477
  prefix = ROLE_LABELS.fetch(role, role)
478
+ prefix = "#{CLOCK_ICON} #{prefix}" if pending
336
479
  style = tui.style(fg: color)
337
480
 
338
481
  meta = []
@@ -464,7 +607,7 @@ module TUI
464
607
  end
465
608
 
466
609
  def render_input(frame, area, tui)
467
- disabled = @loading || !connected?
610
+ disabled = !connected?
468
611
  styles = input_styles(tui, disabled)
469
612
 
470
613
  title = input_title
@@ -490,7 +633,7 @@ module TUI
490
633
  )
491
634
  frame.render_widget(widget, area)
492
635
 
493
- return if disabled
636
+ return if disabled || @chat_focused
494
637
 
495
638
  cursor_x = area.x + 1 + @cursor_visual_col
496
639
  cursor_y = area.y + 1 + @cursor_visual_row - input_scroll
@@ -501,16 +644,20 @@ module TUI
501
644
  end
502
645
 
503
646
  def input_styles(tui, disabled)
647
+ border_color = if disabled || @chat_focused
648
+ "dark_gray"
649
+ else
650
+ "green"
651
+ end
652
+
504
653
  {
505
654
  text: disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white"),
506
- border: disabled ? {fg: "dark_gray"} : {fg: "green"}
655
+ border: {fg: border_color}
507
656
  }
508
657
  end
509
658
 
510
659
  def input_title
511
- if @loading
512
- "Waiting..."
513
- elsif !connected?
660
+ if !connected?
514
661
  "Disconnected"
515
662
  else
516
663
  "Input"
@@ -614,10 +761,112 @@ module TUI
614
761
  return unless connected?
615
762
 
616
763
  text = @input_buffer.consume
764
+ save_to_history(text)
765
+ reset_history_browsing
617
766
  @input_scroll_offset = 0
618
767
  @cable_client.speak(text)
619
768
  end
620
769
 
770
+ # Recalls the last pending user message for editing. Removes it from
771
+ # the message store, puts its content back in the input buffer, and
772
+ # tells the server to delete the event.
773
+ #
774
+ # @return [Boolean] true if a message was recalled
775
+ def recall_pending_message
776
+ pending = @message_store.last_pending_user_message
777
+ return false unless pending
778
+
779
+ @message_store.remove_by_id(pending[:id])
780
+ @input_buffer.clear
781
+ @input_buffer.insert(pending[:content])
782
+ @cable_client.recall_pending(pending[:id])
783
+ true
784
+ end
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
+
621
870
  # Dispatches arrow and page keys to {#scroll_up} or {#scroll_down}.
622
871
  # @return [true] always redraws after scrolling
623
872
  def handle_scroll_key(event)
@@ -669,13 +918,12 @@ module TUI
669
918
  end
670
919
 
671
920
  # Inserts pasted clipboard content at cursor position.
672
- # Paste is dispatched before the generic loading guard in {#handle_event}
673
- # but still blocked during loading to match the visually-disabled input.
674
921
  # @param event [RatatuiRuby::Event::Paste] paste event with content
675
- # @return [Boolean] true if content was inserted, false if loading or buffer full
922
+ # @return [Boolean] true if content was inserted, false if buffer full
676
923
  def handle_paste_event(event)
677
- return false if @loading || @input_buffer.full?
924
+ return false if @input_buffer.full?
678
925
 
926
+ reset_history_browsing
679
927
  @input_buffer.insert(event.content)
680
928
  end
681
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