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,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_component"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module UI2
|
|
7
|
+
module Components
|
|
8
|
+
# MessageComponent renders user and assistant messages
|
|
9
|
+
class MessageComponent < BaseComponent
|
|
10
|
+
# Render a message
|
|
11
|
+
# @param data [Hash] Message data
|
|
12
|
+
# - :role [String] "user" or "assistant"
|
|
13
|
+
# - :content [String] Message content
|
|
14
|
+
# - :timestamp [Time, nil] Optional timestamp
|
|
15
|
+
# - :files [Array<Hash>] Optional file hashes (for user messages)
|
|
16
|
+
# - :prefix_newline [Boolean] Whether to add newline before message (for system messages)
|
|
17
|
+
# @return [String] Rendered message
|
|
18
|
+
def render(data)
|
|
19
|
+
role = data[:role]
|
|
20
|
+
content = data[:content]
|
|
21
|
+
timestamp = data[:timestamp]
|
|
22
|
+
files = data[:files] || []
|
|
23
|
+
prefix_newline = data.fetch(:prefix_newline, true)
|
|
24
|
+
|
|
25
|
+
case role
|
|
26
|
+
when "user"
|
|
27
|
+
render_user_message(content, timestamp, files)
|
|
28
|
+
when "assistant"
|
|
29
|
+
render_assistant_message(content, timestamp)
|
|
30
|
+
else
|
|
31
|
+
render_system_message(content, timestamp, prefix_newline)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Render user message
|
|
37
|
+
# @param content [String] Message content
|
|
38
|
+
# @param timestamp [Time, nil] Optional timestamp
|
|
39
|
+
# @param files [Array<Hash>] Optional file hashes { name:, mime_type:, ... }
|
|
40
|
+
# @return [String] Rendered message
|
|
41
|
+
def render_user_message(content, timestamp = nil, files = [])
|
|
42
|
+
symbol = format_symbol(:user)
|
|
43
|
+
text = format_text(content, :user)
|
|
44
|
+
time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
|
|
45
|
+
|
|
46
|
+
result = "\n#{symbol} #{text} #{time_str}".rstrip
|
|
47
|
+
|
|
48
|
+
# Append file attachment info if present
|
|
49
|
+
if files && files.any?
|
|
50
|
+
files.each_with_index do |f, idx|
|
|
51
|
+
filename = f[:name] || f["name"] || "file"
|
|
52
|
+
result += "\n" + @pastel.dim(" [File #{idx + 1}] #{filename}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private def format_filesize(bytes)
|
|
60
|
+
if bytes < 1024
|
|
61
|
+
"#{bytes}b"
|
|
62
|
+
elsif bytes < 1024 * 1024
|
|
63
|
+
"#{(bytes / 1024.0).round(1)}kb"
|
|
64
|
+
else
|
|
65
|
+
"#{(bytes / (1024.0 * 1024)).round(1)}mb"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Render assistant message
|
|
70
|
+
# @param content [String] Message content
|
|
71
|
+
# @param timestamp [Time, nil] Optional timestamp
|
|
72
|
+
# @return [String] Rendered message
|
|
73
|
+
def render_assistant_message(content, timestamp = nil)
|
|
74
|
+
return "" if content.nil? || content.empty?
|
|
75
|
+
|
|
76
|
+
symbol = format_symbol(:assistant)
|
|
77
|
+
text = format_text(content, :assistant)
|
|
78
|
+
time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
|
|
79
|
+
|
|
80
|
+
"\n#{symbol} #{text} #{time_str}".rstrip
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Render system message
|
|
84
|
+
# @param content [String] Message content
|
|
85
|
+
# @param timestamp [Time, nil] Optional timestamp
|
|
86
|
+
# @param prefix_newline [Boolean] Whether to add newline before message
|
|
87
|
+
# @return [String] Rendered message
|
|
88
|
+
private def render_system_message(content, timestamp = nil, prefix_newline = true)
|
|
89
|
+
symbol = format_symbol(:info)
|
|
90
|
+
text = format_text(content, :info)
|
|
91
|
+
time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
|
|
92
|
+
|
|
93
|
+
prefix = prefix_newline ? "\n" : ""
|
|
94
|
+
"#{prefix}#{symbol} #{text} #{time_str}".rstrip
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'tty-prompt'
|
|
5
|
+
require_relative 'base_component'
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
module UI2
|
|
9
|
+
module Components
|
|
10
|
+
# ModalComponent - Displays a centered modal dialog with form fields
|
|
11
|
+
class ModalComponent < BaseComponent
|
|
12
|
+
attr_reader :width, :height
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
super
|
|
16
|
+
@width = 70
|
|
17
|
+
@height = 16
|
|
18
|
+
@title = ""
|
|
19
|
+
@fields = []
|
|
20
|
+
@choices = []
|
|
21
|
+
@values = {}
|
|
22
|
+
@selected_index = 0
|
|
23
|
+
@mode = :form
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Configure and show the modal
|
|
27
|
+
# @param title [String] Modal title
|
|
28
|
+
# @param fields [Array<Hash>] Field definitions (for form mode)
|
|
29
|
+
# @param choices [Array<Hash>] Choice definitions (for menu mode)
|
|
30
|
+
# Example: [{ name: "Option 1", value: :opt1 }, { name: "---", disabled: true }]
|
|
31
|
+
# @param validator [Proc, nil] Optional validation callback that receives values hash
|
|
32
|
+
# Should return { success: true } or { success: false, error: "message" }
|
|
33
|
+
# @param on_close [Proc, nil] Optional callback to execute when modal closes (e.g., to re-render screen)
|
|
34
|
+
# @return [Hash, nil] Hash of field values or selected value, or nil if cancelled
|
|
35
|
+
def show(title:, fields: nil, choices: nil, validator: nil, on_close: nil)
|
|
36
|
+
@title = title
|
|
37
|
+
@mode = choices ? :menu : :form
|
|
38
|
+
@fields = fields || []
|
|
39
|
+
@choices = choices || []
|
|
40
|
+
@values = {}
|
|
41
|
+
@error_message = nil
|
|
42
|
+
@selected_index = 0
|
|
43
|
+
|
|
44
|
+
# For menu mode, find first non-disabled choice
|
|
45
|
+
if @mode == :menu
|
|
46
|
+
@selected_index = @choices.index { |c| !c[:disabled] } || 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Adjust height based on mode
|
|
50
|
+
if @mode == :menu
|
|
51
|
+
visible_items = [@choices.length, 15].min
|
|
52
|
+
@height = visible_items + 4 # +4 for title, borders, and instructions
|
|
53
|
+
else
|
|
54
|
+
# Form mode - adjust height based on number of fields
|
|
55
|
+
# Each field takes 2 rows (label + input)
|
|
56
|
+
# +3 for title and top border
|
|
57
|
+
# +5 for error message area, buttons, and bottom border
|
|
58
|
+
@height = (@fields.length * 2) + 3 + 5
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get terminal size
|
|
62
|
+
term_height, term_width = IO.console.winsize
|
|
63
|
+
|
|
64
|
+
# Calculate modal position (centered)
|
|
65
|
+
start_row = [(term_height - @height) / 2, 1].max
|
|
66
|
+
start_col = [(term_width - @width) / 2, 1].max
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
if @mode == :menu
|
|
70
|
+
# Menu mode - show choices and handle selection
|
|
71
|
+
return show_menu_mode(start_row, start_col)
|
|
72
|
+
else
|
|
73
|
+
# Form mode - collect field inputs
|
|
74
|
+
return show_form_mode(start_row, start_col, validator)
|
|
75
|
+
end
|
|
76
|
+
ensure
|
|
77
|
+
# Clear modal area
|
|
78
|
+
clear_modal(start_row, start_col)
|
|
79
|
+
# Call on_close callback if provided (e.g., to re-render screen)
|
|
80
|
+
on_close&.call
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Render method (required by BaseComponent, not used for modal)
|
|
85
|
+
def render(data)
|
|
86
|
+
# Modal uses interactive show() method instead
|
|
87
|
+
""
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Show menu mode
|
|
92
|
+
private def show_menu_mode(start_row, start_col)
|
|
93
|
+
loop do
|
|
94
|
+
# Draw menu
|
|
95
|
+
draw_modal(start_row, start_col)
|
|
96
|
+
draw_menu_choices(start_row + 2, start_col)
|
|
97
|
+
draw_menu_instructions(start_row + @height - 2, start_col)
|
|
98
|
+
|
|
99
|
+
# Read input
|
|
100
|
+
char = STDIN.getch
|
|
101
|
+
|
|
102
|
+
case char
|
|
103
|
+
when "\r", "\n" # Enter - select current choice
|
|
104
|
+
selected = @choices[@selected_index]
|
|
105
|
+
return nil if selected.nil? || selected[:disabled]
|
|
106
|
+
print "\e[?25l" # Hide cursor
|
|
107
|
+
return selected[:value]
|
|
108
|
+
when "\e" # Escape sequence
|
|
109
|
+
seq = STDIN.read_nonblock(2) rescue ''
|
|
110
|
+
if seq.empty?
|
|
111
|
+
# Just Esc key - cancel
|
|
112
|
+
print "\e[?25l"
|
|
113
|
+
return nil
|
|
114
|
+
elsif seq == '[A' # Up arrow
|
|
115
|
+
move_menu_selection(-1)
|
|
116
|
+
elsif seq == '[B' # Down arrow
|
|
117
|
+
move_menu_selection(1)
|
|
118
|
+
end
|
|
119
|
+
when "\u0003" # Ctrl+C
|
|
120
|
+
print "\e[?25l"
|
|
121
|
+
return nil
|
|
122
|
+
when 'k', 'K' # Vim-style up
|
|
123
|
+
move_menu_selection(-1)
|
|
124
|
+
when 'j', 'J' # Vim-style down
|
|
125
|
+
move_menu_selection(1)
|
|
126
|
+
when 'q', 'Q' # Quit
|
|
127
|
+
print "\e[?25l"
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Show form mode
|
|
134
|
+
private def show_form_mode(start_row, start_col, validator)
|
|
135
|
+
loop do
|
|
136
|
+
# Draw modal background and border
|
|
137
|
+
draw_modal(start_row, start_col)
|
|
138
|
+
|
|
139
|
+
# Draw error message if present
|
|
140
|
+
if @error_message
|
|
141
|
+
draw_error_message(start_row + @height - 5, start_col)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Draw instructions
|
|
145
|
+
draw_buttons(start_row + @height - 3, start_col)
|
|
146
|
+
|
|
147
|
+
# Collect input for each field
|
|
148
|
+
current_row = start_row + 3
|
|
149
|
+
@fields.each do |field|
|
|
150
|
+
# Use previously entered value as default if validation failed
|
|
151
|
+
field_with_previous = field.dup
|
|
152
|
+
field_with_previous[:default] = @values[field[:name]] || field[:default]
|
|
153
|
+
|
|
154
|
+
value = collect_field_input(field_with_previous, current_row, start_col)
|
|
155
|
+
if value == :cancelled
|
|
156
|
+
print "\e[?25l" # Hide cursor
|
|
157
|
+
return nil # User pressed Esc
|
|
158
|
+
end
|
|
159
|
+
@values[field[:name]] = value
|
|
160
|
+
current_row += 2
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# All fields collected - validate if validator provided
|
|
164
|
+
if validator
|
|
165
|
+
# Show "Testing..." message
|
|
166
|
+
testing_row = start_row + @height - 5
|
|
167
|
+
testing_col = start_col + 3
|
|
168
|
+
print "\e[#{testing_row};#{testing_col}H\e[K"
|
|
169
|
+
print "\e[#{testing_row + 1};#{testing_col}H\e[K"
|
|
170
|
+
print @pastel.cyan("⏳ Testing connection...")
|
|
171
|
+
STDOUT.flush
|
|
172
|
+
|
|
173
|
+
validation_result = validator.call(@values)
|
|
174
|
+
|
|
175
|
+
# Clear testing messages
|
|
176
|
+
print "\e[#{testing_row};#{testing_col}H\e[K"
|
|
177
|
+
print "\e[#{testing_row + 1};#{testing_col}H\e[K"
|
|
178
|
+
|
|
179
|
+
if validation_result[:success]
|
|
180
|
+
# Validation passed - hide cursor and return values
|
|
181
|
+
print "\e[?25l"
|
|
182
|
+
return @values
|
|
183
|
+
else
|
|
184
|
+
# Validation failed - show error and loop again to let user correct input
|
|
185
|
+
@error_message = validation_result[:error] || "Validation failed"
|
|
186
|
+
# Don't clear modal - just loop again to redraw with error message
|
|
187
|
+
# This prevents the modal from flickering
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
# No validator - return immediately
|
|
191
|
+
print "\e[?25l"
|
|
192
|
+
return @values
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Draw the modal background and border
|
|
198
|
+
private def draw_modal(start_row, start_col)
|
|
199
|
+
# Use theme colors - cyan for border, bright_cyan for title
|
|
200
|
+
reset = "\e[0m"
|
|
201
|
+
|
|
202
|
+
# Draw box with border
|
|
203
|
+
@height.times do |i|
|
|
204
|
+
print "\e[#{start_row + i};#{start_col}H"
|
|
205
|
+
|
|
206
|
+
if i == 0
|
|
207
|
+
# Top border with title
|
|
208
|
+
title_text = " #{@title} "
|
|
209
|
+
padding = (@width - title_text.length - 2) / 2
|
|
210
|
+
remaining = @width - padding - title_text.length - 2
|
|
211
|
+
border_line = @pastel.cyan("┌" + "─" * padding)
|
|
212
|
+
title_part = @pastel.bright_cyan(title_text)
|
|
213
|
+
border_rest = @pastel.cyan("─" * remaining + "┐")
|
|
214
|
+
print border_line + title_part + border_rest
|
|
215
|
+
elsif i == @height - 1
|
|
216
|
+
# Bottom border
|
|
217
|
+
print @pastel.cyan("└" + "─" * (@width - 2) + "┘")
|
|
218
|
+
else
|
|
219
|
+
# Side borders with background
|
|
220
|
+
left_border = @pastel.cyan("│")
|
|
221
|
+
right_border = @pastel.cyan("│")
|
|
222
|
+
print left_border + " " * (@width - 2) + right_border
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Collect input for a single field
|
|
228
|
+
private def collect_field_input(field, row, col)
|
|
229
|
+
require 'io/console'
|
|
230
|
+
|
|
231
|
+
label_text = @pastel.white(field[:label])
|
|
232
|
+
|
|
233
|
+
# Draw field label
|
|
234
|
+
print "\e[#{row};#{col + 2}H#{label_text}"
|
|
235
|
+
|
|
236
|
+
# Input field position
|
|
237
|
+
input_row = row + 1
|
|
238
|
+
input_col = col + 4
|
|
239
|
+
input_width = @width - 8
|
|
240
|
+
|
|
241
|
+
# Initialize input buffer with default value
|
|
242
|
+
buffer = field[:default].to_s.dup
|
|
243
|
+
cursor_pos = buffer.length
|
|
244
|
+
placeholder = "Press Enter to keep current"
|
|
245
|
+
|
|
246
|
+
# Show cursor for input
|
|
247
|
+
print "\e[?25h"
|
|
248
|
+
|
|
249
|
+
loop do
|
|
250
|
+
# Draw input field with cursor or placeholder
|
|
251
|
+
if buffer.empty?
|
|
252
|
+
# Show placeholder in dim gray
|
|
253
|
+
display_text = @pastel.dim(placeholder)
|
|
254
|
+
elsif field[:mask]
|
|
255
|
+
# Show masked input - limit display length to prevent overflow
|
|
256
|
+
mask_length = [buffer.length, input_width].min
|
|
257
|
+
display_text = @pastel.cyan('*' * mask_length)
|
|
258
|
+
else
|
|
259
|
+
# Show normal input
|
|
260
|
+
display_text = @pastel.cyan(buffer)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Clear line and draw input
|
|
264
|
+
print "\e[#{input_row};#{input_col}H\e[K"
|
|
265
|
+
print display_text
|
|
266
|
+
|
|
267
|
+
# Position cursor and ensure it's visible
|
|
268
|
+
visible_cursor_pos = [cursor_pos, input_width - 1].min
|
|
269
|
+
print "\e[#{input_row};#{input_col + visible_cursor_pos}H"
|
|
270
|
+
STDOUT.flush
|
|
271
|
+
|
|
272
|
+
# Read character
|
|
273
|
+
char = STDIN.getch
|
|
274
|
+
|
|
275
|
+
case char
|
|
276
|
+
when "\r", "\n" # Enter - confirm input
|
|
277
|
+
# Clear placeholder if input is empty
|
|
278
|
+
if buffer.empty?
|
|
279
|
+
print "\e[#{input_row};#{input_col}H\e[K"
|
|
280
|
+
end
|
|
281
|
+
# Don't hide cursor here - next field will reuse it
|
|
282
|
+
return buffer
|
|
283
|
+
when "\e" # Escape sequence
|
|
284
|
+
seq = STDIN.read_nonblock(2) rescue ''
|
|
285
|
+
if seq.empty?
|
|
286
|
+
# Just Esc key - cancel (hide cursor when cancelling)
|
|
287
|
+
print "\e[?25l"
|
|
288
|
+
return :cancelled
|
|
289
|
+
elsif seq == '[C' # Right arrow
|
|
290
|
+
cursor_pos = [cursor_pos + 1, buffer.length].min
|
|
291
|
+
elsif seq == '[D' # Left arrow
|
|
292
|
+
cursor_pos = [cursor_pos - 1, 0].max
|
|
293
|
+
elsif seq == '[H' # Home
|
|
294
|
+
cursor_pos = 0
|
|
295
|
+
elsif seq == '[F' # End
|
|
296
|
+
cursor_pos = buffer.length
|
|
297
|
+
end
|
|
298
|
+
when "\u007F", "\b" # Backspace
|
|
299
|
+
if cursor_pos > 0
|
|
300
|
+
buffer[cursor_pos - 1] = ''
|
|
301
|
+
cursor_pos -= 1
|
|
302
|
+
end
|
|
303
|
+
when "\u0003" # Ctrl+C (hide cursor when cancelling)
|
|
304
|
+
print "\e[?25l"
|
|
305
|
+
return :cancelled
|
|
306
|
+
when "\u0015" # Ctrl+U - clear line
|
|
307
|
+
buffer = ''
|
|
308
|
+
cursor_pos = 0
|
|
309
|
+
else
|
|
310
|
+
# Regular character input
|
|
311
|
+
if char.ord >= 32 && char.ord < 127
|
|
312
|
+
buffer.insert(cursor_pos, char)
|
|
313
|
+
cursor_pos += 1
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Draw error message
|
|
320
|
+
private def draw_error_message(row, col)
|
|
321
|
+
return if @error_message.nil? || @error_message.empty?
|
|
322
|
+
|
|
323
|
+
max_width = @width - 6
|
|
324
|
+
# Truncate error message if too long
|
|
325
|
+
error_text = @error_message.length > max_width ? @error_message[0..max_width-4] + "..." : @error_message
|
|
326
|
+
error_col = col + 3
|
|
327
|
+
|
|
328
|
+
# Clear the line first to prevent leftover characters
|
|
329
|
+
print "\e[#{row};#{error_col}H\e[K"
|
|
330
|
+
formatted = @pastel.red("⚠ #{error_text}")
|
|
331
|
+
print formatted
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Draw confirmation buttons
|
|
335
|
+
private def draw_buttons(row, col)
|
|
336
|
+
# Show instructions at bottom of modal
|
|
337
|
+
buttons_text = "Press Enter after each field • Press Esc to cancel"
|
|
338
|
+
button_col = col + (@width - buttons_text.length) / 2
|
|
339
|
+
|
|
340
|
+
formatted = @pastel.dim(buttons_text)
|
|
341
|
+
print "\e[#{row};#{button_col}H#{formatted}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Clear the modal area
|
|
345
|
+
private def clear_modal(start_row, start_col)
|
|
346
|
+
@height.times do |i|
|
|
347
|
+
print "\e[#{start_row + i};#{start_col}H#{' ' * @width}"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Draw menu choices
|
|
352
|
+
private def draw_menu_choices(start_row, start_col)
|
|
353
|
+
@choices.each_with_index do |choice, index|
|
|
354
|
+
row = start_row + index
|
|
355
|
+
draw_menu_choice(choice, index, row, start_col)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Draw a single menu choice
|
|
360
|
+
private def draw_menu_choice(choice, index, row, col)
|
|
361
|
+
is_selected = (index == @selected_index)
|
|
362
|
+
|
|
363
|
+
# Calculate available width for text
|
|
364
|
+
text_width = @width - 6 # Account for borders, marker, and padding
|
|
365
|
+
|
|
366
|
+
# Prepare choice text
|
|
367
|
+
choice_text = choice[:name] || ""
|
|
368
|
+
if choice_text.length > text_width
|
|
369
|
+
choice_text = choice_text[0..text_width-4] + "..."
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Prepare marker and styling
|
|
373
|
+
if choice[:disabled]
|
|
374
|
+
# Disabled choice (like separator)
|
|
375
|
+
marker = " "
|
|
376
|
+
text = @pastel.dim(choice_text)
|
|
377
|
+
elsif is_selected
|
|
378
|
+
marker = @pastel.bright_cyan("→ ")
|
|
379
|
+
text = @pastel.bright_white(choice_text)
|
|
380
|
+
else
|
|
381
|
+
marker = " "
|
|
382
|
+
text = @pastel.white(choice_text)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Draw line
|
|
386
|
+
print "\e[#{row};#{col}H"
|
|
387
|
+
print @pastel.cyan("│") + marker + text
|
|
388
|
+
|
|
389
|
+
# Pad to width
|
|
390
|
+
used_length = choice_text.length + 2 # marker is 2 chars
|
|
391
|
+
padding_needed = @width - used_length - 2 # -2 for borders
|
|
392
|
+
print " " * padding_needed + @pastel.cyan("│")
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Draw menu instructions
|
|
396
|
+
private def draw_menu_instructions(row, col)
|
|
397
|
+
instructions = "↑↓/jk: Navigate • Enter: Select • Esc/q: Cancel"
|
|
398
|
+
padding = (@width - instructions.length - 2) / 2
|
|
399
|
+
remaining = @width - padding - instructions.length - 2
|
|
400
|
+
|
|
401
|
+
print "\e[#{row};#{col}H"
|
|
402
|
+
border_line = @pastel.cyan("└" + "─" * padding)
|
|
403
|
+
instr_part = @pastel.dim(instructions)
|
|
404
|
+
border_rest = @pastel.cyan("─" * remaining + "┘")
|
|
405
|
+
print border_line + instr_part + border_rest
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Move menu selection up or down
|
|
409
|
+
private def move_menu_selection(direction)
|
|
410
|
+
loop do
|
|
411
|
+
@selected_index = (@selected_index + direction) % @choices.length
|
|
412
|
+
# Skip disabled choices
|
|
413
|
+
break unless @choices[@selected_index][:disabled]
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module UI2
|
|
7
|
+
module Components
|
|
8
|
+
# TodoArea displays active todos above the separator line
|
|
9
|
+
class TodoArea
|
|
10
|
+
attr_accessor :height
|
|
11
|
+
attr_reader :todos
|
|
12
|
+
|
|
13
|
+
MAX_DISPLAY_TASKS = 3 # Show current + next 2 tasks
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@todos = []
|
|
17
|
+
@pending_todos = []
|
|
18
|
+
@completed_count = 0
|
|
19
|
+
@total_count = 0
|
|
20
|
+
@pastel = Pastel.new
|
|
21
|
+
@width = TTY::Screen.width
|
|
22
|
+
@height = 0 # Dynamic height based on todos
|
|
23
|
+
@hidden = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Update todos list
|
|
27
|
+
# @param todos [Array<Hash>] Array of todo items
|
|
28
|
+
def update(todos)
|
|
29
|
+
@todos = todos || []
|
|
30
|
+
@pending_todos = @todos.select { |t| t[:status] == "pending" }
|
|
31
|
+
@completed_count = @todos.count { |t| t[:status] == "completed" }
|
|
32
|
+
@total_count = @todos.size
|
|
33
|
+
|
|
34
|
+
recalc_height
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Hide the area without discarding todos data; show again to restore.
|
|
38
|
+
def hide
|
|
39
|
+
return if @hidden
|
|
40
|
+
@hidden = true
|
|
41
|
+
@height = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show
|
|
45
|
+
return unless @hidden
|
|
46
|
+
@hidden = false
|
|
47
|
+
recalc_height
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private def recalc_height
|
|
51
|
+
if @hidden || @pending_todos.empty?
|
|
52
|
+
@height = 0
|
|
53
|
+
else
|
|
54
|
+
@height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if there are todos to display
|
|
59
|
+
def visible?
|
|
60
|
+
@height > 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Render todos area
|
|
64
|
+
# @param start_row [Integer] Screen row to start rendering
|
|
65
|
+
def render(start_row:)
|
|
66
|
+
return unless visible?
|
|
67
|
+
|
|
68
|
+
update_width
|
|
69
|
+
|
|
70
|
+
# Render each task on separate line
|
|
71
|
+
tasks_to_show = @pending_todos.take(MAX_DISPLAY_TASKS)
|
|
72
|
+
|
|
73
|
+
tasks_to_show.each_with_index do |task, index|
|
|
74
|
+
move_cursor(start_row + index, 0)
|
|
75
|
+
|
|
76
|
+
# Build the line content
|
|
77
|
+
line_content = if index == 0
|
|
78
|
+
# First line: Task [2/4]: #3 - Current task description
|
|
79
|
+
progress = "#{@completed_count}/#{@total_count}"
|
|
80
|
+
prefix = "Task [#{progress}]: "
|
|
81
|
+
task_text = "##{task[:id]} - #{task[:task]}"
|
|
82
|
+
available_width = @width - prefix.length - 2
|
|
83
|
+
truncated_task = truncate_text(task_text, available_width)
|
|
84
|
+
|
|
85
|
+
"#{@pastel.cyan(prefix)}#{truncated_task}"
|
|
86
|
+
else
|
|
87
|
+
# Subsequent lines: -> Next: #4 - Next task description
|
|
88
|
+
label = index == 1 ? "Next" : "After"
|
|
89
|
+
prefix = "-> #{label}: "
|
|
90
|
+
task_text = "##{task[:id]} - #{task[:task]}"
|
|
91
|
+
available_width = @width - prefix.length - 2
|
|
92
|
+
truncated_task = truncate_text(task_text, available_width)
|
|
93
|
+
|
|
94
|
+
"#{@pastel.dim(prefix)}#{@pastel.dim(truncated_task)}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Use carriage return and print content directly (overwrite existing content)
|
|
98
|
+
print "\r#{line_content}"
|
|
99
|
+
# Clear any remaining characters from previous render if line is shorter
|
|
100
|
+
clear_to_end_of_line
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
flush
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Clear the area
|
|
107
|
+
def clear
|
|
108
|
+
@todos = []
|
|
109
|
+
@pending_todos = []
|
|
110
|
+
@completed_count = 0
|
|
111
|
+
@total_count = 0
|
|
112
|
+
@height = 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Truncate text to fit width
|
|
117
|
+
def truncate_text(text, max_width)
|
|
118
|
+
return "" if text.nil?
|
|
119
|
+
|
|
120
|
+
if text.length > max_width
|
|
121
|
+
text[0...(max_width - 3)] + "..."
|
|
122
|
+
else
|
|
123
|
+
text
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Update width on resize
|
|
128
|
+
def update_width
|
|
129
|
+
@width = TTY::Screen.width
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Move cursor to position
|
|
133
|
+
def move_cursor(row, col)
|
|
134
|
+
print "\e[#{row + 1};#{col + 1}H"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Clear from cursor to end of line
|
|
138
|
+
def clear_to_end_of_line
|
|
139
|
+
print "\e[0K"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Flush output
|
|
143
|
+
def flush
|
|
144
|
+
$stdout.flush
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|