anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
data/lib/tools/write.rb CHANGED
@@ -15,16 +15,16 @@ module Tools
15
15
  # tool.execute("path" => "README.md", "content" => "# Title\n")
16
16
  # # => "Wrote 9 bytes to /home/user/project/README.md"
17
17
  class Write < Base
18
- def self.tool_name = "write"
18
+ def self.tool_name = "write_file"
19
19
 
20
- def self.description = "Create or overwrite a file. Creates intermediate directories automatically. Use for new files or full replacement."
20
+ def self.description = "Write file."
21
21
 
22
22
  def self.input_schema
23
23
  {
24
24
  type: "object",
25
25
  properties: {
26
- path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
27
- content: {type: "string", description: "Full file content to write"}
26
+ path: {type: "string", description: "Relative paths resolve against working directory. Creates intermediate directories."},
27
+ content: {type: "string"}
28
28
  },
29
29
  required: %w[path content]
30
30
  }
data/lib/tui/app.rb CHANGED
@@ -21,7 +21,7 @@ module TUI
21
21
  }.freeze
22
22
 
23
23
  MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
24
- ["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
24
+ ["[\u2191] Scroll chat", "[\u2193] Return to input", "[\u2192] Scroll HUD"]).freeze
25
25
 
26
26
  # HUD occupies 1/3 of screen width, clamped to a usable minimum.
27
27
  HUD_MIN_WIDTH = 24
@@ -78,10 +78,16 @@ module TUI
78
78
  # Grace period for watchdog thread to exit before force-killing it.
79
79
  WATCHDOG_SHUTDOWN_TIMEOUT = 1
80
80
 
81
+ # HUD scroll step sizes (lines per event).
82
+ HUD_SCROLL_STEP = 1
83
+ HUD_MOUSE_SCROLL_STEP = 2
84
+
81
85
  attr_reader :current_screen, :command_mode, :session_picker_active,
82
86
  :view_mode_picker_active
83
87
  # @return [Boolean] true when the HUD info panel is visible
84
88
  attr_reader :hud_visible
89
+ # @return [Boolean] true when the HUD pane has keyboard focus for scrolling
90
+ attr_reader :hud_focused
85
91
  # @return [Boolean] true when the token setup popup overlay is visible
86
92
  attr_reader :token_setup_active
87
93
  # @return [Boolean] true when graceful shutdown has been requested via signal
@@ -102,6 +108,11 @@ module TUI
102
108
  @session_picker_mode = :root
103
109
  @session_picker_parent_id = nil
104
110
  @hud_visible = true
111
+ @hud_focused = false
112
+ @hud_scroll_offset = 0
113
+ @hud_max_scroll = 0
114
+ @hud_visible_height = 0
115
+ @hud_content_area = nil
105
116
  @view_mode_picker_active = false
106
117
  @view_mode_picker_index = 0
107
118
  @token_setup_active = false
@@ -197,8 +208,13 @@ module TUI
197
208
  GOAL_ICON_ACTIVE = "\u25CF" # ●
198
209
  GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
199
210
  GOAL_ICON_COMPLETED = "\u2713" # ✓
200
- CHILD_ICON_RUNNING = "\u25CF" # ●
201
- CHILD_ICON_IDLE = "\u25CC" #
211
+
212
+ # Sub-agent state icons — form communicates type of work,
213
+ # color communicates status. Work independently.
214
+ CHILD_ICON_IDLE = "\u25CC" # ◌ hollow — nothing happening
215
+ CHILD_ICON_GENERATING = "\u25CF" # ● filled — LLM thinking
216
+ CHILD_ICON_TOOL_EXECUTING = "\u25C9" # ◉ dot-in-circle — tool running
217
+ CHILD_ICON_INTERRUPTING = "\u25CF" # ● filled — stopping
202
218
 
203
219
  def render_info(frame, area, tui)
204
220
  session = @screens[:chat].session_info
@@ -218,8 +234,10 @@ module TUI
218
234
  end
219
235
 
220
236
  # Renders the main HUD content: session name, goals, skills,
221
- # workflow, and sub-agents.
237
+ # workflow, and sub-agents. Supports vertical scrolling with
238
+ # a scrollbar when content exceeds the visible area.
222
239
  def render_hud_content(frame, area, tui, session)
240
+ @hud_content_area = area
223
241
  session_label = session[:name] || "##{session[:id]}"
224
242
 
225
243
  lines = [
@@ -234,16 +252,42 @@ module TUI
234
252
  interaction_state_line(tui)
235
253
  ].flatten.compact
236
254
 
255
+ @hud_visible_height = [area.height - 2, 0].max
256
+ inner_width = [area.width - 2, 1].max
257
+ total_height = tui.paragraph(text: lines, wrap: true).line_count(inner_width)
258
+ @hud_max_scroll = [total_height - @hud_visible_height, 0].max
259
+ @hud_scroll_offset = @hud_scroll_offset.clamp(0, @hud_max_scroll)
260
+
261
+ border_color = @hud_focused ? "yellow" : "white"
262
+
237
263
  content = tui.paragraph(
238
264
  text: lines,
239
265
  wrap: true,
266
+ scroll: [@hud_scroll_offset, 0],
240
267
  block: tui.block(
241
268
  borders: [:left, :top, :right],
242
269
  border_type: :rounded,
243
- border_style: {fg: "white"}
270
+ border_style: {fg: border_color}
244
271
  )
245
272
  )
246
273
  frame.render_widget(content, area)
274
+
275
+ render_hud_scrollbar(frame, area, tui)
276
+ end
277
+
278
+ # Renders a scrollbar on the right edge of the HUD when content overflows.
279
+ def render_hud_scrollbar(frame, area, tui)
280
+ return unless @hud_max_scroll > 0
281
+
282
+ scrollbar = tui.scrollbar(
283
+ content_length: @hud_max_scroll,
284
+ position: @hud_scroll_offset,
285
+ orientation: :vertical_right,
286
+ thumb_style: {fg: "cyan"},
287
+ track_symbol: "\u2502",
288
+ track_style: {fg: "dark_gray"}
289
+ )
290
+ frame.render_widget(scrollbar, area)
247
291
  end
248
292
 
249
293
  # Renders the bottom status bar: connection state and model name.
@@ -382,26 +426,44 @@ module TUI
382
426
  end
383
427
 
384
428
  # Returns the activity icon and color for a child session.
429
+ # Form communicates type of work, color communicates status —
430
+ # they work independently so even without color the shape tells
431
+ # the story.
385
432
  #
386
- # @param child [Hash] child session data with "processing" key
433
+ # @param child [Hash] child session data with "session_state" key
387
434
  # @return [Array(String, String)] icon and color pair
388
435
  def child_icon_and_color(child)
389
- if child["processing"]
390
- [CHILD_ICON_RUNNING, "yellow"]
436
+ case child["session_state"]
437
+ when "llm_generating"
438
+ [CHILD_ICON_GENERATING, "green"]
439
+ when "tool_executing"
440
+ [CHILD_ICON_TOOL_EXECUTING, "green"]
441
+ when "interrupting"
442
+ [CHILD_ICON_INTERRUPTING, "red"]
391
443
  else
392
- [CHILD_ICON_IDLE, "green"]
444
+ [CHILD_ICON_IDLE, "dark_gray"]
393
445
  end
394
446
  end
395
447
 
396
- # Shows "Scrolling" when chat pane is focused, "Thinking..." during LLM processing.
448
+ # Shows focus mode when a pane is focused, or the braille spinner
449
+ # during active processing.
397
450
  def interaction_state_line(tui)
398
- if @screens[:chat].chat_focused
451
+ if @hud_focused
452
+ tui.line(spans: [
453
+ tui.span(content: "HUD Scroll", style: tui.style(fg: "yellow", modifiers: [:bold]))
454
+ ])
455
+ elsif @screens[:chat].chat_focused
399
456
  tui.line(spans: [
400
457
  tui.span(content: "Scrolling", style: tui.style(fg: "yellow", modifiers: [:bold]))
401
458
  ])
402
459
  elsif chat_loading?
460
+ chat = @screens[:chat]
461
+ char = chat.spinner.tick || "\u2800"
462
+ color = chat.spinner_color
463
+ label = chat.spinner_label
403
464
  tui.line(spans: [
404
- tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
465
+ tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
466
+ tui.span(content: label, style: tui.style(fg: color))
405
467
  ])
406
468
  end
407
469
  end
@@ -410,6 +472,38 @@ module TUI
410
472
  @screens[:chat].loading?
411
473
  end
412
474
 
475
+ # Switches keyboard focus to the HUD pane for scrolling.
476
+ # Unfocuses the chat pane if it was focused.
477
+ #
478
+ # @return [void]
479
+ def focus_hud
480
+ @screens[:chat].unfocus_chat if @screens[:chat].chat_focused
481
+ @hud_focused = true
482
+ end
483
+
484
+ # Returns keyboard focus from the HUD pane.
485
+ #
486
+ # @return [void]
487
+ def unfocus_hud
488
+ @hud_focused = false
489
+ end
490
+
491
+ # Scrolls the HUD viewport up, clamping at the top.
492
+ #
493
+ # @param lines [Integer] number of lines to scroll
494
+ # @return [void]
495
+ def scroll_hud_up(lines)
496
+ @hud_scroll_offset = [@hud_scroll_offset - lines, 0].max
497
+ end
498
+
499
+ # Scrolls the HUD viewport down, clamping at max_scroll.
500
+ #
501
+ # @param lines [Integer] number of lines to scroll
502
+ # @return [void]
503
+ def scroll_hud_down(lines)
504
+ @hud_scroll_offset = [@hud_scroll_offset + lines, @hud_max_scroll].min
505
+ end
506
+
413
507
  def handle_event(event)
414
508
  return nil if event.none?
415
509
  return :quit if event.ctrl_c?
@@ -422,6 +516,8 @@ module TUI
422
516
  handle_view_mode_picker(event)
423
517
  elsif @command_mode
424
518
  handle_command_mode(event)
519
+ elsif @hud_focused
520
+ handle_hud_focused_event(event)
425
521
  else
426
522
  handle_normal_mode(event)
427
523
  end
@@ -442,6 +538,11 @@ module TUI
442
538
  return nil
443
539
  end
444
540
 
541
+ if event.right? && @hud_visible
542
+ focus_hud
543
+ return nil
544
+ end
545
+
445
546
  action = COMMAND_KEYS[event.code]
446
547
  case action
447
548
  when :quit
@@ -451,6 +552,7 @@ module TUI
451
552
  nil
452
553
  when :toggle_hud
453
554
  @hud_visible = !@hud_visible
555
+ unfocus_hud if !@hud_visible
454
556
  nil
455
557
  when :new_session
456
558
  @screens[:chat].new_session
@@ -466,7 +568,13 @@ module TUI
466
568
  end
467
569
 
468
570
  def handle_normal_mode(event)
469
- if event.mouse? || event.paste?
571
+ if event.paste?
572
+ delegate_to_screen(event)
573
+ return nil
574
+ end
575
+
576
+ if event.mouse?
577
+ return nil if route_mouse_to_hud(event)
470
578
  delegate_to_screen(event)
471
579
  return nil
472
580
  end
@@ -497,6 +605,63 @@ module TUI
497
605
  nil
498
606
  end
499
607
 
608
+ # Handles keyboard events when the HUD pane has focus.
609
+ # Arrow keys and Page Up/Down scroll the HUD; Escape and Ctrl+A exit.
610
+ def handle_hud_focused_event(event)
611
+ return nil if event.none?
612
+ return :quit if event.ctrl_c?
613
+
614
+ if event.mouse?
615
+ return nil if route_mouse_to_hud(event)
616
+ delegate_to_screen(event)
617
+ return nil
618
+ end
619
+
620
+ return nil unless event.key?
621
+
622
+ if event.esc? || ctrl_a?(event)
623
+ unfocus_hud
624
+ return nil
625
+ end
626
+
627
+ if event.up?
628
+ scroll_hud_up(HUD_SCROLL_STEP)
629
+ elsif event.down?
630
+ scroll_hud_down(HUD_SCROLL_STEP)
631
+ elsif event.page_up?
632
+ scroll_hud_up(@hud_visible_height)
633
+ elsif event.page_down?
634
+ scroll_hud_down(@hud_visible_height)
635
+ elsif event.home?
636
+ scroll_hud_up(@hud_max_scroll)
637
+ elsif event.end?
638
+ scroll_hud_down(@hud_max_scroll)
639
+ end
640
+ nil
641
+ end
642
+
643
+ # Routes mouse scroll events to the HUD when the cursor is over the HUD area.
644
+ # @return [Boolean] true if the event was handled by the HUD
645
+ def route_mouse_to_hud(event)
646
+ return false if !@hud_visible || !@hud_content_area
647
+ return false unless mouse_over_hud?(event)
648
+ return false unless event.scroll_up? || event.scroll_down?
649
+
650
+ if event.scroll_up?
651
+ scroll_hud_up(HUD_MOUSE_SCROLL_STEP)
652
+ else
653
+ scroll_hud_down(HUD_MOUSE_SCROLL_STEP)
654
+ end
655
+ true
656
+ end
657
+
658
+ # Checks whether a mouse event's coordinates fall within the HUD content area.
659
+ def mouse_over_hud?(event)
660
+ area = @hud_content_area
661
+ event.x >= area.x && event.x < area.x + area.width &&
662
+ event.y >= area.y && event.y < area.y + area.height
663
+ end
664
+
500
665
  # Switches to the parent session when viewing a child (sub-agent) session.
501
666
  # No-op if the current session is a root session.
502
667
  #
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Animated braille spinner that communicates session state through distinct
5
+ # visual patterns. Each state gets its own animation — a user watching long
6
+ # enough starts _feeling_ the difference between LLM thinking and tool
7
+ # execution without reading text.
8
+ #
9
+ # The 2x4 braille grid (U+2800-U+28FF) encodes 8 dots in a single character
10
+ # cell. Dot positions map to bit flags:
11
+ #
12
+ # ┌───┬───┐
13
+ # │ 0 │ 3 │ bit 0 = top-left, bit 3 = top-right
14
+ # │ 1 │ 4 │ bit 1 = mid-left, bit 4 = mid-right
15
+ # │ 2 │ 5 │ bit 2 = lower-left, bit 5 = lower-right
16
+ # │ 6 │ 7 │ bit 6 = bottom-left, bit 7 = bottom-right
17
+ # └───┴───┘
18
+ #
19
+ # During LLM generation, a snake weaves through the grid — organic,
20
+ # unpredictable movement like watching a campfire. During tool execution,
21
+ # a fast staccato pulse signals mechanical work. Interrupting decelerates
22
+ # to a freeze.
23
+ #
24
+ # @example Basic usage
25
+ # spinner = BrailleSpinner.new
26
+ # spinner.state = "llm_generating"
27
+ # char = spinner.tick # => "⠋" (braille pattern)
28
+ class BrailleSpinner
29
+ # Clockwise traversal of the 8 dots in the braille grid.
30
+ # Produces a smooth rotating animation — one dot lit at a time.
31
+ SNAKE_FRAMES = [
32
+ 0x01, # ⠁ dot 0 (top-left)
33
+ 0x02, # ⠂ dot 1 (mid-left)
34
+ 0x04, # ⠄ dot 2 (lower-left)
35
+ 0x40, # ⡀ dot 6 (bottom-left)
36
+ 0x80, # ⢀ dot 7 (bottom-right)
37
+ 0x20, # ⠠ dot 5 (lower-right)
38
+ 0x10, # ⠐ dot 4 (mid-right)
39
+ 0x08 # ⠈ dot 3 (top-right)
40
+ ].freeze
41
+
42
+ # Snake animation: 3 consecutive dots form a growing/moving tail.
43
+ # Each frame is the OR of 3 adjacent positions in SNAKE_FRAMES,
44
+ # creating a worm-like creature circling the grid.
45
+ SNAKE_TRAIL_FRAMES = SNAKE_FRAMES.each_index.map { |idx|
46
+ SNAKE_FRAMES[idx] | SNAKE_FRAMES[(idx + 1) % 8] | SNAKE_FRAMES[(idx + 2) % 8]
47
+ }.freeze
48
+
49
+ # Tool execution: alternating dot patterns for a staccato pulse.
50
+ # Fast, mechanical, clearly different from the smooth snake.
51
+ TOOL_FRAMES = [
52
+ 0x09, # ⠉ dots 0+3 (top row)
53
+ 0x12, # ⠒ dots 1+4 (middle row)
54
+ 0x24, # ⠤ dots 2+5 (lower row)
55
+ 0xC0, # ⣀ dots 6+7 (bottom row)
56
+ 0x24, # ⠤ dots 2+5 (lower row)
57
+ 0x12 # ⠒ dots 1+4 (middle row)
58
+ ].freeze
59
+
60
+ # Interrupting: rapid deceleration — full grid fading to empty.
61
+ INTERRUPT_FRAMES = [
62
+ 0xFF, # ⣿ all dots
63
+ 0xDB, # ⣛ most dots
64
+ 0x49, # ⡉ sparse
65
+ 0x00, # ⠀ empty
66
+ 0x49, # ⡉ sparse
67
+ 0xFF # ⣿ all dots
68
+ ].freeze
69
+
70
+ # Braille Unicode block base codepoint.
71
+ BRAILLE_BASE = 0x2800
72
+
73
+ # Ticks per frame for each state — controls animation speed.
74
+ # Higher = slower. At ~15fps render loop: 2 = ~7.5fps, 4 = ~3.75fps.
75
+ SPEED = {
76
+ "llm_generating" => 2,
77
+ "tool_executing" => 1,
78
+ "interrupting" => 1
79
+ }.freeze
80
+
81
+ # @return [String] current session state
82
+ attr_reader :state
83
+
84
+ def initialize
85
+ @state = "idle"
86
+ @frame_index = 0
87
+ @tick_count = 0
88
+ end
89
+
90
+ # Updates the session state driving the animation.
91
+ # Resets frame position on state change for a clean transition.
92
+ #
93
+ # @param new_state [String] one of "idle", "llm_generating",
94
+ # "tool_executing", "interrupting"
95
+ def state=(new_state)
96
+ if @state != new_state
97
+ @frame_index = 0
98
+ @tick_count = 0
99
+ end
100
+ @state = new_state
101
+ end
102
+
103
+ # Advances the animation by one tick and returns the current
104
+ # braille character. Returns nil when idle (no animation).
105
+ #
106
+ # @return [String, nil] single braille character, or nil when idle
107
+ def tick
108
+ return nil if @state == "idle"
109
+
110
+ frames = frames_for_state
111
+ return nil unless frames
112
+
113
+ speed = SPEED.fetch(@state, 2)
114
+ @tick_count += 1
115
+ if @tick_count >= speed
116
+ @tick_count = 0
117
+ @frame_index = (@frame_index + 1) % frames.size
118
+ end
119
+
120
+ (BRAILLE_BASE + frames[@frame_index]).chr(Encoding::UTF_8)
121
+ end
122
+
123
+ # Returns the current frame character without advancing.
124
+ #
125
+ # @return [String, nil] single braille character, or nil when idle
126
+ def current
127
+ return nil if @state == "idle"
128
+
129
+ frames = frames_for_state
130
+ return nil unless frames
131
+
132
+ (BRAILLE_BASE + frames[@frame_index]).chr(Encoding::UTF_8)
133
+ end
134
+
135
+ # Whether the spinner is actively animating.
136
+ #
137
+ # @return [Boolean]
138
+ def active?
139
+ @state != "idle"
140
+ end
141
+
142
+ private
143
+
144
+ def frames_for_state
145
+ case @state
146
+ when "llm_generating" then SNAKE_TRAIL_FRAMES
147
+ when "tool_executing" then TOOL_FRAMES
148
+ when "interrupting" then INTERRUPT_FRAMES
149
+ end
150
+ end
151
+ end
152
+ end
@@ -129,9 +129,9 @@ module TUI
129
129
  # Requests the brain to recall (delete) a pending message so the user
130
130
  # can edit it before the LLM sees it.
131
131
  #
132
- # @param event_id [Integer] database ID of the pending user_message event
133
- def recall_pending(event_id)
134
- send_action("recall_pending", {"event_id" => event_id})
132
+ # @param pending_message_id [Integer] database ID of the {PendingMessage}
133
+ def recall_pending(pending_message_id)
134
+ send_action("recall_pending", {"pending_message_id" => pending_message_id})
135
135
  end
136
136
 
137
137
  # Requests interruption of the current tool execution. The server sets
@@ -143,7 +143,7 @@ module TUI
143
143
  end
144
144
 
145
145
  # Sends an Anthropic subscription token to the brain for validation and storage.
146
- # The token flows directly from TUI input to encrypted credentials — never
146
+ # The token flows directly from TUI input to the encrypted secrets table — never
147
147
  # enters the LLM context window.
148
148
  #
149
149
  # @param token [String] Anthropic subscription token (sk-ant-oat01-...)
@@ -71,6 +71,8 @@ module TUI
71
71
  end
72
72
 
73
73
  # Generic tool response rendering — success/failure indicator and content.
74
+ # Token counts get their own color-coded span so expensive responses
75
+ # visually jump out in debug mode.
74
76
  # Subclasses override for tool-specific presentation.
75
77
  #
76
78
  # @param tui [RatatuiRuby] TUI rendering API
@@ -79,16 +81,22 @@ module TUI
79
81
  indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
80
82
  tool_id = data["tool_use_id"]
81
83
  tokens = data["tokens"]
84
+ style = tui.style(fg: response_color)
82
85
 
83
86
  meta_parts = []
84
87
  meta_parts << "[#{tool_id}]" if tool_id
85
88
  meta_parts << indicator
86
- meta_parts << format_token_label(tokens, data["estimated"]) if tokens
87
89
  prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
88
90
 
89
91
  content_lines = data["content"].to_s.split("\n", -1)
90
- style = tui.style(fg: response_color)
91
- lines = [tui.line(spans: [tui.span(content: "#{prefix}#{content_lines.first}", style: style)])]
92
+ first_line_spans = [tui.span(content: prefix, style: style)]
93
+ if tokens
94
+ tok_label = format_token_label(tokens, data["estimated"])
95
+ first_line_spans << tui.span(content: "#{tok_label} ", style: tui.style(fg: token_count_color(tokens)))
96
+ end
97
+ first_line_spans << tui.span(content: content_lines.first.to_s, style: style)
98
+
99
+ lines = [tui.line(spans: first_line_spans)]
92
100
  content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
93
101
  lines
94
102
  end
@@ -108,10 +116,11 @@ module TUI
108
116
  ICON
109
117
  end
110
118
 
111
- # Color for tool call headers. Subclasses override for tool-specific colors.
119
+ # Unified color for all tool call headers. Keeps tool invocations
120
+ # visually distinct from conversation messages (user/assistant/thought).
112
121
  # @return [String]
113
122
  def color
114
- "white"
123
+ "magenta"
115
124
  end
116
125
 
117
126
  # Color for tool response content. Subclasses override for tool-specific colors.
@@ -152,9 +161,9 @@ module TUI
152
161
  case tool_name
153
162
  when "bash" then BashDecorator
154
163
  when "think" then ThinkDecorator
155
- when "read" then ReadDecorator
156
- when "edit" then EditDecorator
157
- when "write" then WriteDecorator
164
+ when "read_file" then ReadDecorator
165
+ when "edit_file" then EditDecorator
166
+ when "write_file" then WriteDecorator
158
167
  when "web_get" then WebGetDecorator
159
168
  else self
160
169
  end
@@ -3,8 +3,8 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders bash tool calls and responses.
6
- # Calls show the shell command with a terminal icon.
7
- # Responses use green for success, red for failure.
6
+ # Calls show the shell command with a terminal icon in the unified tool color.
7
+ # Responses use green for success, red for failure — immediate actionable feedback.
8
8
  class BashDecorator < BaseDecorator
9
9
  ICON = "\u{1F4BB}" # laptop / terminal
10
10
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  module TUI
4
4
  module Decorators
5
- # Renders edit tool calls and responses.
6
- # Calls show the file path with a pencil icon.
5
+ # Renders edit_file tool calls and responses.
6
+ # Calls show the file path with a pencil icon in the unified tool color.
7
+ # Responses use the CRUD Update color (light_yellow) to flag modifications.
7
8
  class EditDecorator < BaseDecorator
8
9
  ICON = "\u270F\uFE0F" # pencil
9
10
 
@@ -11,8 +12,8 @@ module TUI
11
12
  ICON
12
13
  end
13
14
 
14
- def color
15
- "yellow"
15
+ def response_color
16
+ "light_yellow"
16
17
  end
17
18
  end
18
19
  end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module TUI
4
4
  module Decorators
5
- # Renders read tool calls and responses.
6
- # Calls show the file path with a page icon.
7
- # Responses show file content in dim text.
5
+ # Renders read_file tool calls and responses.
6
+ # Calls show the file path with a page icon in the unified tool color.
7
+ # Responses use the CRUD Read color (light_blue) for informational content.
8
8
  class ReadDecorator < BaseDecorator
9
9
  ICON = "\u{1F4C4}" # page facing up
10
10
 
@@ -12,12 +12,8 @@ module TUI
12
12
  ICON
13
13
  end
14
14
 
15
- def color
16
- "cyan"
17
- end
18
-
19
15
  def response_color
20
- "dark_gray"
16
+ "light_blue"
21
17
  end
22
18
  end
23
19
  end
@@ -3,8 +3,8 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders think tool events — the agent's inner reasoning.
6
- # "aloud" thoughts use yellow (narration for the user), "inner"
7
- # thoughts use dark_gray (dimmed to signal internality).
6
+ # Both "aloud" and "inner" thoughts use grey (dark_gray) to visually
7
+ # de-emphasize reasoning content vs. actual conversation output.
8
8
  class ThinkDecorator < BaseDecorator
9
9
  THOUGHT_BUBBLE = "\u{1F4AD}" # thought balloon
10
10
 
@@ -17,9 +17,7 @@ module TUI
17
17
  # @param tui [RatatuiRuby] TUI rendering API
18
18
  # @return [Array<RatatuiRuby::Widgets::Line>]
19
19
  def render_think(tui)
20
- aloud = data["visibility"] == "aloud"
21
- fg = aloud ? "yellow" : "dark_gray"
22
- style = tui.style(fg: fg)
20
+ style = tui.style(fg: "dark_gray")
23
21
  ts = data["timestamp"]
24
22
 
25
23
  meta = []
@@ -3,7 +3,8 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders web_get tool calls and responses.
6
- # Calls show the URL with a globe icon.
6
+ # Calls show the URL with a globe icon in the unified tool color.
7
+ # Responses use the CRUD Read color (light_blue) for fetched content.
7
8
  class WebGetDecorator < BaseDecorator
8
9
  ICON = "\u{1F310}" # globe with meridians
9
10
 
@@ -11,8 +12,8 @@ module TUI
11
12
  ICON
12
13
  end
13
14
 
14
- def color
15
- "blue"
15
+ def response_color
16
+ "light_blue"
16
17
  end
17
18
  end
18
19
  end