anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -20,30 +20,23 @@ module TUI
20
20
  class Chat
21
21
  include Formatting
22
22
 
23
- MIN_INPUT_HEIGHT = 3
24
23
  PRINTABLE_CHAR = /\A[[:print:]]\z/
25
24
 
26
25
  ROLE_USER = "user"
27
26
  ROLE_ASSISTANT = "assistant"
28
27
 
29
- SCROLL_STEP = 1
30
- MOUSE_SCROLL_STEP = 2
31
-
32
28
  TOOL_ICON = "\u{1F527}"
33
29
  CHECKMARK = "\u2713"
34
30
 
35
- # Viewport virtualization tuning
36
- VIEWPORT_BACK_BUFFER = 3 # entries before scroll target for upward scroll margin
37
- VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
38
- VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
39
-
40
31
  # Background-highlighted styles for conversation roles.
41
32
  # Dark tinted backgrounds make user/assistant messages easy to scan.
42
- # 22 = dark green (#005f00), 17 = dark navy (#00005f) in 256-color.
43
- ROLE_STYLES = {
44
- "user" => {fg: "white", bg: 22, modifiers: [:bold]},
45
- "assistant" => {fg: "white", bg: 17, modifiers: [:bold]}
46
- }.freeze
33
+ # Colors configured via [theme] user_message_bg / assistant_message_bg.
34
+ def self.role_styles
35
+ {
36
+ "user" => {fg: Settings.theme_color_text, bg: Settings.theme_user_message_bg, modifiers: [:bold]},
37
+ "assistant" => {fg: Settings.theme_color_text, bg: Settings.theme_assistant_message_bg, modifiers: [:bold]}
38
+ }
39
+ end
47
40
 
48
41
  # Intentionally duplicated from Session::VIEW_MODES to keep the TUI
49
42
  # independent of Rails. Must stay in sync when adding new modes.
@@ -268,8 +261,8 @@ module TUI
268
261
  # @return [String]
269
262
  def spinner_label
270
263
  case @session_state
271
- when "llm_generating" then "Thinking..."
272
- when "tool_executing" then "Executing..."
264
+ when "awaiting" then "Thinking..."
265
+ when "executing" then "Executing..."
273
266
  when "interrupting" then "Stopping..."
274
267
  else "Working..."
275
268
  end
@@ -282,9 +275,9 @@ module TUI
282
275
  # @return [String]
283
276
  def spinner_color
284
277
  case @session_state
285
- when "llm_generating", "tool_executing" then "green"
286
- when "interrupting" then "red"
287
- else "dark_gray"
278
+ when "awaiting", "executing" then Settings.theme_color_success
279
+ when "interrupting" then Settings.theme_color_error
280
+ else Settings.theme_color_muted
288
281
  end
289
282
  end
290
283
 
@@ -322,10 +315,12 @@ module TUI
322
315
  handle_goals_updated(msg)
323
316
  when "children_updated"
324
317
  handle_children_updated(msg)
318
+ when "subagent_evicted"
319
+ handle_subagent_evicted(msg)
325
320
  when "sessions_list"
326
321
  @sessions_list = msg["sessions"]
327
322
  when "pending_message_created"
328
- @message_store.add_pending(msg["pending_message_id"], msg["content"]) if msg["pending_message_id"]
323
+ @message_store.add_pending(msg["pending_message_id"], msg) if msg["pending_message_id"]
329
324
  when "pending_message_removed"
330
325
  @message_store.remove_pending(msg["pending_message_id"]) if msg["pending_message_id"]
331
326
  when "authentication_required"
@@ -363,20 +358,20 @@ module TUI
363
358
  end
364
359
  end
365
360
 
366
- handle_viewport_evictions(msg)
361
+ handle_eviction(msg) if msg["action"] == "eviction"
367
362
  end
368
363
  end
369
364
 
370
- # Removes messages that left the LLM's context window. Broadcasts
371
- # include `evicted_message_ids` when old messages are pushed out of the
372
- # viewport by new ones.
365
+ # Removes messages above the eviction cutoff from the message store.
366
+ # Triggered by {Events::EvictionCompleted} when Mneme advances the
367
+ # boundary past an eviction zone.
373
368
  #
374
- # @param msg [Hash] incoming WebSocket message
375
- def handle_viewport_evictions(msg)
376
- evicted_ids = msg["evicted_message_ids"]
377
- return unless evicted_ids.is_a?(Array) && evicted_ids.any?
369
+ # @param msg [Hash] incoming WebSocket message with "evict_above_id"
370
+ def handle_eviction(msg)
371
+ cutoff = msg["evict_above_id"]
372
+ return unless cutoff
378
373
 
379
- @message_store.remove_by_ids(evicted_ids)
374
+ @message_store.remove_above(cutoff)
380
375
  end
381
376
 
382
377
  # Renders flash messages as colored bars inside the chat frame,
@@ -456,7 +451,7 @@ module TUI
456
451
  @session_info[:name] = msg["name"]
457
452
  end
458
453
 
459
- # Updates the active skills list when the analytical brain activates or
454
+ # Updates the active skills list when Melete activates or
460
455
  # deactivates skills. Only applies to the current session.
461
456
  def handle_active_skills_updated(msg)
462
457
  return unless msg["session_id"] == @session_info[:id]
@@ -464,7 +459,7 @@ module TUI
464
459
  @session_info[:active_skills] = msg["active_skills"] || []
465
460
  end
466
461
 
467
- # Updates the active workflow when the analytical brain activates or
462
+ # Updates the active workflow when Melete activates or
468
463
  # deactivates a workflow. Only applies to the current session.
469
464
  def handle_active_workflow_updated(msg)
470
465
  return unless msg["session_id"] == @session_info[:id]
@@ -472,7 +467,7 @@ module TUI
472
467
  @session_info[:active_workflow] = msg["active_workflow"]
473
468
  end
474
469
 
475
- # Updates the goals list when the analytical brain creates or
470
+ # Updates the goals list when Melete creates or
476
471
  # completes goals. Only applies to the current session.
477
472
  def handle_goals_updated(msg)
478
473
  return unless msg["session_id"] == @session_info[:id]
@@ -488,6 +483,20 @@ module TUI
488
483
  @session_info[:children] = msg["children"] || []
489
484
  end
490
485
 
486
+ # Removes a sub-agent from the HUD panel when viewport eviction has
487
+ # taken its last trace past the Mneme boundary. Only applies to the
488
+ # current session — the brain emits this only on the parent's stream.
489
+ #
490
+ # @param msg [Hash] ActionCable payload with "session_id" and "child_id" keys
491
+ def handle_subagent_evicted(msg)
492
+ return unless msg["session_id"] == @session_info[:id]
493
+
494
+ child_id = msg["child_id"]
495
+ return unless child_id
496
+
497
+ @session_info[:children] = @session_info[:children]&.reject { |child| child["id"] == child_id }
498
+ end
499
+
491
500
  # Handles explicit session state transitions from the server.
492
501
  # Drives the braille spinner animation. Only processes broadcasts
493
502
  # matching the current session.
@@ -510,14 +519,14 @@ module TUI
510
519
  child_id = msg["child_id"]
511
520
  return unless child_id
512
521
 
513
- child = @session_info[:children]&.find { |c| c["id"] == child_id }
514
- child["session_state"] = msg["state"] if child
522
+ entry = @session_info[:children]&.find { |child| child["id"] == child_id }
523
+ entry["session_state"] = msg["state"] if entry
515
524
  end
516
525
 
517
526
  # Updates the session state and synchronizes the spinner.
518
527
  #
519
- # @param state [String] one of "idle", "llm_generating",
520
- # "tool_executing", "interrupting"
528
+ # @param state [String] one of "idle", "awaiting",
529
+ # "executing", "interrupting"
521
530
  def update_session_state(state)
522
531
  @session_state = state
523
532
  @spinner.state = state
@@ -598,7 +607,7 @@ module TUI
598
607
 
599
608
  # Phase 5: Paragraph widget + wrapped line count
600
609
  base_widget = @perf_logger.measure(:paragraph) {
601
- tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
610
+ tui.paragraph(text: lines, wrap: true, style: tui.style(fg: Settings.theme_color_text))
602
611
  }
603
612
  wrapped_height = @perf_logger.measure(:line_count) {
604
613
  cached_viewport_line_count(base_widget, inner_width, version)
@@ -633,9 +642,9 @@ module TUI
633
642
  content_length: @max_scroll,
634
643
  position: @scroll_offset,
635
644
  orientation: :vertical_right,
636
- thumb_style: {fg: "cyan"},
645
+ thumb_style: {fg: Settings.theme_scrollbar_thumb},
637
646
  track_symbol: "\u2502",
638
- track_style: {fg: "dark_gray"}
647
+ track_style: {fg: Settings.theme_scrollbar_track}
639
648
  )
640
649
  frame.render_widget(scrollbar, area)
641
650
  end
@@ -678,15 +687,15 @@ module TUI
678
687
  [spinner_line(tui)]
679
688
  elsif @session_loading
680
689
  [tui.line(spans: [
681
- tui.span(content: "Loading session\u2026", style: tui.style(fg: "yellow"))
690
+ tui.span(content: "Loading session\u2026", style: tui.style(fg: Settings.theme_color_warning))
682
691
  ])]
683
692
  else
684
693
  [tui.line(spans: [
685
- tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
694
+ tui.span(content: "Type a message to start chatting.", style: tui.style(fg: Settings.theme_color_muted))
686
695
  ])]
687
696
  end
688
697
 
689
- widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
698
+ widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: Settings.theme_color_text))
690
699
  .with(scroll: [0, 0], block: tui.block(**chat_block_config))
691
700
  frame.render_widget(widget, area)
692
701
  @max_scroll = 0
@@ -729,12 +738,12 @@ module TUI
729
738
  entry_count = entries.size
730
739
 
731
740
  # Start a few entries before the scroll target for upward buffer
732
- buf_first = [first_visible_est - VIEWPORT_BACK_BUFFER, 0].max
741
+ buf_first = [first_visible_est - Settings.chat_viewport_back_buffer, 0].max
733
742
 
734
743
  # Build forward until we've accumulated enough lines to fill the
735
744
  # viewport with margin. Pre-wrap count is a lower bound on visual
736
745
  # height (wrapping only adds lines), so 2x guarantees coverage.
737
- target = @visible_height * VIEWPORT_OVERFLOW_MULTIPLIER
746
+ target = @visible_height * Settings.chat_viewport_overflow_multiplier
738
747
  lines = []
739
748
  pre_wrap_count = 0
740
749
  buf_last = buf_first
@@ -748,7 +757,7 @@ module TUI
748
757
  # the bottom. Near the bottom, always include trailing entries
749
758
  # so the viewport covers the actual end of content — otherwise
750
759
  # the last entries become unreachable.
751
- break if pre_wrap_count >= target && entry_count - idx > VIEWPORT_BOTTOM_THRESHOLD
760
+ break if pre_wrap_count >= target && entry_count - idx > Settings.chat_viewport_bottom_threshold
752
761
  end
753
762
 
754
763
  @perf_logger.info(
@@ -815,6 +824,10 @@ module TUI
815
824
  lines = estimate_text_height(text, effective_width)
816
825
  lines += 1 # header/label line
817
826
  lines += 1 unless entry[:message_type] == "tool_call" # separator
827
+ if data["tools"].is_a?(Array) && data["tools"].any?
828
+ lines += 2 # blank line + "## Tools (N)" header
829
+ lines += estimate_text_height(tools_toon(data), effective_width)
830
+ end
818
831
  lines
819
832
  when :message
820
833
  lines = estimate_text_height(entry[:content].to_s, effective_width)
@@ -853,7 +866,7 @@ module TUI
853
866
  title: "Chat",
854
867
  borders: [:all],
855
868
  border_type: :rounded,
856
- border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
869
+ border_style: @chat_focused ? {fg: Settings.theme_border_focused} : {fg: Settings.theme_color_info}
857
870
  }
858
871
  if @chat_focused
859
872
  config[:titles] = [
@@ -873,7 +886,7 @@ module TUI
873
886
  responses = counter[:responses]
874
887
  complete = calls == responses
875
888
  label = "#{TOOL_ICON} Tools: #{calls}/#{responses}#{" #{CHECKMARK}" if complete}"
876
- color = complete ? "green" : "yellow"
889
+ color = complete ? Settings.theme_color_success : Settings.theme_color_warning
877
890
  [
878
891
  tui.line(spans: [tui.span(content: label, style: tui.style(fg: color))]),
879
892
  tui.line(spans: [tui.span(content: "")])
@@ -900,8 +913,14 @@ module TUI
900
913
  render_system_entry(tui, data)
901
914
  when "system_prompt"
902
915
  render_system_prompt_entry(tui, data)
916
+ when "pending_subagent"
917
+ render_pending_subagent_entry(tui, data)
918
+ when "pending_melete"
919
+ render_pending_melete_entry(tui, data)
920
+ when "pending_mneme"
921
+ render_pending_mneme_entry(tui, data)
903
922
  else
904
- [tui.line(spans: [tui.span(content: data["content"].to_s, style: tui.style(fg: "white"))])]
923
+ [tui.line(spans: [tui.span(content: data["content"].to_s, style: tui.style(fg: Settings.theme_color_text))])]
905
924
  end
906
925
 
907
926
  # Tool calls and their responses are visually one unit — no separator
@@ -935,9 +954,9 @@ module TUI
935
954
  label = role_label(role)
936
955
 
937
956
  if pending
938
- style = tui.style(fg: "gray")
957
+ style = tui.style(fg: Settings.theme_color_muted)
939
958
  else
940
- role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
959
+ role_cfg = self.class.role_styles.fetch(role, {fg: Settings.theme_color_text})
941
960
  style = tui.style(**role_cfg)
942
961
  end
943
962
 
@@ -949,7 +968,7 @@ module TUI
949
968
 
950
969
  first_spans = if tokens && !pending
951
970
  tok_style = {fg: token_count_color(tokens)}
952
- role_bg = ROLE_STYLES.dig(role, :bg)
971
+ role_bg = self.class.role_styles.dig(role, :bg)
953
972
  tok_style[:bg] = role_bg if role_bg
954
973
  [
955
974
  tui.span(content: ts_prefix, style: style),
@@ -962,7 +981,7 @@ module TUI
962
981
  end
963
982
 
964
983
  lines = [tui.line(spans: first_spans)]
965
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: line, style: style)]) }
984
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(line), style: style)]) }
966
985
  lines
967
986
  end
968
987
 
@@ -973,11 +992,63 @@ module TUI
973
992
  def render_system_entry(tui, data)
974
993
  ts = data["timestamp"]
975
994
  header = ts ? "[#{format_ns_timestamp(ts)}] [system]" : "[system]"
976
- style = tui.style(fg: "white")
995
+ style = tui.style(fg: Settings.theme_color_text)
977
996
 
978
997
  content_lines = data["content"].to_s.split("\n", -1)
979
998
  lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
980
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
999
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
1000
+ lines
1001
+ end
1002
+
1003
+ # Renders a pending sub-agent delivery — a sub-agent's reply that
1004
+ # landed on the parent's mailbox and is queued for promotion. Shows
1005
+ # a +[from <nickname>]+ badge in the muted color so it reads as
1006
+ # in-flight, distinct from a real conversation message.
1007
+ # @param tui [RatatuiRuby] TUI rendering API
1008
+ # @param data [Hash] structured data with "source", "content"
1009
+ # @return [Array<RatatuiRuby::Widgets::Line>]
1010
+ def render_pending_subagent_entry(tui, data)
1011
+ render_pending_badge_entry(tui, data, badge: "from #{data["source"]}")
1012
+ end
1013
+
1014
+ # Renders a pending Mneme recall — a memory Mneme will inject as a
1015
+ # phantom tool pair on the next active drain. Visible from verbose
1016
+ # so the user can see what context is about to enter the LLM turn.
1017
+ # @param tui [RatatuiRuby] TUI rendering API
1018
+ # @param data [Hash] structured data with "content"
1019
+ # @return [Array<RatatuiRuby::Widgets::Line>]
1020
+ def render_pending_mneme_entry(tui, data)
1021
+ render_pending_badge_entry(tui, data, badge: "Mneme recall")
1022
+ end
1023
+
1024
+ # Renders a pending Melete activation (skill / workflow / goal).
1025
+ # Badge label includes the activation kind and source name, matching
1026
+ # how Melete narrates its own choices in the transcript.
1027
+ # @param tui [RatatuiRuby] TUI rendering API
1028
+ # @param data [Hash] structured data with "kind", "source", "content"
1029
+ # @return [Array<RatatuiRuby::Widgets::Line>]
1030
+ def render_pending_melete_entry(tui, data)
1031
+ render_pending_badge_entry(tui, data, badge: "Melete #{data["kind"]}: #{data["source"]}")
1032
+ end
1033
+
1034
+ # Shared rendering for badge-prefixed pending entries. Header reads
1035
+ # +[<badge>]+ on the first line; body content (when present)
1036
+ # follows on indented continuation lines. Everything renders in the
1037
+ # muted theme color so the eye groups the in-flight pipeline as
1038
+ # distinct from real messages.
1039
+ # @return [Array<RatatuiRuby::Widgets::Line>]
1040
+ def render_pending_badge_entry(tui, data, badge:)
1041
+ style = tui.style(fg: Settings.theme_color_muted)
1042
+ header = "[#{badge}]"
1043
+ first_content = data["content"].to_s
1044
+
1045
+ if first_content.empty?
1046
+ return [tui.line(spans: [tui.span(content: header, style: style)])]
1047
+ end
1048
+
1049
+ content_lines = first_content.split("\n", -1)
1050
+ lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
1051
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
981
1052
  lines
982
1053
  end
983
1054
 
@@ -989,9 +1060,9 @@ module TUI
989
1060
  # @return [Array<RatatuiRuby::Widgets::Line>]
990
1061
  def render_system_prompt_entry(tui, data)
991
1062
  tokens = data["tokens"]
992
- bold_style = tui.style(fg: "magenta", modifiers: [:bold])
993
- style = tui.style(fg: "magenta")
994
- tool_style = tui.style(fg: "cyan")
1063
+ bold_style = tui.style(fg: Settings.theme_color_accent, modifiers: [:bold])
1064
+ style = tui.style(fg: Settings.theme_color_accent)
1065
+ tool_style = tui.style(fg: Settings.theme_color_info)
995
1066
 
996
1067
  header_spans = [tui.span(content: "[SYSTEM] ", style: bold_style)]
997
1068
  if tokens
@@ -1001,14 +1072,14 @@ module TUI
1001
1072
 
1002
1073
  lines = [tui.line(spans: header_spans)]
1003
1074
  data["content"].to_s.split("\n").each do |line|
1004
- lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
1075
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
1005
1076
  end
1006
1077
 
1007
1078
  if data["tools"].is_a?(Array) && data["tools"].any?
1008
1079
  lines << tui.line(spans: [tui.span(content: "", style: style)])
1009
- lines << tui.line(spans: [tui.span(content: "\u00a0\u00a0## Tools (#{data["tools"].size})", style: bold_style)])
1080
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" ## Tools (#{data["tools"].size})"), style: bold_style)])
1010
1081
  tools_toon(data).split("\n").each do |line|
1011
- lines << tui.line(spans: [tui.span(content: line, style: tool_style)])
1082
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(line), style: tool_style)])
1012
1083
  end
1013
1084
  end
1014
1085
 
@@ -1017,18 +1088,17 @@ module TUI
1017
1088
 
1018
1089
  # Converts tool schemas to TOON format for display. Caches the result
1019
1090
  # on the data hash so the conversion runs once per broadcast, not per
1020
- # frame. Uses non-breaking spaces for indentation because ratatui's
1021
- # Paragraph widget with wrap:true trims regular leading spaces.
1091
+ # frame. NBSP substitution is applied at the span level via
1092
+ # preserve_indentation, not here.
1022
1093
  # @param data [Hash] entry data containing "tools" array
1023
1094
  # @return [String] TOON-formatted tool schemas
1024
1095
  def tools_toon(data)
1025
1096
  data["tools_toon"] ||= Toon.encode(data["tools"])
1026
- .gsub(/^( +)/) { "\u00a0" * _1.length }
1027
1097
  end
1028
1098
 
1029
1099
  def build_chat_message_lines(tui, msg)
1030
1100
  role = msg[:role]
1031
- role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
1101
+ role_cfg = self.class.role_styles.fetch(role, {fg: Settings.theme_color_text})
1032
1102
  role_style = tui.style(**role_cfg)
1033
1103
 
1034
1104
  label = role_label(role)
@@ -1036,14 +1106,14 @@ module TUI
1036
1106
 
1037
1107
  lines = [tui.line(spans: [
1038
1108
  tui.span(content: "#{label}: ", style: role_style),
1039
- tui.span(content: content_lines.first.to_s)
1109
+ tui.span(content: preserve_indentation(content_lines.first.to_s))
1040
1110
  ])]
1041
- content_lines.drop(1).each { |text| lines << tui.line(spans: [tui.span(content: text)]) }
1111
+ content_lines.drop(1).each { |text| lines << tui.line(spans: [tui.span(content: preserve_indentation(text))]) }
1042
1112
  lines << tui.line(spans: [tui.span(content: "")])
1043
1113
  end
1044
1114
 
1045
1115
  # Dynamically calculates input area height based on wrapped content.
1046
- # Clamped between MIN_INPUT_HEIGHT and 50% of available height.
1116
+ # Clamped between Settings.chat_min_input_height and 50% of available height.
1047
1117
  def calculate_input_height(_tui, area_width, area_height)
1048
1118
  inner_width = [area_width - 2, 1].max
1049
1119
 
@@ -1052,8 +1122,8 @@ module TUI
1052
1122
  }
1053
1123
  desired = content_height + 2 # top + bottom border
1054
1124
 
1055
- max_height = [area_height / 2, MIN_INPUT_HEIGHT].max
1056
- desired.clamp(MIN_INPUT_HEIGHT, max_height)
1125
+ max_height = [area_height / 2, Settings.chat_min_input_height].max
1126
+ desired.clamp(Settings.chat_min_input_height, max_height)
1057
1127
  end
1058
1128
 
1059
1129
  def render_input(frame, area, tui)
@@ -1093,15 +1163,15 @@ module TUI
1093
1163
 
1094
1164
  def input_styles(tui, disabled)
1095
1165
  border_color = if disabled || @chat_focused
1096
- "dark_gray"
1166
+ Settings.theme_border_input_disconnected
1097
1167
  elsif @session_loading
1098
- "yellow"
1168
+ Settings.theme_border_input_connecting
1099
1169
  else
1100
- "green"
1170
+ Settings.theme_border_input_connected
1101
1171
  end
1102
1172
 
1103
1173
  {
1104
- text: disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white"),
1174
+ text: disabled ? tui.style(fg: Settings.theme_color_muted) : tui.style(fg: Settings.theme_color_text),
1105
1175
  border: {fg: border_color}
1106
1176
  }
1107
1177
  end
@@ -1263,10 +1333,10 @@ module TUI
1263
1333
  # @return [Boolean] true if the event was handled
1264
1334
  def handle_chat_focused_event(event)
1265
1335
  if event.up?
1266
- scroll_up(SCROLL_STEP)
1336
+ scroll_up(Settings.chat_scroll_step)
1267
1337
  true
1268
1338
  elsif event.down?
1269
- scroll_down(SCROLL_STEP)
1339
+ scroll_down(Settings.chat_scroll_step)
1270
1340
  true
1271
1341
  elsif event.home?
1272
1342
  scroll_up(@max_scroll)
@@ -1351,9 +1421,9 @@ module TUI
1351
1421
  # @return [true] always redraws after scrolling
1352
1422
  def handle_scroll_key(event)
1353
1423
  if event.up?
1354
- scroll_up(SCROLL_STEP)
1424
+ scroll_up(Settings.chat_scroll_step)
1355
1425
  elsif event.down?
1356
- scroll_down(SCROLL_STEP)
1426
+ scroll_down(Settings.chat_scroll_step)
1357
1427
  elsif event.page_up?
1358
1428
  scroll_up(@visible_height)
1359
1429
  elsif event.page_down?
@@ -1366,10 +1436,10 @@ module TUI
1366
1436
  # @return [Boolean] true if the event was a scroll wheel event
1367
1437
  def handle_mouse_event(event)
1368
1438
  if event.scroll_up?
1369
- scroll_up(MOUSE_SCROLL_STEP)
1439
+ scroll_up(Settings.chat_mouse_scroll_step)
1370
1440
  true
1371
1441
  elsif event.scroll_down?
1372
- scroll_down(MOUSE_SCROLL_STEP)
1442
+ scroll_down(Settings.chat_mouse_scroll_step)
1373
1443
  true
1374
1444
  else
1375
1445
  false
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ module TUI
6
+ # TUI-specific configuration backed by +~/.anima/tui.toml+.
7
+ #
8
+ # Zero Rails dependency — the TUI is a standalone client process.
9
+ #
10
+ # Accessors are generated automatically from the template TOML file.
11
+ # Convention: method name = +section_key+ (e.g. +[hud] min_width+ →
12
+ # +hud_min_width+). To add a setting, add the key to +tui.toml+ — the
13
+ # accessor appears automatically.
14
+ #
15
+ # Settings are loaded once at startup. Restart the TUI to pick up
16
+ # changes — it's a thin client, the brain won't notice.
17
+ #
18
+ # @example Reading a setting
19
+ # TUI::Settings.connection_default_host #=> "localhost:42134"
20
+ # TUI::Settings.hud_min_width #=> 24
21
+ #
22
+ # @see Anima::Installer#create_tui_config creates the config file
23
+ module Settings
24
+ DEFAULT_PATH = File.expand_path("~/.anima/tui.toml")
25
+ TEMPLATE_PATH = File.expand_path("../../../templates/tui.toml", __FILE__)
26
+ TEMPLATE = TomlRB.load_file(TEMPLATE_PATH)
27
+
28
+ class MissingConfigError < StandardError; end
29
+ class MissingSettingError < StandardError; end
30
+
31
+ @config_path = nil
32
+
33
+ class << self
34
+ TEMPLATE.each do |section, keys|
35
+ keys.each_key { |key| attr_reader :"#{section}_#{key}" }
36
+ end
37
+
38
+ # Override config file path (for testing).
39
+ # Triggers a load so the new config takes effect immediately.
40
+ #
41
+ # @param path [String, nil] custom path, or +nil+ to restore default
42
+ def config_path=(path)
43
+ @config_path = path
44
+ load! if path
45
+ end
46
+
47
+ # @return [String] active config file path
48
+ def config_path
49
+ @config_path || DEFAULT_PATH
50
+ end
51
+
52
+ # Restores template defaults and clears the path override.
53
+ # Useful in test teardown.
54
+ def reset!
55
+ @config_path = nil
56
+ load_defaults!
57
+ end
58
+
59
+ # Populates ivars from the cached {TEMPLATE} hash without touching disk.
60
+ # Intended for test suites that want template defaults without paying
61
+ # the TOML parse cost for every example.
62
+ def load_defaults!
63
+ TEMPLATE.each do |section, keys|
64
+ keys.each { |key, value| instance_variable_set(:"@#{section}_#{key}", value) }
65
+ end
66
+ end
67
+
68
+ # Parses the config file and populates all setting ivars.
69
+ #
70
+ # @raise [MissingConfigError] when tui.toml does not exist
71
+ # @raise [MissingSettingError] when a template key is missing from config
72
+ def load!
73
+ path = config_path
74
+ unless File.exist?(path)
75
+ raise MissingConfigError,
76
+ "TUI config file not found: #{path}. Run `anima install` to create it."
77
+ end
78
+
79
+ parsed = TomlRB.load_file(path)
80
+ TEMPLATE.each do |section, keys|
81
+ keys.each_key do |key|
82
+ value = parsed.dig(section, key)
83
+ if value.nil?
84
+ raise MissingSettingError,
85
+ "[#{section}] #{key} is not set in #{path}. Run `anima update` to add missing settings."
86
+ end
87
+ instance_variable_set(:"@#{section}_#{key}", value)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -7,10 +7,10 @@ module Workflows
7
7
 
8
8
  # A workflow parsed from a Markdown definition file.
9
9
  # YAML frontmatter holds metadata; the Markdown body contains free-form
10
- # instructions that the analytical brain reads and converts into goals.
10
+ # instructions that Melete reads and converts into goals.
11
11
  #
12
12
  # Workflows are operational recipes — they describe WHAT to do step by
13
- # step. The analytical brain uses judgment to decompose workflow prose
13
+ # step. Melete uses judgment to decompose workflow prose
14
14
  # into tracked goals based on the user's specific context.
15
15
  #
16
16
  # @example Workflow file format
@@ -25,7 +25,7 @@ module Workflows
25
25
  # @return [String] unique workflow identifier used in read_workflow(name: "...")
26
26
  attr_reader :name
27
27
 
28
- # @return [String] description shown to the analytical brain for relevance matching
28
+ # @return [String] description shown to Melete for relevance matching
29
29
  attr_reader :description
30
30
 
31
31
  # @return [String] workflow content (Markdown body) — free-form instructions
@@ -64,7 +64,7 @@ module Workflows
64
64
  @workflows[name]
65
65
  end
66
66
 
67
- # Workflow names and descriptions for inclusion in the analytical brain's context.
67
+ # Workflow names and descriptions for inclusion in Melete's context.
68
68
  #
69
69
  # @return [Hash{String => String}] name => description
70
70
  def catalog
data/skills/github.md ADDED
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: github
3
+ description: "GitHub operations — issues, pull requests, releases, CI runs, comments. Activate when an issue or PR number comes up, or when the conversation turns to repo state."
4
+ ---
5
+
6
+ # GitHub
7
+
8
+ `gh` is installed and authenticated.
9
+
10
+ ## Sub-issues — REST only
11
+
12
+ Not in top-level `gh` commands. Use the issue's *global ID* (not its number):
13
+
14
+ ```bash
15
+ REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
16
+ ISSUE_ID=$(gh api repos/$REPO/issues/42 --jq '.id')
17
+
18
+ # add
19
+ gh api repos/$REPO/issues/36/sub_issues -X POST -F sub_issue_id=$ISSUE_ID
20
+
21
+ # list
22
+ gh api repos/$REPO/issues/36/sub_issues
23
+
24
+ # reorder (move one to sit after another)
25
+ gh api repos/$REPO/issues/36/sub_issues/priority -X PATCH \
26
+ -F sub_issue_id=$ISSUE_ID -F after_id=$AFTER_ID
27
+ ```
28
+
29
+ ## Reading JSON
30
+
31
+ `gh` JSON output is token-expensive. Pipe through `toon` — a lossless LLM-optimized format that round-trips back to JSON:
32
+
33
+ ```bash
34
+ gh pr view 459 --json title,body,reviewDecision | toon
35
+ gh api repos/$REPO/pulls/459/comments | toon
36
+ ```
37
+
38
+ Skip `toon` when the output is going into further tooling that needs raw JSON (jq, scripts).