rubino-agent 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# The {BottomComposer}'s /command + @file completion menu: an inline
|
|
8
|
+
# navigable list rendered in the multi-row live region above the prompt.
|
|
9
|
+
# Candidates come from the shared CompletionSource. The menu auto-opens as
|
|
10
|
+
# you type a `/` or `@` token (Reline parity); Tab also opens/accepts, ↑/↓
|
|
11
|
+
# navigate, Enter accepts, ESC dismisses immediately (and STICKS for the
|
|
12
|
+
# token) leaving the typed buffer untouched.
|
|
13
|
+
#
|
|
14
|
+
# Pure state machine + row formatting: it reads the buffer/cursor the
|
|
15
|
+
# composer passes in and never prints or takes the render mutex — opening,
|
|
16
|
+
# navigation and the accept SPLICE are decided here, but the composer
|
|
17
|
+
# applies the splice to its buffer and owns every redraw.
|
|
18
|
+
class CompletionMenu
|
|
19
|
+
# Most candidate rows shown at once (the list scrolls within this window
|
|
20
|
+
# for longer candidate sets so the prompt is never pushed off-screen).
|
|
21
|
+
MAX_ROWS = 8
|
|
22
|
+
|
|
23
|
+
# @param completion_source [CompletionSource, nil] shared completion
|
|
24
|
+
# discovery (slash commands + @file picker). nil ⇒ the menu is inert
|
|
25
|
+
# (steering / standalone), so the composer degrades to a plain editor.
|
|
26
|
+
def initialize(completion_source)
|
|
27
|
+
@completion = completion_source
|
|
28
|
+
# Open state: nil when closed, else a Hash with the candidate :items,
|
|
29
|
+
# the :selected index, the :top of the visible window, the :token span
|
|
30
|
+
# being completed (so accept can splice the replacement at the cursor)
|
|
31
|
+
# and the :navigated accept-intent flag.
|
|
32
|
+
@state = nil
|
|
33
|
+
# Sticky ESC-dismiss: once the user presses ESC on an open menu, keep
|
|
34
|
+
# it closed for the CURRENT token instead of re-opening on the next
|
|
35
|
+
# keystroke. Cleared when the token is cleared / on submit / on accept /
|
|
36
|
+
# on an explicit Tab, so a fresh token (or a deliberate Tab) reopens.
|
|
37
|
+
@suppressed = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def open?
|
|
41
|
+
!@state.nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The open menu's candidate items (test/inspection helper), nil when closed.
|
|
45
|
+
def items
|
|
46
|
+
@state && @state[:items]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Explicit open (Tab): always clears a sticky ESC-dismiss first — a
|
|
50
|
+
# deliberate Tab reopens a dismissed menu — then opens for the completion
|
|
51
|
+
# context under the cursor, if any. Returns the opened state (truthy; the
|
|
52
|
+
# composer redraws on it), or nil when nothing completes here.
|
|
53
|
+
def open(buffer, cursor)
|
|
54
|
+
@suppressed = false
|
|
55
|
+
ctx = completion_context(buffer, cursor)
|
|
56
|
+
return unless ctx
|
|
57
|
+
|
|
58
|
+
items, start, len = ctx
|
|
59
|
+
@state = { items: items, selected: 0, top: 0, start: start, token_len: len,
|
|
60
|
+
navigated: false }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Open / update / close the menu on every edit and cursor move, matching
|
|
64
|
+
# the old Reline autocompletion: typing a leading `/` or `@` token
|
|
65
|
+
# AUTO-opens the dropdown (no Tab needed), refining as the token grows and
|
|
66
|
+
# closing when it no longer completes. Called from every buffer-edit and
|
|
67
|
+
# cursor-move path so the list always tracks the token under the cursor.
|
|
68
|
+
#
|
|
69
|
+
# * no token under the cursor → close the menu AND clear the sticky
|
|
70
|
+
# ESC-dismiss flag (a fresh token may auto-open again);
|
|
71
|
+
# * token present but ESC-dismissed for it → stay closed;
|
|
72
|
+
# * token with candidates → OPEN a new menu, or UPDATE an open one
|
|
73
|
+
# (preserving the clamped selection); no candidates → close.
|
|
74
|
+
#
|
|
75
|
+
# The selected index is preserved (clamped) across an update so refining
|
|
76
|
+
# the token doesn't jump the highlight back to the top mid-navigation.
|
|
77
|
+
def auto_update(buffer, cursor)
|
|
78
|
+
ctx = completion_context(buffer, cursor)
|
|
79
|
+
if ctx.nil?
|
|
80
|
+
@state = nil
|
|
81
|
+
@suppressed = false # token cleared: a fresh token can auto-open
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
return if @suppressed # ESC stuck this token/argument closed
|
|
85
|
+
|
|
86
|
+
items, start, len = ctx
|
|
87
|
+
sel = (@state ? @state[:selected] : 0).clamp(0, items.size - 1)
|
|
88
|
+
@state = { items: items, selected: sel, top: window_top(sel, items.size),
|
|
89
|
+
start: start, token_len: len,
|
|
90
|
+
navigated: @state ? @state[:navigated] : false }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ↑/↓ within the open menu (routed from the composer's history keys).
|
|
94
|
+
# Arrowing marks the menu as NAVIGATED — an explicit accept intent, so
|
|
95
|
+
# Enter on an empty argument token accepts the highlight instead of
|
|
96
|
+
# submitting the buffer (see #exact_command?).
|
|
97
|
+
def up
|
|
98
|
+
@state[:selected] = [@state[:selected] - 1, 0].max
|
|
99
|
+
navigated_to_selection
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def down
|
|
103
|
+
@state[:selected] = [@state[:selected] + 1, @state[:items].size - 1].min
|
|
104
|
+
navigated_to_selection
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Accept the highlighted candidate: returns the splice the composer
|
|
108
|
+
# applies — [start, token_len, replacement] where the replacement carries
|
|
109
|
+
# a trailing space (so the next token starts clean, like Reline's append
|
|
110
|
+
# char) — and closes the menu (clearing the sticky dismiss: accepting
|
|
111
|
+
# ends this token; a new one can auto-open).
|
|
112
|
+
def accept_splice
|
|
113
|
+
choice = @state[:items][@state[:selected]].to_s
|
|
114
|
+
splice = [@state[:start], @state[:token_len], "#{choice} "]
|
|
115
|
+
close!
|
|
116
|
+
splice
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True when the buffer is ALREADY an exact, complete command, so Enter
|
|
120
|
+
# should SUBMIT it rather than accept-and-space (D5/#147). Compares the
|
|
121
|
+
# TOKEN the menu would splice (not the whole buffer, which never matches
|
|
122
|
+
# a bare argument candidate — that's what swallowed Enter on a fully
|
|
123
|
+
# typed `/agents sa_xxx`): submit when the typed token equals a
|
|
124
|
+
# candidate exactly AND that match is the menu's current selection (or
|
|
125
|
+
# the only candidate) — so a partial/ambiguous token (e.g. "/re" with
|
|
126
|
+
# /reasoning + /reset) still accepts the highlight on Enter as before.
|
|
127
|
+
# An EMPTY argument token (`/agents sa_xxx ` with the verb dropdown
|
|
128
|
+
# open) also submits — the buffer is already a complete command and
|
|
129
|
+
# accepting would splice a verb the user never typed — UNLESS the user
|
|
130
|
+
# explicitly arrow-navigated onto a candidate, which is an accept
|
|
131
|
+
# intent. Tab-accept is untouched.
|
|
132
|
+
def exact_command?(buffer)
|
|
133
|
+
return false unless @state
|
|
134
|
+
|
|
135
|
+
typed = Array(buffer.chars[@state[:start], @state[:token_len]]).join
|
|
136
|
+
return !@state[:navigated] if typed.empty?
|
|
137
|
+
|
|
138
|
+
items = @state[:items]
|
|
139
|
+
return false unless items.include?(typed)
|
|
140
|
+
|
|
141
|
+
selected = items[@state[:selected]].to_s
|
|
142
|
+
items.size == 1 || selected == typed
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Close the menu and clear the sticky ESC-dismiss flag (submit / accept):
|
|
146
|
+
# the next token starts fresh and is free to auto-open again.
|
|
147
|
+
def close!
|
|
148
|
+
@state = nil
|
|
149
|
+
@suppressed = false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Lone-ESC dismiss: close AND STICK for the current token so it doesn't
|
|
153
|
+
# pop back on the next keystroke. Cleared when the token changes to nil,
|
|
154
|
+
# on submit/accept, or on an explicit Tab (see #auto_update / #close!).
|
|
155
|
+
def dismiss!
|
|
156
|
+
@state = nil
|
|
157
|
+
@suppressed = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Teardown hide (composer stop/suspend): close the rows without touching
|
|
161
|
+
# the sticky dismiss, so a resume mid-token behaves exactly as before.
|
|
162
|
+
def hide!
|
|
163
|
+
@state = nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The rendered menu rows (the slice in view, the selected one marked with
|
|
167
|
+
# a cyan ❯ and inverse highlight), or [] when no menu is open. House
|
|
168
|
+
# grammar: a dim aside bar leads each row. Candidates with a registered
|
|
169
|
+
# description (BuiltIns/custom command one-liners, the /agents subcommand
|
|
170
|
+
# hints) show it dim in an aligned column next to the name (#39).
|
|
171
|
+
def rows(cols)
|
|
172
|
+
return [] unless @state
|
|
173
|
+
|
|
174
|
+
items = @state[:items]
|
|
175
|
+
top = @state[:top]
|
|
176
|
+
sel = @state[:selected]
|
|
177
|
+
slice = items[top, MAX_ROWS] || []
|
|
178
|
+
pad = slice.map { |item| LiveRegion.display_width(item.to_s) }.max.to_i
|
|
179
|
+
rows = slice.each_with_index.map do |item, i|
|
|
180
|
+
candidate_row(item, pad, cols, selected: top + i == sel)
|
|
181
|
+
end
|
|
182
|
+
rows << pastel.dim("┄ #{sel + 1}/#{items.size} ┄") if items.size > MAX_ROWS
|
|
183
|
+
rows
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def navigated_to_selection
|
|
189
|
+
@state[:top] = window_top(@state[:selected], @state[:items].size)
|
|
190
|
+
@state[:navigated] = true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def candidate_row(item, pad, cols, selected:)
|
|
194
|
+
row = if selected
|
|
195
|
+
"#{pastel.cyan("❯")} #{pastel.inverse(" #{item} ")}"
|
|
196
|
+
else
|
|
197
|
+
"#{pastel.dim("┊")} #{item}"
|
|
198
|
+
end
|
|
199
|
+
desc = description(item, pad, cols)
|
|
200
|
+
if desc
|
|
201
|
+
# Align the description column across rows: the inverse highlight
|
|
202
|
+
# already widens the selected name by 2 (its padding spaces).
|
|
203
|
+
row += (" " * (pad - LiveRegion.display_width(item.to_s) + (selected ? 0 : 2)))
|
|
204
|
+
row += pastel.dim(desc)
|
|
205
|
+
end
|
|
206
|
+
row
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The dim description for a menu candidate, fitted to the row budget so a
|
|
210
|
+
# long one-liner is right-truncated here instead of the shared row clamp
|
|
211
|
+
# left-truncating the candidate NAME away. nil when the source has none
|
|
212
|
+
# (files, skill names) or the row is too narrow to show one usefully.
|
|
213
|
+
def description(item, pad, cols)
|
|
214
|
+
return nil unless @completion.respond_to?(:description_for)
|
|
215
|
+
|
|
216
|
+
desc = @completion.description_for(item).to_s
|
|
217
|
+
return nil if desc.empty?
|
|
218
|
+
|
|
219
|
+
budget = cols - pad - 6 # glyph + gaps + the one-column scroll guard
|
|
220
|
+
return nil if budget < 8
|
|
221
|
+
|
|
222
|
+
desc.length > budget ? "#{desc[0, budget - 1]}…" : desc
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Resolve what to complete at the cursor: returns [items, start, len]
|
|
226
|
+
# where +items+ are the candidate strings, +start+ the codepoint index
|
|
227
|
+
# where the splice begins, and +len+ the length of the text the accepted
|
|
228
|
+
# choice replaces — or nil when nothing completes here.
|
|
229
|
+
#
|
|
230
|
+
# Two shapes, in priority order:
|
|
231
|
+
# 1. COMMAND ARGUMENT — the buffer is `/<cmd> <partial>` and <cmd> has a
|
|
232
|
+
# registered argument source (e.g. `/skills ruby` → skill names). The
|
|
233
|
+
# partial (possibly empty) is the splice span; this is what lets the
|
|
234
|
+
# SAME dropdown pick a skill name as it picks a /command or @file.
|
|
235
|
+
# 2. LEADING TOKEN — a `/command` or `@file` token under the cursor
|
|
236
|
+
# (the original behavior), spliced over the whole token.
|
|
237
|
+
def completion_context(buffer, cursor)
|
|
238
|
+
return nil unless @completion
|
|
239
|
+
|
|
240
|
+
if (arg = command_arg_context(buffer, cursor))
|
|
241
|
+
command, partial, start, args = arg
|
|
242
|
+
items = arg_candidates(command, partial, args)
|
|
243
|
+
return nil if items.empty?
|
|
244
|
+
|
|
245
|
+
return [items, start, partial.chars.length]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
tok = current_token(buffer, cursor)
|
|
249
|
+
return nil unless tok
|
|
250
|
+
|
|
251
|
+
token, start = tok
|
|
252
|
+
items = candidates(token)
|
|
253
|
+
return nil if items.empty?
|
|
254
|
+
|
|
255
|
+
[items, start, token.chars.length]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The completion TOKEN under the cursor: the leading run of non-space
|
|
259
|
+
# chars from the start of the line up to the cursor, when it begins with
|
|
260
|
+
# / or @. Returns [token, start_index] or nil when the cursor isn't on a
|
|
261
|
+
# token.
|
|
262
|
+
def current_token(buffer, cursor)
|
|
263
|
+
prefix = buffer.chars.first(cursor).join
|
|
264
|
+
# Only the FIRST token on the line completes (a leading /command, or an
|
|
265
|
+
# @mention anywhere the run back to a space starts with @).
|
|
266
|
+
m = prefix.match(%r{(?:\A|\s)([/@]\S*)\z})
|
|
267
|
+
return nil unless m
|
|
268
|
+
|
|
269
|
+
[m[1], m.begin(1)]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# When the buffer is an ARGUMENT position of a slash command — i.e.
|
|
273
|
+
# `/<cmd> [args…] <partial>` with the cursor in the trailing argument —
|
|
274
|
+
# returns [command, partial, partial_start, args] so
|
|
275
|
+
# {#completion_context} can complete it; nil otherwise. +args+ are the
|
|
276
|
+
# COMPLETE arguments before the partial, so a positional source can own a
|
|
277
|
+
# subcommand grammar (`/agents <id> steer|probe|--stop`, #39); whether a
|
|
278
|
+
# position completes at all is the CompletionSource's call (a
|
|
279
|
+
# single-argument command like /skills stops after its first).
|
|
280
|
+
def command_arg_context(buffer, cursor)
|
|
281
|
+
prefix = buffer.chars.first(cursor).join
|
|
282
|
+
m = prefix.match(%r{\A/(\S+)((?:[ \t]+\S+)*)[ \t]+(\S*)\z})
|
|
283
|
+
return nil unless m
|
|
284
|
+
|
|
285
|
+
[m[1], m[3], m.begin(3), m[2].split]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def candidates(token)
|
|
289
|
+
@completion.candidates_for(token)
|
|
290
|
+
rescue StandardError
|
|
291
|
+
[]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Argument candidates for a slash command (e.g. skill names for `/skills`,
|
|
295
|
+
# ids + steer/probe/--stop for `/agents`), via the CompletionSource.
|
|
296
|
+
# Guarded so a registry hiccup degrades the menu to closed rather than
|
|
297
|
+
# crashing the prompt — same contract as #candidates.
|
|
298
|
+
def arg_candidates(command, partial, args)
|
|
299
|
+
return [] unless @completion.respond_to?(:arg_candidates_for)
|
|
300
|
+
|
|
301
|
+
@completion.arg_candidates_for(command, partial, args)
|
|
302
|
+
rescue StandardError
|
|
303
|
+
[]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The visible window's top index so the selected row stays in view.
|
|
307
|
+
def window_top(selected, size)
|
|
308
|
+
return 0 if size <= MAX_ROWS
|
|
309
|
+
|
|
310
|
+
top = @state ? @state[:top] : 0
|
|
311
|
+
top = selected if selected < top
|
|
312
|
+
top = selected - MAX_ROWS + 1 if selected >= top + MAX_ROWS
|
|
313
|
+
top.clamp(0, size - MAX_ROWS)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def pastel
|
|
317
|
+
@pastel ||= Pastel.new
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module UI
|
|
8
|
+
# Shared completion DISCOVERY + token HIGHLIGHT for the interactive prompt.
|
|
9
|
+
# The bottom composer's /command + @file completion menu and token highlight
|
|
10
|
+
# consult this single implementation (git→rg→glob walk, @file candidate
|
|
11
|
+
# shaping, caps/TTL cache, cyan leading-token highlight) instead of each
|
|
12
|
+
# path duplicating it.
|
|
13
|
+
#
|
|
14
|
+
# * +candidates_for(token)+ — slash commands or @file paths for a token.
|
|
15
|
+
# * +highlight_line(line)+ — cyan the leading /command / @mention token.
|
|
16
|
+
#
|
|
17
|
+
# Discovery is fastest-first (git tracked+untracked honoring .gitignore →
|
|
18
|
+
# ripgrep --files → a capped Dir.glob walk) and memoized for a few seconds so
|
|
19
|
+
# a burst of @ keystrokes never reshells. Every tier is guarded so a failure
|
|
20
|
+
# degrades to the next tier (and finally to []), never crashing the prompt.
|
|
21
|
+
class CompletionSource
|
|
22
|
+
# Tokens that trigger highlighting at the start of the line. A leading
|
|
23
|
+
# `!` (the bang shell escape) glows like `/` so the user can SEE the
|
|
24
|
+
# line will run as a shell command, not a message — highlight only, it
|
|
25
|
+
# never opens the completion menu.
|
|
26
|
+
TRIGGER_TOKEN = %r{\A([/@]\S+|!\S*)}
|
|
27
|
+
|
|
28
|
+
# Cap on candidates — keeps the menu skimmable and bounds work on huge
|
|
29
|
+
# repos. Cline et al. ship similar caps.
|
|
30
|
+
MAX_CANDIDATES = 30
|
|
31
|
+
|
|
32
|
+
# How long a computed file list stays warm before the next `@` reshells.
|
|
33
|
+
FILE_CACHE_TTL = 5.0
|
|
34
|
+
|
|
35
|
+
# Hardcoded ignore set for the last-resort Dir.glob walk (git/rg already
|
|
36
|
+
# honor .gitignore; this is only the fallback's safety net).
|
|
37
|
+
GLOB_IGNORE_DIRS = %w[.git node_modules vendor tmp log .bundle].freeze
|
|
38
|
+
|
|
39
|
+
# Hard ceiling on the Dir.glob fallback so a giant tree can't hang the
|
|
40
|
+
# prompt while we walk it.
|
|
41
|
+
GLOB_MAX_FILES = 5000
|
|
42
|
+
|
|
43
|
+
# The `✗ none` clear entry shown at the TOP of an argument list whose
|
|
44
|
+
# command supports clearing its active selection (e.g. `/skills`). Picking
|
|
45
|
+
# it submits the bare sentinel so the command handler clears the slot.
|
|
46
|
+
NONE_ENTRY = "✗ none"
|
|
47
|
+
|
|
48
|
+
# @param commands [Array<String>] the slash-command names (incl. leading /)
|
|
49
|
+
# @param files [#call, nil] lazy proc returning the workspace root to scan
|
|
50
|
+
# @param arg_sources [Hash{String=>#call}] maps a BARE command name (no
|
|
51
|
+
# leading slash, e.g. "skills") to a proc returning that command's
|
|
52
|
+
# argument candidates. Two shapes:
|
|
53
|
+
# * a NO-ARG proc — a single-argument command (e.g. "skills" → the
|
|
54
|
+
# skill names); only the FIRST argument completes, and the list is
|
|
55
|
+
# prefixed with the `✗ none` clear entry (NONE_ENTRY) so the picker
|
|
56
|
+
# can clear the active selection from the top.
|
|
57
|
+
# * a ONE-ARG proc — receives the PRIOR-argument array and decides
|
|
58
|
+
# what completes at this position (e.g. "agents": [] → live ids,
|
|
59
|
+
# [id] → steer/probe/--stop), so a subcommand grammar is
|
|
60
|
+
# discoverable from the same dropdown (#39). No `✗ none` entry is
|
|
61
|
+
# injected — but the source may INCLUDE the NONE_ENTRY string in
|
|
62
|
+
# its own list (e.g. "skills", whose first position mixes the
|
|
63
|
+
# activate-by-name list with the enable/disable verbs, #188), and
|
|
64
|
+
# it keeps the same special matching the no-arg shape gives it.
|
|
65
|
+
# Closed enums (`/mode`, `/reasoning`, `/think`, #185) use this
|
|
66
|
+
# shape too — `->(args) { args.empty? ? VALUES : [] }` — exactly
|
|
67
|
+
# because it carries no `✗ none` entry (there is no "clear" for a
|
|
68
|
+
# mode; the no-arg shape's prefix would offer a bogus value).
|
|
69
|
+
# * a TWO-ARG proc — receives (prior args, the PARTIAL typed so far)
|
|
70
|
+
# and OWNS the matching (no additional prefix filter): a
|
|
71
|
+
# filesystem-path source (`/add-dir`, #185) expands `~`, which a
|
|
72
|
+
# literal prefix filter would drop. No `✗ none` entry.
|
|
73
|
+
# @param descriptions [Hash{String=>String}] one-line description per
|
|
74
|
+
# candidate string (e.g. BuiltIns::DESCRIPTIONS), rendered dim next to
|
|
75
|
+
# the name in the dropdown (#39). Candidates without an entry show
|
|
76
|
+
# bare, as before.
|
|
77
|
+
def initialize(commands: [], files: nil, arg_sources: {}, descriptions: {})
|
|
78
|
+
@commands = Array(commands).uniq
|
|
79
|
+
@files_root_proc = files
|
|
80
|
+
@arg_sources = arg_sources || {}
|
|
81
|
+
@descriptions = descriptions || {}
|
|
82
|
+
@pastel = Pastel.new
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Candidates for a completion token. A `/`-prefixed token completes from
|
|
86
|
+
# the command list; an `@`-prefixed token completes from workspace files;
|
|
87
|
+
# anything else has no candidates. Case-insensitive prefix matching.
|
|
88
|
+
def candidates_for(token)
|
|
89
|
+
case token
|
|
90
|
+
when %r{\A/}
|
|
91
|
+
down = token.downcase
|
|
92
|
+
@commands.select { |c| c.downcase.start_with?(down) }
|
|
93
|
+
when /\A@/
|
|
94
|
+
file_candidates(token)
|
|
95
|
+
else
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Candidates for the ARGUMENT of a command, e.g. the skill names when the
|
|
101
|
+
# buffer is `/skills <partial>`. +command+ is the bare command name (no
|
|
102
|
+
# leading slash); +partial+ is the text typed so far for the argument
|
|
103
|
+
# (may be empty); +args+ the COMPLETE arguments typed before it. Returns
|
|
104
|
+
# [] when the command has no registered argument source.
|
|
105
|
+
#
|
|
106
|
+
# Candidates are filtered by case-insensitive prefix and capped at
|
|
107
|
+
# MAX_CANDIDATES — the SAME cap the `/command` and `@file` lists honor.
|
|
108
|
+
# A no-arg source (single-argument command) completes only the first
|
|
109
|
+
# argument and leads with the `✗ none` clear entry; a one-arg source is
|
|
110
|
+
# called with +args+ and owns the per-position grammar (#39) — see
|
|
111
|
+
# #initialize.
|
|
112
|
+
def arg_candidates_for(command, partial, args = [])
|
|
113
|
+
source = @arg_sources[command.to_s]
|
|
114
|
+
return [] unless source
|
|
115
|
+
|
|
116
|
+
down = partial.to_s.downcase
|
|
117
|
+
list =
|
|
118
|
+
if source.arity.zero?
|
|
119
|
+
return [] unless args.empty? # single-argument command: first arg only
|
|
120
|
+
|
|
121
|
+
# The `✗ none` clear entry matches an empty partial or a
|
|
122
|
+
# "n"/"no…"/"none" prefix, so typing toward "none" keeps it in view.
|
|
123
|
+
none = down.empty? || NONE.start_with?(down) ? [NONE_ENTRY] : []
|
|
124
|
+
none + Array(source.call).select { |n| n.to_s.downcase.start_with?(down) }
|
|
125
|
+
elsif source.arity == 2
|
|
126
|
+
# PARTIAL-AWARE source: it derives candidates FROM the typed text
|
|
127
|
+
# (e.g. a filesystem glob) and owns the matching — see #initialize.
|
|
128
|
+
Array(source.call(args, partial.to_s))
|
|
129
|
+
else
|
|
130
|
+
positional_candidates(source.call(args), down)
|
|
131
|
+
end
|
|
132
|
+
list.first(MAX_CANDIDATES)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Prefix-filtered candidates from a positional (one-arg) source. A
|
|
136
|
+
# literal NONE_ENTRY in the source's list (the /skills first position,
|
|
137
|
+
# #188) keeps the clear entry's special matching — shown on an empty
|
|
138
|
+
# partial or while typing toward "none" — instead of being dropped by
|
|
139
|
+
# the literal `✗ ` prefix filter.
|
|
140
|
+
def positional_candidates(list, down)
|
|
141
|
+
list = Array(list)
|
|
142
|
+
has_none = list.delete(NONE_ENTRY)
|
|
143
|
+
matched = list.select { |n| n.to_s.downcase.start_with?(down) }
|
|
144
|
+
return matched unless has_none && (down.empty? || NONE.start_with?(down))
|
|
145
|
+
|
|
146
|
+
[NONE_ENTRY] + matched
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Directory candidates for a PATH-shaped argument (`/add-dir `, #185) —
|
|
150
|
+
# the directory-flavored sibling of the `@file` picker. Globs the
|
|
151
|
+
# filesystem from the typed partial (relative to cwd, absolute, or
|
|
152
|
+
# `~`-prefixed — an added root usually lives OUTSIDE the workspace, so
|
|
153
|
+
# the workspace file list is the wrong source here), keeps only
|
|
154
|
+
# directories, and folds `~` back so the spliced candidate preserves the
|
|
155
|
+
# user's spelling. Best-effort: any failure (e.g. `~nouser`) returns [].
|
|
156
|
+
def self.directory_candidates(partial)
|
|
157
|
+
text = partial.to_s
|
|
158
|
+
pattern = text.start_with?("~") ? File.expand_path(text) : text
|
|
159
|
+
Dir.glob("#{pattern}*")
|
|
160
|
+
.select { |p| File.directory?(p) }
|
|
161
|
+
.sort
|
|
162
|
+
.map { |p| text.start_with?("~") ? p.sub(File.expand_path("~"), "~") : p }
|
|
163
|
+
.first(MAX_CANDIDATES)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The sentinel a `✗ none` selection resolves to once spliced + submitted —
|
|
169
|
+
# the command handler treats this argument as "clear the active selection".
|
|
170
|
+
NONE = "none"
|
|
171
|
+
|
|
172
|
+
# The one-line description for a dropdown candidate (#39): the same
|
|
173
|
+
# strings /help shows for a `/command`, a usage hint for a subcommand.
|
|
174
|
+
# nil when the candidate has none (files, skill names) — the menu row
|
|
175
|
+
# renders bare, exactly as before.
|
|
176
|
+
def description_for(candidate)
|
|
177
|
+
@descriptions[candidate.to_s]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Subtly colorize a leading /command or @mention token (cyan). Plain text
|
|
181
|
+
# and non-strings are returned unchanged. Matches LineInput#highlight_line.
|
|
182
|
+
# A "[Pasted text #N +M lines]" paste placeholder (UI::PasteStore) glows
|
|
183
|
+
# the same way wherever it sits in the line, so the user can SEE it is a
|
|
184
|
+
# token that expands at send, not literal text.
|
|
185
|
+
def highlight_line(line)
|
|
186
|
+
return line unless line.is_a?(String)
|
|
187
|
+
|
|
188
|
+
line.sub(TRIGGER_TOKEN) { @pastel.cyan(Regexp.last_match(1)) }
|
|
189
|
+
.gsub(PasteStore::TOKEN_RE) { |token| @pastel.cyan(token) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# Turn an `@<partial>` token into `@<relpath>` candidates, prefix-matching
|
|
195
|
+
# the relative path case-insensitively (MVP is prefix-only, as Cline ships).
|
|
196
|
+
def file_candidates(token)
|
|
197
|
+
partial = token.sub(/\A@/, "")
|
|
198
|
+
down = partial.downcase
|
|
199
|
+
|
|
200
|
+
workspace_files
|
|
201
|
+
.lazy
|
|
202
|
+
.select { |rel| rel.downcase.start_with?(down) }
|
|
203
|
+
.map { |rel| "@#{rel}" }
|
|
204
|
+
.first(MAX_CANDIDATES)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Workspace-relative file list, discovered once per `@` burst and memoized
|
|
208
|
+
# for FILE_CACHE_TTL so we never reshell on every keystroke.
|
|
209
|
+
def workspace_files
|
|
210
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
return @files_cache if @files_cache && @files_cache_at && (now - @files_cache_at) < FILE_CACHE_TTL
|
|
212
|
+
|
|
213
|
+
@files_cache = discover_files(workspace_root)
|
|
214
|
+
@files_cache_at = now
|
|
215
|
+
@files_cache
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Same source of truth as Tools::Base#workspace_root (the primary root).
|
|
219
|
+
def workspace_root
|
|
220
|
+
root = @files_root_proc&.call if @files_root_proc
|
|
221
|
+
root || Rubino::Workspace.primary_root
|
|
222
|
+
rescue StandardError
|
|
223
|
+
Dir.pwd
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Ignore-aware file discovery, fastest-first. Any failure at a tier falls
|
|
227
|
+
# through; if everything fails we return [] and the prompt keeps working.
|
|
228
|
+
def discover_files(root)
|
|
229
|
+
return [] unless root && File.directory?(root)
|
|
230
|
+
|
|
231
|
+
git_files(root) || rg_files(root) || glob_files(root) || []
|
|
232
|
+
rescue StandardError
|
|
233
|
+
[]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# (1) git: tracked + untracked, honoring .gitignore. nil (not []) on
|
|
237
|
+
# failure so the caller falls through to the next tier. err: File::NULL so
|
|
238
|
+
# git's "fatal: not a git repository" never bleeds onto the prompt (D5).
|
|
239
|
+
def git_files(root)
|
|
240
|
+
out, status = Open3.capture2(
|
|
241
|
+
"git", "ls-files", "--cached", "--others", "--exclude-standard",
|
|
242
|
+
chdir: root, err: File::NULL
|
|
243
|
+
)
|
|
244
|
+
return nil unless status.success?
|
|
245
|
+
|
|
246
|
+
out.split("\n").reject(&:empty?)
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# (2) ripgrep: --files lists every file rg would search (.gitignore aware).
|
|
252
|
+
def rg_files(root)
|
|
253
|
+
return nil unless ripgrep_available?
|
|
254
|
+
|
|
255
|
+
out, status = Open3.capture2("rg", "--files", chdir: root, err: File::NULL)
|
|
256
|
+
return nil unless status.success?
|
|
257
|
+
|
|
258
|
+
out.split("\n").reject(&:empty?)
|
|
259
|
+
rescue StandardError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def ripgrep_available?
|
|
264
|
+
system("which rg > /dev/null 2>&1")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# (3) last resort: a capped, ignore-aware Dir.glob walk.
|
|
268
|
+
def glob_files(root)
|
|
269
|
+
files = []
|
|
270
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: root) do |rel|
|
|
271
|
+
next if [".", ".."].include?(rel)
|
|
272
|
+
next if GLOB_IGNORE_DIRS.any? { |d| rel == d || rel.start_with?("#{d}/") }
|
|
273
|
+
next unless File.file?(File.join(root, rel))
|
|
274
|
+
|
|
275
|
+
files << rel
|
|
276
|
+
break if files.size >= GLOB_MAX_FILES
|
|
277
|
+
end
|
|
278
|
+
files
|
|
279
|
+
rescue StandardError
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|