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,1674 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module CLI
|
|
8
|
+
# Interactive and non-interactive chat session command.
|
|
9
|
+
# Supported flags:
|
|
10
|
+
# -q/--query one-shot non-interactive prompt
|
|
11
|
+
# -c/--continue resume most recent session
|
|
12
|
+
# -r/--resume resume session by ID or title
|
|
13
|
+
# --provider override provider
|
|
14
|
+
# --yolo skip all approval prompts
|
|
15
|
+
# --max-turns override max tool iterations
|
|
16
|
+
# --ignore-rules skip AGENTS.md and context files
|
|
17
|
+
class ChatCommand
|
|
18
|
+
include Rubino::UI::ProbeWaitIndicator
|
|
19
|
+
|
|
20
|
+
# Window (seconds) for the Aider-style double-tap: a second Ctrl+C
|
|
21
|
+
# within this of the first re-raises so the user can actually quit.
|
|
22
|
+
DOUBLE_TAP_SECONDS = 2.0
|
|
23
|
+
|
|
24
|
+
# Picker snippet length — enough to recognize the message at a glance.
|
|
25
|
+
REWIND_SNIPPET_CHARS = 60
|
|
26
|
+
|
|
27
|
+
# The confirm press must come after a deliberate beat (a blind mash
|
|
28
|
+
# re-arms instead of confirming — the exact failure mode of #152 was
|
|
29
|
+
# 2-5 quick presses while watching the stream) and before the arm goes
|
|
30
|
+
# stale (the toast is long gone; a lone later press must re-confirm).
|
|
31
|
+
YOLO_CONFIRM_MIN_SECONDS = 0.3
|
|
32
|
+
YOLO_CONFIRM_WINDOW_SECONDS = 5.0
|
|
33
|
+
|
|
34
|
+
PROMPT_CARET = "❯"
|
|
35
|
+
PROMPT_RAIL = "▍"
|
|
36
|
+
|
|
37
|
+
def initialize(options = {})
|
|
38
|
+
@options = options
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute
|
|
42
|
+
ensure_setup!
|
|
43
|
+
ensure_model_configured!
|
|
44
|
+
|
|
45
|
+
query = opt(:query) || opt(:q)
|
|
46
|
+
if query
|
|
47
|
+
run_oneshot(query)
|
|
48
|
+
else
|
|
49
|
+
run_interactive
|
|
50
|
+
end
|
|
51
|
+
rescue Rubino::AmbiguousSessionError, Rubino::SessionError => e
|
|
52
|
+
# Render session-resolution errors as a clean stderr message + non-zero
|
|
53
|
+
# exit, not a Ruby stack trace. AmbiguousSessionError's message
|
|
54
|
+
# already includes the candidate list, so just print it.
|
|
55
|
+
warn e.message
|
|
56
|
+
exit(1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# --- Collaborators (#17): cohesive REPL concerns extracted into their own
|
|
62
|
+
# classes (image inbox / session resolution + replay / idle card host);
|
|
63
|
+
# ChatCommand orchestrates them around the turn loop. ---
|
|
64
|
+
|
|
65
|
+
def image_inbox
|
|
66
|
+
@image_inbox ||= Chat::ImageInbox.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The per-session paste store behind the file-backed paste pipeline:
|
|
70
|
+
# large pastes collapse to "[Pasted text #N +M lines]" placeholders in
|
|
71
|
+
# the composer and are expanded back to the full body (or to a
|
|
72
|
+
# paste_N.txt read-tool pointer for oversized ones) in #run_turn, the
|
|
73
|
+
# message-build seam. Shared across the per-turn composers, like
|
|
74
|
+
# #pending_queued; /clear-images never touches it (different inbox).
|
|
75
|
+
def paste_store
|
|
76
|
+
@paste_store ||= Rubino::UI::PasteStore.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def session_resolver
|
|
80
|
+
@session_resolver ||= Chat::SessionResolver.new(@options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def idle_cards
|
|
84
|
+
@idle_cards ||= Chat::IdleCardHost.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def bang_shell
|
|
88
|
+
@bang_shell ||= Chat::BangShell.new
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- One-shot mode ---
|
|
92
|
+
|
|
93
|
+
def run_oneshot(query)
|
|
94
|
+
apply_yolo! if opt(:yolo)
|
|
95
|
+
|
|
96
|
+
# Structured JSON log lines (llm.retry & friends) must never contaminate
|
|
97
|
+
# the one-shot stdout (#99): `answer=$(rubino prompt ...)` pipes stdout,
|
|
98
|
+
# so a warn event would interleave JSON noise with the answer. Route the
|
|
99
|
+
# logger to stderr for the whole one-shot run — the diagnostic twin of
|
|
100
|
+
# the interactive REPL's redirect-to-file (#125). Restored in the ensure
|
|
101
|
+
# so embedders/tests sharing the memoized logger are unaffected.
|
|
102
|
+
prev_log_io = redirect_logger_to_stderr
|
|
103
|
+
|
|
104
|
+
# Surface the resolved model (and any unknown-id warning) before the
|
|
105
|
+
# answer (#142). In one-shot mode there is no chat header, so without
|
|
106
|
+
# this a typo'd `-m` silently runs the wrong/forced-through model with
|
|
107
|
+
# zero feedback. Echo only when the user passed an explicit override so
|
|
108
|
+
# we don't add noise to the default-model happy path.
|
|
109
|
+
announce_resolved_model
|
|
110
|
+
|
|
111
|
+
# Seed --add-dir roots; one-shot mode is non-interactive so the trust
|
|
112
|
+
# prompt is skipped (an untrusted dir simply runs in restricted mode).
|
|
113
|
+
setup_workspace_and_trust!(Rubino.ui, interactive: false)
|
|
114
|
+
|
|
115
|
+
# Headless/scripted attachment: honour @image tokens in the prompt AND
|
|
116
|
+
# explicit --image PATH flags, both routed to the native vision slot
|
|
117
|
+
# (image_paths) — the same path the interactive REPL uses. Without this,
|
|
118
|
+
# `-q` / `prompt` / `chat "..."` had no way to attach an image at all
|
|
119
|
+
# (attachment was REPL-only); automation, jobs and tests can now drive it.
|
|
120
|
+
text, image_paths = Chat::ImageInbox.resolve_oneshot(query, opt(:image))
|
|
121
|
+
|
|
122
|
+
runner = build_runner(session_id: session_resolver.resolve_session_id, ui: UI::Null.new)
|
|
123
|
+
|
|
124
|
+
# Use run! (not run) so a model/credential failure PROPAGATES instead of
|
|
125
|
+
# being swallowed into a nil and printed as an empty line with exit 0.
|
|
126
|
+
# A brand-new user with no key would otherwise see ~80s of silent retries
|
|
127
|
+
# then an empty prompt and a success exit (#93) — here we surface the
|
|
128
|
+
# actionable error to stderr and exit non-zero so automation/the user can
|
|
129
|
+
# actually tell it failed.
|
|
130
|
+
announce_attachment_upload(image_paths)
|
|
131
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
132
|
+
response = runner.run!(text, image_paths: image_paths)
|
|
133
|
+
|
|
134
|
+
print_oneshot_answer(response.to_s)
|
|
135
|
+
$stdout.flush
|
|
136
|
+
|
|
137
|
+
# Fire the turn-finished attention seam for headless runs (#215). A
|
|
138
|
+
# scripted `rubino prompt`/-q run never goes through UI::CLI#turn_finished
|
|
139
|
+
# (it uses UI::Null), so the documented notifications.command hook —
|
|
140
|
+
# exactly what automation wants to ping a human on completion — never
|
|
141
|
+
# fired. Drive the same notifier here: the BELL self-suppresses into a
|
|
142
|
+
# pipe (bell_sink is nil off a TTY), so only the detached command hook
|
|
143
|
+
# actually does anything headless, which is the intent. Best-effort: a
|
|
144
|
+
# notification must never fail the run or contaminate the piped answer.
|
|
145
|
+
notify_oneshot_finished(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
146
|
+
# rubocop:disable Lint/ShadowedException -- Interrupt is listed explicitly (doc value), though SignalException covers it
|
|
147
|
+
rescue Rubino::Interrupted, Interrupt, SystemExit, SignalException
|
|
148
|
+
raise
|
|
149
|
+
# rubocop:enable Lint/ShadowedException
|
|
150
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
151
|
+
warn "rubino: #{e.message}"
|
|
152
|
+
exit(1)
|
|
153
|
+
ensure
|
|
154
|
+
restore_logger(prev_log_io)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# One deterministic status line before a request that carries attachments
|
|
158
|
+
# (#101): a multi-MB upload can stall for tens of seconds with zero
|
|
159
|
+
# feedback in one-shot mode. Goes to stderr so the piped stdout answer
|
|
160
|
+
# stays clean.
|
|
161
|
+
def announce_attachment_upload(image_paths)
|
|
162
|
+
return if image_paths.empty?
|
|
163
|
+
|
|
164
|
+
mb = (image_paths.sum { |p| File.size?(p).to_i } / 1_048_576.0).round(1)
|
|
165
|
+
label = image_paths.size == 1 ? "image" : "#{image_paths.size} images"
|
|
166
|
+
warn "sending #{label} (#{mb} MB)…"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Prints the one-shot answer. On a real TTY the answer goes through the
|
|
170
|
+
# SAME markdown pipeline interactive chat uses (UI::CLI#assistant_text →
|
|
171
|
+
# MarkdownRenderer: styled headings/bold/code, width-fit tables, wrapping)
|
|
172
|
+
# so `prompt`/-q doesn't dump literal `**`/`|---|` markdown at a human
|
|
173
|
+
# (#69). When stdout is NOT a TTY the raw text is kept byte-for-byte —
|
|
174
|
+
# `answer=$(rubino prompt ...)` and downstream tools want plain text, and
|
|
175
|
+
# diagnostics already route to stderr (#99) so the pipe stays clean.
|
|
176
|
+
def print_oneshot_answer(text)
|
|
177
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
178
|
+
UI::CLI.new.assistant_text(text)
|
|
179
|
+
else
|
|
180
|
+
$stdout.puts text
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Drives the turn-finished attention notifier after a one-shot run (#215),
|
|
185
|
+
# so the documented notifications.command hook fires for headless/scripted
|
|
186
|
+
# `rubino prompt` / -q completions too — the seam automation uses to ping a
|
|
187
|
+
# human. The notifier's own min_turn_seconds gate still applies (quick runs
|
|
188
|
+
# stay silent) and the bell self-suppresses into a pipe, so off a TTY only
|
|
189
|
+
# the detached command hook runs. Wholly best-effort: a notification detail
|
|
190
|
+
# must never fail the run.
|
|
191
|
+
def notify_oneshot_finished(elapsed)
|
|
192
|
+
UI::Notifier.new.turn_finished(elapsed)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Routes the structured logger to stderr for the one-shot run (#99).
|
|
198
|
+
# Returns the previous sink IO to restore on exit; nil (no-op) on failure —
|
|
199
|
+
# a logging-destination detail must never break the run.
|
|
200
|
+
def redirect_logger_to_stderr
|
|
201
|
+
Rubino.logger.reopen($stderr)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- Interactive mode ---
|
|
207
|
+
#
|
|
208
|
+
# One path for TTY and non-TTY: inline streaming to stdout.
|
|
209
|
+
# No fullscreen TUI. Native terminal scroll, copy, and shell
|
|
210
|
+
# history all keep working because we never leave the main screen.
|
|
211
|
+
|
|
212
|
+
def run_interactive
|
|
213
|
+
apply_yolo! if opt(:yolo)
|
|
214
|
+
|
|
215
|
+
ui = Rubino.ui
|
|
216
|
+
|
|
217
|
+
# Capture git context before creating runner (session not yet available)
|
|
218
|
+
git = git_context
|
|
219
|
+
|
|
220
|
+
ui.blank_line
|
|
221
|
+
ui.info("rubino")
|
|
222
|
+
ui.status("workspace #{collapse_home(Dir.pwd)}")
|
|
223
|
+
if git
|
|
224
|
+
dirty_mark = git[:dirty] ? " *" : ""
|
|
225
|
+
ui.status("branch #{git[:branch]}#{dirty_mark} @ #{git[:sha]}")
|
|
226
|
+
end
|
|
227
|
+
ui.status("model #{model_name}")
|
|
228
|
+
warn_unknown_model if model_override_given?
|
|
229
|
+
# Update-available notice (interactive only): one dim line, sourced
|
|
230
|
+
# purely from the local cache so it never slows boot. The network
|
|
231
|
+
# refresh below is detached/rescued and only freshens the cache for the
|
|
232
|
+
# NEXT boot. No-ops entirely until rubino-agent is published.
|
|
233
|
+
note = Rubino::UpdateCheck.notice_from_cache
|
|
234
|
+
ui.status(note) if note
|
|
235
|
+
Rubino::UpdateCheck.refresh_async_if_stale
|
|
236
|
+
ui.blank_line
|
|
237
|
+
|
|
238
|
+
# Seed --add-dir roots and run the folder-trust gate before any turn
|
|
239
|
+
# assembles a system prompt that could pull in an untrusted dir's
|
|
240
|
+
# AGENTS.md / skills.
|
|
241
|
+
setup_workspace_and_trust!(ui, interactive: true)
|
|
242
|
+
|
|
243
|
+
runner = build_runner(session_id: session_resolver.resolve_session_id(auto_resume: true), ui: ui)
|
|
244
|
+
|
|
245
|
+
# Scope tier-2 paste files under the CURRENT session's artifacts dir
|
|
246
|
+
# (<home>/sessions/<id>/paste_N.txt). The closure reads the local
|
|
247
|
+
# `runner` at write time, so /new //sessions //branch — which reassign
|
|
248
|
+
# it — re-scope the files without re-wiring.
|
|
249
|
+
paste_store.session_source = -> { runner.session[:id] }
|
|
250
|
+
|
|
251
|
+
# The runner already announced the session ("New/Resuming session: <id>");
|
|
252
|
+
# re-printing the full uuid here was the third copy of the same id on boot
|
|
253
|
+
# (#82). The short id is enough; the full one lives in /status.
|
|
254
|
+
|
|
255
|
+
# Best-effort: a closed terminal / kill marks the session ended too (#100).
|
|
256
|
+
prev_signal_traps = install_session_end_traps(runner)
|
|
257
|
+
|
|
258
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
259
|
+
cmd_loader = Rubino::Commands::Loader.new
|
|
260
|
+
|
|
261
|
+
# The bottom composer is now the SINGLE input path (idle AND in-turn): one
|
|
262
|
+
# pinned-bottom editor with full editing parity, so output/reasoning/
|
|
263
|
+
# footers commit ABOVE the prompt and out-of-band keys can't smear the
|
|
264
|
+
# stream. Build its shared completion source + history once; #next_input
|
|
265
|
+
# routes the idle prompt through a composer wired with them. A plain
|
|
266
|
+
# cooked readline remains the fallback for non-TTY / piped / -q input.
|
|
267
|
+
@completion_source = Chat::CompletionBuilder.new(cmd_loader).build
|
|
268
|
+
@input_history = Rubino::UI::InputHistory.new
|
|
269
|
+
|
|
270
|
+
if session_resolver.resuming_session?
|
|
271
|
+
# On a bare-chat auto-resume (#99) tell the user, clearly and once,
|
|
272
|
+
# that we picked up their last session and how to start fresh —
|
|
273
|
+
# otherwise the continuation is silent and looks like a fresh boot.
|
|
274
|
+
session_resolver.print_auto_resume_line(ui, runner.session) if session_resolver.auto_resumed_session
|
|
275
|
+
session_resolver.print_session_history(ui, runner.session[:id])
|
|
276
|
+
else
|
|
277
|
+
# First-run welcome panel: the same assembler /status uses, trimmed.
|
|
278
|
+
Rubino::Commands::Executor.welcome(runner: runner, ui: ui)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# `chat --image/-i` without -q: stage the flag paths into the SAME
|
|
282
|
+
# pending-attachment inbox @image tokens, /paste and dropped paths fill
|
|
283
|
+
# (#160) — the flag used to be consumed only by the one-shot path, so
|
|
284
|
+
# in interactive mode it was silently dropped.
|
|
285
|
+
stage_flag_images(ui)
|
|
286
|
+
|
|
287
|
+
# Steering: lines the user types *during* a turn are captured by the
|
|
288
|
+
# background reader (see #run_turn) and parked here. At the next turn
|
|
289
|
+
# boundary we drain them and they become the next prompt, so a message
|
|
290
|
+
# typed while the agent was working is answered as the next turn with
|
|
291
|
+
# no copy/paste — instead of blocking on a fresh readline.
|
|
292
|
+
input_queue = Rubino::Interaction::InputQueue.new
|
|
293
|
+
|
|
294
|
+
# Drive the turn-scoped status row from bus events the UI doesn't see
|
|
295
|
+
# directly: MESSAGE_COMPLETED (a streamed block ended — commit its tail
|
|
296
|
+
# and resume the row between blocks, the P4 inter-tool gap) and
|
|
297
|
+
# JOB_STARTED/JOB_FINISHED (the post-turn inline jobs spending aux-LLM
|
|
298
|
+
# seconds after the footer — the P6 "polishing" phase). Both arrive on
|
|
299
|
+
# the process-global bus the interactive runner and the inline job
|
|
300
|
+
# runner emit on. Best-effort: a UI hiccup must never fail the source.
|
|
301
|
+
subscribe_status_row_events(ui)
|
|
302
|
+
|
|
303
|
+
# Reset the shared explicit-queue stack for this interactive session (see
|
|
304
|
+
# #pending_queued): live "⏳ queued: <msg>" rows the composers render and
|
|
305
|
+
# the loop commits as normal messages when their turn runs.
|
|
306
|
+
@pending_queued = []
|
|
307
|
+
|
|
308
|
+
# Keep structured JSON log lines OUT of the raw-mode TUI (#125): for the
|
|
309
|
+
# whole interactive session the logger writes to a file in the logs dir
|
|
310
|
+
# instead of the terminal $stdout the renderer owns. A warn/info event
|
|
311
|
+
# (e.g. a network blip while a background subagent runs) would otherwise
|
|
312
|
+
# be dumped as raw JSON into the rendered conversation, corrupting the
|
|
313
|
+
# bottom-composer frame. Restored on teardown. Logs are not lost — they
|
|
314
|
+
# go to the file.
|
|
315
|
+
prev_log_io = redirect_logger_to_file
|
|
316
|
+
|
|
317
|
+
interacted = false
|
|
318
|
+
begin
|
|
319
|
+
loop do
|
|
320
|
+
input = next_input(input_queue, runner)
|
|
321
|
+
# Esc-Esc rewind: the idle read forked the session at the picked
|
|
322
|
+
# message and parked the fork's runner — adopt it BEFORE dispatch
|
|
323
|
+
# so the edited message runs as the next turn on the fork (the
|
|
324
|
+
# same swap-in-place /branch and /compact do).
|
|
325
|
+
if (rewound = @rewound_runner)
|
|
326
|
+
@rewound_runner = nil
|
|
327
|
+
runner = rewound
|
|
328
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
329
|
+
end
|
|
330
|
+
if input.nil? || exit_command?(input)
|
|
331
|
+
break if confirm_quit?(ui)
|
|
332
|
+
|
|
333
|
+
next
|
|
334
|
+
end
|
|
335
|
+
next if input.strip.empty?
|
|
336
|
+
|
|
337
|
+
input = input.strip
|
|
338
|
+
|
|
339
|
+
# Image-input commands manipulate the pending-attachment state local
|
|
340
|
+
# to this REPL (not the agent), so they're handled here before the
|
|
341
|
+
# slash dispatcher. `/paste` grabs a clipboard image; `/clear-images`
|
|
342
|
+
# drops anything queued.
|
|
343
|
+
if image_inbox.handle_image_command(input, ui)
|
|
344
|
+
commit_queued_dispatch
|
|
345
|
+
next
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Pull any image references (@image, dropped/quoted path) out of the
|
|
349
|
+
# line into image_paths (the native vision slot); the rest stays text.
|
|
350
|
+
# An image-only line STAGES the attachment instead of submitting an
|
|
351
|
+
# empty turn (#100): the in-prompt hint promises a "sent with your
|
|
352
|
+
# next message (/clear-images to drop)" window, so honour it for
|
|
353
|
+
# @image/dropped paths the same as /paste — the image goes out with
|
|
354
|
+
# the next message that carries text.
|
|
355
|
+
input = image_inbox.extract_images!(input, ui)
|
|
356
|
+
if input.empty?
|
|
357
|
+
commit_queued_dispatch
|
|
358
|
+
next
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# A leading `? ` is the one-keystroke ephemeral probe (Option A of
|
|
362
|
+
# the locked UX): the rest of the line is a side-question answered
|
|
363
|
+
# from the session context, rendered in a dim aside, then DISCARDED
|
|
364
|
+
# — nothing is written to the transcript. Handled BEFORE slash
|
|
365
|
+
# dispatch so `? /foo` is still a probe about a literal `/foo`.
|
|
366
|
+
if (question = probe_question(input))
|
|
367
|
+
commit_queued_dispatch
|
|
368
|
+
run_probe(runner, question, ui)
|
|
369
|
+
next
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# A leading `!` is the human shell escape (Claude Code's bash
|
|
373
|
+
# mode): run the rest of the line in the user's shell NOW — no
|
|
374
|
+
# approval, the human typed it — stream the output into the
|
|
375
|
+
# transcript, then inject command + output into the session as
|
|
376
|
+
# user-role <bash-input>/<bash-stdout><bash-stderr> messages so
|
|
377
|
+
# the model can reference them next turn. Handled BEFORE slash
|
|
378
|
+
# dispatch so `!` always wins. :ran counts as interaction (the
|
|
379
|
+
# session now has messages worth a resume hint); a bare-`!`
|
|
380
|
+
# usage line (:handled) does not.
|
|
381
|
+
case bang_shell.handle(input, runner, ui)
|
|
382
|
+
when :ran
|
|
383
|
+
interacted = true
|
|
384
|
+
commit_queued_dispatch
|
|
385
|
+
next
|
|
386
|
+
when :handled
|
|
387
|
+
commit_queued_dispatch
|
|
388
|
+
next
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
if input.start_with?("/")
|
|
392
|
+
# A dequeued line that resolves to a SLASH COMMAND never reaches
|
|
393
|
+
# #run_turn, so #commit_queued_prompt would never fire for it and
|
|
394
|
+
# its live "⏳ queued:" row would leak across later prompts
|
|
395
|
+
# (#192). Commit it here — echo + drop the indicator — before the
|
|
396
|
+
# command runs, whatever the dispatch result is.
|
|
397
|
+
commit_queued_dispatch
|
|
398
|
+
result = cmd_executor.try_execute(input)
|
|
399
|
+
case result
|
|
400
|
+
when :exit then break
|
|
401
|
+
when :handled then next
|
|
402
|
+
when Hash
|
|
403
|
+
if result[:probe]
|
|
404
|
+
# /probe <text>: same ephemeral side-inference as the `? `
|
|
405
|
+
# prefix, then discard. The teaching-only bare /probe returned
|
|
406
|
+
# :handled above, so this always carries a question.
|
|
407
|
+
run_probe(runner, result[:probe], ui)
|
|
408
|
+
next
|
|
409
|
+
end
|
|
410
|
+
if result[:branch]
|
|
411
|
+
# /branch [name]: fork the current session here into a new
|
|
412
|
+
# saved one (inheriting context + any preceding probe) and
|
|
413
|
+
# SWITCH into it, leaving the original intact.
|
|
414
|
+
runner = branch_runner(ui, runner, result[:title])
|
|
415
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
416
|
+
next
|
|
417
|
+
end
|
|
418
|
+
if result[:resume_session_id]
|
|
419
|
+
# /sessions <id|title>: rebuild the runner on the chosen
|
|
420
|
+
# session in place and replay its history, then go back to the
|
|
421
|
+
# prompt — no process restart needed. Leaving a branch (e.g.
|
|
422
|
+
# back to the parent) drops the branch token from the status bar.
|
|
423
|
+
@branch_short_id = nil
|
|
424
|
+
runner = resume_runner(ui, result[:resume_session_id])
|
|
425
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
426
|
+
next
|
|
427
|
+
end
|
|
428
|
+
if result[:compact_into]
|
|
429
|
+
# /compact: the compactor wrote head+summary+tail into a
|
|
430
|
+
# child session (the source is now status "compacted") —
|
|
431
|
+
# swap the runner into the child WITHOUT replaying history,
|
|
432
|
+
# so the next turn runs on the compacted context.
|
|
433
|
+
runner = build_runner(session_id: result[:compact_into], ui: ui)
|
|
434
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
435
|
+
next
|
|
436
|
+
end
|
|
437
|
+
if result[:new_session]
|
|
438
|
+
# /new: end the current session and rebuild the runner on a
|
|
439
|
+
# fresh one in place — the counterpart to the bare-chat resume.
|
|
440
|
+
@branch_short_id = nil
|
|
441
|
+
runner.end_session!
|
|
442
|
+
runner = fresh_runner(ui)
|
|
443
|
+
cmd_executor = Rubino::Commands::Executor.new(ui: ui, runner: runner)
|
|
444
|
+
interacted = false
|
|
445
|
+
next
|
|
446
|
+
end
|
|
447
|
+
interacted = true
|
|
448
|
+
run_turn(runner, result[:prompt], ui, input_queue)
|
|
449
|
+
else interacted = true
|
|
450
|
+
run_turn(runner, input, ui, input_queue)
|
|
451
|
+
end
|
|
452
|
+
else
|
|
453
|
+
interacted = true
|
|
454
|
+
run_turn(runner, input, ui, input_queue)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
rescue Interrupt
|
|
458
|
+
# A double-tap Ctrl+C inside run_turn re-raises to break out of the
|
|
459
|
+
# REPL — exit cleanly instead of dumping a signal backtrace.
|
|
460
|
+
ensure
|
|
461
|
+
restore_signal_traps(prev_signal_traps)
|
|
462
|
+
restore_logger(prev_log_io)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Mark the session ended on a clean teardown (#100) so it stops showing
|
|
466
|
+
# as "active" forever and cleanup/--continue can tell finished from live.
|
|
467
|
+
runner.end_session!
|
|
468
|
+
|
|
469
|
+
ui.blank_line
|
|
470
|
+
ui.info("Session ended.")
|
|
471
|
+
session_resolver.print_resume_hint(ui, runner.session) if interacted
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Best-effort: on a terminal close (SIGHUP) or kill (SIGTERM) mark the
|
|
475
|
+
# current session ended too, so a closed window doesn't leave it looking
|
|
476
|
+
# active (#100). The handler must stay trap-safe — one synchronous DB
|
|
477
|
+
# update then exit; no I/O, no locking. Returns the previous handlers so
|
|
478
|
+
# they can be restored on the normal exit path. nil for signals this
|
|
479
|
+
# platform doesn't define (e.g. SIGHUP on Windows).
|
|
480
|
+
def install_session_end_traps(runner)
|
|
481
|
+
%w[HUP TERM].each_with_object({}) do |sig, prev|
|
|
482
|
+
next unless Signal.list.key?(sig)
|
|
483
|
+
|
|
484
|
+
prev[sig] = Signal.trap(sig) do
|
|
485
|
+
runner.end_session!
|
|
486
|
+
exit(0)
|
|
487
|
+
end
|
|
488
|
+
rescue ArgumentError
|
|
489
|
+
nil # signal not supported on this platform
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def restore_signal_traps(prev)
|
|
494
|
+
return unless prev
|
|
495
|
+
|
|
496
|
+
prev.each { |sig, handler| Signal.trap(sig, handler || "DEFAULT") }
|
|
497
|
+
rescue ArgumentError
|
|
498
|
+
nil
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Install the idle-prompt SIGINT trap (BH-2). The block is the whole
|
|
502
|
+
# handler body and MUST be trap-safe — the caller passes one that only
|
|
503
|
+
# flips a plain flag (no Mutex, no I/O), exactly like the during-turn INT
|
|
504
|
+
# trap. Returns the previous handler so #restore_idle_int can put it back.
|
|
505
|
+
# nil (no trap installed) on a platform without SIGINT.
|
|
506
|
+
def trap_idle_int(&)
|
|
507
|
+
Signal.trap("INT", &)
|
|
508
|
+
rescue ArgumentError
|
|
509
|
+
nil
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Restore whatever INT handler was in place before the idle read armed its
|
|
513
|
+
# own (the session-end / default handler), so the trap never leaks past the
|
|
514
|
+
# idle prompt into a turn (which installs its own double-tap INT trap).
|
|
515
|
+
def restore_idle_int(prev)
|
|
516
|
+
Signal.trap("INT", prev || "DEFAULT")
|
|
517
|
+
rescue ArgumentError
|
|
518
|
+
nil
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Routes the structured logger to a file for the interactive session so
|
|
522
|
+
# JSON log lines never reach the terminal $stdout the TUI renders into
|
|
523
|
+
# (#125). Returns the previous sink IO to restore on exit; nil (no-op,
|
|
524
|
+
# logger untouched) if the file can't be opened — a logging-destination
|
|
525
|
+
# detail must never break the chat boot.
|
|
526
|
+
def redirect_logger_to_file
|
|
527
|
+
dir = File.expand_path(Rubino.configuration.dig("paths", "logs") || "~/.rubino/logs")
|
|
528
|
+
FileUtils.mkdir_p(dir)
|
|
529
|
+
file = File.open(File.join(dir, "rubino.log"), "a") # rubocop:disable Style/FileOpen -- the sink must outlive this method
|
|
530
|
+
file.sync = true
|
|
531
|
+
Rubino.logger.reopen(file)
|
|
532
|
+
rescue StandardError
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Restores the logger's sink to whatever it was before the interactive
|
|
537
|
+
# session redirected it (typically $stdout). No-op when redirection was
|
|
538
|
+
# skipped (prev nil).
|
|
539
|
+
def restore_logger(prev)
|
|
540
|
+
return unless prev
|
|
541
|
+
|
|
542
|
+
Rubino.logger.reopen(prev)
|
|
543
|
+
rescue StandardError
|
|
544
|
+
nil
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Relays bus events into the turn-scoped status row. Subscribed once per
|
|
548
|
+
# interactive session on the process-global bus:
|
|
549
|
+
# MESSAGE_COMPLETED — the adapter closed one streamed content block;
|
|
550
|
+
# the UI commits the block's tail and resumes the row so the gap
|
|
551
|
+
# until the next tool/block isn't dead air (P4). Subagents run on
|
|
552
|
+
# their own per-task bus, so their blocks never reach this listener.
|
|
553
|
+
# JOB_STARTED/JOB_FINISHED — the inline post-turn jobs (memory extract,
|
|
554
|
+
# skill distill); the row shows "polishing · memory|skills" (P6).
|
|
555
|
+
# Every callback is fully rescued: a cosmetic repaint failure must never
|
|
556
|
+
# bubble into the emitter (it would fail the job / abort the stream).
|
|
557
|
+
def subscribe_status_row_events(ui)
|
|
558
|
+
return if @status_row_subscribed
|
|
559
|
+
|
|
560
|
+
@status_row_subscribed = true
|
|
561
|
+
bus = Rubino.event_bus
|
|
562
|
+
bus.on(Rubino::Interaction::Events::MESSAGE_COMPLETED) do |payload|
|
|
563
|
+
ui.stream_block_end(payload[:message_id]) if ui.respond_to?(:stream_block_end)
|
|
564
|
+
rescue StandardError
|
|
565
|
+
nil
|
|
566
|
+
end
|
|
567
|
+
bus.on(Rubino::Interaction::Events::JOB_STARTED) do |payload|
|
|
568
|
+
ui.job_started(payload[:type]) if ui.respond_to?(:job_started)
|
|
569
|
+
rescue StandardError
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
572
|
+
bus.on(Rubino::Interaction::Events::JOB_FINISHED) do |payload|
|
|
573
|
+
ui.job_finished(payload[:type]) if ui.respond_to?(:job_finished)
|
|
574
|
+
rescue StandardError
|
|
575
|
+
nil
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Shared stack of EXPLICITLY-queued messages (Alt+Enter / "/queued"),
|
|
580
|
+
# rendered as live "⏳ queued: <msg>" rows above whichever composer is
|
|
581
|
+
# current (idle or in-turn) and removed — the item committed as a normal
|
|
582
|
+
# "<prompt><msg>" message — when its turn actually runs (see #run_turn).
|
|
583
|
+
# Memoized so it survives the per-turn composer teardown AND so unit tests
|
|
584
|
+
# that drive #read_idle_line / #start_composer directly (without going
|
|
585
|
+
# through #run_interactive) still get a real list, not nil.
|
|
586
|
+
def pending_queued
|
|
587
|
+
@pending_queued ||= []
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Next prompt for the REPL. If the user typed while the previous turn
|
|
591
|
+
# ran, those lines were parked in the InputQueue; consume them as the
|
|
592
|
+
# next prompt INSTEAD of blocking on a fresh readline. Each parked line is
|
|
593
|
+
# taken ONE at a time (FIFO) and run as its OWN turn (B4) — an
|
|
594
|
+
# interrupt-by-default Enter, an Alt+Enter, or a "/queued" each get their
|
|
595
|
+
# own turn in submission order, never coalesced into a single
|
|
596
|
+
# newline-joined message. The remaining queued items stay parked (their
|
|
597
|
+
# "⏳ queued:" indicators remain) and each runs on a later #next_input.
|
|
598
|
+
# When nothing is queued, fall back to the normal readline prompt.
|
|
599
|
+
# +runner+ (optional) feeds the status bar under the idle composer —
|
|
600
|
+
# model id + context saturation for the CURRENT session, refreshed at
|
|
601
|
+
# this turn boundary (and so on session resume/branch/new too, which all
|
|
602
|
+
# rebuild the runner before the next idle prompt).
|
|
603
|
+
# Pops any text the user typed during a synchronous /probe wait (#221),
|
|
604
|
+
# parked on the UI by ProbeWaitIndicator. nil on adapters that don't stash.
|
|
605
|
+
def probe_draft_stash
|
|
606
|
+
ui = Rubino.ui
|
|
607
|
+
ui.take_probe_draft if ui.respond_to?(:take_probe_draft)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def next_input(input_queue, runner = nil)
|
|
611
|
+
# Take the OLDEST parked line (FIFO). Mark it so #run_turn commits the
|
|
612
|
+
# normal "<prompt><line>" echo (and clears any "⏳ queued:" indicator)
|
|
613
|
+
# when this line runs. The rest stay queued for their own later turns.
|
|
614
|
+
queued = input_queue.shift
|
|
615
|
+
unless queued.nil?
|
|
616
|
+
@input_from_queue = [queued]
|
|
617
|
+
return queued
|
|
618
|
+
end
|
|
619
|
+
@input_from_queue = nil
|
|
620
|
+
|
|
621
|
+
# Carry over any draft the user typed into the bottom composer during the
|
|
622
|
+
# previous turn but never submitted (no Enter): the turn-scoped composer
|
|
623
|
+
# is torn down at turn end, so without this the in-progress text would
|
|
624
|
+
# vanish. Consume it once — the next idle prompt starts empty again.
|
|
625
|
+
draft = @pending_draft
|
|
626
|
+
@pending_draft = nil
|
|
627
|
+
# A synchronous /probe wait owned a transient composer to echo input
|
|
628
|
+
# (#221); anything typed there was parked on the UI and is restored into
|
|
629
|
+
# this prompt's draft so it reappears in `❯` after the peek.
|
|
630
|
+
if (probe_draft = probe_draft_stash) && !probe_draft.empty?
|
|
631
|
+
draft = draft.to_s.empty? ? probe_draft : "#{draft}#{probe_draft}"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# The bottom composer is the single idle input path on a real TTY: it
|
|
635
|
+
# pins the prompt at the bottom, owns its own raw reader (so keys can't
|
|
636
|
+
# smear the stream), updates the status bar's mode token LIVE on
|
|
637
|
+
# Shift+Tab, and hosts
|
|
638
|
+
# the background-subagent card region (F1) when children are live. The
|
|
639
|
+
# plain cooked readline is the fallback for non-TTY / piped / -q input.
|
|
640
|
+
if UI::BottomComposer.active?
|
|
641
|
+
read_idle_line(input_queue, draft, runner)
|
|
642
|
+
else
|
|
643
|
+
cooked_input(build_prompt, draft)
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Reads the user's next line at the IDLE prompt through the bottom composer
|
|
648
|
+
# — the single input path. The composer pins the prompt at the bottom and
|
|
649
|
+
# owns its own raw reader (full editing parity: arrows/Home/End/word-jump,
|
|
650
|
+
# ↑↓ history, /command + @file completion menu with immediate-Esc dismiss,
|
|
651
|
+
# cyan token highlight), updates the status bar's mode token LIVE on
|
|
652
|
+
# Shift+Tab, reveals
|
|
653
|
+
# reasoning on Ctrl+O, and hosts the collapsed subagent card region (F1)
|
|
654
|
+
# when background children are live — repaints land above the prompt and
|
|
655
|
+
# update in place, serialized through the composer's render mutex.
|
|
656
|
+
#
|
|
657
|
+
# We seed the carried-over draft, then BLOCK until the user submits a line,
|
|
658
|
+
# polling the same InputQueue the composer's reader pushes into (reusing the
|
|
659
|
+
# turn loop's hand-off). A half-typed, un-submitted draft is preserved in
|
|
660
|
+
# @pending_draft on teardown so it survives into the next prompt.
|
|
661
|
+
def read_idle_line(input_queue, draft, runner = nil)
|
|
662
|
+
# Esc-Esc rewind flag, flipped from the composer's reader thread and
|
|
663
|
+
# drained by the poll loop below — the same trap-safe split the idle
|
|
664
|
+
# Ctrl+C uses (the hook must never take the render mutex over there).
|
|
665
|
+
# Declared BEFORE the composer so the lambda captures this local.
|
|
666
|
+
# Without a runner there is no session to rewind, so no hook.
|
|
667
|
+
rewind_pending = false
|
|
668
|
+
composer = UI::BottomComposer.new(
|
|
669
|
+
input_queue: input_queue,
|
|
670
|
+
prompt: build_prompt,
|
|
671
|
+
rail: composer_rail,
|
|
672
|
+
on_ctrl_o: ctrl_o_handler,
|
|
673
|
+
on_mode_cycle: mode_cycle_handler(runner),
|
|
674
|
+
completion_source: @completion_source,
|
|
675
|
+
history: @input_history,
|
|
676
|
+
echo: :prompt,
|
|
677
|
+
pending_queued: pending_queued,
|
|
678
|
+
status_line: build_status_line(runner),
|
|
679
|
+
max_input_rows: Rubino.configuration.display_input_max_rows,
|
|
680
|
+
paste_store: paste_store,
|
|
681
|
+
on_double_esc: runner ? -> { rewind_pending = true } : nil
|
|
682
|
+
)
|
|
683
|
+
composer.start
|
|
684
|
+
# Route $stdout through the composer for the whole idle read — the SAME
|
|
685
|
+
# StdoutProxy swap a turn gets — so anything printed while the idle
|
|
686
|
+
# prompt is pinned (a background subagent's completion note, a late
|
|
687
|
+
# status line) commits ABOVE the input under the composer's render
|
|
688
|
+
# mutex instead of raw-painting over the prompt row (#169). The logger
|
|
689
|
+
# is forced to bind to the real IO first, exactly as in #start_composer.
|
|
690
|
+
real_stdout = $stdout
|
|
691
|
+
Rubino.logger
|
|
692
|
+
$stdout = UI::StdoutProxy.new(composer)
|
|
693
|
+
seed_draft(composer, draft)
|
|
694
|
+
idle_cards.paint
|
|
695
|
+
ticker = idle_cards.children_live? ? idle_cards.start_ticker(composer) : nil
|
|
696
|
+
|
|
697
|
+
# Gate idle Ctrl+C through the composer (BH-2): the composer runs under
|
|
698
|
+
# raw(intr: true), so a single Ctrl+C still raises SIGINT — which would
|
|
699
|
+
# otherwise hit the session-end / default handler and quit, silently
|
|
700
|
+
# discarding a typed draft. Trap INT here so a draft is never nuked: the
|
|
701
|
+
# trap body stays trap-safe (flip a flag only — Mutex#lock is forbidden
|
|
702
|
+
# in a trap, Ruby #14222), and the poll loop below performs the actual
|
|
703
|
+
# clear/hint/exit through the composer OUTSIDE trap context. Restored in
|
|
704
|
+
# the ensure so the trap never leaks past the idle read.
|
|
705
|
+
int_pending = false
|
|
706
|
+
prev_int = trap_idle_int { int_pending = true }
|
|
707
|
+
|
|
708
|
+
line = nil
|
|
709
|
+
loop do
|
|
710
|
+
# Drained the idle Ctrl+C the trap recorded: clear the draft (non-empty)
|
|
711
|
+
# or arm/confirm the two-tap exit (empty). Done here, not in the trap,
|
|
712
|
+
# so the render mutex is safe.
|
|
713
|
+
if int_pending
|
|
714
|
+
int_pending = false
|
|
715
|
+
break if composer.idle_interrupt(window: DOUBLE_TAP_SECONDS) == :exit
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Take ONE parked line (FIFO) so several items queued at idle each run
|
|
719
|
+
# as their OWN turn (B4), in submission order — never coalesced. The
|
|
720
|
+
# rest stay parked for the next #next_input / loop pass. Checked
|
|
721
|
+
# BEFORE the rewind flag: a line the user already submitted wins over
|
|
722
|
+
# an Esc-Esc that raced it (the pending rewind dies with the break —
|
|
723
|
+
# a picker must never pop over a turn that is about to start).
|
|
724
|
+
queued = input_queue.shift
|
|
725
|
+
unless queued.nil?
|
|
726
|
+
# An idle plain submit already echoed "<prompt><line>" at submit time;
|
|
727
|
+
# only an EXPLICITLY-queued item (Alt+Enter / "/queued" at idle, which
|
|
728
|
+
# carries a "⏳ queued:" indicator and no echo yet) needs run_turn to
|
|
729
|
+
# commit it as a normal message. Flag just that so a plain submit is
|
|
730
|
+
# never double-echoed.
|
|
731
|
+
@input_from_queue = pending_queued.include?(queued) ? [queued] : nil
|
|
732
|
+
line = queued
|
|
733
|
+
break
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Drain an Esc-Esc the reader recorded: open the rewind picker (it
|
|
737
|
+
# suspends the composer via run_in_terminal, so it must run on THIS
|
|
738
|
+
# thread, never the reader's). A pick forks the session, parks the
|
|
739
|
+
# fork in @rewound_runner for the REPL to adopt, and pre-fills the
|
|
740
|
+
# composer with the picked message; Esc-cancel changes nothing.
|
|
741
|
+
if rewind_pending
|
|
742
|
+
rewind_pending = false
|
|
743
|
+
if (rewound = handle_rewind(composer, runner, Rubino.ui))
|
|
744
|
+
runner = rewound
|
|
745
|
+
@rewound_runner = rewound
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
sleep(0.05)
|
|
749
|
+
end
|
|
750
|
+
line
|
|
751
|
+
ensure
|
|
752
|
+
restore_idle_int(prev_int)
|
|
753
|
+
ticker&.kill
|
|
754
|
+
ticker&.join
|
|
755
|
+
# Mirror #stop_composer: restore the real $stdout, then flush any held
|
|
756
|
+
# partial line through the still-live composer before tearing it down.
|
|
757
|
+
if real_stdout
|
|
758
|
+
proxy = $stdout
|
|
759
|
+
$stdout = real_stdout
|
|
760
|
+
proxy.finish if proxy.respond_to?(:finish)
|
|
761
|
+
end
|
|
762
|
+
if composer
|
|
763
|
+
pending = composer.buffer.to_s
|
|
764
|
+
@pending_draft = pending unless pending.strip.empty?
|
|
765
|
+
end
|
|
766
|
+
composer&.stop
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
# Seed a carried-over draft into the composer char-by-char so cursor/delete
|
|
770
|
+
# stay codepoint-granular (handle_key edits one codepoint at a time).
|
|
771
|
+
def seed_draft(composer, draft)
|
|
772
|
+
return if draft.nil? || draft.to_s.empty?
|
|
773
|
+
|
|
774
|
+
draft.to_s.each_char { |c| composer.handle_key(c) }
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Plain cooked prompt for non-TTY / piped / scripted interactive input,
|
|
778
|
+
# where the raw-mode composer can't run. Prints the prompt, reads one line,
|
|
779
|
+
# and pre-pends any carried-over draft so it isn't lost. nil on EOF.
|
|
780
|
+
def cooked_input(prompt, draft)
|
|
781
|
+
$stdout.print(prompt)
|
|
782
|
+
$stdout.flush
|
|
783
|
+
line = $stdin.gets
|
|
784
|
+
return nil if line.nil?
|
|
785
|
+
|
|
786
|
+
line = line.chomp
|
|
787
|
+
draft && !draft.to_s.empty? ? "#{draft}#{line}" : line
|
|
788
|
+
rescue IOError
|
|
789
|
+
nil
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# Seeds the interactive pending-images inbox from --image/-i flag paths
|
|
793
|
+
# (#160); the attachment gate + indicator live in Chat::ImageInbox.
|
|
794
|
+
def stage_flag_images(ui)
|
|
795
|
+
image_inbox.stage_flag_images(opt(:image), ui)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Wraps a single turn: Ctrl+C cancels the in-flight generation and
|
|
799
|
+
# drops back to the prompt, instead of killing the session.
|
|
800
|
+
#
|
|
801
|
+
# Aider-style double-tap (also how Codex/Claude Code behave): the first
|
|
802
|
+
# INT during a turn cooperatively cancels and prints a hint; a second
|
|
803
|
+
# INT within DOUBLE_TAP_SECONDS exits. The trap body must be trap-safe —
|
|
804
|
+
# it only flips the mutex-free CancelToken (see CancelToken: Mutex#lock
|
|
805
|
+
# is forbidden in a trap context, Ruby bug #14222) and reads/writes plain
|
|
806
|
+
# locals; no locking, no I/O, no re-entrant trap. The previous handler is
|
|
807
|
+
# always restored in +ensure+.
|
|
808
|
+
#
|
|
809
|
+
# Steering: when +input_queue+ is given and both ends are a TTY, a
|
|
810
|
+
# bottom-pinned composer (UI::BottomComposer) runs alongside the turn so
|
|
811
|
+
# the user can TYPE — with visible echo and backspace — while agent output
|
|
812
|
+
# streams ABOVE the input line into native scrollback. Completed lines are
|
|
813
|
+
# parked in the queue and picked up by the agent loop at the next ITERATION
|
|
814
|
+
# boundary (Phase 2 — between tool steps, never mid-tool); anything still
|
|
815
|
+
# queued after the turn ends falls back to #next_input as the next turn
|
|
816
|
+
# (the MVP boundary).
|
|
817
|
+
#
|
|
818
|
+
# Output coordination: while the composer is live, $stdout is swapped for a
|
|
819
|
+
# UI::StdoutProxy so the existing $stdout.print/puts call sites across
|
|
820
|
+
# UI::CLI / PrinterBase route their output through the composer's
|
|
821
|
+
# print_above instead of clobbering the input line — zero changes to those
|
|
822
|
+
# call sites. The proxy is torn down and the terminal restored to cooked
|
|
823
|
+
# mode in +ensure+ so raw mode / the swap never leak on a raise.
|
|
824
|
+
def run_turn(runner, prompt, ui, input_queue = nil)
|
|
825
|
+
# A real turn has happened, so any prior probe is no longer the
|
|
826
|
+
# "immediately-preceding interaction" — a later /branch must NOT fold it
|
|
827
|
+
# into the seed. Clear it here, the single chokepoint for real turns.
|
|
828
|
+
@last_probe = nil
|
|
829
|
+
|
|
830
|
+
# Consume the turn's queued image attachments (the native vision slot)
|
|
831
|
+
# so they're attached exactly once, not re-sent next turn.
|
|
832
|
+
image_paths = image_inbox.take!
|
|
833
|
+
|
|
834
|
+
# The message-build seam of the paste pipeline: COLLECT each
|
|
835
|
+
# "[Pasted text #N +M lines]" placeholder's full body (or the paste_N.txt
|
|
836
|
+
# read-tool pointer for oversized ones) WITHOUT mutating the prompt. The
|
|
837
|
+
# placeholder stays in the prompt — the message PERSISTED to the session
|
|
838
|
+
# keeps it, so live echo AND resume replay show the compact token (#213)
|
|
839
|
+
# — while the expansion map rides alongside as metadata, expanded into
|
|
840
|
+
# the MODEL-FACING content by Message#to_context. Queued (Alt+Enter) and
|
|
841
|
+
# history-recalled drafts collect here too, whichever turn they run as.
|
|
842
|
+
paste_expansions = paste_store.expansions_in(prompt)
|
|
843
|
+
|
|
844
|
+
# The interim idle-key GATE is retired: the bottom composer is now the
|
|
845
|
+
# single input path and serializes every above-line write through its
|
|
846
|
+
# render mutex, so Shift+Tab (mode footer) and Ctrl+O (reveal reasoning)
|
|
847
|
+
# commit cleanly ABOVE the pinned prompt even DURING a turn — no
|
|
848
|
+
# out-of-band $stdout race to smear the stream (the old D1/D3/D4 cause).
|
|
849
|
+
last_int_at = nil
|
|
850
|
+
in_trap = false
|
|
851
|
+
|
|
852
|
+
prev = Signal.trap("INT") do
|
|
853
|
+
# Guard against trap re-entrancy: a burst of signals must not stack.
|
|
854
|
+
unless in_trap
|
|
855
|
+
in_trap = true
|
|
856
|
+
begin
|
|
857
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
858
|
+
raise Interrupt if last_int_at && (now - last_int_at) <= DOUBLE_TAP_SECONDS
|
|
859
|
+
|
|
860
|
+
# Second tap in the window: raise to the main thread so the
|
|
861
|
+
# REPL unwinds and exits — a real Ctrl+C now quits.
|
|
862
|
+
|
|
863
|
+
last_int_at = now
|
|
864
|
+
runner.cancel!
|
|
865
|
+
# The runner commits the standardized dim "⎿ interrupted" marker
|
|
866
|
+
# once it unwinds the cancelled turn; here we only add the
|
|
867
|
+
# actionable double-tap hint so the two don't restate the same
|
|
868
|
+
# "interrupted" wording (L10). Single ASCII write —
|
|
869
|
+
# async-signal-safe enough for a trap.
|
|
870
|
+
$stderr.write("\n(press Ctrl+C again to exit)\n")
|
|
871
|
+
ensure
|
|
872
|
+
in_trap = false
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Stale-flag guard (#111): a quiet suppression armed by a prior turn
|
|
878
|
+
# that completed before observing its cancel must not swallow THIS
|
|
879
|
+
# turn's real `⎿ interrupted` marker.
|
|
880
|
+
ui.suppress_interrupt_marker(value: false) if ui.respond_to?(:suppress_interrupt_marker)
|
|
881
|
+
|
|
882
|
+
composer, real_stdout = start_composer(input_queue, runner)
|
|
883
|
+
|
|
884
|
+
# Mark the composer "in a turn" for the WHOLE turn — covering the THINKING
|
|
885
|
+
# phase AND the content stream — so a "queued ▸" type-ahead echo submitted
|
|
886
|
+
# before the first content token is deferred too, not stranded above the
|
|
887
|
+
# thought line and the answer (D7e). Cleared (and the deferred echoes
|
|
888
|
+
# flushed, after the footer) in the ensure below.
|
|
889
|
+
composer.begin_turn if composer.respond_to?(:begin_turn)
|
|
890
|
+
|
|
891
|
+
# If this turn's prompt came off the input queue (interrupt-by-default
|
|
892
|
+
# Enter, Alt+Enter, or "/queued" during the previous turn), commit it now
|
|
893
|
+
# as a NORMAL "<prompt><line>" message above the input — the same echo an
|
|
894
|
+
# idle submit gets — and remove its "⏳ queued:" indicator so it visibly
|
|
895
|
+
# MOVES from the above-input pending row to a transcript message at send
|
|
896
|
+
# time. An idle-submitted prompt already echoed at submit, so it isn't
|
|
897
|
+
# marked and is skipped here (no double echo).
|
|
898
|
+
commit_queued_prompt(composer)
|
|
899
|
+
|
|
900
|
+
# Open the TURN-SCOPED status row (the "Ruby facet" ticker): one engine
|
|
901
|
+
# thread for the whole turn — model waits, tools, inter-tool gaps AND
|
|
902
|
+
# the post-turn inline jobs all just swap its label. Closed in the
|
|
903
|
+
# ensure below (turn end / error / interrupt), so the post-footer
|
|
904
|
+
# polishing phase stays animated instead of freezing the UI.
|
|
905
|
+
ui.turn_started if ui.respond_to?(:turn_started)
|
|
906
|
+
|
|
907
|
+
# Pass the SAME queue the composer pushes into through to the agent loop:
|
|
908
|
+
# the loop drains it at each iteration boundary (Phase-2 mid-turn
|
|
909
|
+
# steering). Anything still queued in the gap after the turn ends falls
|
|
910
|
+
# back to #next_input for the NEXT turn (the MVP behaviour). nil ⇒ no
|
|
911
|
+
# injection (piped/-q input has no composer anyway).
|
|
912
|
+
run_kwargs = { image_paths: image_paths, input_queue: input_queue }
|
|
913
|
+
# Only thread the paste expansions when a placeholder was actually
|
|
914
|
+
# collected, so a normal turn's runner.run signature is unchanged.
|
|
915
|
+
run_kwargs[:paste_expansions] = paste_expansions unless paste_expansions.empty?
|
|
916
|
+
runner.run(prompt, **run_kwargs)
|
|
917
|
+
rescue Interrupt
|
|
918
|
+
# Reached on the second tap (raised from the trap) or a stray INT that
|
|
919
|
+
# escaped the cooperative path. Cancel and re-raise so run_interactive's
|
|
920
|
+
# loop breaks and the session ends cleanly.
|
|
921
|
+
runner.cancel!
|
|
922
|
+
ui.blank_line
|
|
923
|
+
ui.warning("turn cancelled")
|
|
924
|
+
raise
|
|
925
|
+
ensure
|
|
926
|
+
# End the turn BEFORE tearing the composer down: the runner has fully
|
|
927
|
+
# unwound here, so the turn-summary footer is already in scrollback. This
|
|
928
|
+
# clears the turn-active flag and flushes any deferred "queued ▸" echoes
|
|
929
|
+
# via the still-live composer's print_above, so they land AFTER the footer
|
|
930
|
+
# (answer → reveal → `↳ turn` → `queued ▸`). A no-content/aborted turn
|
|
931
|
+
# still flushes here, so a mid-turn type-ahead is never stranded.
|
|
932
|
+
# The status row stops FIRST — the post-turn jobs have drained by the
|
|
933
|
+
# time the runner returns, so the facet has already landed in the
|
|
934
|
+
# footer and the engine thread must not outlive the turn.
|
|
935
|
+
ui.turn_finished if ui.respond_to?(:turn_finished)
|
|
936
|
+
composer.end_turn if composer.respond_to?(:end_turn)
|
|
937
|
+
# Refresh the status bar (model + context saturation) now that the
|
|
938
|
+
# turn's messages are persisted — the "after each footer" boundary.
|
|
939
|
+
# The bar then stays correct for however long this composer remains
|
|
940
|
+
# pinned (post-turn inline jobs); the next idle composer recomputes it
|
|
941
|
+
# at build time anyway.
|
|
942
|
+
composer.set_status(build_status_line(runner)) if composer.respond_to?(:set_status)
|
|
943
|
+
stop_composer(composer, real_stdout)
|
|
944
|
+
Signal.trap("INT", prev) if prev
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# The status-bar line for the CURRENT session (see UI::StatusBar):
|
|
948
|
+
# mode (+ branch/skill when set) · resolved model id · context
|
|
949
|
+
# saturation. Saturation prefers the REAL
|
|
950
|
+
# usage the provider reported for the session's last response (the
|
|
951
|
+
# input_tokens the agent loop records in the assistant message metadata
|
|
952
|
+
# — the whole assembled prompt incl. the system prompt) and falls back
|
|
953
|
+
# to the SAME estimate the compaction logic runs on —
|
|
954
|
+
# Context::TokenBudget#estimate_tokens (chars/4) over the stored
|
|
955
|
+
# messages. The window comes from `model.context_length` /
|
|
956
|
+
# `context.max_tokens` (TokenBudget's default otherwise), so the
|
|
957
|
+
# percentage tracks the compaction thresholds. nil (no bar) when
|
|
958
|
+
# disabled via display.statusbar or on any failure: a cosmetic line
|
|
959
|
+
# must never break the prompt.
|
|
960
|
+
def build_status_line(runner)
|
|
961
|
+
return nil unless runner && Rubino.configuration.display_statusbar?
|
|
962
|
+
|
|
963
|
+
session = runner.session
|
|
964
|
+
budget = Context::TokenBudget.new(model_id: session[:model], config: Rubino.configuration)
|
|
965
|
+
messages = ::Rubino::Session::Store.new.for_session(session[:id])
|
|
966
|
+
UI::StatusBar.render(
|
|
967
|
+
chips: { mode: Rubino::Modes.current, branch: @branch_short_id,
|
|
968
|
+
skill: Rubino::ActiveSkill.current },
|
|
969
|
+
model: session[:model] || model_name,
|
|
970
|
+
tokens: context_tokens(messages, budget),
|
|
971
|
+
window: budget.available_tokens,
|
|
972
|
+
pastel: pastel
|
|
973
|
+
)
|
|
974
|
+
rescue StandardError
|
|
975
|
+
nil
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
# Estimated tokens in the session's context: the last recorded REAL
|
|
979
|
+
# context size (input + output of the newest assistant response that
|
|
980
|
+
# carries usage) when available, else TokenBudget's chars/4 estimate.
|
|
981
|
+
def context_tokens(messages, budget)
|
|
982
|
+
last = messages.reverse_each.find { |m| m.metadata&.dig(:input_tokens).to_i.positive? }
|
|
983
|
+
return last.metadata[:input_tokens].to_i + last.token_count.to_i if last
|
|
984
|
+
|
|
985
|
+
budget.estimate_tokens(messages.map { |m| { content: m.content } })
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Commits the just-dequeued prompt as a normal "<prompt><line>" transcript
|
|
989
|
+
# message and removes its "⏳ queued:" indicator. Each line the previous
|
|
990
|
+
# turn parked (set in #next_input as @input_from_queue) is echoed in the
|
|
991
|
+
# clean "❯ " prompt, so a queued/interrupt-sent message reads back exactly
|
|
992
|
+
# like an idle submit. No-op when the prompt was an idle submit (already
|
|
993
|
+
# echoed) or there's no composer (piped / -q). Clears the marker after.
|
|
994
|
+
def commit_queued_prompt(composer)
|
|
995
|
+
lines = @input_from_queue
|
|
996
|
+
@input_from_queue = nil
|
|
997
|
+
return unless lines && composer
|
|
998
|
+
|
|
999
|
+
lines.each do |line|
|
|
1000
|
+
# Drop the live "⏳ queued:" row first (explicit-queue items), then
|
|
1001
|
+
# commit the normal echo above the input.
|
|
1002
|
+
composer.commit_queued(line) if composer.respond_to?(:commit_queued)
|
|
1003
|
+
composer.print_above("#{build_prompt}#{line}")
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# The NON-TURN counterpart of #commit_queued_prompt (#192): a dequeued
|
|
1008
|
+
# line consumed by the dispatch loop WITHOUT running a model turn (a slash
|
|
1009
|
+
# command, a `!` shell escape, a `? ` probe, an image command) never
|
|
1010
|
+
# reaches #run_turn, so its "⏳ queued:" indicator would linger above the
|
|
1011
|
+
# composer across later prompts. Commit it here instead: drop the row from
|
|
1012
|
+
# the shared pending stack (the next composer renders from it) and echo
|
|
1013
|
+
# the line as the normal "<prompt><line>" message — no composer is live
|
|
1014
|
+
# between turns, so the echo goes straight to scrollback. No-op for an
|
|
1015
|
+
# idle submit (not flagged in @input_from_queue).
|
|
1016
|
+
def commit_queued_dispatch
|
|
1017
|
+
lines = @input_from_queue
|
|
1018
|
+
@input_from_queue = nil
|
|
1019
|
+
return unless lines
|
|
1020
|
+
|
|
1021
|
+
lines.each do |line|
|
|
1022
|
+
idx = pending_queued.index(line)
|
|
1023
|
+
pending_queued.delete_at(idx) if idx
|
|
1024
|
+
$stdout.puts("#{build_prompt}#{line}")
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Starts the bottom-pinned composer for the duration of a turn and swaps
|
|
1029
|
+
# $stdout for a proxy that routes all turn output through it.
|
|
1030
|
+
#
|
|
1031
|
+
# Returns [composer, real_stdout]. Both are nil unless steering is wired
|
|
1032
|
+
# AND both ends are real TTYs (UI::BottomComposer.active?) — for piped /
|
|
1033
|
+
# `-q` / server input there is nothing to read raw and we must not touch
|
|
1034
|
+
# terminal modes or swap $stdout, so this is a no-op there and the plain
|
|
1035
|
+
# path runs exactly as before.
|
|
1036
|
+
#
|
|
1037
|
+
# Terminal mode: the composer reader runs inside +$stdin.raw(intr: true)+
|
|
1038
|
+
# so each keystroke arrives unbuffered while +intr: true+ keeps the ISIG
|
|
1039
|
+
# flag on — Ctrl+C still generates SIGINT and reaches the double-tap trap
|
|
1040
|
+
# installed above (we never read or swallow \x03). The block form of #raw
|
|
1041
|
+
# restores the prior termios; #stop additionally forces cooked mode.
|
|
1042
|
+
#
|
|
1043
|
+
# The composer only appends to the thread-safe InputQueue; it never mutates
|
|
1044
|
+
# the runner or the agent loop, so it cannot race the turn own work — the
|
|
1045
|
+
# parked text is consumed by the loop at a safe iteration boundary (atomic
|
|
1046
|
+
# #drain), or by #next_input between turns for anything typed in the gap.
|
|
1047
|
+
def start_composer(input_queue, runner)
|
|
1048
|
+
return [nil, nil] unless input_queue && UI::BottomComposer.active?
|
|
1049
|
+
|
|
1050
|
+
# The mode/branch/skill context rides the STATUS BAR (build_status_line);
|
|
1051
|
+
# the prompt itself is the constant clean "❯ " behind the red rail.
|
|
1052
|
+
# `runner` is threaded in (not captured from an enclosing scope) so the
|
|
1053
|
+
# interrupt lambda resolves it — it is a parameter of #run_turn, not in
|
|
1054
|
+
# scope here, and there is no @runner ivar, so capturing it implicitly
|
|
1055
|
+
# raised NameError the instant an Enter-during-turn fired (BH-1).
|
|
1056
|
+
# Same completion + history wiring as the idle composer: the prompt is
|
|
1057
|
+
# pinned and editable for the WHOLE turn — including the post-turn
|
|
1058
|
+
# window where inline jobs (memory auto-extract, skill distill) spend
|
|
1059
|
+
# aux-LLM seconds after the `↳ turn` footer — so `/` and `@` dropdowns
|
|
1060
|
+
# and ↑↓ history work whenever the prompt is visible (#169).
|
|
1061
|
+
composer = UI::BottomComposer.new(input_queue: input_queue, prompt: build_prompt,
|
|
1062
|
+
rail: composer_rail,
|
|
1063
|
+
on_ctrl_o: ctrl_o_handler,
|
|
1064
|
+
on_mode_cycle: mode_cycle_handler(runner),
|
|
1065
|
+
on_interrupt: interrupt_handler(runner),
|
|
1066
|
+
completion_source: @completion_source,
|
|
1067
|
+
history: @input_history,
|
|
1068
|
+
pending_queued: pending_queued,
|
|
1069
|
+
status_line: build_status_line(runner),
|
|
1070
|
+
max_input_rows: Rubino.configuration.display_input_max_rows,
|
|
1071
|
+
paste_store: paste_store)
|
|
1072
|
+
composer.start
|
|
1073
|
+
real_stdout = $stdout
|
|
1074
|
+
# Force the lazily-built logger to bind to the REAL $stdout NOW, before
|
|
1075
|
+
# the swap — otherwise the first log call during the turn would build a
|
|
1076
|
+
# Logger against the proxy and route diagnostic lines into the chat (and,
|
|
1077
|
+
# after the turn, into a dead proxy). The logger stays on the real IO.
|
|
1078
|
+
Rubino.logger
|
|
1079
|
+
$stdout = UI::StdoutProxy.new(composer)
|
|
1080
|
+
[composer, real_stdout]
|
|
1081
|
+
rescue StandardError
|
|
1082
|
+
# Setup failed — fall back to the plain path so the turn still runs
|
|
1083
|
+
# (no raw, no proxy).
|
|
1084
|
+
composer&.stop
|
|
1085
|
+
$stdout = real_stdout if real_stdout
|
|
1086
|
+
[nil, nil]
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# The composer's Enter-during-turn hook: cancel the runner so the just-
|
|
1090
|
+
# submitted line runs as the next turn. +quiet+ marks a slash-command
|
|
1091
|
+
# submit at an idle-LOOKING moment — nothing visibly streaming, only the
|
|
1092
|
+
# live cards animating (#111) — so the UI is told to swallow the
|
|
1093
|
+
# upcoming `⎿ interrupted` marker instead of stranding it above the
|
|
1094
|
+
# command's own output.
|
|
1095
|
+
def interrupt_handler(runner)
|
|
1096
|
+
lambda { |quiet = false|
|
|
1097
|
+
ui = Rubino.ui
|
|
1098
|
+
ui.suppress_interrupt_marker if quiet && ui.respond_to?(:suppress_interrupt_marker)
|
|
1099
|
+
runner.cancel!
|
|
1100
|
+
}
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# Tears down the composer: restores the real $stdout, flushes any held
|
|
1104
|
+
# partial line into scrollback, stops the reader and restores cooked mode.
|
|
1105
|
+
# Safe to call with nils (no composer was started).
|
|
1106
|
+
def stop_composer(composer, real_stdout)
|
|
1107
|
+
proxy = $stdout
|
|
1108
|
+
$stdout = real_stdout if real_stdout
|
|
1109
|
+
proxy.finish if proxy.respond_to?(:finish)
|
|
1110
|
+
# Preserve an un-submitted draft (text typed during the turn with no
|
|
1111
|
+
# Enter) before tearing the composer down; #next_input pre-fills the next
|
|
1112
|
+
# prompt with it. A submitted line clears the buffer, so this only ever
|
|
1113
|
+
# carries genuinely-pending input. An empty buffer leaves any prior draft
|
|
1114
|
+
# untouched so it survives queued steering turns in between.
|
|
1115
|
+
if composer
|
|
1116
|
+
draft = composer.buffer.to_s
|
|
1117
|
+
@pending_draft = draft unless draft.strip.empty?
|
|
1118
|
+
end
|
|
1119
|
+
composer&.stop
|
|
1120
|
+
rescue IOError, Errno::ENOTTY, Errno::EIO
|
|
1121
|
+
nil
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# The leading `? ` ephemeral-probe trigger. Returns the side-question text
|
|
1125
|
+
# (everything after the `? `) when the line is a probe, nil otherwise. A
|
|
1126
|
+
# bare `?` or `?` with no following space is NOT a probe (so a real
|
|
1127
|
+
# message can start with `?` by typing it without the trailing space, or
|
|
1128
|
+
# by leading with a space per the escape rule in the UX doc).
|
|
1129
|
+
def probe_question(input)
|
|
1130
|
+
return nil unless input.start_with?("? ")
|
|
1131
|
+
|
|
1132
|
+
q = input[2..].to_s.strip
|
|
1133
|
+
q.empty? ? nil : q
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
# Runs an ephemeral side-question against the live session and renders it
|
|
1137
|
+
# in the dim "probe (ephemeral · not saved)" aside, then DISCARDS it: the
|
|
1138
|
+
# Q&A never touches the session store, so the next real turn is unchanged.
|
|
1139
|
+
# The Q&A is stashed in @last_probe so a `/branch` right after can promote
|
|
1140
|
+
# it into the fork seed (the "actually, let's pursue this" move).
|
|
1141
|
+
def run_probe(runner, question, ui)
|
|
1142
|
+
# The probe is a synchronous side-inference with nothing streaming, so
|
|
1143
|
+
# the wait used to look frozen (#58): show the SAME thinking row a
|
|
1144
|
+
# normal turn gets, cleared before the aside (or failure) renders. TTY
|
|
1145
|
+
# only — never an indicator into a pipe.
|
|
1146
|
+
probe_thinking_started(ui)
|
|
1147
|
+
result = Interaction::Probe.new(
|
|
1148
|
+
session: runner.session,
|
|
1149
|
+
model_override: model_name,
|
|
1150
|
+
provider_override: opt(:provider)
|
|
1151
|
+
).ask(question)
|
|
1152
|
+
probe_thinking_finished(ui)
|
|
1153
|
+
ui.probe_aside(result.answer)
|
|
1154
|
+
@last_probe = result
|
|
1155
|
+
rescue StandardError => e
|
|
1156
|
+
probe_thinking_finished(ui)
|
|
1157
|
+
# A probe is a throwaway aside — a failure must never break the REPL.
|
|
1158
|
+
ui.warning("probe failed: #{e.message}")
|
|
1159
|
+
@last_probe = nil
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
# Forks the current session at this point into a NEW saved session and
|
|
1163
|
+
# returns a runner switched into it (the REPL replaces its runner with
|
|
1164
|
+
# this). The original session is left untouched.
|
|
1165
|
+
#
|
|
1166
|
+
# Reuse: Session::Repository#create(parent_session_id:) sets the lineage
|
|
1167
|
+
# column, and Session::Store#copy_into seeds the child with the parent's
|
|
1168
|
+
# message history so far — the same context a resume would replay. When
|
|
1169
|
+
# the immediately-preceding interaction was a probe (@last_probe set), its
|
|
1170
|
+
# Q&A is appended to the seed too, so an aside that "never happened" in the
|
|
1171
|
+
# original becomes the branch's starting point.
|
|
1172
|
+
def branch_runner(ui, parent_runner, title)
|
|
1173
|
+
parent = parent_runner.session
|
|
1174
|
+
store = ::Rubino::Session::Store.new
|
|
1175
|
+
# Persist the parent if it was a lazily-built, never-saved session, so a
|
|
1176
|
+
# branch from a brand-new chat still inherits whatever is there and the
|
|
1177
|
+
# parent_session_id points at a real row.
|
|
1178
|
+
Session::Repository.new.persist!(parent) if parent[:persisted] == false
|
|
1179
|
+
|
|
1180
|
+
child = Session::Repository.new.create(
|
|
1181
|
+
source: "cli",
|
|
1182
|
+
model: parent[:model],
|
|
1183
|
+
provider: parent[:provider],
|
|
1184
|
+
title: title,
|
|
1185
|
+
parent_session_id: parent[:id]
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
store.copy_into(child[:id], store.for_session(parent[:id]))
|
|
1189
|
+
included_probe = seed_probe_into!(store, child[:id])
|
|
1190
|
+
# copy_into/seed write message rows but don't touch the session's cached
|
|
1191
|
+
# message_count, so sync it once here — otherwise /sessions shows the
|
|
1192
|
+
# inherited branch as "0 msgs" even though its transcript is populated.
|
|
1193
|
+
Session::Repository.new.update(child[:id], message_count: store.count(child[:id]))
|
|
1194
|
+
|
|
1195
|
+
ui.branch_confirmation(
|
|
1196
|
+
new_id: child[:id],
|
|
1197
|
+
parent_id: parent[:id],
|
|
1198
|
+
title: title,
|
|
1199
|
+
included_probe: included_probe
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
@branch_short_id = child[:id][0..3]
|
|
1203
|
+
@last_probe = nil
|
|
1204
|
+
resume_runner(ui, child[:id])
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# Appends the immediately-preceding probe's Q&A to the branch seed when one
|
|
1208
|
+
# is present (the user is promoting the aside). Returns true if a probe was
|
|
1209
|
+
# folded in, false otherwise.
|
|
1210
|
+
def seed_probe_into!(store, child_session_id) # rubocop:disable Naming/PredicateMethod -- a seeding mutator that reports what it did
|
|
1211
|
+
probe = @last_probe
|
|
1212
|
+
return false unless probe
|
|
1213
|
+
|
|
1214
|
+
store.create(session_id: child_session_id, role: "user", content: probe.question)
|
|
1215
|
+
store.create(session_id: child_session_id, role: "assistant", content: probe.answer)
|
|
1216
|
+
true
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# --- Esc-Esc rewind (edit-and-resend) -----------------------------------
|
|
1220
|
+
#
|
|
1221
|
+
# Double-Esc at the idle prompt walks back through the session's USER
|
|
1222
|
+
# messages: a picker (the same arrow-key machinery /sessions uses, Esc
|
|
1223
|
+
# cancels) lists them most recent first; picking one FORKS the session at
|
|
1224
|
+
# the point BEFORE that message (the /branch copy-truncated infra), parks
|
|
1225
|
+
# the fork's runner for the REPL to adopt, and pre-fills the composer
|
|
1226
|
+
# with the message text ready to edit — Enter sends it as the next turn
|
|
1227
|
+
# on the fork. The original session is never touched.
|
|
1228
|
+
|
|
1229
|
+
# Run the rewind flow. Returns the fork's runner on a pick, nil on
|
|
1230
|
+
# cancel / nothing to rewind to. Must run OFF the composer's reader
|
|
1231
|
+
# thread: ui.select suspends the composer (run_in_terminal), which joins
|
|
1232
|
+
# that thread.
|
|
1233
|
+
def handle_rewind(composer, runner, ui)
|
|
1234
|
+
messages = ::Rubino::Session::Store.new.for_session(runner.session[:id])
|
|
1235
|
+
user_idx = messages.each_index.select { |i| rewindable_message?(messages[i]) }
|
|
1236
|
+
if user_idx.empty?
|
|
1237
|
+
composer.announce("(no earlier message to rewind to)")
|
|
1238
|
+
return nil
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
choices = user_idx.reverse.map { |i| [rewind_choice_label(messages[i]), i] }
|
|
1242
|
+
chosen = ui.select("Rewind to which message? (Esc to cancel)", choices)
|
|
1243
|
+
return nil if chosen.nil?
|
|
1244
|
+
|
|
1245
|
+
rewind_onto_fork(composer, runner, ui, messages, chosen,
|
|
1246
|
+
ordinal: user_idx.index(chosen) + 1)
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
# Fork the session at the picked message and switch onto it: seed the
|
|
1250
|
+
# child with everything BEFORE the message (copy-truncated), adopt the
|
|
1251
|
+
# fork's runner + status bar, print the dim note, and pre-fill the
|
|
1252
|
+
# composer with the message text (multiline-safe) for edit-and-resend.
|
|
1253
|
+
def rewind_onto_fork(composer, runner, ui, messages, index, ordinal:)
|
|
1254
|
+
child = rewind_fork(runner, messages.first(index))
|
|
1255
|
+
# The rewind has its own "┄ rewound to message N — editing ┄" marker, so
|
|
1256
|
+
# suppress the generic "Resuming session: <id>…" plumbing line the runner
|
|
1257
|
+
# would otherwise emit on the fork switch (#220).
|
|
1258
|
+
new_runner = build_runner(session_id: child[:id], ui: ui, announce_session: false)
|
|
1259
|
+
@branch_short_id = child[:id][0..3]
|
|
1260
|
+
ui.note("rewound to message #{ordinal} — editing")
|
|
1261
|
+
composer.set_status(build_status_line(new_runner))
|
|
1262
|
+
composer.prefill(messages[index].content)
|
|
1263
|
+
new_runner
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
# A row the rewind picker offers: a REAL typed user message — not a tool
|
|
1267
|
+
# result riding the user role, and not the `!` bang-shell injections
|
|
1268
|
+
# (<bash-input>/<bash-stdout> context glue is not something to resend).
|
|
1269
|
+
def rewindable_message?(msg)
|
|
1270
|
+
msg.role == "user" && msg.tool_call_id.nil? &&
|
|
1271
|
+
!msg.content.to_s.start_with?("<bash-")
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# One picker row: `N ago · <first 60 chars>` — recency + a flattened
|
|
1275
|
+
# snippet, enough to recognize the turn at a glance.
|
|
1276
|
+
def rewind_choice_label(msg)
|
|
1277
|
+
snippet = msg.content.to_s.gsub(/\s+/, " ").strip
|
|
1278
|
+
snippet = "#{snippet[0, REWIND_SNIPPET_CHARS]}…" if snippet.length > REWIND_SNIPPET_CHARS
|
|
1279
|
+
age = message_age(msg)
|
|
1280
|
+
age ? "#{age} · #{snippet}" : snippet
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# "5m ago" for a message row (same humanization as the /sessions picker);
|
|
1284
|
+
# nil when the timestamp is unparseable — the row renders without it.
|
|
1285
|
+
def message_age(msg)
|
|
1286
|
+
created = msg.created_at
|
|
1287
|
+
created = Time.parse(created.to_s) unless created.is_a?(Time)
|
|
1288
|
+
"#{Rubino::Util::Duration.human_duration(Time.now - created)} ago"
|
|
1289
|
+
rescue StandardError
|
|
1290
|
+
nil
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# The copy-truncated fork (the /branch infra, cut at the rewind point):
|
|
1294
|
+
# a child session with lineage set, seeded with +seed_messages+ — every
|
|
1295
|
+
# message BEFORE the picked one — leaving the original untouched.
|
|
1296
|
+
def rewind_fork(runner, seed_messages)
|
|
1297
|
+
parent = runner.session
|
|
1298
|
+
repo = Session::Repository.new
|
|
1299
|
+
# Persist a lazily-built, never-saved parent first, exactly as /branch
|
|
1300
|
+
# does, so parent_session_id points at a real row.
|
|
1301
|
+
repo.persist!(parent) if parent[:persisted] == false
|
|
1302
|
+
|
|
1303
|
+
child = repo.create(
|
|
1304
|
+
source: "cli",
|
|
1305
|
+
model: parent[:model],
|
|
1306
|
+
provider: parent[:provider],
|
|
1307
|
+
title: nil,
|
|
1308
|
+
parent_session_id: parent[:id]
|
|
1309
|
+
)
|
|
1310
|
+
store = ::Rubino::Session::Store.new
|
|
1311
|
+
store.copy_into(child[:id], seed_messages)
|
|
1312
|
+
# copy_into writes message rows but not the cached message_count —
|
|
1313
|
+
# sync it once, same as /branch (#/sessions would show "0 msgs").
|
|
1314
|
+
repo.update(child[:id], message_count: store.count(child[:id]))
|
|
1315
|
+
child
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# The Ctrl+O callback for the composer: reveal the last retained reasoning
|
|
1319
|
+
# aside via the UI adapter (the CLI keeps the buffer). The reveal commits
|
|
1320
|
+
# through the composer's serialized print_above, so it lands cleanly above
|
|
1321
|
+
# the prompt idle OR mid-turn. nil when the adapter can't reveal, so the
|
|
1322
|
+
# composer treats Ctrl+O as a no-op.
|
|
1323
|
+
def ctrl_o_handler
|
|
1324
|
+
ui = Rubino.ui
|
|
1325
|
+
return nil unless ui.respond_to?(:reveal_last_reasoning)
|
|
1326
|
+
|
|
1327
|
+
-> { ui.reveal_last_reasoning }
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
# The Shift+Tab callback for the composer: cycle the mode to the next in
|
|
1331
|
+
# Modes::ALL (default→plan→yolo→default), PERSIST it via Modes.set, show
|
|
1332
|
+
# the transition toast, and RETURN the freshly-built STATUS-BAR line so
|
|
1333
|
+
# the composer updates the mode token LIVE (the mode lives in the status
|
|
1334
|
+
# bar now, not in a prompt chip). +runner+ feeds the bar's model/context
|
|
1335
|
+
# numbers. The composer holds no mode logic — it just adopts the
|
|
1336
|
+
# returned status line.
|
|
1337
|
+
def mode_cycle_handler(runner)
|
|
1338
|
+
-> { cycle_mode(runner) }
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
# Shift+Tab: cycle the mode, show a SINGLE TRANSIENT confirmation banner,
|
|
1342
|
+
# and RETURN the freshly-built status-bar line so the composer redraws the
|
|
1343
|
+
# mode token LIVE (fixes the stale-chip D7). The persistent indicator is
|
|
1344
|
+
# the STATUS BAR's leading mode token; the banner is a one-shot toast
|
|
1345
|
+
# rendered in the composer's live region via #announce — redrawn in place,
|
|
1346
|
+
# cleared on the next keystroke, NEVER committed to scrollback. So cycling
|
|
1347
|
+
# N times leaves ZERO stacked banner lines (D3) and a mid-stream Shift+Tab
|
|
1348
|
+
# can't wedge a banner between answer chunks (D2). With no composer
|
|
1349
|
+
# (cooked fallback) it falls back to a plain dim line.
|
|
1350
|
+
#
|
|
1351
|
+
# Entering YOLO from the cycle is gated behind a second press (#152):
|
|
1352
|
+
# the press that lands on yolo only ARMS it and shows a confirm toast;
|
|
1353
|
+
# blind mashing past plan can no longer silently drop the approval gates
|
|
1354
|
+
# of the session AND its running background children. An explicit
|
|
1355
|
+
# `/mode yolo` stays direct.
|
|
1356
|
+
def cycle_mode(runner = nil)
|
|
1357
|
+
previous = Rubino::Modes.current
|
|
1358
|
+
idx = Rubino::Modes::ALL.index(previous) || 0
|
|
1359
|
+
nxt = Rubino::Modes::ALL[(idx + 1) % Rubino::Modes::ALL.length]
|
|
1360
|
+
return announce_yolo_confirm if nxt == Rubino::Modes::YOLO && !yolo_cycle_confirmed?
|
|
1361
|
+
|
|
1362
|
+
@yolo_armed_at = nil
|
|
1363
|
+
Rubino::Modes.set(nxt)
|
|
1364
|
+
# Same `<old> → <new>` arrow grammar as the /mode footer (#78), plus
|
|
1365
|
+
# the description and the cycle hint only this transient toast carries.
|
|
1366
|
+
show_mode_footer("┄ mode #{previous} → #{nxt} — #{Rubino::Modes.description(nxt)}, shift+tab to cycle ┄")
|
|
1367
|
+
build_status_line(runner)
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# True when THIS Shift+Tab press is the deliberate second press that
|
|
1371
|
+
# confirms entering yolo. Anything else (first press, mash, stale arm)
|
|
1372
|
+
# (re-)arms and returns false.
|
|
1373
|
+
def yolo_cycle_confirmed?
|
|
1374
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1375
|
+
elapsed = @yolo_armed_at ? now - @yolo_armed_at : nil
|
|
1376
|
+
return true if elapsed&.between?(YOLO_CONFIRM_MIN_SECONDS, YOLO_CONFIRM_WINDOW_SECONDS)
|
|
1377
|
+
|
|
1378
|
+
@yolo_armed_at = now
|
|
1379
|
+
false
|
|
1380
|
+
end
|
|
1381
|
+
|
|
1382
|
+
# The arm toast: says what yolo will do — including to RUNNING background
|
|
1383
|
+
# children, whose gates drop the moment the mode flips — and how to
|
|
1384
|
+
# confirm. Returns nil (the mode did not change ⇒ no status-bar update).
|
|
1385
|
+
def announce_yolo_confirm
|
|
1386
|
+
live = Tools::BackgroundTasks.instance.running.size
|
|
1387
|
+
children = live.positive? ? " — #{live} running subagent(s) will run gated actions unprompted" : ""
|
|
1388
|
+
show_mode_footer("┄ yolo skips ALL approvals#{children} — press shift+tab again to confirm ┄")
|
|
1389
|
+
nil
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
# Routes a transient mode footer through the live composer's #announce
|
|
1393
|
+
# (never committed to scrollback, D2/D3) or, with no composer (cooked
|
|
1394
|
+
# fallback), prints a plain dim line.
|
|
1395
|
+
def show_mode_footer(text)
|
|
1396
|
+
footer = pastel.dim(text)
|
|
1397
|
+
composer = UI::BottomComposer.current
|
|
1398
|
+
if composer
|
|
1399
|
+
composer.announce(footer)
|
|
1400
|
+
else
|
|
1401
|
+
$stdout.print "\n#{footer}\n"
|
|
1402
|
+
$stdout.flush
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# The clean Rail-rubino prompt: a bare "❯ " caret. The mode/branch/skill
|
|
1407
|
+
# chip that used to lead it lives in the STATUS BAR now (see
|
|
1408
|
+
# #build_status_line / UI::StatusBar) — the composer prepends the red
|
|
1409
|
+
# rail itself (#composer_rail), so committed echoes built from this
|
|
1410
|
+
# ("❯ <line>") stay rail-free in scrollback.
|
|
1411
|
+
def build_prompt
|
|
1412
|
+
"#{PROMPT_CARET} "
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
# The one-column brand rail (the red ▍ glyph) the composer draws as
|
|
1416
|
+
# the first column of EVERY input row — first row and continuations.
|
|
1417
|
+
# Pastel auto-disables color off a TTY, and the composer itself only
|
|
1418
|
+
# runs on a real TTY, so the rail never reaches piped output.
|
|
1419
|
+
def composer_rail
|
|
1420
|
+
pastel.red(PROMPT_RAIL)
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
def pastel
|
|
1424
|
+
@pastel ||= Pastel.new
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
def collapse_home(path)
|
|
1428
|
+
home = Dir.home
|
|
1429
|
+
path.start_with?(home) ? path.sub(home, "~") : path
|
|
1430
|
+
rescue ArgumentError
|
|
1431
|
+
path
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
# Best-effort git status. Returns nil outside a checkout. Shells out
|
|
1435
|
+
# because we're already paying a readline-roundtrip on every prompt —
|
|
1436
|
+
# 3 git commands at ~5ms each is invisible against that.
|
|
1437
|
+
def git_context
|
|
1438
|
+
return nil unless system("git rev-parse --is-inside-work-tree > /dev/null 2>&1")
|
|
1439
|
+
|
|
1440
|
+
branch = `git branch --show-current 2>/dev/null`.strip
|
|
1441
|
+
sha = `git rev-parse --short HEAD 2>/dev/null`.strip
|
|
1442
|
+
dirty = !`git status --porcelain 2>/dev/null`.strip.empty?
|
|
1443
|
+
return nil if branch.empty? && sha.empty?
|
|
1444
|
+
|
|
1445
|
+
{ branch: branch.empty? ? "(detached)" : branch, sha: sha, dirty: dirty }
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# --- Helpers ---
|
|
1449
|
+
|
|
1450
|
+
def opt(key)
|
|
1451
|
+
@options[key] || @options[key.to_s]
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
# Seeds extra workspace roots from --add-dir and runs the folder-trust
|
|
1455
|
+
# gate for the primary root and each added dir, BEFORE any turn assembles
|
|
1456
|
+
# a system prompt (so an untrusted dir's AGENTS.md/skills are withheld).
|
|
1457
|
+
# +interactive+ false (one-shot/-q) skips the prompt entirely.
|
|
1458
|
+
def setup_workspace_and_trust!(ui, interactive:)
|
|
1459
|
+
gate = TrustGate.new(ui: ui, interactive: interactive, ignore_rules: opt(:ignore_rules) || false)
|
|
1460
|
+
|
|
1461
|
+
# Primary root first — the dir rubino was launched in.
|
|
1462
|
+
gate.ensure_trust(Rubino::Workspace.primary_root)
|
|
1463
|
+
|
|
1464
|
+
Array(opt(:add_dir)).each do |dir|
|
|
1465
|
+
real = Rubino::Workspace.add(dir)
|
|
1466
|
+
ui.status("added workspace #{collapse_home(real)}") if ui.respond_to?(:status)
|
|
1467
|
+
gate.ensure_trust(real)
|
|
1468
|
+
rescue ArgumentError => e
|
|
1469
|
+
ui.error("--add-dir #{dir}: #{e.message}") if ui.respond_to?(:error)
|
|
1470
|
+
end
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
def model_name
|
|
1474
|
+
opt(:model) || opt(:m) || Rubino.configuration.model_default
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
def model_override_given?
|
|
1478
|
+
!!(opt(:model) || opt(:m))
|
|
1479
|
+
end
|
|
1480
|
+
|
|
1481
|
+
# Echoes the effective model in one-shot mode and warns on an unknown id
|
|
1482
|
+
# (#142). The warning + echo go to stderr so the answer on stdout stays
|
|
1483
|
+
# clean for piping. Only fires for an explicit `-m`/`--model` override so
|
|
1484
|
+
# the default-model happy path is unchanged.
|
|
1485
|
+
def announce_resolved_model
|
|
1486
|
+
return unless model_override_given?
|
|
1487
|
+
|
|
1488
|
+
warn "model: #{model_name}"
|
|
1489
|
+
warn_unknown_model
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
# When the resolved model id isn't in the known catalog, print a clear
|
|
1493
|
+
# stderr warning — then PROCEED (assume-exists providers like MiniMax pass
|
|
1494
|
+
# arbitrary ids through deliberately), so a typo no longer becomes a silent
|
|
1495
|
+
# wrong-model run (#142).
|
|
1496
|
+
def warn_unknown_model
|
|
1497
|
+
id = model_name
|
|
1498
|
+
return if id.nil? || id.to_s.empty?
|
|
1499
|
+
return if model_known?(id)
|
|
1500
|
+
|
|
1501
|
+
warn "rubino: warning: model '#{id}' is not in the known model catalog " \
|
|
1502
|
+
"(accepted unverified; a typo here will hit the provider as-is)."
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# True when the model id resolves in ruby_llm's registry. A fake/* id (the
|
|
1506
|
+
# dev FakeProvider) is always treated as known so it never triggers the
|
|
1507
|
+
# warning. Any registry hiccup is treated as "known" so we never block on a
|
|
1508
|
+
# cosmetic check.
|
|
1509
|
+
def model_known?(id)
|
|
1510
|
+
return true if id.to_s.start_with?("fake/") || opt(:provider).to_s == "fake"
|
|
1511
|
+
|
|
1512
|
+
!RubyLLM.models.find(id).nil?
|
|
1513
|
+
rescue RubyLLM::ModelNotFoundError
|
|
1514
|
+
false
|
|
1515
|
+
rescue StandardError
|
|
1516
|
+
# A registry-load hiccup must not produce a false "unknown" warning;
|
|
1517
|
+
# treat it as known and let the provider be the source of truth.
|
|
1518
|
+
true
|
|
1519
|
+
end
|
|
1520
|
+
|
|
1521
|
+
# The `--max-turns N` flag, threaded into the runner so it actually caps
|
|
1522
|
+
# per-turn tool iterations (#141). Thor delivers a numeric as a Float;
|
|
1523
|
+
# the IterationBudget coerces/validates it (0/blank ⇒ use config default).
|
|
1524
|
+
def max_turns_override
|
|
1525
|
+
opt(:max_turns) || opt(:"max-turns")
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
# Builds an Agent::Runner with this invocation's shared flag overrides —
|
|
1529
|
+
# only the session and UI vary per call site (one-shot, interactive boot,
|
|
1530
|
+
# /sessions resume, /new).
|
|
1531
|
+
def build_runner(session_id:, ui:, announce_session: true)
|
|
1532
|
+
Agent::Runner.new(
|
|
1533
|
+
session_id: session_id,
|
|
1534
|
+
model_override: model_name,
|
|
1535
|
+
provider_override: opt(:provider),
|
|
1536
|
+
max_turns: max_turns_override,
|
|
1537
|
+
ignore_rules: opt(:ignore_rules) || false,
|
|
1538
|
+
ui: ui,
|
|
1539
|
+
announce_session: announce_session
|
|
1540
|
+
)
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
# Rebuilds the runner on a chosen session (the /sessions in-chat resume)
|
|
1544
|
+
# and replays its history so the transcript matches what was there before.
|
|
1545
|
+
def resume_runner(ui, session_id)
|
|
1546
|
+
runner = build_runner(session_id: session_id, ui: ui)
|
|
1547
|
+
session_resolver.print_session_history(ui, runner.session[:id])
|
|
1548
|
+
runner
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1551
|
+
# Builds a runner on a brand-new session (the in-chat `/new`), without
|
|
1552
|
+
# passing any session_id so the runner creates a fresh one.
|
|
1553
|
+
def fresh_runner(ui)
|
|
1554
|
+
build_runner(session_id: nil, ui: ui)
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
# `--yolo` is the CLI flag form of `/mode yolo`. We route both through
|
|
1558
|
+
# Rubino::Modes so the status bar's mode token, the API event, and the
|
|
1559
|
+
# ApprovalPolicy short-circuit all see a single source of truth.
|
|
1560
|
+
def apply_yolo!
|
|
1561
|
+
Rubino::Modes.set(:yolo)
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1564
|
+
def ensure_setup!
|
|
1565
|
+
ensure_database_ready!
|
|
1566
|
+
|
|
1567
|
+
# Same opt-in gate as ServerCommand: fake provider is dev-only and
|
|
1568
|
+
# must not be reachable without RUBINO_ALLOW_FAKE=1.
|
|
1569
|
+
if Rubino.configuration.model_provider.to_s == "fake" &&
|
|
1570
|
+
ENV["RUBINO_ALLOW_FAKE"] != "1"
|
|
1571
|
+
warn "fake provider is dev-only — set RUBINO_ALLOW_FAKE=1 to opt in."
|
|
1572
|
+
exit(1)
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
# Without this the tool registry stays empty, Lifecycle#load_tools
|
|
1576
|
+
# returns [], no `tools: [...]` is sent on the wire, and the model
|
|
1577
|
+
# has no choice but to roleplay bash in markdown. Symptom verified
|
|
1578
|
+
# via RUBYLLM_DEBUG=1 — request body was missing `tools` entirely.
|
|
1579
|
+
# Gate on a missing CORE tool, not on emptiness: a partially-populated
|
|
1580
|
+
# registry (e.g. only "shell" left behind) must still get the defaults
|
|
1581
|
+
# re-registered — #register is idempotent by name and never touches
|
|
1582
|
+
# MCP-prefixed wrappers.
|
|
1583
|
+
Rubino::Tools::Registry.register_defaults! unless Rubino::Tools::Registry.find("write")
|
|
1584
|
+
|
|
1585
|
+
# MCP is experimental and opt-in: a configured `mcp.servers` block
|
|
1586
|
+
# connects the servers and registers their prefixed tools alongside
|
|
1587
|
+
# the built-ins (#91). Best-effort — boot! warns and returns nil on
|
|
1588
|
+
# failure, it never blocks chat.
|
|
1589
|
+
Rubino::MCP.boot!
|
|
1590
|
+
|
|
1591
|
+
# Instantiate the shared agent registry at boot so the `task` tool can
|
|
1592
|
+
# resolve subagents (explore/general) in chat — same delegation flow as
|
|
1593
|
+
# the API path. Memoized on Rubino.agent_registry.
|
|
1594
|
+
Rubino.agent_registry
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# First-run credential gate (#93). Before any model call, check the
|
|
1598
|
+
# resolved provider actually has a usable key. If it does, do nothing —
|
|
1599
|
+
# an already-configured user is unaffected. If it doesn't:
|
|
1600
|
+
# • interactive TTY → run the onboarding wizard so the user picks a
|
|
1601
|
+
# provider/model and pastes a key; bail out if they decline.
|
|
1602
|
+
# • non-interactive (-q / piped / no TTY) → print the clear, actionable
|
|
1603
|
+
# guidance to stderr and exit non-zero, instead of dropping into an
|
|
1604
|
+
# ~80s silent-retry storm that exits 0 empty.
|
|
1605
|
+
# An explicit --model/--provider override or RUBINO_ALLOW_FAKE bypasses
|
|
1606
|
+
# this gate (the user is steering deliberately).
|
|
1607
|
+
def ensure_model_configured!
|
|
1608
|
+
# An explicit --model/--provider means the user is steering deliberately
|
|
1609
|
+
# (e.g. fake provider, a local model, a per-invocation override): skip the
|
|
1610
|
+
# config-based preflight and let the runtime classifier fail fast on a
|
|
1611
|
+
# real missing credential. The preflight only guards the DEFAULT path.
|
|
1612
|
+
return if opt(:model) || opt(:m) || opt(:provider)
|
|
1613
|
+
return if LLM::CredentialCheck.usable?
|
|
1614
|
+
|
|
1615
|
+
if interactive_setup_possible?
|
|
1616
|
+
ok = OnboardingWizard.new(ui: Rubino.ui).run
|
|
1617
|
+
# Re-check: the wizard wrote config/.env in this process. If the user
|
|
1618
|
+
# skipped or it still isn't usable, fall through to the guidance/exit.
|
|
1619
|
+
return if ok && LLM::CredentialCheck.usable?
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
warn LLM::CredentialCheck.missing_key_message
|
|
1623
|
+
exit(1)
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
# Onboarding is only meaningful when we can actually prompt the user: both
|
|
1627
|
+
# ends a real TTY, and not a one-shot/scripted invocation.
|
|
1628
|
+
def interactive_setup_possible?
|
|
1629
|
+
return false if opt(:query) || opt(:q)
|
|
1630
|
+
|
|
1631
|
+
$stdin.tty? && $stdout.tty?
|
|
1632
|
+
rescue StandardError
|
|
1633
|
+
false
|
|
1634
|
+
end
|
|
1635
|
+
|
|
1636
|
+
def exit_command?(input)
|
|
1637
|
+
%w[exit quit bye /exit /quit].include?(input.strip.downcase)
|
|
1638
|
+
end
|
|
1639
|
+
|
|
1640
|
+
# Background subagents die with the process (nothing is persisted), so a
|
|
1641
|
+
# /quit with live children must not be silent (#154): list them and
|
|
1642
|
+
# confirm, default No. Off a real terminal there is no one to ask — the
|
|
1643
|
+
# listed warning becomes the clear kill notice and the exit proceeds.
|
|
1644
|
+
def confirm_quit?(ui)
|
|
1645
|
+
live = Rubino::Tools::BackgroundTasks.instance.running
|
|
1646
|
+
return true if live.empty?
|
|
1647
|
+
|
|
1648
|
+
n = live.size
|
|
1649
|
+
ui.warning("#{n} background subagent#{"s" if n != 1} still running — quitting stops " \
|
|
1650
|
+
"#{n == 1 ? "it" : "them"} (partial side effects may remain):")
|
|
1651
|
+
live.each { |e| ui.info(" #{e.id} · #{e.subagent} · #{e.status}") }
|
|
1652
|
+
return true unless ui.respond_to?(:interactive_terminal?) && ui.interactive_terminal?
|
|
1653
|
+
|
|
1654
|
+
answer = ui.ask("quit anyway? [y/N] ")
|
|
1655
|
+
%w[y yes].include?(answer.to_s.strip.downcase)
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
# First-run guard. A brand-new user who runs `chat` before `setup` used
|
|
1659
|
+
# to hit a raw `SQLite3::SQLException: no such table: sessions` stack
|
|
1660
|
+
# trace: `database.healthy?` only runs `SELECT 1`, which succeeds the
|
|
1661
|
+
# moment SQLite lazily creates an empty file — the schema is still
|
|
1662
|
+
# missing (F2). Detect the un-migrated DB and auto-initialize (create the
|
|
1663
|
+
# home dirs + run migrations); migrations are idempotent, so this is safe
|
|
1664
|
+
# to run every boot. Only fall back to a friendly "run setup" message if
|
|
1665
|
+
# the auto-init itself fails, never a Ruby backtrace.
|
|
1666
|
+
def ensure_database_ready!
|
|
1667
|
+
return if Rubino.ensure_database_ready!
|
|
1668
|
+
|
|
1669
|
+
warn "rubino isn't set up yet — run `rubino setup` first."
|
|
1670
|
+
exit(1)
|
|
1671
|
+
end
|
|
1672
|
+
end
|
|
1673
|
+
end
|
|
1674
|
+
end
|