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
data/lib/tui/app.rb CHANGED
@@ -18,7 +18,8 @@ module TUI
18
18
  "q" => :quit
19
19
  }.freeze
20
20
 
21
- MENU_LABELS = COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" }.freeze
21
+ MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
22
+ ["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
22
23
 
23
24
  SIDEBAR_WIDTH = 28
24
25
 
@@ -88,6 +89,9 @@ module TUI
88
89
  @command_mode = false
89
90
  @session_picker_active = false
90
91
  @session_picker_index = 0
92
+ @session_picker_page = 0
93
+ @session_picker_mode = :root
94
+ @session_picker_parent_id = nil
91
95
  @view_mode_picker_active = false
92
96
  @view_mode_picker_index = 0
93
97
  @token_setup_active = false
@@ -178,19 +182,30 @@ module TUI
178
182
  else "cyan"
179
183
  end
180
184
 
185
+ session_label = session[:name] || "##{session[:id]}"
186
+
181
187
  lines = [
182
188
  tui.line(spans: [
183
189
  tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
184
190
  ]),
185
191
  tui.line(spans: [tui.span(content: "")]),
186
- tui.line(spans: [
187
- tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
188
- tui.span(content: "##{session[:id]}", style: tui.style(fg: "cyan", modifiers: [:bold]))
189
- ]),
192
+ if session[:name]
193
+ tui.line(spans: [
194
+ tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
195
+ ])
196
+ else
197
+ tui.line(spans: [
198
+ tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
199
+ tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
200
+ ])
201
+ end,
190
202
  tui.line(spans: [
191
203
  tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
192
204
  tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
193
205
  ]),
206
+ active_skills_line(tui, session),
207
+ active_workflow_line(tui, session),
208
+ goals_line(tui, session),
194
209
  tui.line(spans: [tui.span(content: "")]),
195
210
  tui.line(spans: [
196
211
  tui.span(content: "Mode ", style: tui.style(fg: "dark_gray")),
@@ -204,7 +219,7 @@ module TUI
204
219
  tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
205
220
  tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
206
221
  ])
207
- ]
222
+ ].compact
208
223
 
209
224
  info = tui.paragraph(
210
225
  text: lines,
@@ -218,10 +233,65 @@ module TUI
218
233
  frame.render_widget(info, area)
219
234
  end
220
235
 
236
+ # Builds the active skills line for the info panel.
237
+ # Returns nil when no skills are active so the line is hidden entirely.
238
+ # @param tui [RatatuiRuby] TUI rendering context
239
+ # @param session [Hash] session info hash containing :active_skills array
240
+ # @return [RatatuiRuby::Widgets::Line, nil] styled skills line, or nil when empty
241
+ def active_skills_line(tui, session)
242
+ skills = session[:active_skills]
243
+ return if skills.nil? || skills.empty?
244
+
245
+ label = skills.join(", ")
246
+ tui.line(spans: [
247
+ tui.span(content: "\u{1F4DA} ", style: tui.style(fg: "dark_gray")),
248
+ tui.span(content: label, style: tui.style(fg: "yellow"))
249
+ ])
250
+ end
251
+
252
+ # Builds the active workflow line for the info panel.
253
+ # Returns nil when no workflow is active so the line is hidden entirely.
254
+ # @param tui [RatatuiRuby] TUI rendering context
255
+ # @param session [Hash] session info hash containing :active_workflow string
256
+ # @return [RatatuiRuby::Widgets::Line, nil] styled workflow line, or nil when empty
257
+ def active_workflow_line(tui, session)
258
+ workflow = session[:active_workflow]
259
+ return if workflow.nil? || workflow.empty?
260
+
261
+ tui.line(spans: [
262
+ tui.span(content: "\u{1F504} ", style: tui.style(fg: "dark_gray")),
263
+ tui.span(content: workflow, style: tui.style(fg: "magenta"))
264
+ ])
265
+ end
266
+
267
+ # Builds the active goals line for the info panel.
268
+ # Returns nil when no goals exist so the line is hidden entirely.
269
+ # Shows root goal count with active/completed breakdown.
270
+ # @param tui [RatatuiRuby] TUI rendering context
271
+ # @param session [Hash] session info hash containing :goals array
272
+ # @return [RatatuiRuby::Widgets::Line, nil] styled goals line, or nil when empty
273
+ def goals_line(tui, session)
274
+ goal_list = session[:goals]
275
+ return if goal_list.nil? || goal_list.empty?
276
+
277
+ active = goal_list.count { |g| g["status"] == "active" }
278
+ completed = goal_list.count { |g| g["status"] == "completed" }
279
+ label = "#{active} active"
280
+ label += ", #{completed} done" if completed > 0
281
+ tui.line(spans: [
282
+ tui.span(content: "\u{1F3AF} ", style: tui.style(fg: "dark_gray")),
283
+ tui.span(content: label, style: tui.style(fg: "green"))
284
+ ])
285
+ end
286
+
221
287
  # Builds the interaction state line for the info panel.
222
- # Shows "Thinking..." during LLM processing.
288
+ # Shows "Scrolling" when chat pane is focused, or "Thinking..." during LLM processing.
223
289
  def interaction_state_line(tui)
224
- if chat_loading?
290
+ if @screens[:chat].chat_focused
291
+ tui.line(spans: [
292
+ tui.span(content: "Scrolling", style: tui.style(fg: "yellow", modifiers: [:bold]))
293
+ ])
294
+ elsif chat_loading?
225
295
  tui.line(spans: [
226
296
  tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
227
297
  ])
@@ -278,6 +348,16 @@ module TUI
278
348
 
279
349
  return nil unless event.key?
280
350
 
351
+ if event.up?
352
+ @screens[:chat].focus_chat
353
+ return nil
354
+ end
355
+
356
+ if event.down?
357
+ @screens[:chat].unfocus_chat
358
+ return nil
359
+ end
360
+
281
361
  action = COMMAND_KEYS[event.code]
282
362
  case action
283
363
  when :quit
@@ -311,10 +391,30 @@ module TUI
311
391
  return nil
312
392
  end
313
393
 
394
+ if event.esc?
395
+ if @screens[:chat].chat_focused
396
+ @screens[:chat].unfocus_chat
397
+ else
398
+ return_to_parent_session
399
+ end
400
+ return nil
401
+ end
402
+
314
403
  delegate_to_screen(event)
315
404
  nil
316
405
  end
317
406
 
407
+ # Switches to the parent session when viewing a child (sub-agent) session.
408
+ # No-op if the current session is a root session.
409
+ #
410
+ # @return [void]
411
+ def return_to_parent_session
412
+ parent_id = @screens[:chat].parent_session_id
413
+ return unless parent_id
414
+
415
+ @screens[:chat].switch_session(parent_id)
416
+ end
417
+
318
418
  # Forwards an event to the active screen for handling
319
419
  def delegate_to_screen(event)
320
420
  screen = @screens[@current_screen]
@@ -365,71 +465,197 @@ module TUI
365
465
  end
366
466
 
367
467
  # Maps digit key codes to picker list indices.
368
- # Keys 1-9 map to indices 0-8, key 0 maps to index 9.
468
+ # Keys 1-9 map to indices 0-8. Key 0 is reserved for Load More in paginated pickers.
369
469
  #
370
470
  # @param code [String] the key code
371
471
  # @return [Integer, nil] list index, or nil for non-digit keys
372
472
  def hotkey_to_index(code)
373
473
  return nil unless code.length == 1
374
474
 
375
- case code
376
- when "1".."9" then code.to_i - 1
377
- when "0" then 9
378
- end
475
+ code.to_i - 1 if ("1".."9").cover?(code)
379
476
  end
380
477
 
381
478
  # Returns the hotkey character for a given picker list position.
382
- # Positions 0-8 get keys "1"-"9", position 9 gets "0".
479
+ # Positions 0-8 get keys "1"-"9". Positions beyond 8 get no hotkey.
383
480
  #
384
481
  # @param idx [Integer] zero-based list position
385
- # @return [String, nil] hotkey character, or nil for positions beyond 9
482
+ # @return [String, nil] hotkey character, or nil for positions beyond 8
386
483
  def picker_hotkey(idx)
387
- return (idx + 1).to_s if idx < 9
388
- return "0" if idx == 9
389
- nil
484
+ (idx + 1).to_s if idx >= 0 && idx < 9
390
485
  end
391
486
 
392
487
  # -- Session picker ------------------------------------------------
393
488
 
489
+ # Status indicators for child session state.
490
+ CHILD_STATUS_RUNNING = "\u27F3" # ⟳
491
+ CHILD_STATUS_DONE = "\u2713" # ✓
492
+ CHILDREN_ARROW = "\u25B8" # ▸ shown next to sessions with children
493
+ UNNAMED_SUBAGENT_LABEL = "sub-agent"
494
+ SESSION_PICKER_PAGE_SIZE = 9
495
+ SESSION_PICKER_FETCH_LIMIT = 50
496
+ BACK_ARROW = "\u2190" # ←
497
+
394
498
  # Requests the session list from the brain and opens the picker overlay.
499
+ # Fetches up to SESSION_PICKER_FETCH_LIMIT sessions for client-side pagination.
395
500
  # @return [void]
396
501
  def activate_session_picker
397
502
  @session_picker_active = true
398
503
  @session_picker_index = 0
399
- @cable_client.list_sessions
504
+ @session_picker_page = 0
505
+ @session_picker_mode = :root
506
+ @session_picker_parent_id = nil
507
+ @cable_client.list_sessions(limit: SESSION_PICKER_FETCH_LIMIT)
400
508
  end
401
509
 
402
510
  # Dispatches keyboard events while the session picker overlay is open.
511
+ # Supports drill-down navigation: root sessions → children, with
512
+ # pagination via key 0 (Load More) at both levels.
403
513
  #
404
514
  # @param event [RatatuiRuby::Event] keyboard event
405
515
  # @return [nil]
406
516
  def handle_session_picker(event)
517
+ return nil unless event.key?
518
+
519
+ if event.esc?
520
+ handle_session_picker_escape
521
+ return nil
522
+ end
523
+
524
+ visible = session_picker_visible_items
525
+ return nil if visible.empty?
526
+
527
+ if event.up?
528
+ @session_picker_index = [@session_picker_index - 1, 0].max
529
+ elsif event.down?
530
+ @session_picker_index = [@session_picker_index + 1, visible.size - 1].min
531
+ elsif event.right?
532
+ drill_into_children(visible)
533
+ elsif event.left?
534
+ return_to_root_sessions
535
+ elsif event.enter?
536
+ select_session_picker_item(visible)
537
+ elsif event.code == "0" && session_picker_has_more?
538
+ load_more_sessions
539
+ else
540
+ idx = hotkey_to_index(event.code)
541
+ if idx && idx < visible.size
542
+ @session_picker_index = idx
543
+ select_session_picker_item(visible)
544
+ end
545
+ end
546
+
547
+ nil
548
+ end
549
+
550
+ # Returns the raw items for the current picker mode (root sessions or children).
551
+ #
552
+ # @return [Array<Hash>] session or child hashes from the sessions list
553
+ def session_picker_all_items_for_mode
407
554
  sessions = @screens[:chat].sessions_list || []
408
- result = navigate_picker(event, items: sessions, index_ivar: :@session_picker_index)
409
555
 
410
- case result
411
- when :close
556
+ case @session_picker_mode
557
+ when :root
558
+ sessions
559
+ when :children
560
+ parent = sessions.find { |s| s["id"] == @session_picker_parent_id }
561
+ parent&.dig("children") || []
562
+ end
563
+ end
564
+
565
+ # Returns the visible items for the current page of the current mode.
566
+ # Each item is a Hash with :type (:root or :child), :data, and :parent_id (for children).
567
+ #
568
+ # @return [Array<Hash>] visible items for the current page
569
+ def session_picker_visible_items
570
+ all = session_picker_all_items_for_mode
571
+ start = @session_picker_page * SESSION_PICKER_PAGE_SIZE
572
+ page = all[start, SESSION_PICKER_PAGE_SIZE] || []
573
+
574
+ page.map do |item|
575
+ case @session_picker_mode
576
+ when :root
577
+ {type: :root, data: item}
578
+ when :children
579
+ {type: :child, data: item, parent_id: @session_picker_parent_id}
580
+ end
581
+ end
582
+ end
583
+
584
+ # @return [Boolean] true when more items exist beyond the current page
585
+ def session_picker_has_more?
586
+ total = session_picker_all_items_for_mode.size
587
+ ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE) < total
588
+ end
589
+
590
+ # @return [Integer] number of items beyond the current page
591
+ def session_picker_remaining_count
592
+ total = session_picker_all_items_for_mode.size
593
+ [total - ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE), 0].max
594
+ end
595
+
596
+ # Handles Escape in the session picker. In children mode, returns to root.
597
+ # In root mode, closes the picker.
598
+ # @return [void]
599
+ def handle_session_picker_escape
600
+ if @session_picker_mode == :children
601
+ return_to_root_sessions
602
+ else
412
603
  @session_picker_active = false
413
- when Hash
414
- pick_session(result)
415
604
  end
605
+ end
416
606
 
417
- nil
607
+ # Drills into the children of the selected root session.
608
+ # Only available in root mode on sessions with children.
609
+ #
610
+ # @param visible [Array<Hash>] current page items from {#session_picker_visible_items}
611
+ # @return [void]
612
+ def drill_into_children(visible)
613
+ return unless @session_picker_mode == :root
614
+
615
+ item = visible[@session_picker_index]
616
+ return unless item&.dig(:type) == :root
617
+
618
+ session = item[:data]
619
+ return unless session["children"]&.any?
620
+
621
+ @session_picker_mode = :children
622
+ @session_picker_parent_id = session["id"]
623
+ @session_picker_page = 0
624
+ @session_picker_index = 0
625
+ end
626
+
627
+ # Returns from children mode to root sessions view.
628
+ # @return [void]
629
+ def return_to_root_sessions
630
+ return unless @session_picker_mode == :children
631
+
632
+ @session_picker_mode = :root
633
+ @session_picker_parent_id = nil
634
+ @session_picker_page = 0
635
+ @session_picker_index = 0
636
+ end
637
+
638
+ # Advances to the next page of sessions in the current mode.
639
+ # @return [void]
640
+ def load_more_sessions
641
+ @session_picker_page += 1
642
+ @session_picker_index = 0
418
643
  end
419
644
 
420
- # Switches to the selected session and closes the picker.
645
+ # Switches to the session selected in the picker and closes the overlay.
421
646
  #
422
- # @param session [Hash] session entry from sessions_list
647
+ # @param visible [Array<Hash>] current page items from {#session_picker_visible_items}
423
648
  # @return [void]
424
- def pick_session(session)
425
- return unless session
649
+ def select_session_picker_item(visible)
650
+ item = visible[@session_picker_index]
651
+ return unless item
426
652
 
427
653
  @session_picker_active = false
428
- @screens[:chat].switch_session(session["id"])
654
+ @screens[:chat].switch_session(item[:data]["id"])
429
655
  end
430
656
 
431
657
  # Renders the session picker overlay in the sidebar.
432
- # Shows a loading indicator until the sessions_list arrives from the brain.
658
+ # Shows paginated root sessions or children with drill-down navigation.
433
659
  #
434
660
  # @param frame [RatatuiRuby::Frame] terminal frame for widget rendering
435
661
  # @param area [RatatuiRuby::Rect] sidebar area to render into
@@ -444,10 +670,19 @@ module TUI
444
670
  tui.span(content: "Loading...", style: tui.style(fg: "yellow"))
445
671
  ])]
446
672
  else
447
- lines = sessions.each_with_index.flat_map do |session, idx|
448
- format_session_picker_entry(tui, session, idx, current_id)
673
+ visible = session_picker_visible_items
674
+ @session_picker_index = @session_picker_index.clamp(0, [visible.size - 1, 0].max)
675
+
676
+ lines = visible.each_with_index.flat_map do |item, idx|
677
+ if item[:type] == :root
678
+ format_root_session_entry(tui, item[:data], idx, current_id)
679
+ else
680
+ format_child_session_entry(tui, item[:data], idx, current_id)
681
+ end
449
682
  end
450
683
 
684
+ lines.concat(format_load_more_entry(tui)) if session_picker_has_more?
685
+
451
686
  if lines.empty?
452
687
  lines = [tui.line(spans: [
453
688
  tui.span(content: "No sessions", style: tui.style(fg: "dark_gray"))
@@ -458,7 +693,7 @@ module TUI
458
693
  picker = tui.paragraph(
459
694
  text: lines,
460
695
  block: tui.block(
461
- title: "Sessions",
696
+ title: session_picker_title,
462
697
  borders: [:all],
463
698
  border_type: :rounded,
464
699
  border_style: {fg: "cyan"}
@@ -467,26 +702,39 @@ module TUI
467
702
  frame.render_widget(picker, area)
468
703
  end
469
704
 
470
- # Formats a single session entry for the picker. Highlights the selected
471
- # entry and marks the currently active session.
705
+ # Returns the picker title based on the current navigation mode.
706
+ #
707
+ # @return [String] "Sessions" for root mode, "← #N" for children mode
708
+ def session_picker_title
709
+ case @session_picker_mode
710
+ when :root then "Sessions"
711
+ when :children then "#{BACK_ARROW} ##{@session_picker_parent_id}"
712
+ end
713
+ end
714
+
715
+ # Formats a root session entry with drill-in arrow and child count.
472
716
  #
473
717
  # @param tui [RatatuiRuby] TUI rendering API
474
- # @param session [Hash] session data with "id", "message_count", "updated_at"
475
- # @param idx [Integer] position in the list (determines hotkey)
476
- # @param current_id [Integer] the active session's ID
477
- # @return [Array<RatatuiRuby::Widgets::Line>] single line for this entry
478
- def format_session_picker_entry(tui, session, idx, current_id)
718
+ # @param session [Hash] serialized session from the brain
719
+ # @param idx [Integer] position in the current page
720
+ # @param current_id [Integer] ID of the currently active session
721
+ # @return [Array<RatatuiRuby::Widgets::Line>]
722
+ def format_root_session_entry(tui, session, idx, current_id)
479
723
  selected = idx == @session_picker_index
480
724
  is_current = session["id"] == current_id
725
+ children = session["children"] || []
481
726
 
482
727
  hotkey = picker_hotkey(idx)
483
728
  prefix = hotkey ? "[#{hotkey}]" : " "
484
729
  marker = is_current ? "*" : " "
485
- id_label = "##{session["id"]}"
730
+ arrow = children.any? ? CHILDREN_ARROW : " "
731
+
732
+ display_name = session["name"] || "##{session["id"]}"
486
733
  count = "#{session["message_count"]}msg"
487
734
  time = format_relative_time(session["updated_at"])
735
+ child_info = children.any? ? " (#{children.size})" : ""
488
736
 
489
- label = "#{prefix}#{marker}#{id_label} #{count} #{time}"
737
+ label = "#{prefix}#{marker}#{arrow}#{display_name} #{count}#{child_info} #{time}"
490
738
 
491
739
  style = if selected
492
740
  tui.style(fg: "black", bg: "cyan")
@@ -499,6 +747,47 @@ module TUI
499
747
  [tui.line(spans: [tui.span(content: label, style: style)])]
500
748
  end
501
749
 
750
+ # Formats a child session entry with hotkey, status indicator, and agent name.
751
+ #
752
+ # @param tui [RatatuiRuby] TUI rendering API
753
+ # @param child [Hash] serialized child session from the brain
754
+ # @param idx [Integer] position in the current page
755
+ # @param current_id [Integer] ID of the currently active session
756
+ # @return [Array<RatatuiRuby::Widgets::Line>]
757
+ def format_child_session_entry(tui, child, idx, current_id)
758
+ selected = idx == @session_picker_index
759
+ is_current = child["id"] == current_id
760
+
761
+ hotkey = picker_hotkey(idx)
762
+ prefix = hotkey ? "[#{hotkey}]" : " "
763
+ marker = is_current ? "*" : " "
764
+ status = child["processing"] ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
765
+ status_color = child["processing"] ? "yellow" : "green"
766
+ display_name = child["name"] || UNNAMED_SUBAGENT_LABEL
767
+
768
+ label = "#{prefix}#{marker}#{status} #{display_name}"
769
+
770
+ style = if selected
771
+ tui.style(fg: "black", bg: "cyan")
772
+ elsif is_current
773
+ tui.style(fg: "cyan", modifiers: [:bold])
774
+ else
775
+ tui.style(fg: status_color)
776
+ end
777
+
778
+ [tui.line(spans: [tui.span(content: label, style: style)])]
779
+ end
780
+
781
+ # Formats the "Load more" entry shown when additional pages exist.
782
+ #
783
+ # @param tui [RatatuiRuby] TUI rendering API
784
+ # @return [Array<RatatuiRuby::Widgets::Line>]
785
+ def format_load_more_entry(tui)
786
+ remaining = session_picker_remaining_count
787
+ label = "[0] Load more (#{remaining})"
788
+ [tui.line(spans: [tui.span(content: label, style: tui.style(fg: "dark_gray"))])]
789
+ end
790
+
502
791
  # -- View mode picker ----------------------------------------------
503
792
 
504
793
  # Opens the view mode picker overlay. Pre-selects the current mode.
@@ -115,6 +115,26 @@ module TUI
115
115
  end
116
116
  end
117
117
 
118
+ # Removes entries by their event IDs. Used when the brain reports
119
+ # that events have left the LLM's viewport (context window eviction).
120
+ # Acquires the mutex once for the entire batch.
121
+ #
122
+ # @param event_ids [Array<Integer>] database IDs of events to remove
123
+ # @return [Integer] count of entries actually removed
124
+ def remove_by_ids(event_ids)
125
+ @mutex.synchronize do
126
+ removed = 0
127
+ event_ids.each do |event_id|
128
+ entry = @entries_by_id.delete(event_id)
129
+ next unless entry
130
+
131
+ @entries.delete(entry)
132
+ removed += 1
133
+ end
134
+ removed
135
+ end
136
+ end
137
+
118
138
  private
119
139
 
120
140
  # Replaces data on an existing entry matched by event ID.