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,1987 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
require "tty-table"
|
|
5
|
+
require "tty-spinner"
|
|
6
|
+
require "pastel"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "unicode/display_width"
|
|
9
|
+
|
|
10
|
+
module Rubino
|
|
11
|
+
module UI
|
|
12
|
+
# Terminal-based UI adapter using TTY gems.
|
|
13
|
+
#
|
|
14
|
+
# All output goes to stdout via plain prints — no alt-screen, no
|
|
15
|
+
# mouse capture, no cursor positioning. Native terminal scroll, copy,
|
|
16
|
+
# and shell history all keep working because we never leave the
|
|
17
|
+
# main screen.
|
|
18
|
+
#
|
|
19
|
+
# Extends PrinterBase; uses compact append-only timeline rendering
|
|
20
|
+
# (no boxes, no per-element timestamps, no horizontal rules).
|
|
21
|
+
# Visual language:
|
|
22
|
+
# ● active tool or activity
|
|
23
|
+
# ✓ completed successfully
|
|
24
|
+
# ✗ failed
|
|
25
|
+
# ◆ approval required
|
|
26
|
+
# ┄ low-priority metadata
|
|
27
|
+
class CLI < PrinterBase
|
|
28
|
+
# Page size tty-prompt paginates a select menu at (its Paginator's
|
|
29
|
+
# DEFAULT_PAGE_SIZE) — the count of menu rows visible at once, used to wipe
|
|
30
|
+
# a cancelled picker's frame (#219).
|
|
31
|
+
PICKER_PAGE_SIZE = 6
|
|
32
|
+
|
|
33
|
+
# @param session_id [String] key for the session approval cache. One
|
|
34
|
+
# CLI process serves exactly one chat session, so a per-process id is
|
|
35
|
+
# the right granularity for "remember for this session" — the cache is
|
|
36
|
+
# in-memory/process-lifetime anyway. Injectable for tests.
|
|
37
|
+
# @param approval_cache [Run::SessionApprovalCache] shared cache so a
|
|
38
|
+
# prior "always" decision short-circuits the prompt, matching UI::API.
|
|
39
|
+
def initialize(session_id: nil, approval_cache: nil)
|
|
40
|
+
super()
|
|
41
|
+
@prompt = TTY::Prompt.new
|
|
42
|
+
@stream_type = nil
|
|
43
|
+
@stream_md = nil # StreamingMarkdown buffer, lazily built per content stream
|
|
44
|
+
@thinking_indicator = false
|
|
45
|
+
# Turn-scoped status row ("Ruby facet"): ONE ticker thread per turn —
|
|
46
|
+
# started when the turn (or a stand-alone wait like /probe) starts and
|
|
47
|
+
# stopped only at turn end / error / interrupt. Events swap its LABEL
|
|
48
|
+
# under @status_mutex instead of killing the thread, so inter-tool gaps
|
|
49
|
+
# and post-turn inline jobs keep an animated row instead of dead air.
|
|
50
|
+
# @thinking_started_at marks the start of the current reasoning phase so
|
|
51
|
+
# the collapse cue can report the elapsed seconds, and @reasoning_buffer
|
|
52
|
+
# accumulates the model's reasoning deltas (no longer raw-printed) for
|
|
53
|
+
# the collapse cue / full aside / ctrl-o.
|
|
54
|
+
@thinking_thread = nil
|
|
55
|
+
@status_mutex = Mutex.new
|
|
56
|
+
@status = nil
|
|
57
|
+
@turn_active = false
|
|
58
|
+
@turn_started_at = nil
|
|
59
|
+
@turn_tool_count = 0
|
|
60
|
+
@turn_tok_chars = 0
|
|
61
|
+
@thinking_started_at = nil
|
|
62
|
+
@reasoning_buffer = +""
|
|
63
|
+
# The last retained reasoning block (committed/collapsed), revealable via
|
|
64
|
+
# ctrl-o even after the answer has streamed. Reset per turn.
|
|
65
|
+
@last_reasoning = nil
|
|
66
|
+
@last_reasoning_seconds = nil
|
|
67
|
+
@activity_open = false
|
|
68
|
+
@activity_name = nil
|
|
69
|
+
# Rhythm tracker (P3): the kind of the last committed block — :tool
|
|
70
|
+
# (frames butt together), :gap (a trailing blank is already open, so
|
|
71
|
+
# the next separator is skipped), :answer, :other.
|
|
72
|
+
@last_block = :other
|
|
73
|
+
# Task ids whose FULL report the lifecycle block already rendered
|
|
74
|
+
# (#subagent_lifecycle): the injected completion notice for one of
|
|
75
|
+
# these drops its duplicated Result body (#elide_shown_reports).
|
|
76
|
+
@reported_subagent_ids = []
|
|
77
|
+
@session_id = session_id || SecureRandom.uuid
|
|
78
|
+
@approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The attention notifier (terminal bell + optional command hook).
|
|
82
|
+
# Public so the background-task plumbing can ring it when a child
|
|
83
|
+
# parks on an approval (TaskTool#approval_handler_for).
|
|
84
|
+
def notifier
|
|
85
|
+
@notifier ||= Notifier.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Renders a table, degrading to a readable vertical card layout when the
|
|
89
|
+
# full grid would overflow a narrow terminal (#84). The card layout uses
|
|
90
|
+
# FULL field labels (no `Cre…`/`Sta…` truncation — each label sits alone
|
|
91
|
+
# with room to spare) and a rule between records so cards don't run
|
|
92
|
+
# together. Field order is the header order the caller chose, which the
|
|
93
|
+
# list callers now lead with the identifying fields (ID/Title/Created).
|
|
94
|
+
def table(headers:, rows:)
|
|
95
|
+
if grid_overflows?(headers, rows)
|
|
96
|
+
render_cards(headers, rows)
|
|
97
|
+
else
|
|
98
|
+
tbl = TTY::Table.new(header: headers, rows: rows)
|
|
99
|
+
# Pin the width explicitly: TTY::Table otherwise probes the terminal
|
|
100
|
+
# via ioctl, which blows up when $stdout is a StringIO (tests/pipes).
|
|
101
|
+
$stdout.puts tbl.render(:unicode, padding: [0, 1], width: terminal_cols, resize: false)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# True when the natural grid width (column maxima + unicode borders +
|
|
106
|
+
# padding) won't fit the terminal. Measured by display width so wide
|
|
107
|
+
# glyphs count as 2. Computed directly so we never have to render-then-
|
|
108
|
+
# measure (which would probe the terminal and crash on a StringIO).
|
|
109
|
+
def grid_overflows?(headers, rows)
|
|
110
|
+
col_widths = Array.new(headers.size, 0)
|
|
111
|
+
([headers] + rows).each do |row|
|
|
112
|
+
row.each_with_index { |cell, i| col_widths[i] = [col_widths[i], display_width(cell.to_s)].max }
|
|
113
|
+
end
|
|
114
|
+
# Per column: 1 left border + 2 padding + content; plus 1 closing border.
|
|
115
|
+
natural = col_widths.sum { |w| w + 3 } + 1
|
|
116
|
+
natural > terminal_cols
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Vertical key/value cards: `Label value`, labels padded to a common
|
|
120
|
+
# width, a dim rule between records. No header truncation.
|
|
121
|
+
def render_cards(headers, rows)
|
|
122
|
+
label_w = headers.map { |h| display_width(h.to_s) }.max.to_i
|
|
123
|
+
rule = @pastel.dim("─" * [[terminal_cols, 1].max, 40].min)
|
|
124
|
+
rows.each_with_index do |row, i|
|
|
125
|
+
$stdout.puts rule if i.positive?
|
|
126
|
+
headers.each_with_index do |h, col|
|
|
127
|
+
label = h.to_s.ljust(label_w + (h.to_s.length - display_width(h.to_s)))
|
|
128
|
+
$stdout.puts "#{label} #{row[col]}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Terminal column count, headless-safe (falls back to 80).
|
|
134
|
+
def terminal_cols
|
|
135
|
+
cols = begin
|
|
136
|
+
IO.console&.winsize&.last
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
cols&.positive? ? cols : 80
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def display_width(str)
|
|
144
|
+
Unicode::DisplayWidth.of(str.to_s)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def ask(prompt)
|
|
148
|
+
# Off a real terminal (piped / non-interactive) there is no user who
|
|
149
|
+
# can answer, TTY::Prompt would leak raw cursor-control escapes into
|
|
150
|
+
# the stream (#106), and it would read whatever ambient stdin happens
|
|
151
|
+
# to hold (#107). Fail closed: no prompt, deterministic nil.
|
|
152
|
+
return nil unless interactive_terminal?
|
|
153
|
+
|
|
154
|
+
# A mid-turn prompt must own the real terminal: pause the bottom composer
|
|
155
|
+
# so TTY::Prompt reads the real $stdin and tty-screen probes the real
|
|
156
|
+
# $stdout (not the write-only StdoutProxy). No-op when no composer is
|
|
157
|
+
# active (between-turns / piped input).
|
|
158
|
+
BottomComposer.run_in_terminal { @prompt.ask(prompt) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# True when both ends are a real interactive terminal — the shared gate
|
|
162
|
+
# for every interactive prompt/menu (#ask / #select): off a TTY they
|
|
163
|
+
# return nil instead of rendering ANSI into a pipe.
|
|
164
|
+
#
|
|
165
|
+
# While a bottom composer owns the screen, $stdout is the WRITE-ONLY
|
|
166
|
+
# StdoutProxy (tty? deliberately false) but the terminal itself is real —
|
|
167
|
+
# BottomComposer.active? gates composer creation on both ends being TTYs.
|
|
168
|
+
# Probing the swapped global would wrongly bail a picker opened from
|
|
169
|
+
# under the pinned prompt (the Esc-Esc rewind), so a live composer
|
|
170
|
+
# answers the question directly; run_in_terminal then restores the real
|
|
171
|
+
# IOs for the prompt's lifetime.
|
|
172
|
+
def interactive_terminal?
|
|
173
|
+
return true if BottomComposer.current
|
|
174
|
+
|
|
175
|
+
$stdin.respond_to?(:tty?) && $stdin.tty? && $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
176
|
+
rescue StandardError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Arrow-key single-select menu — the SAME TTY::Prompt component the tool
|
|
181
|
+
# approval menu uses (see #approval_choice), so /sessions resume reuses the
|
|
182
|
+
# existing picker rather than introducing a second menu system (#145).
|
|
183
|
+
# +choices+ is an array of [label, value] pairs. Returns the chosen value,
|
|
184
|
+
# or nil when there's no real terminal (so the caller keeps the
|
|
185
|
+
# non-interactive shortcut). Esc/Ctrl-C cancels and returns nil — Esc via
|
|
186
|
+
# the #cancellable_prompt keyescape binding (#73), Ctrl-C via tty-prompt's
|
|
187
|
+
# own InputInterrupt; both land in the rescue below.
|
|
188
|
+
def select(prompt, choices)
|
|
189
|
+
return nil if choices.nil? || choices.empty?
|
|
190
|
+
return nil unless interactive_terminal?
|
|
191
|
+
|
|
192
|
+
BottomComposer.run_in_terminal do
|
|
193
|
+
cancellable_prompt.select(prompt, cycle: false, filter: true) do |menu|
|
|
194
|
+
choices.each { |label, value| menu.choice label, value }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
rescue TTY::Reader::InputInterrupt
|
|
198
|
+
# Esc aborts tty-prompt mid-render — the exception unwinds straight out of
|
|
199
|
+
# its draw loop, so the per-frame refresh that would have CLEARED the just
|
|
200
|
+
# drawn header + menu never runs. The frame is left committed to the
|
|
201
|
+
# scrollback (a dead "Resume which session? …" / "Rewind to which
|
|
202
|
+
# message? …" header + its first row), and repeated cancels stack corpses
|
|
203
|
+
# (#219). Erase the picker's frame so cancel restores the prompt cleanly —
|
|
204
|
+
# "nothing changed", as documented. The cursor is parked at the end of the
|
|
205
|
+
# last menu row, so we walk up over every drawn line and wipe to the end
|
|
206
|
+
# of the screen.
|
|
207
|
+
erase_picker_frame(choices.length)
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Clears a cancelled picker's drawn frame: 1 header row + the visible menu
|
|
212
|
+
# rows (tty-prompt paginates at PICKER_PAGE_SIZE). Walks the cursor up to
|
|
213
|
+
# the header column-0 and erases everything below it, leaving the terminal
|
|
214
|
+
# exactly as it was before the picker opened.
|
|
215
|
+
def erase_picker_frame(choice_count)
|
|
216
|
+
rows = 1 + [choice_count, PICKER_PAGE_SIZE].min
|
|
217
|
+
$stdout.print(TTY::Cursor.column(1))
|
|
218
|
+
$stdout.print(TTY::Cursor.up(rows))
|
|
219
|
+
$stdout.print(TTY::Cursor.clear_screen_down)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A DEDICATED TTY::Prompt for cancellable pickers, with Esc bound to the
|
|
223
|
+
# same InputInterrupt Ctrl-C raises (#73): tty-reader parses full escape
|
|
224
|
+
# sequences, so arrows (ESC [ A…) never trip :keyescape — only a lone Esc
|
|
225
|
+
# does. Deliberately separate from the shared @prompt so the approval
|
|
226
|
+
# menu's keymap is untouched (an Esc there must not become a deny).
|
|
227
|
+
def cancellable_prompt
|
|
228
|
+
@cancellable_prompt ||= TTY::Prompt.new.tap do |picker|
|
|
229
|
+
picker.on(:keyescape) { raise TTY::Reader::InputInterrupt }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Approval prompt with session memory. Mirrors UI::API#confirm: a prior
|
|
234
|
+
# "session"/"always_*" decision (or a persisted prefix) for this scope —
|
|
235
|
+
# or its tool-wide parent — short-circuits the prompt so the same call
|
|
236
|
+
# isn't re-asked. Decisions are mapped to the SAME cache/persister actions
|
|
237
|
+
# the HTTP path uses, so CLE and API persist identical DERIVED RULES to
|
|
238
|
+
# `security.command_allowlist` for the "always" forms:
|
|
239
|
+
#
|
|
240
|
+
# :once — approve this call only (nothing remembered)
|
|
241
|
+
# :always_prefix — persist the derived PREFIX rule (offered only when a
|
|
242
|
+
# prefix is derivable AND the command isn't dangerous)
|
|
243
|
+
# :always_command — persist the NARROW rule (pattern key if dangerous,
|
|
244
|
+
# else the exact command); survives restart
|
|
245
|
+
# :always_tool — CLI-ONLY convenience: remember the whole tool for the
|
|
246
|
+
# session (never an HTTP decision, never persisted)
|
|
247
|
+
# :no — deny this call
|
|
248
|
+
#
|
|
249
|
+
# @param scope [String, nil] "<tool>:<command>" cache key from the
|
|
250
|
+
# caller. Nil opts out of memory (legacy callers still get a prompt).
|
|
251
|
+
# @param tool [String, nil] tool name, for rule derivation.
|
|
252
|
+
# @param command [String, nil] literal command/args, for prefix derivation.
|
|
253
|
+
# @param pattern_key [String, nil] matched dangerous-pattern key, if any.
|
|
254
|
+
# @param description [String, nil] dangerous-pattern description, if any.
|
|
255
|
+
# @return [Boolean] true when approved.
|
|
256
|
+
def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
|
|
257
|
+
return true if approval_cached?(scope)
|
|
258
|
+
|
|
259
|
+
# Finalize any live streaming state before the approval card so the card
|
|
260
|
+
# header doesn't glue onto it ("thinking…⚠ shell wants:" or a
|
|
261
|
+
# reasoning tail like "Let me run this.⚠ shell wants…"). The model
|
|
262
|
+
# emits reasoning/content right up to the tool call, so the transient
|
|
263
|
+
# indicator or the in-progress stream tail is still on the current line
|
|
264
|
+
# when approval is requested. #finalize_stream commits the tail and
|
|
265
|
+
# clears the indicator, mirroring a normal stream_end.
|
|
266
|
+
finalize_stream
|
|
267
|
+
|
|
268
|
+
# Attention: the run is now parked on a human decision — ring the
|
|
269
|
+
# bell/hook so an approval can't sit unseen behind a quiet terminal.
|
|
270
|
+
notifier.needs_approval(question.to_s)
|
|
271
|
+
|
|
272
|
+
# ⚠ is the attention glyph (P7): ◆ belongs to the animated status row.
|
|
273
|
+
rule = derive_rule(tool, command, pattern_key)
|
|
274
|
+
$stdout.puts @pastel.yellow("⚠ #{question}")
|
|
275
|
+
# The danger annotation is the single most safety-relevant line on the
|
|
276
|
+
# card, so it must be the MOST prominent — red + bold, not dim (#83).
|
|
277
|
+
$stdout.puts @pastel.red.bold(" ⚠ #{description}") unless description.to_s.empty?
|
|
278
|
+
|
|
279
|
+
choice = approval_choice(rule, tool: tool)
|
|
280
|
+
approved = apply_choice(choice, scope: scope, command: command, rule: rule)
|
|
281
|
+
# First plain "Approve once" of the session: point at the session-scope
|
|
282
|
+
# menu options so a multi-edit refactor doesn't keep interrupting
|
|
283
|
+
# without the user knowing it can stop (#110). Presentation only — the
|
|
284
|
+
# approval model is untouched.
|
|
285
|
+
session_scope_tip(tool, choice) if approved
|
|
286
|
+
# A deny is a safety action: confirm explicitly that nothing ran, in the
|
|
287
|
+
# same red ✗ styling failed tools use, so "Done." can't be read as "ran"
|
|
288
|
+
# (#83). Approve/allow paths are unchanged.
|
|
289
|
+
denied(tool) unless approved
|
|
290
|
+
approved
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A destructive yes/No confirm — NOT the tool-approval menu (#218).
|
|
294
|
+
# Deleting a session or forgetting a fact is not a tool/command the model
|
|
295
|
+
# proposed, so the "Approve once / this command / this tool" vocabulary is
|
|
296
|
+
# wrong, and its highlighted default (Approve) turns a stray Enter or a
|
|
297
|
+
# piped answer into a data-loss. This defaults to **No**: blank/Esc/EOF and
|
|
298
|
+
# every non-interactive path (piped stdin) decline, and only an explicit
|
|
299
|
+
# "y"/"yes" proceeds. Returns true only when the user affirmatively agreed.
|
|
300
|
+
def confirm_destructive(question)
|
|
301
|
+
$stdout.puts @pastel.yellow("⚠ #{question}")
|
|
302
|
+
# Off a real terminal there is no one to answer; fail closed (decline)
|
|
303
|
+
# so a piped `n` — or any pipe at all — can never destroy (#218).
|
|
304
|
+
return false unless interactive_terminal?
|
|
305
|
+
|
|
306
|
+
answer = BottomComposer.run_in_terminal do
|
|
307
|
+
@prompt.yes?(@pastel.bold("Proceed?"), default: false)
|
|
308
|
+
end
|
|
309
|
+
!!answer
|
|
310
|
+
rescue TTY::Reader::InputInterrupt
|
|
311
|
+
# Esc / Ctrl-C mid-prompt: treat as decline, never destroy.
|
|
312
|
+
$stdout.puts
|
|
313
|
+
false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# One dim line, once per session, after the FIRST "Approve once" (#110):
|
|
317
|
+
# the "this tool (this session)" option already exists in the menu, but
|
|
318
|
+
# nothing surfaced it, so users approved every single edit by hand.
|
|
319
|
+
def session_scope_tip(tool, choice)
|
|
320
|
+
return unless choice == :once
|
|
321
|
+
return if @session_scope_tip_shown
|
|
322
|
+
|
|
323
|
+
@session_scope_tip_shown = true
|
|
324
|
+
label = tool.to_s.empty? ? "this tool" : tool
|
|
325
|
+
$stdout.puts @pastel.dim(
|
|
326
|
+
%(┄ tip: choose "Approve — this tool (this session)" to stop being asked for #{label} this session ┄)
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Explicit, visible confirmation that a denied command was NOT executed.
|
|
331
|
+
def denied(tool = nil)
|
|
332
|
+
label = tool ? "#{tool} command" : "command"
|
|
333
|
+
error("#{label} denied — not executed")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def separator
|
|
337
|
+
$stdout.puts @pastel.dim("─" * 80)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Panel color diet (P8): dim label, PLAIN value, cyan reserved for the
|
|
341
|
+
# actionable pointer (`(use /mcp)`). The ljust width matches the
|
|
342
|
+
# /status grid so values line up in one column.
|
|
343
|
+
def panel_line(label, value, pointer: nil)
|
|
344
|
+
row = " #{@pastel.dim(label.to_s.ljust(10))} #{value}"
|
|
345
|
+
row += " #{@pastel.cyan(pointer)}" if pointer
|
|
346
|
+
$stdout.puts row
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Welcome-panel hint row (P8): the actionable command is the ONE cyan
|
|
350
|
+
# accent; its description stays plain.
|
|
351
|
+
def hint_row(command, description)
|
|
352
|
+
$stdout.puts " #{@pastel.cyan(command.to_s.ljust(9))} #{description}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# --- Compact timeline rendering (M2) ---
|
|
356
|
+
|
|
357
|
+
# Activity started: renders as `● name` or `● name hint` — a QUIET dim
|
|
358
|
+
# row with only the ● in cyan. The tool frame is plumbing, not payload:
|
|
359
|
+
# a fully cyan "● running read · path" row outshouted the answer (P1).
|
|
360
|
+
def activity_started(name, hint: nil)
|
|
361
|
+
# Replace a still-showing "thinking…" indicator before the committed
|
|
362
|
+
# activity row so it isn't stranded above it (#86): the model emits the
|
|
363
|
+
# indicator during TTFB and may go straight to a tool call. Collapse any
|
|
364
|
+
# buffered reasoning into the cue/aside FIRST so a reasoning→tool turn
|
|
365
|
+
# (no answer text) never strands the thought.
|
|
366
|
+
collapse_reasoning
|
|
367
|
+
hint_str = hint ? " #{hint}" : ""
|
|
368
|
+
# ONE blank before the first frame of a tool run; frames inside a run
|
|
369
|
+
# butt together, and a gap left by the previous block isn't doubled (P3).
|
|
370
|
+
$stdout.puts unless %i[tool gap].include?(@last_block)
|
|
371
|
+
$stdout.puts "#{@pastel.cyan("●")} #{@pastel.dim("#{name}#{hint_str}")}"
|
|
372
|
+
@activity_open = true
|
|
373
|
+
@activity_name = name
|
|
374
|
+
@last_block = :tool
|
|
375
|
+
reset_tool_preview
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Activity finished. Success is QUIET and compact: `└ ✓ 11 lines` — the
|
|
379
|
+
# ✓ already says "done" and the opener row said the name, so repeating
|
|
380
|
+
# both was noise (P10); dim, not green — color is reserved for the one
|
|
381
|
+
# outcome that needs eyes (P1). Failure keeps name + wording, in red:
|
|
382
|
+
# `└ ✗ failed · shell · exit 1` — the word must agree with the glyph;
|
|
383
|
+
# "✗ done" read as if the errored tool had still succeeded (#153).
|
|
384
|
+
def activity_finished(name, metric: nil, failed: false)
|
|
385
|
+
@activity_open = false
|
|
386
|
+
flush_tool_preview_overflow
|
|
387
|
+
# The metric can carry newlines (e.g. a task_result body): interpolating
|
|
388
|
+
# it raw would continue flush-left and unstyled on the next lines —
|
|
389
|
+
# inline it into the ONE styled row instead.
|
|
390
|
+
inline = metric ? truncate_inline(metric, 120) : nil
|
|
391
|
+
if failed
|
|
392
|
+
suffix = inline && !inline.empty? ? " · #{inline}" : ""
|
|
393
|
+
$stdout.puts @pastel.red(" └ ✗ failed · #{name}#{suffix}")
|
|
394
|
+
else
|
|
395
|
+
suffix = inline && !inline.empty? ? " #{inline}" : ""
|
|
396
|
+
$stdout.puts @pastel.dim(" └ ✓#{suffix}")
|
|
397
|
+
end
|
|
398
|
+
@last_block = :tool
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Approval requested: renders as `◆ summary`
|
|
402
|
+
def approval_requested(summary:, choices:)
|
|
403
|
+
$stdout.puts
|
|
404
|
+
$stdout.puts @pastel.yellow("◆ #{summary}")
|
|
405
|
+
choices.each do |choice|
|
|
406
|
+
$stdout.puts @pastel.dim(" [#{choice[:key]}] #{choice[:label]}")
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Body text rendered with modest indentation (no big box).
|
|
411
|
+
def body(text)
|
|
412
|
+
return if text.nil? || text.to_s.empty?
|
|
413
|
+
|
|
414
|
+
text.each_line do |line|
|
|
415
|
+
$stdout.puts " #{line.chomp}"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# A turn that ends in ERROR must tear down the live "thinking…" animation
|
|
420
|
+
# (and any open stream) BEFORE the error line prints — otherwise the
|
|
421
|
+
# ticking row strands below the error and keeps interleaving into every
|
|
422
|
+
# subsequent print until a full repaint (#74). The success path settles
|
|
423
|
+
# via stream_end/collapse_reasoning; this gives the error path the same
|
|
424
|
+
# cleanup. Idempotent — a no-op for errors printed outside a turn.
|
|
425
|
+
def error(message)
|
|
426
|
+
finalize_stream
|
|
427
|
+
# An error tears the turn-scoped status row down entirely (#74): the
|
|
428
|
+
# next model attempt (retry/fallback) restarts it via thinking_started.
|
|
429
|
+
status_stop
|
|
430
|
+
@thinking_indicator = false
|
|
431
|
+
super
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# One-shot suppression of the next `⎿ interrupted` marker (#111). The
|
|
435
|
+
# chat loop sets it when a slash-command submit interrupted a turn with
|
|
436
|
+
# nothing visibly in flight (no stream, no live partial — e.g. only a
|
|
437
|
+
# subagent card animating): the turn LOOKED idle, so the marker would
|
|
438
|
+
# read as a stray artifact above the command's own output. Consumed by
|
|
439
|
+
# #turn_interrupted; the chat loop resets it at each turn start so a
|
|
440
|
+
# suppression that never fired can't leak into a later real Ctrl+C.
|
|
441
|
+
def suppress_interrupt_marker(value: true)
|
|
442
|
+
@suppress_interrupt_marker = value
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Commits the standardized interrupt marker right after the partial answer
|
|
446
|
+
# that was kept when a turn is cancelled (Ctrl+C, or the interrupt-by-
|
|
447
|
+
# default Enter): a dim `⎿ interrupted` row, house grammar. Leading CR +
|
|
448
|
+
# clear-line so it lands cleanly even if the cursor is sitting after a
|
|
449
|
+
# partial stream chunk. This is the single visible interrupt notice — the
|
|
450
|
+
# runner no longer also prints a separate "interrupted by user" warning.
|
|
451
|
+
# Tears down a still-ticking "thinking…" animation first, same as the
|
|
452
|
+
# error path (#74) — Loop#stream_end usually already did, but an
|
|
453
|
+
# interrupt raised outside the streaming bracket must settle too.
|
|
454
|
+
# Swallowed once after a QUIET slash-command interrupt (#111, above).
|
|
455
|
+
def turn_interrupted
|
|
456
|
+
finalize_stream
|
|
457
|
+
# Interrupt = turn end for the status row: kill the engine thread.
|
|
458
|
+
status_stop
|
|
459
|
+
@thinking_indicator = false
|
|
460
|
+
if @suppress_interrupt_marker
|
|
461
|
+
@suppress_interrupt_marker = false
|
|
462
|
+
return
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
clear_line
|
|
466
|
+
$stdout.puts @pastel.dim(" ⎿ interrupted")
|
|
467
|
+
$stdout.flush
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Free-line annotation rendered as `┄ message ┄`, dim.
|
|
471
|
+
def note(text)
|
|
472
|
+
return if text.nil? || text.to_s.empty?
|
|
473
|
+
|
|
474
|
+
$stdout.puts unless @last_block == :gap
|
|
475
|
+
$stdout.puts @pastel.dim("┄ #{text} ┄")
|
|
476
|
+
@last_block = :other
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# The STATIC turn footer rail, all dim: `┄ turn · 16.6s · 3 tools ┄`.
|
|
480
|
+
# No red ◆ — red is the error color; the animated status row keeps its
|
|
481
|
+
# red facet as the living brand mark (P4). Attached directly under the
|
|
482
|
+
# answer with no leading blank (P3). Subagent completions stashed
|
|
483
|
+
# mid-turn (#subagent_finished) fold into the grammar instead of
|
|
484
|
+
# stacking a second `┄ ┄` rail right at turn end:
|
|
485
|
+
# ┄ turn · 16.6s · 3 tools · 105 tok · sa_e488 done ┄
|
|
486
|
+
def turn_footer(text)
|
|
487
|
+
pending = Array(@pending_subagent_footers)
|
|
488
|
+
@pending_subagent_footers = nil
|
|
489
|
+
line = ([text] + pending.map { |p| p[:fold] }).join(" · ")
|
|
490
|
+
$stdout.puts @pastel.dim("┄ #{line} ┄")
|
|
491
|
+
@last_block = :other
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# A background subagent reached a terminal state. Mid-turn the one-line
|
|
495
|
+
# summary is STASHED and folded into the turn footer (P4) so two `┄ ┄`
|
|
496
|
+
# rails never stack at turn end (the report still reaches the model via
|
|
497
|
+
# the InputQueue notice, rendered by #input_injected); between turns the
|
|
498
|
+
# full lifecycle block renders immediately.
|
|
499
|
+
def subagent_finished(line, id: nil, status: "done", report: nil)
|
|
500
|
+
if @turn_active && id
|
|
501
|
+
(@pending_subagent_footers ||= []) << { fold: "#{id} #{status}",
|
|
502
|
+
line: line, status: status, report: report, id: id }
|
|
503
|
+
else
|
|
504
|
+
subagent_lifecycle(line, status: status, report: report, id: id)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# ONE lifecycle grammar (P6): the live-card-shaped row
|
|
509
|
+
# (`▸ sa_e488 · explore · completed · 1 tool · 12s`) — dim; red only on
|
|
510
|
+
# failure — and the child's FULL report markdown-rendered under its own
|
|
511
|
+
# `↳ report:` lead (the #139 fold-in treatment), never amputated to a
|
|
512
|
+
# one-line head. The id is remembered so the completion notice the model
|
|
513
|
+
# receives next turn doesn't ECHO the same report a second time
|
|
514
|
+
# (#input_injected elides the already-shown Result body).
|
|
515
|
+
def subagent_lifecycle(line, status: "done", report: nil, id: nil)
|
|
516
|
+
$stdout.puts unless @last_block == :gap
|
|
517
|
+
$stdout.puts(status == "failed" ? @pastel.red(line) : @pastel.dim(line))
|
|
518
|
+
if report && !report.to_s.strip.empty?
|
|
519
|
+
$stdout.puts @pastel.dim(" ↳ report:")
|
|
520
|
+
commit_markdown_block(report)
|
|
521
|
+
remember_reported_subagent(id)
|
|
522
|
+
end
|
|
523
|
+
@last_block = :other
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Commits the ⛔ "a subagent needs you" attention banner into scrollback the
|
|
527
|
+
# instant a background child escalates an ask_parent to the human. This is
|
|
528
|
+
# the ATTENTION event (the one-time, unmissable banner); the persistent
|
|
529
|
+
# AMBIENT reminder is the ⛔ card line the live region keeps showing (see
|
|
530
|
+
# UI::SubagentCards#hint_line) so a blocked tree can never hide behind a
|
|
531
|
+
# spinner. The answer verb is /reply <id>; --stop cancels the child. Routed
|
|
532
|
+
# through $stdout so (during a turn) it lands above the bottom composer like
|
|
533
|
+
# every other committed line; between turns it prints inline.
|
|
534
|
+
def subagent_ask_banner(id, subagent, question)
|
|
535
|
+
$stdout.puts
|
|
536
|
+
$stdout.puts @pastel.dim("┄ a subagent needs you ┄")
|
|
537
|
+
$stdout.puts @pastel.red.bold("⛔ #{id} (#{subagent}) is BLOCKED, waiting on your answer")
|
|
538
|
+
$stdout.puts @pastel.yellow(" ❓ #{question}")
|
|
539
|
+
$stdout.puts @pastel.dim(" everything it needs is paused until you answer — #{ask_timeout_hint}")
|
|
540
|
+
$stdout.puts @pastel.dim(" → /reply #{id} <answer> to answer · /agents #{id} --stop to cancel")
|
|
541
|
+
$stdout.flush
|
|
542
|
+
# The ⛔ state is the loudest one — the whole subtree is parked on the
|
|
543
|
+
# human — so it also rings the attention bell/hook.
|
|
544
|
+
notifier.blocked("#{id} (#{subagent}) is waiting on your answer")
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# The honest bound for the ⛔ banner: a blocking ask_parent waits at most
|
|
548
|
+
# tasks.ask_parent_timeout seconds, then the child proceeds with its best
|
|
549
|
+
# judgement (ask_parent_tool.rb). The banner must say so — "no timeout" was
|
|
550
|
+
# a lie unless the bound is explicitly disabled (nil/0) in config (#145).
|
|
551
|
+
def ask_timeout_hint
|
|
552
|
+
seconds = Rubino.configuration.tasks_ask_parent_timeout.to_i
|
|
553
|
+
return "no timeout" unless seconds.positive?
|
|
554
|
+
|
|
555
|
+
human = (seconds % 60).zero? ? "#{seconds / 60}m" : "#{seconds}s"
|
|
556
|
+
"auto-resumes with its best judgement in #{human}"
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Renders an ephemeral `probe` answer in the dim, fenced aside that the
|
|
560
|
+
# locked UX prescribes: an opening `┄ probe (ephemeral · not saved) ┄`
|
|
561
|
+
# rail, the answer body on a dim `┊` left-rail, then a closing
|
|
562
|
+
# `┄ vanished · main thread untouched ┄` rail. The whole block is dim and
|
|
563
|
+
# never enters scrollback as a "real" answer — it is the visual contract
|
|
564
|
+
# that nothing here was saved. Same render family as #note / #mode_changed.
|
|
565
|
+
def probe_aside(answer)
|
|
566
|
+
$stdout.puts
|
|
567
|
+
$stdout.puts @pastel.dim("┄ probe (ephemeral · not saved) ┄#{"─" * 28}")
|
|
568
|
+
answer.to_s.each_line do |line|
|
|
569
|
+
$stdout.puts @pastel.dim("┊ #{line.chomp}")
|
|
570
|
+
end
|
|
571
|
+
$stdout.puts @pastel.dim("┄ vanished · main thread untouched ┄#{"─" * 25}")
|
|
572
|
+
$stdout.puts
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Confirms a `/branch` fork in the dim block from the locked UX: the new
|
|
576
|
+
# session id + title, the parent it inherits from, and the literal way
|
|
577
|
+
# back (`/sessions <parent>`), bracketed by `┄ branched ┄` / `┄ now in
|
|
578
|
+
# <id> ┄` rails. The CLI flips the prompt chip to `branch:<id> ❯` after.
|
|
579
|
+
def branch_confirmation(new_id:, parent_id:, title:, included_probe:)
|
|
580
|
+
short_new = new_id.to_s[0..3]
|
|
581
|
+
short_parent = parent_id.to_s[0..3]
|
|
582
|
+
seed = "inherits #{short_parent} ▸ up to here"
|
|
583
|
+
seed += " + the probe above" if included_probe
|
|
584
|
+
$stdout.puts
|
|
585
|
+
$stdout.puts @pastel.dim("┄ branched ┄#{"─" * 50}")
|
|
586
|
+
label = title.to_s.strip.empty? ? "" : %( "#{title}")
|
|
587
|
+
$stdout.puts @pastel.dim("┊ new session #{short_new}#{label}")
|
|
588
|
+
$stdout.puts @pastel.dim("┊ #{seed}")
|
|
589
|
+
$stdout.puts @pastel.dim("┊ original #{short_parent} left intact — /sessions #{short_parent} to return")
|
|
590
|
+
$stdout.puts @pastel.dim("┄ now in #{short_new} ┄#{"─" * 42}")
|
|
591
|
+
$stdout.puts
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Repaints the SUBAGENT CARD block in the live region from the
|
|
595
|
+
# BackgroundTasks registry (Variant A). Called whenever a background
|
|
596
|
+
# subagent's activity changes (a child tool started/finished, a spawn, a
|
|
597
|
+
# completion, an approval request) so the collapsed cards update IN PLACE
|
|
598
|
+
# without flooding scrollback. Renders the registry's CURRENT live snapshot
|
|
599
|
+
# rather than a single delta, so cards added/removed/updated all converge.
|
|
600
|
+
#
|
|
601
|
+
# The card block only exists while a turn owns the bottom composer
|
|
602
|
+
# (BottomComposer.current); between turns there is no live region, so this
|
|
603
|
+
# is a quiet no-op (the /agents drill-in covers the idle case). Reads the
|
|
604
|
+
# registry under its own mutex via #running; the formatting is pure.
|
|
605
|
+
def set_subagent_cards
|
|
606
|
+
composer = BottomComposer.current
|
|
607
|
+
return unless composer
|
|
608
|
+
|
|
609
|
+
entries = Tools::BackgroundTasks.instance.running
|
|
610
|
+
composer.set_cards(subagent_cards.card_lines(entries))
|
|
611
|
+
rescue StandardError
|
|
612
|
+
# A card repaint is cosmetic — never let it break the turn or the child.
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def subagent_cards
|
|
616
|
+
@subagent_cards ||= SubagentCards.new(pastel: @pastel)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Echoes a line the user typed mid-turn, parked for the next turn.
|
|
620
|
+
# Rendered dim on its own line, prefixed `▸`, so the steered text stays
|
|
621
|
+
# visible without competing with the streaming assistant output. Starts
|
|
622
|
+
# with a CR + clear-line so it lands cleanly even if the cursor is
|
|
623
|
+
# sitting after a partial stream chunk.
|
|
624
|
+
def queued(text)
|
|
625
|
+
return if text.nil? || text.to_s.empty?
|
|
626
|
+
|
|
627
|
+
clear_line
|
|
628
|
+
$stdout.puts @pastel.dim("queued ▸ #{text}")
|
|
629
|
+
$stdout.flush
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Confirms text the loop picked up mid-turn and injected into the CURRENT
|
|
633
|
+
# turn (Phase-2 steering). Rendered dim on its own line, prefixed `↳`, so
|
|
634
|
+
# the user sees their interjection landed without it competing with the
|
|
635
|
+
# streaming assistant output. Leading CR + clear-line so it sits cleanly
|
|
636
|
+
# even if the cursor is mid-stream-chunk.
|
|
637
|
+
#
|
|
638
|
+
# A multi-line injection (a `[background-task] … Result:` completion
|
|
639
|
+
# notice carrying the child's markdown report) keeps the dim `↳` prefix
|
|
640
|
+
# on its FIRST line only; the body renders through the same markdown
|
|
641
|
+
# pipeline as assistant answers, so the child's report shows styled
|
|
642
|
+
# headings/bold instead of literal `##`/`**` (#139).
|
|
643
|
+
#
|
|
644
|
+
# An injected line that carried a live "⏳ queued:" indicator (an
|
|
645
|
+
# Alt+Enter / "/queued" item the loop folded into the current turn) has
|
|
646
|
+
# been CONSUMED — drop its indicator, or it would sit above the input
|
|
647
|
+
# forever for a message that already ran (#129).
|
|
648
|
+
def input_injected(text)
|
|
649
|
+
return if text.nil? || text.to_s.empty?
|
|
650
|
+
|
|
651
|
+
if (composer = BottomComposer.current)
|
|
652
|
+
# The loop coalesces several drained lines into one injection — match
|
|
653
|
+
# the whole text AND each line so every consumed indicator clears.
|
|
654
|
+
composer.commit_queued(text)
|
|
655
|
+
text.to_s.split("\n").each { |line| composer.commit_queued(line) }
|
|
656
|
+
end
|
|
657
|
+
clear_line
|
|
658
|
+
first, rest = elide_shown_reports(text.to_s).split("\n", 2)
|
|
659
|
+
$stdout.puts @pastel.dim("↳ received while working: #{first}")
|
|
660
|
+
commit_markdown_block(rest) if rest && !rest.strip.empty?
|
|
661
|
+
$stdout.flush
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Drops the Result body from a completion notice whose report the
|
|
665
|
+
# lifecycle block ALREADY rendered in full (#subagent_lifecycle), so the
|
|
666
|
+
# user doesn't read the same report twice — once at completion and again
|
|
667
|
+
# when the queued notice is injected next turn. DISPLAY-ONLY: the
|
|
668
|
+
# model-facing injected text is untouched. Anchored to the notice shape
|
|
669
|
+
# TaskTool#completion_notice emits; an unmatched notice renders whole
|
|
670
|
+
# (duplicated beats lost). Each id is consumed on first elision.
|
|
671
|
+
def elide_shown_reports(text)
|
|
672
|
+
ids = @reported_subagent_ids
|
|
673
|
+
return text if ids.nil? || ids.empty?
|
|
674
|
+
|
|
675
|
+
ids.dup.each do |id|
|
|
676
|
+
quoted = Regexp.escape(id)
|
|
677
|
+
pattern = Regexp.new(
|
|
678
|
+
"^(\\[background-task\\] Task #{quoted} \\([^)]*\\) completed\\.)\n" \
|
|
679
|
+
"Result:\n.*?\n\\(full result via task_result\\(\"#{quoted}\"\\)\\)",
|
|
680
|
+
Regexp::MULTILINE
|
|
681
|
+
)
|
|
682
|
+
replaced = text.sub(pattern) do
|
|
683
|
+
"#{::Regexp.last_match(1)} (report shown above — full result via task_result(\"#{id}\"))"
|
|
684
|
+
end
|
|
685
|
+
next if replaced == text
|
|
686
|
+
|
|
687
|
+
text = replaced
|
|
688
|
+
ids.delete(id)
|
|
689
|
+
end
|
|
690
|
+
text
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Bounded memory of lifecycle-rendered report ids (see #elide_shown_reports).
|
|
694
|
+
def remember_reported_subagent(id)
|
|
695
|
+
return unless id
|
|
696
|
+
|
|
697
|
+
@reported_subagent_ids ||= []
|
|
698
|
+
@reported_subagent_ids << id.to_s
|
|
699
|
+
@reported_subagent_ids.shift while @reported_subagent_ids.size > 32
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Markdown rendering: assistant output rendered as readable text with
|
|
703
|
+
# modest indentation, no box.
|
|
704
|
+
def assistant_text(text)
|
|
705
|
+
return if text.nil? || text.to_s.empty?
|
|
706
|
+
|
|
707
|
+
# A progress indicator must be REPLACED by its result, never left as
|
|
708
|
+
# residue above the answer (#86). On the non-streaming path nothing
|
|
709
|
+
# else clears the transient "thinking…" line before the committed
|
|
710
|
+
# answer, so collapse any buffered reasoning + clear the animation first.
|
|
711
|
+
collapse_reasoning
|
|
712
|
+
answer_gap
|
|
713
|
+
commit_markdown_block(text)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Exactly ONE blank line before the answer payload (P3) — skipped when
|
|
717
|
+
# the previous committed block already left a gap open. No trailing
|
|
718
|
+
# blank: the turn footer attaches directly under the answer. Shared by
|
|
719
|
+
# the non-streamed (#assistant_text) and streamed (#stream) paths so
|
|
720
|
+
# both turns read identically.
|
|
721
|
+
def answer_gap
|
|
722
|
+
$stdout.puts unless @last_block == :gap
|
|
723
|
+
@last_block = :answer
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# The left margin every committed markdown line is printed behind. The
|
|
727
|
+
# live tail (#show_live_tail) reuses it so the raw in-flight lines sit in
|
|
728
|
+
# the SAME column as the rendered block they become — a flush-left tail
|
|
729
|
+
# under indented committed output read as a jarring seam.
|
|
730
|
+
MD_MARGIN = " "
|
|
731
|
+
|
|
732
|
+
# Renders a markdown string to committed, styled lines above the composer
|
|
733
|
+
# (each line as `$stdout.puts "#{MD_MARGIN}#{line}"`). Shared by
|
|
734
|
+
# #assistant_text and the per-block streaming path so both apply the
|
|
735
|
+
# identical rendering.
|
|
736
|
+
def commit_markdown_block(text)
|
|
737
|
+
return if text.nil? || text.to_s.empty?
|
|
738
|
+
|
|
739
|
+
render_markdown_block(text).each { |line| $stdout.puts "#{MD_MARGIN}#{line}" }
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# A markdown string -> Array<String> of ANSI-styled lines (no indent).
|
|
743
|
+
# Tables are fit to the terminal width minus the 2-space indent that
|
|
744
|
+
# #commit_markdown_block adds, so wide tables wrap instead of overflowing.
|
|
745
|
+
def render_markdown_block(text)
|
|
746
|
+
MarkdownRenderer.new(width: markdown_width).render(text).map do |line_tokens|
|
|
747
|
+
line_tokens.map do |token, style|
|
|
748
|
+
style.nil? ? token : apply_style(token, style)
|
|
749
|
+
end.join
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Smallest usable markdown/table budget. Below this a streamed table's
|
|
754
|
+
# columns collapse to ~1 char each (#95), so we floor here rather than at 1.
|
|
755
|
+
MIN_MARKDOWN_WIDTH = 40
|
|
756
|
+
|
|
757
|
+
# How many trailing lines of the in-flight block stay visible live (#127).
|
|
758
|
+
LIVE_TAIL_ROWS = 3
|
|
759
|
+
|
|
760
|
+
# A spawn handle: the verbose model-facing acknowledgement the task tool
|
|
761
|
+
# returns for a BACKGROUND child. The model needs the whole instruction;
|
|
762
|
+
# the human only needs "it started".
|
|
763
|
+
SPAWN_HANDLE_RE = /\AStarted background subagent '([^']+)' as task (\S+?)\.(?:\s|\z)/
|
|
764
|
+
|
|
765
|
+
# Column budget for markdown rendering: terminal width minus the MD_MARGIN
|
|
766
|
+
# indent applied to every committed line. Headless-safe (falls back to 80).
|
|
767
|
+
#
|
|
768
|
+
# `winsize` can under-report during the bottom-composer raw-mode TUI while a
|
|
769
|
+
# table is still streaming, returning a tiny/zero column count (#95). Treat
|
|
770
|
+
# any non-positive width as "unknown" and fall back to 80, and never let the
|
|
771
|
+
# budget drop below MIN_MARKDOWN_WIDTH, so columns stay readable mid-stream.
|
|
772
|
+
def markdown_width
|
|
773
|
+
cols = begin
|
|
774
|
+
IO.console&.winsize&.last
|
|
775
|
+
rescue StandardError
|
|
776
|
+
nil
|
|
777
|
+
end
|
|
778
|
+
cols = 80 unless cols&.positive?
|
|
779
|
+
[cols - MD_MARGIN.length, MIN_MARKDOWN_WIDTH].max
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# --- Streaming (unchanged except visual, now uses assistant_text) ---
|
|
783
|
+
|
|
784
|
+
def stream(chunk)
|
|
785
|
+
type = chunk[:type] || :content
|
|
786
|
+
text = chunk[:text].to_s
|
|
787
|
+
return if text.empty?
|
|
788
|
+
|
|
789
|
+
@turn_tok_chars += text.length if @turn_active
|
|
790
|
+
|
|
791
|
+
# Reasoning deltas are NEVER raw-printed (that dumped unstyled reasoning
|
|
792
|
+
# indistinguishable from the answer). Buffer them so the collapse cue /
|
|
793
|
+
# full aside / ctrl-o reveal can render them in house style instead. The
|
|
794
|
+
# status row keeps animating (label "thinking") while reasoning
|
|
795
|
+
# accumulates — and RESUMES if a tool/content block hid it (P4).
|
|
796
|
+
if type == :thinking
|
|
797
|
+
@reasoning_buffer << text
|
|
798
|
+
@thinking_started_at ||= monotonic_now
|
|
799
|
+
if @turn_active && thinking_painter
|
|
800
|
+
@thinking_indicator = true
|
|
801
|
+
status_ensure("thinking", phase: :thinking)
|
|
802
|
+
end
|
|
803
|
+
return
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# First answer token: collapse any buffered reasoning into scrollback
|
|
807
|
+
# (cue or aside per mode) before the answer streams below it. The
|
|
808
|
+
# status row hides while answer text streams — the live tail owns the
|
|
809
|
+
# transient row until the block ends.
|
|
810
|
+
collapse_reasoning if @thinking_indicator || !@reasoning_buffer.empty?
|
|
811
|
+
clear_thinking_indicator
|
|
812
|
+
|
|
813
|
+
if type != @stream_type
|
|
814
|
+
stream_end if @stream_type
|
|
815
|
+
@stream_type = type
|
|
816
|
+
# The streamed answer gets the SAME single committed gap the
|
|
817
|
+
# non-streamed path gets (P3) — once, when the content stream opens.
|
|
818
|
+
answer_gap if type == :content
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Signal the bottom composer that ANSWER content is now actively
|
|
822
|
+
# streaming so it defers a mid-stream Ctrl+O reveal (D1) instead of
|
|
823
|
+
# bisecting the answer. Thinking deltas never reach here (they return
|
|
824
|
+
# early above), so the thinking phase stays "not streaming" and its
|
|
825
|
+
# commits still land cleanly above.
|
|
826
|
+
mark_content_streaming(true)
|
|
827
|
+
stream_content(text)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def stream_end
|
|
831
|
+
clear_thinking_indicator
|
|
832
|
+
if @stream_type == :content && @stream_md
|
|
833
|
+
flush_content_stream
|
|
834
|
+
elsif @stream_type
|
|
835
|
+
$stdout.puts
|
|
836
|
+
end
|
|
837
|
+
@stream_md = nil
|
|
838
|
+
@stream_type = nil
|
|
839
|
+
# The answer block is finished: tell the composer to flush any reveal
|
|
840
|
+
# that was deferred during the stream so the `┊` aside renders cleanly
|
|
841
|
+
# AFTER the answer (D1).
|
|
842
|
+
mark_content_streaming(false)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Block boundary on the STREAMING path, driven by the adapter's
|
|
846
|
+
# after_message callback (one assistant message == one content block; on
|
|
847
|
+
# a multi-step tool turn several blocks stream within one model call).
|
|
848
|
+
# Commits the in-flight block's tail and clears @stream_type so the
|
|
849
|
+
# status row can resume between blocks (the P4 inter-tool gap) and a
|
|
850
|
+
# later #thinking_started isn't gated out by a stale open stream.
|
|
851
|
+
# Idempotent: a no-op when no stream is open (non-streaming path, or the
|
|
852
|
+
# boundary for a block that carried no content).
|
|
853
|
+
def stream_block_end(_message_id = nil)
|
|
854
|
+
return unless @stream_type
|
|
855
|
+
|
|
856
|
+
stream_end
|
|
857
|
+
return unless @turn_active && thinking_painter
|
|
858
|
+
|
|
859
|
+
@thinking_indicator = true
|
|
860
|
+
status_ensure("thinking", phase: :thinking)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Repaint cadence for the status-row animation (seconds).
|
|
864
|
+
STATUS_TICK = 0.1
|
|
865
|
+
# "Ruby facet" skin: a red ◆ sweeping back and forth on a 5-cell dim ┄
|
|
866
|
+
# track (the house separator glyph). 12-frame loop @100ms — the facet
|
|
867
|
+
# dwells one extra beat at each end of the sweep.
|
|
868
|
+
FACET_TRACK_CELLS = 5
|
|
869
|
+
FACET_FRAMES = [0, 0, 0, 1, 2, 3, 4, 4, 4, 3, 2, 1].freeze
|
|
870
|
+
# Don't nag fast turns: the "enter to interrupt" hint appears only after
|
|
871
|
+
# the wait has visibly dragged.
|
|
872
|
+
INTERRUPT_HINT_AFTER = 1.5
|
|
873
|
+
|
|
874
|
+
# Marks the start of a TURN: resets the per-turn stats and starts the
|
|
875
|
+
# status-row engine in its initial "thinking" phase (the P1 wait). Called
|
|
876
|
+
# by the chat loop right before the runner takes over; guarded with
|
|
877
|
+
# respond_to? at the call site so other UI adapters are unaffected.
|
|
878
|
+
def turn_started
|
|
879
|
+
@turn_active = true
|
|
880
|
+
@turn_started_at = monotonic_now
|
|
881
|
+
@turn_tool_count = 0
|
|
882
|
+
@turn_tok_chars = 0
|
|
883
|
+
@thinking_indicator = true if thinking_painter
|
|
884
|
+
status_show("thinking", phase: :thinking)
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Marks the end of a TURN (normal completion, error, or interrupt): the
|
|
888
|
+
# one place the turn-scoped ticker thread is allowed to die.
|
|
889
|
+
def turn_finished
|
|
890
|
+
elapsed = @turn_active && @turn_started_at ? monotonic_now - @turn_started_at : nil
|
|
891
|
+
@turn_active = false
|
|
892
|
+
@thinking_indicator = false
|
|
893
|
+
status_stop
|
|
894
|
+
# A completion stashed after the footer printed (or on an interrupted
|
|
895
|
+
# turn that never got one) must not vanish — flush the full block.
|
|
896
|
+
pending = Array(@pending_subagent_footers)
|
|
897
|
+
@pending_subagent_footers = nil
|
|
898
|
+
pending.each do |p|
|
|
899
|
+
subagent_lifecycle(p[:line], status: p[:status] || "done", report: p[:report], id: p[:id])
|
|
900
|
+
end
|
|
901
|
+
# Attention signal LAST, with the footer already committed: a LONG
|
|
902
|
+
# turn rings the bell/hook so a human who looked away comes back;
|
|
903
|
+
# quick turns stay silent (the notifier's min_turn_seconds gate).
|
|
904
|
+
notifier.turn_finished(elapsed) if elapsed
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# Shows the status row during the model wait. Mid-turn this only swaps
|
|
908
|
+
# the label back to "thinking" (the engine thread is already running);
|
|
909
|
+
# for a stand-alone wait with no turn bracket — the /probe side-inference
|
|
910
|
+
# (#58) — it starts the engine fresh. Frames go through #paint_live, so
|
|
911
|
+
# mid-turn they pass the composer's render mutex; on a BARE TTY with no
|
|
912
|
+
# #live seam the row repaints in place via CR + clear-line. Into a pipe
|
|
913
|
+
# it stays a single static dim print — never animate into a non-terminal.
|
|
914
|
+
def thinking_started
|
|
915
|
+
return if @stream_type
|
|
916
|
+
|
|
917
|
+
@thinking_started_at ||= monotonic_now
|
|
918
|
+
unless thinking_painter
|
|
919
|
+
return if @thinking_indicator
|
|
920
|
+
|
|
921
|
+
@thinking_indicator = true
|
|
922
|
+
$stdout.print @pastel.dim("thinking…")
|
|
923
|
+
$stdout.flush
|
|
924
|
+
return
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
@thinking_indicator = true
|
|
928
|
+
status_ensure("thinking", phase: :thinking)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# Clears the status row for callers that bracket a synchronous wait with
|
|
932
|
+
# no stream lifecycle of their own — the /probe side-inference (#58).
|
|
933
|
+
# Public counterpart to #thinking_started; a no-op when nothing is
|
|
934
|
+
# showing. Outside a turn this also stops the engine thread.
|
|
935
|
+
def thinking_finished
|
|
936
|
+
clear_thinking_indicator
|
|
937
|
+
status_stop unless @turn_active
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# Holds text the user typed during a synchronous /probe wait (#221), so the
|
|
941
|
+
# next idle prompt seeds it back into `❯` — the wait owns a transient
|
|
942
|
+
# composer to echo input, but it's torn down before the REPL reopens its
|
|
943
|
+
# idle composer, so the buffer is parked here in between.
|
|
944
|
+
def stash_probe_draft(text)
|
|
945
|
+
@probe_draft = text
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Consumes the parked /probe draft (see #stash_probe_draft), or nil.
|
|
949
|
+
def take_probe_draft
|
|
950
|
+
draft = @probe_draft
|
|
951
|
+
@probe_draft = nil
|
|
952
|
+
draft
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def monotonic_now
|
|
956
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# The per-frame paint strategy for the thinking animation, or nil when
|
|
960
|
+
# the output can't host one (a pipe with no composer). Frames go through
|
|
961
|
+
# #paint_live, which re-resolves the right seam on EVERY frame — so a
|
|
962
|
+
# ticker that outlives a composer/proxy swap can never paint through a
|
|
963
|
+
# stale handle (#169).
|
|
964
|
+
def thinking_painter
|
|
965
|
+
return unless $stdout.respond_to?(:live) || BottomComposer.current || tty_stdout?
|
|
966
|
+
|
|
967
|
+
method(:paint_live)
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
# Paints (or, with an empty +frame+, clears) the ONE transient live row
|
|
971
|
+
# through whichever seam owns the bottom of the screen, resolved per call:
|
|
972
|
+
# * during a turn $stdout is the StdoutProxy — #live replaces the
|
|
973
|
+
# composer's transient row under its render mutex;
|
|
974
|
+
# * an ACTIVE composer without the proxy is painted via
|
|
975
|
+
# BottomComposer#set_partial — same row, same mutex — NEVER with a raw
|
|
976
|
+
# CR repaint that would clobber the pinned prompt line (#169);
|
|
977
|
+
# * a bare TTY with no composer (the cooked /probe wait, #58; one-shot)
|
|
978
|
+
# repaints in place via CR + clear-line;
|
|
979
|
+
# * a pipe hosts nothing — raw escapes must not leak into the cooked
|
|
980
|
+
# output (#56).
|
|
981
|
+
def paint_live(frame)
|
|
982
|
+
if $stdout.respond_to?(:live)
|
|
983
|
+
$stdout.live(frame)
|
|
984
|
+
elsif (composer = BottomComposer.current)
|
|
985
|
+
composer.set_partial(frame)
|
|
986
|
+
elsif tty_stdout?
|
|
987
|
+
# The bare-TTY repaint owns ONE row (CR + clear-line): show only the
|
|
988
|
+
# last line of a multi-line frame so the in-place repaint can't wrap
|
|
989
|
+
# and leave residue it can never erase.
|
|
990
|
+
$stdout.print("\r\e[2K#{frame.to_s.split("\n").last}")
|
|
991
|
+
$stdout.flush
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
# True when $stdout is a real terminal (guarded for IO doubles).
|
|
996
|
+
def tty_stdout?
|
|
997
|
+
$stdout.respond_to?(:tty?) && $stdout.tty?
|
|
998
|
+
rescue StandardError
|
|
999
|
+
false
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
# In-place clear of the current row (CR + erase-line) before a committed
|
|
1003
|
+
# line lands. Purely a cursor-positioning nicety, so it is gated on a real
|
|
1004
|
+
# TTY: into a pipe there is no cursor and the raw `\e[2K` would leak as
|
|
1005
|
+
# literal bytes into the cooked output (#56).
|
|
1006
|
+
def clear_line
|
|
1007
|
+
return unless tty_stdout?
|
|
1008
|
+
|
|
1009
|
+
$stdout.print("\r\e[2K")
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# The active reasoning render mode (:hidden | :collapsed | :full), resolved
|
|
1013
|
+
# from config (which /reasoning writes to, so the adapter gate and this
|
|
1014
|
+
# render path share one source of truth). Handles the legacy show_reasoning
|
|
1015
|
+
# back-compat mapping.
|
|
1016
|
+
def reasoning_mode
|
|
1017
|
+
Config::ReasoningPrefs.mode(Rubino.configuration)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# Whole seconds the current/last thinking phase ran, for the collapse cue.
|
|
1021
|
+
def thinking_elapsed_seconds
|
|
1022
|
+
return 0 unless @thinking_started_at
|
|
1023
|
+
|
|
1024
|
+
(monotonic_now - @thinking_started_at).to_i
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Replay user input in compact form
|
|
1028
|
+
def replay_user_input(text, at: nil)
|
|
1029
|
+
$stdout.puts
|
|
1030
|
+
$stdout.puts @pastel.green("#{text}")
|
|
1031
|
+
$stdout.puts
|
|
1032
|
+
@last_block = :gap
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# Tool started renders as the quiet `● name hint` open row (P1).
|
|
1036
|
+
# The `task` (delegation) tool gets a dedicated row so the timeline reads
|
|
1037
|
+
# as a hand-off, not a generic tool call: `● delegated → <subagent> <prompt>`.
|
|
1038
|
+
#
|
|
1039
|
+
# Finalize any OPEN content stream first (#136): on the streaming path the
|
|
1040
|
+
# model can emit answer text right up to the tool call (ruby_llm runs the
|
|
1041
|
+
# tool mid-stream, so no stream_end intervenes). Without this the pre-tool
|
|
1042
|
+
# text stayed buffered in the stream splitter, committed only AFTER the
|
|
1043
|
+
# tool card, glued straight onto the post-tool continuation
|
|
1044
|
+
# ("…number.Confirmed — …"). Committing it here preserves stream order
|
|
1045
|
+
# (text → tool card → text) and the block boundary between the segments.
|
|
1046
|
+
# Idempotent: the non-streaming path already closed the stream
|
|
1047
|
+
# (Loop#close_intermediate_stream), so this is a no-op there — the same
|
|
1048
|
+
# contract #confirm uses before the approval card.
|
|
1049
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
1050
|
+
finalize_stream
|
|
1051
|
+
return delegation_started(arguments) if name == "task"
|
|
1052
|
+
|
|
1053
|
+
hint = args_hint(arguments)
|
|
1054
|
+
activity_started(name, hint: hint)
|
|
1055
|
+
# The committed `● name` open row is in scrollback; SWITCH the status-row
|
|
1056
|
+
# label to the tool (P3) instead of leaving the live region dead while
|
|
1057
|
+
# the tool runs. The engine thread stays the same — label swap only.
|
|
1058
|
+
status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
# DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of
|
|
1062
|
+
# a tool's output plus a `… +N lines (full output → context)` marker —
|
|
1063
|
+
# the FULL output still goes to the model/context unchanged. Governed by
|
|
1064
|
+
# display.tool_output_preview_lines (0 = old full dump).
|
|
1065
|
+
def tool_body(text, kind: :plain)
|
|
1066
|
+
return if text.nil? || text.to_s.empty?
|
|
1067
|
+
|
|
1068
|
+
limit = tool_preview_limit
|
|
1069
|
+
lines = text.to_s.lines
|
|
1070
|
+
shown = limit.positive? ? lines.first(limit) : lines
|
|
1071
|
+
hidden = lines.size - shown.size
|
|
1072
|
+
write_body_lines(shown.join) do |chomped|
|
|
1073
|
+
if kind == :diff
|
|
1074
|
+
case chomped[0]
|
|
1075
|
+
when "+" then @pastel.green(chomped)
|
|
1076
|
+
when "-" then @pastel.red(chomped)
|
|
1077
|
+
else @pastel.dim(chomped)
|
|
1078
|
+
end
|
|
1079
|
+
else
|
|
1080
|
+
@pastel.dim(chomped)
|
|
1081
|
+
end
|
|
1082
|
+
end
|
|
1083
|
+
$stdout.puts @pastel.dim(" #{hidden_lines_marker(hidden)}") if hidden.positive?
|
|
1084
|
+
@last_block = :tool
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
# Streamed tool output (shell): same display-only collapse as #tool_body,
|
|
1088
|
+
# accumulated across chunks. Lines past the preview budget are counted
|
|
1089
|
+
# silently; #activity_finished flushes the `… +N lines` marker right
|
|
1090
|
+
# before the close row.
|
|
1091
|
+
def tool_chunk(_name, chunk)
|
|
1092
|
+
return if chunk.nil? || chunk.to_s.empty?
|
|
1093
|
+
|
|
1094
|
+
limit = tool_preview_limit
|
|
1095
|
+
unless limit.positive?
|
|
1096
|
+
write_body_lines(chunk.to_s) { |chomped| @pastel.dim(chomped) }
|
|
1097
|
+
return
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
chunk.to_s.each_line do |line|
|
|
1101
|
+
if @tool_preview_shown.to_i < limit
|
|
1102
|
+
@tool_preview_shown = @tool_preview_shown.to_i + 1
|
|
1103
|
+
write_body_lines(line) { |chomped| @pastel.dim(chomped) }
|
|
1104
|
+
else
|
|
1105
|
+
@tool_preview_hidden = @tool_preview_hidden.to_i + 1
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
@last_block = :tool
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Tool finished renders as the compact `└ ✓ metric` close row, or
|
|
1112
|
+
# `└ ✗ failed · name · error` in red (P10).
|
|
1113
|
+
# The `task` tool closes the delegation row: `✓ <subagent>: <summary>`.
|
|
1114
|
+
def tool_finished(name, result: nil)
|
|
1115
|
+
return delegation_finished(result) if name == "task"
|
|
1116
|
+
|
|
1117
|
+
failed = result.respond_to?(:errorish?) ? result.errorish? : (result.respond_to?(:success?) && !result.success?)
|
|
1118
|
+
metric = if failed
|
|
1119
|
+
result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil
|
|
1120
|
+
else
|
|
1121
|
+
(result.respond_to?(:metrics) && result.metrics) ||
|
|
1122
|
+
(result&.respond_to?(:truncated_preview) ? result.truncated_preview : nil)
|
|
1123
|
+
end
|
|
1124
|
+
activity_finished(name, metric: metric, failed: failed)
|
|
1125
|
+
status_back_to_thinking
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# After a tool's `└ ✓` close row commits, swap the status row back to the
|
|
1129
|
+
# thinking phase (the P4 inter-tool gap) with the accumulated stats. The
|
|
1130
|
+
# live row count is a simple per-turn UI tally — the footer's exact
|
|
1131
|
+
# ran/denied split from the Loop stays authoritative.
|
|
1132
|
+
def status_back_to_thinking
|
|
1133
|
+
return unless @turn_active
|
|
1134
|
+
|
|
1135
|
+
@turn_tool_count += 1
|
|
1136
|
+
return unless thinking_painter
|
|
1137
|
+
|
|
1138
|
+
@thinking_indicator = true
|
|
1139
|
+
status_show("thinking", phase: :thinking)
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def compression_started(at: nil)
|
|
1143
|
+
$stdout.puts
|
|
1144
|
+
$stdout.puts @pastel.dim("┄ compacting context… ┄")
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def compression_finished(metadata, at: nil)
|
|
1148
|
+
saved = metadata[:saved_tokens] || metadata["saved_tokens"] || 0
|
|
1149
|
+
$stdout.puts @pastel.dim("┄ compacted · saved #{saved} tok ┄")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# Ctrl+O reveal: re-render the LAST retained reasoning buffer as the
|
|
1153
|
+
# full-style `┊` aside, committed into scrollback NOW (append-only — a
|
|
1154
|
+
# scrollback terminal can't un-print the committed cue, so this is a
|
|
1155
|
+
# one-way reveal of the retained buffer, not a hide-toggle). A no-op when
|
|
1156
|
+
# nothing is retained (hidden mode, or no reasoning yet this session).
|
|
1157
|
+
# Wired as the BottomComposer's on_ctrl_o callback; prints through $stdout
|
|
1158
|
+
# so it lands above the prompt under the composer's render mutex.
|
|
1159
|
+
def reveal_last_reasoning
|
|
1160
|
+
# NOTHING retained (hidden mode never buffered one, or — the common case
|
|
1161
|
+
# on providers that stream no thinking blocks at all — no reasoning ever
|
|
1162
|
+
# arrived): give the advertised key ONE dim line of feedback instead of
|
|
1163
|
+
# a forever-silent no-op that reads as a broken keybinding (#133). One
|
|
1164
|
+
# note per dry spell: further presses stay silent until reasoning is
|
|
1165
|
+
# actually retained (which resets the flag below).
|
|
1166
|
+
if @last_reasoning.nil? || @last_reasoning.strip.empty?
|
|
1167
|
+
unless @no_reasoning_note_shown
|
|
1168
|
+
@no_reasoning_note_shown = true
|
|
1169
|
+
note("no reasoning retained — this provider streamed no thinking blocks")
|
|
1170
|
+
end
|
|
1171
|
+
return
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
# IDEMPOTENT + SILENT: a scrollback aside can't be un-printed, so
|
|
1175
|
+
# revealing the SAME retained buffer twice would just stack an identical
|
|
1176
|
+
# block. Once this thought has been revealed, any further Ctrl+O is a
|
|
1177
|
+
# true silent no-op — we print NOTHING (no ack line), so a human mashing
|
|
1178
|
+
# Ctrl+O gets silence, not growing scrollback. #collapse_reasoning clears
|
|
1179
|
+
# the flag when a NEW thought is retained, so its first reveal works, and
|
|
1180
|
+
# a new turn resets it so its first reveal works again.
|
|
1181
|
+
return if @last_reasoning_revealed
|
|
1182
|
+
|
|
1183
|
+
commit_reasoning_aside(@last_reasoning, @last_reasoning_seconds.to_i)
|
|
1184
|
+
@last_reasoning_revealed = true
|
|
1185
|
+
# Re-emit the idle prompt so the cursor returns to a proper prompt line
|
|
1186
|
+
# instead of being stranded on a bare line below the reveal. Guarded —
|
|
1187
|
+
# degrade silently if Reline isn't the active input (e.g. in-turn).
|
|
1188
|
+
redisplay_idle_prompt
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# Ask Reline to repaint its prompt + current buffer after out-of-band
|
|
1192
|
+
# output (the Ctrl+O reveal) has scrolled below the parked idle prompt.
|
|
1193
|
+
# Uses the public Reline line-refresh seam; fully guarded so a Reline that
|
|
1194
|
+
# lacks it (or a non-Reline input path) degrades to a no-op rather than
|
|
1195
|
+
# crashing the prompt. Does NOT attempt to move the reveal above the prompt
|
|
1196
|
+
# (that's the deferred pinned-layout work) — it only restores the prompt
|
|
1197
|
+
# line so the cursor isn't left bare.
|
|
1198
|
+
def redisplay_idle_prompt
|
|
1199
|
+
return unless defined?(Reline)
|
|
1200
|
+
|
|
1201
|
+
core = Reline.respond_to?(:core) ? Reline.core : nil
|
|
1202
|
+
line_editor = core&.instance_variable_get(:@line_editor)
|
|
1203
|
+
if line_editor.respond_to?(:rerender)
|
|
1204
|
+
line_editor.rerender
|
|
1205
|
+
elsif core.respond_to?(:line_editor) && core.line_editor.respond_to?(:rerender)
|
|
1206
|
+
core.line_editor.rerender
|
|
1207
|
+
end
|
|
1208
|
+
rescue StandardError
|
|
1209
|
+
nil
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
# `/reasoning` with no arg: confirm the current render mode in house style.
|
|
1213
|
+
# ┄ reasoning: collapsed ┄
|
|
1214
|
+
def reasoning_status(mode)
|
|
1215
|
+
$stdout.puts
|
|
1216
|
+
$stdout.puts @pastel.dim("┄ reasoning: #{mode} ┄")
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# `/reasoning <mode>`: confirm the session render-mode switch. The actual
|
|
1220
|
+
# state change is written to config by the executor so the adapter gate
|
|
1221
|
+
# (which reads config) and this render path stay on one source of truth.
|
|
1222
|
+
# ┄ reasoning collapsed → full ┄
|
|
1223
|
+
# Switching to `hidden` gets an explanatory line instead of the terse arrow
|
|
1224
|
+
# — "hidden" is otherwise opaque (no cue, no aside), so we spell out what it
|
|
1225
|
+
# does and how to bring reasoning back.
|
|
1226
|
+
def reasoning_changed(mode, previous: nil)
|
|
1227
|
+
$stdout.puts
|
|
1228
|
+
if mode.to_sym == :hidden
|
|
1229
|
+
$stdout.puts @pastel.dim("┄ reasoning hidden — won't be shown (ctrl-o or /reasoning to bring it back) ┄")
|
|
1230
|
+
else
|
|
1231
|
+
arrow = previous && previous != mode ? "#{previous} → #{mode}" : mode.to_s
|
|
1232
|
+
$stdout.puts @pastel.dim("┄ reasoning #{arrow} ┄")
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# `/think` with no arg: confirm the current effort in house style.
|
|
1237
|
+
# ┄ effort: medium ┄
|
|
1238
|
+
def think_status(effort)
|
|
1239
|
+
$stdout.puts
|
|
1240
|
+
$stdout.puts @pastel.dim("┄ effort: #{effort} ┄")
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# `/think <level>`: confirm the effort switch.
|
|
1244
|
+
# ┄ effort medium → high ┄
|
|
1245
|
+
def think_changed(effort, previous: nil)
|
|
1246
|
+
arrow = previous && previous != effort ? "#{previous} → #{effort}" : effort.to_s
|
|
1247
|
+
$stdout.puts
|
|
1248
|
+
$stdout.puts @pastel.dim("┄ effort #{arrow} ┄")
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
def mode_changed(name, previous: nil)
|
|
1252
|
+
arrow = previous && previous != name ? "#{previous} → #{name}" : name.to_s
|
|
1253
|
+
text = "┄ mode #{arrow} ┄"
|
|
1254
|
+
$stdout.puts
|
|
1255
|
+
$stdout.puts(name.to_sym == :yolo ? @pastel.yellow(text) : @pastel.dim(text))
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
# Short human labels for the post-turn inline jobs the status row tracks.
|
|
1259
|
+
JOB_STATUS_LABELS = {
|
|
1260
|
+
"ExtractMemoryJob" => "memory",
|
|
1261
|
+
"DistillSkillJob" => "skills",
|
|
1262
|
+
"SummarizeSessionJob" => "summary"
|
|
1263
|
+
}.freeze
|
|
1264
|
+
|
|
1265
|
+
def job_enqueued(type)
|
|
1266
|
+
puts_colored(:dim, " ⊕ Job enqueued: #{type}") if Rubino.configuration.ui_verbose?
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
# Post-turn inline jobs (P6): the aux-LLM memory extract / skill distill
|
|
1270
|
+
# used to freeze the UI for seconds after the footer. The turn-scoped
|
|
1271
|
+
# status row is still alive here (it stops at #turn_finished, not at the
|
|
1272
|
+
# footer), so swap its label to "polishing · <job>" while each job runs.
|
|
1273
|
+
def job_started(type)
|
|
1274
|
+
puts_colored(:dim, " ▶ Job started: #{type}") if Rubino.configuration.ui_verbose?
|
|
1275
|
+
return unless @turn_active && thinking_painter
|
|
1276
|
+
|
|
1277
|
+
@thinking_indicator = true
|
|
1278
|
+
status_show("polishing", phase: :job, hint: job_status_label(type))
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
def job_finished(type)
|
|
1282
|
+
puts_colored(:dim, " ■ Job finished: #{type}") if Rubino.configuration.ui_verbose?
|
|
1283
|
+
clear_thinking_indicator if @turn_active
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
def job_status_label(type)
|
|
1287
|
+
JOB_STATUS_LABELS[type.to_s] || type.to_s
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
def with_spinner(message, &block)
|
|
1291
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
|
|
1292
|
+
spinner.auto_spin
|
|
1293
|
+
result = block.call
|
|
1294
|
+
spinner.success
|
|
1295
|
+
result
|
|
1296
|
+
rescue StandardError => e
|
|
1297
|
+
spinner.error
|
|
1298
|
+
raise e
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
# --- Legacy box methods (used by print_session_history replay) ---
|
|
1302
|
+
|
|
1303
|
+
def box_open(*pieces, at: nil, color: nil)
|
|
1304
|
+
# Compact: just print the activity name
|
|
1305
|
+
type = pieces.first.to_s
|
|
1306
|
+
activity_started(type)
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
def box_close(*_pieces, color: nil)
|
|
1310
|
+
# Compact: close the activity
|
|
1311
|
+
activity_finished(@activity_name || "done", failed: color == :red)
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
private
|
|
1315
|
+
|
|
1316
|
+
# True when a prior "always" decision covers this call — either the
|
|
1317
|
+
# exact (tool, args) scope or the tool-wide parent ("always this tool").
|
|
1318
|
+
def approval_cached?(scope)
|
|
1319
|
+
return false unless scope
|
|
1320
|
+
|
|
1321
|
+
@approval_cache.allowed?(@session_id, scope) ||
|
|
1322
|
+
@approval_cache.allowed?(@session_id, tool_scope(scope))
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# The tool-wide parent of a "<tool>:<command>" scope. "shell:ls" → "shell".
|
|
1326
|
+
# A scope without a command part is already tool-wide.
|
|
1327
|
+
def tool_scope(scope)
|
|
1328
|
+
scope.to_s.split(":", 2).first
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
def remember(scope, decision)
|
|
1332
|
+
return unless scope
|
|
1333
|
+
|
|
1334
|
+
@approval_cache.remember(@session_id, scope, decision)
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# The rule this approval would be remembered/persisted as, derived from
|
|
1338
|
+
# the command (PrefixDeriver). Nil when there is no command (tool-wide /
|
|
1339
|
+
# structured-arg tools), so no prefix is offered and "always" persists
|
|
1340
|
+
# nothing. Mirrors UI::API#derive_rule.
|
|
1341
|
+
def derive_rule(tool, command, pattern_key)
|
|
1342
|
+
return nil if command.to_s.strip.empty?
|
|
1343
|
+
|
|
1344
|
+
Security::PrefixDeriver.rule_for(tool: tool.to_s, command: command.to_s, pattern_key: pattern_key)
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
# Routes the chosen menu symbol to the matching cache/persister action,
|
|
1348
|
+
# mirroring UI::API#apply_decision so CLI and HTTP behave identically:
|
|
1349
|
+
# :once -> nothing
|
|
1350
|
+
# :deny_always -> persist a permissions:deny rule, then deny
|
|
1351
|
+
# :always_prefix -> session cache + persist the derived PREFIX rule
|
|
1352
|
+
# :always_command -> session cache + persist the NARROW rule
|
|
1353
|
+
# :always_tool -> CLI-only: remember the whole tool (in-memory only)
|
|
1354
|
+
# :no -> deny this call only (one-off, nothing remembered)
|
|
1355
|
+
# Returns the boolean approval result.
|
|
1356
|
+
def apply_choice(choice, scope:, command:, rule:)
|
|
1357
|
+
case choice
|
|
1358
|
+
when :once
|
|
1359
|
+
true
|
|
1360
|
+
when :deny_always
|
|
1361
|
+
persist_deny(scope, command, rule)
|
|
1362
|
+
false
|
|
1363
|
+
when :always_prefix
|
|
1364
|
+
remember(scope, "session")
|
|
1365
|
+
persist_rule(rule)
|
|
1366
|
+
true
|
|
1367
|
+
when :always_command
|
|
1368
|
+
remember(scope, "session")
|
|
1369
|
+
persist_rule(narrow_rule(command))
|
|
1370
|
+
true
|
|
1371
|
+
when :always_tool
|
|
1372
|
+
remember(tool_scope(scope), "always")
|
|
1373
|
+
true
|
|
1374
|
+
else
|
|
1375
|
+
false
|
|
1376
|
+
end
|
|
1377
|
+
end
|
|
1378
|
+
|
|
1379
|
+
# Persists a derived rule value to security.command_allowlist (append-
|
|
1380
|
+
# unique) so it pre-approves siblings across restarts. Skips when there is
|
|
1381
|
+
# no value to persist. Same path UI::API uses.
|
|
1382
|
+
def persist_rule(rule)
|
|
1383
|
+
Security::AllowlistPersister.persist(rule.value) if rule
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
# Persists a permissions:deny rule for the "deny always" choice, scoped the
|
|
1387
|
+
# SAME way the allow side scopes (prefix when derivable, else exact command).
|
|
1388
|
+
# ApprovalPolicy#decide checks permissions:deny first, so this auto-denies
|
|
1389
|
+
# the pattern across restarts. The tool name comes from the scope key
|
|
1390
|
+
# ("<tool>:<command>"). No-op when there is no pattern to key on.
|
|
1391
|
+
def persist_deny(scope, command, rule)
|
|
1392
|
+
pattern = Security::DenyPersister.pattern_for(
|
|
1393
|
+
tool: tool_scope(scope), rule: rule, command: command
|
|
1394
|
+
)
|
|
1395
|
+
Security::DenyPersister.persist(pattern) if pattern
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
# The narrow rule for :always_command — exact command, or the dangerous
|
|
1399
|
+
# pattern key when the command is dangerous (S3/S5 semantics).
|
|
1400
|
+
def narrow_rule(command)
|
|
1401
|
+
return nil if command.to_s.strip.empty?
|
|
1402
|
+
|
|
1403
|
+
Security::PrefixDeriver.narrow_rule_for(tool: "shell", command: command.to_s)
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# A DEDICATED TTY::Prompt for the approval menu whose output is wrapped
|
|
1407
|
+
# in IndentedIO, so the question + menu render in the SAME column as the
|
|
1408
|
+
# card's body (P7) instead of flush-left under a split card. Separate
|
|
1409
|
+
# from @prompt so #ask and other prompts keep their flush layout.
|
|
1410
|
+
def approval_prompt
|
|
1411
|
+
@approval_prompt ||= TTY::Prompt.new(output: IndentedIO.new)
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
# Prompts for the approval choice. The menu is built from the derived
|
|
1415
|
+
# rule: an "always — allow `<prefix>` commands" item is offered only when
|
|
1416
|
+
# a :prefix rule is derivable (non-dangerous command). For a dangerous
|
|
1417
|
+
# command no prefix is offered (the pattern description is already shown);
|
|
1418
|
+
# only the narrow "always, this command" persists. Returns one of
|
|
1419
|
+
# :once, :always_prefix, :always_command, :always_tool, :no (deny this
|
|
1420
|
+
# call only), :deny_always (persist a permissions:deny rule).
|
|
1421
|
+
def approval_choice(rule = nil, tool: nil)
|
|
1422
|
+
prefix = rule&.kind == :prefix ? rule.value : nil
|
|
1423
|
+
# The narrow "always" scope reads in the TOOL's own terms: "this command"
|
|
1424
|
+
# is shell-flavored and is confusing on an `edit`/`write` card (which
|
|
1425
|
+
# shows file_path/old_string, not a command), so non-shell tools get
|
|
1426
|
+
# "this exact call" instead (#222). Shell keeps "command".
|
|
1427
|
+
narrow = scope_noun(tool)
|
|
1428
|
+
# Pause the bottom composer for the duration of the select so the menu
|
|
1429
|
+
# reads the real $stdin (no reader-thread race) and tty-screen sizes the
|
|
1430
|
+
# real $stdout (no NoMethodError on the StdoutProxy). No-op off-turn.
|
|
1431
|
+
BottomComposer.run_in_terminal do
|
|
1432
|
+
# Labels are grammatically parallel (#87): every line is an
|
|
1433
|
+
# "<Approve|Deny> — <scope>" verb phrase, so the affirmatives and
|
|
1434
|
+
# denies read symmetrically instead of mixing "yes, once" with
|
|
1435
|
+
# "no — deny this once".
|
|
1436
|
+
approval_prompt.select("approve?", cycle: false) do |menu|
|
|
1437
|
+
menu.choice "Approve once", :once
|
|
1438
|
+
menu.choice "Approve — `#{prefix}` commands (always)", :always_prefix if prefix
|
|
1439
|
+
menu.choice "Approve — #{narrow} (always)", :always_command
|
|
1440
|
+
menu.choice "Approve — this tool (this session)", :always_tool
|
|
1441
|
+
menu.choice "Deny once", :no
|
|
1442
|
+
menu.choice "Deny — #{narrow} (always)", :deny_always
|
|
1443
|
+
end
|
|
1444
|
+
end
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
# The narrow-scope noun for the "always" approval rows, by tool kind: a
|
|
1448
|
+
# shell command is literally a "command"; every other tool (edit, write, …)
|
|
1449
|
+
# has no command, so the call itself is the scope (#222).
|
|
1450
|
+
def scope_noun(tool)
|
|
1451
|
+
tool.to_s == "shell" ? "this command" : "this exact call"
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
# Head lines of tool output the transcript shows (P2). Resolved from
|
|
1455
|
+
# config on every call so /config changes apply mid-session.
|
|
1456
|
+
def tool_preview_limit
|
|
1457
|
+
Rubino.configuration.display_tool_output_preview_lines
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
def reset_tool_preview
|
|
1461
|
+
@tool_preview_shown = 0
|
|
1462
|
+
@tool_preview_hidden = 0
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
# The dim collapse marker: `… +N lines (full output → context)`.
|
|
1466
|
+
def hidden_lines_marker(hidden)
|
|
1467
|
+
"… +#{hidden} line#{"s" if hidden != 1} (full output → context)"
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
# Commits the marker for streamed lines the preview budget swallowed
|
|
1471
|
+
# (#tool_chunk), right before the close row. Idempotent per tool run.
|
|
1472
|
+
def flush_tool_preview_overflow
|
|
1473
|
+
hidden = @tool_preview_hidden.to_i
|
|
1474
|
+
reset_tool_preview
|
|
1475
|
+
return unless hidden.positive?
|
|
1476
|
+
|
|
1477
|
+
$stdout.puts @pastel.dim(" #{hidden_lines_marker(hidden)}")
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
# Renders body text with the current activity open.
|
|
1481
|
+
def write_body_lines(text, &style)
|
|
1482
|
+
text.each_line do |line|
|
|
1483
|
+
chomped = line.chomp
|
|
1484
|
+
rendered = style ? style.call(chomped) : chomped
|
|
1485
|
+
$stdout.puts " #{rendered}"
|
|
1486
|
+
end
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
# Applies a style hash to a token string.
|
|
1490
|
+
def apply_style(text, style)
|
|
1491
|
+
return text if style.nil? || style.empty?
|
|
1492
|
+
|
|
1493
|
+
decorators = []
|
|
1494
|
+
modifiers = style[:modifiers] || []
|
|
1495
|
+
decorators << :bold if modifiers.include?(:bold)
|
|
1496
|
+
decorators << :italic if modifiers.include?(:italic)
|
|
1497
|
+
decorators << :underline if modifiers.include?(:underline)
|
|
1498
|
+
|
|
1499
|
+
fg = style[:fg]
|
|
1500
|
+
result = text
|
|
1501
|
+
decorators.each do |dec|
|
|
1502
|
+
result = @pastel.send(dec, result) if @pastel.valid?(dec)
|
|
1503
|
+
end
|
|
1504
|
+
# The MarkdownRenderer emits a few color names Pastel doesn't define
|
|
1505
|
+
# (e.g. :gray). Skip an unknown fg rather than raise — degrade to no
|
|
1506
|
+
# color so streamed markdown never crashes the turn.
|
|
1507
|
+
result = @pastel.send(fg, result) if fg && @pastel.valid?(fg)
|
|
1508
|
+
result
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
# --- Streaming markdown (per-block render + commit) ---
|
|
1512
|
+
|
|
1513
|
+
# Streams one content chunk: feed the block buffer, render+commit every
|
|
1514
|
+
# block that just completed (markdown), and show the still-incomplete tail
|
|
1515
|
+
# RAW in the live region. The tail is shown raw on purpose — it gets
|
|
1516
|
+
# re-rendered + committed the moment its block closes (so a `**bold**` token
|
|
1517
|
+
# mid-stream shows raw for a beat, then snaps to styled once the block ends).
|
|
1518
|
+
def stream_content(text)
|
|
1519
|
+
@stream_md ||= StreamingMarkdown.new
|
|
1520
|
+
completed = @stream_md.feed(text)
|
|
1521
|
+
# On the plain path the previous raw tail sits on the current line with no
|
|
1522
|
+
# newline; clear it before committing finished blocks so a committed line
|
|
1523
|
+
# doesn't glue onto the leftover tail. (The #live seam replaces its own
|
|
1524
|
+
# transient row, so this is a no-op there.)
|
|
1525
|
+
clear_plain_tail if completed.any?
|
|
1526
|
+
completed.each { |block| commit_markdown_block(block) }
|
|
1527
|
+
# Live region: a small ROLLING window over the in-flight block — its last
|
|
1528
|
+
# few raw lines, so a long list/table block keeps its recent context
|
|
1529
|
+
# visible while it streams instead of vanishing to a single flickering
|
|
1530
|
+
# line until the whole block commits (#127). Bounded, so a long open
|
|
1531
|
+
# fence can never push the prompt off-screen; the block still snaps to
|
|
1532
|
+
# rendered markdown the moment it completes.
|
|
1533
|
+
show_live_tail(@stream_md.live_tail(LIVE_TAIL_ROWS))
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
# Erases an in-place raw tail on the plain (no-#live) path before a commit.
|
|
1537
|
+
def clear_plain_tail
|
|
1538
|
+
return if $stdout.respond_to?(:live)
|
|
1539
|
+
|
|
1540
|
+
clear_line
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
# Flush on stream end: render+commit the final block. If a fence is still
|
|
1544
|
+
# open (the model never sent the closing ```), the buffered text is emitted
|
|
1545
|
+
# as PLAIN lines so nothing is lost (markdown of a half-open fence would be
|
|
1546
|
+
# garbage). Always clears the live region.
|
|
1547
|
+
def flush_content_stream
|
|
1548
|
+
remaining = @stream_md.flush
|
|
1549
|
+
clear_plain_tail if remaining
|
|
1550
|
+
if remaining
|
|
1551
|
+
if open_fence?(remaining)
|
|
1552
|
+
remaining.split("\n", -1).each { |line| $stdout.puts "#{MD_MARGIN}#{line}" }
|
|
1553
|
+
else
|
|
1554
|
+
commit_markdown_block(remaining)
|
|
1555
|
+
end
|
|
1556
|
+
end
|
|
1557
|
+
show_live_tail("")
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
# An odd number of fence lines means a ``` was opened but never closed.
|
|
1561
|
+
def open_fence?(text)
|
|
1562
|
+
text.to_s.lines.count { |l| l.match?(StreamingMarkdown::FENCE_RE) }.odd?
|
|
1563
|
+
end
|
|
1564
|
+
|
|
1565
|
+
# Shows the raw in-progress tail in the live region — #paint_live resolves
|
|
1566
|
+
# the seam (proxy #live / active composer row / CR repaint on a bare TTY /
|
|
1567
|
+
# skipped into a pipe). A blank tail just clears the transient row.
|
|
1568
|
+
# Nothing is lost on the skipped path — every block is still rendered +
|
|
1569
|
+
# committed in full when it completes.
|
|
1570
|
+
#
|
|
1571
|
+
# Each tail row carries the SAME MD_MARGIN the committed lines above it
|
|
1572
|
+
# get (#commit_markdown_block), so the raw in-flight lines sit in the
|
|
1573
|
+
# same column as the rendered block they snap into — a flush-left tail
|
|
1574
|
+
# under indented output read as a jarring seam. Off-TTY this is moot:
|
|
1575
|
+
# #paint_live skips pipes entirely (#56).
|
|
1576
|
+
def show_live_tail(tail)
|
|
1577
|
+
paint_live(margined_tail(tail))
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
# WRAPS the in-flight tail to the terminal width and keeps the last
|
|
1581
|
+
# LIVE_TAIL_ROWS wrapped rows (P12): a long streamed paragraph used to
|
|
1582
|
+
# collapse into ONE head-truncated row ("…the very end of it") because
|
|
1583
|
+
# the raw tail was clamped per LINE, not wrapped. Each visible row
|
|
1584
|
+
# carries the SAME MD_MARGIN the committed lines above it get
|
|
1585
|
+
# (#commit_markdown_block), so the raw in-flight rows sit in the same
|
|
1586
|
+
# column as the rendered block they snap into. A blank tail passes
|
|
1587
|
+
# through untouched (it just clears the transient row).
|
|
1588
|
+
def margined_tail(tail)
|
|
1589
|
+
text = tail.to_s
|
|
1590
|
+
return text if text.empty?
|
|
1591
|
+
|
|
1592
|
+
budget = terminal_cols - MD_MARGIN.length - 1
|
|
1593
|
+
rows = text.split("\n", -1).flat_map { |line| wrap_tail_row(line, budget) }
|
|
1594
|
+
rows.last(LIVE_TAIL_ROWS).map { |row| "#{MD_MARGIN}#{row}" }.join("\n")
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# Splits one raw line into display-width-budgeted rows (wide glyphs are
|
|
1598
|
+
# never split across rows — same measurement the composer/live region
|
|
1599
|
+
# use). An empty line stays one empty row.
|
|
1600
|
+
def wrap_tail_row(line, budget)
|
|
1601
|
+
budget = 1 if budget < 1
|
|
1602
|
+
rows = [+""]
|
|
1603
|
+
width = 0
|
|
1604
|
+
line.each_char do |ch|
|
|
1605
|
+
w = LiveRegion.display_width(ch)
|
|
1606
|
+
if width + w > budget && !rows.last.empty?
|
|
1607
|
+
rows << +""
|
|
1608
|
+
width = 0
|
|
1609
|
+
end
|
|
1610
|
+
rows.last << ch
|
|
1611
|
+
width += w
|
|
1612
|
+
end
|
|
1613
|
+
rows
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# Commits any in-progress streaming so the next committed output (the
|
|
1617
|
+
# approval card, a note, etc.) starts on its own clean line. When a
|
|
1618
|
+
# content/thinking stream is open it runs the normal #stream_end (flush
|
|
1619
|
+
# the tail + clear the indicator); otherwise it just clears a lone
|
|
1620
|
+
# "thinking…" indicator. Idempotent: a no-op when nothing is live.
|
|
1621
|
+
def finalize_stream
|
|
1622
|
+
if @stream_type
|
|
1623
|
+
stream_end
|
|
1624
|
+
else
|
|
1625
|
+
clear_thinking_indicator
|
|
1626
|
+
end
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
# Toggles the bottom composer's "answer content is actively streaming"
|
|
1630
|
+
# flag (D1). The composer gates the Ctrl+O reveal on it: a reveal requested
|
|
1631
|
+
# while true is deferred and flushed by #end_content_stream when the answer
|
|
1632
|
+
# finishes, so the `┊` aside never lands between answer chunks. A no-op when
|
|
1633
|
+
# no composer owns the screen (between turns / piped input / plain mode).
|
|
1634
|
+
# No respond_to?/blanket-rescue safety net here: the composer is our own
|
|
1635
|
+
# class, so a signature drift across this seam must fail LOUDLY in the
|
|
1636
|
+
# suite instead of silently un-gating the reveal (#62). Only terminal IO
|
|
1637
|
+
# errors are swallowed — end_content_stream can flush a deferred reveal
|
|
1638
|
+
# (real output), and a dying tty must not break the turn. Cosmetic.
|
|
1639
|
+
def mark_content_streaming(active)
|
|
1640
|
+
composer = BottomComposer.current
|
|
1641
|
+
return unless composer
|
|
1642
|
+
|
|
1643
|
+
active ? composer.begin_content_stream : composer.end_content_stream
|
|
1644
|
+
rescue IOError, Errno::EIO
|
|
1645
|
+
nil
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
# Erases the transient status row through the same seam the frames used
|
|
1649
|
+
# (#paint_live): the proxy/composer transient row when one is active,
|
|
1650
|
+
# else an in-place CR + clear-line on a bare TTY. INSIDE a turn this only
|
|
1651
|
+
# HIDES the row (the turn-scoped engine thread keeps running so the next
|
|
1652
|
+
# event can swap the label back in); outside a turn it stops the engine
|
|
1653
|
+
# entirely — the old one-shot semantics (#58, #74).
|
|
1654
|
+
def clear_thinking_indicator
|
|
1655
|
+
return unless @thinking_indicator
|
|
1656
|
+
|
|
1657
|
+
if @turn_active
|
|
1658
|
+
status_hide
|
|
1659
|
+
else
|
|
1660
|
+
status_stop
|
|
1661
|
+
end
|
|
1662
|
+
@thinking_indicator = false
|
|
1663
|
+
end
|
|
1664
|
+
|
|
1665
|
+
# --- Turn-scoped status row engine (V3 "Ruby facet") ---
|
|
1666
|
+
|
|
1667
|
+
# Shows the row with +label+ (and optional +hint+), resetting the phase
|
|
1668
|
+
# clock. Starts the engine thread when none is running. No-op into a pipe
|
|
1669
|
+
# — there is nothing to animate and raw escapes must not leak (#56).
|
|
1670
|
+
def status_show(label, phase:, hint: nil)
|
|
1671
|
+
return unless thinking_painter
|
|
1672
|
+
|
|
1673
|
+
@status_mutex.synchronize do
|
|
1674
|
+
@turn_started_at ||= monotonic_now
|
|
1675
|
+
@status = { label: label, hint: hint, phase: phase,
|
|
1676
|
+
phase_started_at: monotonic_now, visible: true }
|
|
1677
|
+
start_status_thread
|
|
1678
|
+
end
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
# Like #status_show, but keeps the current phase clock when the row is
|
|
1682
|
+
# already showing this exact label — so per-delta callers (the reasoning
|
|
1683
|
+
# stream) don't reset the elapsed counter ten times a second.
|
|
1684
|
+
def status_ensure(label, phase:, hint: nil)
|
|
1685
|
+
current = @status_mutex.synchronize { @status&.dup }
|
|
1686
|
+
return if current && current[:visible] && current[:label] == label && current[:hint] == hint
|
|
1687
|
+
|
|
1688
|
+
status_show(label, phase: phase, hint: hint)
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
# Hides the row WITHOUT killing the engine thread (mid-turn: the live
|
|
1692
|
+
# answer tail takes the row over while text streams).
|
|
1693
|
+
def status_hide
|
|
1694
|
+
@status_mutex.synchronize do
|
|
1695
|
+
@status[:visible] = false if @status
|
|
1696
|
+
paint_live("")
|
|
1697
|
+
end
|
|
1698
|
+
$stdout.flush
|
|
1699
|
+
end
|
|
1700
|
+
|
|
1701
|
+
# Kills + joins the engine thread and clears the row. Idempotent. The
|
|
1702
|
+
# only exits: turn end, error, interrupt, or a stand-alone wait ending.
|
|
1703
|
+
def status_stop
|
|
1704
|
+
thread = @thinking_thread
|
|
1705
|
+
@thinking_thread = nil
|
|
1706
|
+
if thread
|
|
1707
|
+
thread.kill
|
|
1708
|
+
thread.join
|
|
1709
|
+
end
|
|
1710
|
+
@status_mutex.synchronize { @status = nil }
|
|
1711
|
+
@turn_started_at = nil unless @turn_active
|
|
1712
|
+
paint_live("")
|
|
1713
|
+
$stdout.flush
|
|
1714
|
+
rescue StandardError
|
|
1715
|
+
nil
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1718
|
+
# The single ticker thread for the turn. Frames are built AND painted
|
|
1719
|
+
# under @status_mutex so a hide/relabel can never interleave with a
|
|
1720
|
+
# half-painted stale frame.
|
|
1721
|
+
def start_status_thread
|
|
1722
|
+
return if @thinking_thread&.alive?
|
|
1723
|
+
|
|
1724
|
+
@thinking_thread = Thread.new do
|
|
1725
|
+
i = 0
|
|
1726
|
+
loop do
|
|
1727
|
+
@status_mutex.synchronize do
|
|
1728
|
+
paint_live(status_frame(i)) if @status && @status[:visible]
|
|
1729
|
+
end
|
|
1730
|
+
i += 1
|
|
1731
|
+
sleep STATUS_TICK
|
|
1732
|
+
end
|
|
1733
|
+
rescue StandardError
|
|
1734
|
+
# The animation is cosmetic — a repaint failure must never break the
|
|
1735
|
+
# turn. Stop quietly.
|
|
1736
|
+
end
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
# One frame: the sweeping red ◆ on its dim ┄ track, label + stats right.
|
|
1740
|
+
def status_frame(tick)
|
|
1741
|
+
pos = FACET_FRAMES[tick % FACET_FRAMES.length]
|
|
1742
|
+
track = (0...FACET_TRACK_CELLS).map do |cell|
|
|
1743
|
+
cell == pos ? @pastel.red("◆") : @pastel.dim("┄")
|
|
1744
|
+
end.join
|
|
1745
|
+
"#{track} #{@pastel.dim(status_text)}"
|
|
1746
|
+
end
|
|
1747
|
+
|
|
1748
|
+
# The text to the right of the track. Thinking phase: turn-elapsed +
|
|
1749
|
+
# accumulated stats (tools run, ~tok streamed); tool/job phases: the
|
|
1750
|
+
# label · hint · per-phase elapsed. Always fits 80 cols.
|
|
1751
|
+
def status_text(now = monotonic_now)
|
|
1752
|
+
s = @status
|
|
1753
|
+
parts = [s[:label]]
|
|
1754
|
+
parts << s[:hint] if s[:hint]
|
|
1755
|
+
if s[:phase] == :thinking
|
|
1756
|
+
parts << "#{(now - (@turn_started_at || s[:phase_started_at])).to_i}s"
|
|
1757
|
+
parts << "#{@turn_tool_count} tool#{"s" if @turn_tool_count != 1}" if @turn_tool_count.positive?
|
|
1758
|
+
parts << "~#{format_status_tokens(@turn_tok_chars / 4)} tok" if @turn_tok_chars >= 4
|
|
1759
|
+
parts << "enter to interrupt" if interrupt_hint?(s, now)
|
|
1760
|
+
else
|
|
1761
|
+
parts << "#{(now - s[:phase_started_at]).to_i}s"
|
|
1762
|
+
end
|
|
1763
|
+
text = parts.join(" · ")
|
|
1764
|
+
budget = [terminal_cols, 80].min - FACET_TRACK_CELLS - 2
|
|
1765
|
+
text.length > budget ? "#{text[0, budget - 1]}…" : text
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
# Mid-turn token spend is an ESTIMATE from streamed deltas (~4 chars/tok)
|
|
1769
|
+
# — always marked with the leading ~; the exact total stays in the footer.
|
|
1770
|
+
def format_status_tokens(count)
|
|
1771
|
+
count >= 1000 ? "#{(count / 1000.0).round(1)}k" : count.to_s
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
# The hint only appears where Enter actually interrupts (a composer owns
|
|
1775
|
+
# the keyboard) and only once the wait has dragged past the threshold.
|
|
1776
|
+
def interrupt_hint?(state, now)
|
|
1777
|
+
@turn_active &&
|
|
1778
|
+
(now - state[:phase_started_at]) >= INTERRUPT_HINT_AFTER &&
|
|
1779
|
+
!BottomComposer.current.nil?
|
|
1780
|
+
end
|
|
1781
|
+
|
|
1782
|
+
# Commits the buffered reasoning into scrollback per the active render mode,
|
|
1783
|
+
# then clears the animation. Called when the first answer token arrives, or
|
|
1784
|
+
# when a tool/activity starts with reasoning still buffered (never strand
|
|
1785
|
+
# the cue). After committing it retains the buffer in @last_reasoning so a
|
|
1786
|
+
# later ctrl-o can re-reveal it, and resets @reasoning_buffer for the next
|
|
1787
|
+
# phase. :hidden commits NOTHING but still retains the buffer, so a single
|
|
1788
|
+
# Ctrl+O can pull the last thought back on demand — exactly what the
|
|
1789
|
+
# hidden-mode ack promises (#76).
|
|
1790
|
+
def collapse_reasoning
|
|
1791
|
+
seconds = thinking_elapsed_seconds
|
|
1792
|
+
buffered = @reasoning_buffer
|
|
1793
|
+
mode = reasoning_mode
|
|
1794
|
+
|
|
1795
|
+
clear_thinking_indicator
|
|
1796
|
+
|
|
1797
|
+
unless buffered.strip.empty?
|
|
1798
|
+
if mode == :full
|
|
1799
|
+
commit_reasoning_aside(buffered, seconds)
|
|
1800
|
+
elsif mode == :collapsed
|
|
1801
|
+
commit_reasoning_cue(seconds)
|
|
1802
|
+
end
|
|
1803
|
+
@last_reasoning = buffered
|
|
1804
|
+
@last_reasoning_seconds = seconds
|
|
1805
|
+
# A new thought is retained — reset the reveal guard so the first
|
|
1806
|
+
# Ctrl+O on THIS thought re-emits its aside (Fix 1 idempotency), and
|
|
1807
|
+
# re-arm the "no reasoning retained" note (#133) for a later dry spell.
|
|
1808
|
+
@last_reasoning_revealed = false
|
|
1809
|
+
@no_reasoning_note_shown = false
|
|
1810
|
+
end
|
|
1811
|
+
|
|
1812
|
+
@reasoning_buffer = +""
|
|
1813
|
+
@thinking_started_at = nil
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
# The dim one-liner committed in :collapsed mode:
|
|
1817
|
+
# ┄ ✻ thought for <N>s · ctrl-o to show ┄
|
|
1818
|
+
def commit_reasoning_cue(seconds)
|
|
1819
|
+
$stdout.puts @pastel.dim("┄ ✻ thought for #{seconds}s · ctrl-o to show ┄")
|
|
1820
|
+
end
|
|
1821
|
+
|
|
1822
|
+
# The expanded reasoning aside (full mode / ctrl-o reveal), reusing the
|
|
1823
|
+
# `┊` left-rail family of #probe_aside: a `┄ thinking ┄` opening rail, the
|
|
1824
|
+
# reasoning body on a dim 2-space `┊` rail, and a `┄ thought for <N>s ┄`
|
|
1825
|
+
# closing rail. The aside is already fully shown and is append-only
|
|
1826
|
+
# scrollback that can't be un-printed, so the close line carries NO toggle
|
|
1827
|
+
# hint — promising "ctrl-o to hide" would be a lie and "ctrl-o to show"
|
|
1828
|
+
# would be redundant. The collapsed one-liner cue (#commit_reasoning_cue)
|
|
1829
|
+
# is the only place that carries the "ctrl-o to show" affordance.
|
|
1830
|
+
def commit_reasoning_aside(text, seconds)
|
|
1831
|
+
$stdout.puts
|
|
1832
|
+
$stdout.puts @pastel.dim("┄ thinking ┄#{"─" * 50}")
|
|
1833
|
+
text.to_s.each_line do |line|
|
|
1834
|
+
$stdout.puts @pastel.dim("┊ #{line.chomp}")
|
|
1835
|
+
end
|
|
1836
|
+
$stdout.puts @pastel.dim("┄ thought for #{seconds}s ┄")
|
|
1837
|
+
$stdout.puts
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
# --- Subagent delegation rows (the `task` tool) ---
|
|
1841
|
+
|
|
1842
|
+
# `● delegated → <subagent> <prompt-preview>`. Stashes the subagent name so
|
|
1843
|
+
# the matching #delegation_finished can label the close row even though
|
|
1844
|
+
# tool_finished only receives the result, not the arguments.
|
|
1845
|
+
def delegation_started(arguments)
|
|
1846
|
+
collapse_reasoning
|
|
1847
|
+
sub = delegation_field(arguments, :subagent) || "subagent"
|
|
1848
|
+
prompt = delegation_field(arguments, :prompt)
|
|
1849
|
+
@delegation_subagent = sub
|
|
1850
|
+
preview = prompt ? " #{truncate_inline(prompt, 60)}" : ""
|
|
1851
|
+
$stdout.puts unless %i[tool gap].include?(@last_block)
|
|
1852
|
+
$stdout.puts "#{@pastel.cyan("●")} #{@pastel.dim("delegated → #{sub}#{preview}")}"
|
|
1853
|
+
@activity_open = true
|
|
1854
|
+
@activity_name = "task"
|
|
1855
|
+
@last_block = :tool
|
|
1856
|
+
status_show("task", phase: :tool, hint: sub) if @turn_active
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
# `✓ <subagent>: <summary>` (or `✗ <subagent>: <error>` on failure).
|
|
1860
|
+
#
|
|
1861
|
+
# The `task` tool reports its failures by RETURNING an error STRING
|
|
1862
|
+
# ("Error: unknown subagent …", "At capacity: …") — the executor then
|
|
1863
|
+
# wraps that in a SUCCESS-status Result, so #success? is true and the row
|
|
1864
|
+
# used to render a misleading green ✓ (#123, the B7 family on the
|
|
1865
|
+
# delegation card). Use the same #errorish? predicate #tool_finished
|
|
1866
|
+
# uses, plus the "At capacity:" prefix the task tool emits, so a failed
|
|
1867
|
+
# delegation renders the red ✗ variant — consistent with regular tools.
|
|
1868
|
+
def delegation_finished(result)
|
|
1869
|
+
@activity_open = false
|
|
1870
|
+
sub = @delegation_subagent || "subagent"
|
|
1871
|
+
output = (result.respond_to?(:output) ? result.output : result).to_s
|
|
1872
|
+
if !delegation_failed?(result) && (m = SPAWN_HANDLE_RE.match(output))
|
|
1873
|
+
# Background spawn: ONE lifecycle grammar (P6) — the live-card row
|
|
1874
|
+
# shape, dim, no green ✓ (nothing finished yet; it only started).
|
|
1875
|
+
$stdout.puts @pastel.dim(" └ ▸ #{m[2]} · #{m[1]} · started")
|
|
1876
|
+
else
|
|
1877
|
+
summary = truncate_inline(output.strip, 80)
|
|
1878
|
+
icon, color =
|
|
1879
|
+
if delegation_failed?(result) then ["✗", :red]
|
|
1880
|
+
elsif delegation_noop?(result) then ["⊘", :dim]
|
|
1881
|
+
else ["✓", :dim] # quiet close — color only on failure (P1)
|
|
1882
|
+
end
|
|
1883
|
+
$stdout.puts @pastel.public_send(color, " └ #{icon} #{sub}: #{summary}")
|
|
1884
|
+
end
|
|
1885
|
+
@delegation_subagent = nil
|
|
1886
|
+
@last_block = :tool
|
|
1887
|
+
status_back_to_thinking
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1890
|
+
# True when a delegation did nothing / was denied: the subagent produced no
|
|
1891
|
+
# final text, so the task tool returned the no-op placeholder. Not a failure
|
|
1892
|
+
# (no error), but not a success either — it renders a neutral ⊘ instead of a
|
|
1893
|
+
# misleading green ✓ (#16).
|
|
1894
|
+
def delegation_noop?(result)
|
|
1895
|
+
output = result.respond_to?(:output) ? result.output : result
|
|
1896
|
+
Tools::TaskTool.noop_result?(output)
|
|
1897
|
+
end
|
|
1898
|
+
|
|
1899
|
+
# True when a delegation result represents a failure. Mirrors how
|
|
1900
|
+
# #tool_finished decides (Result#errorish? — non-success status, an
|
|
1901
|
+
# error_code, or an "Error:" output), and additionally treats the task
|
|
1902
|
+
# tool's "At capacity:" string (a success-status Result that #errorish?
|
|
1903
|
+
# does not catch) as a failure so the row shows ✗.
|
|
1904
|
+
def delegation_failed?(result)
|
|
1905
|
+
return false if result.nil?
|
|
1906
|
+
|
|
1907
|
+
base = result.respond_to?(:errorish?) ? result.errorish? : (result.respond_to?(:success?) && !result.success?)
|
|
1908
|
+
return true if base
|
|
1909
|
+
|
|
1910
|
+
output = result.respond_to?(:output) ? result.output : result
|
|
1911
|
+
output.to_s.lstrip.start_with?("At capacity:")
|
|
1912
|
+
end
|
|
1913
|
+
|
|
1914
|
+
def delegation_field(arguments, key)
|
|
1915
|
+
return nil unless arguments.is_a?(Hash)
|
|
1916
|
+
|
|
1917
|
+
value = arguments[key] || arguments[key.to_s]
|
|
1918
|
+
v = value.to_s.strip
|
|
1919
|
+
v.empty? ? nil : v
|
|
1920
|
+
end
|
|
1921
|
+
|
|
1922
|
+
# Collapses a possibly-multiline text into ONE inline segment: lines are
|
|
1923
|
+
# joined with " — " (instead of dropping everything after the first), then
|
|
1924
|
+
# clamped to +max+ chars. Keeps multi-line tool metrics / subagent
|
|
1925
|
+
# summaries on a single styled row.
|
|
1926
|
+
def truncate_inline(text, max)
|
|
1927
|
+
inline = text.to_s.lines.map(&:strip).reject(&:empty?).join(" — ")
|
|
1928
|
+
inline.length > max ? "#{inline[0, max - 1]}…" : inline
|
|
1929
|
+
end
|
|
1930
|
+
|
|
1931
|
+
# Short identifier piece for the tool header.
|
|
1932
|
+
def args_hint(arguments)
|
|
1933
|
+
return nil unless arguments.is_a?(Hash)
|
|
1934
|
+
|
|
1935
|
+
raw_key, raw_value = pick_hint(arguments)
|
|
1936
|
+
return nil unless raw_value
|
|
1937
|
+
|
|
1938
|
+
hint = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s
|
|
1939
|
+
first = hint.lines.first.to_s.strip
|
|
1940
|
+
label = first.length > 60 ? "#{first[0, 57]}..." : first
|
|
1941
|
+
|
|
1942
|
+
if path_key?(raw_key)
|
|
1943
|
+
Util::Hyperlink.wrap_path(first, label: label)
|
|
1944
|
+
else
|
|
1945
|
+
label
|
|
1946
|
+
end
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1949
|
+
# A PLAIN short hint for the status row (no OSC-8 hyperlink wrapping —
|
|
1950
|
+
# the live row is repainted 10×/s and must stay measurable plain text).
|
|
1951
|
+
def status_hint(arguments)
|
|
1952
|
+
return nil unless arguments.is_a?(Hash)
|
|
1953
|
+
|
|
1954
|
+
raw_key, raw_value = pick_hint(arguments)
|
|
1955
|
+
return nil unless raw_value
|
|
1956
|
+
|
|
1957
|
+
first = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s.lines.first.to_s.strip
|
|
1958
|
+
first.length > 30 ? "#{first[0, 29]}…" : first
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
def path_key?(key)
|
|
1962
|
+
k = key.to_s
|
|
1963
|
+
%w[file_path path].include?(k)
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
def pick_hint(arguments)
|
|
1967
|
+
%i[pattern file_path path command].each do |k|
|
|
1968
|
+
v = arguments[k] || arguments[k.to_s]
|
|
1969
|
+
return [k, v] if v && !v.to_s.empty?
|
|
1970
|
+
end
|
|
1971
|
+
nil
|
|
1972
|
+
end
|
|
1973
|
+
|
|
1974
|
+
def color_for(role)
|
|
1975
|
+
case role
|
|
1976
|
+
when :info then :cyan
|
|
1977
|
+
when :success then :green
|
|
1978
|
+
when :warning then :yellow
|
|
1979
|
+
when :error then :red
|
|
1980
|
+
when :status then :dim
|
|
1981
|
+
when :tool then :cyan
|
|
1982
|
+
when :muted then :dim
|
|
1983
|
+
end
|
|
1984
|
+
end
|
|
1985
|
+
end
|
|
1986
|
+
end
|
|
1987
|
+
end
|