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,506 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
require "tty-table"
|
|
6
|
+
require "unicode/display_width"
|
|
7
|
+
begin
|
|
8
|
+
require "io/console"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# io/console is part of stdlib; if it's somehow unavailable we fall back
|
|
11
|
+
# to the 80-col default and never crash.
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Rubino
|
|
15
|
+
module UI
|
|
16
|
+
# Renders a markdown string into a list of styled token-lines.
|
|
17
|
+
#
|
|
18
|
+
# Output shape:
|
|
19
|
+
# render(text) -> [LineTokens, LineTokens, ...]
|
|
20
|
+
# LineTokens = [[String, StyleHash], ...]
|
|
21
|
+
# StyleHash = { fg:, bg:, modifiers: [...] } (any subset; nil ≈ default)
|
|
22
|
+
#
|
|
23
|
+
# The caller turns these into ANSI-colored strings via Pastel.
|
|
24
|
+
# Keeping the output as plain Ruby data lets the renderer be tested
|
|
25
|
+
# without a real terminal.
|
|
26
|
+
#
|
|
27
|
+
# Coverage: headings 1-3, paragraphs, **bold**, *italic*, `inline code`,
|
|
28
|
+
# ```fenced``` code blocks, ordered/unordered lists (one level), block
|
|
29
|
+
# quotes, [links](url), horizontal rules. Anything unrecognized falls
|
|
30
|
+
# back to its raw text content, never blowing up.
|
|
31
|
+
class MarkdownRenderer
|
|
32
|
+
# Map of common GFM language hints we don't need to special-case. Listed
|
|
33
|
+
# only to acknowledge: rendering treats all languages identically (no
|
|
34
|
+
# syntax highlighting — too much code for marginal gain).
|
|
35
|
+
|
|
36
|
+
# Smallest width we'll ask TTY::Table to fit into. Below this, resize
|
|
37
|
+
# tends to raise (a column needs at least ~2 cols + borders); we clamp up
|
|
38
|
+
# to keep the headless/extreme-narrow paths from blowing up.
|
|
39
|
+
MIN_TABLE_WIDTH = 20
|
|
40
|
+
|
|
41
|
+
DEFAULT_WIDTH = 80
|
|
42
|
+
|
|
43
|
+
# @param width [Integer, nil] the column budget tables must fit into. When
|
|
44
|
+
# nil we detect the terminal width (IO.console winsize), falling back to
|
|
45
|
+
# 80 so the renderer still works headless / without a real terminal.
|
|
46
|
+
def initialize(width: nil)
|
|
47
|
+
@width = width || detect_width
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render(text)
|
|
51
|
+
return [] if text.nil? || text.to_s.strip.empty?
|
|
52
|
+
|
|
53
|
+
doc = Kramdown::Document.new(normalize(text.to_s), input: "GFM", auto_ids: false, hard_wrap: false)
|
|
54
|
+
block_lines(doc.root).reject { |line| line == :drop }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
# Parser failure -> degrade to plain text rather than break the UI.
|
|
57
|
+
text.to_s.split("\n", -1).map { |l| [[l, nil]] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# A GFM pipe-table separator row, e.g. "|---|:--:|---|" or "---|---".
|
|
63
|
+
TABLE_SEP_RE = /\A\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*\z/
|
|
64
|
+
|
|
65
|
+
# Kramdown's GFM parser only recognizes a pipe table when its header row is
|
|
66
|
+
# preceded by a blank line. LLMs frequently emit a table glued directly to
|
|
67
|
+
# the previous line ("Results:\n| a | b |\n|---|---|"), which then degrades
|
|
68
|
+
# to raw pipe text with the separator turned into an em-dash (L4). We insert
|
|
69
|
+
# the missing blank line before any header row that is followed by a
|
|
70
|
+
# separator row, so tables always parse.
|
|
71
|
+
def normalize(text)
|
|
72
|
+
lines = text.split("\n", -1)
|
|
73
|
+
out = []
|
|
74
|
+
lines.each_with_index do |line, i|
|
|
75
|
+
nxt = lines[i + 1]
|
|
76
|
+
if nxt && line.include?("|") && nxt.match?(TABLE_SEP_RE) &&
|
|
77
|
+
!out.empty? && !out.last.strip.empty? && !out.last.match?(TABLE_SEP_RE)
|
|
78
|
+
out << ""
|
|
79
|
+
end
|
|
80
|
+
out << line
|
|
81
|
+
end
|
|
82
|
+
out.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Terminal column count, headless-safe. Never raises: if there's no
|
|
86
|
+
# console (tests, pipes, CI) we fall back to 80.
|
|
87
|
+
def detect_width
|
|
88
|
+
IO.console&.winsize&.last || DEFAULT_WIDTH
|
|
89
|
+
rescue StandardError
|
|
90
|
+
DEFAULT_WIDTH
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Element -> [LineTokens, LineTokens, ...]
|
|
94
|
+
def block_lines(el)
|
|
95
|
+
case el.type
|
|
96
|
+
when :root
|
|
97
|
+
el.children.flat_map { |c| block_lines(c) }
|
|
98
|
+
when :header
|
|
99
|
+
header_lines(el)
|
|
100
|
+
when :p
|
|
101
|
+
wrap_lines(paragraph_lines(el))
|
|
102
|
+
when :ul
|
|
103
|
+
list_lines(el, ordered: false)
|
|
104
|
+
when :ol
|
|
105
|
+
list_lines(el, ordered: true)
|
|
106
|
+
when :blockquote
|
|
107
|
+
blockquote_lines(el)
|
|
108
|
+
when :codeblock
|
|
109
|
+
codeblock_lines(el)
|
|
110
|
+
when :hr
|
|
111
|
+
[[["─" * 60, { fg: :gray }]]]
|
|
112
|
+
when :table
|
|
113
|
+
table_lines(el)
|
|
114
|
+
when :blank
|
|
115
|
+
[[]]
|
|
116
|
+
when :html_element
|
|
117
|
+
# Treat HTML as paragraph of its rendered text content.
|
|
118
|
+
wrap_lines(paragraph_lines(el))
|
|
119
|
+
else
|
|
120
|
+
# Unknown block: try to recover any inline content.
|
|
121
|
+
tokens = inline_tokens(el.children, {})
|
|
122
|
+
tokens_to_lines(tokens)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def header_lines(el)
|
|
127
|
+
level = el.options[:level].to_i.clamp(1, 6)
|
|
128
|
+
style = case level
|
|
129
|
+
when 1 then { fg: :cyan, modifiers: [:bold] }
|
|
130
|
+
when 2 then { fg: :cyan, modifiers: [:bold] }
|
|
131
|
+
when 3 then { fg: :white, modifiers: [:bold] }
|
|
132
|
+
else { fg: :white, modifiers: %i[bold dim] }
|
|
133
|
+
end
|
|
134
|
+
# Headings are STYLED, not prefixed with literal "#" markers (L3): the
|
|
135
|
+
# raw "##" would otherwise show through verbatim. A leading bar gives a
|
|
136
|
+
# subtle visual cue without leaking markdown syntax.
|
|
137
|
+
body = inline_tokens(el.children, style)
|
|
138
|
+
wrap_lines(tokens_to_lines([["▌ ", style]] + body), hang: 2)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Raw (un-wrapped) paragraph lines. Wrapping is applied by the CALLER
|
|
142
|
+
# (block_lines for a top-level :p, list_lines for an :li) so a list item's
|
|
143
|
+
# prose is wrapped ONCE, with the marker, instead of being wrapped twice
|
|
144
|
+
# (which dropped the hanging indent on continuation lines).
|
|
145
|
+
def paragraph_lines(el)
|
|
146
|
+
tokens_to_lines(inline_tokens(el.children, {}))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def list_lines(el, ordered:)
|
|
150
|
+
out = []
|
|
151
|
+
el.children.each_with_index do |li, idx|
|
|
152
|
+
next unless li.type == :li
|
|
153
|
+
|
|
154
|
+
marker = ordered ? "#{idx + 1}. " : "• "
|
|
155
|
+
indent = " " * marker.length
|
|
156
|
+
# Inner content UN-wrapped (a :p yields raw tokens_to_lines): we wrap
|
|
157
|
+
# ONCE below with the marker + hanging indent, so a long item breaks on
|
|
158
|
+
# words under the marker instead of being wrapped twice (which lost the
|
|
159
|
+
# continuation indent).
|
|
160
|
+
item_lines = li.children.flat_map { |c| c.type == :p ? paragraph_lines(c) : block_lines(c) }
|
|
161
|
+
# Strip trailing blank line a kramdown :p inside :li sometimes adds.
|
|
162
|
+
item_lines.pop while item_lines.last == []
|
|
163
|
+
|
|
164
|
+
if item_lines.empty?
|
|
165
|
+
out << [[marker, { fg: :gray }]]
|
|
166
|
+
else
|
|
167
|
+
item_lines.each_with_index do |line_tokens, i|
|
|
168
|
+
prefix = i.zero? ? marker : indent
|
|
169
|
+
line = [[prefix, { fg: :gray }]] + line_tokens
|
|
170
|
+
out.concat(wrap_lines([line], hang: marker.length))
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
out
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def blockquote_lines(el)
|
|
178
|
+
# Inner lines UN-wrapped (a :p child yields raw tokens_to_lines); we add
|
|
179
|
+
# the "│ " prefix and wrap ONCE here with a hanging indent so a long
|
|
180
|
+
# quote breaks on words under the bar instead of being wrapped twice.
|
|
181
|
+
inner = el.children.flat_map { |c| c.type == :p ? paragraph_lines(c) : block_lines(c) }
|
|
182
|
+
inner.flat_map do |line_tokens|
|
|
183
|
+
dimmed = line_tokens.map { |text, style| [text, merge_style(style, fg: :gray, modifiers: [:italic])] }
|
|
184
|
+
wrap_lines([[["│ ", { fg: :gray }]] + dimmed], hang: 2)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def codeblock_lines(el)
|
|
189
|
+
text = el.value.to_s
|
|
190
|
+
lines = text.split("\n", -1)
|
|
191
|
+
# kramdown's fenced codeblock value ends with a trailing newline -> empty last line. Drop it.
|
|
192
|
+
lines.pop if lines.last == ""
|
|
193
|
+
|
|
194
|
+
lang = el.options[:lang].to_s
|
|
195
|
+
out = []
|
|
196
|
+
out << if lang.empty?
|
|
197
|
+
[["┌─ code ", { fg: :gray }], ["─" * 40, { fg: :gray }]]
|
|
198
|
+
else
|
|
199
|
+
[["┌─ ", { fg: :gray }], [lang, { fg: :gray, modifiers: [:italic] }], [" ", { fg: :gray }],
|
|
200
|
+
["─" * 40, { fg: :gray }]]
|
|
201
|
+
end
|
|
202
|
+
lines.each do |line|
|
|
203
|
+
out << [["│ ", { fg: :gray }], [line, { fg: :bright_white }]]
|
|
204
|
+
end
|
|
205
|
+
out << [["└", { fg: :gray }], ["─" * 48, { fg: :gray }]]
|
|
206
|
+
out
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# GFM tables: flatten each cell to a plain string (inline bold/italic is
|
|
210
|
+
# dropped inside cells — alignment matters more than per-cell styling),
|
|
211
|
+
# then let TTY::Table reallocate column widths to fit @width and wrap long
|
|
212
|
+
# cells, so the table never overflows the terminal. The rendered string is
|
|
213
|
+
# split back into our token format ([[line, { fg: :gray }]] per line).
|
|
214
|
+
def table_lines(el)
|
|
215
|
+
header, rows = extract_table(el)
|
|
216
|
+
return [[]] if header.nil? && rows.empty?
|
|
217
|
+
|
|
218
|
+
ncols = ([header&.size || 0] + rows.map(&:size)).max
|
|
219
|
+
return [[]] if ncols.zero?
|
|
220
|
+
|
|
221
|
+
header = pad_cells(header, ncols) if header
|
|
222
|
+
rows = rows.map { |r| pad_cells(r, ncols) }
|
|
223
|
+
|
|
224
|
+
rendered = render_tty_table(header, rows)
|
|
225
|
+
return rendered if rendered
|
|
226
|
+
|
|
227
|
+
# Pathological input (e.g. TTY::Table resize raising even after clamp):
|
|
228
|
+
# degrade to a plain join of the cells, never raise.
|
|
229
|
+
fallback_table_lines(header, rows)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# element -> [header (Array<String> or nil), rows (Array<Array<String>>)]
|
|
233
|
+
def extract_table(el)
|
|
234
|
+
header = nil
|
|
235
|
+
rows = []
|
|
236
|
+
el.children.each do |section|
|
|
237
|
+
next unless %i[thead tbody tfoot].include?(section.type)
|
|
238
|
+
|
|
239
|
+
section.children.each do |tr|
|
|
240
|
+
next unless tr.type == :tr
|
|
241
|
+
|
|
242
|
+
cells = tr.children.select { |c| %i[td th].include?(c.type) }
|
|
243
|
+
.map { |cell| flatten_cell(cell) }
|
|
244
|
+
if section.type == :thead && header.nil?
|
|
245
|
+
header = cells
|
|
246
|
+
else
|
|
247
|
+
rows << cells
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
[header, rows]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A cell's inline children -> a single plain string. Hard breaks (:br)
|
|
255
|
+
# become spaces so TTY::Table can re-wrap freely.
|
|
256
|
+
def flatten_cell(cell)
|
|
257
|
+
inline_tokens(cell.children, {})
|
|
258
|
+
.map { |t, _| t == :br ? " " : t.to_s }
|
|
259
|
+
.join
|
|
260
|
+
.strip
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def pad_cells(cells, ncols)
|
|
264
|
+
return Array.new(ncols, "") if cells.nil?
|
|
265
|
+
|
|
266
|
+
cells = cells.dup
|
|
267
|
+
cells << "" while cells.size < ncols
|
|
268
|
+
cells
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Returns Array<LineTokens> on success, or nil if TTY::Table can't render
|
|
272
|
+
# (so the caller can fall back).
|
|
273
|
+
def render_tty_table(header, rows)
|
|
274
|
+
fit = [@width.to_i, MIN_TABLE_WIDTH].max
|
|
275
|
+
table = TTY::Table.new(header: header, rows: rows.empty? ? [Array.new(header&.size || 1, "")] : rows)
|
|
276
|
+
# No horizontal padding: TTY::Table's resize budget ignores padding
|
|
277
|
+
# (it would overflow @width by ~2 cols per row), so we omit it to keep
|
|
278
|
+
# the fit-to-width guarantee. Cells still get the border gutters.
|
|
279
|
+
str = table.render(:unicode, resize: true, width: fit, multiline: true)
|
|
280
|
+
return nil if str.nil?
|
|
281
|
+
|
|
282
|
+
str.split("\n").map { |line| [[line, { fg: :gray }]] }
|
|
283
|
+
rescue StandardError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Last-resort plain rendering used only if TTY::Table fails. Joins cells
|
|
288
|
+
# with " │ " and keeps a header separator; no width fitting (the resize
|
|
289
|
+
# path already covers the normal case).
|
|
290
|
+
def fallback_table_lines(header, rows)
|
|
291
|
+
all = (header ? [header] : []) + rows
|
|
292
|
+
widths = Array.new(all.map(&:size).max || 0, 0)
|
|
293
|
+
all.each { |r| r.each_with_index { |c, i| widths[i] = [widths[i], c.to_s.length].max } }
|
|
294
|
+
|
|
295
|
+
out = []
|
|
296
|
+
join_row = lambda do |cells|
|
|
297
|
+
cells.each_with_index.flat_map do |c, i|
|
|
298
|
+
tok = [[c.to_s.ljust(widths[i]), { fg: :gray }]]
|
|
299
|
+
tok << [" │ ", { fg: :gray }] if i < cells.size - 1
|
|
300
|
+
tok
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
out << join_row.call(header) if header
|
|
304
|
+
if header
|
|
305
|
+
out << widths.each_with_index.flat_map do |w, i|
|
|
306
|
+
t = [["─" * w, { fg: :gray }]]
|
|
307
|
+
t << ["─┼─", { fg: :gray }] if i < widths.size - 1
|
|
308
|
+
t
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
rows.each { |r| out << join_row.call(r) }
|
|
312
|
+
out
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# ---- inline ---------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
# children -> flat list of [String, StyleHash] tokens. A token with text
|
|
318
|
+
# equal to :br is a hard line break (split lines around it).
|
|
319
|
+
def inline_tokens(children, parent_style)
|
|
320
|
+
children.flat_map { |el| inline_for(el, parent_style) }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def inline_for(el, parent_style)
|
|
324
|
+
case el.type
|
|
325
|
+
when :text
|
|
326
|
+
text_tokens(el.value.to_s, parent_style)
|
|
327
|
+
when :strong
|
|
328
|
+
inline_tokens(el.children, merge_style(parent_style, modifiers: [:bold]))
|
|
329
|
+
when :em
|
|
330
|
+
inline_tokens(el.children, merge_style(parent_style, modifiers: [:italic]))
|
|
331
|
+
when :codespan
|
|
332
|
+
[[el.value.to_s, merge_style(parent_style, fg: :yellow)]]
|
|
333
|
+
when :a
|
|
334
|
+
link_tokens(el, parent_style)
|
|
335
|
+
when :smart_quote
|
|
336
|
+
[[smart_quote_char(el.value), parent_style]]
|
|
337
|
+
when :typographic_sym
|
|
338
|
+
[[typographic_sym_char(el.value), parent_style]]
|
|
339
|
+
when :entity
|
|
340
|
+
[[el.value.char.to_s, parent_style]]
|
|
341
|
+
when :br, :linebreak
|
|
342
|
+
[[:br, nil]]
|
|
343
|
+
when :softbreak
|
|
344
|
+
[[" ", parent_style]]
|
|
345
|
+
when :html_element
|
|
346
|
+
# Render inline HTML as its text content with parent style.
|
|
347
|
+
inline_tokens(el.children, parent_style)
|
|
348
|
+
else
|
|
349
|
+
# Recurse into anything else (e.g. nested em/strong, raw_text)
|
|
350
|
+
inline_tokens(el.children, parent_style)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def link_tokens(el, parent_style)
|
|
355
|
+
url = el.attr["href"].to_s
|
|
356
|
+
text_st = merge_style(parent_style, fg: :cyan, modifiers: [:underline])
|
|
357
|
+
text_tok = inline_tokens(el.children, text_st)
|
|
358
|
+
return text_tok if url.empty?
|
|
359
|
+
|
|
360
|
+
# If the visible text equals the URL, don't repeat it.
|
|
361
|
+
flat = text_tok.map { |t, _| t }.join
|
|
362
|
+
return text_tok if flat == url
|
|
363
|
+
|
|
364
|
+
text_tok + [[" (", { fg: :gray }], [url, { fg: :gray, modifiers: [:underline] }], [")", { fg: :gray }]]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Plain text -> tokens, with embedded newlines becoming :br breaks.
|
|
368
|
+
def text_tokens(text, style)
|
|
369
|
+
return [[text, style]] unless text.include?("\n")
|
|
370
|
+
|
|
371
|
+
parts = text.split("\n", -1)
|
|
372
|
+
tokens = []
|
|
373
|
+
parts.each_with_index do |part, i|
|
|
374
|
+
tokens << [part, style] unless part.empty?
|
|
375
|
+
tokens << [:br, nil] if i < parts.length - 1
|
|
376
|
+
end
|
|
377
|
+
tokens
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Word-wrap each LineTokens to @width, breaking on whitespace only (never
|
|
381
|
+
# mid-word — the terminal would otherwise hard-wrap at the column edge and
|
|
382
|
+
# split words, L2). Continuation lines are indented by `hang` columns so
|
|
383
|
+
# list items / headings stay visually aligned under their first line. A
|
|
384
|
+
# single word longer than the budget is left intact (an over-long line
|
|
385
|
+
# beats a meaningless mid-word split), mirroring the /skills wrapper (B8).
|
|
386
|
+
def wrap_lines(lines, hang: 0)
|
|
387
|
+
lines.flat_map { |line| wrap_one(line, hang) }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def wrap_one(line, hang)
|
|
391
|
+
return [line] if line.empty?
|
|
392
|
+
|
|
393
|
+
width = @width.to_i
|
|
394
|
+
return [line] if width <= 0 || line_length(line) <= width
|
|
395
|
+
|
|
396
|
+
words = words_of(line) # word groups: [[[frag, style], ...], ...]
|
|
397
|
+
return [line] if words.empty?
|
|
398
|
+
|
|
399
|
+
indent = " " * hang
|
|
400
|
+
out = []
|
|
401
|
+
cur = []
|
|
402
|
+
cur_len = 0
|
|
403
|
+
|
|
404
|
+
words.each do |word|
|
|
405
|
+
word_len = word.sum { |frag, _| display_width(frag) }
|
|
406
|
+
sp = cur.empty? ? 0 : 1
|
|
407
|
+
if cur_len + sp + word_len > width && !cur.empty?
|
|
408
|
+
out << cur
|
|
409
|
+
cur = hang.zero? ? [] : [[indent, nil]]
|
|
410
|
+
cur_len = hang
|
|
411
|
+
sp = 0
|
|
412
|
+
end
|
|
413
|
+
cur << [" ", nil] if sp == 1
|
|
414
|
+
cur.concat(word)
|
|
415
|
+
cur_len += sp + word_len
|
|
416
|
+
end
|
|
417
|
+
out << cur unless cur.empty?
|
|
418
|
+
out.empty? ? [line] : out
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Flatten a LineTokens to a list of WORD GROUPS — each an array of styled
|
|
422
|
+
# fragments [[text, style], ...] — splitting ONLY at real whitespace
|
|
423
|
+
# (collapsed to single breaks the wrapper re-inserts as spaces). Adjacent
|
|
424
|
+
# inline tokens with NO whitespace between them stay glued in ONE group:
|
|
425
|
+
# kramdown emits `don’t` as three tokens ("don", :smart_quote ’, "t"),
|
|
426
|
+
# and treating each token as its own word made the wrapper re-join them
|
|
427
|
+
# with injected spaces ("don ’ t", #104). Styles are carried per-fragment
|
|
428
|
+
# so bold/italic survive wrapping.
|
|
429
|
+
def words_of(line)
|
|
430
|
+
words = []
|
|
431
|
+
glue = false # the previous fragment did NOT end in whitespace
|
|
432
|
+
line.each do |text, style|
|
|
433
|
+
if text == :br
|
|
434
|
+
glue = false
|
|
435
|
+
next
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
str = text.to_s
|
|
439
|
+
next if str.empty?
|
|
440
|
+
|
|
441
|
+
parts = str.split(/\s+/).reject(&:empty?)
|
|
442
|
+
if parts.empty? # whitespace-only token: a break, never a word
|
|
443
|
+
glue = false
|
|
444
|
+
next
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
leading = str.match?(/\A\s/)
|
|
448
|
+
parts.each_with_index do |w, i|
|
|
449
|
+
if i.zero? && glue && !leading
|
|
450
|
+
words.last << [w, style]
|
|
451
|
+
else
|
|
452
|
+
words << [[w, style]]
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
glue = !str.match?(/\s\z/)
|
|
456
|
+
end
|
|
457
|
+
words
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def line_length(line)
|
|
461
|
+
line.sum { |text, _| text == :br ? 0 : display_width(text) }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Terminal display columns for a string (CJK/full-width/wide emoji count
|
|
465
|
+
# as 2, zero-width/combining as 0). ASCII is 1:1 identical to String#length
|
|
466
|
+
# so normal text wrapping is unchanged.
|
|
467
|
+
def display_width(str)
|
|
468
|
+
Unicode::DisplayWidth.of(str.to_s)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def tokens_to_lines(tokens)
|
|
472
|
+
lines = [[]]
|
|
473
|
+
tokens.each do |text, style|
|
|
474
|
+
if text == :br
|
|
475
|
+
lines << []
|
|
476
|
+
else
|
|
477
|
+
lines.last << [text, style]
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
lines
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def merge_style(base, **add)
|
|
484
|
+
base ||= {}
|
|
485
|
+
out = base.dup
|
|
486
|
+
add.each do |k, v|
|
|
487
|
+
if k == :modifiers
|
|
488
|
+
out[:modifiers] = ((out[:modifiers] || []) | v).uniq
|
|
489
|
+
else
|
|
490
|
+
out[k] = v
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
out
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def smart_quote_char(sym)
|
|
497
|
+
{ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”" }[sym] || ""
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def typographic_sym_char(sym)
|
|
501
|
+
{ mdash: "—", ndash: "–", hellip: "…", laquo: "«", raquo: "»",
|
|
502
|
+
laquo_space: "« ", raquo_space: " »" }[sym] || ""
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Attention notifications for the moments the agent needs human eyes:
|
|
6
|
+
# a long agentic turn finishing, an approval prompt parking the run on a
|
|
7
|
+
# human decision, or a background subagent blocking on the human (an
|
|
8
|
+
# escalated ask_parent).
|
|
9
|
+
#
|
|
10
|
+
# Channels — mirroring the dominant pattern across coding agents (Claude
|
|
11
|
+
# Code's terminal bell + hooks, Codex's notify hook, aider's
|
|
12
|
+
# --notifications):
|
|
13
|
+
# * terminal bell (BEL, "\a") — default on. BEL never moves the cursor,
|
|
14
|
+
# so it is safe even while the bottom composer owns the screen; it is
|
|
15
|
+
# still routed to the composer's REAL output (never the StdoutProxy,
|
|
16
|
+
# whose partial-line buffer would re-ring the byte on every repaint)
|
|
17
|
+
# and NEVER into a pipe.
|
|
18
|
+
# * OSC 9 ("\e]9;msg\a") — additionally emitted on iTerm2
|
|
19
|
+
# (TERM_PROGRAM=iTerm.app), which renders it as a native macOS
|
|
20
|
+
# notification.
|
|
21
|
+
# * notifications.command — an optional shell command spawned
|
|
22
|
+
# NON-BLOCKING and best-effort per event with RUBINO_EVENT
|
|
23
|
+
# (turn_finished | needs_approval | blocked) and RUBINO_MESSAGE in
|
|
24
|
+
# its environment; failures are swallowed to the structured log.
|
|
25
|
+
# Covers osascript / notify-send users.
|
|
26
|
+
#
|
|
27
|
+
# Spam control: events landing within COALESCE_SECONDS of the last
|
|
28
|
+
# emitted one are dropped, so a burst (several children blocking at once)
|
|
29
|
+
# rings at most once.
|
|
30
|
+
class Notifier
|
|
31
|
+
# Event names the command hook sees via RUBINO_EVENT.
|
|
32
|
+
EVENTS = %i[turn_finished needs_approval blocked].freeze
|
|
33
|
+
# Burst window: events within this many seconds of the last emitted
|
|
34
|
+
# notification coalesce (are dropped).
|
|
35
|
+
COALESCE_SECONDS = 1.0
|
|
36
|
+
|
|
37
|
+
# @param config [Config::Configuration, nil] resolved lazily per event
|
|
38
|
+
# from Rubino.configuration when nil, so a config reload (or a
|
|
39
|
+
# test-injected configuration) is honored without rebuilding the UI.
|
|
40
|
+
def initialize(config: nil)
|
|
41
|
+
@config = config
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@last_emitted_at = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A turn ended after +seconds+. Quick turns stay silent
|
|
47
|
+
# (notifications.min_turn_seconds): focus detection is unreliable in
|
|
48
|
+
# plain terminals, so duration is the proxy for "the human probably
|
|
49
|
+
# looked away".
|
|
50
|
+
def turn_finished(seconds)
|
|
51
|
+
return if seconds.nil? || seconds.to_f < min_turn_seconds
|
|
52
|
+
|
|
53
|
+
notify(:turn_finished, "turn finished after #{seconds.to_i}s")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An approval prompt is parked on the human — the main agent's confirm
|
|
57
|
+
# card, or a background child flipped to :needs_approval.
|
|
58
|
+
def needs_approval(message = "approval required")
|
|
59
|
+
notify(:needs_approval, message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A background child is blocked on the human (the ⛔ escalated
|
|
63
|
+
# ask_parent banner).
|
|
64
|
+
def blocked(message = "a subagent is waiting on you")
|
|
65
|
+
notify(:blocked, message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Emits one notification through every enabled channel. Best-effort: a
|
|
69
|
+
# channel failure is logged and never raised into the turn.
|
|
70
|
+
def notify(event, message)
|
|
71
|
+
return unless enabled?
|
|
72
|
+
return unless mark_emittable!
|
|
73
|
+
|
|
74
|
+
emit_bell(message)
|
|
75
|
+
spawn_command(event, message)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log_failure(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Coalescing gate: claims the emission slot, or returns false when the
|
|
83
|
+
# last notification fired under COALESCE_SECONDS ago.
|
|
84
|
+
def mark_emittable!
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
return false if @last_emitted_at && (now - @last_emitted_at) < COALESCE_SECONDS
|
|
88
|
+
|
|
89
|
+
@last_emitted_at = now
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def emit_bell(message)
|
|
95
|
+
return unless bell_enabled?
|
|
96
|
+
|
|
97
|
+
sink = bell_sink
|
|
98
|
+
return unless sink
|
|
99
|
+
|
|
100
|
+
payload = +"\a"
|
|
101
|
+
payload << "\e]9;#{osc_safe(message)}\a" if iterm?
|
|
102
|
+
sink.write(payload)
|
|
103
|
+
sink.flush if sink.respond_to?(:flush)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
log_failure(e)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The REAL terminal the bell may ring on, or nil (never bell into a
|
|
109
|
+
# pipe). While a composer owns the screen $stdout is the StdoutProxy
|
|
110
|
+
# (tty? false by design); the composer's +output+ is the real IO it
|
|
111
|
+
# captured before the swap.
|
|
112
|
+
def bell_sink
|
|
113
|
+
out = BottomComposer.current&.output || $stdout
|
|
114
|
+
return out if out.respond_to?(:tty?) && out.tty?
|
|
115
|
+
|
|
116
|
+
nil
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def iterm?
|
|
122
|
+
ENV["TERM_PROGRAM"] == "iTerm.app"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# OSC payload hygiene: a control byte (including the BEL terminator
|
|
126
|
+
# itself) inside the message would cut or corrupt the sequence.
|
|
127
|
+
def osc_safe(message)
|
|
128
|
+
message.to_s.gsub(/[[:cntrl:]]/, " ")[0, 200]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Fire-and-forget command hook: spawned detached with the event in its
|
|
132
|
+
# env, stdio nulled so it can never write into the composer's screen.
|
|
133
|
+
def spawn_command(event, message)
|
|
134
|
+
cmd = command
|
|
135
|
+
return unless cmd
|
|
136
|
+
|
|
137
|
+
pid = Process.spawn(
|
|
138
|
+
{ "RUBINO_EVENT" => event.to_s, "RUBINO_MESSAGE" => message.to_s },
|
|
139
|
+
cmd,
|
|
140
|
+
in: File::NULL, out: File::NULL, err: File::NULL
|
|
141
|
+
)
|
|
142
|
+
Process.detach(pid)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
log_failure(e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_failure(error)
|
|
148
|
+
Rubino.logger.debug(event: "ui.notifier.failed", error: error.message)
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def config
|
|
154
|
+
@config || Rubino.configuration
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def enabled? = config.notifications_enabled?
|
|
158
|
+
def bell_enabled? = config.notifications_bell?
|
|
159
|
+
def command = config.notifications_command
|
|
160
|
+
def min_turn_seconds = config.notifications_min_turn_seconds
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|