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.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +4 -0
- data/README.md +219 -25
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +76 -28
- data/app/jobs/agent_request_job.rb +24 -0
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/concerns/event/broadcasting.rb +20 -2
- data/app/models/event.rb +1 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +347 -22
- data/config/application.rb +2 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -9
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +4 -0
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +8 -7
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +3 -4
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +15 -16
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tui/app.rb +332 -43
- data/lib/tui/message_store.rb +20 -0
- data/lib/tui/screens/chat.rb +207 -20
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +284 -1
data/lib/tui/screens/chat.rb
CHANGED
|
@@ -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
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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:
|
|
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
|