octo-agent 0.11.2
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/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require_relative "../theme_manager"
|
|
6
|
+
require_relative "../line_editor"
|
|
7
|
+
require_relative "command_suggestions"
|
|
8
|
+
require_relative "../../utils/encoding"
|
|
9
|
+
|
|
10
|
+
module Octo
|
|
11
|
+
module UI2
|
|
12
|
+
module Components
|
|
13
|
+
# InputArea manages the fixed input area at the bottom of the screen
|
|
14
|
+
# Enhanced with multi-line support, image paste, and more
|
|
15
|
+
class InputArea
|
|
16
|
+
include LineEditor
|
|
17
|
+
|
|
18
|
+
# User tips pool - can be extended with more tips over time
|
|
19
|
+
USER_TIPS = [
|
|
20
|
+
"Shift+Tab to toggle permission mode (confirm_safes ⇄ auto_approve)",
|
|
21
|
+
"Ctrl+C to interrupt AI execution or clear input",
|
|
22
|
+
"Shift+Enter to create multi-line input",
|
|
23
|
+
"Ctrl+V to paste images (supports up to 3 images)",
|
|
24
|
+
"Ctrl+D to delete pasted images",
|
|
25
|
+
"Use /clear to restart session, /help for commands"
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
attr_accessor :row
|
|
29
|
+
attr_reader :cursor_position, :line_index, :files, :tips_message, :tips_type
|
|
30
|
+
|
|
31
|
+
HISTORY_FILE = File.expand_path("~/.octo/cmd_history.json").freeze
|
|
32
|
+
MAX_HISTORY = 100
|
|
33
|
+
|
|
34
|
+
def initialize(row: 0)
|
|
35
|
+
@row = row
|
|
36
|
+
@lines = [""]
|
|
37
|
+
@line_index = 0
|
|
38
|
+
@cursor_position = 0
|
|
39
|
+
@history = load_history
|
|
40
|
+
@history_index = -1
|
|
41
|
+
@pastel = Pastel.new
|
|
42
|
+
@width = TTY::Screen.width
|
|
43
|
+
|
|
44
|
+
@files = []
|
|
45
|
+
@paste_counter = 0
|
|
46
|
+
@paste_placeholders = {}
|
|
47
|
+
@last_ctrl_c_time = nil
|
|
48
|
+
@tips_message = nil
|
|
49
|
+
@tips_type = :info
|
|
50
|
+
@tips_timer = nil
|
|
51
|
+
@last_render_row = nil
|
|
52
|
+
|
|
53
|
+
# User tip (usage suggestion) - separate from system tips
|
|
54
|
+
@user_tip = nil
|
|
55
|
+
@user_tip_timer = nil
|
|
56
|
+
@user_tip_count = 0
|
|
57
|
+
|
|
58
|
+
# Paused state - when InlineInput is active
|
|
59
|
+
@paused = false
|
|
60
|
+
|
|
61
|
+
# Session bar info
|
|
62
|
+
@sessionbar_info = {
|
|
63
|
+
session_id: nil, # Full session id; rendered as first 8 chars (parity with WebUI)
|
|
64
|
+
working_dir: nil,
|
|
65
|
+
mode: nil,
|
|
66
|
+
model: nil,
|
|
67
|
+
tasks: 0,
|
|
68
|
+
status: 'idle' # Workspace status: 'idle' or 'working'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Animation state for working status
|
|
72
|
+
@animation_frame = 0
|
|
73
|
+
@last_animation_update = Time.now
|
|
74
|
+
@working_frames = ["❄", "❅", "❆"]
|
|
75
|
+
|
|
76
|
+
# Command suggestions dropdown
|
|
77
|
+
@command_suggestions = CommandSuggestions.new
|
|
78
|
+
@skill_loader = nil # Will be set via set_skill_loader method
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get current theme from ThemeManager
|
|
82
|
+
def theme
|
|
83
|
+
UI2::ThemeManager.current_theme
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get prompt symbol from theme
|
|
87
|
+
def prompt
|
|
88
|
+
"#{theme.symbol(:user)} "
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def required_height
|
|
92
|
+
# When paused (InlineInput active), don't take up any space
|
|
93
|
+
return 0 if @paused
|
|
94
|
+
|
|
95
|
+
height = 0
|
|
96
|
+
|
|
97
|
+
# Session bar - calculate actual wrapped height
|
|
98
|
+
height += calculate_sessionbar_height
|
|
99
|
+
|
|
100
|
+
# Separator after session bar
|
|
101
|
+
height += 1
|
|
102
|
+
|
|
103
|
+
# Images
|
|
104
|
+
height += @files.size
|
|
105
|
+
|
|
106
|
+
# Calculate height considering wrapped lines
|
|
107
|
+
# Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
|
|
108
|
+
content_width = effective_content_width(@width)
|
|
109
|
+
@lines.each_with_index do |line, idx|
|
|
110
|
+
prefix = if idx == 0
|
|
111
|
+
prompt
|
|
112
|
+
else
|
|
113
|
+
" " * prompt.length
|
|
114
|
+
end
|
|
115
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
116
|
+
available_width = [content_width - prefix_width, 20].max # At least 20 chars
|
|
117
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
118
|
+
height += wrapped_segments.size
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Bottom separator
|
|
122
|
+
height += 1
|
|
123
|
+
|
|
124
|
+
# Command suggestions (rendered above input)
|
|
125
|
+
height += @command_suggestions.required_height if @command_suggestions
|
|
126
|
+
|
|
127
|
+
# Tips and user tips
|
|
128
|
+
height += 1 if @tips_message
|
|
129
|
+
height += 1 if @user_tip
|
|
130
|
+
|
|
131
|
+
height
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Set skill loader for command suggestions
|
|
135
|
+
# @param skill_loader [Octo::SkillLoader] The skill loader instance
|
|
136
|
+
# @param agent_profile [Octo::AgentProfile, nil] Current agent profile for skill filtering
|
|
137
|
+
def set_skill_loader(skill_loader, agent_profile = nil)
|
|
138
|
+
@skill_loader = skill_loader
|
|
139
|
+
@command_suggestions.load_skill_commands(skill_loader, agent_profile) if skill_loader
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Update session bar info
|
|
143
|
+
# @param session_id [String] Full session id (rendered as first 8 chars)
|
|
144
|
+
# @param working_dir [String] Working directory
|
|
145
|
+
# @param mode [String] Permission mode
|
|
146
|
+
# @param model [String] AI model name
|
|
147
|
+
# @param tasks [Integer] Number of completed tasks
|
|
148
|
+
# @param status [String] Workspace status ('idle' or 'working')
|
|
149
|
+
def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, status: nil)
|
|
150
|
+
@sessionbar_info[:session_id] = session_id if session_id
|
|
151
|
+
@sessionbar_info[:working_dir] = working_dir if working_dir
|
|
152
|
+
@sessionbar_info[:mode] = mode if mode
|
|
153
|
+
@sessionbar_info[:model] = model if model
|
|
154
|
+
@sessionbar_info[:tasks] = tasks if tasks
|
|
155
|
+
@sessionbar_info[:status] = status if status
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def input_buffer
|
|
159
|
+
@lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def handle_key(key)
|
|
163
|
+
# Ignore input when paused (InlineInput is active)
|
|
164
|
+
return { action: nil } if @paused
|
|
165
|
+
|
|
166
|
+
old_height = required_height
|
|
167
|
+
|
|
168
|
+
# Handle command suggestions navigation first if visible
|
|
169
|
+
if @command_suggestions.visible
|
|
170
|
+
case key
|
|
171
|
+
when :up_arrow
|
|
172
|
+
@command_suggestions.select_previous
|
|
173
|
+
return { action: nil }
|
|
174
|
+
when :down_arrow
|
|
175
|
+
@command_suggestions.select_next
|
|
176
|
+
return { action: nil }
|
|
177
|
+
when :enter
|
|
178
|
+
# Accept selected command and submit immediately
|
|
179
|
+
if @command_suggestions.has_suggestions?
|
|
180
|
+
selected = @command_suggestions.selected_command_text
|
|
181
|
+
if selected
|
|
182
|
+
# Replace current input with selected command
|
|
183
|
+
@lines = [selected]
|
|
184
|
+
@line_index = 0
|
|
185
|
+
@cursor_position = selected.length
|
|
186
|
+
@command_suggestions.hide
|
|
187
|
+
# Submit the command immediately
|
|
188
|
+
return handle_enter
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
# Fall through to normal enter handling if no suggestion
|
|
192
|
+
when :escape
|
|
193
|
+
@command_suggestions.hide
|
|
194
|
+
return { action: nil }
|
|
195
|
+
when :tab
|
|
196
|
+
# Tab accepts the currently highlighted suggestion
|
|
197
|
+
if @command_suggestions.has_suggestions?
|
|
198
|
+
selected = @command_suggestions.selected_command_text
|
|
199
|
+
if selected
|
|
200
|
+
hint = @command_suggestions.selected_argument_hint
|
|
201
|
+
completed = "#{selected} "
|
|
202
|
+
@lines = [completed]
|
|
203
|
+
@line_index = 0
|
|
204
|
+
@cursor_position = completed.length
|
|
205
|
+
@command_suggestions.hide
|
|
206
|
+
# Show argument hint as a tip if available
|
|
207
|
+
set_tips("Usage: #{selected} #{hint}", type: :info) if hint && !hint.empty?
|
|
208
|
+
return { action: nil }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Tab with no visible suggestions: trigger slash-command completion
|
|
215
|
+
if key == :tab
|
|
216
|
+
trigger_tab_completion
|
|
217
|
+
return { action: nil }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
result = case key
|
|
221
|
+
when Hash
|
|
222
|
+
if key[:type] == :rapid_input
|
|
223
|
+
insert_text(key[:text])
|
|
224
|
+
clear_tips
|
|
225
|
+
update_command_suggestions
|
|
226
|
+
end
|
|
227
|
+
{ action: nil }
|
|
228
|
+
when :enter then handle_enter
|
|
229
|
+
when :newline then newline; { action: nil }
|
|
230
|
+
when :backspace
|
|
231
|
+
backspace
|
|
232
|
+
update_command_suggestions
|
|
233
|
+
{ action: nil }
|
|
234
|
+
when :delete
|
|
235
|
+
delete_char
|
|
236
|
+
update_command_suggestions
|
|
237
|
+
{ action: nil }
|
|
238
|
+
when :left_arrow, :ctrl_b then cursor_left; { action: nil }
|
|
239
|
+
when :right_arrow, :ctrl_f then cursor_right; { action: nil }
|
|
240
|
+
when :up_arrow then handle_up_arrow
|
|
241
|
+
when :down_arrow then handle_down_arrow
|
|
242
|
+
when :home, :ctrl_a then cursor_home; { action: nil }
|
|
243
|
+
when :end, :ctrl_e then cursor_end; { action: nil }
|
|
244
|
+
when :ctrl_k then kill_to_end; { action: nil }
|
|
245
|
+
when :ctrl_u then kill_to_start; { action: nil }
|
|
246
|
+
when :ctrl_w then kill_word; { action: nil }
|
|
247
|
+
when :ctrl_c then handle_ctrl_c
|
|
248
|
+
when :ctrl_d then handle_ctrl_d
|
|
249
|
+
when :ctrl_v then handle_paste
|
|
250
|
+
when :ctrl_o then { action: :toggle_expand }
|
|
251
|
+
when :shift_tab then { action: :toggle_mode }
|
|
252
|
+
when :escape
|
|
253
|
+
if @command_suggestions.visible
|
|
254
|
+
@command_suggestions.hide
|
|
255
|
+
{ action: nil }
|
|
256
|
+
else
|
|
257
|
+
# Trigger time machine when ESC is pressed and suggestions not visible
|
|
258
|
+
{ action: :time_machine }
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
if key.is_a?(String) && key.length >= 1 && key.ord >= 32
|
|
262
|
+
insert_char(key)
|
|
263
|
+
update_command_suggestions
|
|
264
|
+
end
|
|
265
|
+
{ action: nil }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
new_height = required_height
|
|
269
|
+
if new_height != old_height
|
|
270
|
+
result[:height_changed] = true
|
|
271
|
+
result[:new_height] = new_height
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
result
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def render(start_row:, width: nil)
|
|
278
|
+
@width = width || TTY::Screen.width
|
|
279
|
+
@last_render_row = start_row # Save for tips auto-clear
|
|
280
|
+
|
|
281
|
+
# When paused, don't render anything (InlineInput is active)
|
|
282
|
+
return if @paused
|
|
283
|
+
|
|
284
|
+
current_row = start_row
|
|
285
|
+
|
|
286
|
+
# Session bar at top
|
|
287
|
+
render_sessionbar(current_row)
|
|
288
|
+
current_row += 1
|
|
289
|
+
|
|
290
|
+
# Separator after session bar
|
|
291
|
+
render_separator(current_row)
|
|
292
|
+
current_row += 1
|
|
293
|
+
|
|
294
|
+
# Files (images / documents)
|
|
295
|
+
@files.each_with_index do |f, idx|
|
|
296
|
+
move_cursor(current_row, 0)
|
|
297
|
+
filename = f[:name] || f["name"] || "file"
|
|
298
|
+
size = f[:size] || f["size"]
|
|
299
|
+
size_str = size ? " #{format_filesize(size)}" : ""
|
|
300
|
+
content = @pastel.dim("[File #{idx + 1}] #{filename}#{size_str} (Ctrl+D to delete)")
|
|
301
|
+
print_with_padding(content)
|
|
302
|
+
current_row += 1
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Input lines with auto-wrap support
|
|
306
|
+
current_row = render_input_lines(current_row)
|
|
307
|
+
|
|
308
|
+
# Bottom separator
|
|
309
|
+
render_separator(current_row)
|
|
310
|
+
current_row += 1
|
|
311
|
+
|
|
312
|
+
# Command suggestions (rendered above tips)
|
|
313
|
+
if @command_suggestions && @command_suggestions.visible
|
|
314
|
+
# Render suggestions at current row
|
|
315
|
+
print @command_suggestions.render(row: current_row, col: 0, width: [@width - 4, 60].min)
|
|
316
|
+
current_row += @command_suggestions.required_height
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Tips bar (if any)
|
|
320
|
+
if @tips_message
|
|
321
|
+
move_cursor(current_row, 0)
|
|
322
|
+
content = format_tips(@tips_message, @tips_type)
|
|
323
|
+
print_with_padding(content)
|
|
324
|
+
current_row += 1
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# User tip (if any)
|
|
328
|
+
if @user_tip
|
|
329
|
+
move_cursor(current_row, 0)
|
|
330
|
+
content = format_user_tip(@user_tip)
|
|
331
|
+
print_with_padding(content)
|
|
332
|
+
current_row += 1
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Position cursor at current edit position
|
|
336
|
+
position_cursor(start_row)
|
|
337
|
+
flush
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def position_cursor(start_row)
|
|
341
|
+
# Calculate which wrapped line the cursor is on
|
|
342
|
+
cursor_row = start_row + 2 + @files.size # session_bar + separator + images
|
|
343
|
+
# Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
|
|
344
|
+
content_width = effective_content_width(@width)
|
|
345
|
+
|
|
346
|
+
# Add rows for lines before current line
|
|
347
|
+
@lines[0...@line_index].each_with_index do |line, idx|
|
|
348
|
+
prefix = if idx == 0
|
|
349
|
+
prompt
|
|
350
|
+
else
|
|
351
|
+
" " * prompt.length
|
|
352
|
+
end
|
|
353
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
354
|
+
available_width = [content_width - prefix_width, 20].max
|
|
355
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
356
|
+
cursor_row += wrapped_segments.size
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Find which wrapped segment of current line contains cursor
|
|
360
|
+
current = current_line
|
|
361
|
+
prefix = if @line_index == 0
|
|
362
|
+
prompt
|
|
363
|
+
else
|
|
364
|
+
" " * prompt.length
|
|
365
|
+
end
|
|
366
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
367
|
+
available_width = [content_width - prefix_width, 20].max
|
|
368
|
+
wrapped_segments = wrap_line(current, available_width)
|
|
369
|
+
|
|
370
|
+
# Find cursor segment and position within segment
|
|
371
|
+
cursor_segment_idx = 0
|
|
372
|
+
cursor_pos_in_segment = @cursor_position
|
|
373
|
+
|
|
374
|
+
wrapped_segments.each_with_index do |segment, idx|
|
|
375
|
+
if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
|
|
376
|
+
cursor_segment_idx = idx
|
|
377
|
+
cursor_pos_in_segment = @cursor_position - segment[:start]
|
|
378
|
+
break
|
|
379
|
+
elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
|
|
380
|
+
# Cursor at very end
|
|
381
|
+
cursor_segment_idx = idx
|
|
382
|
+
cursor_pos_in_segment = segment[:end] - segment[:start]
|
|
383
|
+
break
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
cursor_row += cursor_segment_idx
|
|
388
|
+
|
|
389
|
+
# Calculate display width of text before cursor in this segment
|
|
390
|
+
chars = current.chars
|
|
391
|
+
segment_start = wrapped_segments[cursor_segment_idx][:start]
|
|
392
|
+
text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
|
|
393
|
+
display_width = calculate_display_width(text_in_segment_before_cursor)
|
|
394
|
+
|
|
395
|
+
cursor_col = prefix_width + display_width
|
|
396
|
+
move_cursor(cursor_row, cursor_col)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def set_tips(message, type: :info)
|
|
400
|
+
# Cancel existing timer if any
|
|
401
|
+
if @tips_timer&.alive?
|
|
402
|
+
@tips_timer.kill
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
@tips_message = message
|
|
406
|
+
@tips_type = type
|
|
407
|
+
|
|
408
|
+
# Auto-clear tips after 2 seconds
|
|
409
|
+
@tips_timer = Thread.new do
|
|
410
|
+
sleep 2
|
|
411
|
+
# Clear tips from state and screen
|
|
412
|
+
@tips_message = nil
|
|
413
|
+
# Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
|
|
414
|
+
tips_row = @last_render_row + 2 + @files.size + @lines.size + 1
|
|
415
|
+
move_cursor(tips_row, 0)
|
|
416
|
+
clear_line
|
|
417
|
+
flush
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def clear_tips
|
|
422
|
+
# Cancel timer if any
|
|
423
|
+
if @tips_timer&.alive?
|
|
424
|
+
@tips_timer.kill
|
|
425
|
+
end
|
|
426
|
+
@tips_message = nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Show a random user tip with probability and auto-rotation (max 3 tips)
|
|
430
|
+
# @param probability [Float] Probability of showing tip (0.0 to 1.0, default: 0.4)
|
|
431
|
+
# @param rotation_interval [Integer] Seconds between tip rotation (default: 12)
|
|
432
|
+
# @param max_tips [Integer] Maximum number of tips to show before stopping (default: 3)
|
|
433
|
+
def show_user_tip(probability: 0.4, rotation_interval: 12, max_tips: 3)
|
|
434
|
+
# Random chance to show tip
|
|
435
|
+
return unless rand < probability
|
|
436
|
+
|
|
437
|
+
# Stop existing timer if any
|
|
438
|
+
stop_user_tip_timer
|
|
439
|
+
|
|
440
|
+
# Reset counter and pick first random tip
|
|
441
|
+
@user_tip_count = 1
|
|
442
|
+
@user_tip = USER_TIPS.sample
|
|
443
|
+
|
|
444
|
+
# Start rotation timer (will show max_tips total)
|
|
445
|
+
@user_tip_timer = Thread.new do
|
|
446
|
+
while @user_tip_count < max_tips
|
|
447
|
+
sleep rotation_interval
|
|
448
|
+
@user_tip_count += 1
|
|
449
|
+
|
|
450
|
+
# Pick a different tip
|
|
451
|
+
old_tip = @user_tip
|
|
452
|
+
loop do
|
|
453
|
+
@user_tip = USER_TIPS.sample
|
|
454
|
+
break if @user_tip != old_tip || USER_TIPS.size == 1
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# After showing max_tips, wait then clear
|
|
459
|
+
sleep rotation_interval
|
|
460
|
+
@user_tip = nil
|
|
461
|
+
@user_tip_count = 0
|
|
462
|
+
rescue => e
|
|
463
|
+
# Silently handle thread errors
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Clear user tip and stop rotation
|
|
468
|
+
def clear_user_tip
|
|
469
|
+
stop_user_tip_timer
|
|
470
|
+
@user_tip = nil
|
|
471
|
+
@user_tip_count = 0
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
private def stop_user_tip_timer
|
|
475
|
+
if @user_tip_timer&.alive?
|
|
476
|
+
@user_tip_timer.kill
|
|
477
|
+
@user_tip_timer = nil
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Pause input area (when InlineInput is active)
|
|
482
|
+
def pause
|
|
483
|
+
@paused = true
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Resume input area (when InlineInput is done)
|
|
487
|
+
def resume
|
|
488
|
+
@paused = false
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Check if paused
|
|
492
|
+
def paused?
|
|
493
|
+
@paused
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def current_content
|
|
497
|
+
text = expand_placeholders(@lines.join("\n"))
|
|
498
|
+
|
|
499
|
+
# If both text and images are empty, return empty string
|
|
500
|
+
return "" if text.empty? && @files.empty?
|
|
501
|
+
|
|
502
|
+
# Format user input with color and spacing from theme
|
|
503
|
+
symbol = theme.format_symbol(:user)
|
|
504
|
+
content = theme.format_text(text, :user)
|
|
505
|
+
|
|
506
|
+
result = "\n#{symbol} #{content}\n"
|
|
507
|
+
|
|
508
|
+
# Append file information if present
|
|
509
|
+
if @files.any?
|
|
510
|
+
@files.each_with_index do |f, idx|
|
|
511
|
+
filename = f[:name] || f["name"] || "file"
|
|
512
|
+
result += @pastel.dim(" [File #{idx + 1}] #{filename}") + "\n"
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
result
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def current_value
|
|
520
|
+
expand_placeholders(@lines.join("\n"))
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def empty?
|
|
524
|
+
@lines.all?(&:empty?) && @files.empty?
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def multiline?
|
|
528
|
+
@lines.size > 1
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def has_images?
|
|
532
|
+
@files.any?
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def set_prompt(prompt)
|
|
536
|
+
prompt = prompt
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# --- Public editing methods ---
|
|
540
|
+
|
|
541
|
+
def insert_char(char)
|
|
542
|
+
chars = current_line.chars
|
|
543
|
+
chars.insert(@cursor_position, char)
|
|
544
|
+
@lines[@line_index] = chars.join
|
|
545
|
+
@cursor_position += 1
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def backspace
|
|
549
|
+
if @cursor_position > 0
|
|
550
|
+
chars = current_line.chars
|
|
551
|
+
chars.delete_at(@cursor_position - 1)
|
|
552
|
+
@lines[@line_index] = chars.join
|
|
553
|
+
@cursor_position -= 1
|
|
554
|
+
elsif @line_index > 0
|
|
555
|
+
prev_line = @lines[@line_index - 1]
|
|
556
|
+
current = @lines[@line_index]
|
|
557
|
+
@lines.delete_at(@line_index)
|
|
558
|
+
@line_index -= 1
|
|
559
|
+
@cursor_position = prev_line.chars.length
|
|
560
|
+
@lines[@line_index] = prev_line + current
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def delete_char
|
|
565
|
+
chars = current_line.chars
|
|
566
|
+
return if @cursor_position >= chars.length
|
|
567
|
+
chars.delete_at(@cursor_position)
|
|
568
|
+
@lines[@line_index] = chars.join
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def cursor_left
|
|
572
|
+
@cursor_position = [@cursor_position - 1, 0].max
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def cursor_right
|
|
576
|
+
@cursor_position = [@cursor_position + 1, current_line.chars.length].min
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def cursor_home
|
|
580
|
+
@cursor_position = 0
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def cursor_end
|
|
584
|
+
@cursor_position = current_line.chars.length
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def clear
|
|
588
|
+
@lines = [""]
|
|
589
|
+
@line_index = 0
|
|
590
|
+
@cursor_position = 0
|
|
591
|
+
@history_index = -1
|
|
592
|
+
@files = []
|
|
593
|
+
@paste_counter = 0
|
|
594
|
+
@paste_placeholders = {}
|
|
595
|
+
clear_tips
|
|
596
|
+
@command_suggestions.hide if @command_suggestions
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def submit
|
|
600
|
+
text = current_value
|
|
601
|
+
files = @files.dup
|
|
602
|
+
add_to_history(text) unless text.empty?
|
|
603
|
+
clear
|
|
604
|
+
{ text: text, files: files }
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def history_prev
|
|
608
|
+
return if @history.empty?
|
|
609
|
+
if @history_index == -1
|
|
610
|
+
@history_index = @history.size - 1
|
|
611
|
+
else
|
|
612
|
+
@history_index = [@history_index - 1, 0].max
|
|
613
|
+
end
|
|
614
|
+
load_history_entry
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def history_next
|
|
618
|
+
return if @history_index == -1
|
|
619
|
+
@history_index += 1
|
|
620
|
+
if @history_index >= @history.size
|
|
621
|
+
@history_index = -1
|
|
622
|
+
@lines = [""]
|
|
623
|
+
@line_index = 0
|
|
624
|
+
@cursor_position = 0
|
|
625
|
+
else
|
|
626
|
+
load_history_entry
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# Update command suggestions based on current input
|
|
632
|
+
# Shows suggestions when input starts with /
|
|
633
|
+
private def update_command_suggestions
|
|
634
|
+
return unless @command_suggestions
|
|
635
|
+
|
|
636
|
+
current = current_line.strip
|
|
637
|
+
|
|
638
|
+
# Check if we should show suggestions (input starts with /)
|
|
639
|
+
if current.start_with?('/') && @line_index == 0
|
|
640
|
+
# Extract the filter text (everything after /)
|
|
641
|
+
filter_text = current[1..-1] || ""
|
|
642
|
+
@command_suggestions.show(filter_text)
|
|
643
|
+
else
|
|
644
|
+
@command_suggestions.hide
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
# Trigger tab completion: show all commands or filter by current slash input
|
|
649
|
+
# Called when Tab is pressed and no suggestions dropdown is visible
|
|
650
|
+
private def trigger_tab_completion
|
|
651
|
+
return unless @command_suggestions
|
|
652
|
+
|
|
653
|
+
current = current_line.strip
|
|
654
|
+
|
|
655
|
+
if current.empty?
|
|
656
|
+
# Empty input: type "/" and show all commands
|
|
657
|
+
insert_char("/")
|
|
658
|
+
@command_suggestions.show("")
|
|
659
|
+
elsif current.start_with?("/")
|
|
660
|
+
# Already typing a slash command: show/refresh filtered suggestions
|
|
661
|
+
filter_text = current[1..-1] || ""
|
|
662
|
+
@command_suggestions.show(filter_text)
|
|
663
|
+
end
|
|
664
|
+
# Tab on normal text has no effect
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Render all input lines with auto-wrap support
|
|
668
|
+
# @param start_row [Integer] Starting row position
|
|
669
|
+
# @return [Integer] Next available row after rendering all lines
|
|
670
|
+
def render_input_lines(start_row)
|
|
671
|
+
current_row = start_row
|
|
672
|
+
# Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
|
|
673
|
+
content_width = effective_content_width(@width)
|
|
674
|
+
|
|
675
|
+
@lines.each_with_index do |line, line_idx|
|
|
676
|
+
prefix = calculate_line_prefix(line_idx)
|
|
677
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
678
|
+
available_width = content_width - prefix_width
|
|
679
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
680
|
+
|
|
681
|
+
wrapped_segments.each_with_index do |segment_info, wrap_idx|
|
|
682
|
+
content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
|
|
683
|
+
move_cursor(current_row, 0)
|
|
684
|
+
print_with_padding(content)
|
|
685
|
+
current_row += 1
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
current_row
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Calculate the prefix (prompt or indent) for a given line index
|
|
693
|
+
# @param line_idx [Integer] Index of the line
|
|
694
|
+
# @return [String] Prefix string (with formatting)
|
|
695
|
+
private def calculate_line_prefix(line_idx)
|
|
696
|
+
if line_idx == 0
|
|
697
|
+
theme.format_symbol(:user) + " "
|
|
698
|
+
else
|
|
699
|
+
" " * prompt.length
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Render a single segment of a line (handling cursor and wrapping)
|
|
704
|
+
# @param line [String] Full line text
|
|
705
|
+
# @param line_idx [Integer] Index of the line in @lines
|
|
706
|
+
# @param segment_info [Hash] Segment information from wrap_line
|
|
707
|
+
# @param wrap_idx [Integer] Index of this segment in wrapped segments
|
|
708
|
+
# @param prefix [String] Line prefix (prompt or indent)
|
|
709
|
+
# @param prefix_width [Integer] Display width of the prefix
|
|
710
|
+
# @return [String] Formatted content for this segment
|
|
711
|
+
private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
|
|
712
|
+
segment_text = segment_info[:text]
|
|
713
|
+
segment_start = segment_info[:start]
|
|
714
|
+
segment_end = segment_info[:end]
|
|
715
|
+
|
|
716
|
+
is_current_line = (line_idx == @line_index)
|
|
717
|
+
is_first_segment = (wrap_idx == 0)
|
|
718
|
+
|
|
719
|
+
# Determine the line prefix
|
|
720
|
+
line_prefix = if is_first_segment
|
|
721
|
+
prefix
|
|
722
|
+
else
|
|
723
|
+
" " * prefix_width # Continuation indent
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Render the segment content (with or without cursor)
|
|
727
|
+
segment_content = if is_current_line
|
|
728
|
+
render_line_segment_with_cursor(line, segment_start, segment_end)
|
|
729
|
+
else
|
|
730
|
+
theme.format_text(segment_text, :user)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
"#{line_prefix}#{segment_content}"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Wrap a line into multiple segments based on available width
|
|
737
|
+
# Considers display width of characters (multi-byte characters like Chinese)
|
|
738
|
+
# @param line [String] The line to wrap
|
|
739
|
+
# @param max_width [Integer] Maximum display width per wrapped line
|
|
740
|
+
# @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
|
|
741
|
+
def wrap_line(line, max_width)
|
|
742
|
+
super(line, max_width)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Calculate display width of a single character
|
|
746
|
+
# @param char [String] Single character
|
|
747
|
+
# @return [Integer] Display width (1 or 2)
|
|
748
|
+
def char_display_width(char)
|
|
749
|
+
super(char)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Strip ANSI escape codes from a string
|
|
753
|
+
# @param text [String] Text with ANSI codes
|
|
754
|
+
# @return [String] Text without ANSI codes
|
|
755
|
+
def strip_ansi_codes(text)
|
|
756
|
+
text.gsub(/\e\[[0-9;]*m/, '')
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Print content and pad with spaces to clear any remaining characters from previous render
|
|
760
|
+
# This avoids flickering from clear_line while ensuring old content is erased
|
|
761
|
+
def print_with_padding(content)
|
|
762
|
+
# Calculate visible width (strip ANSI codes for width calculation)
|
|
763
|
+
visible_content = content.gsub(/\e\[[0-9;]*m/, '')
|
|
764
|
+
visible_width = calculate_display_width(visible_content)
|
|
765
|
+
|
|
766
|
+
# IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
|
|
767
|
+
if visible_width > @width
|
|
768
|
+
# Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
|
|
769
|
+
truncate_at = 0
|
|
770
|
+
current_width = 0
|
|
771
|
+
visible_content.each_char.with_index do |char, idx|
|
|
772
|
+
char_width = char_display_width(char)
|
|
773
|
+
break if current_width + char_width + 3 > @width # Reserve 3 for "..."
|
|
774
|
+
current_width += char_width
|
|
775
|
+
truncate_at = idx + 1
|
|
776
|
+
end
|
|
777
|
+
print visible_content[0...truncate_at]
|
|
778
|
+
print "..."
|
|
779
|
+
# Pad remaining
|
|
780
|
+
remaining = @width - current_width - 3
|
|
781
|
+
print " " * remaining if remaining > 0
|
|
782
|
+
else
|
|
783
|
+
# Content fits - print normally
|
|
784
|
+
print content
|
|
785
|
+
# Pad with spaces if needed to clear old content
|
|
786
|
+
remaining = @width - visible_width
|
|
787
|
+
print " " * remaining if remaining > 0
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def handle_enter
|
|
792
|
+
text = current_value.strip
|
|
793
|
+
|
|
794
|
+
# Prepare display content and data BEFORE clearing
|
|
795
|
+
content_to_display = current_content
|
|
796
|
+
result_text = current_value
|
|
797
|
+
result_files = @files.dup
|
|
798
|
+
|
|
799
|
+
# Handle commands (with or without slash)
|
|
800
|
+
if text.start_with?('/')
|
|
801
|
+
# Check if it's a command (single slash followed by English letters only)
|
|
802
|
+
# Paths like /xxx/xxxx should not be treated as commands
|
|
803
|
+
if text =~ /^\/([a-zA-Z-]+)$/
|
|
804
|
+
case text
|
|
805
|
+
when '/clear'
|
|
806
|
+
add_to_history(result_text) unless result_text.empty?
|
|
807
|
+
clear
|
|
808
|
+
return { action: :clear_output, data: { text: result_text, files: result_files, display: content_to_display } }
|
|
809
|
+
when '/help'
|
|
810
|
+
add_to_history(result_text) unless result_text.empty?
|
|
811
|
+
clear
|
|
812
|
+
return { action: :help, data: { text: result_text, files: result_files, display: content_to_display } }
|
|
813
|
+
when '/exit', '/quit'
|
|
814
|
+
return { action: :exit }
|
|
815
|
+
else
|
|
816
|
+
# Let other commands (like skills) pass through to agent
|
|
817
|
+
# Fall through to submit
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
# If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
|
|
821
|
+
elsif text == '?'
|
|
822
|
+
add_to_history(result_text) unless result_text.empty?
|
|
823
|
+
clear
|
|
824
|
+
return { action: :help, data: { text: result_text, files: result_files, display: content_to_display } }
|
|
825
|
+
elsif text == 'exit' || text == 'quit'
|
|
826
|
+
return { action: :exit }
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
if text.empty? && @files.empty?
|
|
830
|
+
return { action: nil }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
add_to_history(result_text) unless result_text.empty?
|
|
834
|
+
clear
|
|
835
|
+
|
|
836
|
+
{ action: :submit, data: { text: result_text, files: result_files, display: content_to_display } }
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def handle_up_arrow
|
|
840
|
+
if multiline?
|
|
841
|
+
unless cursor_up
|
|
842
|
+
history_prev
|
|
843
|
+
end
|
|
844
|
+
else
|
|
845
|
+
# Navigate history when single line (empty or not)
|
|
846
|
+
history_prev
|
|
847
|
+
end
|
|
848
|
+
{ action: nil }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def handle_down_arrow
|
|
852
|
+
if multiline?
|
|
853
|
+
unless cursor_down
|
|
854
|
+
history_next
|
|
855
|
+
end
|
|
856
|
+
else
|
|
857
|
+
# Navigate history when single line (empty or not)
|
|
858
|
+
history_next
|
|
859
|
+
end
|
|
860
|
+
{ action: nil }
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def handle_ctrl_c
|
|
864
|
+
{ action: :interrupt }
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def handle_ctrl_d
|
|
868
|
+
if has_images?
|
|
869
|
+
if @files.size == 1
|
|
870
|
+
@files.clear
|
|
871
|
+
else
|
|
872
|
+
@files.shift
|
|
873
|
+
end
|
|
874
|
+
clear_tips
|
|
875
|
+
{ action: nil }
|
|
876
|
+
elsif empty?
|
|
877
|
+
{ action: :exit }
|
|
878
|
+
else
|
|
879
|
+
{ action: nil }
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def handle_paste
|
|
884
|
+
pasted = paste_from_clipboard
|
|
885
|
+
if pasted[:type] == :image
|
|
886
|
+
path = pasted[:path]
|
|
887
|
+
mime_type = pasted[:mime_type] || "image/png"
|
|
888
|
+
size = File.exist?(path) ? File.size(path) : 0
|
|
889
|
+
@files << { name: File.basename(path), mime_type: mime_type, path: path, size: size }
|
|
890
|
+
clear_tips
|
|
891
|
+
else
|
|
892
|
+
insert_text(pasted[:text])
|
|
893
|
+
clear_tips
|
|
894
|
+
end
|
|
895
|
+
{ action: nil }
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def insert_text(text)
|
|
899
|
+
return if text.nil? || text.empty?
|
|
900
|
+
|
|
901
|
+
text_lines = text.split(/\r\n|\r|\n/)
|
|
902
|
+
|
|
903
|
+
if text_lines.size > 1
|
|
904
|
+
@paste_counter += 1
|
|
905
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
906
|
+
@paste_placeholders[placeholder] = text
|
|
907
|
+
|
|
908
|
+
chars = current_line.chars
|
|
909
|
+
chars.insert(@cursor_position, *placeholder.chars)
|
|
910
|
+
@lines[@line_index] = chars.join
|
|
911
|
+
@cursor_position += placeholder.length
|
|
912
|
+
else
|
|
913
|
+
chars = current_line.chars
|
|
914
|
+
text.chars.each_with_index do |c, i|
|
|
915
|
+
chars.insert(@cursor_position + i, c)
|
|
916
|
+
end
|
|
917
|
+
@lines[@line_index] = chars.join
|
|
918
|
+
@cursor_position += text.length
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def newline
|
|
923
|
+
chars = current_line.chars
|
|
924
|
+
@lines[@line_index] = chars[0...@cursor_position].join
|
|
925
|
+
@lines.insert(@line_index + 1, chars[@cursor_position..-1]&.join || "")
|
|
926
|
+
@line_index += 1
|
|
927
|
+
@cursor_position = 0
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def cursor_up
|
|
931
|
+
return false if @line_index == 0
|
|
932
|
+
@line_index -= 1
|
|
933
|
+
@cursor_position = [@cursor_position, current_line.chars.length].min
|
|
934
|
+
true
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
def cursor_down
|
|
938
|
+
return false if @line_index >= @lines.size - 1
|
|
939
|
+
@line_index += 1
|
|
940
|
+
@cursor_position = [@cursor_position, current_line.chars.length].min
|
|
941
|
+
true
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def kill_to_end
|
|
945
|
+
chars = current_line.chars
|
|
946
|
+
@lines[@line_index] = chars[0...@cursor_position].join
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def kill_to_start
|
|
950
|
+
chars = current_line.chars
|
|
951
|
+
@lines[@line_index] = chars[@cursor_position..-1]&.join || ""
|
|
952
|
+
@cursor_position = 0
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def kill_word
|
|
956
|
+
chars = current_line.chars
|
|
957
|
+
pos = @cursor_position - 1
|
|
958
|
+
|
|
959
|
+
while pos >= 0 && chars[pos] =~ /\s/
|
|
960
|
+
pos -= 1
|
|
961
|
+
end
|
|
962
|
+
while pos >= 0 && chars[pos] =~ /\S/
|
|
963
|
+
pos -= 1
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
delete_start = pos + 1
|
|
967
|
+
chars.slice!(delete_start...@cursor_position)
|
|
968
|
+
@lines[@line_index] = chars.join
|
|
969
|
+
@cursor_position = delete_start
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def load_history_entry
|
|
973
|
+
return unless @history_index >= 0 && @history_index < @history.size
|
|
974
|
+
entry = @history[@history_index]
|
|
975
|
+
@lines = entry.split("\n")
|
|
976
|
+
@lines = [""] if @lines.empty?
|
|
977
|
+
@line_index = @lines.size - 1
|
|
978
|
+
@cursor_position = current_line.chars.length
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def add_to_history(entry)
|
|
982
|
+
return if @history.last == entry
|
|
983
|
+
@history << entry
|
|
984
|
+
@history = @history.last(MAX_HISTORY) if @history.size > MAX_HISTORY
|
|
985
|
+
save_history
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
def load_history
|
|
989
|
+
return [] unless File.exist?(HISTORY_FILE)
|
|
990
|
+
JSON.parse(File.read(HISTORY_FILE))
|
|
991
|
+
rescue JSON::ParserError
|
|
992
|
+
[]
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def save_history
|
|
996
|
+
File.write(HISTORY_FILE, JSON.pretty_generate(@history))
|
|
997
|
+
rescue => e
|
|
998
|
+
# Silently fail if we can't write history
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
def paste_from_clipboard
|
|
1002
|
+
case RbConfig::CONFIG["host_os"]
|
|
1003
|
+
when /darwin/i
|
|
1004
|
+
paste_from_clipboard_macos
|
|
1005
|
+
when /linux/i
|
|
1006
|
+
paste_from_clipboard_linux
|
|
1007
|
+
else
|
|
1008
|
+
{ type: :text, text: "" }
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def paste_from_clipboard_macos
|
|
1013
|
+
has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
|
|
1014
|
+
|
|
1015
|
+
if has_image
|
|
1016
|
+
temp_dir = Dir.tmpdir
|
|
1017
|
+
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
1018
|
+
temp_path = File.join(temp_dir, temp_filename)
|
|
1019
|
+
|
|
1020
|
+
script = <<~APPLESCRIPT
|
|
1021
|
+
set png_data to the clipboard as «class PNGf»
|
|
1022
|
+
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
1023
|
+
write png_data to the_file
|
|
1024
|
+
close access the_file
|
|
1025
|
+
APPLESCRIPT
|
|
1026
|
+
|
|
1027
|
+
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
1028
|
+
|
|
1029
|
+
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
1030
|
+
return { type: :image, path: temp_path }
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
text = `pbpaste 2>/dev/null`.to_s
|
|
1035
|
+
text = Octo::Utils::Encoding.to_utf8(text)
|
|
1036
|
+
{ type: :text, text: text }
|
|
1037
|
+
rescue => e
|
|
1038
|
+
{ type: :text, text: "" }
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
def paste_from_clipboard_linux
|
|
1042
|
+
if system("which xclip >/dev/null 2>&1")
|
|
1043
|
+
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
1044
|
+
text = Octo::Utils::Encoding.to_utf8(text)
|
|
1045
|
+
{ type: :text, text: text }
|
|
1046
|
+
elsif system("which xsel >/dev/null 2>&1")
|
|
1047
|
+
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
1048
|
+
text = Octo::Utils::Encoding.to_utf8(text)
|
|
1049
|
+
{ type: :text, text: text }
|
|
1050
|
+
else
|
|
1051
|
+
{ type: :text, text: "" }
|
|
1052
|
+
end
|
|
1053
|
+
rescue => e
|
|
1054
|
+
{ type: :text, text: "" }
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def current_line
|
|
1058
|
+
@lines[@line_index] || ""
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
def expand_placeholders(text)
|
|
1062
|
+
super(text, @paste_placeholders)
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
def render_line_with_cursor(line)
|
|
1066
|
+
chars = line.chars
|
|
1067
|
+
before_cursor = chars[0...@cursor_position].join
|
|
1068
|
+
cursor_char = chars[@cursor_position] || " "
|
|
1069
|
+
after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
|
|
1070
|
+
|
|
1071
|
+
"#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# Render a segment of a line with cursor if cursor is in this segment
|
|
1075
|
+
# Applies theme colors to the text
|
|
1076
|
+
# @param line [String] Full line text
|
|
1077
|
+
# @param segment_start [Integer] Start position of segment in line (char index)
|
|
1078
|
+
# @param segment_end [Integer] End position of segment in line (char index)
|
|
1079
|
+
# @return [String] Rendered segment with cursor and theme colors applied
|
|
1080
|
+
def render_line_segment_with_cursor(line, segment_start, segment_end)
|
|
1081
|
+
chars = line.chars
|
|
1082
|
+
segment_chars = chars[segment_start...segment_end]
|
|
1083
|
+
|
|
1084
|
+
# Check if cursor is in this segment
|
|
1085
|
+
if @cursor_position >= segment_start && @cursor_position < segment_end
|
|
1086
|
+
# Cursor is in this segment
|
|
1087
|
+
cursor_pos_in_segment = @cursor_position - segment_start
|
|
1088
|
+
before_cursor = segment_chars[0...cursor_pos_in_segment].join
|
|
1089
|
+
cursor_char = segment_chars[cursor_pos_in_segment] || " "
|
|
1090
|
+
after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
|
|
1091
|
+
|
|
1092
|
+
# Apply theme color to text parts, keep cursor highlight as is
|
|
1093
|
+
"#{theme.format_text(before_cursor, :user)}#{@pastel.on_white(@pastel.black(cursor_char))}#{theme.format_text(after_cursor, :user)}"
|
|
1094
|
+
elsif @cursor_position == segment_end && segment_end == line.length
|
|
1095
|
+
# Cursor is at the very end of the line, show it in last segment
|
|
1096
|
+
segment_text = segment_chars.join
|
|
1097
|
+
"#{theme.format_text(segment_text, :user)}#{@pastel.on_white(@pastel.black(' '))}"
|
|
1098
|
+
else
|
|
1099
|
+
# Cursor is not in this segment, apply theme color
|
|
1100
|
+
theme.format_text(segment_chars.join, :user)
|
|
1101
|
+
end
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
# Render a separator line (ensures it doesn't exceed screen width)
|
|
1105
|
+
# @param row [Integer] Row position to render
|
|
1106
|
+
def render_separator(row)
|
|
1107
|
+
move_cursor(row, 0)
|
|
1108
|
+
# Ensure separator doesn't exceed screen width to prevent wrapping
|
|
1109
|
+
separator_width = [@width, 1].max
|
|
1110
|
+
content = @pastel.dim("─" * separator_width)
|
|
1111
|
+
print content
|
|
1112
|
+
# Clear any remaining space
|
|
1113
|
+
remaining = @width - separator_width
|
|
1114
|
+
print " " * remaining if remaining > 0
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
# Render session bar with wrapping support
|
|
1118
|
+
# @param row [Integer] Starting row position
|
|
1119
|
+
# @return [Integer] Number of rows actually used
|
|
1120
|
+
def render_sessionbar(row)
|
|
1121
|
+
move_cursor(row, 0)
|
|
1122
|
+
|
|
1123
|
+
# If no sessionbar info, just render a separator
|
|
1124
|
+
unless @sessionbar_info[:working_dir]
|
|
1125
|
+
separator_width = [@width, 1].max
|
|
1126
|
+
content = @pastel.dim("─" * separator_width)
|
|
1127
|
+
print content
|
|
1128
|
+
remaining = @width - separator_width
|
|
1129
|
+
print " " * remaining if remaining > 0
|
|
1130
|
+
return 1
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
session_line = build_sessionbar_content
|
|
1134
|
+
|
|
1135
|
+
# IMPORTANT: Always use print_with_padding which handles truncation
|
|
1136
|
+
# to prevent terminal auto-wrap
|
|
1137
|
+
print_with_padding(session_line)
|
|
1138
|
+
1
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# Build the session bar content string
|
|
1142
|
+
# @return [String] Formatted session bar content
|
|
1143
|
+
private def build_sessionbar_content
|
|
1144
|
+
parts = []
|
|
1145
|
+
separator = @pastel.dim(" │ ")
|
|
1146
|
+
|
|
1147
|
+
# Workspace status with animation
|
|
1148
|
+
if @sessionbar_info[:status]
|
|
1149
|
+
status_color = status_color_for(@sessionbar_info[:status])
|
|
1150
|
+
status_indicator = get_status_indicator(@sessionbar_info[:status], status_color)
|
|
1151
|
+
parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Session id — first 8 chars (parity with WebUI #sib-id)
|
|
1155
|
+
if @sessionbar_info[:session_id]
|
|
1156
|
+
sid_short = @sessionbar_info[:session_id].to_s[0, 8]
|
|
1157
|
+
parts << theme.format_text(sid_short, :statusbar_secondary) unless sid_short.empty?
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
# Working directory (shortened if too long)
|
|
1161
|
+
if @sessionbar_info[:working_dir]
|
|
1162
|
+
dir_display = shorten_path(@sessionbar_info[:working_dir])
|
|
1163
|
+
parts << theme.format_text(dir_display, :statusbar_path)
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# Permission mode
|
|
1167
|
+
if @sessionbar_info[:mode]
|
|
1168
|
+
mode_color = mode_color_for(@sessionbar_info[:mode])
|
|
1169
|
+
parts << @pastel.public_send(mode_color, @sessionbar_info[:mode])
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# Model
|
|
1173
|
+
if @sessionbar_info[:model]
|
|
1174
|
+
parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# Tasks count
|
|
1178
|
+
parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
|
|
1179
|
+
|
|
1180
|
+
" " + parts.join(separator)
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# Truncate session bar content to fit within max length
|
|
1184
|
+
# @param content [String] Full session bar content with ANSI codes
|
|
1185
|
+
# @param max_length [Integer] Maximum visible length
|
|
1186
|
+
# @return [String] Truncated content
|
|
1187
|
+
private def truncate_sessionbar_content(content, max_length)
|
|
1188
|
+
# Strip ANSI codes to calculate visible length
|
|
1189
|
+
visible_content = strip_ansi_codes(content)
|
|
1190
|
+
visible_width = calculate_display_width(visible_content)
|
|
1191
|
+
|
|
1192
|
+
return content if visible_width <= max_length
|
|
1193
|
+
|
|
1194
|
+
# Truncate from the end with "..." indicator
|
|
1195
|
+
chars = visible_content.chars
|
|
1196
|
+
current_width = 0
|
|
1197
|
+
truncate_at = 0
|
|
1198
|
+
|
|
1199
|
+
chars.each_with_index do |char, idx|
|
|
1200
|
+
char_width = char_display_width(char)
|
|
1201
|
+
if current_width + char_width + 3 > max_length # Reserve 3 for "..."
|
|
1202
|
+
truncate_at = idx
|
|
1203
|
+
break
|
|
1204
|
+
end
|
|
1205
|
+
current_width += char_width
|
|
1206
|
+
truncate_at = idx + 1
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# For simplicity with ANSI codes, just show first part + ...
|
|
1210
|
+
# This is a simplified version - proper implementation would preserve ANSI codes
|
|
1211
|
+
visible_content[0...truncate_at] + "..."
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Calculate how many rows the session bar will occupy
|
|
1215
|
+
# @return [Integer] Number of rows needed
|
|
1216
|
+
private def calculate_sessionbar_height
|
|
1217
|
+
return 1 unless @sessionbar_info[:working_dir]
|
|
1218
|
+
|
|
1219
|
+
# Session bar always renders on one line (we truncate if needed)
|
|
1220
|
+
1
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
def shorten_path(path)
|
|
1224
|
+
return path if path.length <= 40
|
|
1225
|
+
|
|
1226
|
+
# Replace home directory with ~
|
|
1227
|
+
home = ENV["HOME"]
|
|
1228
|
+
if home && path.start_with?(home)
|
|
1229
|
+
path = path.sub(home, "~")
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
# If still too long, show last parts
|
|
1233
|
+
if path.length > 40
|
|
1234
|
+
parts = path.split("/")
|
|
1235
|
+
if parts.length > 3
|
|
1236
|
+
".../" + parts[-3..-1].join("/")
|
|
1237
|
+
else
|
|
1238
|
+
path[0..40] + "..."
|
|
1239
|
+
end
|
|
1240
|
+
else
|
|
1241
|
+
path
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
def mode_color_for(mode)
|
|
1246
|
+
case mode.to_s
|
|
1247
|
+
when /auto_approve/
|
|
1248
|
+
:magenta
|
|
1249
|
+
when /confirm_safes/
|
|
1250
|
+
:cyan
|
|
1251
|
+
else
|
|
1252
|
+
:white
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
def status_color_for(status)
|
|
1257
|
+
case status.to_s.downcase
|
|
1258
|
+
when 'idle'
|
|
1259
|
+
:cyan # Use darker cyan for idle state
|
|
1260
|
+
when 'working'
|
|
1261
|
+
:yellow # Use yellow to highlight working state
|
|
1262
|
+
else
|
|
1263
|
+
:cyan
|
|
1264
|
+
end
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
def get_status_indicator(status, color)
|
|
1268
|
+
case status.to_s.downcase
|
|
1269
|
+
when 'working'
|
|
1270
|
+
# Update animation frame if enough time has passed
|
|
1271
|
+
now = Time.now
|
|
1272
|
+
if now - @last_animation_update >= 0.3
|
|
1273
|
+
@animation_frame = (@animation_frame + 1) % @working_frames.length
|
|
1274
|
+
@last_animation_update = now
|
|
1275
|
+
end
|
|
1276
|
+
@pastel.public_send(color, @working_frames[@animation_frame])
|
|
1277
|
+
else
|
|
1278
|
+
@pastel.public_send(color, "●") # Idle indicator with same color as text
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
def format_tips(message, type)
|
|
1283
|
+
# Limit message length to prevent line wrapping
|
|
1284
|
+
# Reserve space for prefix like "[Warn] " (about 8 chars) and some margin
|
|
1285
|
+
max_length = @width - 10
|
|
1286
|
+
if message.length > max_length
|
|
1287
|
+
message = message[0...(max_length - 3)] + "..."
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
case type
|
|
1291
|
+
when :warning
|
|
1292
|
+
@pastel.dim("[") + @pastel.yellow("Warn") + @pastel.dim("] ") + @pastel.yellow(message)
|
|
1293
|
+
when :error
|
|
1294
|
+
@pastel.dim("[") + @pastel.red("Error") + @pastel.dim("] ") + @pastel.red(message)
|
|
1295
|
+
else
|
|
1296
|
+
@pastel.dim("[") + @pastel.cyan("Info") + @pastel.dim("] ") + @pastel.white(message)
|
|
1297
|
+
end
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def format_filesize(size)
|
|
1301
|
+
if size < 1024
|
|
1302
|
+
"#{size}B"
|
|
1303
|
+
elsif size < 1024 * 1024
|
|
1304
|
+
"#{(size / 1024.0).round(1)}KB"
|
|
1305
|
+
else
|
|
1306
|
+
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
1307
|
+
end
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
# Format user tip (usage suggestion) with lightbulb icon
|
|
1311
|
+
# @param tip [String] Tip message
|
|
1312
|
+
# @return [String] Formatted tip with styling
|
|
1313
|
+
def format_user_tip(tip)
|
|
1314
|
+
# Limit message length to prevent line wrapping
|
|
1315
|
+
max_length = @width - 5 # Reserve space for icon and margins
|
|
1316
|
+
if tip.length > max_length
|
|
1317
|
+
tip = tip[0...(max_length - 3)] + "..."
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
# Use lightbulb icon and dim cyan color for subtle appearance
|
|
1321
|
+
@pastel.dim(@pastel.cyan("💡 #{tip}"))
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def move_cursor(row, col)
|
|
1325
|
+
print "\e[#{row + 1};#{col + 1}H"
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
def clear_line
|
|
1329
|
+
print "\e[2K"
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def flush
|
|
1333
|
+
$stdout.flush
|
|
1334
|
+
end
|
|
1335
|
+
end
|
|
1336
|
+
end
|
|
1337
|
+
end
|
|
1338
|
+
end
|