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,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Null UI adapter that discards all output.
|
|
6
|
+
# Used in testing and background job execution
|
|
7
|
+
# where no terminal output is needed.
|
|
8
|
+
class Null < Base
|
|
9
|
+
attr_reader :messages
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@messages = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def info(message)
|
|
16
|
+
@messages << { level: :info, message: message }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success(message)
|
|
20
|
+
@messages << { level: :success, message: message }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warning(message)
|
|
24
|
+
@messages << { level: :warning, message: message }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error(message)
|
|
28
|
+
@messages << { level: :error, message: message }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def status(message)
|
|
32
|
+
@messages << { level: :status, message: message }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def box_open(*pieces, at: nil, color: nil)
|
|
36
|
+
@messages << { level: :box_open, pieces: pieces, at: at, color: color }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def box_close(*pieces, color: nil)
|
|
40
|
+
@messages << { level: :box_close, pieces: pieces, color: color }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def body(text)
|
|
44
|
+
@messages << { level: :body, message: text }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def assistant_text(text)
|
|
48
|
+
@messages << { level: :assistant_text, message: text }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def note(text)
|
|
52
|
+
@messages << { level: :note, message: text }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def probe_aside(answer)
|
|
56
|
+
@messages << { level: :probe_aside, message: answer.to_s }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def branch_confirmation(new_id:, parent_id:, title:, included_probe:)
|
|
60
|
+
@messages << {
|
|
61
|
+
level: :branch_confirmation,
|
|
62
|
+
message: { new_id: new_id, parent_id: parent_id, title: title,
|
|
63
|
+
included_probe: included_probe }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stream(chunk)
|
|
68
|
+
# Every adapter yields the common chunk contract:
|
|
69
|
+
# { type: :content | :thinking, text: String, message_id: Integer }
|
|
70
|
+
text = chunk[:text].to_s
|
|
71
|
+
type = chunk[:type] || :content
|
|
72
|
+
@messages << { level: :stream, message: text, stream_type: type }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_end
|
|
76
|
+
@messages << { level: :stream_end, message: "" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def replay_user_input(text, at: nil)
|
|
80
|
+
@messages << { level: :replay_user_input, message: text, at: at }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def thinking_started
|
|
84
|
+
@messages << { level: :thinking_started, message: "" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def thinking_finished
|
|
88
|
+
@messages << { level: :thinking_finished, message: "" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def table(headers:, rows:)
|
|
92
|
+
@messages << { level: :table, message: { headers: headers, rows: rows } }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ask(_prompt)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# No interactive selection off a real terminal; callers fall back to a
|
|
100
|
+
# non-interactive path (e.g. the static /sessions table + shortcut).
|
|
101
|
+
def select(_prompt, _choices)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `scope:` is part of the shared UI contract (ToolExecutor always
|
|
106
|
+
# passes it); the Null adapter auto-approves and ignores it.
|
|
107
|
+
def confirm(_question, scope: nil, **_context)
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Destructive confirm (#218): no human to ask, so fail closed (decline)
|
|
112
|
+
# — never destroy on the non-interactive Null adapter.
|
|
113
|
+
def confirm_destructive(_question)
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
118
|
+
@messages << { level: :tool_started, message: name, arguments: arguments, at: at }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def tool_finished(name, result: nil)
|
|
122
|
+
@messages << { level: :tool_finished, message: name }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def tool_body(text, kind: :plain)
|
|
126
|
+
@messages << { level: :tool_body, message: text, kind: kind }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def tool_chunk(name, chunk)
|
|
130
|
+
@messages << { level: :tool_chunk, name: name, chunk: chunk }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def compression_started(at: nil)
|
|
134
|
+
@messages << { level: :compression_started, message: "", at: at }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def compression_finished(metadata, at: nil)
|
|
138
|
+
@messages << { level: :compression_finished, message: metadata, at: at }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def job_enqueued(type)
|
|
142
|
+
@messages << { level: :job_enqueued, message: type }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def job_started(type)
|
|
146
|
+
@messages << { level: :job_started, message: type }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def job_finished(type)
|
|
150
|
+
@messages << { level: :job_finished, message: type }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def separator
|
|
154
|
+
@messages << { level: :separator, message: "" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def blank_line
|
|
158
|
+
@messages << { level: :blank_line, message: "" }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def mode_changed(name, previous: nil)
|
|
162
|
+
@messages << { level: :mode_changed, message: name, previous: previous }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def reasoning_status(mode)
|
|
166
|
+
@messages << { level: :reasoning_status, message: mode }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def reasoning_changed(mode, previous: nil)
|
|
170
|
+
@messages << { level: :reasoning_changed, message: mode, previous: previous }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def think_status(effort)
|
|
174
|
+
@messages << { level: :think_status, message: effort }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def think_changed(effort, previous: nil)
|
|
178
|
+
@messages << { level: :think_changed, message: effort, previous: previous }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def queued(text)
|
|
182
|
+
@messages << { level: :queued, message: text }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def input_injected(text)
|
|
186
|
+
@messages << { level: :input_injected, message: text }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Resets captured messages (useful between test cases)
|
|
190
|
+
def reset!
|
|
191
|
+
@messages = []
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# The per-session PASTE store behind the composer's file-backed paste
|
|
8
|
+
# pipeline (Hermes-style, two tiers).
|
|
9
|
+
#
|
|
10
|
+
# A large bracketed paste does not flood the composer: the body is
|
|
11
|
+
# registered here and a single compact PLACEHOLDER token —
|
|
12
|
+
# "[Pasted text #1 +123 lines]" — is inserted into the editable buffer
|
|
13
|
+
# instead. The token rides the draft like normal text (editable around,
|
|
14
|
+
# history-recalled, queueable) and is EXPANDED to the full body only at
|
|
15
|
+
# the message-build seam, where the line leaves the composer for the
|
|
16
|
+
# agent loop (ChatCommand#run_turn): the model sees everything, while the
|
|
17
|
+
# transcript echo keeps the placeholder so scrollback stays clean.
|
|
18
|
+
#
|
|
19
|
+
# Two tiers, both behind one placeholder shape:
|
|
20
|
+
#
|
|
21
|
+
# * Tier 1 — PLACEHOLDER COLLAPSE: a paste longer than
|
|
22
|
+
# `paste.collapse_lines` lines (default 5) is held in memory and the
|
|
23
|
+
# token expands to the verbatim body at submit.
|
|
24
|
+
# * Tier 2 — FILE OVERFLOW: a paste bigger than
|
|
25
|
+
# `paste.file_threshold_tokens` (default 8000, estimated at the same
|
|
26
|
+
# chars/4 rule Context::TokenBudget uses) is written to a session-
|
|
27
|
+
# scoped file — <RUBINO_HOME>/sessions/<id>/paste_N.txt — and the
|
|
28
|
+
# token expands to a one-line pointer telling the model to read the
|
|
29
|
+
# file with the read tool. The home sessions dir is where session
|
|
30
|
+
# artifacts already live, it never pollutes the workspace tree, and
|
|
31
|
+
# the read tool is deliberately un-sandboxed (only WRITES are gated
|
|
32
|
+
# by Workspace roots), so the model can read it from any cwd.
|
|
33
|
+
#
|
|
34
|
+
# Lifecycle: a tier-1 body is consumed when its token is expanded into an
|
|
35
|
+
# outgoing message (re-submitting the line from history later leaves the
|
|
36
|
+
# literal placeholder, matching Hermes); tier-2 files persist for the
|
|
37
|
+
# session so the model can re-read them in later turns. Pastes at or
|
|
38
|
+
# under the collapse threshold never reach the store — they inline into
|
|
39
|
+
# the buffer exactly as before.
|
|
40
|
+
class PasteStore
|
|
41
|
+
# The placeholder shape, shared with the CompletionSource highlight and
|
|
42
|
+
# the composer's whole-token backspace.
|
|
43
|
+
TOKEN_RE = /\[Pasted text #\d+ \+\d+ lines\]/
|
|
44
|
+
|
|
45
|
+
# Built-in fallbacks when config is missing/garbage.
|
|
46
|
+
DEFAULT_COLLAPSE_LINES = 5
|
|
47
|
+
DEFAULT_THRESHOLD_TOKENS = 8000
|
|
48
|
+
|
|
49
|
+
# @param config [Config::Configuration, nil] resolved lazily from
|
|
50
|
+
# Rubino.configuration when nil, so a long-lived store follows config
|
|
51
|
+
# reloads.
|
|
52
|
+
# @param session_source [#call, String, nil] the session id the tier-2
|
|
53
|
+
# files are scoped under. A callable is resolved at WRITE time, so the
|
|
54
|
+
# chat loop can hand a closure over its (re-assignable) runner and
|
|
55
|
+
# /new //sessions //branch swaps are honored without re-wiring.
|
|
56
|
+
def initialize(config: nil, session_source: nil)
|
|
57
|
+
@config = config
|
|
58
|
+
@session_source = session_source
|
|
59
|
+
@entries = {} # placeholder token => expansion text
|
|
60
|
+
@counter = 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Late wiring for the session scope (see #initialize) — the chat command
|
|
64
|
+
# builds the store before the runner exists.
|
|
65
|
+
attr_writer :session_source
|
|
66
|
+
|
|
67
|
+
# True when +body+ should collapse to a placeholder instead of inlining:
|
|
68
|
+
# strictly more lines than paste.collapse_lines.
|
|
69
|
+
def collapse?(body)
|
|
70
|
+
body.to_s.lines.length > collapse_lines
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Registers a pasted +body+ and returns the placeholder token to insert
|
|
74
|
+
# into the buffer. Oversized bodies (tier 2) are written to the session
|
|
75
|
+
# paste file here, at paste time; their token expands to the file
|
|
76
|
+
# pointer instead of the content.
|
|
77
|
+
def register(body)
|
|
78
|
+
body = body.to_s
|
|
79
|
+
n = (@counter += 1)
|
|
80
|
+
token = "[Pasted text ##{n} +#{body.lines.length} lines]"
|
|
81
|
+
@entries[token] = oversize?(body) ? overflow_to_file(n, body) : body
|
|
82
|
+
token
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Expands every registered placeholder in +text+ to its stored body
|
|
86
|
+
# (tier 1) or file pointer (tier 2) — the message-build seam. Consumed
|
|
87
|
+
# entries are dropped ("cleared on submit"); unknown placeholder-shaped
|
|
88
|
+
# text is left verbatim, so user-typed literals are never rewritten.
|
|
89
|
+
def expand(text)
|
|
90
|
+
return text unless text.is_a?(String) && @entries.keys.any? { |t| text.include?(t) }
|
|
91
|
+
|
|
92
|
+
text.gsub(TOKEN_RE) { |token| @entries.delete(token) || token }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The registered [token, body] pairs whose placeholder appears in +text+,
|
|
96
|
+
# CONSUMING them like #expand does (re-submitting from history later leaves
|
|
97
|
+
# the literal placeholder). Returned as an array of pairs so the tokens
|
|
98
|
+
# survive JSON round-trips intact (a token is not a valid symbol key).
|
|
99
|
+
# Empty array when +text+ carries no live placeholder.
|
|
100
|
+
def expansions_in(text)
|
|
101
|
+
return [] unless text.is_a?(String)
|
|
102
|
+
|
|
103
|
+
text.scan(TOKEN_RE).uniq.filter_map do |token|
|
|
104
|
+
body = @entries.delete(token)
|
|
105
|
+
[token, body] if body
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The [start, length] (codepoint) span of the registered placeholder
|
|
110
|
+
# covering the char just BEFORE +cursor+ in +buffer+, or nil. The
|
|
111
|
+
# composer's backspace uses it to delete a placeholder WHOLE — a
|
|
112
|
+
# half-eaten token would neither read nor expand. Only spans the store
|
|
113
|
+
# actually registered qualify; lookalike text the user typed is edited
|
|
114
|
+
# char-by-char as usual.
|
|
115
|
+
def placeholder_span(buffer, cursor)
|
|
116
|
+
return nil if @entries.empty? || buffer.nil?
|
|
117
|
+
|
|
118
|
+
pos = 0
|
|
119
|
+
while (m = TOKEN_RE.match(buffer, pos))
|
|
120
|
+
start = m.begin(0)
|
|
121
|
+
length = m[0].length
|
|
122
|
+
return [start, length] if @entries.key?(m[0]) && cursor > start && cursor <= start + length
|
|
123
|
+
|
|
124
|
+
pos = start + length
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def collapse_lines
|
|
132
|
+
positive(config&.paste_collapse_lines) || DEFAULT_COLLAPSE_LINES
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def threshold_tokens
|
|
136
|
+
positive(config&.paste_file_threshold_tokens) || DEFAULT_THRESHOLD_TOKENS
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Tier-2 gate: the same chars/4 estimate compaction runs on
|
|
140
|
+
# (Context::TokenBudget::CHARS_PER_TOKEN), so "a context share" here and
|
|
141
|
+
# the status bar / compactor agree on what a token is.
|
|
142
|
+
def oversize?(body)
|
|
143
|
+
(body.length / Context::TokenBudget::CHARS_PER_TOKEN) > threshold_tokens
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Write the oversized body to <home>/sessions/<id>/paste_N.txt and
|
|
147
|
+
# return the pointer line its token expands to. Best-effort: if the
|
|
148
|
+
# write fails for any reason the body is kept in memory (tier-1
|
|
149
|
+
# behavior) — a paste must never be lost to a disk hiccup.
|
|
150
|
+
def overflow_to_file(num, body)
|
|
151
|
+
dir = session_dir
|
|
152
|
+
FileUtils.mkdir_p(dir)
|
|
153
|
+
path = File.join(dir, "paste_#{num}.txt")
|
|
154
|
+
File.write(path, body)
|
|
155
|
+
"[Pasted text ##{num} saved to #{path} — too large to inline; read it with the read tool]"
|
|
156
|
+
rescue StandardError
|
|
157
|
+
body
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def session_dir
|
|
161
|
+
id = @session_source.respond_to?(:call) ? @session_source.call : @session_source
|
|
162
|
+
id = "pastes-#{Process.pid}" if id.nil? || id.to_s.empty?
|
|
163
|
+
File.join(Rubino.home_path, "sessions", id.to_s)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def config
|
|
167
|
+
@config || Rubino.configuration
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def positive(value)
|
|
171
|
+
v = value.to_i
|
|
172
|
+
v.positive? ? v : nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# Shared printing behaviour for terminal-based UI adapters.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses must implement #color_for(role) returning a Pastel method name
|
|
10
|
+
# (e.g. :cyan, :green) so that message formatting stays here while each
|
|
11
|
+
# adapter controls its own color scheme.
|
|
12
|
+
class PrinterBase < Base
|
|
13
|
+
def initialize
|
|
14
|
+
@pastel = Pastel.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def info(message) = puts_colored(color_for(:info), message)
|
|
18
|
+
def success(message) = puts_colored(color_for(:success), "✓ #{message}")
|
|
19
|
+
def warning(message) = puts_colored(color_for(:warning), "⚠ #{message}")
|
|
20
|
+
def error(message) = puts_colored(color_for(:error), "✗ #{message}")
|
|
21
|
+
def status(message) = puts_colored(color_for(:status), message)
|
|
22
|
+
|
|
23
|
+
def stream(chunk)
|
|
24
|
+
text = chunk[:text].to_s
|
|
25
|
+
$stdout.print text
|
|
26
|
+
$stdout.flush
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stream_end
|
|
30
|
+
$stdout.puts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
34
|
+
puts_colored(color_for(:tool), " → Running tool: #{name}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tool_finished(name, result: nil)
|
|
38
|
+
suffix = result ? " (#{result.truncated_preview})" : ""
|
|
39
|
+
puts_colored(color_for(:tool), " ← #{name} done#{suffix}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def compression_started(at: nil)
|
|
43
|
+
puts_colored(color_for(:muted), " ⟳ Compacting context...")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def compression_finished(metadata, at: nil)
|
|
47
|
+
saved = metadata[:saved_tokens] || 0
|
|
48
|
+
puts_colored(color_for(:muted), " ⟳ Context compacted (saved #{saved} tokens)")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def job_enqueued(_type) = nil
|
|
52
|
+
def job_started(_type) = nil
|
|
53
|
+
def job_finished(_type) = nil
|
|
54
|
+
|
|
55
|
+
def blank_line = $stdout.puts
|
|
56
|
+
|
|
57
|
+
# Default fallback. CLI overrides to render the
|
|
58
|
+
# `┄ HH:MM · mode → plan ┄` free-line variant.
|
|
59
|
+
def mode_changed(name, previous: nil)
|
|
60
|
+
arrow = previous && previous != name ? " #{previous} → #{name}" : " #{name}"
|
|
61
|
+
puts_colored(color_for(:muted), " ⟳ mode#{arrow}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Subclasses override to map a semantic role to a Pastel method symbol.
|
|
67
|
+
# @param role [Symbol] e.g. :info, :success, :warning, :error, :tool, :muted
|
|
68
|
+
# @return [Symbol, nil] Pastel method name, or nil to skip coloring
|
|
69
|
+
def color_for(_role)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def puts_colored(color, text)
|
|
74
|
+
line = color ? @pastel.send(color, text) : text
|
|
75
|
+
$stdout.puts line
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bottom_composer"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# The /probe (#58) and /agents probe (#146) wait indicator: reuse the UI's
|
|
8
|
+
# thinking-row machinery (UI::CLI) while a billed ephemeral peek runs, and
|
|
9
|
+
# stay silent on Null/API adapters or piped stdout. Mixed into both the
|
|
10
|
+
# CLI::ChatCommand and Commands::Executor probe paths, which carried a
|
|
11
|
+
# byte-identical pair of guards.
|
|
12
|
+
#
|
|
13
|
+
# The peek is SYNCHRONOUS (seconds of model wait) and runs at the idle REPL
|
|
14
|
+
# with no live composer, so keystrokes typed during it used to smear onto the
|
|
15
|
+
# thinking row — there was no `❯` to echo into (#221). To own input the same
|
|
16
|
+
# way a streaming turn does, the wait now runs under a transient bottom
|
|
17
|
+
# composer: it draws a real `❯`, its reader thread buffers keystrokes, and
|
|
18
|
+
# the thinking ticker paints into its transient row via #set_partial (the
|
|
19
|
+
# #169 seam) instead of colliding with the input. Anything typed is recovered
|
|
20
|
+
# into the next idle prompt's draft, so input is never lost.
|
|
21
|
+
module ProbeWaitIndicator
|
|
22
|
+
def probe_thinking_started(ui)
|
|
23
|
+
return unless $stdout.tty? && ui.respond_to?(:thinking_started)
|
|
24
|
+
|
|
25
|
+
# Own the bottom of the screen for the wait so typed input lands in a
|
|
26
|
+
# visible `❯` instead of smearing onto the ticker row (#221). Started
|
|
27
|
+
# BEFORE the ticker so #thinking_started paints into the composer's
|
|
28
|
+
# transient row. A standalone editor (no completion/history wiring) — it
|
|
29
|
+
# only needs to echo input and host the ticker. Best-effort: a terminal
|
|
30
|
+
# that can't host a raw composer (no real device, sized double) just
|
|
31
|
+
# keeps the old ticker-only wait — never break the probe.
|
|
32
|
+
@probe_composer = build_probe_composer
|
|
33
|
+
ui.thinking_started
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def probe_thinking_finished(ui)
|
|
37
|
+
ui.thinking_finished if ui.respond_to?(:thinking_finished)
|
|
38
|
+
composer = @probe_composer
|
|
39
|
+
@probe_composer = nil
|
|
40
|
+
return unless composer
|
|
41
|
+
|
|
42
|
+
# Hand whatever the user typed during the wait to the next idle prompt as
|
|
43
|
+
# a draft, so the buffered text reappears in `❯` after the peek (no data
|
|
44
|
+
# loss). Read the buffer before #stop tears the composer down and
|
|
45
|
+
# restores cooked mode + a clean line. Best-effort: tearing down a
|
|
46
|
+
# degraded composer must never break the probe (mirrors the start guard).
|
|
47
|
+
typed = begin
|
|
48
|
+
composer.buffer.dup
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
begin
|
|
53
|
+
composer.stop
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
ui.stash_probe_draft(typed) if typed && !typed.empty? && ui.respond_to?(:stash_probe_draft)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Builds and starts the transient probe composer, or nil when the terminal
|
|
63
|
+
# can't host a raw input reader (no real stdin/stdout device — piped, or a
|
|
64
|
+
# test double). Best-effort: a failed start degrades to the old ticker-only
|
|
65
|
+
# wait rather than breaking the probe.
|
|
66
|
+
def build_probe_composer
|
|
67
|
+
return nil unless $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
68
|
+
|
|
69
|
+
BottomComposer.new(input_queue: [], echo: :prompt).start
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# The {BottomComposer}'s stack of EXPLICITLY-queued messages (Alt+Enter /
|
|
8
|
+
# "/queued <msg>") awaiting their turn, rendered as live "⏳ queued: <msg>"
|
|
9
|
+
# rows above the input — never committed to scrollback. Wraps the list the
|
|
10
|
+
# chat loop SHARES across the per-turn composers, so the indicator survives
|
|
11
|
+
# a composer teardown and is removed (and the item committed as a normal
|
|
12
|
+
# message) when the queued item's turn runs. Pure state + row formatting:
|
|
13
|
+
# the composer owns the render mutex and the redraw around every mutation.
|
|
14
|
+
class QueuedIndicators
|
|
15
|
+
# Hard cap on the visible rows so a burst of explicit queues can never
|
|
16
|
+
# push the prompt off-screen. Beyond the cap, a dim count row stands in
|
|
17
|
+
# for the overflow.
|
|
18
|
+
MAX_ROWS = 4
|
|
19
|
+
|
|
20
|
+
# @param list [Array<String>] the shared (or private) pending stack.
|
|
21
|
+
def initialize(list)
|
|
22
|
+
@list = list
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def any?
|
|
26
|
+
@list.any?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Add +msg+ to the pending stack. +front+ jumps the queue (the
|
|
30
|
+
# interrupt-by-default Enter): its indicator leads the pending rows so
|
|
31
|
+
# the visible order matches the run order (#129).
|
|
32
|
+
def push(msg, front: false)
|
|
33
|
+
front ? @list.unshift(msg) : @list.push(msg)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove the FIRST pending indicator matching +msg+ (the chat loop calls
|
|
37
|
+
# through when the queued item's turn starts). Returns the removed
|
|
38
|
+
# message, or nil when none matched.
|
|
39
|
+
def remove(msg)
|
|
40
|
+
idx = @list.index(msg)
|
|
41
|
+
return unless idx
|
|
42
|
+
|
|
43
|
+
@list.delete_at(idx)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The "⏳ queued: <msg>" indicator rows for the pending stack, in
|
|
47
|
+
# submission order. House grammar: the ⏳ glyph, dim. Capped to MAX_ROWS
|
|
48
|
+
# with a dim "┄ +N more queued ┄" overflow row.
|
|
49
|
+
def rows
|
|
50
|
+
return [] if @list.empty?
|
|
51
|
+
|
|
52
|
+
shown = @list.first(MAX_ROWS)
|
|
53
|
+
rows = shown.map { |msg| pastel.dim("⏳ queued: #{msg}") }
|
|
54
|
+
overflow = @list.size - shown.size
|
|
55
|
+
rows << pastel.dim("┄ +#{overflow} more queued ┄") if overflow.positive?
|
|
56
|
+
rows
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def pastel
|
|
62
|
+
@pastel ||= Pastel.new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# Formats the dim one-line status bar the {BottomComposer} renders BELOW
|
|
8
|
+
# the pinned input row:
|
|
9
|
+
#
|
|
10
|
+
# default · minimax-m3 · ctx ~8.4k/64k (13%)
|
|
11
|
+
#
|
|
12
|
+
# Content: the session MODE leads (the prompt chip moved here in the
|
|
13
|
+
# Rail-rubino redesign — the prompt is a constant "▍❯ "), then the
|
|
14
|
+
# optional branch / active-skill tokens, the resolved model id and the
|
|
15
|
+
# context saturation — the SAME estimate the compaction logic runs on
|
|
16
|
+
# (Context::TokenBudget: chars/4 over the session messages, window from
|
|
17
|
+
# `model.context_length` / `context.max_tokens` with the TokenBudget
|
|
18
|
+
# default). The caller passes the values; this module only formats. ONE
|
|
19
|
+
# encoding of the saturation (P9): the used/window pair, with the
|
|
20
|
+
# percentage in parentheses — omitted entirely below 1% so a fresh
|
|
21
|
+
# session doesn't carry a "(0%)". With no usable window the bar degrades
|
|
22
|
+
# to `~8.4k tok`.
|
|
23
|
+
#
|
|
24
|
+
# Color: everything dim, except the mode token when it carries risk
|
|
25
|
+
# (plan yellow, yolo red — subtle, no bold) and the percentage when high
|
|
26
|
+
# — yellow ≥ 70%, red ≥ 90% — matching the existing pastel usage. Each
|
|
27
|
+
# segment is styled SEPARATELY (never a colored span nested inside one
|
|
28
|
+
# dim span) so a colored reset can't strip the dim from the rest of the
|
|
29
|
+
# line. The single leading space tucks the bar one column in, under the
|
|
30
|
+
# input rail.
|
|
31
|
+
module StatusBar
|
|
32
|
+
WARN_PCT = 70
|
|
33
|
+
CRIT_PCT = 90
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# The styled status line. +chips+ carries the leading session-context
|
|
38
|
+
# tokens — :mode (the mode token shown FIRST; plan/yolo carry their
|
|
39
|
+
# accent), :branch (the short id after a `/branch` fork) and :skill
|
|
40
|
+
# (the active skill, rendered "skill <name>") — each omitted when
|
|
41
|
+
# nil/absent, so callers without that context get the bare
|
|
42
|
+
# model-and-ctx bar. +tokens+ is the estimated tokens in the
|
|
43
|
+
# conversation; +window+ the model's context window (nil/0 ⇒ unknown,
|
|
44
|
+
# percentage omitted). Returns a string ready to draw (leading indent
|
|
45
|
+
# included) — the composer clamps/omits it per terminal width.
|
|
46
|
+
def render(model:, tokens:, window: nil, chips: {}, pastel: Pastel.new)
|
|
47
|
+
segments = chip_segments(chips, pastel)
|
|
48
|
+
segments << pastel.dim(model.to_s)
|
|
49
|
+
if window.to_i.positive?
|
|
50
|
+
pct = (tokens.to_i * 100.0 / window.to_i).round
|
|
51
|
+
ctx = pastel.dim("ctx ~#{abbreviate(tokens)}/#{abbreviate(window)}")
|
|
52
|
+
ctx += " #{pastel.dim("(")}#{percent_segment(pct, pastel)}#{pastel.dim(")")}" if pct >= 1
|
|
53
|
+
segments << ctx
|
|
54
|
+
else
|
|
55
|
+
segments << pastel.dim("~#{abbreviate(tokens)} tok")
|
|
56
|
+
end
|
|
57
|
+
" #{segments.join(pastel.dim(" · "))}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The leading session-context segments, in fixed order: mode, branch,
|
|
61
|
+
# skill (each omitted when absent). The mode token is dim for default
|
|
62
|
+
# and carries a subtle color accent when the mode carries risk — plan
|
|
63
|
+
# yellow, yolo red (the same red as the input rail's brand accent).
|
|
64
|
+
def chip_segments(chips, pastel)
|
|
65
|
+
segments = []
|
|
66
|
+
segments << mode_segment(chips[:mode], pastel) if chips[:mode]
|
|
67
|
+
segments << pastel.dim("branch:#{chips[:branch]}") if chips[:branch]
|
|
68
|
+
segments << pastel.dim("skill #{chips[:skill]}") if chips[:skill]
|
|
69
|
+
segments
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mode_segment(mode, pastel)
|
|
73
|
+
case mode.to_s
|
|
74
|
+
when "plan" then pastel.yellow("plan")
|
|
75
|
+
when "yolo" then pastel.red("yolo")
|
|
76
|
+
else pastel.dim(mode.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The "<pct>%" segment: dim normally, yellow from WARN_PCT, red from
|
|
81
|
+
# CRIT_PCT — the at-a-glance compaction warning.
|
|
82
|
+
def percent_segment(pct, pastel)
|
|
83
|
+
text = "#{pct}%"
|
|
84
|
+
return pastel.red(text) if pct >= CRIT_PCT
|
|
85
|
+
return pastel.yellow(text) if pct >= WARN_PCT
|
|
86
|
+
|
|
87
|
+
pastel.dim(text)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Human token count: 842 → "842", 8421 → "8.4k", 128_000 → "128k".
|
|
91
|
+
def abbreviate(count)
|
|
92
|
+
n = count.to_i
|
|
93
|
+
return n.to_s if n < 1000
|
|
94
|
+
|
|
95
|
+
k = n / 1000.0
|
|
96
|
+
k >= 100 ? "#{k.round}k" : format("%.1fk", k).sub(".0k", "k")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|