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
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"]).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,21 +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
66
  attr_reader :current_screen, :command_mode, :session_picker_active,
82
67
  :view_mode_picker_active
83
68
  # @return [Boolean] true when the HUD info panel is visible
84
69
  attr_reader :hud_visible
70
+ # @return [Boolean] true when the HUD pane has keyboard focus for scrolling
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
85
74
  # @return [Boolean] true when the token setup popup overlay is visible
86
75
  attr_reader :token_setup_active
87
76
  # @return [Boolean] true when graceful shutdown has been requested via signal
@@ -102,6 +91,12 @@ module TUI
102
91
  @session_picker_mode = :root
103
92
  @session_picker_parent_id = nil
104
93
  @hud_visible = true
94
+ @hud_focused = false
95
+ @hud_child_index = nil
96
+ @hud_scroll_offset = 0
97
+ @hud_max_scroll = 0
98
+ @hud_visible_height = 0
99
+ @hud_content_area = nil
105
100
  @view_mode_picker_active = false
106
101
  @view_mode_picker_index = 0
107
102
  @token_setup_active = false
@@ -146,7 +141,7 @@ module TUI
146
141
  @screens[:chat].hud_hint = !@hud_visible
147
142
 
148
143
  if @hud_visible
149
- hud_width = [frame.area.width / 3, HUD_MIN_WIDTH].max
144
+ hud_width = [frame.area.width / 3, Settings.hud_min_width].max
150
145
  content_area, sidebar = tui.split(
151
146
  frame.area,
152
147
  direction: :horizontal,
@@ -187,7 +182,7 @@ module TUI
187
182
  title: "Command",
188
183
  borders: [:all],
189
184
  border_type: :rounded,
190
- border_style: {fg: "yellow"}
185
+ border_style: {fg: Settings.theme_border_focused}
191
186
  )
192
187
  )
193
188
  frame.render_widget(menu, area)
@@ -197,35 +192,58 @@ module TUI
197
192
  GOAL_ICON_ACTIVE = "\u25CF" # ●
198
193
  GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
199
194
  GOAL_ICON_COMPLETED = "\u2713" # ✓
200
- CHILD_ICON_RUNNING = "\u25CF" # ●
201
- CHILD_ICON_IDLE = "\u25CC" #
195
+
196
+ # Sub-agent state icons — form communicates type of work,
197
+ # color communicates status. Work independently.
198
+ CHILD_ICON_IDLE = "\u25CC" # ◌ hollow — nothing happening
199
+ CHILD_ICON_GENERATING = "\u25CF" # ● filled — LLM thinking
200
+ CHILD_ICON_TOOL_EXECUTING = "\u25C9" # ◉ dot-in-circle — tool running
201
+ CHILD_ICON_INTERRUPTING = "\u25CF" # ● filled — stopping
202
202
 
203
203
  def render_info(frame, area, tui)
204
204
  session = @screens[:chat].session_info
205
-
206
- # Split into main content area and bottom status bar
207
- main_area, status_area = tui.split(
208
- area,
209
- direction: :vertical,
210
- constraints: [
211
- tui.constraint_fill(1),
212
- tui.constraint_length(3)
213
- ]
214
- )
215
-
216
- 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
217
233
  render_hud_status_bar(frame, status_area, tui)
218
234
  end
219
235
 
220
236
  # Renders the main HUD content: session name, goals, skills,
221
- # workflow, and sub-agents.
237
+ # workflow, and sub-agents. Supports vertical scrolling with
238
+ # a scrollbar when content exceeds the visible area.
222
239
  def render_hud_content(frame, area, tui, session)
240
+ @hud_content_area = area
223
241
  session_label = session[:name] || "##{session[:id]}"
224
242
 
225
243
  lines = [
226
244
  tui.line(spans: [
227
- tui.span(content: "\u{1F4CB} ", style: tui.style(fg: "dark_gray")),
228
- 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]))
229
247
  ]),
230
248
  hud_goals_section(tui, session),
231
249
  hud_skills_line(tui, session),
@@ -234,51 +252,79 @@ module TUI
234
252
  interaction_state_line(tui)
235
253
  ].flatten.compact
236
254
 
255
+ @hud_visible_height = [area.height - 2, 0].max
256
+ inner_width = [area.width - 2, 1].max
257
+ total_height = tui.paragraph(text: lines, wrap: true).line_count(inner_width)
258
+ @hud_max_scroll = [total_height - @hud_visible_height, 0].max
259
+ @hud_scroll_offset = @hud_scroll_offset.clamp(0, @hud_max_scroll)
260
+
261
+ border_color = @hud_focused ? Settings.theme_border_focused : Settings.theme_border_normal
262
+
237
263
  content = tui.paragraph(
238
264
  text: lines,
239
265
  wrap: true,
266
+ scroll: [@hud_scroll_offset, 0],
240
267
  block: tui.block(
241
268
  borders: [:left, :top, :right],
242
269
  border_type: :rounded,
243
- border_style: {fg: "white"}
270
+ border_style: {fg: border_color}
244
271
  )
245
272
  )
246
273
  frame.render_widget(content, area)
274
+
275
+ render_hud_scrollbar(frame, area, tui)
276
+ end
277
+
278
+ # Renders a scrollbar on the right edge of the HUD when content overflows.
279
+ def render_hud_scrollbar(frame, area, tui)
280
+ return unless @hud_max_scroll > 0
281
+
282
+ scrollbar = tui.scrollbar(
283
+ content_length: @hud_max_scroll,
284
+ position: @hud_scroll_offset,
285
+ orientation: :vertical_right,
286
+ thumb_style: {fg: Settings.theme_scrollbar_thumb},
287
+ track_symbol: "\u2502",
288
+ track_style: {fg: Settings.theme_scrollbar_track}
289
+ )
290
+ frame.render_widget(scrollbar, area)
247
291
  end
248
292
 
249
293
  # Renders the bottom status bar: connection state and model name.
250
294
  def render_hud_status_bar(frame, area, tui)
251
295
  cable_status = @cable_client.status
252
- 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))
253
298
 
254
299
  status_label = if cable_status == :reconnecting
255
300
  attempt = @cable_client.reconnect_attempt
256
- max = CableClient::MAX_RECONNECT_ATTEMPTS
257
- "#{style[:label]} (#{attempt}/#{max})"
301
+ max = Settings.connection_max_reconnect_attempts
302
+ "#{label} (#{attempt}/#{max})"
258
303
  else
259
- style[:label]
304
+ label
260
305
  end
261
306
 
262
307
  view_mode = @screens[:chat].view_mode
263
308
  mode_label = view_mode.capitalize
264
309
  mode_color = case view_mode
265
- when "verbose" then "yellow"
266
- when "debug" then "magenta"
267
- else "cyan"
310
+ when "verbose" then Settings.theme_color_warning
311
+ when "debug" then Settings.theme_color_accent
312
+ else Settings.theme_color_info
268
313
  end
269
314
 
270
315
  bar = tui.paragraph(
271
316
  text: [
272
317
  tui.line(spans: [
273
- tui.span(content: status_label, style: tui.style(fg: style[:color], modifiers: [:bold])),
274
- 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)),
275
321
  tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
276
322
  ])
277
323
  ],
278
324
  block: tui.block(
279
325
  borders: [:left, :bottom, :right],
280
326
  border_type: :rounded,
281
- border_style: {fg: "white"}
327
+ border_style: {fg: Settings.theme_border_normal}
282
328
  )
283
329
  )
284
330
  frame.render_widget(bar, area)
@@ -296,7 +342,7 @@ module TUI
296
342
  lines = [
297
343
  tui.line(spans: [tui.span(content: "")]),
298
344
  tui.line(spans: [
299
- 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))
300
346
  ])
301
347
  ]
302
348
 
@@ -304,7 +350,7 @@ module TUI
304
350
  icon, color = goal_icon_and_color(goal)
305
351
  lines << tui.line(spans: [
306
352
  tui.span(content: " #{icon} ", style: tui.style(fg: color)),
307
- 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))
308
354
  ])
309
355
  end
310
356
 
@@ -318,11 +364,11 @@ module TUI
318
364
  # @return [Array(String, String)] icon and color pair
319
365
  def goal_icon_and_color(goal)
320
366
  if goal["status"] == "completed"
321
- [GOAL_ICON_COMPLETED, "green"]
367
+ [GOAL_ICON_COMPLETED, Settings.theme_color_success]
322
368
  elsif goal["sub_goals"]&.any? { |sg| sg["status"] == "completed" }
323
- [GOAL_ICON_IN_PROGRESS, "yellow"]
369
+ [GOAL_ICON_IN_PROGRESS, Settings.theme_color_warning]
324
370
  else
325
- [GOAL_ICON_ACTIVE, "cyan"]
371
+ [GOAL_ICON_ACTIVE, Settings.theme_color_info]
326
372
  end
327
373
  end
328
374
 
@@ -335,8 +381,8 @@ module TUI
335
381
  [
336
382
  tui.line(spans: [tui.span(content: "")]),
337
383
  tui.line(spans: [
338
- tui.span(content: "\u{1F9E0} ", style: tui.style(fg: "dark_gray")),
339
- 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))
340
386
  ])
341
387
  ]
342
388
  end
@@ -350,58 +396,273 @@ module TUI
350
396
  [
351
397
  tui.line(spans: [tui.span(content: "")]),
352
398
  tui.line(spans: [
353
- tui.span(content: "\u{1F4DC} ", style: tui.style(fg: "dark_gray")),
354
- 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))
355
401
  ])
356
402
  ]
357
403
  end
358
404
 
359
405
  # Builds the sub-agents section with activity indicators.
406
+ # Highlights the selected child when HUD child navigation is active.
360
407
  # @return [Array<RatatuiRuby::Widgets::Line>, nil]
361
408
  def hud_children_section(tui, session)
362
409
  children = session[:children]
363
410
  return if children.nil? || children.empty?
364
411
 
412
+ header_label = @hud_child_index ? "Sub-agents [\u2190 back]" : "Sub-agents"
413
+
365
414
  lines = [
366
415
  tui.line(spans: [tui.span(content: "")]),
367
416
  tui.line(spans: [
368
- 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))
369
418
  ])
370
419
  ]
371
420
 
372
- children.each do |child|
421
+ children.each_with_index do |child, idx|
373
422
  icon, color = child_icon_and_color(child)
374
423
  name = child["name"] || "sub-agent"
375
- lines << tui.line(spans: [
376
- tui.span(content: " #{icon} ", style: tui.style(fg: color)),
377
- tui.span(content: "@#{name}", style: tui.style(fg: "white"))
378
- ])
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
379
437
  end
380
438
 
381
439
  lines
382
440
  end
383
441
 
384
442
  # Returns the activity icon and color for a child session.
443
+ # Form communicates type of work, color communicates status —
444
+ # they work independently so even without color the shape tells
445
+ # the story.
385
446
  #
386
- # @param child [Hash] child session data with "processing" key
447
+ # @param child [Hash] child session data with "session_state" key
387
448
  # @return [Array(String, String)] icon and color pair
388
449
  def child_icon_and_color(child)
389
- if child["processing"]
390
- [CHILD_ICON_RUNNING, "yellow"]
450
+ case child["session_state"]
451
+ when "llm_generating"
452
+ [CHILD_ICON_GENERATING, Settings.theme_color_success]
453
+ when "tool_executing"
454
+ [CHILD_ICON_TOOL_EXECUTING, Settings.theme_color_success]
455
+ when "interrupting"
456
+ [CHILD_ICON_INTERRUPTING, Settings.theme_color_error]
391
457
  else
392
- [CHILD_ICON_IDLE, "green"]
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
393
493
  end
494
+ count
394
495
  end
395
496
 
396
- # Shows "Scrolling" when chat pane is focused, "Thinking..." during LLM processing.
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"
635
+ else
636
+ "\u279E#{minutes}m"
637
+ end
638
+ end
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
+
647
+ # Shows focus mode when a pane is focused, or the braille spinner
648
+ # during active processing.
397
649
  def interaction_state_line(tui)
398
- if @screens[:chat].chat_focused
650
+ if @hud_focused
651
+ tui.line(spans: [
652
+ tui.span(content: "HUD Scroll", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
653
+ ])
654
+ elsif @screens[:chat].chat_focused
399
655
  tui.line(spans: [
400
- 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]))
401
657
  ])
402
658
  elsif chat_loading?
659
+ chat = @screens[:chat]
660
+ char = chat.spinner.tick || "\u2800"
661
+ color = chat.spinner_color
662
+ label = chat.spinner_label
403
663
  tui.line(spans: [
404
- tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
664
+ tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
665
+ tui.span(content: label, style: tui.style(fg: color))
405
666
  ])
406
667
  end
407
668
  end
@@ -410,6 +671,52 @@ module TUI
410
671
  @screens[:chat].loading?
411
672
  end
412
673
 
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.
677
+ #
678
+ # @return [void]
679
+ def focus_hud
680
+ @screens[:chat].unfocus_chat if @screens[:chat].chat_focused
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
687
+ end
688
+
689
+ # Returns keyboard focus from the HUD pane and exits child navigation.
690
+ #
691
+ # @return [void]
692
+ def unfocus_hud
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] || []
702
+ end
703
+
704
+ # Scrolls the HUD viewport up, clamping at the top.
705
+ #
706
+ # @param lines [Integer] number of lines to scroll
707
+ # @return [void]
708
+ def scroll_hud_up(lines)
709
+ @hud_scroll_offset = [@hud_scroll_offset - lines, 0].max
710
+ end
711
+
712
+ # Scrolls the HUD viewport down, clamping at max_scroll.
713
+ #
714
+ # @param lines [Integer] number of lines to scroll
715
+ # @return [void]
716
+ def scroll_hud_down(lines)
717
+ @hud_scroll_offset = [@hud_scroll_offset + lines, @hud_max_scroll].min
718
+ end
719
+
413
720
  def handle_event(event)
414
721
  return nil if event.none?
415
722
  return :quit if event.ctrl_c?
@@ -422,6 +729,8 @@ module TUI
422
729
  handle_view_mode_picker(event)
423
730
  elsif @command_mode
424
731
  handle_command_mode(event)
732
+ elsif @hud_focused
733
+ handle_hud_focused_event(event)
425
734
  else
426
735
  handle_normal_mode(event)
427
736
  end
@@ -442,6 +751,11 @@ module TUI
442
751
  return nil
443
752
  end
444
753
 
754
+ if event.right? && @hud_visible
755
+ focus_hud
756
+ return nil
757
+ end
758
+
445
759
  action = COMMAND_KEYS[event.code]
446
760
  case action
447
761
  when :quit
@@ -451,6 +765,7 @@ module TUI
451
765
  nil
452
766
  when :toggle_hud
453
767
  @hud_visible = !@hud_visible
768
+ unfocus_hud if !@hud_visible
454
769
  nil
455
770
  when :new_session
456
771
  @screens[:chat].new_session
@@ -466,7 +781,13 @@ module TUI
466
781
  end
467
782
 
468
783
  def handle_normal_mode(event)
469
- if event.mouse? || event.paste?
784
+ if event.paste?
785
+ delegate_to_screen(event)
786
+ return nil
787
+ end
788
+
789
+ if event.mouse?
790
+ return nil if route_mouse_to_hud(event)
470
791
  delegate_to_screen(event)
471
792
  return nil
472
793
  end
@@ -497,6 +818,95 @@ module TUI
497
818
  nil
498
819
  end
499
820
 
821
+ # Handles keyboard events when the HUD pane has focus.
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.
826
+ def handle_hud_focused_event(event)
827
+ return nil if event.none?
828
+ return :quit if event.ctrl_c?
829
+
830
+ if event.mouse?
831
+ return nil if route_mouse_to_hud(event)
832
+ delegate_to_screen(event)
833
+ return nil
834
+ end
835
+
836
+ return nil unless event.key?
837
+
838
+ if event.esc? || ctrl_a?(event)
839
+ unfocus_hud
840
+ return nil
841
+ end
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
+
858
+ if event.up?
859
+ @hud_child_index = [@hud_child_index - 1, 0].max
860
+ elsif event.down?
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)
877
+ elsif event.page_up?
878
+ scroll_hud_up(@hud_visible_height)
879
+ elsif event.page_down?
880
+ scroll_hud_down(@hud_visible_height)
881
+ elsif event.home?
882
+ scroll_hud_up(@hud_max_scroll)
883
+ elsif event.end?
884
+ scroll_hud_down(@hud_max_scroll)
885
+ end
886
+ end
887
+
888
+ # Routes mouse scroll events to the HUD when the cursor is over the HUD area.
889
+ # @return [Boolean] true if the event was handled by the HUD
890
+ def route_mouse_to_hud(event)
891
+ return false if !@hud_visible || !@hud_content_area
892
+ return false unless mouse_over_hud?(event)
893
+ return false unless event.scroll_up? || event.scroll_down?
894
+
895
+ if event.scroll_up?
896
+ scroll_hud_up(Settings.hud_mouse_scroll_step)
897
+ else
898
+ scroll_hud_down(Settings.hud_mouse_scroll_step)
899
+ end
900
+ true
901
+ end
902
+
903
+ # Checks whether a mouse event's coordinates fall within the HUD content area.
904
+ def mouse_over_hud?(event)
905
+ area = @hud_content_area
906
+ event.x >= area.x && event.x < area.x + area.width &&
907
+ event.y >= area.y && event.y < area.y + area.height
908
+ end
909
+
500
910
  # Switches to the parent session when viewing a child (sub-agent) session.
501
911
  # No-op if the current session is a root session.
502
912
  #
@@ -584,12 +994,10 @@ module TUI
584
994
  CHILD_STATUS_DONE = "\u2713" # ✓
585
995
  CHILDREN_ARROW = "\u25B8" # ▸ shown next to sessions with children
586
996
  UNNAMED_SUBAGENT_LABEL = "sub-agent"
587
- SESSION_PICKER_PAGE_SIZE = 9
588
- SESSION_PICKER_FETCH_LIMIT = 50
589
997
  BACK_ARROW = "\u2190" # ←
590
998
 
591
999
  # Requests the session list from the brain and opens the picker overlay.
592
- # 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.
593
1001
  # @return [void]
594
1002
  def activate_session_picker
595
1003
  @session_picker_active = true
@@ -597,7 +1005,7 @@ module TUI
597
1005
  @session_picker_page = 0
598
1006
  @session_picker_mode = :root
599
1007
  @session_picker_parent_id = nil
600
- @cable_client.list_sessions(limit: SESSION_PICKER_FETCH_LIMIT)
1008
+ @cable_client.list_sessions(limit: Settings.session_picker_fetch_limit)
601
1009
  end
602
1010
 
603
1011
  # Dispatches keyboard events while the session picker overlay is open.
@@ -661,8 +1069,8 @@ module TUI
661
1069
  # @return [Array<Hash>] visible items for the current page
662
1070
  def session_picker_visible_items
663
1071
  all = session_picker_all_items_for_mode
664
- start = @session_picker_page * SESSION_PICKER_PAGE_SIZE
665
- 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] || []
666
1074
 
667
1075
  page.map do |item|
668
1076
  case @session_picker_mode
@@ -677,13 +1085,13 @@ module TUI
677
1085
  # @return [Boolean] true when more items exist beyond the current page
678
1086
  def session_picker_has_more?
679
1087
  total = session_picker_all_items_for_mode.size
680
- ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE) < total
1088
+ ((@session_picker_page + 1) * Settings.session_picker_page_size) < total
681
1089
  end
682
1090
 
683
1091
  # @return [Integer] number of items beyond the current page
684
1092
  def session_picker_remaining_count
685
1093
  total = session_picker_all_items_for_mode.size
686
- [total - ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE), 0].max
1094
+ [total - ((@session_picker_page + 1) * Settings.session_picker_page_size), 0].max
687
1095
  end
688
1096
 
689
1097
  # Handles Escape in the session picker. In children mode, returns to root.
@@ -760,7 +1168,7 @@ module TUI
760
1168
 
761
1169
  if sessions.nil?
762
1170
  lines = [tui.line(spans: [
763
- tui.span(content: "Loading...", style: tui.style(fg: "yellow"))
1171
+ tui.span(content: "Loading...", style: tui.style(fg: Settings.theme_color_warning))
764
1172
  ])]
765
1173
  else
766
1174
  visible = session_picker_visible_items
@@ -778,7 +1186,7 @@ module TUI
778
1186
 
779
1187
  if lines.empty?
780
1188
  lines = [tui.line(spans: [
781
- 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))
782
1190
  ])]
783
1191
  end
784
1192
  end
@@ -789,7 +1197,7 @@ module TUI
789
1197
  title: session_picker_title,
790
1198
  borders: [:all],
791
1199
  border_type: :rounded,
792
- border_style: {fg: "cyan"}
1200
+ border_style: {fg: Settings.theme_color_info}
793
1201
  )
794
1202
  )
795
1203
  frame.render_widget(picker, area)
@@ -830,11 +1238,11 @@ module TUI
830
1238
  label = "#{prefix}#{marker}#{arrow}#{display_name} #{count}#{child_info} #{time}"
831
1239
 
832
1240
  style = if selected
833
- tui.style(fg: "black", bg: "cyan")
1241
+ tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
834
1242
  elsif is_current
835
- tui.style(fg: "cyan", modifiers: [:bold])
1243
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
836
1244
  else
837
- tui.style(fg: "white")
1245
+ tui.style(fg: Settings.theme_color_text)
838
1246
  end
839
1247
 
840
1248
  [tui.line(spans: [tui.span(content: label, style: style)])]
@@ -855,15 +1263,15 @@ module TUI
855
1263
  prefix = hotkey ? "[#{hotkey}]" : " "
856
1264
  marker = is_current ? "*" : " "
857
1265
  status = child["processing"] ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
858
- status_color = child["processing"] ? "yellow" : "green"
1266
+ status_color = child["processing"] ? Settings.theme_color_warning : Settings.theme_color_success
859
1267
  display_name = child["name"] || UNNAMED_SUBAGENT_LABEL
860
1268
 
861
1269
  label = "#{prefix}#{marker}#{status} #{display_name}"
862
1270
 
863
1271
  style = if selected
864
- tui.style(fg: "black", bg: "cyan")
1272
+ tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
865
1273
  elsif is_current
866
- tui.style(fg: "cyan", modifiers: [:bold])
1274
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
867
1275
  else
868
1276
  tui.style(fg: status_color)
869
1277
  end
@@ -878,7 +1286,7 @@ module TUI
878
1286
  def format_load_more_entry(tui)
879
1287
  remaining = session_picker_remaining_count
880
1288
  label = "[0] Load more (#{remaining})"
881
- [tui.line(spans: [tui.span(content: label, style: tui.style(fg: "dark_gray"))])]
1289
+ [tui.line(spans: [tui.span(content: label, style: tui.style(fg: Settings.theme_color_muted))])]
882
1290
  end
883
1291
 
884
1292
  # -- View mode picker ----------------------------------------------
@@ -935,7 +1343,7 @@ module TUI
935
1343
  title: "View Mode",
936
1344
  borders: [:all],
937
1345
  border_type: :rounded,
938
- border_style: {fg: "cyan"}
1346
+ border_style: {fg: Settings.theme_color_info}
939
1347
  )
940
1348
  )
941
1349
  frame.render_widget(picker, area)
@@ -957,17 +1365,17 @@ module TUI
957
1365
  prefix = hotkey ? "[#{hotkey}]" : " "
958
1366
  marker = is_current ? "*" : " "
959
1367
 
960
- selected_style = tui.style(fg: "black", bg: "cyan")
1368
+ selected_style = tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
961
1369
 
962
1370
  name_style = if selected
963
1371
  selected_style
964
1372
  elsif is_current
965
- tui.style(fg: "cyan", modifiers: [:bold])
1373
+ tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
966
1374
  else
967
- tui.style(fg: "white")
1375
+ tui.style(fg: Settings.theme_color_text)
968
1376
  end
969
1377
 
970
- desc_style = selected ? selected_style : tui.style(fg: "dark_gray")
1378
+ desc_style = selected ? selected_style : tui.style(fg: Settings.theme_color_muted)
971
1379
 
972
1380
  [
973
1381
  tui.line(spans: [tui.span(content: "#{prefix}#{marker}#{mode.capitalize}", style: name_style)]),
@@ -1112,9 +1520,9 @@ module TUI
1112
1520
  frame.render_widget(tui.clear, popup_area)
1113
1521
 
1114
1522
  border_color = case @token_setup_status
1115
- when :success then "green"
1116
- when :error then "red"
1117
- else "yellow"
1523
+ when :success then Settings.theme_color_success
1524
+ when :error then Settings.theme_color_error
1525
+ else Settings.theme_color_warning
1118
1526
  end
1119
1527
 
1120
1528
  lines = build_token_setup_lines(tui)
@@ -1143,36 +1551,36 @@ module TUI
1143
1551
  # Status
1144
1552
  status_text, status_color = token_status_display
1145
1553
  lines << tui.line(spans: [
1146
- tui.span(content: "Status: ", style: tui.style(fg: "dark_gray")),
1554
+ tui.span(content: "Status: ", style: tui.style(fg: Settings.theme_color_muted)),
1147
1555
  tui.span(content: status_text, style: tui.style(fg: status_color, modifiers: [:bold]))
1148
1556
  ])
1149
1557
  lines << tui.line(spans: [tui.span(content: "")])
1150
1558
 
1151
1559
  # Instructions
1152
1560
  lines << tui.line(spans: [
1153
- tui.span(content: "Run ", style: tui.style(fg: "white")),
1154
- tui.span(content: "claude setup-token", style: tui.style(fg: "cyan", modifiers: [:bold])),
1155
- tui.span(content: " to get", style: tui.style(fg: "white"))
1561
+ tui.span(content: "Run ", style: tui.style(fg: Settings.theme_color_text)),
1562
+ tui.span(content: "claude setup-token", style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold])),
1563
+ tui.span(content: " to get", style: tui.style(fg: Settings.theme_color_text))
1156
1564
  ])
1157
1565
  lines << tui.line(spans: [
1158
- tui.span(content: "your token, then paste it here.", style: tui.style(fg: "white"))
1566
+ tui.span(content: "your token, then paste it here.", style: tui.style(fg: Settings.theme_color_text))
1159
1567
  ])
1160
1568
  lines << tui.line(spans: [tui.span(content: "")])
1161
1569
 
1162
1570
  # Token input
1163
1571
  masked = mask_token(@token_input_buffer.text)
1164
1572
  lines << tui.line(spans: [
1165
- tui.span(content: "Token:", style: tui.style(fg: "white", modifiers: [:bold]))
1573
+ tui.span(content: "Token:", style: tui.style(fg: Settings.theme_color_text, modifiers: [:bold]))
1166
1574
  ])
1167
1575
  lines << tui.line(spans: [
1168
- tui.span(content: "> #{masked}", style: tui.style(fg: "white"))
1576
+ tui.span(content: "> #{masked}", style: tui.style(fg: Settings.theme_color_text))
1169
1577
  ])
1170
1578
  lines << tui.line(spans: [tui.span(content: "")])
1171
1579
 
1172
1580
  # Error or success message
1173
1581
  if @token_setup_error
1174
1582
  lines << tui.line(spans: [
1175
- tui.span(content: @token_setup_error, style: tui.style(fg: "red"))
1583
+ tui.span(content: @token_setup_error, style: tui.style(fg: Settings.theme_color_error))
1176
1584
  ])
1177
1585
  lines << tui.line(spans: [tui.span(content: "")])
1178
1586
  end
@@ -1180,11 +1588,11 @@ module TUI
1180
1588
  if @token_setup_status == :success
1181
1589
  lines << if @token_setup_warning
1182
1590
  tui.line(spans: [
1183
- tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: "yellow", modifiers: [:bold]))
1591
+ tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
1184
1592
  ])
1185
1593
  else
1186
1594
  tui.line(spans: [
1187
- tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
1595
+ tui.span(content: "Token saved and validated!", style: tui.style(fg: Settings.theme_color_success, modifiers: [:bold]))
1188
1596
  ])
1189
1597
  end
1190
1598
  lines << tui.line(spans: [tui.span(content: "")])
@@ -1197,7 +1605,7 @@ module TUI
1197
1605
  else "[Enter] Save [Esc] Cancel"
1198
1606
  end
1199
1607
  lines << tui.line(spans: [
1200
- tui.span(content: hint, style: tui.style(fg: "dark_gray"))
1608
+ tui.span(content: hint, style: tui.style(fg: Settings.theme_color_muted))
1201
1609
  ])
1202
1610
 
1203
1611
  lines
@@ -1207,31 +1615,31 @@ module TUI
1207
1615
  def token_status_display
1208
1616
  case @token_setup_status
1209
1617
  when :success
1210
- @token_setup_warning ? ["Saved (unverified)", "yellow"] : ["Valid", "green"]
1618
+ @token_setup_warning ? ["Saved (unverified)", Settings.theme_color_warning] : ["Valid", Settings.theme_color_success]
1211
1619
  when :validating
1212
- ["Validating...", "yellow"]
1620
+ ["Validating...", Settings.theme_color_warning]
1213
1621
  when :error
1214
- ["Invalid", "red"]
1622
+ ["Invalid", Settings.theme_color_error]
1215
1623
  else
1216
1624
  if @token_input_buffer.text.empty?
1217
- ["Not configured", "dark_gray"]
1625
+ ["Not configured", Settings.theme_color_muted]
1218
1626
  else
1219
- ["Ready to save", "cyan"]
1627
+ ["Ready to save", Settings.theme_color_info]
1220
1628
  end
1221
1629
  end
1222
1630
  end
1223
1631
 
1224
- # Masks an Anthropic token for display: shows the first TOKEN_MASK_VISIBLE
1632
+ # Masks an Anthropic token for display: shows the first Settings.token_dialog_mask_visible
1225
1633
  # characters (the prefix) and replaces the rest with stars.
1226
1634
  #
1227
1635
  # @param token [String] raw token text
1228
1636
  # @return [String] masked display text
1229
1637
  def mask_token(token)
1230
1638
  return "" if token.empty?
1231
- return token if token.length <= TOKEN_MASK_VISIBLE
1639
+ return token if token.length <= Settings.token_dialog_mask_visible
1232
1640
 
1233
- visible = token[0...TOKEN_MASK_VISIBLE]
1234
- hidden_count = [token.length - TOKEN_MASK_VISIBLE, TOKEN_MASK_STARS].min
1641
+ visible = token[0...Settings.token_dialog_mask_visible]
1642
+ hidden_count = [token.length - Settings.token_dialog_mask_visible, Settings.token_dialog_mask_stars].min
1235
1643
  "#{visible}#{"*" * hidden_count}..."
1236
1644
  end
1237
1645
 
@@ -1269,7 +1677,7 @@ module TUI
1269
1677
  # @param area [RatatuiRuby::Rect] full terminal area
1270
1678
  # @return [RatatuiRuby::Rect] centered popup area
1271
1679
  def centered_popup_area(tui, area)
1272
- popup_height = [POPUP_HEIGHT, area.height - 2].min
1680
+ popup_height = [Settings.token_dialog_popup_height, area.height - 2].min
1273
1681
  v_margin = [(area.height - popup_height) / 2, 0].max
1274
1682
 
1275
1683
  _, center_v, _ = tui.split(
@@ -1282,7 +1690,7 @@ module TUI
1282
1690
  ]
1283
1691
  )
1284
1692
 
1285
- popup_width = (area.width * 60 / 100).clamp(POPUP_MIN_WIDTH, area.width - 2)
1693
+ popup_width = (area.width * 60 / 100).clamp(Settings.token_dialog_popup_min_width, area.width - 2)
1286
1694
  h_margin = [(area.width - popup_width) / 2, 0].max
1287
1695
 
1288
1696
  _, center, _ = tui.split(
@@ -1362,18 +1770,18 @@ module TUI
1362
1770
  def stop_terminal_watchdog
1363
1771
  return unless @watchdog_thread
1364
1772
 
1365
- @watchdog_thread.join(WATCHDOG_SHUTDOWN_TIMEOUT)
1773
+ @watchdog_thread.join(Settings.terminal_shutdown_timeout)
1366
1774
  @watchdog_thread.kill if @watchdog_thread.alive?
1367
1775
  @watchdog_thread = nil
1368
1776
  end
1369
1777
 
1370
- # Opens {CONTROLLING_TERMINAL} every {TERMINAL_CHECK_INTERVAL} seconds.
1778
+ # Opens {CONTROLLING_TERMINAL} every +terminal.check_interval+ seconds.
1371
1779
  # File.open (not File.stat) is required because stat only checks the
1372
1780
  # filesystem entry which always exists; open actually probes the device.
1373
1781
  # When the terminal disappears, calls {#handle_terminal_loss}.
1374
1782
  # Exits silently in non-TTY environments (CI, test suites).
1375
1783
  # @see CONTROLLING_TERMINAL
1376
- # @see TERMINAL_CHECK_INTERVAL
1784
+ # @see TUI::Settings.terminal_check_interval
1377
1785
  # @return [void]
1378
1786
  def terminal_watchdog_loop
1379
1787
  # Empty block triggers open syscall to probe the device, then immediately closes the FD.
@@ -1386,7 +1794,7 @@ module TUI
1386
1794
  rescue Errno::ENXIO, Errno::EIO, Errno::ENOENT
1387
1795
  handle_terminal_loss
1388
1796
  end
1389
- sleep TERMINAL_CHECK_INTERVAL
1797
+ sleep Settings.terminal_check_interval
1390
1798
  end
1391
1799
  rescue SystemCallError
1392
1800
  # No controlling terminal — nothing to watch (ENXIO, EIO, ENOENT, EACCES, etc.)