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,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../utils/limit_stack"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module UI2
|
|
7
|
+
# OutputBuffer manages the logical sequence of rendered output lines.
|
|
8
|
+
#
|
|
9
|
+
# It replaces the scattered state that used to live across
|
|
10
|
+
# LayoutManager (@output_buffer + @output_row) and UIController
|
|
11
|
+
# (@progress_message / "last line" assumptions).
|
|
12
|
+
#
|
|
13
|
+
# Core concepts:
|
|
14
|
+
#
|
|
15
|
+
# - Every append returns an +id+. Callers can later +replace(id, ...)+
|
|
16
|
+
# or +remove(id)+ that exact entry without relying on "the last line".
|
|
17
|
+
# - Each entry tracks whether it has been "committed" to the terminal
|
|
18
|
+
# scrollback (i.e. scrolled off the top of the visible window by a
|
|
19
|
+
# native terminal \n). Committed entries are NEVER re-drawn from the
|
|
20
|
+
# buffer again — this is what prevents the classic "scroll up shows
|
|
21
|
+
# a duplicated line" bug.
|
|
22
|
+
# - Entries may contain multi-line content (already wrapped). Each entry
|
|
23
|
+
# stores its visual line count so the renderer can compute exact rows
|
|
24
|
+
# to clear when replacing or removing.
|
|
25
|
+
#
|
|
26
|
+
# The buffer itself does NOT talk to the terminal. It is a pure data
|
|
27
|
+
# structure; a renderer (LayoutManager) consumes it through the
|
|
28
|
+
# snapshot APIs: +visible_entries+, +entry_by_id+, +tail_lines+.
|
|
29
|
+
class OutputBuffer
|
|
30
|
+
# A single logical output entry.
|
|
31
|
+
#
|
|
32
|
+
# @!attribute id [Integer] Monotonic id, unique within the buffer
|
|
33
|
+
# @!attribute lines [Array<String>] Rendered (already-wrapped) visual lines
|
|
34
|
+
# @!attribute kind [Symbol] :text | :progress | :system (hint for renderer)
|
|
35
|
+
# @!attribute committed [Boolean] True once pushed into terminal scrollback
|
|
36
|
+
Entry = Struct.new(:id, :lines, :kind, :committed, :committed_line_offset, keyword_init: true) do
|
|
37
|
+
# Visual row count this entry currently OCCUPIES on screen. Once a
|
|
38
|
+
# prefix of the entry's lines has been pushed into scrollback by
|
|
39
|
+
# a scroll+partial-commit, those prefix rows are no longer on
|
|
40
|
+
# screen — so height drops accordingly. When +committed+ flips to
|
|
41
|
+
# true the entry is considered fully off-screen and height is 0.
|
|
42
|
+
def height
|
|
43
|
+
return 0 if committed
|
|
44
|
+
lines.length - (committed_line_offset || 0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The currently on-screen lines of this entry (lines that haven't
|
|
48
|
+
# been pushed to scrollback yet). Returns [] once fully committed.
|
|
49
|
+
def visible_lines
|
|
50
|
+
return [] if committed
|
|
51
|
+
off = committed_line_offset || 0
|
|
52
|
+
off.zero? ? lines : lines[off..] || []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_s
|
|
56
|
+
lines.join("\n")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
DEFAULT_MAX_ENTRIES = 2000
|
|
61
|
+
|
|
62
|
+
attr_reader :entries
|
|
63
|
+
|
|
64
|
+
def initialize(max_entries: DEFAULT_MAX_ENTRIES)
|
|
65
|
+
@entries = [] # Array<Entry> in insertion order
|
|
66
|
+
@index = {} # id => Entry (fast lookup)
|
|
67
|
+
@next_id = 1
|
|
68
|
+
@max_entries = max_entries
|
|
69
|
+
@mutex = Mutex.new
|
|
70
|
+
# Monotonic counter incremented every time the buffer changes.
|
|
71
|
+
# Renderers can compare this against a saved version to decide
|
|
72
|
+
# whether their cached screen image is still valid.
|
|
73
|
+
@version = 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Append a new entry. +content+ may be a String (may include \n) or
|
|
77
|
+
# an Array<String> of already-split lines.
|
|
78
|
+
#
|
|
79
|
+
# @param content [String, Array<String>]
|
|
80
|
+
# @param kind [Symbol] :text (default), :progress, :system
|
|
81
|
+
# @return [Integer] id of the newly created entry
|
|
82
|
+
def append(content, kind: :text)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
lines = normalize_lines(content)
|
|
85
|
+
entry = Entry.new(id: next_id!, lines: lines, kind: kind, committed: false, committed_line_offset: 0)
|
|
86
|
+
@entries << entry
|
|
87
|
+
@index[entry.id] = entry
|
|
88
|
+
trim_if_needed
|
|
89
|
+
bump_version
|
|
90
|
+
entry.id
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Replace an existing entry's content. If the id no longer exists
|
|
95
|
+
# (e.g. the entry was trimmed or already committed and recycled),
|
|
96
|
+
# this is a no-op and returns nil.
|
|
97
|
+
#
|
|
98
|
+
# Replacing a committed entry is silently ignored — committed content
|
|
99
|
+
# lives in terminal scrollback and cannot be edited in place. Same
|
|
100
|
+
# for an entry whose prefix has been partial-committed: the prefix
|
|
101
|
+
# is already in scrollback and replacing the entry would either
|
|
102
|
+
# strand those lines (if shorter) or duplicate them (if longer).
|
|
103
|
+
#
|
|
104
|
+
# @param id [Integer]
|
|
105
|
+
# @param content [String, Array<String>]
|
|
106
|
+
# @return [Integer, nil] Old visible height if replaced, nil if no-op
|
|
107
|
+
def replace(id, content)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
entry = @index[id]
|
|
110
|
+
return nil unless entry
|
|
111
|
+
return nil if entry.committed
|
|
112
|
+
return nil if (entry.committed_line_offset || 0) > 0
|
|
113
|
+
|
|
114
|
+
old_height = entry.height
|
|
115
|
+
entry.lines = normalize_lines(content)
|
|
116
|
+
bump_version
|
|
117
|
+
old_height
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Remove an entry. Committed entries cannot be removed (they are in
|
|
122
|
+
# terminal scrollback). Partially-committed entries also cannot be
|
|
123
|
+
# removed — their prefix is frozen in scrollback. Returns the
|
|
124
|
+
# removed Entry, or nil if no-op.
|
|
125
|
+
#
|
|
126
|
+
# @param id [Integer]
|
|
127
|
+
# @return [Entry, nil]
|
|
128
|
+
def remove(id)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
entry = @index[id]
|
|
131
|
+
return nil unless entry
|
|
132
|
+
return nil if entry.committed
|
|
133
|
+
return nil if (entry.committed_line_offset || 0) > 0
|
|
134
|
+
|
|
135
|
+
@entries.delete(entry)
|
|
136
|
+
@index.delete(id)
|
|
137
|
+
bump_version
|
|
138
|
+
entry
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Mark an entry (and every older live entry) as committed to terminal
|
|
143
|
+
# scrollback. Called by the renderer after it has emitted a native \n
|
|
144
|
+
# that scrolled the top-of-screen row off into scrollback.
|
|
145
|
+
#
|
|
146
|
+
# Committing always flows from oldest → newest: if entry X is
|
|
147
|
+
# committed, every entry older than X must also be committed, because
|
|
148
|
+
# they have already scrolled past X on the screen.
|
|
149
|
+
#
|
|
150
|
+
# @param id [Integer]
|
|
151
|
+
def commit_through(id)
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
committed_any = false
|
|
154
|
+
@entries.each do |e|
|
|
155
|
+
break if e.id > id
|
|
156
|
+
unless e.committed
|
|
157
|
+
e.committed = true
|
|
158
|
+
committed_any = true
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
bump_version if committed_any
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Commit the oldest N VISUAL rows. Used when the renderer scrolls N
|
|
166
|
+
# lines off the top via native \n. Commits are precise at the visual
|
|
167
|
+
# row granularity (even mid-entry): if the oldest live entry is
|
|
168
|
+
# multi-line and only its prefix has scrolled off, that prefix is
|
|
169
|
+
# recorded in +committed_line_offset+ and only the still-visible
|
|
170
|
+
# suffix remains eligible for future repaints.
|
|
171
|
+
#
|
|
172
|
+
# This is the critical invariant for preventing the "scroll up to
|
|
173
|
+
# see a line already in scrollback, then render_output_from_buffer
|
|
174
|
+
# repaints it again on screen" duplicate-output regression: every
|
|
175
|
+
# visual row that went into terminal scrollback MUST be removed
|
|
176
|
+
# from the buffer's pool of repaintable live rows, regardless of
|
|
177
|
+
# whether it sat alone in a 1-line entry or at the top of a 10-line
|
|
178
|
+
# entry.
|
|
179
|
+
#
|
|
180
|
+
# @param line_count [Integer] Number of visual lines pushed to scrollback
|
|
181
|
+
# @return [Integer] Number of entries NEWLY marked fully committed
|
|
182
|
+
# (partial commits on an entry do NOT count toward this total —
|
|
183
|
+
# callers use the return value only as a debug hint, not for row
|
|
184
|
+
# bookkeeping).
|
|
185
|
+
def commit_oldest_lines(line_count)
|
|
186
|
+
return 0 if line_count <= 0
|
|
187
|
+
|
|
188
|
+
@mutex.synchronize do
|
|
189
|
+
remaining = line_count
|
|
190
|
+
committed = 0
|
|
191
|
+
changed = false
|
|
192
|
+
@entries.each do |e|
|
|
193
|
+
break if remaining <= 0
|
|
194
|
+
next if e.committed
|
|
195
|
+
|
|
196
|
+
h = e.height
|
|
197
|
+
if h <= remaining
|
|
198
|
+
# Full scroll-off of this entry's remaining visible rows.
|
|
199
|
+
e.committed = true
|
|
200
|
+
e.committed_line_offset = e.lines.length # normalize
|
|
201
|
+
remaining -= h
|
|
202
|
+
committed += 1
|
|
203
|
+
changed = true
|
|
204
|
+
else
|
|
205
|
+
# Partial scroll: record the new offset and stop (there are
|
|
206
|
+
# still visible rows of this entry on screen).
|
|
207
|
+
e.committed_line_offset = (e.committed_line_offset || 0) + remaining
|
|
208
|
+
remaining = 0
|
|
209
|
+
changed = true
|
|
210
|
+
break
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
bump_version if changed
|
|
214
|
+
committed
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Entries that are still live (not committed). These are candidates
|
|
219
|
+
# for re-rendering into the visible output area.
|
|
220
|
+
#
|
|
221
|
+
# @return [Array<Entry>]
|
|
222
|
+
def live_entries
|
|
223
|
+
@mutex.synchronize { @entries.reject(&:committed).dup }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The last N *visual lines* across live entries, preserving entry
|
|
227
|
+
# boundaries. Returns an Array<String> suitable for row-by-row
|
|
228
|
+
# painting. If the last live entry is taller than +n+, only its last
|
|
229
|
+
# +n+ lines are returned.
|
|
230
|
+
#
|
|
231
|
+
# @param n [Integer]
|
|
232
|
+
# @return [Array<String>]
|
|
233
|
+
def tail_lines(n)
|
|
234
|
+
return [] if n <= 0
|
|
235
|
+
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
collected = []
|
|
238
|
+
@entries.reverse_each do |e|
|
|
239
|
+
break if collected.length >= n
|
|
240
|
+
next if e.committed
|
|
241
|
+
|
|
242
|
+
# The entry's still-visible lines (excluding any prefix already
|
|
243
|
+
# committed to scrollback via a partial commit).
|
|
244
|
+
vis = e.visible_lines
|
|
245
|
+
next if vis.empty?
|
|
246
|
+
|
|
247
|
+
# Prepend the entry's visible lines in order
|
|
248
|
+
remaining = n - collected.length
|
|
249
|
+
if vis.length <= remaining
|
|
250
|
+
collected = vis + collected
|
|
251
|
+
else
|
|
252
|
+
collected = vis.last(remaining) + collected
|
|
253
|
+
break
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
collected
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Look up an entry by id.
|
|
261
|
+
# @param id [Integer]
|
|
262
|
+
# @return [Entry, nil]
|
|
263
|
+
def entry_by_id(id)
|
|
264
|
+
@mutex.synchronize { @index[id] }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Does this id still refer to a live, editable entry?
|
|
268
|
+
# @param id [Integer]
|
|
269
|
+
def live?(id)
|
|
270
|
+
@mutex.synchronize do
|
|
271
|
+
e = @index[id]
|
|
272
|
+
!!(e && !e.committed)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Does this id refer to an entry that can still be replaced or
|
|
277
|
+
# removed in place? A partially-committed entry (prefix already in
|
|
278
|
+
# scrollback via a scroll) is NOT editable — its visible suffix is
|
|
279
|
+
# frozen until it either fully commits or (rare) a full repaint
|
|
280
|
+
# rewrites the screen.
|
|
281
|
+
#
|
|
282
|
+
# @param id [Integer]
|
|
283
|
+
def fully_editable?(id)
|
|
284
|
+
@mutex.synchronize do
|
|
285
|
+
e = @index[id]
|
|
286
|
+
!!(e && !e.committed && (e.committed_line_offset || 0) == 0)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Total number of entries (committed + live) currently tracked.
|
|
291
|
+
def size
|
|
292
|
+
@mutex.synchronize { @entries.size }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Number of live entries.
|
|
296
|
+
def live_size
|
|
297
|
+
@mutex.synchronize { @entries.count { |e| !e.committed } }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Total visual lines across live entries.
|
|
301
|
+
def live_line_count
|
|
302
|
+
@mutex.synchronize { @entries.sum { |e| e.committed ? 0 : e.height } }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Monotonic version (incremented on every mutation).
|
|
306
|
+
def version
|
|
307
|
+
@version
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Clear everything. Used by /clear command.
|
|
311
|
+
def clear
|
|
312
|
+
@mutex.synchronize do
|
|
313
|
+
@entries.clear
|
|
314
|
+
@index.clear
|
|
315
|
+
bump_version
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# --- helpers ----------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
private def next_id!
|
|
322
|
+
id = @next_id
|
|
323
|
+
@next_id += 1
|
|
324
|
+
id
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private def bump_version
|
|
328
|
+
@version += 1
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Drop the oldest entries when the buffer grows past the cap. This is
|
|
332
|
+
# a soft safety net — in practice live entries stay small because
|
|
333
|
+
# write_output_line commits them to scrollback as they scroll off.
|
|
334
|
+
private def trim_if_needed
|
|
335
|
+
while @entries.size > @max_entries
|
|
336
|
+
dropped = @entries.shift
|
|
337
|
+
@index.delete(dropped.id)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Normalize input into an array of visual lines (no trailing \n kept).
|
|
342
|
+
# Empty strings are preserved so callers can explicitly append blank
|
|
343
|
+
# rows.
|
|
344
|
+
#
|
|
345
|
+
# Rules:
|
|
346
|
+
# - nil → [""]
|
|
347
|
+
# - Array<String> → deep copy (caller has pre-split)
|
|
348
|
+
# - "hello" → ["hello"]
|
|
349
|
+
# - "a\nb" → ["a", "b"]
|
|
350
|
+
# - "a\n" → ["a"] (trailing newline is not a new line)
|
|
351
|
+
# - "a\n\n" → ["a", ""] (explicit blank line preserved)
|
|
352
|
+
# - "" → [""]
|
|
353
|
+
private def normalize_lines(content)
|
|
354
|
+
case content
|
|
355
|
+
when nil
|
|
356
|
+
[""]
|
|
357
|
+
when Array
|
|
358
|
+
content.map(&:to_s)
|
|
359
|
+
else
|
|
360
|
+
str = content.to_s
|
|
361
|
+
return [""] if str.empty?
|
|
362
|
+
# Strip a single trailing newline so "a\n" → ["a"], but keep
|
|
363
|
+
# explicit blank lines ("a\n\n" → ["a", ""]).
|
|
364
|
+
str = str.chomp("\n")
|
|
365
|
+
str.split("\n", -1)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|