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,1599 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module UI
|
|
7
|
+
# A persistent, VISIBLE, editable input line pinned at the bottom of the
|
|
8
|
+
# terminal while agent output streams ABOVE it and scrolls into native
|
|
9
|
+
# scrollback. No alternate screen, no mouse tracking — trackpad/wheel scroll
|
|
10
|
+
# and text selection keep working like a normal shell.
|
|
11
|
+
#
|
|
12
|
+
# This is the Ruby equivalent of prompt_toolkit's +patch_stdout+ /
|
|
13
|
+
# +run_in_terminal+: every write that should land above the prompt goes
|
|
14
|
+
# through {#print_above}, which erases the input line, emits the output (it
|
|
15
|
+
# scrolls up), then redraws the input from the preserved buffer. A render
|
|
16
|
+
# +Mutex+ makes each erase→print→redraw an atomic frame so the streaming
|
|
17
|
+
# writer and the keystroke handler never interleave a half-frame.
|
|
18
|
+
#
|
|
19
|
+
# Responsibilities:
|
|
20
|
+
# * own the editable +@buffer+ and draw it ({#draw_input})
|
|
21
|
+
# * funnel all turn output through {#print_above} so it never clobbers the
|
|
22
|
+
# input line (the {StdoutProxy} swaps +$stdout+ for the turn so the ~30
|
|
23
|
+
# existing +$stdout.print/puts+ call sites need zero changes)
|
|
24
|
+
# * run a raw, char-by-char keystroke loop in a thread that echoes typed
|
|
25
|
+
# chars and pushes completed lines into the shared
|
|
26
|
+
# {Interaction::InputQueue} the steering logic already consumes
|
|
27
|
+
#
|
|
28
|
+
# Four collaborators carry the cohesive sub-jobs behind narrow seams, with
|
|
29
|
+
# the composer as the facade that owns the render mutex and the public API:
|
|
30
|
+
# {EscapeReader} (escape-sequence byte reading/parsing → semantic actions),
|
|
31
|
+
# {CompletionMenu} (the /command + @file dropdown state machine + rows),
|
|
32
|
+
# {QueuedIndicators} (the "⏳ queued:" stack + rows) and {LiveRegion} (the
|
|
33
|
+
# erase→commit→redraw frame discipline + width math). {StatusBar} formats
|
|
34
|
+
# the model/context line the composer pins BELOW the input (see below).
|
|
35
|
+
#
|
|
36
|
+
# The INPUT BLOCK is multi-row: a buffer longer than the terminal width
|
|
37
|
+
# WRAPS and the input grows downward as the user types (like Claude Code),
|
|
38
|
+
# up to +max_input_rows+ visual rows; past the cap it scrolls vertically,
|
|
39
|
+
# keeping the caret row in view. A multi-line PASTE keeps its REAL newlines
|
|
40
|
+
# in the buffer and the submitted payload (#57) and each newline now renders
|
|
41
|
+
# as a REAL row break in the editing view. ↑/↓ move by visual row while the
|
|
42
|
+
# caret is inside a multi-row buffer and fall back to history navigation on
|
|
43
|
+
# the first/last row (the readline/Claude Code convention). Below the input
|
|
44
|
+
# block an optional dim STATUS BAR shows the model id + context saturation;
|
|
45
|
+
# it is the live region's LAST row, redrawn with every frame and omitted on
|
|
46
|
+
# narrow (< MIN_STATUS_COLS) terminals.
|
|
47
|
+
#
|
|
48
|
+
# (Two earlier MVP limitations no longer apply: arrows/Home/End/Delete/
|
|
49
|
+
# word-jump now drive the cursor via #consume_escape_sequence, and the
|
|
50
|
+
# draw/wrap/clamp paths all measure by DISPLAY width — a wide CJK/emoji
|
|
51
|
+
# glyph counts as two columns — so fullwidth lines wrap at the right
|
|
52
|
+
# column instead of "slightly early".)
|
|
53
|
+
class BottomComposer
|
|
54
|
+
PROMPT = "❯ "
|
|
55
|
+
ANSI_RE = /\e\[[0-9;]*m/
|
|
56
|
+
|
|
57
|
+
# Hard ceiling on the subagent card block (rows ABOVE the partial + prompt).
|
|
58
|
+
# The registry caps live children at MAX_CONCURRENT (3) and the formatter
|
|
59
|
+
# adds an overflow + hint line, so 5 rows covers the worst case while
|
|
60
|
+
# guaranteeing the live region can never grow unbounded and push the prompt
|
|
61
|
+
# off-screen — a corrupt caller is clamped, not trusted.
|
|
62
|
+
MAX_CARD_ROWS = 6
|
|
63
|
+
|
|
64
|
+
# Hard ceiling on the live partial rows so a runaway caller can never push
|
|
65
|
+
# the prompt off-screen (mirrors MAX_CARD_ROWS for the card block).
|
|
66
|
+
MAX_PARTIAL_ROWS = 4
|
|
67
|
+
|
|
68
|
+
# Default cap on the input block's visual rows (config:
|
|
69
|
+
# display.input_max_rows, threaded in by the chat command). Past it the
|
|
70
|
+
# block scrolls vertically, keeping the caret row in view, so a huge
|
|
71
|
+
# paste can never push the live region off-screen.
|
|
72
|
+
MAX_INPUT_ROWS = 8
|
|
73
|
+
|
|
74
|
+
# The status bar is omitted on terminals narrower than this — at that
|
|
75
|
+
# width the truncated line carries no information worth a row.
|
|
76
|
+
MIN_STATUS_COLS = 40
|
|
77
|
+
|
|
78
|
+
# QUEUED-message prefix: submitting a line that starts with this queues the
|
|
79
|
+
# REST instead of interrupting — the discoverable, terminal-independent
|
|
80
|
+
# fallback for Alt+Enter (which some terminals don't deliver).
|
|
81
|
+
QUEUED_PREFIX = "/queued "
|
|
82
|
+
|
|
83
|
+
# Double-Esc window (seconds): two LONE Esc presses within this at the
|
|
84
|
+
# IDLE prompt fire the +on_double_esc+ hook (the Esc-Esc rewind picker —
|
|
85
|
+
# the Claude Code muscle-memory chord). Tight enough that a deliberate
|
|
86
|
+
# single Esc (menu dismiss) followed by an unrelated Esc later never
|
|
87
|
+
# reads as a chord.
|
|
88
|
+
DOUBLE_ESC_SECONDS = 0.4
|
|
89
|
+
|
|
90
|
+
# Bracketed paste (DEC 2004): the terminal wraps pasted text in
|
|
91
|
+
# ESC[200~ … ESC[201~ so we can tell a PASTE from typed keystrokes and
|
|
92
|
+
# keep each embedded \n from submitting a half-line (L1 — "pasteline2"
|
|
93
|
+
# glue). The body is inserted as ONE editable string with its REAL
|
|
94
|
+
# newlines preserved (#57, see #submit_paste); each renders as a real
|
|
95
|
+
# row break in the multi-row input block. We enable it on start, disable
|
|
96
|
+
# on stop/suspend; the {EscapeReader} accumulates the body between the
|
|
97
|
+
# markers.
|
|
98
|
+
PASTE_ON = "\e[?2004h"
|
|
99
|
+
PASTE_OFF = "\e[?2004l"
|
|
100
|
+
|
|
101
|
+
# @param input_queue [Interaction::InputQueue] completed lines are pushed
|
|
102
|
+
# here; the agent loop / REPL drain it (steering). Required for the
|
|
103
|
+
# reader to do anything useful.
|
|
104
|
+
# @param input [IO] keystroke source (default $stdin).
|
|
105
|
+
# @param output [IO] where the prompt + above-output is written
|
|
106
|
+
# (default $stdout).
|
|
107
|
+
# @param prompt [String] the input-line prefix after the rail — the
|
|
108
|
+
# plain "❯ " caret (may contain ANSI color). Defaults to the bare
|
|
109
|
+
# caret for standalone use / tests. The mode/skill chip that used to
|
|
110
|
+
# ride here lives in the STATUS BAR now (the Rail rubino redesign).
|
|
111
|
+
# @param rail [String, nil] the one-column brand rail (the red "▍")
|
|
112
|
+
# drawn as the FIRST column of EVERY input row — the first row AND
|
|
113
|
+
# each wrapped/newline continuation — so a multi-row draft reads as
|
|
114
|
+
# one block. May carry ANSI color. nil/empty ⇒ no rail (standalone /
|
|
115
|
+
# tests / the cooked fallback), with the exact pre-rail geometry.
|
|
116
|
+
# The rail is pure input-block chrome: committed echoes
|
|
117
|
+
# ("<prompt><line>") never carry it, so scrollback stays clean.
|
|
118
|
+
# @param on_ctrl_o [#call, nil] invoked when the user presses Ctrl+O — the
|
|
119
|
+
# CLI uses it to REVEAL the last retained reasoning buffer as a `┊` aside
|
|
120
|
+
# committed into scrollback. The composer never formats reasoning itself;
|
|
121
|
+
# it only dispatches the keystroke. nil = no-op.
|
|
122
|
+
# @param on_mode_cycle [#call, nil] invoked when the user presses Shift+Tab
|
|
123
|
+
# to cycle the mode. The callback owns the mode logic (persist + emit the
|
|
124
|
+
# transition footer) and RETURNS the freshly-built STATUS-BAR line (the
|
|
125
|
+
# mode token leads it), which the composer adopts and redraws — the mode
|
|
126
|
+
# lives in the status bar now, not in a prompt chip. nil return ⇒ no
|
|
127
|
+
# status change (e.g. the yolo arm toast). The composer holds no mode
|
|
128
|
+
# knowledge itself. nil = Shift+Tab is a no-op.
|
|
129
|
+
# @param echo [Symbol] how a submitted line is echoed into scrollback:
|
|
130
|
+
# :queued (default) is the IN-TURN composer — Enter INTERRUPTS the active
|
|
131
|
+
# turn and sends the line as the next turn (the default), so it never
|
|
132
|
+
# commits an echo here (the next turn's prompt echo is committed by the
|
|
133
|
+
# chat loop when it runs); :prompt prints the prompt + the line (e.g.
|
|
134
|
+
# "default ❯ <line>") — the idle case, where the line IS the user's
|
|
135
|
+
# message and should read back like a normal shell submit.
|
|
136
|
+
# @param on_interrupt [#call, nil] invoked when the user presses Enter to
|
|
137
|
+
# submit a line WHILE a turn is active. The chat loop wires this to the
|
|
138
|
+
# active turn's cancel so the current turn is interrupted and the
|
|
139
|
+
# just-submitted line runs as the next turn immediately. nil ⇒ no
|
|
140
|
+
# interrupt (the line is simply queued, as before).
|
|
141
|
+
# @param pending_queued [Array<String>, nil] shared stack of messages the
|
|
142
|
+
# user EXPLICITLY queued (Alt+Enter / "/queued <msg>") while a turn is
|
|
143
|
+
# active. Rendered as "⏳ queued: <msg>" rows ABOVE the input (live region,
|
|
144
|
+
# never committed). Shared across the per-turn composers by the chat loop
|
|
145
|
+
# so the indicator survives a composer teardown and is removed/committed as
|
|
146
|
+
# a normal message when the queued item's turn runs. nil ⇒ a private list
|
|
147
|
+
# (standalone / tests).
|
|
148
|
+
# @param status_line [String, nil] the styled model/context line pinned
|
|
149
|
+
# BELOW the input row (see {StatusBar}). nil/empty ⇒ no bar. Updated
|
|
150
|
+
# at turn boundaries via {#set_status} — never per-delta.
|
|
151
|
+
# @param max_input_rows [Integer, nil] cap on the input block's visual
|
|
152
|
+
# rows (config display.input_max_rows); nil ⇒ MAX_INPUT_ROWS.
|
|
153
|
+
# @param paste_store [UI::PasteStore, nil] the per-session paste store
|
|
154
|
+
# behind the file-backed paste pipeline: a large paste collapses to a
|
|
155
|
+
# "[Pasted text #N +M lines]" placeholder registered here (expanded to
|
|
156
|
+
# the full body at the chat loop's message-build seam), and backspace
|
|
157
|
+
# on a placeholder deletes it WHOLE. Shared across the per-turn
|
|
158
|
+
# composers by the chat command, like +pending_queued+. nil ⇒ every
|
|
159
|
+
# paste inlines into the buffer (standalone / tests), as before.
|
|
160
|
+
# @param on_double_esc [#call, nil] invoked when the user presses Esc
|
|
161
|
+
# twice within {DOUBLE_ESC_SECONDS} at the IDLE prompt — the Esc-Esc
|
|
162
|
+
# rewind chord. Wired only on the IDLE composer (the chat loop opens
|
|
163
|
+
# the rewind picker from it); the in-turn composer leaves it nil, so
|
|
164
|
+
# Esc keeps no double-tap meaning during a turn. With a menu open the
|
|
165
|
+
# first Esc keeps its dismiss meaning AND arms the chord, so Esc-Esc
|
|
166
|
+
# over a menu reads dismiss-then-rewind. The hook runs on the reader
|
|
167
|
+
# thread — callers must only flip a flag, never block or take the
|
|
168
|
+
# composer's locks (the idle loop drains it, like the Ctrl+C trap).
|
|
169
|
+
def initialize(input_queue:, input: $stdin, output: $stdout, prompt: PROMPT,
|
|
170
|
+
rail: nil, on_ctrl_o: nil, on_mode_cycle: nil,
|
|
171
|
+
completion_source: nil, history: nil, echo: :queued,
|
|
172
|
+
on_interrupt: nil, pending_queued: nil,
|
|
173
|
+
status_line: nil, max_input_rows: nil, paste_store: nil,
|
|
174
|
+
on_double_esc: nil)
|
|
175
|
+
@input_queue = input_queue
|
|
176
|
+
@input = input
|
|
177
|
+
@output = output
|
|
178
|
+
@on_ctrl_o = on_ctrl_o
|
|
179
|
+
@on_mode_cycle = on_mode_cycle
|
|
180
|
+
@on_double_esc = on_double_esc
|
|
181
|
+
# Monotonic time of the last LONE Esc (nil when unarmed) — the
|
|
182
|
+
# double-tap window the Esc-Esc rewind chord measures against.
|
|
183
|
+
@last_esc_at = nil
|
|
184
|
+
@echo = echo
|
|
185
|
+
@on_interrupt = on_interrupt
|
|
186
|
+
# Per-session paste store (file-backed paste pipeline). nil ⇒ inline
|
|
187
|
+
# pastes, the exact legacy behavior.
|
|
188
|
+
@paste_store = paste_store
|
|
189
|
+
# Shared (or private) stack of EXPLICITLY-queued messages, rendered as
|
|
190
|
+
# "⏳ queued: <msg>" rows above the input while pending.
|
|
191
|
+
@queued = QueuedIndicators.new(pending_queued || [])
|
|
192
|
+
# Shared completion discovery (slash commands + @file picker) extracted
|
|
193
|
+
# from LineInput. nil ⇒ the `/`+`@` completion menu is inert (steering /
|
|
194
|
+
# standalone use), so the composer degrades to a plain editor. Kept for
|
|
195
|
+
# the token highlight; the dropdown itself lives in the CompletionMenu.
|
|
196
|
+
@completion = completion_source
|
|
197
|
+
# History ring, backed by Reline::HISTORY by default for continuity with
|
|
198
|
+
# the old idle prompt. nil keeps a private ring (tests / standalone).
|
|
199
|
+
@history = history || InputHistory.new
|
|
200
|
+
# The /command + @file dropdown: open/refine/accept/dismiss state and
|
|
201
|
+
# the rendered rows (see CompletionMenu). Inert without a source.
|
|
202
|
+
@menu = CompletionMenu.new(completion_source)
|
|
203
|
+
# Escape-sequence reader: consumes the byte tail of an ESC keystroke
|
|
204
|
+
# from @input and returns the semantic action (see EscapeReader). The
|
|
205
|
+
# callable indirection keeps it on the composer's CURRENT input.
|
|
206
|
+
@escapes = EscapeReader.new(-> { @input })
|
|
207
|
+
@prompt = prompt.to_s.empty? ? PROMPT : prompt
|
|
208
|
+
# The brand rail (red "▍"): the first column of EVERY input row.
|
|
209
|
+
# Empty ⇒ railless, the exact legacy geometry.
|
|
210
|
+
@rail = (rail || "").to_s
|
|
211
|
+
# Visible widths ignore ANSI color escapes so the wrap math is
|
|
212
|
+
# correct for a colored rail/prompt. @prefix_width is the column the
|
|
213
|
+
# input text starts in on EVERY row (rail + prompt on the first,
|
|
214
|
+
# rail + hanging indent on continuations) — all caret/wrap math
|
|
215
|
+
# anchors to it.
|
|
216
|
+
@rail_width = @rail.gsub(ANSI_RE, "").length
|
|
217
|
+
@prompt_width = @prompt.gsub(ANSI_RE, "").length
|
|
218
|
+
@prefix_width = @rail_width + @prompt_width
|
|
219
|
+
@buffer = +""
|
|
220
|
+
# Insertion point, measured in CHARACTERS (codepoints) into @buffer.
|
|
221
|
+
# Always in 0..@buffer.length; the terminal cursor is parked here on
|
|
222
|
+
# every redraw. Replaces the old append-only model.
|
|
223
|
+
@cursor = 0
|
|
224
|
+
@partial = +"" # live, un-committed streamed line shown above the prompt
|
|
225
|
+
# TRANSIENT announcement row (e.g. the Shift+Tab mode confirmation):
|
|
226
|
+
# rendered in the live region directly above the partial/prompt, redrawn
|
|
227
|
+
# in place every frame and NEVER committed to scrollback. Cleared on the
|
|
228
|
+
# next keystroke so it reads as a one-shot toast, not stacking scrollback
|
|
229
|
+
# (D3). Empty ⇒ no row.
|
|
230
|
+
@announce = +""
|
|
231
|
+
# True only while the model's ANSWER content is actively streaming (set by
|
|
232
|
+
# the CLI's stream/stream_end lifecycle, NOT the thinking phase — commits
|
|
233
|
+
# during thinking land cleanly above the partial). Gates the Ctrl+O reveal
|
|
234
|
+
# so it never bisects a streaming answer (D1).
|
|
235
|
+
@content_streaming = false
|
|
236
|
+
# True for the WHOLE turn — from the moment the chat loop hands a prompt to
|
|
237
|
+
# the runner until the turn fully unwinds — including the THINKING phase
|
|
238
|
+
# that precedes the first content token. Set/cleared by the chat loop's
|
|
239
|
+
# run_turn bracket (#begin_turn / #end_turn). A "queued ▸" type-ahead echo
|
|
240
|
+
# is deferred whenever a turn is active (thinking OR content streaming), not
|
|
241
|
+
# only when content is streaming: a line submitted while the model is still
|
|
242
|
+
# THINKING would otherwise commit its echo ABOVE the thought line and the
|
|
243
|
+
# whole answer (D7e). nil/false ⇒ idle, immediate echo as before.
|
|
244
|
+
@turn_active = false
|
|
245
|
+
# A reveal (Ctrl+O) requested WHILE content was streaming, queued to flush
|
|
246
|
+
# once the stream ends so the `┊` aside renders cleanly AFTER the answer
|
|
247
|
+
# instead of between chunks (D1). nil ⇒ nothing deferred.
|
|
248
|
+
@deferred_reveal = false
|
|
249
|
+
# Subagent CARD block (Variant A): zero or more collapsed live rows shown
|
|
250
|
+
# ABOVE the streamed partial and the prompt, redrawn in place each frame.
|
|
251
|
+
# Driven by UI::CLI#set_subagent_cards from the BackgroundTasks registry.
|
|
252
|
+
@cards = []
|
|
253
|
+
# The live-region renderer: owns the count of rows currently drawn ABOVE
|
|
254
|
+
# the prompt and the scroll-safe erase→commit→redraw frame discipline
|
|
255
|
+
# (see LiveRegion).
|
|
256
|
+
@region = LiveRegion.new(output)
|
|
257
|
+
# The dim status line pinned BELOW the input block (model + context
|
|
258
|
+
# saturation). Drawn as the live region's LAST row on every frame;
|
|
259
|
+
# empty ⇒ no bar (one fewer row). Updated via #set_status at turn
|
|
260
|
+
# boundaries only — it rides the existing redraws, never repaints on
|
|
261
|
+
# its own per stream delta.
|
|
262
|
+
@status = (status_line || "").to_s
|
|
263
|
+
# Input-block geometry: the visual-row cap and the vertical scroll
|
|
264
|
+
# offset (top visible layout row) once the buffer outgrows the cap.
|
|
265
|
+
@max_input_rows = positive_int(max_input_rows) || MAX_INPUT_ROWS
|
|
266
|
+
@input_scroll = 0
|
|
267
|
+
@render = Mutex.new
|
|
268
|
+
@reader = nil
|
|
269
|
+
@stop_pipe = nil # self-pipe write end used to wake the reader's select
|
|
270
|
+
@running = false
|
|
271
|
+
@suspended = false
|
|
272
|
+
@saved_stdout = nil
|
|
273
|
+
@cols = compute_cols
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# True only when both ends are real TTYs. Off this path the composer is a
|
|
277
|
+
# no-op and the caller falls back to the plain (cooked, no-proxy) flow —
|
|
278
|
+
# piped / -q / server input must not touch terminal modes.
|
|
279
|
+
def self.active?(input: $stdin, output: $stdout)
|
|
280
|
+
input.tty? && output.tty?
|
|
281
|
+
rescue StandardError
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The composer running the CURRENT turn, if any. Set on #start, cleared on
|
|
286
|
+
# #stop, so {run_in_terminal} can find it without threading it through every
|
|
287
|
+
# call site. One chat process drives one turn at a time, so a single
|
|
288
|
+
# class-level slot is the right granularity.
|
|
289
|
+
class << self
|
|
290
|
+
attr_accessor :current
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Run +block+ with the REAL terminal restored — the Ruby equivalent of
|
|
294
|
+
# prompt_toolkit's +run_in_terminal+. When a composer owns the screen for
|
|
295
|
+
# the current turn, PAUSE it (stop the raw reader, restore $stdout to the
|
|
296
|
+
# real IO, leave cooked mode, clear the prompt rows) for the duration of the
|
|
297
|
+
# block, then RESUME it (re-enter raw mode, restart the reader, redraw the
|
|
298
|
+
# preserved buffer). With no active composer it just yields. This is what
|
|
299
|
+
# lets a mid-turn TTY::Prompt (approval / ask) read the real $stdin and let
|
|
300
|
+
# tty-screen probe the real $stdout's size, instead of crashing on the
|
|
301
|
+
# write-only StdoutProxy or racing the reader thread for $stdin.
|
|
302
|
+
def self.run_in_terminal
|
|
303
|
+
composer = current
|
|
304
|
+
return yield unless composer
|
|
305
|
+
|
|
306
|
+
composer.suspend
|
|
307
|
+
begin
|
|
308
|
+
yield
|
|
309
|
+
ensure
|
|
310
|
+
composer.resume
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Starts the keystroke reader thread and draws the initial prompt. Installs
|
|
315
|
+
# a SIGWINCH handler that recomputes the width and redraws under the mutex.
|
|
316
|
+
# Returns self.
|
|
317
|
+
def start
|
|
318
|
+
return self if @running
|
|
319
|
+
|
|
320
|
+
@running = true
|
|
321
|
+
self.class.current = self
|
|
322
|
+
install_winch_trap
|
|
323
|
+
@render.synchronize do
|
|
324
|
+
# Leave a blank row above the first prompt so the first above-output
|
|
325
|
+
# doesn't glue onto whatever the REPL just printed.
|
|
326
|
+
@output.print(PASTE_ON)
|
|
327
|
+
@output.print("\r\n")
|
|
328
|
+
draw_input
|
|
329
|
+
end
|
|
330
|
+
@reader = start_reader
|
|
331
|
+
self
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Stops the reader thread, restores cooked mode, and leaves the cursor on a
|
|
335
|
+
# fresh line so the next REPL prompt isn't glued to the input line. Safe to
|
|
336
|
+
# call multiple times. Restores the previous SIGWINCH handler.
|
|
337
|
+
def stop
|
|
338
|
+
return unless @running
|
|
339
|
+
|
|
340
|
+
@running = false
|
|
341
|
+
self.class.current = nil if self.class.current.equal?(self)
|
|
342
|
+
stop_reader
|
|
343
|
+
restore_winch_trap
|
|
344
|
+
# Raw mode must never leak past the turn, even if the block-form restore
|
|
345
|
+
# was interrupted. Best-effort.
|
|
346
|
+
@input.cooked! if tty?
|
|
347
|
+
@render.synchronize { clear_live_region_to_clean_line }
|
|
348
|
+
rescue IOError, Errno::ENOTTY, Errno::EIO
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# PAUSE the composer so an interactive prompt can own the real terminal
|
|
353
|
+
# (see {run_in_terminal}). Stops the raw reader and leaves cooked mode so
|
|
354
|
+
# TTY::Prompt can read $stdin uncontended, restores the REAL $stdout (the
|
|
355
|
+
# composer's @output — built BEFORE the StdoutProxy swap) so tty-screen
|
|
356
|
+
# probes the real terminal, and clears the prompt rows. The typed @buffer
|
|
357
|
+
# draft is preserved for #resume. Idempotent: a no-op once already
|
|
358
|
+
# suspended (or never started).
|
|
359
|
+
def suspend
|
|
360
|
+
return unless @running && !@suspended
|
|
361
|
+
|
|
362
|
+
@suspended = true
|
|
363
|
+
@saved_stdout = $stdout
|
|
364
|
+
$stdout = @output
|
|
365
|
+
stop_reader
|
|
366
|
+
restore_winch_trap
|
|
367
|
+
@input.cooked! if tty?
|
|
368
|
+
@render.synchronize { clear_live_region_to_clean_line }
|
|
369
|
+
rescue IOError, Errno::ENOTTY, Errno::EIO
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# RESUME after {suspend}: restore the StdoutProxy, re-enter raw mode,
|
|
374
|
+
# restart the reader, and redraw the input line from the preserved buffer.
|
|
375
|
+
def resume
|
|
376
|
+
return unless @suspended
|
|
377
|
+
|
|
378
|
+
@suspended = false
|
|
379
|
+
$stdout = @saved_stdout if @saved_stdout
|
|
380
|
+
@saved_stdout = nil
|
|
381
|
+
install_winch_trap
|
|
382
|
+
@render.synchronize do
|
|
383
|
+
@output.print(PASTE_ON)
|
|
384
|
+
draw_input
|
|
385
|
+
end
|
|
386
|
+
@reader = start_reader
|
|
387
|
+
self
|
|
388
|
+
rescue IOError, Errno::ENOTTY, Errno::EIO
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Commits one block of output ABOVE the input line — it scrolls up into
|
|
393
|
+
# native scrollback — then redraws the prompt. This is THE coordinator
|
|
394
|
+
# every finished above-the-prompt write goes through (StdoutProxy routes
|
|
395
|
+
# committed lines here). +str+ may contain embedded newlines; each line is
|
|
396
|
+
# emitted with a trailing "\r\n" because OPOST is off in raw mode (a bare
|
|
397
|
+
# "\n" would not return the carriage and the next line would stair-step).
|
|
398
|
+
# Any live streamed partial is cleared first so it doesn't duplicate.
|
|
399
|
+
# A nil +str+ just repaints the prompt; an EMPTY string commits one
|
|
400
|
+
# deliberate blank row (the P3 rhythm gaps — see LiveRegion#commit).
|
|
401
|
+
def print_above(str)
|
|
402
|
+
@render.synchronize do
|
|
403
|
+
@partial = +""
|
|
404
|
+
render_frame(committed: str)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Renders a LIVE, un-committed streamed line on the row directly above the
|
|
409
|
+
# prompt, redrawn in place as it grows (it does NOT scroll). Used by the
|
|
410
|
+
# StdoutProxy for partial stream tokens that have no newline yet, so the
|
|
411
|
+
# in-progress line appears live and grows in place — like prompt_toolkit
|
|
412
|
+
# batching a partial line. {#print_above} (a committed line) clears it.
|
|
413
|
+
def set_partial(str)
|
|
414
|
+
# While SUSPENDED (run_in_terminal: an approval/ask owns the real
|
|
415
|
+
# terminal) a live repaint here would draw the partial + prompt rows
|
|
416
|
+
# straight over the interactive prompt. Drop the frame — the next
|
|
417
|
+
# #resume redraws the region and the ticker's next frame lands normally.
|
|
418
|
+
return if @suspended
|
|
419
|
+
|
|
420
|
+
@render.synchronize do
|
|
421
|
+
@partial = (str || "").to_s
|
|
422
|
+
render_frame(committed: nil)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Sets the SUBAGENT CARD block — a small list of collapsed live rows shown
|
|
427
|
+
# above the streamed partial and the prompt (Variant A). Each frame redraws
|
|
428
|
+
# them in place from this list, so concurrent background subagents appear as
|
|
429
|
+
# a calm stack of one-liners that update without scrolling. An empty/nil
|
|
430
|
+
# list clears the block. Redraws under the same render mutex every other
|
|
431
|
+
# live write uses, so a card update from the parent never interleaves a
|
|
432
|
+
# half-frame with a streamed token or a keystroke. The list is clamped to a
|
|
433
|
+
# sane bound by the caller (UI::SubagentCards), but we also cap it here so a
|
|
434
|
+
# buggy caller can never grow the live region past the screen.
|
|
435
|
+
def set_cards(lines)
|
|
436
|
+
# While SUSPENDED (run_in_terminal: an approval/ask owns the real
|
|
437
|
+
# terminal) a card repaint here would draw straight over the
|
|
438
|
+
# interactive prompt and can abort its blocked TTY read (#144). Drop
|
|
439
|
+
# the frame, like #set_partial — the cards converge from the registry
|
|
440
|
+
# snapshot on the next repaint after #resume.
|
|
441
|
+
return if @suspended
|
|
442
|
+
|
|
443
|
+
capped = Array(lines).first(MAX_CARD_ROWS)
|
|
444
|
+
@render.synchronize do
|
|
445
|
+
@cards = capped
|
|
446
|
+
render_frame(committed: nil)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Remove the FIRST pending "⏳ queued:" indicator matching +msg+ (public:
|
|
451
|
+
# the chat loop calls this when the queued item's turn starts, so the
|
|
452
|
+
# indicator disappears from above the input as the item is committed as a
|
|
453
|
+
# normal message). Operates on the shared pending list, so it works from
|
|
454
|
+
# whichever composer is current. Returns true if one was removed.
|
|
455
|
+
def commit_queued(msg)
|
|
456
|
+
removed = false
|
|
457
|
+
@render.synchronize do
|
|
458
|
+
removed = !@queued.remove(msg).nil?
|
|
459
|
+
redraw if removed
|
|
460
|
+
end
|
|
461
|
+
removed
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# True when a live partial line is currently shown above the prompt.
|
|
465
|
+
def partial?
|
|
466
|
+
!@partial.empty?
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# True while the model's ANSWER content is actively streaming. The CLI's
|
|
470
|
+
# stream lifecycle toggles this (begin/end below); the keystroke handler
|
|
471
|
+
# reads it to defer the Ctrl+O reveal so it never bisects the answer (D1).
|
|
472
|
+
def streaming?
|
|
473
|
+
@content_streaming
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Marks the start of an ACTIVE content stream (called by the CLI when the
|
|
477
|
+
# first answer token arrives). The thinking phase does NOT set this, so a
|
|
478
|
+
# footer/aside that commits during thinking still lands cleanly above.
|
|
479
|
+
def begin_content_stream
|
|
480
|
+
@content_streaming = true
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Marks the end of the content stream (CLI stream_end / finalize). Flushes
|
|
484
|
+
# the Ctrl+O reveal (`┊` aside) deferred during the stream so it renders
|
|
485
|
+
# AFTER the finished answer block instead of between its chunks — the reveal
|
|
486
|
+
# belongs to the JUST-finished answer, so it lands right after the contiguous
|
|
487
|
+
# answer and BEFORE the turn-summary footer (D1). The "queued ▸" type-ahead
|
|
488
|
+
# echoes are NOT flushed here: they belong to the NEXT input the user lined
|
|
489
|
+
# up, so they flush at TURN END (#end_turn), after the footer, so the order
|
|
490
|
+
# reads answer → reveal → `↳ turn` footer → `queued ▸` echo(es) (D7a-c).
|
|
491
|
+
def end_content_stream
|
|
492
|
+
@content_streaming = false
|
|
493
|
+
return unless @deferred_reveal
|
|
494
|
+
|
|
495
|
+
@deferred_reveal = false
|
|
496
|
+
@on_ctrl_o&.call
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Marks the START of a turn — the chat loop's run_turn calls this when it
|
|
500
|
+
# hands a prompt to the runner. From here through #end_turn the composer is
|
|
501
|
+
# "in a turn" (the THINKING phase AND the content stream), so a "queued ▸"
|
|
502
|
+
# type-ahead echo is deferred for the WHOLE turn, not only while content
|
|
503
|
+
# streams (D7e). Idempotent.
|
|
504
|
+
def begin_turn
|
|
505
|
+
@turn_active = true
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Marks the END of a turn — the chat loop's run_turn `ensure` calls this
|
|
509
|
+
# AFTER the runner has fully unwound (so the turn-summary footer is already
|
|
510
|
+
# in scrollback). Idempotent. (The "queued ▸" deferred-echo flush that used
|
|
511
|
+
# to live here is retired: in the interrupt-by-default model a mid-turn
|
|
512
|
+
# Enter interrupts and runs next, and an explicit queue shows a live
|
|
513
|
+
# "⏳ queued:" indicator instead of a post-footer echo.)
|
|
514
|
+
def end_turn
|
|
515
|
+
@turn_active = false
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Sets the TRANSIENT announcement row (the Shift+Tab mode confirmation).
|
|
519
|
+
# It renders in the live region above the prompt and is redrawn in place —
|
|
520
|
+
# cycling N times REPLACES it, never stacks — and is cleared on the next
|
|
521
|
+
# keystroke, so it leaves ZERO committed scrollback lines (D2/D3). An
|
|
522
|
+
# empty/nil string clears it. Must NOT be routed through print_above.
|
|
523
|
+
def announce(text)
|
|
524
|
+
@render.synchronize do
|
|
525
|
+
@announce = (text || "").to_s
|
|
526
|
+
redraw
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Updates the status bar pinned below the input (model + context
|
|
531
|
+
# saturation — see {StatusBar}) and repaints in place. Called at TURN
|
|
532
|
+
# BOUNDARIES only (after the footer / on session resume), never per
|
|
533
|
+
# stream delta, so the bar can't busy-repaint. nil/empty clears the bar
|
|
534
|
+
# (its row disappears on the next frame). Dropped while suspended, like
|
|
535
|
+
# every other live repaint — the next #resume redraws.
|
|
536
|
+
def set_status(text)
|
|
537
|
+
return if @suspended
|
|
538
|
+
|
|
539
|
+
@render.synchronize do
|
|
540
|
+
@status = (text || "").to_s
|
|
541
|
+
redraw
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Handle a Ctrl+C pressed at the IDLE prompt (BH-2). Mirrors the industry
|
|
546
|
+
# norm (Claude Code / Codex / readline) and the during-turn double-tap so a
|
|
547
|
+
# single Ctrl+C never silently discards a typed draft:
|
|
548
|
+
#
|
|
549
|
+
# * buffer NON-EMPTY → CLEAR the line (and any open completion menu) and
|
|
550
|
+
# stay (returns :cleared). The draft-clear resets the exit timer, so a
|
|
551
|
+
# subsequent empty Ctrl+C starts the two-tap exit fresh.
|
|
552
|
+
# * buffer EMPTY, first tap → show a transient "(press Ctrl+C again to
|
|
553
|
+
# exit)" hint and stay (returns :hint).
|
|
554
|
+
# * buffer EMPTY, second tap within +window+ seconds → exit (returns
|
|
555
|
+
# :exit); the caller ends the session.
|
|
556
|
+
#
|
|
557
|
+
# Called by the idle reader OUTSIDE trap context (the SIGINT trap only flips
|
|
558
|
+
# a flag — Mutex#lock is forbidden in a trap), so the render mutex is safe
|
|
559
|
+
# here. +window+ is the double-tap window in seconds (the chat loop passes
|
|
560
|
+
# its DOUBLE_TAP_SECONDS so idle and in-turn behave identically).
|
|
561
|
+
def idle_interrupt(window: 2.0)
|
|
562
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
563
|
+
|
|
564
|
+
unless @buffer.empty?
|
|
565
|
+
@last_idle_int_at = nil
|
|
566
|
+
@render.synchronize do
|
|
567
|
+
@menu.close!
|
|
568
|
+
@buffer.clear
|
|
569
|
+
@cursor = 0
|
|
570
|
+
@announce = +""
|
|
571
|
+
redraw
|
|
572
|
+
end
|
|
573
|
+
return :cleared
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
return :exit if @last_idle_int_at && (now - @last_idle_int_at) <= window
|
|
577
|
+
|
|
578
|
+
@last_idle_int_at = now
|
|
579
|
+
announce("(press Ctrl+C again to exit)")
|
|
580
|
+
:hint
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Replaces the editable buffer with +text+ — MULTILINE-SAFE: real
|
|
584
|
+
# newlines stay in the buffer and render as real row breaks, exactly
|
|
585
|
+
# like a bracketed paste — parking the caret at the end, ready to edit.
|
|
586
|
+
# Used by the Esc-Esc rewind to pre-fill the picked message for
|
|
587
|
+
# edit-and-resend. Any open completion menu is closed (the text is a
|
|
588
|
+
# finished message, not a token being typed; typing afterwards reopens
|
|
589
|
+
# it via the normal auto-update) and history navigation resets so a
|
|
590
|
+
# fresh ↑ starts from the newest entry. nil/empty clears the buffer.
|
|
591
|
+
def prefill(text)
|
|
592
|
+
@render.synchronize do
|
|
593
|
+
@menu.close!
|
|
594
|
+
@buffer.replace(text.to_s)
|
|
595
|
+
@cursor = @buffer.length
|
|
596
|
+
@history.reset!
|
|
597
|
+
redraw
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The card rows currently shown (test/inspection helper).
|
|
602
|
+
attr_reader :cards
|
|
603
|
+
|
|
604
|
+
# The REAL terminal IO captured before the StdoutProxy swap. UI::Notifier
|
|
605
|
+
# rings the attention bell here while a turn owns the screen — BEL never
|
|
606
|
+
# moves the cursor, so it can't disturb the pinned input block.
|
|
607
|
+
attr_reader :output
|
|
608
|
+
|
|
609
|
+
# True when the /command + @file completion menu is open (inspection
|
|
610
|
+
# helper; the reader/specs check it to branch Tab/Enter/Esc handling).
|
|
611
|
+
def menu_open?
|
|
612
|
+
@menu.open?
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Redraws the INPUT BLOCK — the wrapped buffer rows plus the status bar —
|
|
616
|
+
# and parks the terminal cursor at the insertion point (@cursor). The
|
|
617
|
+
# buffer WRAPS at the terminal width (a real newline forces a row break),
|
|
618
|
+
# growing the block downward up to @max_input_rows visual rows; past the
|
|
619
|
+
# cap a vertical window keeps the caret row in view. The block manages
|
|
620
|
+
# its own erase: the previous frame's rows (recorded in the LiveRegion as
|
|
621
|
+
# input geometry) are cleared first, so a shrinking buffer never leaves
|
|
622
|
+
# stale rows, and the cheap keystroke path stays correct without a full
|
|
623
|
+
# live-region frame. All caret repositioning happens AFTER the last byte
|
|
624
|
+
# is printed, so a natural scroll while the block grows at the bottom of
|
|
625
|
+
# the screen can never desync the relative moves. Must be called under
|
|
626
|
+
# @render (callers below already hold it).
|
|
627
|
+
def draw_input
|
|
628
|
+
rows, caret_row, caret_col = visible_input_rows
|
|
629
|
+
status = status_row
|
|
630
|
+
|
|
631
|
+
@region.clear_input_block
|
|
632
|
+
rows.each_with_index do |row, i|
|
|
633
|
+
@output.print("\r\e[2K#{row}")
|
|
634
|
+
@output.print("\r\n") if i < rows.length - 1 || status
|
|
635
|
+
end
|
|
636
|
+
@output.print("\r\e[2K#{status}") if status
|
|
637
|
+
|
|
638
|
+
below = (rows.length - 1 - caret_row) + (status ? 1 : 0)
|
|
639
|
+
park_caret(rows, caret_col, below)
|
|
640
|
+
@region.input_drawn(above: caret_row, below: below)
|
|
641
|
+
@output.flush
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Park the terminal cursor at the caret after the block is fully printed
|
|
645
|
+
# (relative moves are only safe once nothing else will scroll): walk up
|
|
646
|
+
# past the rows below the caret row, re-home, and step right to the
|
|
647
|
+
# caret column. Skipped entirely when printing already left the cursor
|
|
648
|
+
# there — the caret at the end of a frame's last row, the common typing
|
|
649
|
+
# case — so those frames end with the buffer text, byte-minimal.
|
|
650
|
+
def park_caret(rows, caret_col, below)
|
|
651
|
+
return if below.zero? && caret_col == display_width(rows.last.gsub(ANSI_RE, ""))
|
|
652
|
+
|
|
653
|
+
@output.print("\e[#{below}A") if below.positive?
|
|
654
|
+
@output.print("\r")
|
|
655
|
+
@output.print("\e[#{caret_col}C") if caret_col.positive?
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# The current editable buffer (test/inspection helper).
|
|
659
|
+
attr_reader :buffer
|
|
660
|
+
|
|
661
|
+
# Lays out @buffer into wrapped VISUAL rows at the current width.
|
|
662
|
+
# Returns [rows, caret_row, caret_col] where each row is
|
|
663
|
+
# { chars:, start:, prompt: } — its codepoints, the buffer index of its
|
|
664
|
+
# first char, and whether it carries the prompt prefix (only the first) —
|
|
665
|
+
# and caret_row/caret_col locate the insertion point (col in DISPLAY
|
|
666
|
+
# columns from the screen's left edge, so the caret column is comparable
|
|
667
|
+
# across rows for ↑/↓ navigation). A real "\n" forces a row break; a char
|
|
668
|
+
# that would overflow the per-row budget wraps whole (wide glyphs are
|
|
669
|
+
# never split across rows). The caret is placed where the NEXT typed char
|
|
670
|
+
# will land.
|
|
671
|
+
#
|
|
672
|
+
# Continuation rows (wrap or "\n") carry a HANGING INDENT of the prefix
|
|
673
|
+
# width (P12): every row's text starts in the same column as the first
|
|
674
|
+
# row's — after the rail + prompt — instead of dropping flush-left to
|
|
675
|
+
# column 0. The indent is pure layout (rail + spaces on render, width
|
|
676
|
+
# here) — never buffer content.
|
|
677
|
+
def layout_input
|
|
678
|
+
budget = row_budget
|
|
679
|
+
rows = [{ chars: [], start: 0, prompt: true }]
|
|
680
|
+
width = @prefix_width
|
|
681
|
+
|
|
682
|
+
@buffer.each_char.with_index do |ch, i|
|
|
683
|
+
if ch == "\n"
|
|
684
|
+
rows << { chars: [], start: i + 1, prompt: false }
|
|
685
|
+
width = @prefix_width
|
|
686
|
+
next
|
|
687
|
+
end
|
|
688
|
+
w = display_width(ch)
|
|
689
|
+
if width + w > budget
|
|
690
|
+
rows << { chars: [], start: i, prompt: false }
|
|
691
|
+
width = @prefix_width
|
|
692
|
+
end
|
|
693
|
+
rows.last[:chars] << ch
|
|
694
|
+
width += w
|
|
695
|
+
end
|
|
696
|
+
[rows, *caret_position(rows)]
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# The caret's [visual_row, display_col] within a layout. The owning row
|
|
700
|
+
# is the LAST one starting at-or-before @cursor: a caret exactly on a
|
|
701
|
+
# WRAP boundary therefore lands on the wrapped row (where the next char
|
|
702
|
+
# will print), while a caret on a "\n" stays at the END of the broken
|
|
703
|
+
# row (the next row starts one past the newline) — the readline feel.
|
|
704
|
+
def caret_position(rows)
|
|
705
|
+
idx = rows.rindex { |r| @cursor >= r[:start] } || 0
|
|
706
|
+
row = rows[idx]
|
|
707
|
+
# Every row's text hangs at the prefix width (P12), so the caret
|
|
708
|
+
# column starts there on continuation rows too.
|
|
709
|
+
col = @prefix_width
|
|
710
|
+
row[:chars].each_with_index do |ch, j|
|
|
711
|
+
break if row[:start] + j >= @cursor
|
|
712
|
+
|
|
713
|
+
col += display_width(ch)
|
|
714
|
+
end
|
|
715
|
+
[idx, col]
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# The display columns available per input row: one short of the width so
|
|
719
|
+
# a glyph in the final column never arms the terminal's deferred
|
|
720
|
+
# auto-wrap (the same rule LiveRegion#emit_row applies). Guarded so a
|
|
721
|
+
# degenerate narrow terminal still fits at least one char after the
|
|
722
|
+
# prompt instead of looping.
|
|
723
|
+
def row_budget
|
|
724
|
+
[@cols - 1, @prefix_width + 1].max
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# The PRINTED input rows for this frame plus the caret position within
|
|
728
|
+
# them: the layout, windowed to @max_input_rows when the buffer outgrows
|
|
729
|
+
# the cap (the window follows the caret row minimally, like a scrolling
|
|
730
|
+
# viewport), each row rendered to its final string (prompt prefix +
|
|
731
|
+
# token highlight on a single-row buffer; plain continuation rows).
|
|
732
|
+
def visible_input_rows
|
|
733
|
+
rows, caret_row, caret_col = layout_input
|
|
734
|
+
|
|
735
|
+
if rows.length > @max_input_rows
|
|
736
|
+
top = @input_scroll.clamp(0, rows.length - @max_input_rows)
|
|
737
|
+
top = caret_row if caret_row < top
|
|
738
|
+
top = caret_row - @max_input_rows + 1 if caret_row > top + @max_input_rows - 1
|
|
739
|
+
@input_scroll = top
|
|
740
|
+
rows = rows[top, @max_input_rows]
|
|
741
|
+
caret_row -= top
|
|
742
|
+
else
|
|
743
|
+
@input_scroll = 0
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
single = rows.length == 1 && rows.first[:prompt]
|
|
747
|
+
# The rail leads EVERY row; continuations hang-indent under the text
|
|
748
|
+
# start (P12), so the indent fills the prompt columns after the rail.
|
|
749
|
+
indent = "#{@rail}#{" " * @prompt_width}"
|
|
750
|
+
texts = rows.map do |row|
|
|
751
|
+
body = row[:chars].join
|
|
752
|
+
if row[:prompt]
|
|
753
|
+
"#{@rail}#{@prompt}#{single ? highlight_line(body) : body}"
|
|
754
|
+
else
|
|
755
|
+
# Hanging indent (P12): continuations align under the text start.
|
|
756
|
+
"#{indent}#{body}"
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
[texts, caret_row, caret_col]
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# The status-bar row for this frame, or nil when there is no bar: the
|
|
763
|
+
# status text is empty, the terminal is too narrow to be useful, or the
|
|
764
|
+
# styled line wouldn't fit the row (omit whole rather than truncate
|
|
765
|
+
# mid-ANSI — a cut escape sequence would leak attributes into the
|
|
766
|
+
# terminal).
|
|
767
|
+
def status_row
|
|
768
|
+
return nil if @status.empty? || @cols < MIN_STATUS_COLS
|
|
769
|
+
return nil if display_width(@status.gsub(ANSI_RE, "")) > @cols - 1
|
|
770
|
+
|
|
771
|
+
@status
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Feeds a single character through the edit logic. Public so the PTY/unit
|
|
775
|
+
# tests can drive editing without a live raw read. Returns :submit when the
|
|
776
|
+
# key committed a line, :quit on EOF/empty-Ctrl+D, otherwise nil.
|
|
777
|
+
#
|
|
778
|
+
# The buffer is edited at @cursor (a codepoint index), so insert/delete and
|
|
779
|
+
# the arrow/Home/End/word-jump moves all act mid-line, not just at the end.
|
|
780
|
+
def handle_key(ch)
|
|
781
|
+
# The transient mode announcement is a one-shot toast: any keystroke
|
|
782
|
+
# clears it (a fresh Shift+Tab re-sets it below via #cycle_mode). It lives
|
|
783
|
+
# only in the live region, so this never touches scrollback (D2/D3).
|
|
784
|
+
clear_announce
|
|
785
|
+
case ch
|
|
786
|
+
when nil
|
|
787
|
+
return :quit
|
|
788
|
+
when "\r", "\n"
|
|
789
|
+
# Enter while a completion menu is open ACCEPTS the highlighted
|
|
790
|
+
# candidate rather than submitting (matches the old Reline dropdown) —
|
|
791
|
+
# UNLESS the buffer is ALREADY an exact, complete command, in which
|
|
792
|
+
# case Enter SUBMITS it directly instead of splicing a trailing space
|
|
793
|
+
# and requiring a second Enter (D5).
|
|
794
|
+
if menu_open? && !@menu.exact_command?(@buffer)
|
|
795
|
+
accept_completion
|
|
796
|
+
return nil
|
|
797
|
+
end
|
|
798
|
+
submit_line
|
|
799
|
+
return :submit
|
|
800
|
+
when "\t" # Tab: accept the menu selection, or open the menu if a token is typed.
|
|
801
|
+
handle_tab
|
|
802
|
+
when "", "\b" # DEL / Backspace: delete the char BEFORE the cursor.
|
|
803
|
+
delete_back
|
|
804
|
+
when "\x04" # Ctrl+D: delete forward; on an empty buffer it's EOF/quit.
|
|
805
|
+
return :quit if @buffer.empty?
|
|
806
|
+
|
|
807
|
+
delete_forward
|
|
808
|
+
when "\x01" then move_to(0) # Ctrl+A → line start
|
|
809
|
+
when "\x05" then move_to(@buffer.length) # Ctrl+E → line end
|
|
810
|
+
when "\x02" then move_by(-1) # Ctrl+B → left
|
|
811
|
+
when "\x06" then move_by(1) # Ctrl+F → right
|
|
812
|
+
when "\x0b" then kill_to_end # Ctrl+K → delete to end of line
|
|
813
|
+
when "\x15" then kill_to_start # Ctrl+U → delete to start of line
|
|
814
|
+
when "\x0f" # Ctrl+O: reveal the last retained reasoning aside.
|
|
815
|
+
request_reveal
|
|
816
|
+
when "\e"
|
|
817
|
+
# ESC: start of a CSI/SS3 escape (arrows, Home/End, word-jump,
|
|
818
|
+
# Shift+Tab, bracketed paste) OR a lone ESC that dismisses the menu.
|
|
819
|
+
consume_escape_sequence
|
|
820
|
+
else
|
|
821
|
+
insert(ch) if printable?(ch)
|
|
822
|
+
# Other control bytes (incl. \x03 Ctrl+C, which the kernel turns into
|
|
823
|
+
# SIGINT before it reaches here under raw(intr: true)) are ignored.
|
|
824
|
+
end
|
|
825
|
+
nil
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Recomputes width from the terminal and redraws under the mutex. Public so
|
|
829
|
+
# the SIGWINCH handler (trap-context) and tests can call it.
|
|
830
|
+
#
|
|
831
|
+
# Redraws the WHOLE live region (the in-progress streamed @partial AND the
|
|
832
|
+
# prompt), not just the prompt: on resize xterm reflows/clears the bottom
|
|
833
|
+
# rows, so repainting only the prompt left the live streaming line blank
|
|
834
|
+
# until the turn committed (X1). Repainting the partial at the new width
|
|
835
|
+
# keeps mid-stream output visible across a resize. Committed scrollback is
|
|
836
|
+
# untouched (the terminal reflows it natively).
|
|
837
|
+
def resize
|
|
838
|
+
@render.synchronize do
|
|
839
|
+
@cols = compute_cols
|
|
840
|
+
# Repaint the FULL live region (cards + menu + partial + prompt) when
|
|
841
|
+
# anything above the prompt is live, reusing the same atomic frame the
|
|
842
|
+
# streaming writer uses; a bare draw_input would repaint only the
|
|
843
|
+
# prompt and leave the reflowed partial/card rows blank until the turn
|
|
844
|
+
# committed (X1). With nothing live above the prompt the cheap
|
|
845
|
+
# prompt-only redraw is enough. Same gate as every other repaint
|
|
846
|
+
# (#redraw → #live_region?), so the two paths can never drift again.
|
|
847
|
+
redraw
|
|
848
|
+
end
|
|
849
|
+
rescue StandardError
|
|
850
|
+
nil
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
private
|
|
854
|
+
|
|
855
|
+
# Draws one atomic frame via the {LiveRegion}. Layout (top → bottom):
|
|
856
|
+
#
|
|
857
|
+
# [committed lines] ← only when +committed+ is given; scroll into
|
|
858
|
+
# scrollback and stay there
|
|
859
|
+
# [live rows] ← cards, completion menu, transient announce,
|
|
860
|
+
# "⏳ queued:" indicators, streamed partial —
|
|
861
|
+
# redrawn in place every frame (do NOT scroll)
|
|
862
|
+
# [input block] ← "▍❯ " + buffer (the rail leads every row),
|
|
863
|
+
# wrapped over up to @max_input_rows visual
|
|
864
|
+
# rows; the cursor parks at the caret's
|
|
865
|
+
# row/column
|
|
866
|
+
# [status bar] ← the dim model + context line (when set/fits)
|
|
867
|
+
#
|
|
868
|
+
# The +@buffer+ is redrawn on every frame, so it can never be lost across
|
|
869
|
+
# a scroll. Must be called while holding @render.
|
|
870
|
+
def render_frame(committed:)
|
|
871
|
+
@region.frame(committed: committed, rows: live_rows, cols: @cols) { draw_input }
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# The live rows for this frame, top → bottom: the subagent cards; the
|
|
875
|
+
# completion menu (a navigable list redrawn in place each frame, so it
|
|
876
|
+
# never scrolls or smears); the TRANSIENT announcement (mode confirmation
|
|
877
|
+
# — one row, never committed, D2/D3); the EXPLICITLY-queued "⏳ queued:"
|
|
878
|
+
# indicators (removed, and the item committed as a normal message, when
|
|
879
|
+
# its turn runs); and the streamed partial (one row per line, capped, so
|
|
880
|
+
# a rolling markdown tail can't push the prompt off-screen, #127).
|
|
881
|
+
def live_rows
|
|
882
|
+
rows = @cards.dup
|
|
883
|
+
rows.concat(menu_rows)
|
|
884
|
+
rows << @announce unless @announce.empty?
|
|
885
|
+
rows.concat(@queued.rows)
|
|
886
|
+
rows.concat(partial_rows)
|
|
887
|
+
rows
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# The rendered completion-menu rows at the current width (also a spec
|
|
891
|
+
# inspection seam).
|
|
892
|
+
def menu_rows
|
|
893
|
+
@menu.rows(@cols)
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# The partial as drawn: its last MAX_PARTIAL_ROWS lines, one row each.
|
|
897
|
+
def partial_rows
|
|
898
|
+
return [] if @partial.empty?
|
|
899
|
+
|
|
900
|
+
@partial.split("\n").last(MAX_PARTIAL_ROWS) || []
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Width math delegators (see LiveRegion for the display-column semantics):
|
|
904
|
+
# the draw/wrap paths here measure with the SAME rules the live-row
|
|
905
|
+
# clamp uses, so the input-block model can never disagree with the renderer.
|
|
906
|
+
def clamp(str, cols) = LiveRegion.clamp(str, cols)
|
|
907
|
+
def display_width(str) = LiveRegion.display_width(str)
|
|
908
|
+
|
|
909
|
+
# Enter. Captures + clears the buffer, then routes per the interrupt-by-
|
|
910
|
+
# default model:
|
|
911
|
+
# * empty → nothing.
|
|
912
|
+
# * "/queued <msg>" → QUEUE the rest (no interrupt), like Alt+Enter.
|
|
913
|
+
# * :prompt (idle) → immediate "<prompt><line>" echo (unchanged).
|
|
914
|
+
# * :queued + turn active → INTERRUPT the current turn and run the line
|
|
915
|
+
# next (default). The line is pushed; the next
|
|
916
|
+
# turn's prompt echo is committed by the chat
|
|
917
|
+
# loop when it runs, so nothing is echoed here.
|
|
918
|
+
# * :queued + idle → immediate "queued ▸ <line>" (standalone/tests
|
|
919
|
+
# with no turn and no interrupt hook).
|
|
920
|
+
def submit_line
|
|
921
|
+
line = take_buffer
|
|
922
|
+
return if line.strip.empty?
|
|
923
|
+
|
|
924
|
+
if line.start_with?(QUEUED_PREFIX)
|
|
925
|
+
msg = line[QUEUED_PREFIX.length..].to_s.strip
|
|
926
|
+
queue_message(msg) unless msg.empty?
|
|
927
|
+
return
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
@history.remember(line)
|
|
931
|
+
|
|
932
|
+
if @echo == :prompt
|
|
933
|
+
@input_queue&.push(line)
|
|
934
|
+
print_above("#{@prompt}#{line}")
|
|
935
|
+
elsif (@turn_active || @content_streaming) && @on_interrupt
|
|
936
|
+
# Interrupt-by-default: send the line as the NEXT turn immediately and
|
|
937
|
+
# interrupt the current one. Push to the FRONT so it runs ahead of any
|
|
938
|
+
# items the user explicitly parked (Alt+Enter / "/queued") earlier in
|
|
939
|
+
# this turn, THEN fire the interrupt. No echo here — run_turn commits
|
|
940
|
+
# the next turn's "<prompt><line>" when it runs — but the line DOES get
|
|
941
|
+
# a live "⏳ queued:" indicator while parked (#129): if the interrupted
|
|
942
|
+
# turn doesn't unwind instantly (e.g. it is deep in post-turn work),
|
|
943
|
+
# the submit must never be invisible. The indicator is removed at
|
|
944
|
+
# dequeue time like any other queued item.
|
|
945
|
+
queue_message(line, front: true)
|
|
946
|
+
fire_interrupt(line)
|
|
947
|
+
else
|
|
948
|
+
# No active turn (or no interrupt hook wired): a plain queued submit,
|
|
949
|
+
# echoed immediately as before.
|
|
950
|
+
@input_queue&.push(line)
|
|
951
|
+
print_above("queued ▸ #{line}")
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
# Fire the on_interrupt hook for a mid-turn submit. A SLASH COMMAND
|
|
956
|
+
# entered while nothing is visibly in flight (no content stream, no live
|
|
957
|
+
# partial row — e.g. the turn is only repainting a subagent card) is a
|
|
958
|
+
# QUIET interrupt (#111): the hook receives quiet=true so the chat loop
|
|
959
|
+
# can suppress the `⎿ interrupted` marker, which would otherwise strand
|
|
960
|
+
# a stray artifact above the command's own output even though the turn
|
|
961
|
+
# LOOKED idle. A hook that takes no parameter (tests/embedders) keeps
|
|
962
|
+
# the old no-arg contract.
|
|
963
|
+
def fire_interrupt(line)
|
|
964
|
+
if @on_interrupt.arity.zero?
|
|
965
|
+
@on_interrupt.call
|
|
966
|
+
else
|
|
967
|
+
quiet = line.start_with?("/") && !@content_streaming && @partial.empty?
|
|
968
|
+
@on_interrupt.call(quiet)
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Alt+Enter (\e\r / \e\n) — or the "/queued" alias — QUEUES the current
|
|
973
|
+
# buffer WITHOUT interrupting the active turn: push it to the input queue
|
|
974
|
+
# and add a live "⏳ queued: <msg>" row above the input. The current turn
|
|
975
|
+
# keeps running; the queued item is committed as a normal message + the
|
|
976
|
+
# indicator removed when its turn actually runs (the chat loop drives that
|
|
977
|
+
# via #commit_queued at dequeue time).
|
|
978
|
+
#
|
|
979
|
+
# With NO turn active there is nothing to queue behind: Alt+Enter behaves
|
|
980
|
+
# exactly like plain Enter (#130), so an idle chord can never park the
|
|
981
|
+
# message under a "⏳ queued:" indicator that no turn boundary will drain.
|
|
982
|
+
def queue_alt_enter
|
|
983
|
+
return submit_line unless @turn_active || @content_streaming
|
|
984
|
+
|
|
985
|
+
msg = take_buffer.strip
|
|
986
|
+
return if msg.empty?
|
|
987
|
+
|
|
988
|
+
@history.remember(msg)
|
|
989
|
+
queue_message(msg)
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Snapshot + clear the editable buffer under the render mutex, closing any
|
|
993
|
+
# open completion menu and repainting. Shared by Enter and Alt+Enter.
|
|
994
|
+
def take_buffer
|
|
995
|
+
line = nil
|
|
996
|
+
@render.synchronize do
|
|
997
|
+
@menu.close!
|
|
998
|
+
line = @buffer.dup
|
|
999
|
+
@buffer.clear
|
|
1000
|
+
@cursor = 0
|
|
1001
|
+
redraw # clears any open-menu rows above the prompt on submit
|
|
1002
|
+
end
|
|
1003
|
+
line
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# Push +msg+ to the input queue and show its live "⏳ queued:" indicator.
|
|
1007
|
+
# +front+ jumps the queue (the interrupt-by-default Enter): the message is
|
|
1008
|
+
# the NEXT one dequeued, and its indicator leads the pending rows so the
|
|
1009
|
+
# visible order matches the run order (#129).
|
|
1010
|
+
def queue_message(msg, front: false)
|
|
1011
|
+
front ? @input_queue&.push_front(msg) : @input_queue&.push(msg)
|
|
1012
|
+
@render.synchronize do
|
|
1013
|
+
@queued.push(msg, front: front)
|
|
1014
|
+
redraw
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# Redraw the prompt, repainting the FULL live region (cards + menu +
|
|
1019
|
+
# partial) when anything lives above the prompt, else just the prompt row.
|
|
1020
|
+
# Must be called under @render. This is what lets the completion menu —
|
|
1021
|
+
# which renders ABOVE the prompt — appear/clear/track as it changes, the
|
|
1022
|
+
# same way the streamed partial and the subagent cards do.
|
|
1023
|
+
def redraw
|
|
1024
|
+
live_region? ? render_frame(committed: nil) : draw_input
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# True when ANYTHING lives above the prompt — rows already on screen from
|
|
1028
|
+
# the previous frame, or state that will draw rows this frame. The ONE
|
|
1029
|
+
# gate every repaint path shares (#redraw and #resize), extracted after
|
|
1030
|
+
# the two drifted apart (one omitted the open menu) into a latent render
|
|
1031
|
+
# bug (#62).
|
|
1032
|
+
def live_region?
|
|
1033
|
+
@region.live? || @menu.open? || @cards.any? || !@partial.empty? ||
|
|
1034
|
+
!@announce.empty? || @queued.any?
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# --- Cursor-aware editing primitives -------------------------------------
|
|
1038
|
+
# All mutate @buffer at @cursor (a codepoint index, 0..length) under the
|
|
1039
|
+
# render mutex and redraw. The completion menu is auto-opened/updated/closed
|
|
1040
|
+
# after any buffer change (see #auto_update_menu) so it tracks the typed
|
|
1041
|
+
# token the way the old Reline autocompletion did — typing a leading `/` or
|
|
1042
|
+
# `@` opens it with no Tab needed; history navigation is reset on any direct
|
|
1043
|
+
# edit so a fresh ↑ starts from the newest entry.
|
|
1044
|
+
|
|
1045
|
+
# Insert printable text at the cursor (typed char or single-line paste).
|
|
1046
|
+
def insert(str)
|
|
1047
|
+
@render.synchronize do
|
|
1048
|
+
chars = @buffer.chars
|
|
1049
|
+
chars.insert(@cursor, *str.chars)
|
|
1050
|
+
@buffer.replace(chars.join)
|
|
1051
|
+
@cursor += str.chars.length
|
|
1052
|
+
@history.reset!
|
|
1053
|
+
auto_update_menu
|
|
1054
|
+
redraw
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# Backspace: remove the char before the cursor — or, when that char is
|
|
1059
|
+
# inside a registered "[Pasted text #N …]" placeholder, remove the WHOLE
|
|
1060
|
+
# token (a half-eaten placeholder would neither read nor expand). Only
|
|
1061
|
+
# store-registered spans get the whole-token treatment; lookalike text
|
|
1062
|
+
# the user typed deletes char-by-char as usual.
|
|
1063
|
+
def delete_back
|
|
1064
|
+
@render.synchronize do
|
|
1065
|
+
if @cursor.positive?
|
|
1066
|
+
chars = @buffer.chars
|
|
1067
|
+
if (span = @paste_store&.placeholder_span(@buffer, @cursor))
|
|
1068
|
+
chars.slice!(span[0], span[1])
|
|
1069
|
+
@cursor = span[0]
|
|
1070
|
+
else
|
|
1071
|
+
chars.delete_at(@cursor - 1)
|
|
1072
|
+
@cursor -= 1
|
|
1073
|
+
end
|
|
1074
|
+
@buffer.replace(chars.join)
|
|
1075
|
+
end
|
|
1076
|
+
@history.reset!
|
|
1077
|
+
auto_update_menu
|
|
1078
|
+
redraw
|
|
1079
|
+
end
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# Delete-forward (Ctrl+D / the Delete key): remove the char AT the cursor.
|
|
1083
|
+
def delete_forward
|
|
1084
|
+
@render.synchronize do
|
|
1085
|
+
chars = @buffer.chars
|
|
1086
|
+
if @cursor < chars.length
|
|
1087
|
+
chars.delete_at(@cursor)
|
|
1088
|
+
@buffer.replace(chars.join)
|
|
1089
|
+
end
|
|
1090
|
+
@history.reset!
|
|
1091
|
+
auto_update_menu
|
|
1092
|
+
redraw
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Delete from the cursor to the end of the line (Ctrl+K).
|
|
1097
|
+
def kill_to_end
|
|
1098
|
+
@render.synchronize do
|
|
1099
|
+
@buffer.replace(@buffer.chars.first(@cursor).join)
|
|
1100
|
+
@history.reset!
|
|
1101
|
+
auto_update_menu
|
|
1102
|
+
redraw
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# Delete from the start of the line to the cursor (Ctrl+U).
|
|
1107
|
+
def kill_to_start
|
|
1108
|
+
@render.synchronize do
|
|
1109
|
+
@buffer.replace(@buffer.chars.drop(@cursor).join)
|
|
1110
|
+
@cursor = 0
|
|
1111
|
+
@history.reset!
|
|
1112
|
+
auto_update_menu
|
|
1113
|
+
redraw
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# Move the cursor by +delta+ codepoints, clamped to the buffer.
|
|
1118
|
+
def move_by(delta)
|
|
1119
|
+
@render.synchronize do
|
|
1120
|
+
@cursor = (@cursor + delta).clamp(0, @buffer.length)
|
|
1121
|
+
auto_update_menu # moving off the token closes the menu
|
|
1122
|
+
redraw
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
# Move the cursor to an absolute codepoint index, clamped.
|
|
1127
|
+
def move_to(index)
|
|
1128
|
+
@render.synchronize do
|
|
1129
|
+
@cursor = index.clamp(0, @buffer.length)
|
|
1130
|
+
auto_update_menu # moving off the token closes the menu
|
|
1131
|
+
redraw
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Word-jump LEFT (Alt/Ctrl + ←): skip any whitespace immediately left, then
|
|
1136
|
+
# the word characters, landing at the start of the previous word.
|
|
1137
|
+
def word_left
|
|
1138
|
+
@render.synchronize do
|
|
1139
|
+
chars = @buffer.chars
|
|
1140
|
+
i = @cursor
|
|
1141
|
+
i -= 1 while i.positive? && chars[i - 1] =~ /\s/
|
|
1142
|
+
i -= 1 while i.positive? && chars[i - 1] !~ /\s/
|
|
1143
|
+
@cursor = i
|
|
1144
|
+
redraw
|
|
1145
|
+
end
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Word-jump RIGHT (Alt/Ctrl + →): skip the current word then trailing
|
|
1149
|
+
# whitespace, landing at the start of the next word.
|
|
1150
|
+
def word_right
|
|
1151
|
+
@render.synchronize do
|
|
1152
|
+
chars = @buffer.chars
|
|
1153
|
+
i = @cursor
|
|
1154
|
+
i += 1 while i < chars.length && chars[i] !~ /\s/
|
|
1155
|
+
i += 1 while i < chars.length && chars[i] =~ /\s/
|
|
1156
|
+
@cursor = i
|
|
1157
|
+
redraw
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# ↑: navigate the completion menu when open; inside a MULTI-ROW buffer
|
|
1162
|
+
# move the caret up one visual row (column preserved) — only from the
|
|
1163
|
+
# FIRST row does ↑ fall back to walking history to an older entry, the
|
|
1164
|
+
# readline/Claude Code convention. No-op when there's nothing older.
|
|
1165
|
+
def history_up
|
|
1166
|
+
return menu_up if menu_open?
|
|
1167
|
+
return if move_caret_row(-1)
|
|
1168
|
+
|
|
1169
|
+
@render.synchronize do
|
|
1170
|
+
entry = @history.up(@buffer)
|
|
1171
|
+
next if entry.nil?
|
|
1172
|
+
|
|
1173
|
+
@buffer.replace(entry)
|
|
1174
|
+
@cursor = @buffer.length
|
|
1175
|
+
redraw
|
|
1176
|
+
end
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
# ↓: navigate the menu when open; inside a multi-row buffer move the
|
|
1180
|
+
# caret down one visual row — only from the LAST row does ↓ fall back to
|
|
1181
|
+
# walking history forward (newer entry, or back to the stashed draft).
|
|
1182
|
+
# No-op when not navigating history.
|
|
1183
|
+
def history_down
|
|
1184
|
+
return menu_down if menu_open?
|
|
1185
|
+
return if move_caret_row(1)
|
|
1186
|
+
|
|
1187
|
+
@render.synchronize do
|
|
1188
|
+
entry = @history.down(@buffer)
|
|
1189
|
+
next if entry.nil?
|
|
1190
|
+
|
|
1191
|
+
@buffer.replace(entry)
|
|
1192
|
+
@cursor = @buffer.length
|
|
1193
|
+
redraw
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Move the caret one VISUAL row up/down within a wrapped multi-row
|
|
1198
|
+
# buffer, keeping the screen column (clamped to the target row's
|
|
1199
|
+
# content). Returns true when it moved — ↑/↓ then stay inside the block;
|
|
1200
|
+
# false (single-row buffer, or already on the first/last row) lets the
|
|
1201
|
+
# caller fall back to history navigation.
|
|
1202
|
+
def move_caret_row(delta)
|
|
1203
|
+
moved = false
|
|
1204
|
+
@render.synchronize do
|
|
1205
|
+
rows, caret_row, caret_col = layout_input
|
|
1206
|
+
target = caret_row + delta
|
|
1207
|
+
next unless rows.length > 1 && target.between?(0, rows.length - 1)
|
|
1208
|
+
|
|
1209
|
+
@cursor = char_index_at(rows[target], caret_col)
|
|
1210
|
+
auto_update_menu # moving off the token closes the menu
|
|
1211
|
+
redraw
|
|
1212
|
+
moved = true
|
|
1213
|
+
end
|
|
1214
|
+
moved
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
# The buffer index of the char at (or before) screen column +col+ on a
|
|
1218
|
+
# layout row — where the caret lands when ↑/↓ carries the column across
|
|
1219
|
+
# rows. Walks the row's chars by display width (a wide glyph is never
|
|
1220
|
+
# split: a column inside it resolves to its start). Clamps to the row's
|
|
1221
|
+
# end, and to its start when the column falls inside the prompt prefix.
|
|
1222
|
+
def char_index_at(row, col)
|
|
1223
|
+
# Continuation rows hang at the prefix width too (P12).
|
|
1224
|
+
width = @prefix_width
|
|
1225
|
+
index = row[:start]
|
|
1226
|
+
row[:chars].each do |ch|
|
|
1227
|
+
w = display_width(ch)
|
|
1228
|
+
break if width + w > col
|
|
1229
|
+
|
|
1230
|
+
width += w
|
|
1231
|
+
index += 1
|
|
1232
|
+
end
|
|
1233
|
+
index
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# Cyan the leading /command / @mention token (shared with the old prompt).
|
|
1237
|
+
# Plain when no completion source is wired.
|
|
1238
|
+
def highlight_line(line)
|
|
1239
|
+
return line.to_s unless @completion
|
|
1240
|
+
|
|
1241
|
+
@completion.highlight_line(line.to_s)
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
# --- /command + @file completion menu ------------------------------------
|
|
1245
|
+
# The dropdown itself — open/refine/accept/dismiss state, candidate
|
|
1246
|
+
# resolution and row rendering — lives in the {CompletionMenu}; here is
|
|
1247
|
+
# only the keystroke plumbing and the buffer splice (the menu never
|
|
1248
|
+
# touches @buffer or the render mutex).
|
|
1249
|
+
|
|
1250
|
+
# Tab: with the menu open, accept the highlighted candidate; otherwise try
|
|
1251
|
+
# to open the menu for the token under the cursor (an explicit Tab always
|
|
1252
|
+
# reopens an ESC-dismissed menu). A plain Tab on non-completable text is a
|
|
1253
|
+
# no-op (we never insert a literal tab).
|
|
1254
|
+
def handle_tab
|
|
1255
|
+
if menu_open?
|
|
1256
|
+
accept_completion
|
|
1257
|
+
elsif @menu.open(@buffer, @cursor)
|
|
1258
|
+
@render.synchronize { redraw }
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# Track the menu to the token under the cursor after any buffer edit or
|
|
1263
|
+
# cursor move (Reline parity — see CompletionMenu#auto_update).
|
|
1264
|
+
def auto_update_menu
|
|
1265
|
+
@menu.auto_update(@buffer, @cursor)
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
# ↑/↓ within the menu (routed from history_up/down when the menu is open).
|
|
1269
|
+
# Arrowing marks the menu as NAVIGATED — an explicit accept intent, so
|
|
1270
|
+
# Enter on an empty argument token accepts the highlight instead of
|
|
1271
|
+
# submitting the buffer (see CompletionMenu#exact_command?).
|
|
1272
|
+
def menu_up
|
|
1273
|
+
@render.synchronize do
|
|
1274
|
+
@menu.up
|
|
1275
|
+
redraw
|
|
1276
|
+
end
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def menu_down
|
|
1280
|
+
@render.synchronize do
|
|
1281
|
+
@menu.down
|
|
1282
|
+
redraw
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
# Accept the highlighted candidate: splice it in for the token span (the
|
|
1287
|
+
# replacement carries a trailing space, so the next token starts clean,
|
|
1288
|
+
# like Reline's append char), park the cursor after it, and close the menu.
|
|
1289
|
+
def accept_completion
|
|
1290
|
+
return unless menu_open?
|
|
1291
|
+
|
|
1292
|
+
@render.synchronize do
|
|
1293
|
+
start, len, replacement = @menu.accept_splice
|
|
1294
|
+
chars = @buffer.chars
|
|
1295
|
+
chars[start, len] = replacement.chars
|
|
1296
|
+
@buffer.replace(chars.join)
|
|
1297
|
+
@cursor = start + replacement.chars.length
|
|
1298
|
+
# Re-run the menu refresh for the spliced buffer (#63): accepting a
|
|
1299
|
+
# command name lands the cursor in its ARGUMENT position (`/skills `),
|
|
1300
|
+
# so the next-context dropdown (skill names, /agents ids…) opens
|
|
1301
|
+
# immediately instead of one keystroke late. With nothing to complete
|
|
1302
|
+
# there it stays closed — the redraw then just clears the old rows.
|
|
1303
|
+
auto_update_menu
|
|
1304
|
+
redraw
|
|
1305
|
+
end
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
# Handle a bracketed-paste body. The paste is inserted into the editable
|
|
1309
|
+
# buffer at the cursor like fast typing — still editable before submit.
|
|
1310
|
+
# A MULTI-LINE paste keeps its REAL newlines in the buffer (and so in the
|
|
1311
|
+
# submitted message payload — pasted code arrives at the model with its
|
|
1312
|
+
# line structure intact, #57); each newline renders as a real row break
|
|
1313
|
+
# in the multi-row input block (which supersedes the old single-row
|
|
1314
|
+
# ⏎-mark view), so pasted code reads back as the rows it is.
|
|
1315
|
+
#
|
|
1316
|
+
# A LARGE paste (more lines than paste.collapse_lines, default 5) does
|
|
1317
|
+
# not flood the buffer: it is registered in the per-session PasteStore
|
|
1318
|
+
# and a single "[Pasted text #N +M lines]" placeholder is inserted
|
|
1319
|
+
# instead — one editable token, deleted whole by backspace (see
|
|
1320
|
+
# #delete_back) and expanded to the full body at the chat loop's
|
|
1321
|
+
# message-build seam, so the model sees everything while the input and
|
|
1322
|
+
# the transcript echo stay one line. With no store wired (standalone /
|
|
1323
|
+
# tests) every paste inlines exactly as before.
|
|
1324
|
+
def submit_paste(text)
|
|
1325
|
+
return if text.nil? || text.empty?
|
|
1326
|
+
|
|
1327
|
+
body = normalize_paste_newlines(text)
|
|
1328
|
+
return if body.empty?
|
|
1329
|
+
|
|
1330
|
+
if @paste_store&.collapse?(body)
|
|
1331
|
+
insert(@paste_store.register(body))
|
|
1332
|
+
else
|
|
1333
|
+
insert(body) # at the cursor, like fast typing
|
|
1334
|
+
end
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# Normalize a pasted body's line endings to "\n" (terminals deliver CR
|
|
1338
|
+
# for Enter in raw mode) and trim TRAILING newlines so a paste that ends
|
|
1339
|
+
# with one never reads as a blank extra line. Interior newlines — and
|
|
1340
|
+
# the indentation after them — are PRESERVED end-to-end (#57).
|
|
1341
|
+
def normalize_paste_newlines(text)
|
|
1342
|
+
text.to_s.gsub(/\r\n|\r/, "\n").sub(/\n+\z/, "")
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# After ESC, parse and ACT on the escape sequence so arrows / Home / End /
|
|
1346
|
+
# word-jump / Delete drive the cursor instead of leaking into the buffer.
|
|
1347
|
+
# The {EscapeReader} consumes the byte tail (non-blocking, so a lone ESC
|
|
1348
|
+
# doesn't hang) and returns WHAT it means; this table maps the action to
|
|
1349
|
+
# the composer behavior. A lone ESC dismisses an open completion menu
|
|
1350
|
+
# immediately — the composer owns its reader, so there is no
|
|
1351
|
+
# keyseq_timeout race (D6) — and an unrecognized sequence is a quiet no-op.
|
|
1352
|
+
def consume_escape_sequence
|
|
1353
|
+
action, arg = @escapes.read_action
|
|
1354
|
+
case action
|
|
1355
|
+
when :esc then handle_lone_esc
|
|
1356
|
+
# A fast double-tap whose two ESC bytes landed in one read burst:
|
|
1357
|
+
# exactly two lone Escs back-to-back (dismiss/arm then fire — same
|
|
1358
|
+
# path, so menu and idle gating behave identically).
|
|
1359
|
+
when :esc_esc then 2.times { handle_lone_esc }
|
|
1360
|
+
when :alt_enter then queue_alt_enter
|
|
1361
|
+
when :paste then submit_paste(arg)
|
|
1362
|
+
when :mode_cycle then cycle_mode # Shift+Tab
|
|
1363
|
+
when :history_up then history_up
|
|
1364
|
+
when :history_down then history_down
|
|
1365
|
+
when :move_by then move_by(arg)
|
|
1366
|
+
when :word_left then word_left
|
|
1367
|
+
when :word_right then word_right
|
|
1368
|
+
when :move_home then move_to(0)
|
|
1369
|
+
when :move_end then move_to(@buffer.length)
|
|
1370
|
+
when :delete_forward then delete_forward
|
|
1371
|
+
end
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
# Lone ESC: dismiss an open completion menu (immediate — no keyseq_timeout),
|
|
1375
|
+
# leaving the buffer exactly as the user typed it (no fused candidate). The
|
|
1376
|
+
# dismiss STICKS for the current token (see CompletionMenu#dismiss!).
|
|
1377
|
+
#
|
|
1378
|
+
# Every lone Esc also ARMS the Esc-Esc double-tap: a second lone Esc
|
|
1379
|
+
# within {DOUBLE_ESC_SECONDS} fires +on_double_esc+ (the idle rewind
|
|
1380
|
+
# picker). The menu dismiss keeps its meaning — Esc-Esc over an open
|
|
1381
|
+
# menu reads dismiss-then-arm, with the SECOND Esc (menu now closed)
|
|
1382
|
+
# triggering the chord. Idle-only: with no hook wired (the in-turn
|
|
1383
|
+
# composer) or while a turn is active, the chord never fires, so Esc
|
|
1384
|
+
# mashing mid-turn stays a quiet no-op.
|
|
1385
|
+
def handle_lone_esc
|
|
1386
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1387
|
+
|
|
1388
|
+
if menu_open?
|
|
1389
|
+
@render.synchronize do
|
|
1390
|
+
@menu.dismiss!
|
|
1391
|
+
redraw # repaint to CLEAR the now-closed menu rows above the prompt
|
|
1392
|
+
end
|
|
1393
|
+
elsif double_esc_armed?(now)
|
|
1394
|
+
@last_esc_at = nil
|
|
1395
|
+
@on_double_esc.call
|
|
1396
|
+
return
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
@last_esc_at = now
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
# True when a prior lone Esc armed the chord within the window and the
|
|
1403
|
+
# composer may fire it: a hook is wired AND the prompt is idle (no turn
|
|
1404
|
+
# running, no content streaming) — rewind is an idle-only gesture.
|
|
1405
|
+
def double_esc_armed?(now)
|
|
1406
|
+
@on_double_esc && !@turn_active && !@content_streaming &&
|
|
1407
|
+
@last_esc_at && (now - @last_esc_at) <= DOUBLE_ESC_SECONDS
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# Shift+Tab: ask the callback to cycle + persist the mode, then adopt the
|
|
1411
|
+
# new STATUS-BAR line it returns (the mode token leads the bar now — the
|
|
1412
|
+
# prompt is a constant "▍❯ ") and redraw under the render mutex. A nil
|
|
1413
|
+
# return means the mode did not change (e.g. the yolo arm toast) — no
|
|
1414
|
+
# repaint. The confirmation banner goes through the composer's #announce
|
|
1415
|
+
# (a transient row, not committed scrollback, D2/D3). The composer owns
|
|
1416
|
+
# NO mode logic.
|
|
1417
|
+
def cycle_mode
|
|
1418
|
+
return unless @on_mode_cycle
|
|
1419
|
+
|
|
1420
|
+
new_status = @on_mode_cycle.call
|
|
1421
|
+
return if new_status.nil?
|
|
1422
|
+
|
|
1423
|
+
@render.synchronize do
|
|
1424
|
+
@status = new_status.to_s
|
|
1425
|
+
redraw
|
|
1426
|
+
end
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# Ctrl+O: reveal the last retained reasoning aside. When the answer is
|
|
1430
|
+
# actively streaming, DEFER it — committing the `┊` aside now would land it
|
|
1431
|
+
# between answer chunks and bisect the answer (D1). The deferred reveal is
|
|
1432
|
+
# flushed by #end_content_stream once the answer block finishes, so it
|
|
1433
|
+
# renders cleanly AFTER the answer. When idle (not streaming) it reveals
|
|
1434
|
+
# immediately, exactly as before.
|
|
1435
|
+
def request_reveal
|
|
1436
|
+
if @content_streaming
|
|
1437
|
+
@deferred_reveal = true
|
|
1438
|
+
else
|
|
1439
|
+
@on_ctrl_o&.call
|
|
1440
|
+
end
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
# Clears the transient mode-announcement row if one is showing (any
|
|
1444
|
+
# keystroke dismisses the toast). Redraws so the row disappears in place.
|
|
1445
|
+
# No-op (and no redraw) when there's nothing to clear.
|
|
1446
|
+
def clear_announce
|
|
1447
|
+
return if @announce.empty?
|
|
1448
|
+
|
|
1449
|
+
@render.synchronize do
|
|
1450
|
+
@announce = +""
|
|
1451
|
+
redraw
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
# Spawns the raw keystroke loop. raw(intr: true) keeps ISIG on so Ctrl+C
|
|
1456
|
+
# still generates SIGINT and reaches the double-tap trap installed by the
|
|
1457
|
+
# chat command — we never read or swallow \x03. The block form restores
|
|
1458
|
+
# the prior termios on exit; #stop additionally forces cooked mode.
|
|
1459
|
+
#
|
|
1460
|
+
# The loop blocks in IO.select on BOTH $stdin AND a self-pipe "stop"
|
|
1461
|
+
# channel, never in a bare blocking +getc+. {#stop_reader} signals the
|
|
1462
|
+
# stop pipe to wake the select and the loop exits WITHOUT reading $stdin —
|
|
1463
|
+
# so a keystroke that arrives during teardown is left in the terminal for
|
|
1464
|
+
# TTY::Prompt instead of being swallowed by the dying reader (#80). We only
|
|
1465
|
+
# +getc+ once select reports $stdin readable, and only when the stop pipe
|
|
1466
|
+
# is NOT also ready, so the handoff to an approval menu never races a
|
|
1467
|
+
# buffered byte.
|
|
1468
|
+
def start_reader
|
|
1469
|
+
stop_r, stop_w = IO.pipe
|
|
1470
|
+
@stop_pipe = stop_w
|
|
1471
|
+
Thread.new do
|
|
1472
|
+
@input.raw(intr: true) do
|
|
1473
|
+
loop do
|
|
1474
|
+
ready, = IO.select([@input, stop_r])
|
|
1475
|
+
break if ready.include?(stop_r) # stop signalled — don't read stdin
|
|
1476
|
+
next unless ready.include?(@input)
|
|
1477
|
+
|
|
1478
|
+
ch = @input.getc
|
|
1479
|
+
break if ch.nil? # EOF / stdin closed
|
|
1480
|
+
|
|
1481
|
+
result = handle_key(ch)
|
|
1482
|
+
break if result == :quit
|
|
1483
|
+
end
|
|
1484
|
+
end
|
|
1485
|
+
rescue IOError, Errno::EIO, Errno::ENODEV, Errno::ENOTTY
|
|
1486
|
+
# stdin went away (closed/redirected mid-turn) or isn't a raw-capable
|
|
1487
|
+
# device — stop reading; the turn keeps running. Nothing to surface.
|
|
1488
|
+
ensure
|
|
1489
|
+
stop_r.close unless stop_r.closed?
|
|
1490
|
+
@input.cooked! if tty?
|
|
1491
|
+
end
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
# Stop the raw reader thread deterministically (no kill race). Shared by
|
|
1495
|
+
# #stop and #suspend so the thread lifecycle stays in one place. We signal
|
|
1496
|
+
# the self-pipe to wake the reader's IO.select so the loop exits on its own
|
|
1497
|
+
# WITHOUT a +getc+, then +join+ so the thread is fully gone (and out of raw
|
|
1498
|
+
# mode) before control returns. This guarantees the reader is not mid-+getc+
|
|
1499
|
+
# when the caller hands $stdin to TTY::Prompt, so the approval menu receives
|
|
1500
|
+
# the very first keystroke (#80).
|
|
1501
|
+
#
|
|
1502
|
+
# +kill+ remains as a fallback ONLY for a reader with no stop pipe (e.g. a
|
|
1503
|
+
# stubbed reader in unit tests) — there it is the sole exit. For the real
|
|
1504
|
+
# reader the join below always returns via the pipe signal, so the kill is a
|
|
1505
|
+
# no-op on an already-finished thread and never races a buffered byte.
|
|
1506
|
+
# Safe-on-nil and idempotent.
|
|
1507
|
+
def stop_reader
|
|
1508
|
+
if @stop_pipe && !@stop_pipe.closed?
|
|
1509
|
+
# The reader may have ALREADY exited (e.g. EOF) and closed its read end
|
|
1510
|
+
# of the self-pipe before we signal — writing then raises EPIPE. The
|
|
1511
|
+
# signal is moot there (the reader is gone), so swallow it; the join
|
|
1512
|
+
# below still returns. (Errno::EPIPE / IOError on a half-closed pipe.)
|
|
1513
|
+
begin
|
|
1514
|
+
@stop_pipe.write("x")
|
|
1515
|
+
rescue Errno::EPIPE, IOError
|
|
1516
|
+
nil
|
|
1517
|
+
end
|
|
1518
|
+
@stop_pipe.close
|
|
1519
|
+
elsif @reader
|
|
1520
|
+
@reader.kill # no stop pipe (stubbed/edge): kill is the only way out
|
|
1521
|
+
end
|
|
1522
|
+
@reader&.join
|
|
1523
|
+
@reader = nil
|
|
1524
|
+
@stop_pipe = nil
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
# Clear the prompt row (and a live partial row above it, if any) and leave
|
|
1528
|
+
# the cursor on a clean line. Shared teardown for #stop and #suspend. Must
|
|
1529
|
+
# be called while holding @render.
|
|
1530
|
+
def clear_live_region_to_clean_line
|
|
1531
|
+
@output.print(PASTE_OFF)
|
|
1532
|
+
@region.clear
|
|
1533
|
+
@partial = +""
|
|
1534
|
+
@cards = []
|
|
1535
|
+
@menu.hide!
|
|
1536
|
+
@announce = +""
|
|
1537
|
+
@output.flush
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
def printable?(ch)
|
|
1541
|
+
return false unless ch.respond_to?(:valid_encoding?) && ch.valid_encoding?
|
|
1542
|
+
|
|
1543
|
+
ch.bytesize > 1 || ch.ord >= 0x20
|
|
1544
|
+
end
|
|
1545
|
+
|
|
1546
|
+
# Terminal width in columns. winsize can report 0 (or a non-positive
|
|
1547
|
+
# value) in some terminals/multiplexers, at startup, or a zero-height
|
|
1548
|
+
# window — treat anything non-positive as "unknown" and fall back, never
|
|
1549
|
+
# return <= 0 (the clamp/slice math would otherwise crash the turn).
|
|
1550
|
+
def compute_cols
|
|
1551
|
+
cols = begin
|
|
1552
|
+
positive_int(@output.winsize.last)
|
|
1553
|
+
rescue StandardError
|
|
1554
|
+
nil
|
|
1555
|
+
end
|
|
1556
|
+
cols ||= begin
|
|
1557
|
+
positive_int(IO.console&.winsize&.last)
|
|
1558
|
+
rescue StandardError
|
|
1559
|
+
nil
|
|
1560
|
+
end
|
|
1561
|
+
cols || 80
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1564
|
+
def positive_int(value)
|
|
1565
|
+
value.is_a?(Integer) && value.positive? ? value : nil
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
def tty?
|
|
1569
|
+
@input.tty?
|
|
1570
|
+
rescue StandardError
|
|
1571
|
+
false
|
|
1572
|
+
end
|
|
1573
|
+
|
|
1574
|
+
def install_winch_trap
|
|
1575
|
+
return unless Signal.list.key?("WINCH")
|
|
1576
|
+
|
|
1577
|
+
@prev_winch = Signal.trap("WINCH") do
|
|
1578
|
+
# Trap-context: resize takes the mutex, which is allowed here because
|
|
1579
|
+
# the handler runs on its own and never re-enters under the same lock.
|
|
1580
|
+
# Wrapped in rescue so a redraw failure never crashes the process.
|
|
1581
|
+
|
|
1582
|
+
resize
|
|
1583
|
+
rescue StandardError
|
|
1584
|
+
nil
|
|
1585
|
+
end
|
|
1586
|
+
rescue ArgumentError
|
|
1587
|
+
@prev_winch = nil
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
def restore_winch_trap
|
|
1591
|
+
return unless Signal.list.key?("WINCH")
|
|
1592
|
+
|
|
1593
|
+
Signal.trap("WINCH", @prev_winch || "DEFAULT")
|
|
1594
|
+
rescue ArgumentError
|
|
1595
|
+
nil
|
|
1596
|
+
end
|
|
1597
|
+
end
|
|
1598
|
+
end
|
|
1599
|
+
end
|