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
data/lib/tui/app.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "settings"
4
5
  require_relative "cable_client"
5
6
  require_relative "input_buffer"
6
7
  require_relative "message_store"
@@ -21,10 +22,7 @@ module TUI
21
22
  }.freeze
22
23
 
23
24
  MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
24
- ["[\u2191] Scroll chat", "[\u2193] Return to input", "[\u2192] Scroll HUD"]).freeze
25
-
26
- # HUD occupies 1/3 of screen width, clamped to a usable minimum.
27
- HUD_MIN_WIDTH = 24
25
+ ["[\u2191] Scroll chat", "[\u2193] Return to input", "[\u2192] Sub-agents / HUD"]).freeze
28
26
 
29
27
  # Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
30
28
  PICKER_PREFIX_WIDTH = 5
@@ -38,28 +36,22 @@ module TUI
38
36
 
39
37
  # Connection status emoji indicators for the info panel.
40
38
  # Subscribed (normal state) shows only the emoji; other states add text.
41
- STATUS_STYLES = {
42
- disconnected: {label: "🔴 Disconnected", color: "red"},
43
- connecting: {label: "🟡 Connecting", color: "yellow"},
44
- connected: {label: "🟡 Connecting", color: "yellow"},
45
- subscribed: {label: "🟢", color: "green"},
46
- reconnecting: {label: "🟡 Reconnecting", color: "yellow"}
39
+ STATUS_LABELS = {
40
+ disconnected: "🔴 Disconnected",
41
+ connecting: "🟡 Connecting",
42
+ connected: "🟡 Connecting",
43
+ subscribed: "🟢",
44
+ reconnecting: "🟡 Reconnecting"
47
45
  }.freeze
48
46
 
49
- # Number of leading characters to show unmasked in the token input.
50
- # Matches the "sk-ant-oat01-" prefix (13 chars) plus one character of the
51
- # secret portion so the user can verify both the token type and start of key.
52
- TOKEN_MASK_VISIBLE = 14
53
-
54
- # Maximum stars to show in the masked portion of the token.
55
- # Keeps the masked display compact regardless of actual token length.
56
- TOKEN_MASK_STARS = 4
57
-
58
- # Token setup popup dimensions. Height accommodates: status line, blank,
59
- # 2 instruction lines, blank, "Token:" label, input line, blank,
60
- # error/success line, blank, hint line, plus top/bottom borders.
61
- POPUP_HEIGHT = 14
62
- POPUP_MIN_WIDTH = 44
47
+ # Maps connection status to semantic theme color.
48
+ STATUS_COLORS = {
49
+ disconnected: :theme_color_error,
50
+ connecting: :theme_color_warning,
51
+ connected: :theme_color_warning,
52
+ subscribed: :theme_color_success,
53
+ reconnecting: :theme_color_warning
54
+ }.freeze
63
55
 
64
56
  # Matches a single printable Unicode character (no control codes).
65
57
  PRINTABLE_CHAR = /\A[[:print:]]\z/
@@ -67,27 +59,18 @@ module TUI
67
59
  # Signals that trigger graceful shutdown when received from the OS.
68
60
  SHUTDOWN_SIGNALS = %w[HUP TERM INT].freeze
69
61
 
70
- # How often the watchdog thread checks if the controlling terminal is alive.
71
- # @see #terminal_watchdog_loop
72
- TERMINAL_CHECK_INTERVAL = 0.5
73
-
74
62
  # Unix controlling terminal device path.
75
63
  # @see #terminal_watchdog_loop
76
64
  CONTROLLING_TERMINAL = "/dev/tty"
77
65
 
78
- # Grace period for watchdog thread to exit before force-killing it.
79
- WATCHDOG_SHUTDOWN_TIMEOUT = 1
80
-
81
- # HUD scroll step sizes (lines per event).
82
- HUD_SCROLL_STEP = 1
83
- HUD_MOUSE_SCROLL_STEP = 2
84
-
85
66
  attr_reader :current_screen, :command_mode, :session_picker_active,
86
67
  :view_mode_picker_active
87
68
  # @return [Boolean] true when the HUD info panel is visible
88
69
  attr_reader :hud_visible
89
70
  # @return [Boolean] true when the HUD pane has keyboard focus for scrolling
90
71
  attr_reader :hud_focused
72
+ # @return [Integer, nil] index of selected child in HUD navigation, nil when inactive
73
+ attr_reader :hud_child_index
91
74
  # @return [Boolean] true when the token setup popup overlay is visible
92
75
  attr_reader :token_setup_active
93
76
  # @return [Boolean] true when graceful shutdown has been requested via signal
@@ -109,6 +92,7 @@ module TUI
109
92
  @session_picker_parent_id = nil
110
93
  @hud_visible = true
111
94
  @hud_focused = false
95
+ @hud_child_index = nil
112
96
  @hud_scroll_offset = 0
113
97
  @hud_max_scroll = 0
114
98
  @hud_visible_height = 0
@@ -157,7 +141,7 @@ module TUI
157
141
  @screens[:chat].hud_hint = !@hud_visible
158
142
 
159
143
  if @hud_visible
160
- hud_width = [frame.area.width / 3, HUD_MIN_WIDTH].max
144
+ hud_width = [frame.area.width / 3, Settings.hud_min_width].max
161
145
  content_area, sidebar = tui.split(
162
146
  frame.area,
163
147
  direction: :horizontal,
@@ -198,7 +182,7 @@ module TUI
198
182
  title: "Command",
199
183
  borders: [:all],
200
184
  border_type: :rounded,
201
- border_style: {fg: "yellow"}
185
+ border_style: {fg: Settings.theme_border_focused}
202
186
  )
203
187
  )
204
188
  frame.render_widget(menu, area)
@@ -218,18 +202,34 @@ module TUI
218
202
 
219
203
  def render_info(frame, area, tui)
220
204
  session = @screens[:chat].session_info
221
-
222
- # Split into main content area and bottom status bar
223
- main_area, status_area = tui.split(
224
- area,
225
- direction: :vertical,
226
- constraints: [
227
- tui.constraint_fill(1),
228
- tui.constraint_length(3)
229
- ]
230
- )
231
-
232
- render_hud_content(frame, main_area, tui, session)
205
+ stats = @screens[:chat].message_store.token_economy
206
+ has_metrics = stats[:call_count] > 0 || stats[:rate_limits]
207
+
208
+ # Split into main content, optional token economy panel, and status bar
209
+ if has_metrics
210
+ economy_height = token_economy_line_count(stats) + 1 # +1 for top border
211
+ main_area, economy_area, status_area = tui.split(
212
+ area,
213
+ direction: :vertical,
214
+ constraints: [
215
+ tui.constraint_fill(1),
216
+ tui.constraint_length(economy_height),
217
+ tui.constraint_length(2)
218
+ ]
219
+ )
220
+ render_hud_content(frame, main_area, tui, session)
221
+ render_token_economy_panel(frame, economy_area, tui, stats)
222
+ else
223
+ main_area, status_area = tui.split(
224
+ area,
225
+ direction: :vertical,
226
+ constraints: [
227
+ tui.constraint_fill(1),
228
+ tui.constraint_length(2)
229
+ ]
230
+ )
231
+ render_hud_content(frame, main_area, tui, session)
232
+ end
233
233
  render_hud_status_bar(frame, status_area, tui)
234
234
  end
235
235
 
@@ -242,8 +242,8 @@ module TUI
242
242
 
243
243
  lines = [
244
244
  tui.line(spans: [
245
- tui.span(content: "\u{1F4CB} ", style: tui.style(fg: "dark_gray")),
246
- tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
245
+ tui.span(content: "\u{1F4CB} ", style: tui.style(fg: Settings.theme_color_muted)),
246
+ tui.span(content: session_label, style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold]))
247
247
  ]),
248
248
  hud_goals_section(tui, session),
249
249
  hud_skills_line(tui, session),
@@ -258,7 +258,7 @@ module TUI
258
258
  @hud_max_scroll = [total_height - @hud_visible_height, 0].max
259
259
  @hud_scroll_offset = @hud_scroll_offset.clamp(0, @hud_max_scroll)
260
260
 
261
- border_color = @hud_focused ? "yellow" : "white"
261
+ border_color = @hud_focused ? Settings.theme_border_focused : Settings.theme_border_normal
262
262
 
263
263
  content = tui.paragraph(
264
264
  text: lines,
@@ -283,9 +283,9 @@ module TUI
283
283
  content_length: @hud_max_scroll,
284
284
  position: @hud_scroll_offset,
285
285
  orientation: :vertical_right,
286
- thumb_style: {fg: "cyan"},
286
+ thumb_style: {fg: Settings.theme_scrollbar_thumb},
287
287
  track_symbol: "\u2502",
288
- track_style: {fg: "dark_gray"}
288
+ track_style: {fg: Settings.theme_scrollbar_track}
289
289
  )
290
290
  frame.render_widget(scrollbar, area)
291
291
  end
@@ -293,36 +293,38 @@ module TUI
293
293
  # Renders the bottom status bar: connection state and model name.
294
294
  def render_hud_status_bar(frame, area, tui)
295
295
  cable_status = @cable_client.status
296
- style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
296
+ label = STATUS_LABELS.fetch(cable_status, STATUS_LABELS[:disconnected])
297
+ status_color = Settings.public_send(STATUS_COLORS.fetch(cable_status, :theme_color_error))
297
298
 
298
299
  status_label = if cable_status == :reconnecting
299
300
  attempt = @cable_client.reconnect_attempt
300
- max = CableClient::MAX_RECONNECT_ATTEMPTS
301
- "#{style[:label]} (#{attempt}/#{max})"
301
+ max = Settings.connection_max_reconnect_attempts
302
+ "#{label} (#{attempt}/#{max})"
302
303
  else
303
- style[:label]
304
+ label
304
305
  end
305
306
 
306
307
  view_mode = @screens[:chat].view_mode
307
308
  mode_label = view_mode.capitalize
308
309
  mode_color = case view_mode
309
- when "verbose" then "yellow"
310
- when "debug" then "magenta"
311
- else "cyan"
310
+ when "verbose" then Settings.theme_color_warning
311
+ when "debug" then Settings.theme_color_accent
312
+ else Settings.theme_color_info
312
313
  end
313
314
 
314
315
  bar = tui.paragraph(
315
316
  text: [
316
317
  tui.line(spans: [
317
- tui.span(content: status_label, style: tui.style(fg: style[:color], modifiers: [:bold])),
318
- tui.span(content: " \u2502 ", style: tui.style(fg: "dark_gray")),
318
+ tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
319
+ tui.span(content: status_label, style: tui.style(fg: status_color, modifiers: [:bold])),
320
+ tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
319
321
  tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
320
322
  ])
321
323
  ],
322
324
  block: tui.block(
323
325
  borders: [:left, :bottom, :right],
324
326
  border_type: :rounded,
325
- border_style: {fg: "white"}
327
+ border_style: {fg: Settings.theme_border_normal}
326
328
  )
327
329
  )
328
330
  frame.render_widget(bar, area)
@@ -340,7 +342,7 @@ module TUI
340
342
  lines = [
341
343
  tui.line(spans: [tui.span(content: "")]),
342
344
  tui.line(spans: [
343
- tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg: "dark_gray"))
345
+ tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg: Settings.theme_color_muted))
344
346
  ])
345
347
  ]
346
348
 
@@ -348,7 +350,7 @@ module TUI
348
350
  icon, color = goal_icon_and_color(goal)
349
351
  lines << tui.line(spans: [
350
352
  tui.span(content: " #{icon} ", style: tui.style(fg: color)),
351
- tui.span(content: goal["description"].to_s, style: tui.style(fg: "white"))
353
+ tui.span(content: goal["description"].to_s, style: tui.style(fg: Settings.theme_color_text))
352
354
  ])
353
355
  end
354
356
 
@@ -362,11 +364,11 @@ module TUI
362
364
  # @return [Array(String, String)] icon and color pair
363
365
  def goal_icon_and_color(goal)
364
366
  if goal["status"] == "completed"
365
- [GOAL_ICON_COMPLETED, "green"]
367
+ [GOAL_ICON_COMPLETED, Settings.theme_color_success]
366
368
  elsif goal["sub_goals"]&.any? { |sg| sg["status"] == "completed" }
367
- [GOAL_ICON_IN_PROGRESS, "yellow"]
369
+ [GOAL_ICON_IN_PROGRESS, Settings.theme_color_warning]
368
370
  else
369
- [GOAL_ICON_ACTIVE, "cyan"]
371
+ [GOAL_ICON_ACTIVE, Settings.theme_color_info]
370
372
  end
371
373
  end
372
374
 
@@ -379,8 +381,8 @@ module TUI
379
381
  [
380
382
  tui.line(spans: [tui.span(content: "")]),
381
383
  tui.line(spans: [
382
- tui.span(content: "\u{1F9E0} ", style: tui.style(fg: "dark_gray")),
383
- tui.span(content: skills.join(", "), style: tui.style(fg: "yellow"))
384
+ tui.span(content: "\u{1F9E0} ", style: tui.style(fg: Settings.theme_color_muted)),
385
+ tui.span(content: skills.join(", "), style: tui.style(fg: Settings.theme_color_warning))
384
386
  ])
385
387
  ]
386
388
  end
@@ -394,32 +396,44 @@ module TUI
394
396
  [
395
397
  tui.line(spans: [tui.span(content: "")]),
396
398
  tui.line(spans: [
397
- tui.span(content: "\u{1F4DC} ", style: tui.style(fg: "dark_gray")),
398
- tui.span(content: workflow, style: tui.style(fg: "magenta"))
399
+ tui.span(content: "\u{1F4DC} ", style: tui.style(fg: Settings.theme_color_muted)),
400
+ tui.span(content: workflow, style: tui.style(fg: Settings.theme_color_accent))
399
401
  ])
400
402
  ]
401
403
  end
402
404
 
403
405
  # Builds the sub-agents section with activity indicators.
406
+ # Highlights the selected child when HUD child navigation is active.
404
407
  # @return [Array<RatatuiRuby::Widgets::Line>, nil]
405
408
  def hud_children_section(tui, session)
406
409
  children = session[:children]
407
410
  return if children.nil? || children.empty?
408
411
 
412
+ header_label = @hud_child_index ? "Sub-agents [\u2190 back]" : "Sub-agents"
413
+
409
414
  lines = [
410
415
  tui.line(spans: [tui.span(content: "")]),
411
416
  tui.line(spans: [
412
- tui.span(content: "\u{1F465} Sub-agents", style: tui.style(fg: "dark_gray"))
417
+ tui.span(content: "\u{1F465} #{header_label}", style: tui.style(fg: Settings.theme_color_muted))
413
418
  ])
414
419
  ]
415
420
 
416
- children.each do |child|
421
+ children.each_with_index do |child, idx|
417
422
  icon, color = child_icon_and_color(child)
418
423
  name = child["name"] || "sub-agent"
419
- lines << tui.line(spans: [
420
- tui.span(content: " #{icon} ", style: tui.style(fg: color)),
421
- tui.span(content: "@#{name}", style: tui.style(fg: "white"))
422
- ])
424
+ selected = idx == @hud_child_index
425
+
426
+ lines << if selected
427
+ tui.line(spans: [
428
+ tui.span(content: "\u25B8 #{icon} ", style: tui.style(fg: Settings.theme_color_info)),
429
+ tui.span(content: "@#{name}", style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold]))
430
+ ])
431
+ else
432
+ tui.line(spans: [
433
+ tui.span(content: " #{icon} ", style: tui.style(fg: color)),
434
+ tui.span(content: "@#{name}", style: tui.style(fg: Settings.theme_color_text))
435
+ ])
436
+ end
423
437
  end
424
438
 
425
439
  lines
@@ -434,27 +448,212 @@ module TUI
434
448
  # @return [Array(String, String)] icon and color pair
435
449
  def child_icon_and_color(child)
436
450
  case child["session_state"]
437
- when "llm_generating"
438
- [CHILD_ICON_GENERATING, "green"]
439
- when "tool_executing"
440
- [CHILD_ICON_TOOL_EXECUTING, "green"]
451
+ when "awaiting"
452
+ [CHILD_ICON_GENERATING, Settings.theme_color_success]
453
+ when "executing"
454
+ [CHILD_ICON_TOOL_EXECUTING, Settings.theme_color_success]
441
455
  when "interrupting"
442
- [CHILD_ICON_INTERRUPTING, "red"]
456
+ [CHILD_ICON_INTERRUPTING, Settings.theme_color_error]
457
+ else
458
+ [CHILD_ICON_IDLE, Settings.theme_color_muted]
459
+ end
460
+ end
461
+
462
+ # Renders the Token Economy panel as a fixed section between HUD content
463
+ # and the status bar.
464
+ def render_token_economy_panel(frame, area, tui, stats)
465
+ lines = []
466
+
467
+ rate_limits = stats[:rate_limits]
468
+ lines.concat(build_rate_limit_lines(tui, rate_limits)) if rate_limits
469
+ lines.concat(build_cache_metrics_lines(tui, stats)) if stats[:call_count] > 0
470
+
471
+ panel = tui.paragraph(
472
+ text: lines,
473
+ block: tui.block(
474
+ title: " \u{1F4CA} Token Economy ",
475
+ borders: [:left, :top, :right],
476
+ border_type: :rounded,
477
+ border_style: {fg: Settings.theme_border_normal}
478
+ )
479
+ )
480
+ frame.render_widget(panel, area)
481
+ end
482
+
483
+ # Returns the number of content lines the token economy panel will render.
484
+ def token_economy_line_count(stats)
485
+ count = 0
486
+ if stats[:rate_limits]
487
+ count += 1 if stats[:rate_limits]["5h_utilization"]
488
+ count += 1 if stats[:rate_limits]["7d_utilization"]
489
+ end
490
+ if stats[:call_count] > 0
491
+ count += 2 # cache hit bar + saved
492
+ count += 1 if stats[:cache_history]&.size.to_i > 1 # sparkline
493
+ end
494
+ count
495
+ end
496
+
497
+ # Builds rate limit display lines with progress bars and pacing.
498
+ # Compact aligned labels: 5h, 7d (same 2-char width).
499
+ # @return [Array<RatatuiRuby::Widgets::Line>]
500
+ def build_rate_limit_lines(tui, rate_limits)
501
+ lines = []
502
+
503
+ # 5-hour window
504
+ util_5h = rate_limits["5h_utilization"]
505
+ if util_5h
506
+ pct = (util_5h * 100).round
507
+ bar = build_progress_bar(pct, Settings.theme_progress_bar_width)
508
+ color = rate_limit_color(pct)
509
+ reset_5h = rate_limits["5h_reset"]
510
+ reset_label = reset_5h ? " #{format_reset_time(reset_5h)}" : ""
511
+
512
+ lines << tui.line(spans: [
513
+ tui.span(content: " 5h ", style: tui.style(fg: color, modifiers: [:bold])),
514
+ tui.span(content: bar, style: tui.style(fg: color)),
515
+ tui.span(content: " #{pct.to_s.rjust(2)}%", style: tui.style(fg: color)),
516
+ tui.span(content: reset_label, style: tui.style(fg: Settings.theme_color_muted))
517
+ ])
518
+ end
519
+
520
+ # 7-day window
521
+ util_7d = rate_limits["7d_utilization"]
522
+ if util_7d
523
+ pct = (util_7d * 100).round
524
+ bar = build_progress_bar(pct, Settings.theme_progress_bar_width)
525
+ color = rate_limit_color(pct)
526
+
527
+ lines << tui.line(spans: [
528
+ tui.span(content: " 7d ", style: tui.style(fg: color, modifiers: [:bold])),
529
+ tui.span(content: bar, style: tui.style(fg: color)),
530
+ tui.span(content: " #{pct.to_s.rjust(2)}%", style: tui.style(fg: color))
531
+ ])
532
+ end
533
+
534
+ lines
535
+ end
536
+
537
+ # Builds cache efficiency display lines with sparkline graph.
538
+ # ⚡ for hit rate bar, braille sparkline for per-call history, 💾 for saved.
539
+ # @return [Array<RatatuiRuby::Widgets::Line>]
540
+ def build_cache_metrics_lines(tui, stats)
541
+ hit_rate = (stats[:cache_hit_rate] * 100).round
542
+ color = cache_hit_color(hit_rate)
543
+ bar = build_progress_bar(hit_rate, Settings.theme_progress_bar_width)
544
+
545
+ total_cached = stats[:cache_read_input_tokens]
546
+ saved_label = format_token_count(total_cached)
547
+
548
+ lines = [
549
+ tui.line(spans: [
550
+ tui.span(content: " \u{26A1} ", style: tui.style(fg: color)),
551
+ tui.span(content: bar, style: tui.style(fg: color)),
552
+ tui.span(content: " #{hit_rate.to_s.rjust(2)}%", style: tui.style(fg: color))
553
+ ]),
554
+ tui.line(spans: [
555
+ tui.span(content: " \u{1F4BE} ", style: tui.style(fg: Settings.theme_color_success)),
556
+ tui.span(content: saved_label, style: tui.style(fg: Settings.theme_color_success))
557
+ ])
558
+ ]
559
+
560
+ # Braille sparkline: per-call cache hit history (below icons)
561
+ history = stats[:cache_history]
562
+ if history && history.size > 1
563
+ sparkline = build_braille_sparkline(history)
564
+ color = cache_hit_color(hit_rate)
565
+ lines << tui.line(spans: [
566
+ tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
567
+ tui.span(content: sparkline, style: tui.style(fg: color))
568
+ ])
569
+ end
570
+
571
+ lines
572
+ end
573
+
574
+ # Builds a braille sparkline from a series of values (0.0-1.0).
575
+ # Each braille character encodes two adjacent data points using
576
+ # the left and right dot columns (4 dots each = 5 levels: 0-4).
577
+ #
578
+ # @param values [Array<Float>] data points between 0.0 and 1.0
579
+ # @return [String] braille sparkline string
580
+ def build_braille_sparkline(values)
581
+ # Braille dot positions: left column (dots 1,2,3,7), right column (dots 4,5,6,8)
582
+ # Numbered per Unicode spec: bit 0=dot1, bit 1=dot2, bit 2=dot3,
583
+ # bit 3=dot4, bit 4=dot5, bit 5=dot6, bit 6=dot7, bit 7=dot8
584
+ left_dots = [0x01, 0x02, 0x04, 0x40] # dots 1,2,3,7 (bottom to top)
585
+ right_dots = [0x08, 0x10, 0x20, 0x80] # dots 4,5,6,8 (bottom to top)
586
+
587
+ chars = []
588
+ values.each_slice(2) do |pair|
589
+ code = 0x2800 # braille base
590
+ # Left column: first value
591
+ fill_left = (pair[0].clamp(0.0, 1.0) * 4).round
592
+ fill_left.times { |i| code |= left_dots[i] }
593
+ # Right column: second value (or repeat first if odd)
594
+ right_val = pair[1] || pair[0]
595
+ fill_right = (right_val.clamp(0.0, 1.0) * 4).round
596
+ fill_right.times { |i| code |= right_dots[i] }
597
+ chars << code.chr(Encoding::UTF_8)
598
+ end
599
+ chars.join
600
+ end
601
+
602
+ # Builds a Unicode progress bar.
603
+ # @param pct [Integer] percentage (0-100)
604
+ # @param width [Integer] bar width in characters
605
+ # @return [String] progress bar string
606
+ def build_progress_bar(pct, width)
607
+ filled = (pct.clamp(0, 100) * width / 100.0).round
608
+ empty = width - filled
609
+ ("\u2593" * filled) + ("\u2591" * empty)
610
+ end
611
+
612
+ # Color for rate limit percentage: green below warning, yellow below critical, red above.
613
+ def rate_limit_color(pct)
614
+ return Settings.theme_color_error if pct >= Settings.theme_rate_limit_critical
615
+ return Settings.theme_color_warning if pct >= Settings.theme_rate_limit_warning
616
+ Settings.theme_color_success
617
+ end
618
+
619
+ # Color for cache hit rate: green above good, yellow above low, red below.
620
+ def cache_hit_color(pct)
621
+ return Settings.theme_color_success if pct >= Settings.theme_cache_hit_good
622
+ return Settings.theme_color_warning if pct >= Settings.theme_cache_hit_low
623
+ Settings.theme_color_error
624
+ end
625
+
626
+ # Formats a reset timestamp as time remaining (e.g., "2h15m").
627
+ def format_reset_time(epoch_seconds)
628
+ diff = epoch_seconds - Time.now.to_i
629
+ return "soon" if diff <= 0
630
+
631
+ hours = diff / 3600
632
+ minutes = (diff % 3600) / 60
633
+ if hours > 0
634
+ "\u279E#{hours}h#{minutes.to_s.rjust(2, "0")}m"
443
635
  else
444
- [CHILD_ICON_IDLE, "dark_gray"]
636
+ "\u279E#{minutes}m"
445
637
  end
446
638
  end
447
639
 
640
+ # Formats a token count for display (e.g., "47.2K").
641
+ def format_token_count(count)
642
+ return "0" if count == 0
643
+ return count.to_s if count < 1000
644
+ "#{(count / 1000.0).round(1)}K tokens"
645
+ end
646
+
448
647
  # Shows focus mode when a pane is focused, or the braille spinner
449
648
  # during active processing.
450
649
  def interaction_state_line(tui)
451
650
  if @hud_focused
452
651
  tui.line(spans: [
453
- tui.span(content: "HUD Scroll", style: tui.style(fg: "yellow", modifiers: [:bold]))
652
+ tui.span(content: "HUD Scroll", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
454
653
  ])
455
654
  elsif @screens[:chat].chat_focused
456
655
  tui.line(spans: [
457
- tui.span(content: "Scrolling", style: tui.style(fg: "yellow", modifiers: [:bold]))
656
+ tui.span(content: "Scrolling", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
458
657
  ])
459
658
  elsif chat_loading?
460
659
  chat = @screens[:chat]
@@ -472,20 +671,34 @@ module TUI
472
671
  @screens[:chat].loading?
473
672
  end
474
673
 
475
- # Switches keyboard focus to the HUD pane for scrolling.
476
- # Unfocuses the chat pane if it was focused.
674
+ # Switches keyboard focus to the HUD pane.
675
+ # When children exist, enters child navigation mode with the first
676
+ # child selected; otherwise enters plain scroll mode.
477
677
  #
478
678
  # @return [void]
479
679
  def focus_hud
480
680
  @screens[:chat].unfocus_chat if @screens[:chat].chat_focused
481
681
  @hud_focused = true
682
+ children = hud_children
683
+ if children.any?
684
+ @hud_child_index = 0
685
+ @hud_scroll_offset = @hud_max_scroll
686
+ end
482
687
  end
483
688
 
484
- # Returns keyboard focus from the HUD pane.
689
+ # Returns keyboard focus from the HUD pane and exits child navigation.
485
690
  #
486
691
  # @return [void]
487
692
  def unfocus_hud
488
693
  @hud_focused = false
694
+ @hud_child_index = nil
695
+ end
696
+
697
+ # Returns the current session's child sessions (sub-agents).
698
+ #
699
+ # @return [Array<Hash>] child session hashes, empty when none
700
+ def hud_children
701
+ @screens[:chat].session_info[:children] || []
489
702
  end
490
703
 
491
704
  # Scrolls the HUD viewport up, clamping at the top.
@@ -606,7 +819,10 @@ module TUI
606
819
  end
607
820
 
608
821
  # Handles keyboard events when the HUD pane has focus.
609
- # Arrow keys and Page Up/Down scroll the HUD; Escape and Ctrl+A exit.
822
+ # When a child is selected (child navigation mode), arrow keys move
823
+ # between sub-agents and Enter switches to the selected session.
824
+ # Otherwise, arrow keys and Page Up/Down scroll the HUD content.
825
+ # Escape and Ctrl+A always exit HUD focus.
610
826
  def handle_hud_focused_event(event)
611
827
  return nil if event.none?
612
828
  return :quit if event.ctrl_c?
@@ -624,10 +840,40 @@ module TUI
624
840
  return nil
625
841
  end
626
842
 
843
+ if @hud_child_index
844
+ handle_hud_child_navigation(event)
845
+ else
846
+ handle_hud_scroll(event)
847
+ end
848
+ nil
849
+ end
850
+
851
+ # Navigates between sub-agent entries in the HUD.
852
+ # Up/Down move selection, Enter switches to the selected session,
853
+ # Left exits child navigation back to scroll mode.
854
+ def handle_hud_child_navigation(event)
855
+ children = hud_children
856
+ return if children.empty?
857
+
627
858
  if event.up?
628
- scroll_hud_up(HUD_SCROLL_STEP)
859
+ @hud_child_index = [@hud_child_index - 1, 0].max
629
860
  elsif event.down?
630
- scroll_hud_down(HUD_SCROLL_STEP)
861
+ @hud_child_index = [@hud_child_index + 1, children.size - 1].min
862
+ elsif event.left?
863
+ @hud_child_index = nil
864
+ elsif event.enter?
865
+ child = children[@hud_child_index]
866
+ @screens[:chat].switch_session(child["id"]) if child
867
+ unfocus_hud
868
+ end
869
+ end
870
+
871
+ # Scrolls the HUD viewport with arrow/page keys.
872
+ def handle_hud_scroll(event)
873
+ if event.up?
874
+ scroll_hud_up(Settings.hud_scroll_step)
875
+ elsif event.down?
876
+ scroll_hud_down(Settings.hud_scroll_step)
631
877
  elsif event.page_up?
632
878
  scroll_hud_up(@hud_visible_height)
633
879
  elsif event.page_down?
@@ -637,7 +883,6 @@ module TUI
637
883
  elsif event.end?
638
884
  scroll_hud_down(@hud_max_scroll)
639
885
  end
640
- nil
641
886
  end
642
887
 
643
888
  # Routes mouse scroll events to the HUD when the cursor is over the HUD area.
@@ -648,9 +893,9 @@ module TUI
648
893
  return false unless event.scroll_up? || event.scroll_down?
649
894
 
650
895
  if event.scroll_up?
651
- scroll_hud_up(HUD_MOUSE_SCROLL_STEP)
896
+ scroll_hud_up(Settings.hud_mouse_scroll_step)
652
897
  else
653
- scroll_hud_down(HUD_MOUSE_SCROLL_STEP)
898
+ scroll_hud_down(Settings.hud_mouse_scroll_step)
654
899
  end
655
900
  true
656
901
  end
@@ -749,12 +994,10 @@ module TUI
749
994
  CHILD_STATUS_DONE = "\u2713" # ✓
750
995
  CHILDREN_ARROW = "\u25B8" # ▸ shown next to sessions with children
751
996
  UNNAMED_SUBAGENT_LABEL = "sub-agent"
752
- SESSION_PICKER_PAGE_SIZE = 9
753
- SESSION_PICKER_FETCH_LIMIT = 50
754
997
  BACK_ARROW = "\u2190" # ←
755
998
 
756
999
  # Requests the session list from the brain and opens the picker overlay.
757
- # Fetches up to SESSION_PICKER_FETCH_LIMIT sessions for client-side pagination.
1000
+ # Fetches up to Settings.session_picker_fetch_limit sessions for client-side pagination.
758
1001
  # @return [void]
759
1002
  def activate_session_picker
760
1003
  @session_picker_active = true
@@ -762,7 +1005,7 @@ module TUI
762
1005
  @session_picker_page = 0
763
1006
  @session_picker_mode = :root
764
1007
  @session_picker_parent_id = nil
765
- @cable_client.list_sessions(limit: SESSION_PICKER_FETCH_LIMIT)
1008
+ @cable_client.list_sessions(limit: Settings.session_picker_fetch_limit)
766
1009
  end
767
1010
 
768
1011
  # Dispatches keyboard events while the session picker overlay is open.
@@ -826,8 +1069,8 @@ module TUI
826
1069
  # @return [Array<Hash>] visible items for the current page
827
1070
  def session_picker_visible_items
828
1071
  all = session_picker_all_items_for_mode
829
- start = @session_picker_page * SESSION_PICKER_PAGE_SIZE
830
- page = all[start, SESSION_PICKER_PAGE_SIZE] || []
1072
+ start = @session_picker_page * Settings.session_picker_page_size
1073
+ page = all[start, Settings.session_picker_page_size] || []
831
1074
 
832
1075
  page.map do |item|
833
1076
  case @session_picker_mode
@@ -842,13 +1085,13 @@ module TUI
842
1085
  # @return [Boolean] true when more items exist beyond the current page
843
1086
  def session_picker_has_more?
844
1087
  total = session_picker_all_items_for_mode.size
845
- ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE) < total
1088
+ ((@session_picker_page + 1) * Settings.session_picker_page_size) < total
846
1089
  end
847
1090
 
848
1091
  # @return [Integer] number of items beyond the current page
849
1092
  def session_picker_remaining_count
850
1093
  total = session_picker_all_items_for_mode.size
851
- [total - ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE), 0].max
1094
+ [total - ((@session_picker_page + 1) * Settings.session_picker_page_size), 0].max
852
1095
  end
853
1096
 
854
1097
  # Handles Escape in the session picker. In children mode, returns to root.
@@ -925,7 +1168,7 @@ module TUI
925
1168
 
926
1169
  if sessions.nil?
927
1170
  lines = [tui.line(spans: [
928
- tui.span(content: "Loading...", style: tui.style(fg: "yellow"))
1171
+ tui.span(content: "Loading...", style: tui.style(fg: Settings.theme_color_warning))
929
1172
  ])]
930
1173
  else
931
1174
  visible = session_picker_visible_items
@@ -943,7 +1186,7 @@ module TUI
943
1186
 
944
1187
  if lines.empty?
945
1188
  lines = [tui.line(spans: [
946
- tui.span(content: "No sessions", style: tui.style(fg: "dark_gray"))
1189
+ tui.span(content: "No sessions", style: tui.style(fg: Settings.theme_color_muted))
947
1190
  ])]
948
1191
  end
949
1192
  end
@@ -954,7 +1197,7 @@ module TUI
954
1197
  title: session_picker_title,
955
1198
  borders: [:all],
956
1199
  border_type: :rounded,
957
- border_style: {fg: "cyan"}
1200
+ border_style: {fg: Settings.theme_color_info}
958
1201
  )
959
1202
  )
960
1203
  frame.render_widget(picker, area)
@@ -995,11 +1238,11 @@ module TUI
995
1238
  label = "#{prefix}#{marker}#{arrow}#{display_name} #{count}#{child_info} #{time}"
996
1239
 
997
1240
  style = if selected
998
- tui.style(fg: "black", bg: "cyan")
1241
+ tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
999
1242
  elsif is_current
1000
- tui.style(fg: "cyan", modifiers: [:bold])
1243
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
1001
1244
  else
1002
- tui.style(fg: "white")
1245
+ tui.style(fg: Settings.theme_color_text)
1003
1246
  end
1004
1247
 
1005
1248
  [tui.line(spans: [tui.span(content: label, style: style)])]
@@ -1019,16 +1262,17 @@ module TUI
1019
1262
  hotkey = picker_hotkey(idx)
1020
1263
  prefix = hotkey ? "[#{hotkey}]" : " "
1021
1264
  marker = is_current ? "*" : " "
1022
- status = child["processing"] ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
1023
- status_color = child["processing"] ? "yellow" : "green"
1265
+ active = child["session_state"] != "idle"
1266
+ status = active ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
1267
+ status_color = active ? Settings.theme_color_warning : Settings.theme_color_success
1024
1268
  display_name = child["name"] || UNNAMED_SUBAGENT_LABEL
1025
1269
 
1026
1270
  label = "#{prefix}#{marker}#{status} #{display_name}"
1027
1271
 
1028
1272
  style = if selected
1029
- tui.style(fg: "black", bg: "cyan")
1273
+ tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
1030
1274
  elsif is_current
1031
- tui.style(fg: "cyan", modifiers: [:bold])
1275
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
1032
1276
  else
1033
1277
  tui.style(fg: status_color)
1034
1278
  end
@@ -1043,7 +1287,7 @@ module TUI
1043
1287
  def format_load_more_entry(tui)
1044
1288
  remaining = session_picker_remaining_count
1045
1289
  label = "[0] Load more (#{remaining})"
1046
- [tui.line(spans: [tui.span(content: label, style: tui.style(fg: "dark_gray"))])]
1290
+ [tui.line(spans: [tui.span(content: label, style: tui.style(fg: Settings.theme_color_muted))])]
1047
1291
  end
1048
1292
 
1049
1293
  # -- View mode picker ----------------------------------------------
@@ -1100,7 +1344,7 @@ module TUI
1100
1344
  title: "View Mode",
1101
1345
  borders: [:all],
1102
1346
  border_type: :rounded,
1103
- border_style: {fg: "cyan"}
1347
+ border_style: {fg: Settings.theme_color_info}
1104
1348
  )
1105
1349
  )
1106
1350
  frame.render_widget(picker, area)
@@ -1122,17 +1366,17 @@ module TUI
1122
1366
  prefix = hotkey ? "[#{hotkey}]" : " "
1123
1367
  marker = is_current ? "*" : " "
1124
1368
 
1125
- selected_style = tui.style(fg: "black", bg: "cyan")
1369
+ selected_style = tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
1126
1370
 
1127
1371
  name_style = if selected
1128
1372
  selected_style
1129
1373
  elsif is_current
1130
- tui.style(fg: "cyan", modifiers: [:bold])
1374
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
1131
1375
  else
1132
- tui.style(fg: "white")
1376
+ tui.style(fg: Settings.theme_color_text)
1133
1377
  end
1134
1378
 
1135
- desc_style = selected ? selected_style : tui.style(fg: "dark_gray")
1379
+ desc_style = selected ? selected_style : tui.style(fg: Settings.theme_color_muted)
1136
1380
 
1137
1381
  [
1138
1382
  tui.line(spans: [tui.span(content: "#{prefix}#{marker}#{mode.capitalize}", style: name_style)]),
@@ -1277,9 +1521,9 @@ module TUI
1277
1521
  frame.render_widget(tui.clear, popup_area)
1278
1522
 
1279
1523
  border_color = case @token_setup_status
1280
- when :success then "green"
1281
- when :error then "red"
1282
- else "yellow"
1524
+ when :success then Settings.theme_color_success
1525
+ when :error then Settings.theme_color_error
1526
+ else Settings.theme_color_warning
1283
1527
  end
1284
1528
 
1285
1529
  lines = build_token_setup_lines(tui)
@@ -1308,36 +1552,36 @@ module TUI
1308
1552
  # Status
1309
1553
  status_text, status_color = token_status_display
1310
1554
  lines << tui.line(spans: [
1311
- tui.span(content: "Status: ", style: tui.style(fg: "dark_gray")),
1555
+ tui.span(content: "Status: ", style: tui.style(fg: Settings.theme_color_muted)),
1312
1556
  tui.span(content: status_text, style: tui.style(fg: status_color, modifiers: [:bold]))
1313
1557
  ])
1314
1558
  lines << tui.line(spans: [tui.span(content: "")])
1315
1559
 
1316
1560
  # Instructions
1317
1561
  lines << tui.line(spans: [
1318
- tui.span(content: "Run ", style: tui.style(fg: "white")),
1319
- tui.span(content: "claude setup-token", style: tui.style(fg: "cyan", modifiers: [:bold])),
1320
- tui.span(content: " to get", style: tui.style(fg: "white"))
1562
+ tui.span(content: "Run ", style: tui.style(fg: Settings.theme_color_text)),
1563
+ tui.span(content: "claude setup-token", style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold])),
1564
+ tui.span(content: " to get", style: tui.style(fg: Settings.theme_color_text))
1321
1565
  ])
1322
1566
  lines << tui.line(spans: [
1323
- tui.span(content: "your token, then paste it here.", style: tui.style(fg: "white"))
1567
+ tui.span(content: "your token, then paste it here.", style: tui.style(fg: Settings.theme_color_text))
1324
1568
  ])
1325
1569
  lines << tui.line(spans: [tui.span(content: "")])
1326
1570
 
1327
1571
  # Token input
1328
1572
  masked = mask_token(@token_input_buffer.text)
1329
1573
  lines << tui.line(spans: [
1330
- tui.span(content: "Token:", style: tui.style(fg: "white", modifiers: [:bold]))
1574
+ tui.span(content: "Token:", style: tui.style(fg: Settings.theme_color_text, modifiers: [:bold]))
1331
1575
  ])
1332
1576
  lines << tui.line(spans: [
1333
- tui.span(content: "> #{masked}", style: tui.style(fg: "white"))
1577
+ tui.span(content: "> #{masked}", style: tui.style(fg: Settings.theme_color_text))
1334
1578
  ])
1335
1579
  lines << tui.line(spans: [tui.span(content: "")])
1336
1580
 
1337
1581
  # Error or success message
1338
1582
  if @token_setup_error
1339
1583
  lines << tui.line(spans: [
1340
- tui.span(content: @token_setup_error, style: tui.style(fg: "red"))
1584
+ tui.span(content: @token_setup_error, style: tui.style(fg: Settings.theme_color_error))
1341
1585
  ])
1342
1586
  lines << tui.line(spans: [tui.span(content: "")])
1343
1587
  end
@@ -1345,11 +1589,11 @@ module TUI
1345
1589
  if @token_setup_status == :success
1346
1590
  lines << if @token_setup_warning
1347
1591
  tui.line(spans: [
1348
- tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: "yellow", modifiers: [:bold]))
1592
+ tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
1349
1593
  ])
1350
1594
  else
1351
1595
  tui.line(spans: [
1352
- tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
1596
+ tui.span(content: "Token saved and validated!", style: tui.style(fg: Settings.theme_color_success, modifiers: [:bold]))
1353
1597
  ])
1354
1598
  end
1355
1599
  lines << tui.line(spans: [tui.span(content: "")])
@@ -1362,7 +1606,7 @@ module TUI
1362
1606
  else "[Enter] Save [Esc] Cancel"
1363
1607
  end
1364
1608
  lines << tui.line(spans: [
1365
- tui.span(content: hint, style: tui.style(fg: "dark_gray"))
1609
+ tui.span(content: hint, style: tui.style(fg: Settings.theme_color_muted))
1366
1610
  ])
1367
1611
 
1368
1612
  lines
@@ -1372,31 +1616,31 @@ module TUI
1372
1616
  def token_status_display
1373
1617
  case @token_setup_status
1374
1618
  when :success
1375
- @token_setup_warning ? ["Saved (unverified)", "yellow"] : ["Valid", "green"]
1619
+ @token_setup_warning ? ["Saved (unverified)", Settings.theme_color_warning] : ["Valid", Settings.theme_color_success]
1376
1620
  when :validating
1377
- ["Validating...", "yellow"]
1621
+ ["Validating...", Settings.theme_color_warning]
1378
1622
  when :error
1379
- ["Invalid", "red"]
1623
+ ["Invalid", Settings.theme_color_error]
1380
1624
  else
1381
1625
  if @token_input_buffer.text.empty?
1382
- ["Not configured", "dark_gray"]
1626
+ ["Not configured", Settings.theme_color_muted]
1383
1627
  else
1384
- ["Ready to save", "cyan"]
1628
+ ["Ready to save", Settings.theme_color_info]
1385
1629
  end
1386
1630
  end
1387
1631
  end
1388
1632
 
1389
- # Masks an Anthropic token for display: shows the first TOKEN_MASK_VISIBLE
1633
+ # Masks an Anthropic token for display: shows the first Settings.token_dialog_mask_visible
1390
1634
  # characters (the prefix) and replaces the rest with stars.
1391
1635
  #
1392
1636
  # @param token [String] raw token text
1393
1637
  # @return [String] masked display text
1394
1638
  def mask_token(token)
1395
1639
  return "" if token.empty?
1396
- return token if token.length <= TOKEN_MASK_VISIBLE
1640
+ return token if token.length <= Settings.token_dialog_mask_visible
1397
1641
 
1398
- visible = token[0...TOKEN_MASK_VISIBLE]
1399
- hidden_count = [token.length - TOKEN_MASK_VISIBLE, TOKEN_MASK_STARS].min
1642
+ visible = token[0...Settings.token_dialog_mask_visible]
1643
+ hidden_count = [token.length - Settings.token_dialog_mask_visible, Settings.token_dialog_mask_stars].min
1400
1644
  "#{visible}#{"*" * hidden_count}..."
1401
1645
  end
1402
1646
 
@@ -1434,7 +1678,7 @@ module TUI
1434
1678
  # @param area [RatatuiRuby::Rect] full terminal area
1435
1679
  # @return [RatatuiRuby::Rect] centered popup area
1436
1680
  def centered_popup_area(tui, area)
1437
- popup_height = [POPUP_HEIGHT, area.height - 2].min
1681
+ popup_height = [Settings.token_dialog_popup_height, area.height - 2].min
1438
1682
  v_margin = [(area.height - popup_height) / 2, 0].max
1439
1683
 
1440
1684
  _, center_v, _ = tui.split(
@@ -1447,7 +1691,7 @@ module TUI
1447
1691
  ]
1448
1692
  )
1449
1693
 
1450
- popup_width = (area.width * 60 / 100).clamp(POPUP_MIN_WIDTH, area.width - 2)
1694
+ popup_width = (area.width * 60 / 100).clamp(Settings.token_dialog_popup_min_width, area.width - 2)
1451
1695
  h_margin = [(area.width - popup_width) / 2, 0].max
1452
1696
 
1453
1697
  _, center, _ = tui.split(
@@ -1527,18 +1771,18 @@ module TUI
1527
1771
  def stop_terminal_watchdog
1528
1772
  return unless @watchdog_thread
1529
1773
 
1530
- @watchdog_thread.join(WATCHDOG_SHUTDOWN_TIMEOUT)
1774
+ @watchdog_thread.join(Settings.terminal_shutdown_timeout)
1531
1775
  @watchdog_thread.kill if @watchdog_thread.alive?
1532
1776
  @watchdog_thread = nil
1533
1777
  end
1534
1778
 
1535
- # Opens {CONTROLLING_TERMINAL} every {TERMINAL_CHECK_INTERVAL} seconds.
1779
+ # Opens {CONTROLLING_TERMINAL} every +terminal.check_interval+ seconds.
1536
1780
  # File.open (not File.stat) is required because stat only checks the
1537
1781
  # filesystem entry which always exists; open actually probes the device.
1538
1782
  # When the terminal disappears, calls {#handle_terminal_loss}.
1539
1783
  # Exits silently in non-TTY environments (CI, test suites).
1540
1784
  # @see CONTROLLING_TERMINAL
1541
- # @see TERMINAL_CHECK_INTERVAL
1785
+ # @see TUI::Settings.terminal_check_interval
1542
1786
  # @return [void]
1543
1787
  def terminal_watchdog_loop
1544
1788
  # Empty block triggers open syscall to probe the device, then immediately closes the FD.
@@ -1551,7 +1795,7 @@ module TUI
1551
1795
  rescue Errno::ENXIO, Errno::EIO, Errno::ENOENT
1552
1796
  handle_terminal_loss
1553
1797
  end
1554
- sleep TERMINAL_CHECK_INTERVAL
1798
+ sleep Settings.terminal_check_interval
1555
1799
  end
1556
1800
  rescue SystemCallError
1557
1801
  # No controlling terminal — nothing to watch (ENXIO, EIO, ENOENT, EACCES, etc.)