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.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- 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}" }
|
|
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
|
-
#
|
|
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: "
|
|
24
|
-
connecting: {label: "
|
|
25
|
-
connected: {label: "
|
|
26
|
-
subscribed: {label: "
|
|
27
|
-
reconnecting: {label: "
|
|
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 @
|
|
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
|
-
|
|
136
|
-
tui.
|
|
137
|
-
|
|
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
|
|
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
|
|
190
|
-
color = "yellow"
|
|
315
|
+
"#{style[:label]} (#{attempt}/#{max})"
|
|
191
316
|
else
|
|
192
|
-
style
|
|
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 @
|
|
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
|
-
|
|
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.
|