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,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Reads and parses the byte tail of an ESC keystroke for the
|
|
6
|
+
# {BottomComposer}: arrows / Home / End / Delete / word-jump / Shift+Tab /
|
|
7
|
+
# Alt+Enter and the bracketed-paste body. PURE input: it only consumes
|
|
8
|
+
# bytes from the keystroke source and returns a semantic action tuple —
|
|
9
|
+
# the composer maps actions to its editing/menu/turn behavior, so all
|
|
10
|
+
# rendering and state stay on its side of the seam.
|
|
11
|
+
#
|
|
12
|
+
# Three escape families are handled:
|
|
13
|
+
# * CSI — ESC '[' params final (arrows, Home/End, Delete, Shift+Tab,
|
|
14
|
+
# xterm modified keys like ESC[1;5C for Ctrl+→, bracketed paste)
|
|
15
|
+
# * SS3 — ESC 'O' final (application-cursor arrows / Home/End)
|
|
16
|
+
# * Meta — ESC b / ESC f (Alt+b / Alt+f word-jump on many terms)
|
|
17
|
+
class EscapeReader
|
|
18
|
+
# Bracketed paste (DEC 2004) end marker tail: the terminal closes a paste
|
|
19
|
+
# with ESC[201~ (the opener ESC[200~ arrives as a normal CSI above).
|
|
20
|
+
PASTE_END = "201~"
|
|
21
|
+
|
|
22
|
+
# @param source [#call] returns the keystroke IO to read from. A callable
|
|
23
|
+
# rather than a captured IO so the reader always follows the composer's
|
|
24
|
+
# CURRENT input — the escape tail must come from the same stream the
|
|
25
|
+
# leading "\e" byte was read from, even if the IO is swapped (tests).
|
|
26
|
+
def initialize(source)
|
|
27
|
+
@source = source
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Consume the remainder of the escape sequence after a read "\e" and
|
|
31
|
+
# return what it MEANS, as one of:
|
|
32
|
+
#
|
|
33
|
+
# [:esc] lone ESC (no following bytes)
|
|
34
|
+
# [:esc_esc] two ESC bytes in ONE burst (fast double-tap)
|
|
35
|
+
# [:alt_enter] Alt/Meta+Enter (ESC CR / ESC LF)
|
|
36
|
+
# [:paste, body] bracketed paste with its raw body
|
|
37
|
+
# [:mode_cycle] Shift+Tab (ESC[Z)
|
|
38
|
+
# [:history_up] / [:history_down] ↑ / ↓
|
|
39
|
+
# [:move_by, ±1] bare ← / →
|
|
40
|
+
# [:word_left] / [:word_right] modified ←/→, Alt+b/f
|
|
41
|
+
# [:move_home] / [:move_end] Home / End (CSI, SS3 and tilde forms)
|
|
42
|
+
# [:delete_forward] Delete (ESC[3~)
|
|
43
|
+
# nil unrecognized sequence (a quiet no-op)
|
|
44
|
+
#
|
|
45
|
+
# Non-blocking reads so a lone ESC doesn't hang.
|
|
46
|
+
def read_action
|
|
47
|
+
case read_nonblock_char
|
|
48
|
+
when nil then [:esc]
|
|
49
|
+
when "\e" then double_escape_action
|
|
50
|
+
when "\r", "\n" then [:alt_enter]
|
|
51
|
+
when "[" then csi_action(read_csi)
|
|
52
|
+
when "O" then final_action(read_nonblock_char, modifier: 1)
|
|
53
|
+
when "b" then [:word_left]
|
|
54
|
+
when "f" then [:word_right]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Two ESC bytes arrived in ONE burst: a fast Esc-Esc double-tap lands
|
|
61
|
+
# both before the reader wakes, so the second ESC shows up as the tail
|
|
62
|
+
# of the first. With nothing after it that IS the double-tap
|
|
63
|
+
# ([:esc_esc] — the composer treats it as two lone Escs for the rewind
|
|
64
|
+
# chord); with a real sequence after it (ESC ESC [ A — a Meta-prefixed
|
|
65
|
+
# arrow on some terminals) the leading ESC is just the Meta prefix, so
|
|
66
|
+
# the inner action passes through unchanged.
|
|
67
|
+
def double_escape_action
|
|
68
|
+
inner = read_action
|
|
69
|
+
inner == [:esc] ? [:esc_esc] : inner
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Acts on a parsed CSI sequence. Bracketed paste and Shift+Tab are
|
|
73
|
+
# special; everything else splits into "params;…final" so a modified
|
|
74
|
+
# arrow (ESC[1;5C = Ctrl+→) routes to the same move as the bare arrow
|
|
75
|
+
# plus the modifier that promotes it to a word-jump.
|
|
76
|
+
def csi_action(seq)
|
|
77
|
+
case seq
|
|
78
|
+
when "200~" then return [:paste, read_paste_body]
|
|
79
|
+
when "Z" then return [:mode_cycle] # Shift+Tab arrives as ESC[Z
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
final = seq[-1]
|
|
83
|
+
params = seq[0...-1].split(";")
|
|
84
|
+
# The modifier param is the 2nd field for xterm "1;mod<final>" form; the
|
|
85
|
+
# numpad/edit keys (Home/End/Delete) carry "<n>;mod~". Default mod 1.
|
|
86
|
+
modifier = (params[1] || params[0] || "1").to_i
|
|
87
|
+
modifier = 1 if modifier.zero?
|
|
88
|
+
if final == "~"
|
|
89
|
+
tilde_action(params.first.to_i)
|
|
90
|
+
else
|
|
91
|
+
final_action(final, modifier: modifier)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Final-byte cursor keys (and SS3 arrows). A modifier > 1 (Ctrl=5, Alt=3,
|
|
96
|
+
# Shift=2, etc.) promotes ←/→ to a word-jump, matching how terminals
|
|
97
|
+
# encode Ctrl/Alt + arrow.
|
|
98
|
+
def final_action(final, modifier:)
|
|
99
|
+
word = modifier > 1
|
|
100
|
+
case final
|
|
101
|
+
when "A" then [:history_up]
|
|
102
|
+
when "B" then [:history_down]
|
|
103
|
+
when "C" then word ? [:word_right] : [:move_by, 1]
|
|
104
|
+
when "D" then word ? [:word_left] : [:move_by, -1]
|
|
105
|
+
when "H" then [:move_home]
|
|
106
|
+
when "F" then [:move_end]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Tilde-terminated edit keys: 1/7 = Home, 4/8 = End, 3 = Delete-forward.
|
|
111
|
+
def tilde_action(code)
|
|
112
|
+
case code
|
|
113
|
+
when 1, 7 then [:move_home]
|
|
114
|
+
when 4, 8 then [:move_end]
|
|
115
|
+
when 3 then [:delete_forward]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reads the remainder of a CSI sequence: params (digits + ';') up to and
|
|
120
|
+
# including the final byte in 0x40..0x7E. Returns the raw param/final
|
|
121
|
+
# string, e.g. "A", "3~", "1;5C".
|
|
122
|
+
def read_csi
|
|
123
|
+
seq = +""
|
|
124
|
+
loop do
|
|
125
|
+
c = read_nonblock_char
|
|
126
|
+
break if c.nil?
|
|
127
|
+
|
|
128
|
+
seq << c
|
|
129
|
+
break if c.ord.between?(0x40, 0x7E)
|
|
130
|
+
end
|
|
131
|
+
seq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Accumulate a bracketed-paste body until the closing ESC[201~ marker.
|
|
135
|
+
# Blocking reads here: a paste is a contiguous burst, so we won't hang
|
|
136
|
+
# waiting on the user.
|
|
137
|
+
def read_paste_body
|
|
138
|
+
body = +""
|
|
139
|
+
until body.end_with?(PASTE_END)
|
|
140
|
+
c = read_paste_char
|
|
141
|
+
break if c.nil?
|
|
142
|
+
|
|
143
|
+
body << c
|
|
144
|
+
end
|
|
145
|
+
body = body[0...-PASTE_END.length] if body.end_with?(PASTE_END)
|
|
146
|
+
# Drop the ESC[ that precedes the 201~ end marker.
|
|
147
|
+
body.sub(/\e\[\z/, "")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Blocking single-char read for the paste body (a paste arrives as one
|
|
151
|
+
# uninterrupted burst).
|
|
152
|
+
def read_paste_char
|
|
153
|
+
input.getc
|
|
154
|
+
rescue IOError, Errno::EIO # IOError covers EOFError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def read_nonblock_char
|
|
159
|
+
input.read_nonblock(1)
|
|
160
|
+
rescue IO::WaitReadable, IOError, Errno::EIO # IOError covers EOFError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def input
|
|
165
|
+
@source.call
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module UI
|
|
5
|
+
# Output decorator that left-pads everything written through it by a fixed
|
|
6
|
+
# indent, so a TTY::Prompt menu renders in the SAME column as the card
|
|
7
|
+
# above it (P7) instead of flush-left at column 0.
|
|
8
|
+
#
|
|
9
|
+
# tty-prompt repaints its frame by moving the cursor up, clearing lines,
|
|
10
|
+
# and re-printing — so "start of line" is not only "after a newline": a
|
|
11
|
+
# carriage return or a cursor column-reset escape (`\e[<n>G`) also restart
|
|
12
|
+
# the line. The transform tracks that state ACROSS writes and injects the
|
|
13
|
+
# indent before the first visible character of every line, leaving all
|
|
14
|
+
# escape sequences untouched.
|
|
15
|
+
#
|
|
16
|
+
# Resolves the underlying IO from the given block on every call (default:
|
|
17
|
+
# the current $stdout) so a composer/proxy swap can never strand a stale
|
|
18
|
+
# handle. Everything else (tty?, winsize, …) delegates to that IO.
|
|
19
|
+
class IndentedIO
|
|
20
|
+
# ANSI CSI/OSC sequences pass through unindented; any other single
|
|
21
|
+
# character is a candidate for the line-start indent.
|
|
22
|
+
TOKEN_RE = /\e\[[\d;?]*[A-Za-z]|\e\][^\a\e]*(?:\a|\e\\)|./m
|
|
23
|
+
|
|
24
|
+
def initialize(indent: " ", io: nil)
|
|
25
|
+
@indent = indent
|
|
26
|
+
@resolve = io ? -> { io } : -> { $stdout }
|
|
27
|
+
@at_line_start = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def print(*args)
|
|
31
|
+
io.print(*args.map { |a| transform(a.to_s) })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write(*args)
|
|
35
|
+
io.write(*args.map { |a| transform(a.to_s) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def puts(*args)
|
|
39
|
+
if args.empty?
|
|
40
|
+
@at_line_start = true
|
|
41
|
+
io.puts
|
|
42
|
+
else
|
|
43
|
+
print("#{args.join("\n")}\n")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def <<(text)
|
|
48
|
+
write(text)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def flush
|
|
53
|
+
io.flush
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def method_missing(name, *, &)
|
|
57
|
+
io.respond_to?(name) ? io.public_send(name, *, &) : super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def respond_to_missing?(name, include_private = false)
|
|
61
|
+
io.respond_to?(name, include_private) || super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def io
|
|
67
|
+
@resolve.call
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transform(text)
|
|
71
|
+
text.gsub(TOKEN_RE) do |tok|
|
|
72
|
+
if ["\n", "\r"].include?(tok)
|
|
73
|
+
@at_line_start = true
|
|
74
|
+
tok
|
|
75
|
+
elsif tok.start_with?("\e")
|
|
76
|
+
@at_line_start = true if tok.match?(/\e\[\d*G\z/)
|
|
77
|
+
tok
|
|
78
|
+
elsif @at_line_start
|
|
79
|
+
@at_line_start = false
|
|
80
|
+
"#{@indent}#{tok}"
|
|
81
|
+
else
|
|
82
|
+
tok
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# Prompt history for the bottom composer, backed by the SAME store the old
|
|
8
|
+
# Reline idle prompt used (+Reline::HISTORY+) so continuity is preserved when
|
|
9
|
+
# the composer becomes the single idle input path — a session's earlier
|
|
10
|
+
# entries (and anything Reline itself recorded) stay navigable.
|
|
11
|
+
#
|
|
12
|
+
# Navigation model mirrors a shell / Reline: ↑ walks BACK toward older
|
|
13
|
+
# entries, ↓ walks FORWARD toward newer ones and finally back to the live
|
|
14
|
+
# draft the user was typing. The in-progress draft is stashed on the first ↑
|
|
15
|
+
# so ↓-ing all the way down restores exactly what the user had typed, never
|
|
16
|
+
# losing it.
|
|
17
|
+
#
|
|
18
|
+
# Like +LineInput#remember+, consecutive duplicates are de-duped on push so a
|
|
19
|
+
# repeated command doesn't clutter the ring.
|
|
20
|
+
class InputHistory
|
|
21
|
+
def initialize(store: Reline::HISTORY)
|
|
22
|
+
@store = store
|
|
23
|
+
# Cursor into the history ring. nil = "on the live draft" (not navigating
|
|
24
|
+
# history). 0 = most recent entry, increasing = older.
|
|
25
|
+
@index = nil
|
|
26
|
+
@draft = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Append a submitted line, de-duping a consecutive duplicate (matches
|
|
30
|
+
# LineInput#remember). Blank lines are not recorded. Resets navigation so
|
|
31
|
+
# the next ↑ starts from the newest entry again.
|
|
32
|
+
def remember(line)
|
|
33
|
+
reset!
|
|
34
|
+
return if line.nil?
|
|
35
|
+
|
|
36
|
+
stripped = line.strip
|
|
37
|
+
return if stripped.empty? || last == stripped
|
|
38
|
+
# Slash commands (/new, /help, …) are control input, not prompts — keep
|
|
39
|
+
# them out of recall so ↑ surfaces real messages, not commands (H1).
|
|
40
|
+
return if stripped.start_with?("/")
|
|
41
|
+
|
|
42
|
+
@store.push(stripped)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Move toward OLDER entries (↑). +current+ is the buffer the user is
|
|
46
|
+
# editing right now; it's stashed as the draft on the first move up so ↓
|
|
47
|
+
# can restore it. Returns the entry to show, or nil when there's nothing
|
|
48
|
+
# older (caller keeps the current buffer).
|
|
49
|
+
def up(current)
|
|
50
|
+
entries = to_a
|
|
51
|
+
return nil if entries.empty?
|
|
52
|
+
|
|
53
|
+
if @index.nil?
|
|
54
|
+
# dup, not to_s: String#to_s returns self, so a later in-place
|
|
55
|
+
# @buffer.replace by the caller would mutate the stashed draft too.
|
|
56
|
+
@draft = current.to_s.dup
|
|
57
|
+
@index = 0
|
|
58
|
+
elsif @index < entries.size - 1
|
|
59
|
+
@index += 1
|
|
60
|
+
else
|
|
61
|
+
return nil # already on the oldest entry — clamp
|
|
62
|
+
end
|
|
63
|
+
entries[entries.size - 1 - @index]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Move toward NEWER entries (↓). Returns the newer entry, or the stashed
|
|
67
|
+
# draft when stepping back below the newest entry, or nil when not
|
|
68
|
+
# currently navigating history (caller keeps the current buffer).
|
|
69
|
+
def down(_current = nil)
|
|
70
|
+
return nil if @index.nil?
|
|
71
|
+
|
|
72
|
+
entries = to_a
|
|
73
|
+
if @index.positive?
|
|
74
|
+
@index -= 1
|
|
75
|
+
entries[entries.size - 1 - @index]
|
|
76
|
+
else
|
|
77
|
+
# Stepped below the newest entry → back to the live draft.
|
|
78
|
+
@index = nil
|
|
79
|
+
d = @draft.to_s
|
|
80
|
+
@draft = nil
|
|
81
|
+
d
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# True while the cursor is walking the history ring (not on the draft).
|
|
86
|
+
def navigating?
|
|
87
|
+
!@index.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Drop navigation state (called on submit / any direct edit so a fresh ↑
|
|
91
|
+
# starts from the newest entry and a typed edit isn't treated as history).
|
|
92
|
+
def reset!
|
|
93
|
+
@index = nil
|
|
94
|
+
@draft = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def to_a
|
|
100
|
+
@store.respond_to?(:to_a) ? @store.to_a : Array(@store)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def last
|
|
104
|
+
to_a.last
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# The {BottomComposer}'s live-region renderer: the rows redrawn IN PLACE
|
|
8
|
+
# above the prompt every frame (subagent cards, completion menu, transient
|
|
9
|
+
# announce, queued indicators, streamed partial). Owns the count of rows
|
|
10
|
+
# currently on screen and the scroll-safe erase→commit→redraw discipline;
|
|
11
|
+
# the composer assembles the row list and draws the prompt row itself.
|
|
12
|
+
# Pure output: no state of its own beyond the row count, and it NEVER takes
|
|
13
|
+
# the render mutex — the composer holds it around every call.
|
|
14
|
+
#
|
|
15
|
+
# Scroll-safe strategy (mirrors prompt_toolkit / Ink): ERASE the whole live
|
|
16
|
+
# region first (the prompt row, plus the rows above it) so nothing stale is
|
|
17
|
+
# left, then print any committed output and let the terminal scroll
|
|
18
|
+
# NATURALLY, then redraw the live region FRESH from wherever the cursor
|
|
19
|
+
# lands. We never issue a post-scroll +\e[1A+ that assumes the pre-scroll
|
|
20
|
+
# geometry: such a relative move desyncs the instant a trailing newline
|
|
21
|
+
# scrolls the screen at the bottom row, which is exactly what wiped the
|
|
22
|
+
# typed input.
|
|
23
|
+
class LiveRegion
|
|
24
|
+
def initialize(output)
|
|
25
|
+
@output = output
|
|
26
|
+
# How many rows the live region currently occupies ABOVE the input
|
|
27
|
+
# block. The clear walks up exactly this many rows, so a multi-line
|
|
28
|
+
# block clears cleanly without a single-row \e[1A desyncing it.
|
|
29
|
+
@rows_above = 0
|
|
30
|
+
# INPUT-BLOCK geometry, relative to the row the terminal cursor is
|
|
31
|
+
# parked on (the caret's visual row): how many input rows sit ABOVE it
|
|
32
|
+
# and how many rows sit BELOW it (wrapped input rows after the caret +
|
|
33
|
+
# the status bar). The composer records these after every #draw_input
|
|
34
|
+
# via {#input_drawn}, so {#clear_input_block} can erase the whole block
|
|
35
|
+
# — multi-row input + status bar — before the next draw.
|
|
36
|
+
@input_above = 0
|
|
37
|
+
@input_below = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :rows_above, :input_above, :input_below
|
|
41
|
+
|
|
42
|
+
# True when any rows are currently drawn above the input block.
|
|
43
|
+
def live?
|
|
44
|
+
@rows_above.positive?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Record the input block's geometry for the frame just drawn (see
|
|
48
|
+
# ivar docs above). Called by the composer at the end of #draw_input.
|
|
49
|
+
def input_drawn(above:, below:)
|
|
50
|
+
@input_above = above
|
|
51
|
+
@input_below = below
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Erase the INPUT BLOCK in place (every wrapped input row + the status
|
|
55
|
+
# bar) and park the cursor, column 0, on the block's TOP row — where the
|
|
56
|
+
# next #draw_input begins. Walks DOWN from the caret row clearing the
|
|
57
|
+
# rows below first (status bar + wrapped rows after the caret), returns,
|
|
58
|
+
# clears the caret row, then walks UP clearing the rows above. All moves
|
|
59
|
+
# are relative and happen BEFORE any printing, so nothing has scrolled
|
|
60
|
+
# and the walk is valid. Leaves the above-block live rows untouched.
|
|
61
|
+
def clear_input_block
|
|
62
|
+
if @input_below.positive?
|
|
63
|
+
@input_below.times { @output.print("\e[1B\e[2K") }
|
|
64
|
+
@output.print("\e[#{@input_below}A")
|
|
65
|
+
end
|
|
66
|
+
@output.print("\r\e[2K")
|
|
67
|
+
@input_above.times { @output.print("\e[1A\e[2K") }
|
|
68
|
+
@input_above = 0
|
|
69
|
+
@input_below = 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Draws one atomic frame. Layout (top → bottom): the committed lines (only
|
|
73
|
+
# when given; they scroll into scrollback and stay there), then the live
|
|
74
|
+
# +rows+ redrawn in place, then the prompt row drawn by the block.
|
|
75
|
+
# Must be called while the composer holds its render mutex.
|
|
76
|
+
def frame(committed:, rows:, cols:)
|
|
77
|
+
clear # 1) erase prompt (+ live) rows, BEFORE any scroll
|
|
78
|
+
commit(committed) # 2) print committed output, scroll naturally
|
|
79
|
+
# 3) redraw fresh from the post-scroll cursor row
|
|
80
|
+
rows.each { |row| emit_row(row, cols) }
|
|
81
|
+
yield # the prompt row — ALWAYS last, so it survives every scroll
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Erase the live region IN PLACE and park the cursor on its TOP row:
|
|
85
|
+
# clear the input block (wrapped rows + status bar, see
|
|
86
|
+
# {#clear_input_block}), then walk UP and clear each of the rows above it
|
|
87
|
+
# in turn, leaving the cursor on the now-blank top row. This runs BEFORE
|
|
88
|
+
# any output is printed, so the screen has not scrolled yet and the
|
|
89
|
+
# relative walks are valid; afterward the cursor sits on a blank row with
|
|
90
|
+
# nothing stale below.
|
|
91
|
+
def clear
|
|
92
|
+
clear_input_block
|
|
93
|
+
@rows_above.times { @output.print("\e[1A\e[2K") }
|
|
94
|
+
@rows_above = 0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Print ONE live row clamped to one column SHORT of the width and
|
|
98
|
+
# terminated with a CRLF (which scrolls naturally if we're at the bottom),
|
|
99
|
+
# bumping the row count so the NEXT frame's clear walks up exactly this
|
|
100
|
+
# many rows.
|
|
101
|
+
#
|
|
102
|
+
# The one-column-short clamp matters: a glyph in the final column arms the
|
|
103
|
+
# terminal's deferred auto-wrap ("pending wrap"), and the following CRLF
|
|
104
|
+
# can then resolve as a double scroll on some terminals — which slides the
|
|
105
|
+
# live region out from under the next frame's relative \e[1A walk-up and
|
|
106
|
+
# wipes the prompt. One spare column keeps each row scroll-deterministic.
|
|
107
|
+
def emit_row(row, cols)
|
|
108
|
+
@output.print("\r\e[2K#{self.class.clamp(row, cols - 1)}\r\n")
|
|
109
|
+
@rows_above += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Commit finished output from the blank top row. It scrolls into
|
|
113
|
+
# scrollback NATURALLY; after the trailing CRLF the cursor sits on a fresh
|
|
114
|
+
# blank line at the (possibly new) bottom — the anchor the live rows are
|
|
115
|
+
# redrawn from. Crucially we make NO relative cursor move after this, so a
|
|
116
|
+
# scroll here can never desync the redraw. Each line is emitted with a
|
|
117
|
+
# trailing "\r\n" because OPOST is off in raw mode (a bare "\n" would not
|
|
118
|
+
# return the carriage and the next line would stair-step).
|
|
119
|
+
# An EMPTY committed line is a deliberate blank row (the P3 rhythm gaps —
|
|
120
|
+
# one blank before the answer block, the separator before a tool run):
|
|
121
|
+
# it must scroll a real row, not be dropped, or the in-turn rhythm
|
|
122
|
+
# differs from the between-turns one. Only nil is a no-op.
|
|
123
|
+
def commit(committed)
|
|
124
|
+
return if committed.nil?
|
|
125
|
+
|
|
126
|
+
normalized = committed.to_s.gsub("\r\n", "\n").gsub("\n", "\r\n")
|
|
127
|
+
@output.print(normalized)
|
|
128
|
+
@output.print("\r\n") unless normalized.end_with?("\r\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class << self
|
|
132
|
+
# Clamp a single visible line to the terminal width (one row), left-
|
|
133
|
+
# truncating with a leading "…" so a long line never wraps and desyncs
|
|
134
|
+
# the frame.
|
|
135
|
+
#
|
|
136
|
+
# Width is measured in terminal DISPLAY COLUMNS, not characters: a wide
|
|
137
|
+
# glyph (CJK / emoji like ✅ 🔄) occupies two columns but counts as one
|
|
138
|
+
# String#length char. Measuring by char count let a "clamped" line
|
|
139
|
+
# render WIDER than the row, so xterm wrapped it to a second physical
|
|
140
|
+
# line that the single-row clear (\e[1A) never erased — the residue
|
|
141
|
+
# accumulated downward (the streaming-table trail). Truncating by
|
|
142
|
+
# display width keeps each row exactly one physical line so the clear
|
|
143
|
+
# math stays valid.
|
|
144
|
+
def clamp(str, cols)
|
|
145
|
+
flat = str.to_s.tr("\n", " ")
|
|
146
|
+
# Guard a non-positive width (winsize can report 0 cols in some
|
|
147
|
+
# terminals/multiplexers, at startup, or a zero-height window):
|
|
148
|
+
# without this truncation could return an empty/over-wide line and
|
|
149
|
+
# desync the frame, which escaped run_turn's `rescue Interrupt` and
|
|
150
|
+
# killed the whole chat mid-turn.
|
|
151
|
+
cols = 1 if cols.nil? || cols < 1
|
|
152
|
+
return flat if display_width(flat) <= cols
|
|
153
|
+
|
|
154
|
+
# Leading "…" costs one column; fill the rest from the END of the line.
|
|
155
|
+
"…#{take_last_columns(flat, cols - 1)}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Terminal display columns for a string (wide glyphs count as 2).
|
|
159
|
+
def display_width(str)
|
|
160
|
+
Unicode::DisplayWidth.of(str.to_s)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The longest SUFFIX of +str+ whose display width is <= +cols+. Walks
|
|
164
|
+
# from the end so a wide trailing glyph is dropped whole (never
|
|
165
|
+
# half-rendered) rather than cut mid-cell.
|
|
166
|
+
def take_last_columns(str, cols)
|
|
167
|
+
return "" if cols <= 0
|
|
168
|
+
|
|
169
|
+
used = 0
|
|
170
|
+
taken = []
|
|
171
|
+
str.to_s.chars.reverse_each do |ch|
|
|
172
|
+
w = display_width(ch)
|
|
173
|
+
break if used + w > cols
|
|
174
|
+
|
|
175
|
+
taken << ch
|
|
176
|
+
used += w
|
|
177
|
+
end
|
|
178
|
+
taken.reverse.join
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|