anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "websocket-client-simple"
4
4
  require "json"
5
+ require_relative "settings"
5
6
 
6
7
  module TUI
7
8
  # Action Cable WebSocket client for connecting the TUI to the brain server.
@@ -23,14 +24,6 @@ module TUI
23
24
  # messages = client.drain_messages
24
25
  # client.disconnect
25
26
  class CableClient
26
- DISCONNECT_TIMEOUT = 2 # seconds to wait for WebSocket thread to finish
27
- POLL_INTERVAL = 0.1 # seconds between connection status checks
28
- CONNECTION_TIMEOUT = 10 # seconds to wait for the connecting state to advance
29
- MAX_RECONNECT_ATTEMPTS = 10
30
- BACKOFF_BASE = 1.0 # initial backoff delay in seconds
31
- BACKOFF_CAP = 30.0 # maximum backoff delay
32
- PING_STALE_THRESHOLD = 6.0 # seconds without ping before connection is stale
33
-
34
27
  # Message types queued for the TUI render loop via @message_queue
35
28
  MSG_TYPE_CONNECTION = "connection"
36
29
 
@@ -129,9 +122,9 @@ module TUI
129
122
  # Requests the brain to recall (delete) a pending message so the user
130
123
  # can edit it before the LLM sees it.
131
124
  #
132
- # @param message_id [Integer] database ID of the pending user_message
133
- def recall_pending(message_id)
134
- send_action("recall_pending", {"message_id" => message_id})
125
+ # @param pending_message_id [Integer] database ID of the {PendingMessage}
126
+ def recall_pending(pending_message_id)
127
+ send_action("recall_pending", {"pending_message_id" => pending_message_id})
135
128
  end
136
129
 
137
130
  # Requests interruption of the current tool execution. The server sets
@@ -143,7 +136,7 @@ module TUI
143
136
  end
144
137
 
145
138
  # Sends an Anthropic subscription token to the brain for validation and storage.
146
- # The token flows directly from TUI input to encrypted credentials — never
139
+ # The token flows directly from TUI input to the encrypted secrets table — never
147
140
  # enters the LLM context window.
148
141
  #
149
142
  # @param token [String] Anthropic subscription token (sk-ant-oat01-...)
@@ -180,7 +173,7 @@ module TUI
180
173
  @status = :disconnected
181
174
  end
182
175
  @ws&.close
183
- @ws_thread&.join(DISCONNECT_TIMEOUT)
176
+ @ws_thread&.join(Settings.connection_disconnect_timeout)
184
177
  end
185
178
 
186
179
  private
@@ -260,13 +253,13 @@ module TUI
260
253
  loop do
261
254
  break if @status == :disconnected
262
255
 
263
- if @status == :connecting && (Time.now - connection_start) > CONNECTION_TIMEOUT
256
+ if @status == :connecting && (Time.now - connection_start) > Settings.connection_timeout
264
257
  on_disconnected
265
258
  break
266
259
  end
267
260
 
268
261
  check_stale_connection
269
- sleep POLL_INTERVAL
262
+ sleep Settings.connection_poll_interval
270
263
  end
271
264
  end
272
265
 
@@ -276,7 +269,7 @@ module TUI
276
269
  def check_stale_connection
277
270
  stale = @mutex.synchronize do
278
271
  next false unless @last_ping_at && @status == :subscribed
279
- (Time.now - @last_ping_at) >= PING_STALE_THRESHOLD
272
+ (Time.now - @last_ping_at) >= Settings.connection_ping_stale_threshold
280
273
  end
281
274
 
282
275
  on_disconnected if stale
@@ -291,12 +284,12 @@ module TUI
291
284
  @reconnect_attempt
292
285
  end
293
286
 
294
- if attempt > MAX_RECONNECT_ATTEMPTS
287
+ if attempt > Settings.connection_max_reconnect_attempts
295
288
  @mutex.synchronize { @status = :disconnected }
296
289
  @message_queue << {
297
290
  "type" => MSG_TYPE_CONNECTION,
298
291
  "status" => STATUS_FAILED,
299
- "message" => "Reconnection failed after #{MAX_RECONNECT_ATTEMPTS} attempts"
292
+ "message" => "Reconnection failed after #{Settings.connection_max_reconnect_attempts} attempts"
300
293
  }
301
294
  return false
302
295
  end
@@ -307,7 +300,7 @@ module TUI
307
300
  "type" => MSG_TYPE_CONNECTION,
308
301
  "status" => STATUS_RECONNECTING,
309
302
  "attempt" => attempt,
310
- "max_attempts" => MAX_RECONNECT_ATTEMPTS,
303
+ "max_attempts" => Settings.connection_max_reconnect_attempts,
311
304
  "delay" => delay.round(1)
312
305
  }
313
306
 
@@ -321,7 +314,7 @@ module TUI
321
314
  # @param attempt [Integer] current attempt number (1-based)
322
315
  # @return [Float] delay in seconds
323
316
  def backoff_delay(attempt)
324
- max_delay = [BACKOFF_CAP, BACKOFF_BASE * (2**(attempt - 1))].min
317
+ max_delay = [Settings.connection_backoff_cap, Settings.connection_backoff_base * (2**(attempt - 1))].min
325
318
  rand(0.0..max_delay)
326
319
  end
327
320
 
@@ -19,6 +19,26 @@ module TUI
19
19
  # @example Render a tool call
20
20
  # decorator = TUI::Decorators::BaseDecorator.for(data)
21
21
  # lines = decorator.render(tui)
22
+ # Shared rendering for file-related tool decorators (read, write, edit).
23
+ # Extracts the file path from input and displays it in the header line,
24
+ # making the target file immediately visible — like bash shows its command.
25
+ module FileCallBehavior
26
+ def render_call(tui)
27
+ style = tui.style(fg: color)
28
+ input_lines = data["input"].to_s.split("\n", -1)
29
+ path_line = input_lines.first.to_s
30
+
31
+ header = build_call_header
32
+ header = "#{header} #{path_line}" unless path_line.empty?
33
+ lines = [tui.line(spans: [tui.span(content: header, style: style)])]
34
+
35
+ input_lines.drop(1).each do |line|
36
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
37
+ end
38
+ lines
39
+ end
40
+ end
41
+
22
42
  class BaseDecorator
23
43
  include Formatting
24
44
 
@@ -65,12 +85,14 @@ module TUI
65
85
  header = build_call_header
66
86
  lines = [tui.line(spans: [tui.span(content: header, style: style)])]
67
87
  data["input"].to_s.split("\n", -1).each do |line|
68
- lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
88
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
69
89
  end
70
90
  lines
71
91
  end
72
92
 
73
93
  # Generic tool response rendering — success/failure indicator and content.
94
+ # Token counts get their own color-coded span so expensive responses
95
+ # visually jump out in debug mode.
74
96
  # Subclasses override for tool-specific presentation.
75
97
  #
76
98
  # @param tui [RatatuiRuby] TUI rendering API
@@ -79,17 +101,23 @@ module TUI
79
101
  indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
80
102
  tool_id = data["tool_use_id"]
81
103
  tokens = data["tokens"]
104
+ style = tui.style(fg: response_color)
82
105
 
83
106
  meta_parts = []
84
107
  meta_parts << "[#{tool_id}]" if tool_id
85
108
  meta_parts << indicator
86
- meta_parts << format_token_label(tokens, data["estimated"]) if tokens
87
109
  prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
88
110
 
89
111
  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
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
112
+ first_line_spans = [tui.span(content: prefix, style: style)]
113
+ if tokens
114
+ tok_label = format_token_label(tokens, data["estimated"])
115
+ first_line_spans << tui.span(content: "#{tok_label} ", style: tui.style(fg: token_count_color(tokens)))
116
+ end
117
+ first_line_spans << tui.span(content: content_lines.first.to_s, style: style)
118
+
119
+ lines = [tui.line(spans: first_line_spans)]
120
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
93
121
  lines
94
122
  end
95
123
 
@@ -108,16 +136,17 @@ module TUI
108
136
  ICON
109
137
  end
110
138
 
111
- # Color for tool call headers. Subclasses override for tool-specific colors.
139
+ # Unified color for all tool call headers. Keeps tool invocations
140
+ # visually distinct from conversation messages (user/assistant/thought).
112
141
  # @return [String]
113
142
  def color
114
- "white"
143
+ Settings.theme_color_accent
115
144
  end
116
145
 
117
146
  # Color for tool response content. Subclasses override for tool-specific colors.
118
147
  # @return [String]
119
148
  def response_color
120
- "white"
149
+ Settings.theme_color_text
121
150
  end
122
151
 
123
152
  private
@@ -152,9 +181,9 @@ module TUI
152
181
  case tool_name
153
182
  when "bash" then BashDecorator
154
183
  when "think" then ThinkDecorator
155
- when "read" then ReadDecorator
156
- when "edit" then EditDecorator
157
- when "write" then WriteDecorator
184
+ when "read_file" then ReadDecorator
185
+ when "edit_file" then EditDecorator
186
+ when "write_file" then WriteDecorator
158
187
  when "web_get" then WebGetDecorator
159
188
  else self
160
189
  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
 
@@ -13,7 +13,7 @@ module TUI
13
13
  end
14
14
 
15
15
  def response_color
16
- (data["success"] == false) ? "red" : "green"
16
+ (data["success"] == false) ? Settings.theme_color_error : Settings.theme_color_success
17
17
  end
18
18
  end
19
19
  end
@@ -2,17 +2,20 @@
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 in the header line for immediate visibility.
7
+ # Responses use the CRUD Update color (light_yellow) to flag modifications.
7
8
  class EditDecorator < BaseDecorator
9
+ include FileCallBehavior
10
+
8
11
  ICON = "\u270F\uFE0F" # pencil
9
12
 
10
13
  def icon
11
14
  ICON
12
15
  end
13
16
 
14
- def color
15
- "yellow"
17
+ def response_color
18
+ Settings.theme_tool_update_color
16
19
  end
17
20
  end
18
21
  end
@@ -2,22 +2,20 @@
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 in the header line for immediate visibility.
7
+ # Responses use the CRUD Read color (light_blue) for informational content.
8
8
  class ReadDecorator < BaseDecorator
9
+ include FileCallBehavior
10
+
9
11
  ICON = "\u{1F4C4}" # page facing up
10
12
 
11
13
  def icon
12
14
  ICON
13
15
  end
14
16
 
15
- def color
16
- "cyan"
17
- end
18
-
19
17
  def response_color
20
- "dark_gray"
18
+ Settings.theme_tool_read_color
21
19
  end
22
20
  end
23
21
  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: Settings.theme_color_muted)
23
21
  ts = data["timestamp"]
24
22
 
25
23
  meta = []
@@ -28,7 +26,7 @@ module TUI
28
26
 
29
27
  content_lines = data["content"].to_s.split("\n", -1)
30
28
  lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
31
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
29
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
32
30
  lines
33
31
  end
34
32
  end
@@ -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
+ Settings.theme_tool_read_color
16
17
  end
17
18
  end
18
19
  end
@@ -2,17 +2,20 @@
2
2
 
3
3
  module TUI
4
4
  module Decorators
5
- # Renders write tool calls and responses.
6
- # Calls show the file path with a memo icon.
5
+ # Renders write_file tool calls and responses.
6
+ # Calls show the file path in the header line for immediate visibility.
7
+ # Responses use the CRUD Create color (light_green) to signal new content.
7
8
  class WriteDecorator < BaseDecorator
9
+ include FileCallBehavior
10
+
8
11
  ICON = "\u{1F4DD}" # memo
9
12
 
10
13
  def icon
11
14
  ICON
12
15
  end
13
16
 
14
- def color
15
- "yellow"
17
+ def response_color
18
+ Settings.theme_tool_create_color
16
19
  end
17
20
  end
18
21
  end
data/lib/tui/flash.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "settings"
4
+
3
5
  module TUI
4
6
  # Ephemeral notification system for the TUI, modeled after Rails flash
5
7
  # messages. Notifications render as a colored bar at the top of the
@@ -22,18 +24,19 @@ module TUI
22
24
  # @example Dismissing
23
25
  # flash.dismiss!
24
26
  class Flash
25
- AUTO_DISMISS_SECONDS = 5.0
26
-
27
- # Flash area occupies at most 1/3 of the chat pane height.
28
- MAX_HEIGHT_FRACTION = 3
29
-
30
27
  Entry = Struct.new(:message, :level, :created_at, keyword_init: true)
31
28
 
32
- LEVEL_STYLES = {
33
- error: {fg: "white", bg: "red", icon: " \u2718 "},
34
- warning: {fg: "black", bg: "yellow", icon: " \u26A0 "},
35
- info: {fg: "white", bg: "blue", icon: " \u2139 "}
36
- }.freeze
29
+ LEVEL_ICONS = {error: " \u2718 ", warning: " \u26A0 ", info: " \u2139 "}.freeze
30
+
31
+ # Builds level styles from current theme settings.
32
+ # Called per-render so hot-reloaded theme changes take effect immediately.
33
+ def self.level_styles
34
+ {
35
+ error: {fg: Settings.theme_flash_error_fg, bg: Settings.theme_flash_error_bg},
36
+ warning: {fg: Settings.theme_flash_warning_fg, bg: Settings.theme_flash_warning_bg},
37
+ info: {fg: Settings.theme_flash_info_fg, bg: Settings.theme_flash_info_bg}
38
+ }
39
+ end
37
40
 
38
41
  def initialize
39
42
  @entries = []
@@ -81,7 +84,7 @@ module TUI
81
84
  expire!
82
85
  return 0 if @entries.empty?
83
86
 
84
- height = [@entries.size, area.height / MAX_HEIGHT_FRACTION].min
87
+ height = [@entries.size, area.height / Settings.flash_max_height_fraction].min
85
88
 
86
89
  flash_area, _ = tui.split(
87
90
  area,
@@ -110,7 +113,7 @@ module TUI
110
113
 
111
114
  def expire!
112
115
  now = monotonic_now
113
- @entries.reject! { |entry| now - entry.created_at > AUTO_DISMISS_SECONDS }
116
+ @entries.reject! { |entry| now - entry.created_at > Settings.flash_auto_dismiss_seconds }
114
117
  end
115
118
 
116
119
  def row_rect(area, index, tui)
@@ -120,10 +123,12 @@ module TUI
120
123
  end
121
124
 
122
125
  def render_entry(frame, area, entry, tui)
123
- config = LEVEL_STYLES.fetch(entry.level, LEVEL_STYLES[:info])
126
+ styles = self.class.level_styles
127
+ config = styles.fetch(entry.level, styles[:info])
128
+ icon = LEVEL_ICONS.fetch(entry.level, LEVEL_ICONS[:info])
124
129
  style = tui.style(fg: config[:fg], bg: config[:bg], modifiers: [:bold])
125
130
 
126
- text = "#{config[:icon]}#{entry.message} "
131
+ text = "#{icon}#{entry.message} "
127
132
  # Pad to full width so background color fills the entire row
128
133
  padded = text.ljust(area.width)
129
134
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "settings"
4
+
3
5
  module TUI
4
6
  # Shared formatting helpers for timestamps and token counts.
5
7
  # Used by both the Chat screen and client-side decorators
@@ -16,6 +18,28 @@ module TUI
16
18
  "[#{label} tok]"
17
19
  end
18
20
 
21
+ # Returns a semantic color for token count display.
22
+ # Visually flags expensive messages so runaway tool calls or bloated
23
+ # responses jump out immediately in debug mode.
24
+ #
25
+ # Thresholds (empirically tuned from real agent sessions):
26
+ # < 1k → muted (routine, ignorable)
27
+ # < 3k → text (normal)
28
+ # < 10k → warning (notable)
29
+ # < 20k → 208/orange (expensive)
30
+ # ≥ 20k → error (alarm — likely runaway)
31
+ #
32
+ # @param tokens [Integer] token count
33
+ # @return [String, Integer] named color or 256-color index
34
+ def token_count_color(tokens)
35
+ return Settings.theme_color_muted if tokens < 1_000
36
+ return Settings.theme_color_text if tokens < 3_000
37
+ return Settings.theme_color_warning if tokens < 10_000
38
+ return Settings.theme_color_expensive if tokens < 20_000
39
+
40
+ Settings.theme_color_error
41
+ end
42
+
19
43
  # Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
20
44
  # @param ns [Integer, nil] nanosecond timestamp
21
45
  # @return [String] formatted time, or "--:--:--" when nil
@@ -24,5 +48,14 @@ module TUI
24
48
 
25
49
  Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
26
50
  end
51
+
52
+ # Replaces leading ASCII spaces with non-breaking spaces (\u00a0).
53
+ # Ratatui's Paragraph widget with wrap:true trims regular leading
54
+ # spaces; NBSP preserves visual indentation in wrapped text.
55
+ # @param text [String] text that may contain leading spaces
56
+ # @return [String] text with leading spaces replaced by NBSP
57
+ def preserve_indentation(text)
58
+ text.gsub(/^( +)/) { "\u00a0" * _1.length }
59
+ end
27
60
  end
28
61
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "settings"
4
+
3
5
  module TUI
4
6
  # Manages editable text with cursor position tracking.
5
7
  # Supports multiline input with newline insertion, cursor navigation
@@ -7,8 +9,6 @@ module TUI
7
9
  #
8
10
  # Pure logic object with no rendering or framework dependencies.
9
11
  class InputBuffer
10
- MAX_LENGTH = 10_000
11
-
12
12
  attr_reader :text, :cursor_pos
13
13
 
14
14
  def initialize
@@ -36,9 +36,9 @@ module TUI
36
36
  @text.include?("\n")
37
37
  end
38
38
 
39
- # @return [Boolean] whether the buffer has reached MAX_LENGTH
39
+ # @return [Boolean] whether the buffer has reached Settings.input_max_length
40
40
  def full?
41
- @text.length >= MAX_LENGTH
41
+ @text.length >= Settings.input_max_length
42
42
  end
43
43
 
44
44
  # Ensures cursor stays within valid bounds after external state changes.
@@ -48,9 +48,9 @@ module TUI
48
48
  end
49
49
 
50
50
  # @param char [String] character(s) to insert at cursor
51
- # @return [Boolean] true if inserted, false if result would exceed MAX_LENGTH
51
+ # @return [Boolean] true if inserted, false if result would exceed Settings.input_max_length
52
52
  def insert(char)
53
- return false if @text.length + char.length > MAX_LENGTH
53
+ return false if @text.length + char.length > Settings.input_max_length
54
54
 
55
55
  @text = "#{@text[0...@cursor_pos]}#{char}#{@text[@cursor_pos..]}"
56
56
  @cursor_pos += char.length