anima-core 0.2.1 → 1.0.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
data/lib/tui/app.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require_relative "cable_client"
5
+ require_relative "input_buffer"
4
6
  require_relative "message_store"
5
7
  require_relative "screens/chat"
6
8
 
@@ -9,24 +11,56 @@ module TUI
9
11
  SCREENS = %i[chat].freeze
10
12
 
11
13
  COMMAND_KEYS = {
14
+ "a" => :anthropic_token,
12
15
  "n" => :new_session,
16
+ "s" => :session_picker,
13
17
  "v" => :view_mode,
14
18
  "q" => :quit
15
19
  }.freeze
16
20
 
17
- MENU_LABELS = COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" }.freeze
21
+ MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
22
+ ["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
18
23
 
19
24
  SIDEBAR_WIDTH = 28
20
25
 
21
- # Connection status display styles
26
+ # Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
27
+ PICKER_PREFIX_WIDTH = 5
28
+
29
+ # User-facing descriptions shown below each mode name in the view mode picker.
30
+ VIEW_MODE_LABELS = {
31
+ "basic" => "Chat messages only",
32
+ "verbose" => "Tools & timestamps",
33
+ "debug" => "Full LLM context"
34
+ }.freeze
35
+
36
+ # Connection status emoji indicators for the info panel.
37
+ # Subscribed (normal state) shows only the emoji; other states add text.
22
38
  STATUS_STYLES = {
23
- disconnected: {label: " DISCONNECTED ", fg: "white", bg: "red"},
24
- connecting: {label: " CONNECTING ", fg: "black", bg: "yellow"},
25
- connected: {label: " CONNECTED ", fg: "black", bg: "yellow"},
26
- subscribed: {label: " CONNECTED ", fg: "black", bg: "green"},
27
- reconnecting: {label: " RECONNECTING ", fg: "black", bg: "yellow"}
39
+ disconnected: {label: "🔴 Disconnected", color: "red"},
40
+ connecting: {label: "🟡 Connecting", color: "yellow"},
41
+ connected: {label: "🟡 Connecting", color: "yellow"},
42
+ subscribed: {label: "🟢", color: "green"},
43
+ reconnecting: {label: "🟡 Reconnecting", color: "yellow"}
28
44
  }.freeze
29
45
 
46
+ # Number of leading characters to show unmasked in the token input.
47
+ # Matches the "sk-ant-oat01-" prefix (13 chars) plus one character of the
48
+ # secret portion so the user can verify both the token type and start of key.
49
+ TOKEN_MASK_VISIBLE = 14
50
+
51
+ # Maximum stars to show in the masked portion of the token.
52
+ # Keeps the masked display compact regardless of actual token length.
53
+ TOKEN_MASK_STARS = 4
54
+
55
+ # Token setup popup dimensions. Height accommodates: status line, blank,
56
+ # 2 instruction lines, blank, "Token:" label, input line, blank,
57
+ # error/success line, blank, hint line, plus top/bottom borders.
58
+ POPUP_HEIGHT = 14
59
+ POPUP_MIN_WIDTH = 44
60
+
61
+ # Matches a single printable Unicode character (no control codes).
62
+ PRINTABLE_CHAR = /\A[[:print:]]\z/
63
+
30
64
  # Signals that trigger graceful shutdown when received from the OS.
31
65
  SHUTDOWN_SIGNALS = %w[HUP TERM INT].freeze
32
66
 
@@ -41,7 +75,10 @@ module TUI
41
75
  # Grace period for watchdog thread to exit before force-killing it.
42
76
  WATCHDOG_SHUTDOWN_TIMEOUT = 1
43
77
 
44
- attr_reader :current_screen, :command_mode
78
+ attr_reader :current_screen, :command_mode, :session_picker_active,
79
+ :view_mode_picker_active
80
+ # @return [Boolean] true when the token setup popup overlay is visible
81
+ attr_reader :token_setup_active
45
82
  # @return [Boolean] true when graceful shutdown has been requested via signal
46
83
  attr_reader :shutdown_requested
47
84
 
@@ -50,6 +87,17 @@ module TUI
50
87
  @cable_client = cable_client
51
88
  @current_screen = :chat
52
89
  @command_mode = false
90
+ @session_picker_active = false
91
+ @session_picker_index = 0
92
+ @session_picker_page = 0
93
+ @session_picker_mode = :root
94
+ @session_picker_parent_id = nil
95
+ @view_mode_picker_active = false
96
+ @view_mode_picker_index = 0
97
+ @token_setup_active = false
98
+ @token_input_buffer = InputBuffer.new
99
+ @token_setup_error = nil
100
+ @token_setup_status = :idle
53
101
  @shutdown_requested = false
54
102
  @previous_signal_handlers = {}
55
103
  @watchdog_thread = nil
@@ -93,10 +141,17 @@ module TUI
93
141
 
94
142
  @screens[@current_screen].render(frame, content_area, tui)
95
143
  render_sidebar(frame, sidebar, tui)
144
+
145
+ check_token_setup_signals
146
+ render_token_setup_popup(frame, frame.area, tui) if @token_setup_active
96
147
  end
97
148
 
98
149
  def render_sidebar(frame, area, tui)
99
- if @command_mode
150
+ if @session_picker_active
151
+ render_session_picker(frame, area, tui)
152
+ elsif @view_mode_picker_active
153
+ render_view_mode_picker(frame, area, tui)
154
+ elsif @command_mode
100
155
  render_menu(frame, area, tui)
101
156
  else
102
157
  render_info(frame, area, tui)
@@ -127,19 +182,30 @@ module TUI
127
182
  else "cyan"
128
183
  end
129
184
 
185
+ session_label = session[:name] || "##{session[:id]}"
186
+
130
187
  lines = [
131
188
  tui.line(spans: [
132
189
  tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
133
190
  ]),
134
191
  tui.line(spans: [tui.span(content: "")]),
135
- tui.line(spans: [
136
- tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
137
- tui.span(content: "##{session[:id]}", style: tui.style(fg: "cyan", modifiers: [:bold]))
138
- ]),
192
+ if session[:name]
193
+ tui.line(spans: [
194
+ tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
195
+ ])
196
+ else
197
+ tui.line(spans: [
198
+ tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
199
+ tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
200
+ ])
201
+ end,
139
202
  tui.line(spans: [
140
203
  tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
141
204
  tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
142
205
  ]),
206
+ active_skills_line(tui, session),
207
+ active_workflow_line(tui, session),
208
+ goals_line(tui, session),
143
209
  tui.line(spans: [tui.span(content: "")]),
144
210
  tui.line(spans: [
145
211
  tui.span(content: "Mode ", style: tui.style(fg: "dark_gray")),
@@ -153,7 +219,7 @@ module TUI
153
219
  tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
154
220
  tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
155
221
  ])
156
- ]
222
+ ].compact
157
223
 
158
224
  info = tui.paragraph(
159
225
  text: lines,
@@ -167,10 +233,65 @@ module TUI
167
233
  frame.render_widget(info, area)
168
234
  end
169
235
 
236
+ # Builds the active skills line for the info panel.
237
+ # Returns nil when no skills are active so the line is hidden entirely.
238
+ # @param tui [RatatuiRuby] TUI rendering context
239
+ # @param session [Hash] session info hash containing :active_skills array
240
+ # @return [RatatuiRuby::Widgets::Line, nil] styled skills line, or nil when empty
241
+ def active_skills_line(tui, session)
242
+ skills = session[:active_skills]
243
+ return if skills.nil? || skills.empty?
244
+
245
+ label = skills.join(", ")
246
+ tui.line(spans: [
247
+ tui.span(content: "\u{1F4DA} ", style: tui.style(fg: "dark_gray")),
248
+ tui.span(content: label, style: tui.style(fg: "yellow"))
249
+ ])
250
+ end
251
+
252
+ # Builds the active workflow line for the info panel.
253
+ # Returns nil when no workflow is active so the line is hidden entirely.
254
+ # @param tui [RatatuiRuby] TUI rendering context
255
+ # @param session [Hash] session info hash containing :active_workflow string
256
+ # @return [RatatuiRuby::Widgets::Line, nil] styled workflow line, or nil when empty
257
+ def active_workflow_line(tui, session)
258
+ workflow = session[:active_workflow]
259
+ return if workflow.nil? || workflow.empty?
260
+
261
+ tui.line(spans: [
262
+ tui.span(content: "\u{1F504} ", style: tui.style(fg: "dark_gray")),
263
+ tui.span(content: workflow, style: tui.style(fg: "magenta"))
264
+ ])
265
+ end
266
+
267
+ # Builds the active goals line for the info panel.
268
+ # Returns nil when no goals exist so the line is hidden entirely.
269
+ # Shows root goal count with active/completed breakdown.
270
+ # @param tui [RatatuiRuby] TUI rendering context
271
+ # @param session [Hash] session info hash containing :goals array
272
+ # @return [RatatuiRuby::Widgets::Line, nil] styled goals line, or nil when empty
273
+ def goals_line(tui, session)
274
+ goal_list = session[:goals]
275
+ return if goal_list.nil? || goal_list.empty?
276
+
277
+ active = goal_list.count { |g| g["status"] == "active" }
278
+ completed = goal_list.count { |g| g["status"] == "completed" }
279
+ label = "#{active} active"
280
+ label += ", #{completed} done" if completed > 0
281
+ tui.line(spans: [
282
+ tui.span(content: "\u{1F3AF} ", style: tui.style(fg: "dark_gray")),
283
+ tui.span(content: label, style: tui.style(fg: "green"))
284
+ ])
285
+ end
286
+
170
287
  # Builds the interaction state line for the info panel.
171
- # Shows "Thinking..." during LLM processing.
288
+ # Shows "Scrolling" when chat pane is focused, or "Thinking..." during LLM processing.
172
289
  def interaction_state_line(tui)
173
- if chat_loading?
290
+ if @screens[:chat].chat_focused
291
+ tui.line(spans: [
292
+ tui.span(content: "Scrolling", style: tui.style(fg: "yellow", modifiers: [:bold]))
293
+ ])
294
+ elsif chat_loading?
174
295
  tui.line(spans: [
175
296
  tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
176
297
  ])
@@ -180,22 +301,24 @@ module TUI
180
301
  end
181
302
 
182
303
  # Builds the connection status line for the info panel.
304
+ # Shows a single emoji for the normal (subscribed) state; adds descriptive
305
+ # text only when something requires attention.
306
+ # @param tui [RatatuiRuby] TUI rendering context
307
+ # @return [RatatuiRuby::Widgets::Line] styled status line with emoji indicator
183
308
  def connection_status_line(tui)
184
309
  cable_status = @cable_client.status
310
+ style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
185
311
 
186
- if cable_status == :reconnecting
312
+ label = if cable_status == :reconnecting
187
313
  attempt = @cable_client.reconnect_attempt
188
314
  max = CableClient::MAX_RECONNECT_ATTEMPTS
189
- label = "Reconnecting (#{attempt}/#{max})"
190
- color = "yellow"
315
+ "#{style[:label]} (#{attempt}/#{max})"
191
316
  else
192
- style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
193
- label = style[:label].strip
194
- color = style[:bg]
317
+ style[:label]
195
318
  end
196
319
 
197
320
  tui.line(spans: [
198
- tui.span(content: label, style: tui.style(fg: color, modifiers: [:bold]))
321
+ tui.span(content: label, style: tui.style(fg: style[:color], modifiers: [:bold]))
199
322
  ])
200
323
  end
201
324
 
@@ -207,7 +330,13 @@ module TUI
207
330
  return nil if event.none?
208
331
  return :quit if event.ctrl_c?
209
332
 
210
- if @command_mode
333
+ if @token_setup_active
334
+ handle_token_setup(event)
335
+ elsif @session_picker_active
336
+ handle_session_picker(event)
337
+ elsif @view_mode_picker_active
338
+ handle_view_mode_picker(event)
339
+ elsif @command_mode
211
340
  handle_command_mode(event)
212
341
  else
213
342
  handle_normal_mode(event)
@@ -219,17 +348,32 @@ module TUI
219
348
 
220
349
  return nil unless event.key?
221
350
 
351
+ if event.up?
352
+ @screens[:chat].focus_chat
353
+ return nil
354
+ end
355
+
356
+ if event.down?
357
+ @screens[:chat].unfocus_chat
358
+ return nil
359
+ end
360
+
222
361
  action = COMMAND_KEYS[event.code]
223
362
  case action
224
363
  when :quit
225
364
  :quit
365
+ when :anthropic_token
366
+ activate_token_setup
367
+ nil
226
368
  when :new_session
227
369
  @screens[:chat].new_session
228
370
  @current_screen = :chat
229
371
  nil
372
+ when :session_picker
373
+ activate_session_picker
374
+ nil
230
375
  when :view_mode
231
- @screens[:chat].cycle_view_mode
232
- @current_screen = :chat
376
+ activate_view_mode_picker
233
377
  nil
234
378
  end
235
379
  end
@@ -247,10 +391,30 @@ module TUI
247
391
  return nil
248
392
  end
249
393
 
394
+ if event.esc?
395
+ if @screens[:chat].chat_focused
396
+ @screens[:chat].unfocus_chat
397
+ else
398
+ return_to_parent_session
399
+ end
400
+ return nil
401
+ end
402
+
250
403
  delegate_to_screen(event)
251
404
  nil
252
405
  end
253
406
 
407
+ # Switches to the parent session when viewing a child (sub-agent) session.
408
+ # No-op if the current session is a root session.
409
+ #
410
+ # @return [void]
411
+ def return_to_parent_session
412
+ parent_id = @screens[:chat].parent_session_id
413
+ return unless parent_id
414
+
415
+ @screens[:chat].switch_session(parent_id)
416
+ end
417
+
254
418
  # Forwards an event to the active screen for handling
255
419
  def delegate_to_screen(event)
256
420
  screen = @screens[@current_screen]
@@ -261,6 +425,801 @@ module TUI
261
425
  event.code == "a" && event.modifiers&.include?("ctrl")
262
426
  end
263
427
 
428
+ # -- Command mode pickers ------------------------------------------
429
+
430
+ # Shared keyboard navigation for Command Mode picker overlays.
431
+ # Handles arrow keys, Enter, Escape, and digit hotkeys.
432
+ #
433
+ # @param event [RatatuiRuby::Event] keyboard event
434
+ # @param items [Array] list of selectable items
435
+ # @param index_ivar [Symbol] instance variable name tracking selected index
436
+ # @return [:close, Object, nil] :close on Escape, selected item on
437
+ # Enter/hotkey, nil otherwise
438
+ def navigate_picker(event, items:, index_ivar:)
439
+ return nil unless event.key?
440
+ return :close if event.esc?
441
+
442
+ current_index = instance_variable_get(index_ivar)
443
+
444
+ if event.up?
445
+ instance_variable_set(index_ivar, [current_index - 1, 0].max)
446
+ return nil
447
+ end
448
+
449
+ if event.down?
450
+ max = [items.size - 1, 0].max
451
+ instance_variable_set(index_ivar, [current_index + 1, max].min)
452
+ return nil
453
+ end
454
+
455
+ if event.enter? && items.any?
456
+ return items[current_index]
457
+ end
458
+
459
+ idx = hotkey_to_index(event.code)
460
+ if idx && idx < items.size
461
+ return items[idx]
462
+ end
463
+
464
+ nil
465
+ end
466
+
467
+ # Maps digit key codes to picker list indices.
468
+ # Keys 1-9 map to indices 0-8. Key 0 is reserved for Load More in paginated pickers.
469
+ #
470
+ # @param code [String] the key code
471
+ # @return [Integer, nil] list index, or nil for non-digit keys
472
+ def hotkey_to_index(code)
473
+ return nil unless code.length == 1
474
+
475
+ code.to_i - 1 if ("1".."9").cover?(code)
476
+ end
477
+
478
+ # Returns the hotkey character for a given picker list position.
479
+ # Positions 0-8 get keys "1"-"9". Positions beyond 8 get no hotkey.
480
+ #
481
+ # @param idx [Integer] zero-based list position
482
+ # @return [String, nil] hotkey character, or nil for positions beyond 8
483
+ def picker_hotkey(idx)
484
+ (idx + 1).to_s if idx >= 0 && idx < 9
485
+ end
486
+
487
+ # -- Session picker ------------------------------------------------
488
+
489
+ # Status indicators for child session state.
490
+ CHILD_STATUS_RUNNING = "\u27F3" # ⟳
491
+ CHILD_STATUS_DONE = "\u2713" # ✓
492
+ CHILDREN_ARROW = "\u25B8" # ▸ shown next to sessions with children
493
+ UNNAMED_SUBAGENT_LABEL = "sub-agent"
494
+ SESSION_PICKER_PAGE_SIZE = 9
495
+ SESSION_PICKER_FETCH_LIMIT = 50
496
+ BACK_ARROW = "\u2190" # ←
497
+
498
+ # Requests the session list from the brain and opens the picker overlay.
499
+ # Fetches up to SESSION_PICKER_FETCH_LIMIT sessions for client-side pagination.
500
+ # @return [void]
501
+ def activate_session_picker
502
+ @session_picker_active = true
503
+ @session_picker_index = 0
504
+ @session_picker_page = 0
505
+ @session_picker_mode = :root
506
+ @session_picker_parent_id = nil
507
+ @cable_client.list_sessions(limit: SESSION_PICKER_FETCH_LIMIT)
508
+ end
509
+
510
+ # Dispatches keyboard events while the session picker overlay is open.
511
+ # Supports drill-down navigation: root sessions → children, with
512
+ # pagination via key 0 (Load More) at both levels.
513
+ #
514
+ # @param event [RatatuiRuby::Event] keyboard event
515
+ # @return [nil]
516
+ def handle_session_picker(event)
517
+ return nil unless event.key?
518
+
519
+ if event.esc?
520
+ handle_session_picker_escape
521
+ return nil
522
+ end
523
+
524
+ visible = session_picker_visible_items
525
+ return nil if visible.empty?
526
+
527
+ if event.up?
528
+ @session_picker_index = [@session_picker_index - 1, 0].max
529
+ elsif event.down?
530
+ @session_picker_index = [@session_picker_index + 1, visible.size - 1].min
531
+ elsif event.right?
532
+ drill_into_children(visible)
533
+ elsif event.left?
534
+ return_to_root_sessions
535
+ elsif event.enter?
536
+ select_session_picker_item(visible)
537
+ elsif event.code == "0" && session_picker_has_more?
538
+ load_more_sessions
539
+ else
540
+ idx = hotkey_to_index(event.code)
541
+ if idx && idx < visible.size
542
+ @session_picker_index = idx
543
+ select_session_picker_item(visible)
544
+ end
545
+ end
546
+
547
+ nil
548
+ end
549
+
550
+ # Returns the raw items for the current picker mode (root sessions or children).
551
+ #
552
+ # @return [Array<Hash>] session or child hashes from the sessions list
553
+ def session_picker_all_items_for_mode
554
+ sessions = @screens[:chat].sessions_list || []
555
+
556
+ case @session_picker_mode
557
+ when :root
558
+ sessions
559
+ when :children
560
+ parent = sessions.find { |s| s["id"] == @session_picker_parent_id }
561
+ parent&.dig("children") || []
562
+ end
563
+ end
564
+
565
+ # Returns the visible items for the current page of the current mode.
566
+ # Each item is a Hash with :type (:root or :child), :data, and :parent_id (for children).
567
+ #
568
+ # @return [Array<Hash>] visible items for the current page
569
+ def session_picker_visible_items
570
+ all = session_picker_all_items_for_mode
571
+ start = @session_picker_page * SESSION_PICKER_PAGE_SIZE
572
+ page = all[start, SESSION_PICKER_PAGE_SIZE] || []
573
+
574
+ page.map do |item|
575
+ case @session_picker_mode
576
+ when :root
577
+ {type: :root, data: item}
578
+ when :children
579
+ {type: :child, data: item, parent_id: @session_picker_parent_id}
580
+ end
581
+ end
582
+ end
583
+
584
+ # @return [Boolean] true when more items exist beyond the current page
585
+ def session_picker_has_more?
586
+ total = session_picker_all_items_for_mode.size
587
+ ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE) < total
588
+ end
589
+
590
+ # @return [Integer] number of items beyond the current page
591
+ def session_picker_remaining_count
592
+ total = session_picker_all_items_for_mode.size
593
+ [total - ((@session_picker_page + 1) * SESSION_PICKER_PAGE_SIZE), 0].max
594
+ end
595
+
596
+ # Handles Escape in the session picker. In children mode, returns to root.
597
+ # In root mode, closes the picker.
598
+ # @return [void]
599
+ def handle_session_picker_escape
600
+ if @session_picker_mode == :children
601
+ return_to_root_sessions
602
+ else
603
+ @session_picker_active = false
604
+ end
605
+ end
606
+
607
+ # Drills into the children of the selected root session.
608
+ # Only available in root mode on sessions with children.
609
+ #
610
+ # @param visible [Array<Hash>] current page items from {#session_picker_visible_items}
611
+ # @return [void]
612
+ def drill_into_children(visible)
613
+ return unless @session_picker_mode == :root
614
+
615
+ item = visible[@session_picker_index]
616
+ return unless item&.dig(:type) == :root
617
+
618
+ session = item[:data]
619
+ return unless session["children"]&.any?
620
+
621
+ @session_picker_mode = :children
622
+ @session_picker_parent_id = session["id"]
623
+ @session_picker_page = 0
624
+ @session_picker_index = 0
625
+ end
626
+
627
+ # Returns from children mode to root sessions view.
628
+ # @return [void]
629
+ def return_to_root_sessions
630
+ return unless @session_picker_mode == :children
631
+
632
+ @session_picker_mode = :root
633
+ @session_picker_parent_id = nil
634
+ @session_picker_page = 0
635
+ @session_picker_index = 0
636
+ end
637
+
638
+ # Advances to the next page of sessions in the current mode.
639
+ # @return [void]
640
+ def load_more_sessions
641
+ @session_picker_page += 1
642
+ @session_picker_index = 0
643
+ end
644
+
645
+ # Switches to the session selected in the picker and closes the overlay.
646
+ #
647
+ # @param visible [Array<Hash>] current page items from {#session_picker_visible_items}
648
+ # @return [void]
649
+ def select_session_picker_item(visible)
650
+ item = visible[@session_picker_index]
651
+ return unless item
652
+
653
+ @session_picker_active = false
654
+ @screens[:chat].switch_session(item[:data]["id"])
655
+ end
656
+
657
+ # Renders the session picker overlay in the sidebar.
658
+ # Shows paginated root sessions or children with drill-down navigation.
659
+ #
660
+ # @param frame [RatatuiRuby::Frame] terminal frame for widget rendering
661
+ # @param area [RatatuiRuby::Rect] sidebar area to render into
662
+ # @param tui [RatatuiRuby] TUI rendering API
663
+ # @return [void]
664
+ def render_session_picker(frame, area, tui)
665
+ sessions = @screens[:chat].sessions_list
666
+ current_id = @screens[:chat].session_info[:id]
667
+
668
+ if sessions.nil?
669
+ lines = [tui.line(spans: [
670
+ tui.span(content: "Loading...", style: tui.style(fg: "yellow"))
671
+ ])]
672
+ else
673
+ visible = session_picker_visible_items
674
+ @session_picker_index = @session_picker_index.clamp(0, [visible.size - 1, 0].max)
675
+
676
+ lines = visible.each_with_index.flat_map do |item, idx|
677
+ if item[:type] == :root
678
+ format_root_session_entry(tui, item[:data], idx, current_id)
679
+ else
680
+ format_child_session_entry(tui, item[:data], idx, current_id)
681
+ end
682
+ end
683
+
684
+ lines.concat(format_load_more_entry(tui)) if session_picker_has_more?
685
+
686
+ if lines.empty?
687
+ lines = [tui.line(spans: [
688
+ tui.span(content: "No sessions", style: tui.style(fg: "dark_gray"))
689
+ ])]
690
+ end
691
+ end
692
+
693
+ picker = tui.paragraph(
694
+ text: lines,
695
+ block: tui.block(
696
+ title: session_picker_title,
697
+ borders: [:all],
698
+ border_type: :rounded,
699
+ border_style: {fg: "cyan"}
700
+ )
701
+ )
702
+ frame.render_widget(picker, area)
703
+ end
704
+
705
+ # Returns the picker title based on the current navigation mode.
706
+ #
707
+ # @return [String] "Sessions" for root mode, "← #N" for children mode
708
+ def session_picker_title
709
+ case @session_picker_mode
710
+ when :root then "Sessions"
711
+ when :children then "#{BACK_ARROW} ##{@session_picker_parent_id}"
712
+ end
713
+ end
714
+
715
+ # Formats a root session entry with drill-in arrow and child count.
716
+ #
717
+ # @param tui [RatatuiRuby] TUI rendering API
718
+ # @param session [Hash] serialized session from the brain
719
+ # @param idx [Integer] position in the current page
720
+ # @param current_id [Integer] ID of the currently active session
721
+ # @return [Array<RatatuiRuby::Widgets::Line>]
722
+ def format_root_session_entry(tui, session, idx, current_id)
723
+ selected = idx == @session_picker_index
724
+ is_current = session["id"] == current_id
725
+ children = session["children"] || []
726
+
727
+ hotkey = picker_hotkey(idx)
728
+ prefix = hotkey ? "[#{hotkey}]" : " "
729
+ marker = is_current ? "*" : " "
730
+ arrow = children.any? ? CHILDREN_ARROW : " "
731
+
732
+ display_name = session["name"] || "##{session["id"]}"
733
+ count = "#{session["message_count"]}msg"
734
+ time = format_relative_time(session["updated_at"])
735
+ child_info = children.any? ? " (#{children.size})" : ""
736
+
737
+ label = "#{prefix}#{marker}#{arrow}#{display_name} #{count}#{child_info} #{time}"
738
+
739
+ style = if selected
740
+ tui.style(fg: "black", bg: "cyan")
741
+ elsif is_current
742
+ tui.style(fg: "cyan", modifiers: [:bold])
743
+ else
744
+ tui.style(fg: "white")
745
+ end
746
+
747
+ [tui.line(spans: [tui.span(content: label, style: style)])]
748
+ end
749
+
750
+ # Formats a child session entry with hotkey, status indicator, and agent name.
751
+ #
752
+ # @param tui [RatatuiRuby] TUI rendering API
753
+ # @param child [Hash] serialized child session from the brain
754
+ # @param idx [Integer] position in the current page
755
+ # @param current_id [Integer] ID of the currently active session
756
+ # @return [Array<RatatuiRuby::Widgets::Line>]
757
+ def format_child_session_entry(tui, child, idx, current_id)
758
+ selected = idx == @session_picker_index
759
+ is_current = child["id"] == current_id
760
+
761
+ hotkey = picker_hotkey(idx)
762
+ prefix = hotkey ? "[#{hotkey}]" : " "
763
+ marker = is_current ? "*" : " "
764
+ status = child["processing"] ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
765
+ status_color = child["processing"] ? "yellow" : "green"
766
+ display_name = child["name"] || UNNAMED_SUBAGENT_LABEL
767
+
768
+ label = "#{prefix}#{marker}#{status} #{display_name}"
769
+
770
+ style = if selected
771
+ tui.style(fg: "black", bg: "cyan")
772
+ elsif is_current
773
+ tui.style(fg: "cyan", modifiers: [:bold])
774
+ else
775
+ tui.style(fg: status_color)
776
+ end
777
+
778
+ [tui.line(spans: [tui.span(content: label, style: style)])]
779
+ end
780
+
781
+ # Formats the "Load more" entry shown when additional pages exist.
782
+ #
783
+ # @param tui [RatatuiRuby] TUI rendering API
784
+ # @return [Array<RatatuiRuby::Widgets::Line>]
785
+ def format_load_more_entry(tui)
786
+ remaining = session_picker_remaining_count
787
+ label = "[0] Load more (#{remaining})"
788
+ [tui.line(spans: [tui.span(content: label, style: tui.style(fg: "dark_gray"))])]
789
+ end
790
+
791
+ # -- View mode picker ----------------------------------------------
792
+
793
+ # Opens the view mode picker overlay. Pre-selects the current mode.
794
+ # @return [void]
795
+ def activate_view_mode_picker
796
+ @view_mode_picker_active = true
797
+ @view_mode_picker_index = Screens::Chat::VIEW_MODES.index(@screens[:chat].view_mode) || 0
798
+ end
799
+
800
+ # Dispatches keyboard events while the view mode picker is open.
801
+ #
802
+ # @param event [RatatuiRuby::Event] keyboard event
803
+ # @return [nil]
804
+ def handle_view_mode_picker(event)
805
+ result = navigate_picker(event, items: Screens::Chat::VIEW_MODES, index_ivar: :@view_mode_picker_index)
806
+
807
+ case result
808
+ when :close
809
+ @view_mode_picker_active = false
810
+ when String
811
+ pick_view_mode(result)
812
+ end
813
+
814
+ nil
815
+ end
816
+
817
+ # Switches to the selected view mode and closes the picker.
818
+ #
819
+ # @param mode [String] view mode name
820
+ # @return [void]
821
+ def pick_view_mode(mode)
822
+ @view_mode_picker_active = false
823
+ @screens[:chat].switch_view_mode(mode)
824
+ end
825
+
826
+ # Renders the view mode picker overlay in the sidebar.
827
+ #
828
+ # @param frame [RatatuiRuby::Frame] terminal frame for widget rendering
829
+ # @param area [RatatuiRuby::Rect] sidebar area to render into
830
+ # @param tui [RatatuiRuby] TUI rendering API
831
+ # @return [void]
832
+ def render_view_mode_picker(frame, area, tui)
833
+ current_mode = @screens[:chat].view_mode
834
+
835
+ lines = Screens::Chat::VIEW_MODES.each_with_index.flat_map do |mode, idx|
836
+ format_view_mode_entry(tui, mode, idx, current_mode)
837
+ end
838
+
839
+ picker = tui.paragraph(
840
+ text: lines,
841
+ block: tui.block(
842
+ title: "View Mode",
843
+ borders: [:all],
844
+ border_type: :rounded,
845
+ border_style: {fg: "cyan"}
846
+ )
847
+ )
848
+ frame.render_widget(picker, area)
849
+ end
850
+
851
+ # Formats a view mode entry with name and description.
852
+ # Highlights the selected entry and marks the current mode.
853
+ #
854
+ # @param tui [RatatuiRuby] TUI rendering API
855
+ # @param mode [String] view mode name
856
+ # @param idx [Integer] position in the list
857
+ # @param current_mode [String] currently active mode
858
+ # @return [Array<RatatuiRuby::Widgets::Line>] name and description lines
859
+ def format_view_mode_entry(tui, mode, idx, current_mode)
860
+ selected = idx == @view_mode_picker_index
861
+ is_current = mode == current_mode
862
+
863
+ hotkey = picker_hotkey(idx)
864
+ prefix = hotkey ? "[#{hotkey}]" : " "
865
+ marker = is_current ? "*" : " "
866
+
867
+ selected_style = tui.style(fg: "black", bg: "cyan")
868
+
869
+ name_style = if selected
870
+ selected_style
871
+ elsif is_current
872
+ tui.style(fg: "cyan", modifiers: [:bold])
873
+ else
874
+ tui.style(fg: "white")
875
+ end
876
+
877
+ desc_style = selected ? selected_style : tui.style(fg: "dark_gray")
878
+
879
+ [
880
+ tui.line(spans: [tui.span(content: "#{prefix}#{marker}#{mode.capitalize}", style: name_style)]),
881
+ tui.line(spans: [tui.span(content: "#{" " * PICKER_PREFIX_WIDTH}#{VIEW_MODE_LABELS[mode]}", style: desc_style)])
882
+ ]
883
+ end
884
+
885
+ # -- Token setup popup -----------------------------------------------
886
+
887
+ # Opens the token setup popup and resets all input state.
888
+ # Can be triggered manually via Ctrl+a > a or automatically when the
889
+ # brain broadcasts authentication_required.
890
+ # @return [void]
891
+ def activate_token_setup
892
+ @token_setup_active = true
893
+ @token_input_buffer.clear
894
+ @token_setup_error = nil
895
+ @token_setup_status = :idle
896
+ end
897
+
898
+ # Closes the token setup popup and resets all state.
899
+ # @return [void]
900
+ def close_token_setup
901
+ @token_setup_active = false
902
+ @token_input_buffer.clear
903
+ @token_setup_error = nil
904
+ @token_setup_status = :idle
905
+ end
906
+
907
+ # Polls the chat screen for authentication signals and token save results.
908
+ # Called every render frame so the popup reacts to server responses.
909
+ #
910
+ # State transitions:
911
+ # authentication_required signal → activates popup (if not already open)
912
+ # token_saved result → @token_setup_status becomes :success
913
+ # token_error result → @token_setup_status becomes :error
914
+ #
915
+ # @return [void]
916
+ def check_token_setup_signals
917
+ chat = @screens[:chat]
918
+
919
+ if chat.authentication_required && !@token_setup_active
920
+ activate_token_setup
921
+ chat.clear_authentication_required
922
+ end
923
+
924
+ result = chat.consume_token_save_result
925
+ return unless result
926
+
927
+ if result[:success]
928
+ @token_setup_status = :success
929
+ @token_setup_error = nil
930
+ else
931
+ @token_setup_status = :error
932
+ @token_setup_error = result[:message]
933
+ end
934
+ end
935
+
936
+ # Dispatches keyboard and paste events while the token setup popup is open.
937
+ #
938
+ # @param event [RatatuiRuby::Event] input event
939
+ # @return [nil]
940
+ def handle_token_setup(event)
941
+ # In success state, any key closes the popup
942
+ if @token_setup_status == :success
943
+ close_token_setup if event.key? || event.paste?
944
+ return nil
945
+ end
946
+
947
+ # During validation, ignore all input
948
+ return nil if @token_setup_status == :validating
949
+
950
+ if event.paste?
951
+ @token_input_buffer.insert(event.content)
952
+ @token_setup_error = nil
953
+ @token_setup_status = :idle
954
+ return nil
955
+ end
956
+
957
+ return nil unless event.key?
958
+
959
+ if event.esc?
960
+ close_token_setup
961
+ elsif event.enter?
962
+ submit_token
963
+ elsif event.backspace?
964
+ @token_input_buffer.backspace
965
+ @token_setup_error = nil
966
+ @token_setup_status = :idle
967
+ elsif event.delete?
968
+ @token_input_buffer.delete
969
+ elsif event.left?
970
+ @token_input_buffer.move_left
971
+ elsif event.right?
972
+ @token_input_buffer.move_right
973
+ elsif event.home?
974
+ @token_input_buffer.move_home
975
+ elsif event.end?
976
+ @token_input_buffer.move_end
977
+ elsif printable_token_char?(event)
978
+ @token_input_buffer.insert(event.code)
979
+ @token_setup_error = nil
980
+ @token_setup_status = :idle
981
+ end
982
+
983
+ nil
984
+ end
985
+
986
+ # Sends the entered token to the brain for validation and storage.
987
+ # @return [void]
988
+ def submit_token
989
+ token = @token_input_buffer.text.strip
990
+ return if token.empty?
991
+
992
+ @token_setup_status = :validating
993
+ @token_setup_error = nil
994
+ @cable_client.save_token(token)
995
+ end
996
+
997
+ # @param event [RatatuiRuby::Event] keyboard event
998
+ # @return [Boolean] true if the key is a printable character without ctrl
999
+ def printable_token_char?(event)
1000
+ return false if event.modifiers&.include?("ctrl")
1001
+
1002
+ event.code.length == 1 && event.code.match?(PRINTABLE_CHAR)
1003
+ end
1004
+
1005
+ # Renders the token setup popup as a centered overlay on the full terminal area.
1006
+ # Uses the Clear widget to prevent background content from bleeding through.
1007
+ #
1008
+ # @param frame [RatatuiRuby::Frame] terminal frame
1009
+ # @param area [RatatuiRuby::Rect] full terminal area
1010
+ # @param tui [RatatuiRuby] TUI rendering API
1011
+ # @return [void]
1012
+ def render_token_setup_popup(frame, area, tui)
1013
+ popup_area = centered_popup_area(tui, area)
1014
+
1015
+ frame.render_widget(tui.clear, popup_area)
1016
+
1017
+ border_color = case @token_setup_status
1018
+ when :success then "green"
1019
+ when :error then "red"
1020
+ else "yellow"
1021
+ end
1022
+
1023
+ lines = build_token_setup_lines(tui)
1024
+
1025
+ popup = tui.paragraph(
1026
+ text: lines,
1027
+ wrap: true,
1028
+ block: tui.block(
1029
+ title: "Anthropic Token Setup",
1030
+ borders: [:all],
1031
+ border_type: :rounded,
1032
+ border_style: {fg: border_color}
1033
+ )
1034
+ )
1035
+ frame.render_widget(popup, popup_area)
1036
+
1037
+ set_token_input_cursor(frame, popup_area) if token_cursor_visible?
1038
+ end
1039
+
1040
+ # Builds the text lines for the token setup popup.
1041
+ # @param tui [RatatuiRuby] TUI rendering API
1042
+ # @return [Array<RatatuiRuby::Widgets::Line>]
1043
+ def build_token_setup_lines(tui)
1044
+ lines = []
1045
+
1046
+ # Status
1047
+ status_text, status_color = token_status_display
1048
+ lines << tui.line(spans: [
1049
+ tui.span(content: "Status: ", style: tui.style(fg: "dark_gray")),
1050
+ tui.span(content: status_text, style: tui.style(fg: status_color, modifiers: [:bold]))
1051
+ ])
1052
+ lines << tui.line(spans: [tui.span(content: "")])
1053
+
1054
+ # Instructions
1055
+ lines << tui.line(spans: [
1056
+ tui.span(content: "Run ", style: tui.style(fg: "white")),
1057
+ tui.span(content: "claude setup-token", style: tui.style(fg: "cyan", modifiers: [:bold])),
1058
+ tui.span(content: " to get", style: tui.style(fg: "white"))
1059
+ ])
1060
+ lines << tui.line(spans: [
1061
+ tui.span(content: "your token, then paste it here.", style: tui.style(fg: "white"))
1062
+ ])
1063
+ lines << tui.line(spans: [tui.span(content: "")])
1064
+
1065
+ # Token input
1066
+ masked = mask_token(@token_input_buffer.text)
1067
+ lines << tui.line(spans: [
1068
+ tui.span(content: "Token:", style: tui.style(fg: "white", modifiers: [:bold]))
1069
+ ])
1070
+ lines << tui.line(spans: [
1071
+ tui.span(content: "> #{masked}", style: tui.style(fg: "white"))
1072
+ ])
1073
+ lines << tui.line(spans: [tui.span(content: "")])
1074
+
1075
+ # Error or success message
1076
+ if @token_setup_error
1077
+ lines << tui.line(spans: [
1078
+ tui.span(content: @token_setup_error, style: tui.style(fg: "red"))
1079
+ ])
1080
+ lines << tui.line(spans: [tui.span(content: "")])
1081
+ end
1082
+
1083
+ if @token_setup_status == :success
1084
+ lines << tui.line(spans: [
1085
+ tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
1086
+ ])
1087
+ lines << tui.line(spans: [tui.span(content: "")])
1088
+ end
1089
+
1090
+ # Hints
1091
+ hint = case @token_setup_status
1092
+ when :success then "[any key] Close"
1093
+ when :validating then "Validating..."
1094
+ else "[Enter] Save [Esc] Cancel"
1095
+ end
1096
+ lines << tui.line(spans: [
1097
+ tui.span(content: hint, style: tui.style(fg: "dark_gray"))
1098
+ ])
1099
+
1100
+ lines
1101
+ end
1102
+
1103
+ # @return [Array(String, String)] [status_text, color] for the current token setup state
1104
+ def token_status_display
1105
+ case @token_setup_status
1106
+ when :success
1107
+ ["Valid", "green"]
1108
+ when :validating
1109
+ ["Validating...", "yellow"]
1110
+ when :error
1111
+ ["Invalid", "red"]
1112
+ else
1113
+ if @token_input_buffer.text.empty?
1114
+ ["Not configured", "dark_gray"]
1115
+ else
1116
+ ["Ready to save", "cyan"]
1117
+ end
1118
+ end
1119
+ end
1120
+
1121
+ # Masks an Anthropic token for display: shows the first TOKEN_MASK_VISIBLE
1122
+ # characters (the prefix) and replaces the rest with stars.
1123
+ #
1124
+ # @param token [String] raw token text
1125
+ # @return [String] masked display text
1126
+ def mask_token(token)
1127
+ return "" if token.empty?
1128
+ return token if token.length <= TOKEN_MASK_VISIBLE
1129
+
1130
+ visible = token[0...TOKEN_MASK_VISIBLE]
1131
+ hidden_count = [token.length - TOKEN_MASK_VISIBLE, TOKEN_MASK_STARS].min
1132
+ "#{visible}#{"*" * hidden_count}..."
1133
+ end
1134
+
1135
+ # @return [Boolean] true when the blinking cursor should be shown in the input field
1136
+ def token_cursor_visible?
1137
+ @token_setup_status == :idle || @token_setup_status == :error
1138
+ end
1139
+
1140
+ # Positions the terminal cursor on the token input line inside the popup.
1141
+ # The input ">" line is at a fixed offset from the popup top.
1142
+ #
1143
+ # @param frame [RatatuiRuby::Frame] terminal frame
1144
+ # @param popup_area [RatatuiRuby::Rect] popup rectangle
1145
+ # @return [void]
1146
+ def set_token_input_cursor(frame, popup_area)
1147
+ # Content line offsets within the popup (after top border):
1148
+ # 0: Status 1: blank 2: Instructions L1 3: Instructions L2
1149
+ # 4: blank 5: Token: 6: > (input)
1150
+ input_line_offset = 7 # border (1) + 6 content lines
1151
+
1152
+ masked = mask_token(@token_input_buffer.text)
1153
+ prompt_width = 2 # "> " prefix before the masked token text
1154
+ cursor_x = popup_area.x + 1 + prompt_width + masked.length # border + prompt + text
1155
+ cursor_y = popup_area.y + input_line_offset
1156
+
1157
+ return unless cursor_x < popup_area.x + popup_area.width - 1 &&
1158
+ cursor_y < popup_area.y + popup_area.height - 1
1159
+
1160
+ frame.set_cursor_position(cursor_x, cursor_y)
1161
+ end
1162
+
1163
+ # Calculates a centered rectangle for the popup overlay.
1164
+ #
1165
+ # @param tui [RatatuiRuby] TUI rendering API
1166
+ # @param area [RatatuiRuby::Rect] full terminal area
1167
+ # @return [RatatuiRuby::Rect] centered popup area
1168
+ def centered_popup_area(tui, area)
1169
+ popup_height = [POPUP_HEIGHT, area.height - 2].min
1170
+ v_margin = [(area.height - popup_height) / 2, 0].max
1171
+
1172
+ _, center_v, _ = tui.split(
1173
+ area,
1174
+ direction: :vertical,
1175
+ constraints: [
1176
+ tui.constraint_length(v_margin),
1177
+ tui.constraint_length(popup_height),
1178
+ tui.constraint_fill(1)
1179
+ ]
1180
+ )
1181
+
1182
+ popup_width = (area.width * 60 / 100).clamp(POPUP_MIN_WIDTH, area.width - 2)
1183
+ h_margin = [(area.width - popup_width) / 2, 0].max
1184
+
1185
+ _, center, _ = tui.split(
1186
+ center_v,
1187
+ direction: :horizontal,
1188
+ constraints: [
1189
+ tui.constraint_length(h_margin),
1190
+ tui.constraint_length(popup_width),
1191
+ tui.constraint_fill(1)
1192
+ ]
1193
+ )
1194
+
1195
+ center
1196
+ end
1197
+
1198
+ # Formats an ISO8601 timestamp as a human-readable relative time.
1199
+ #
1200
+ # @param iso_string [String, nil] ISO8601 timestamp
1201
+ # @return [String] e.g. "2m ago", "3h ago", "Mar 12"
1202
+ def format_relative_time(iso_string)
1203
+ return "" unless iso_string
1204
+
1205
+ time = Time.parse(iso_string)
1206
+ delta = Time.now - time
1207
+
1208
+ if delta < 60
1209
+ "now"
1210
+ elsif delta < 3_600
1211
+ "#{(delta / 60).to_i}m ago"
1212
+ elsif delta < 86_400
1213
+ "#{(delta / 3_600).to_i}h ago"
1214
+ else
1215
+ time.strftime("%b %d")
1216
+ end
1217
+ rescue ArgumentError
1218
+ ""
1219
+ end
1220
+
1221
+ # -- Signal handling -----------------------------------------------
1222
+
264
1223
  # Traps SIGHUP, SIGTERM, and SIGINT to trigger graceful shutdown.
265
1224
  # Saves previous handlers so they can be restored when {#run} exits.
266
1225
  # Must only be called once per {#run} invocation.