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
@@ -23,7 +23,7 @@ module TUI
23
23
  #
24
24
  # @example Basic usage
25
25
  # spinner = BrailleSpinner.new
26
- # spinner.state = "llm_generating"
26
+ # spinner.state = "awaiting"
27
27
  # char = spinner.tick # => "⠋" (braille pattern)
28
28
  class BrailleSpinner
29
29
  # Clockwise traversal of the 8 dots in the braille grid.
@@ -73,8 +73,8 @@ module TUI
73
73
  # Ticks per frame for each state — controls animation speed.
74
74
  # Higher = slower. At ~15fps render loop: 2 = ~7.5fps, 4 = ~3.75fps.
75
75
  SPEED = {
76
- "llm_generating" => 2,
77
- "tool_executing" => 1,
76
+ "awaiting" => 2,
77
+ "executing" => 1,
78
78
  "interrupting" => 1
79
79
  }.freeze
80
80
 
@@ -90,8 +90,8 @@ module TUI
90
90
  # Updates the session state driving the animation.
91
91
  # Resets frame position on state change for a clean transition.
92
92
  #
93
- # @param new_state [String] one of "idle", "llm_generating",
94
- # "tool_executing", "interrupting"
93
+ # @param new_state [String] one of "idle", "awaiting",
94
+ # "executing", "interrupting"
95
95
  def state=(new_state)
96
96
  if @state != new_state
97
97
  @frame_index = 0
@@ -143,8 +143,8 @@ module TUI
143
143
 
144
144
  def frames_for_state
145
145
  case @state
146
- when "llm_generating" then SNAKE_TRAIL_FRAMES
147
- when "tool_executing" then TOOL_FRAMES
146
+ when "awaiting" then SNAKE_TRAIL_FRAMES
147
+ when "executing" then TOOL_FRAMES
148
148
  when "interrupting" then INTERRUPT_FRAMES
149
149
  end
150
150
  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
 
@@ -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: effective_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
 
@@ -61,11 +81,11 @@ module TUI
61
81
  # @param tui [RatatuiRuby] TUI rendering API
62
82
  # @return [Array<RatatuiRuby::Widgets::Line>]
63
83
  def render_call(tui)
64
- style = tui.style(fg: color)
84
+ style = tui.style(fg: effective_color)
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
@@ -81,7 +101,7 @@ module TUI
81
101
  indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
82
102
  tool_id = data["tool_use_id"]
83
103
  tokens = data["tokens"]
84
- style = tui.style(fg: response_color)
104
+ style = tui.style(fg: effective_response_color)
85
105
 
86
106
  meta_parts = []
87
107
  meta_parts << "[#{tool_id}]" if tool_id
@@ -97,7 +117,7 @@ module TUI
97
117
  first_line_spans << tui.span(content: content_lines.first.to_s, style: style)
98
118
 
99
119
  lines = [tui.line(spans: first_line_spans)]
100
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
120
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
101
121
  lines
102
122
  end
103
123
 
@@ -120,13 +140,34 @@ module TUI
120
140
  # visually distinct from conversation messages (user/assistant/thought).
121
141
  # @return [String]
122
142
  def color
123
- "magenta"
143
+ Settings.theme_color_accent
124
144
  end
125
145
 
126
146
  # Color for tool response content. Subclasses override for tool-specific colors.
127
147
  # @return [String]
128
148
  def response_color
129
- "white"
149
+ Settings.theme_color_text
150
+ end
151
+
152
+ # @return [Boolean] true when the underlying message is still in the
153
+ # pending mailbox (not yet promoted to a {Message}). Drives muted
154
+ # styling so in-flight pipeline content reads as distinct from
155
+ # committed conversation.
156
+ def pending?
157
+ data["status"] == "pending"
158
+ end
159
+
160
+ # Tool-call color, dimmed to the muted theme color while pending so
161
+ # subclass overrides of {#color} don't have to know about pending state.
162
+ # @return [String]
163
+ def effective_color
164
+ pending? ? Settings.theme_color_muted : color
165
+ end
166
+
167
+ # Tool-response color, dimmed to the muted theme color while pending.
168
+ # @return [String]
169
+ def effective_response_color
170
+ pending? ? Settings.theme_color_muted : response_color
130
171
  end
131
172
 
132
173
  private
@@ -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
@@ -3,9 +3,11 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders edit_file tool calls and responses.
6
- # Calls show the file path with a pencil icon in the unified tool color.
6
+ # Calls show the file path in the header line for immediate visibility.
7
7
  # Responses use the CRUD Update color (light_yellow) to flag modifications.
8
8
  class EditDecorator < BaseDecorator
9
+ include FileCallBehavior
10
+
9
11
  ICON = "\u270F\uFE0F" # pencil
10
12
 
11
13
  def icon
@@ -13,7 +15,7 @@ module TUI
13
15
  end
14
16
 
15
17
  def response_color
16
- "light_yellow"
18
+ Settings.theme_tool_update_color
17
19
  end
18
20
  end
19
21
  end
@@ -3,9 +3,11 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders read_file tool calls and responses.
6
- # Calls show the file path with a page icon in the unified tool color.
6
+ # Calls show the file path in the header line for immediate visibility.
7
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
@@ -13,7 +15,7 @@ module TUI
13
15
  end
14
16
 
15
17
  def response_color
16
- "light_blue"
18
+ Settings.theme_tool_read_color
17
19
  end
18
20
  end
19
21
  end
@@ -17,7 +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
- style = tui.style(fg: "dark_gray")
20
+ style = tui.style(fg: Settings.theme_color_muted)
21
21
  ts = data["timestamp"]
22
22
 
23
23
  meta = []
@@ -26,7 +26,7 @@ module TUI
26
26
 
27
27
  content_lines = data["content"].to_s.split("\n", -1)
28
28
  lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
29
- 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)]) }
30
30
  lines
31
31
  end
32
32
  end
@@ -13,7 +13,7 @@ module TUI
13
13
  end
14
14
 
15
15
  def response_color
16
- "light_blue"
16
+ Settings.theme_tool_read_color
17
17
  end
18
18
  end
19
19
  end
@@ -3,9 +3,11 @@
3
3
  module TUI
4
4
  module Decorators
5
5
  # Renders write_file tool calls and responses.
6
- # Calls show the file path with a memo icon in the unified tool color.
6
+ # Calls show the file path in the header line for immediate visibility.
7
7
  # Responses use the CRUD Create color (light_green) to signal new content.
8
8
  class WriteDecorator < BaseDecorator
9
+ include FileCallBehavior
10
+
9
11
  ICON = "\u{1F4DD}" # memo
10
12
 
11
13
  def icon
@@ -13,7 +15,7 @@ module TUI
13
15
  end
14
16
 
15
17
  def response_color
16
- "light_green"
18
+ Settings.theme_tool_create_color
17
19
  end
18
20
  end
19
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 = 20.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
@@ -21,21 +23,21 @@ module TUI
21
23
  # responses jump out immediately in debug mode.
22
24
  #
23
25
  # Thresholds (empirically tuned from real agent sessions):
24
- # < 1k → dark_gray (routine, ignorable)
25
- # < 3k → white (normal)
26
- # < 10k → yellow (notable)
26
+ # < 1k → muted (routine, ignorable)
27
+ # < 3k → text (normal)
28
+ # < 10k → warning (notable)
27
29
  # < 20k → 208/orange (expensive)
28
- # ≥ 20k → red (alarm — likely runaway)
30
+ # ≥ 20k → error (alarm — likely runaway)
29
31
  #
30
32
  # @param tokens [Integer] token count
31
33
  # @return [String, Integer] named color or 256-color index
32
34
  def token_count_color(tokens)
33
- return "dark_gray" if tokens < 1_000
34
- return "white" if tokens < 3_000
35
- return "yellow" if tokens < 10_000
36
- return 208 if tokens < 20_000 # orange (256-color)
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
37
39
 
38
- "red"
40
+ Settings.theme_color_error
39
41
  end
40
42
 
41
43
  # Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
@@ -46,5 +48,14 @@ module TUI
46
48
 
47
49
  Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
48
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
49
60
  end
50
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