iCode-TUI 0.29.1__py3-none-any.whl
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.
- chrys/__init__.py +16 -0
- chrys/app/__init__.py +3 -0
- chrys/app/acp/__init__.py +3 -0
- chrys/app/acp/bridge.py +664 -0
- chrys/app/acp/content.py +132 -0
- chrys/app/acp/doc/frontend-api.md +425 -0
- chrys/app/acp/history.py +585 -0
- chrys/app/acp/server.py +1796 -0
- chrys/app/acp/session_manager.py +1815 -0
- chrys/app/acp/tool_status.py +45 -0
- chrys/app/cli/__init__.py +3 -0
- chrys/app/cli/acp.py +178 -0
- chrys/app/cli/app.py +165 -0
- chrys/app/cli/approvals.py +101 -0
- chrys/app/cli/headless.py +223 -0
- chrys/app/cli/launch_cwd.py +42 -0
- chrys/app/cli/profiles.py +298 -0
- chrys/app/cli/progress.py +857 -0
- chrys/app/cli/run.py +425 -0
- chrys/app/cli/serve.py +1593 -0
- chrys/app/cli/tool_summary.py +225 -0
- chrys/app/cli/trajectory.py +201 -0
- chrys/app/cli/workflow.py +601 -0
- chrys/app/features/__init__.py +3 -0
- chrys/app/features/buddy/__init__.py +3 -0
- chrys/app/features/buddy/actions.py +68 -0
- chrys/app/features/buddy/animation.py +76 -0
- chrys/app/features/buddy/commands.py +196 -0
- chrys/app/features/buddy/docs/design.md +187 -0
- chrys/app/features/buddy/hatchery.py +80 -0
- chrys/app/features/buddy/lifecycle.py +41 -0
- chrys/app/features/buddy/model.py +265 -0
- chrys/app/features/buddy/pixel_renderer.py +113 -0
- chrys/app/features/buddy/pixel_sprites.py +279 -0
- chrys/app/features/buddy/portrait.py +76 -0
- chrys/app/features/buddy/progression.py +58 -0
- chrys/app/features/buddy/replies.py +191 -0
- chrys/app/features/buddy/sprites/alien.toml +70 -0
- chrys/app/features/buddy/sprites/axolotl.toml +71 -0
- chrys/app/features/buddy/sprites/bat.toml +69 -0
- chrys/app/features/buddy/sprites/bee.toml +69 -0
- chrys/app/features/buddy/sprites/cactus.toml +73 -0
- chrys/app/features/buddy/sprites/capybara.toml +70 -0
- chrys/app/features/buddy/sprites/cat.toml +69 -0
- chrys/app/features/buddy/sprites/crab.toml +70 -0
- chrys/app/features/buddy/sprites/dragon.toml +71 -0
- chrys/app/features/buddy/sprites/duck.toml +69 -0
- chrys/app/features/buddy/sprites/fox.toml +69 -0
- chrys/app/features/buddy/sprites/frog.toml +70 -0
- chrys/app/features/buddy/sprites/ghost.toml +70 -0
- chrys/app/features/buddy/sprites/goose.toml +69 -0
- chrys/app/features/buddy/sprites/jelly.toml +70 -0
- chrys/app/features/buddy/sprites/llama.toml +70 -0
- chrys/app/features/buddy/sprites/mushroom.toml +69 -0
- chrys/app/features/buddy/sprites/octopus.toml +70 -0
- chrys/app/features/buddy/sprites/owl.toml +70 -0
- chrys/app/features/buddy/sprites/panda.toml +71 -0
- chrys/app/features/buddy/sprites/penguin.toml +69 -0
- chrys/app/features/buddy/sprites/rabbit.toml +70 -0
- chrys/app/features/buddy/sprites/robot.toml +72 -0
- chrys/app/features/buddy/sprites/shark.toml +70 -0
- chrys/app/features/buddy/sprites/snail.toml +69 -0
- chrys/app/features/buddy/sprites/snake.toml +69 -0
- chrys/app/features/buddy/sprites/tabby.toml +69 -0
- chrys/app/features/buddy/sprites/turtle.toml +69 -0
- chrys/app/features/buddy/store.py +143 -0
- chrys/app/features/session_title/__init__.py +7 -0
- chrys/app/features/session_title/updater.py +582 -0
- chrys/app/installer.py +651 -0
- chrys/app/parsing.py +17 -0
- chrys/app/tui/__init__.py +3 -0
- chrys/app/tui/app.py +1287 -0
- chrys/app/tui/behaviors/__init__.py +3 -0
- chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
- chrys/app/tui/behaviors/insert_clipboard.py +73 -0
- chrys/app/tui/behaviors/right_click_copy.py +146 -0
- chrys/app/tui/binding_display.py +139 -0
- chrys/app/tui/buddy_messages.py +17 -0
- chrys/app/tui/buddy_reply.py +86 -0
- chrys/app/tui/chrys.tcss +549 -0
- chrys/app/tui/clipboard.py +109 -0
- chrys/app/tui/copy_messages.py +10 -0
- chrys/app/tui/i18n.py +230 -0
- chrys/app/tui/language.py +58 -0
- chrys/app/tui/notifications/__init__.py +12 -0
- chrys/app/tui/notifications/drivers.py +180 -0
- chrys/app/tui/notifications/service.py +126 -0
- chrys/app/tui/notifications/settings.py +141 -0
- chrys/app/tui/screens/__init__.py +3 -0
- chrys/app/tui/screens/agents/__init__.py +8 -0
- chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
- chrys/app/tui/screens/agents/config.py +2142 -0
- chrys/app/tui/screens/agents/config.tcss +169 -0
- chrys/app/tui/screens/agents/panels/__init__.py +3 -0
- chrys/app/tui/screens/agents/panels/acp.py +1176 -0
- chrys/app/tui/screens/agents/panels/basic.py +497 -0
- chrys/app/tui/screens/agents/panels/compaction.py +231 -0
- chrys/app/tui/screens/agents/panels/config_card.py +218 -0
- chrys/app/tui/screens/agents/panels/instructions.py +91 -0
- chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
- chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
- chrys/app/tui/screens/agents/panels/memory.py +684 -0
- chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
- chrys/app/tui/screens/agents/panels/skills.py +806 -0
- chrys/app/tui/screens/agents/panels/subagents.py +652 -0
- chrys/app/tui/screens/agents/panels/tools.py +205 -0
- chrys/app/tui/screens/agents/picker.py +103 -0
- chrys/app/tui/screens/agents/skill_paths.py +38 -0
- chrys/app/tui/screens/agents/validation_messages.py +452 -0
- chrys/app/tui/screens/dialogs/__init__.py +35 -0
- chrys/app/tui/screens/dialogs/agent_load.py +834 -0
- chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
- chrys/app/tui/screens/dialogs/app_mode.py +55 -0
- chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
- chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
- chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
- chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
- chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
- chrys/app/tui/screens/dialogs/approval/body.py +110 -0
- chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
- chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
- chrys/app/tui/screens/dialogs/ask_user.py +148 -0
- chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
- chrys/app/tui/screens/dialogs/base.py +84 -0
- chrys/app/tui/screens/dialogs/confirm.py +184 -0
- chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
- chrys/app/tui/screens/dialogs/connection_test.py +183 -0
- chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
- chrys/app/tui/screens/dialogs/diagram.py +102 -0
- chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
- chrys/app/tui/screens/dialogs/editor.py +378 -0
- chrys/app/tui/screens/dialogs/editor.tcss +132 -0
- chrys/app/tui/screens/dialogs/file_picker.py +582 -0
- chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
- chrys/app/tui/screens/dialogs/fork_session.py +219 -0
- chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
- chrys/app/tui/screens/dialogs/image_compression.py +102 -0
- chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
- chrys/app/tui/screens/dialogs/man_page.py +172 -0
- chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
- chrys/app/tui/screens/dialogs/new_folder.py +277 -0
- chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
- chrys/app/tui/screens/dialogs/notice.tcss +14 -0
- chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
- chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
- chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
- chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
- chrys/app/tui/screens/dialogs/session_title.py +110 -0
- chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
- chrys/app/tui/screens/dialogs/tool_view.py +207 -0
- chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
- chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
- chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
- chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
- chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
- chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
- chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
- chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
- chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
- chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
- chrys/app/tui/screens/diff/__init__.py +22 -0
- chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
- chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
- chrys/app/tui/screens/diff/screen.py +1532 -0
- chrys/app/tui/screens/diff/screen.tcss +104 -0
- chrys/app/tui/screens/guides/__init__.py +7 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
- chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
- chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
- chrys/app/tui/screens/guides/index.py +215 -0
- chrys/app/tui/screens/guides/reader.py +173 -0
- chrys/app/tui/screens/guides/screen.py +442 -0
- chrys/app/tui/screens/guides/screen.tcss +52 -0
- chrys/app/tui/screens/logs/__init__.py +7 -0
- chrys/app/tui/screens/logs/screen.py +504 -0
- chrys/app/tui/screens/logs/screen.tcss +45 -0
- chrys/app/tui/screens/main/__init__.py +7 -0
- chrys/app/tui/screens/main/action_availability.py +30 -0
- chrys/app/tui/screens/main/buddy_command.py +143 -0
- chrys/app/tui/screens/main/commands.py +1073 -0
- chrys/app/tui/screens/main/config_actions.py +464 -0
- chrys/app/tui/screens/main/copy_actions.py +117 -0
- chrys/app/tui/screens/main/dialog_controllers.py +826 -0
- chrys/app/tui/screens/main/dialog_gateway.py +166 -0
- chrys/app/tui/screens/main/diff_controller.py +478 -0
- chrys/app/tui/screens/main/engine_read_model.py +184 -0
- chrys/app/tui/screens/main/event_handlers.py +2014 -0
- chrys/app/tui/screens/main/input_flow.py +241 -0
- chrys/app/tui/screens/main/live_diff.py +244 -0
- chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
- chrys/app/tui/screens/main/model_indicator.py +217 -0
- chrys/app/tui/screens/main/navigation.py +343 -0
- chrys/app/tui/screens/main/ports.py +571 -0
- chrys/app/tui/screens/main/recent_dirs.py +123 -0
- chrys/app/tui/screens/main/rollback_controller.py +586 -0
- chrys/app/tui/screens/main/runtime_info.py +71 -0
- chrys/app/tui/screens/main/screen.py +2265 -0
- chrys/app/tui/screens/main/screen.tcss +21 -0
- chrys/app/tui/screens/main/session_handlers.py +1127 -0
- chrys/app/tui/screens/main/session_title.py +239 -0
- chrys/app/tui/screens/main/settings_coordinator.py +365 -0
- chrys/app/tui/screens/main/settings_persistence.py +184 -0
- chrys/app/tui/screens/main/shell_mode.py +112 -0
- chrys/app/tui/screens/main/state.py +289 -0
- chrys/app/tui/screens/main/submit_state.py +11 -0
- chrys/app/tui/screens/main/subscriptions.py +177 -0
- chrys/app/tui/screens/main/suggestions.py +982 -0
- chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
- chrys/app/tui/screens/main/view_adapter.py +1567 -0
- chrys/app/tui/screens/main/workflow_browser.py +747 -0
- chrys/app/tui/screens/main/workflow_content.py +248 -0
- chrys/app/tui/screens/main/workflow_controller.py +240 -0
- chrys/app/tui/screens/main/workflow_feedback.py +137 -0
- chrys/app/tui/screens/main/workflow_flow.py +49 -0
- chrys/app/tui/screens/main/workflow_run_control.py +456 -0
- chrys/app/tui/screens/main/workflow_session_view.py +316 -0
- chrys/app/tui/screens/main/workspace_actions.py +272 -0
- chrys/app/tui/screens/main/workspace_branch.py +194 -0
- chrys/app/tui/screens/menus/__init__.py +8 -0
- chrys/app/tui/screens/menus/languages.py +114 -0
- chrys/app/tui/screens/models/__init__.py +7 -0
- chrys/app/tui/screens/models/picker.py +147 -0
- chrys/app/tui/screens/models/screen.py +2043 -0
- chrys/app/tui/screens/models/screen.tcss +255 -0
- chrys/app/tui/screens/sessions/__init__.py +8 -0
- chrys/app/tui/screens/sessions/presenter.py +586 -0
- chrys/app/tui/screens/sessions/screen.py +779 -0
- chrys/app/tui/screens/sessions/screen.tcss +127 -0
- chrys/app/tui/screens/settings/__init__.py +29 -0
- chrys/app/tui/screens/settings/danger.py +26 -0
- chrys/app/tui/screens/settings/dialog.py +281 -0
- chrys/app/tui/screens/settings/layout.py +519 -0
- chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
- chrys/app/tui/screens/settings/panes/__init__.py +3 -0
- chrys/app/tui/screens/settings/panes/notifications.py +248 -0
- chrys/app/tui/screens/settings/panes/sessions.py +148 -0
- chrys/app/tui/screens/settings/ports.py +55 -0
- chrys/app/tui/screens/settings/provenance.py +102 -0
- chrys/app/tui/screens/settings/rows.py +546 -0
- chrys/app/tui/screens/settings/settings.tcss +406 -0
- chrys/app/tui/screens/themes/__init__.py +8 -0
- chrys/app/tui/screens/themes/dialogs.py +330 -0
- chrys/app/tui/screens/themes/editor.py +632 -0
- chrys/app/tui/screens/themes/editor.tcss +268 -0
- chrys/app/tui/screens/themes/messages.py +95 -0
- chrys/app/tui/screens/themes/palette.py +694 -0
- chrys/app/tui/screens/themes/panel.py +113 -0
- chrys/app/tui/screens/themes/picker.py +203 -0
- chrys/app/tui/screens/themes/save.py +191 -0
- chrys/app/tui/support/__init__.py +3 -0
- chrys/app/tui/support/file_manager.py +64 -0
- chrys/app/tui/support/gc_freeze.py +1114 -0
- chrys/app/tui/support/path_shortcuts.py +50 -0
- chrys/app/tui/support/prompt_history.py +298 -0
- chrys/app/tui/support/workspace_mru.py +476 -0
- chrys/app/tui/terminal/__init__.py +6 -0
- chrys/app/tui/terminal/_pty_backend.py +114 -0
- chrys/app/tui/terminal/emulator/__init__.py +24 -0
- chrys/app/tui/terminal/emulator/buffer.py +456 -0
- chrys/app/tui/terminal/emulator/charsets.py +113 -0
- chrys/app/tui/terminal/emulator/core.py +963 -0
- chrys/app/tui/terminal/emulator/input.py +342 -0
- chrys/app/tui/terminal/emulator/parser.py +227 -0
- chrys/app/tui/terminal/emulator/pen.py +158 -0
- chrys/app/tui/terminal/launcher.py +455 -0
- chrys/app/tui/terminal/leaked_reports.py +44 -0
- chrys/app/tui/terminal/panel.py +323 -0
- chrys/app/tui/terminal/pty_process.py +405 -0
- chrys/app/tui/terminal/rendering.py +151 -0
- chrys/app/tui/terminal/shell.py +273 -0
- chrys/app/tui/terminal/shell_integration.py +199 -0
- chrys/app/tui/terminal/title.py +224 -0
- chrys/app/tui/terminal/widget.py +654 -0
- chrys/app/tui/theme.py +422 -0
- chrys/app/tui/theme_loader.py +201 -0
- chrys/app/tui/theme_stylesheet.py +49 -0
- chrys/app/tui/themes/__init__.py +3 -0
- chrys/app/tui/themes/document.py +120 -0
- chrys/app/tui/themes/preview.py +170 -0
- chrys/app/tui/themes/store.py +176 -0
- chrys/app/tui/toasts.py +69 -0
- chrys/app/tui/util/__init__.py +3 -0
- chrys/app/tui/util/context_pressure.py +146 -0
- chrys/app/tui/util/debug_log.py +292 -0
- chrys/app/tui/util/diff_entries.py +64 -0
- chrys/app/tui/util/formatting.py +53 -0
- chrys/app/tui/util/git_branch.py +326 -0
- chrys/app/tui/util/invocation_progress.py +57 -0
- chrys/app/tui/util/logo.py +32 -0
- chrys/app/tui/util/message_gate.py +35 -0
- chrys/app/tui/util/mutation_sources.py +14 -0
- chrys/app/tui/util/picker_text.py +27 -0
- chrys/app/tui/util/removal.py +47 -0
- chrys/app/tui/util/rich_style.py +36 -0
- chrys/app/tui/util/source_text.py +12 -0
- chrys/app/tui/util/static_update.py +88 -0
- chrys/app/tui/util/todo_format.py +51 -0
- chrys/app/tui/util/visibility.py +214 -0
- chrys/app/tui/widgets/__init__.py +60 -0
- chrys/app/tui/widgets/ask_user_controls.py +652 -0
- chrys/app/tui/widgets/ask_user_markdown.py +17 -0
- chrys/app/tui/widgets/ask_user_prompt.py +606 -0
- chrys/app/tui/widgets/buttons.py +52 -0
- chrys/app/tui/widgets/chat/__init__.py +3 -0
- chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
- chrys/app/tui/widgets/chat/chrome.py +296 -0
- chrys/app/tui/widgets/chat/compaction_card.py +353 -0
- chrys/app/tui/widgets/chat/context_fold.py +77 -0
- chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
- chrys/app/tui/widgets/chat/image_preview.py +214 -0
- chrys/app/tui/widgets/chat/live_renderer.py +383 -0
- chrys/app/tui/widgets/chat/messages.py +1039 -0
- chrys/app/tui/widgets/chat/panel.py +1769 -0
- chrys/app/tui/widgets/chat/ports.py +245 -0
- chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
- chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
- chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
- chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
- chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
- chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
- chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
- chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
- chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
- chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
- chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
- chrys/app/tui/widgets/chat/renderers/search.py +348 -0
- chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
- chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
- chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
- chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
- chrys/app/tui/widgets/chat/renderers/web.py +69 -0
- chrys/app/tui/widgets/chat/replay.py +1605 -0
- chrys/app/tui/widgets/chat/replay_mount.py +322 -0
- chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
- chrys/app/tui/widgets/chat/selection_controller.py +435 -0
- chrys/app/tui/widgets/chat/session_json.py +508 -0
- chrys/app/tui/widgets/chat/toc_model.py +156 -0
- chrys/app/tui/widgets/chat/tool_call.py +2478 -0
- chrys/app/tui/widgets/chat/tool_registry.py +753 -0
- chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
- chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
- chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
- chrys/app/tui/widgets/checkbox.py +24 -0
- chrys/app/tui/widgets/chrome/__init__.py +3 -0
- chrys/app/tui/widgets/chrome/app_header.py +283 -0
- chrys/app/tui/widgets/chrome/commands.py +95 -0
- chrys/app/tui/widgets/chrome/file_index.py +68 -0
- chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
- chrys/app/tui/widgets/chrome/footer.py +284 -0
- chrys/app/tui/widgets/chrome/image_paste.py +351 -0
- chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
- chrys/app/tui/widgets/chrome/status_bar.py +911 -0
- chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
- chrys/app/tui/widgets/click_affordance.py +24 -0
- chrys/app/tui/widgets/color_picker/__init__.py +254 -0
- chrys/app/tui/widgets/color_picker/controls.py +171 -0
- chrys/app/tui/widgets/color_picker/model.py +89 -0
- chrys/app/tui/widgets/dialog_buttons.py +58 -0
- chrys/app/tui/widgets/dialog_scroll.py +24 -0
- chrys/app/tui/widgets/diff_view/__init__.py +20 -0
- chrys/app/tui/widgets/diff_view/cells.py +59 -0
- chrys/app/tui/widgets/diff_view/code.py +280 -0
- chrys/app/tui/widgets/diff_view/compute.py +286 -0
- chrys/app/tui/widgets/diff_view/gutter.py +161 -0
- chrys/app/tui/widgets/diff_view/inline.py +77 -0
- chrys/app/tui/widgets/diff_view/palette.py +87 -0
- chrys/app/tui/widgets/diff_view/protocols.py +24 -0
- chrys/app/tui/widgets/diff_view/rows.py +138 -0
- chrys/app/tui/widgets/diff_view/unified.py +267 -0
- chrys/app/tui/widgets/diff_view/widget.py +209 -0
- chrys/app/tui/widgets/editor/__init__.py +79 -0
- chrys/app/tui/widgets/editor/coordinates.py +30 -0
- chrys/app/tui/widgets/editor/highlighter.py +139 -0
- chrys/app/tui/widgets/editor/keymaps.py +357 -0
- chrys/app/tui/widgets/editor/types.py +216 -0
- chrys/app/tui/widgets/editor/vim.py +1195 -0
- chrys/app/tui/widgets/editor/widget.py +401 -0
- chrys/app/tui/widgets/file_preview.py +58 -0
- chrys/app/tui/widgets/hatch.py +138 -0
- chrys/app/tui/widgets/input.py +66 -0
- chrys/app/tui/widgets/loading.py +125 -0
- chrys/app/tui/widgets/markdown/__init__.py +25 -0
- chrys/app/tui/widgets/markdown/_utils.py +21 -0
- chrys/app/tui/widgets/markdown/anchors.py +71 -0
- chrys/app/tui/widgets/markdown/blocks.py +115 -0
- chrys/app/tui/widgets/markdown/copyable.py +163 -0
- chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
- chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
- chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
- chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
- chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
- chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
- chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
- chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
- chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
- chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
- chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
- chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
- chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
- chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
- chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
- chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
- chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
- chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
- chrys/app/tui/widgets/markdown/links.py +47 -0
- chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
- chrys/app/tui/widgets/markdown/math/layout.py +739 -0
- chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
- chrys/app/tui/widgets/markdown/math/parser.py +325 -0
- chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
- chrys/app/tui/widgets/markdown/navigator.py +80 -0
- chrys/app/tui/widgets/markdown/parser.py +1057 -0
- chrys/app/tui/widgets/markdown/stream.py +73 -0
- chrys/app/tui/widgets/markdown/toc.py +98 -0
- chrys/app/tui/widgets/markdown/viewer.py +146 -0
- chrys/app/tui/widgets/markdown/widget.py +2046 -0
- chrys/app/tui/widgets/marquee.py +101 -0
- chrys/app/tui/widgets/option_menu.py +82 -0
- chrys/app/tui/widgets/page_navigator.py +118 -0
- chrys/app/tui/widgets/select.py +196 -0
- chrys/app/tui/widgets/selection.py +59 -0
- chrys/app/tui/widgets/sidebar/__init__.py +3 -0
- chrys/app/tui/widgets/sidebar/buddy.py +367 -0
- chrys/app/tui/widgets/sidebar/context.py +482 -0
- chrys/app/tui/widgets/sidebar/debug.py +195 -0
- chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
- chrys/app/tui/widgets/sidebar/panel.py +198 -0
- chrys/app/tui/widgets/sidebar/tasks.py +198 -0
- chrys/app/tui/widgets/sidebar/toc.py +166 -0
- chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
- chrys/app/tui/widgets/syntax_theme.py +85 -0
- chrys/app/tui/widgets/text_area.py +258 -0
- chrys/app/tui/widgets/trajectory/__init__.py +8 -0
- chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
- chrys/app/tui/widgets/trajectory/insights.py +1266 -0
- chrys/app/tui/widgets/trajectory/overview.py +1101 -0
- chrys/app/tui/widgets/trajectory/panel.py +983 -0
- chrys/app/tui/widgets/trajectory/presentation.py +311 -0
- chrys/app/tui/widgets/trajectory/session_info.py +124 -0
- chrys/app/tui/widgets/trajectory/text_view.py +124 -0
- chrys/app/tui/widgets/trajectory/timeline.py +380 -0
- chrys/app/tui/widgets/welcome.py +126 -0
- chrys/app/tui/widgets/workflow/__init__.py +5 -0
- chrys/app/tui/widgets/workflow/edges.py +89 -0
- chrys/app/tui/widgets/workflow/graph.py +869 -0
- chrys/app/tui/widgets/workflow/info.py +310 -0
- chrys/app/tui/widgets/workflow/node_view.py +27 -0
- chrys/app/tui/widgets/workflow/output.py +225 -0
- chrys/app/tui/widgets/workflow/panel.py +658 -0
- chrys/app/tui/widgets/workflow/projector.py +324 -0
- chrys/app/tui/widgets/workflow/records.py +69 -0
- chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
- chrys/app/tui/widgets/workflow/selection.py +151 -0
- chrys/app/tui/widgets/workflow/source.py +65 -0
- chrys/app/tui/widgets/workflow/text.py +257 -0
- chrys/app/tui/widgets/workflow/values.py +317 -0
- chrys/foundation/__init__.py +0 -0
- chrys/foundation/branding.py +15 -0
- chrys/foundation/config/__init__.py +7 -0
- chrys/foundation/config/coercion.py +374 -0
- chrys/foundation/config/context.py +34 -0
- chrys/foundation/config/env_file.py +141 -0
- chrys/foundation/config/env_layers.py +370 -0
- chrys/foundation/config/migrations.py +521 -0
- chrys/foundation/config/notification_events.py +22 -0
- chrys/foundation/config/process_settings.py +332 -0
- chrys/foundation/config/runtime_pointer.py +133 -0
- chrys/foundation/config/settings.py +1952 -0
- chrys/foundation/config/settings_store.py +996 -0
- chrys/foundation/config/spec.py +396 -0
- chrys/foundation/config/user_settings.py +140 -0
- chrys/foundation/config/warnings.py +262 -0
- chrys/foundation/config/web_values.py +135 -0
- chrys/foundation/config/yaml_store.py +487 -0
- chrys/foundation/errors/__init__.py +68 -0
- chrys/foundation/errors/_legacy.py +117 -0
- chrys/foundation/errors/_text.py +13 -0
- chrys/foundation/errors/_walk.py +85 -0
- chrys/foundation/errors/classify.py +278 -0
- chrys/foundation/errors/display.py +274 -0
- chrys/foundation/errors/formatting.py +283 -0
- chrys/foundation/errors/kinds.py +67 -0
- chrys/foundation/errors/network.py +497 -0
- chrys/foundation/errors/provider.py +445 -0
- chrys/foundation/errors/route.py +85 -0
- chrys/foundation/events/__init__.py +8 -0
- chrys/foundation/events/bus.py +143 -0
- chrys/foundation/events/types.py +1629 -0
- chrys/foundation/events/workflow.py +38 -0
- chrys/foundation/hosted_tools.py +142 -0
- chrys/foundation/i18n/__init__.py +32 -0
- chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
- chrys/foundation/i18n/catalogs.py +42 -0
- chrys/foundation/i18n/formatting.py +202 -0
- chrys/foundation/i18n/locale.py +92 -0
- chrys/foundation/i18n/localizer.py +187 -0
- chrys/foundation/i18n/messages.py +268 -0
- chrys/foundation/io/__init__.py +3 -0
- chrys/foundation/io/result_content.py +237 -0
- chrys/foundation/models/__init__.py +3 -0
- chrys/foundation/models/approval_reuse.py +23 -0
- chrys/foundation/models/ask_user.py +468 -0
- chrys/foundation/models/execution.py +18 -0
- chrys/foundation/models/history_markers.py +115 -0
- chrys/foundation/models/invocations.py +63 -0
- chrys/foundation/models/mutation_scope.py +28 -0
- chrys/foundation/models/session_env.py +87 -0
- chrys/foundation/models/session_surface.py +29 -0
- chrys/foundation/models/todos.py +74 -0
- chrys/foundation/models/turns.py +125 -0
- chrys/foundation/models/workflow_session.py +89 -0
- chrys/foundation/models/workspace.py +59 -0
- chrys/foundation/net/__init__.py +3 -0
- chrys/foundation/net/address_class.py +57 -0
- chrys/foundation/net/route_probe.py +94 -0
- chrys/foundation/net/url.py +107 -0
- chrys/foundation/observability/__init__.py +3 -0
- chrys/foundation/observability/exporters.py +174 -0
- chrys/foundation/observability/gate.py +31 -0
- chrys/foundation/observability/setup.py +497 -0
- chrys/foundation/observability/sink.py +228 -0
- chrys/foundation/patches/__init__.py +19 -0
- chrys/foundation/patches/mdurl_cache.py +59 -0
- chrys/foundation/patches/patcher.py +287 -0
- chrys/foundation/patches/staged_members.py +205 -0
- chrys/foundation/patches/textual_block_border.py +105 -0
- chrys/foundation/patches/textual_button_ansi.py +65 -0
- chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
- chrys/foundation/patches/textual_callback_cache.py +248 -0
- chrys/foundation/patches/textual_callback_dispatch.py +109 -0
- chrys/foundation/patches/textual_compositor_cjk.py +566 -0
- chrys/foundation/patches/textual_dispatch_cache.py +150 -0
- chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
- chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
- chrys/foundation/patches/textual_lru_acyclic.py +241 -0
- chrys/foundation/patches/textual_message_pump.py +136 -0
- chrys/foundation/patches/textual_node_diet.py +875 -0
- chrys/foundation/patches/textual_one_shot_timer.py +48 -0
- chrys/foundation/patches/textual_option_list.py +90 -0
- chrys/foundation/patches/textual_precompose.py +89 -0
- chrys/foundation/patches/textual_pruned_tabs.py +69 -0
- chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
- chrys/foundation/patches/textual_removed_node_caches.py +86 -0
- chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
- chrys/foundation/patches/textual_selection_extract.py +48 -0
- chrys/foundation/patches/textual_strip_cycles.py +119 -0
- chrys/foundation/patches/textual_tab_selection.py +1611 -0
- chrys/foundation/patches/textual_timer_skip.py +70 -0
- chrys/foundation/patches/textual_utf8_decoder.py +106 -0
- chrys/foundation/patches/textual_win_sleep.py +167 -0
- chrys/foundation/patches/watchdog_fsevents.py +70 -0
- chrys/foundation/patches/watchdog_windows.py +52 -0
- chrys/foundation/platform/__init__.py +556 -0
- chrys/foundation/platform/c_api.py +32 -0
- chrys/foundation/platform/child_reap.py +107 -0
- chrys/foundation/platform/command_line.py +95 -0
- chrys/foundation/platform/files.py +1470 -0
- chrys/foundation/platform/output_capture.py +113 -0
- chrys/foundation/platform/paths.py +193 -0
- chrys/foundation/platform/process.py +1468 -0
- chrys/foundation/platform/pty_output.py +19 -0
- chrys/foundation/platform/runtime_paths.py +281 -0
- chrys/foundation/platform/win_console.py +129 -0
- chrys/foundation/reasoning_origin.py +67 -0
- chrys/foundation/recovery.py +16 -0
- chrys/foundation/retry.py +278 -0
- chrys/foundation/text/__init__.py +3 -0
- chrys/foundation/text/encoding.py +603 -0
- chrys/foundation/text/images.py +304 -0
- chrys/foundation/text/lines.py +24 -0
- chrys/foundation/text/mentions.py +90 -0
- chrys/foundation/text/model_json.py +25 -0
- chrys/foundation/text/reading_order.py +20 -0
- chrys/foundation/text/tokenizer.py +25 -0
- chrys/foundation/text/tool_output.py +287 -0
- chrys/foundation/text/yaml_io.py +62 -0
- chrys/foundation/tool_call_context.py +202 -0
- chrys/foundation/tool_execution_stamp.py +184 -0
- chrys/foundation/tool_invocation_order.py +48 -0
- chrys/foundation/tool_kinds.py +102 -0
- chrys/foundation/tool_result_metadata.py +287 -0
- chrys/foundation/trajectory/__init__.py +10 -0
- chrys/foundation/trajectory/context.py +481 -0
- chrys/foundation/trajectory/envelope.py +571 -0
- chrys/foundation/trajectory/event_types.py +294 -0
- chrys/foundation/trajectory/finalizers.py +46 -0
- chrys/foundation/trajectory/fingerprint.py +62 -0
- chrys/foundation/trajectory/ids.py +155 -0
- chrys/foundation/trajectory/keys.py +99 -0
- chrys/foundation/trajectory/lease.py +173 -0
- chrys/foundation/trajectory/metadata.py +72 -0
- chrys/foundation/trajectory/reader.py +271 -0
- chrys/foundation/trajectory/recovery.py +225 -0
- chrys/foundation/trajectory/revisions.py +152 -0
- chrys/foundation/trajectory/segments.py +225 -0
- chrys/foundation/trajectory/tools.py +117 -0
- chrys/foundation/trajectory/usage.py +151 -0
- chrys/foundation/trajectory/writer.py +972 -0
- chrys/foundation/trajectory_timing.py +97 -0
- chrys/foundation/util/__init__.py +3 -0
- chrys/foundation/util/async_tasks.py +29 -0
- chrys/foundation/util/chrys_headers.py +23 -0
- chrys/foundation/util/env_templates.py +104 -0
- chrys/foundation/util/filenames.py +96 -0
- chrys/foundation/util/header_charset.py +138 -0
- chrys/foundation/util/httpx_helpers.py +12 -0
- chrys/foundation/util/lock.py +236 -0
- chrys/foundation/util/once_close.py +46 -0
- chrys/foundation/util/session_ids.py +29 -0
- chrys/foundation/util/shell_tokens.py +88 -0
- chrys/foundation/util/sub_agent_context.py +38 -0
- chrys/foundation/util/time.py +35 -0
- chrys/foundation/util/unicode_scalars.py +36 -0
- chrys/foundation/vendor/__init__.py +43 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
- chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
- chrys/kernel/__init__.py +256 -0
- chrys/kernel/_content.py +1866 -0
- chrys/kernel/_loop_recorder.py +631 -0
- chrys/kernel/_null_overlay.py +1026 -0
- chrys/kernel/_result_ceiling.py +216 -0
- chrys/kernel/_serialization.py +431 -0
- chrys/kernel/_tool_arg_errors.py +1629 -0
- chrys/kernel/_tool_execution.py +1172 -0
- chrys/kernel/_tool_expansion.py +26 -0
- chrys/kernel/_types.py +2598 -0
- chrys/kernel/agent.py +994 -0
- chrys/kernel/client.py +1084 -0
- chrys/kernel/compaction.py +1315 -0
- chrys/kernel/exceptions.py +211 -0
- chrys/kernel/exchanges.py +683 -0
- chrys/kernel/identity.py +95 -0
- chrys/kernel/images.py +36 -0
- chrys/kernel/instrumentation.py +1618 -0
- chrys/kernel/loop.py +2308 -0
- chrys/kernel/middleware.py +616 -0
- chrys/kernel/sessions.py +604 -0
- chrys/kernel/tools.py +1061 -0
- chrys/kernel/types.py +126 -0
- chrys/orchestration/__init__.py +3 -0
- chrys/orchestration/engine/__init__.py +3 -0
- chrys/orchestration/engine/assembly.py +236 -0
- chrys/orchestration/engine/build/__init__.py +3 -0
- chrys/orchestration/engine/build/builder.py +1061 -0
- chrys/orchestration/engine/build/construction.py +408 -0
- chrys/orchestration/engine/build/loaded.py +139 -0
- chrys/orchestration/engine/engine.py +789 -0
- chrys/orchestration/engine/execution.py +771 -0
- chrys/orchestration/engine/loader.py +865 -0
- chrys/orchestration/engine/rollback.py +918 -0
- chrys/orchestration/engine/run/__init__.py +3 -0
- chrys/orchestration/engine/run/active_injection.py +419 -0
- chrys/orchestration/engine/run/attachments.py +519 -0
- chrys/orchestration/engine/run/bindings.py +1095 -0
- chrys/orchestration/engine/run/coordinator.py +1062 -0
- chrys/orchestration/engine/run/finalizer.py +417 -0
- chrys/orchestration/engine/run/input_refs.py +60 -0
- chrys/orchestration/engine/run/prompt_content.py +457 -0
- chrys/orchestration/engine/run/resume.py +299 -0
- chrys/orchestration/engine/run/retry.py +948 -0
- chrys/orchestration/engine/run/runner.py +625 -0
- chrys/orchestration/engine/run/runtime_skills.py +146 -0
- chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
- chrys/orchestration/engine/run/turn_hooks.py +258 -0
- chrys/orchestration/engine/run/turn_state.py +149 -0
- chrys/orchestration/engine/run/working_dir.py +50 -0
- chrys/orchestration/engine/session_lifecycle.py +2013 -0
- chrys/orchestration/engine/state/__init__.py +3 -0
- chrys/orchestration/engine/state/active_session.py +183 -0
- chrys/orchestration/engine/state/controls.py +639 -0
- chrys/orchestration/engine/state/current_agent.py +26 -0
- chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
- chrys/orchestration/engine/state/machine.py +193 -0
- chrys/orchestration/engine/state/session_writer.py +434 -0
- chrys/orchestration/engine/trajectory.py +714 -0
- chrys/orchestration/engine/usage.py +118 -0
- chrys/orchestration/invoker/__init__.py +3 -0
- chrys/orchestration/invoker/acp.py +727 -0
- chrys/orchestration/invoker/acp_protocol.py +1115 -0
- chrys/orchestration/invoker/acp_spec.py +79 -0
- chrys/orchestration/invoker/attempts.py +994 -0
- chrys/orchestration/invoker/child_compaction.py +131 -0
- chrys/orchestration/invoker/child_history.py +99 -0
- chrys/orchestration/invoker/contracts.py +253 -0
- chrys/orchestration/invoker/evidence.py +94 -0
- chrys/orchestration/invoker/kernel.py +343 -0
- chrys/orchestration/invoker/origin.py +79 -0
- chrys/orchestration/invoker/resources.py +343 -0
- chrys/orchestration/invoker/runtime.py +299 -0
- chrys/orchestration/invoker/usage.py +80 -0
- chrys/orchestration/session_hooks.py +117 -0
- chrys/orchestration/session_host.py +1023 -0
- chrys/orchestration/session_resources.py +95 -0
- chrys/orchestration/session_usage.py +219 -0
- chrys/orchestration/startup.py +255 -0
- chrys/orchestration/sub_agents/__init__.py +3 -0
- chrys/orchestration/sub_agents/acp_policy.py +269 -0
- chrys/orchestration/sub_agents/kernel_policy.py +848 -0
- chrys/orchestration/sub_agents/registration.py +44 -0
- chrys/orchestration/sub_agents/shell.py +337 -0
- chrys/orchestration/sub_agents/tools.py +2228 -0
- chrys/orchestration/workflows/__init__.py +3 -0
- chrys/orchestration/workflows/agent_archive.py +156 -0
- chrys/orchestration/workflows/agent_node.py +894 -0
- chrys/orchestration/workflows/agent_node_build.py +687 -0
- chrys/orchestration/workflows/catalog.py +332 -0
- chrys/orchestration/workflows/coordinator.py +771 -0
- chrys/orchestration/workflows/hooks.py +176 -0
- chrys/orchestration/workflows/preview.py +322 -0
- chrys/orchestration/workflows/rollback.py +151 -0
- chrys/orchestration/workflows/runner.py +986 -0
- chrys/orchestration/workflows/session.py +390 -0
- chrys/orchestration/workflows/settings.py +97 -0
- chrys/orchestration/workflows/validation.py +674 -0
- chrys/orchestration/workflows/worker_client.py +1002 -0
- chrys/service/__init__.py +3 -0
- chrys/service/acp_client/__init__.py +65 -0
- chrys/service/acp_client/_transport.py +922 -0
- chrys/service/acp_client/client.py +1311 -0
- chrys/service/acp_client/errors.py +224 -0
- chrys/service/acp_client/protocol.py +220 -0
- chrys/service/acp_client/spawn.py +239 -0
- chrys/service/acp_client/spec.py +100 -0
- chrys/service/agent_middleware/__init__.py +60 -0
- chrys/service/agent_middleware/_metadata_keys.py +52 -0
- chrys/service/agent_middleware/control/__init__.py +3 -0
- chrys/service/agent_middleware/control/approval.py +769 -0
- chrys/service/agent_middleware/control/approval_hooks.py +62 -0
- chrys/service/agent_middleware/control/ask_user.py +207 -0
- chrys/service/agent_middleware/control/interrupt.py +50 -0
- chrys/service/agent_middleware/control/sleep.py +118 -0
- chrys/service/agent_middleware/control/todo.py +117 -0
- chrys/service/agent_middleware/events/__init__.py +3 -0
- chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
- chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
- chrys/service/agent_middleware/events/intermediate_text.py +128 -0
- chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
- chrys/service/agent_middleware/events/result_persistence.py +98 -0
- chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
- chrys/service/agent_middleware/events/tool_error_text.py +22 -0
- chrys/service/agent_middleware/events/tool_events.py +779 -0
- chrys/service/agent_middleware/injection.py +510 -0
- chrys/service/agent_middleware/reminders/__init__.py +91 -0
- chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
- chrys/service/agent_middleware/reminders/base.py +25 -0
- chrys/service/agent_middleware/reminders/context_usage.py +74 -0
- chrys/service/agent_middleware/reminders/file_change.py +63 -0
- chrys/service/agent_middleware/reminders/mcp.py +39 -0
- chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
- chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
- chrys/service/agent_middleware/reminders/skills.py +44 -0
- chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
- chrys/service/agent_middleware/reminders/todo.py +35 -0
- chrys/service/agent_middleware/reminders/turn_line.py +48 -0
- chrys/service/agent_middleware/response_validation.py +1380 -0
- chrys/service/agent_middleware/system_reminder.py +1107 -0
- chrys/service/agent_middleware/validators.py +396 -0
- chrys/service/analytics/__init__.py +124 -0
- chrys/service/analytics/_actions.py +896 -0
- chrys/service/analytics/_context_evidence.py +357 -0
- chrys/service/analytics/_critical_path.py +179 -0
- chrys/service/analytics/_facts.py +919 -0
- chrys/service/analytics/_insights.py +769 -0
- chrys/service/analytics/_metric_ops.py +83 -0
- chrys/service/analytics/_session_projection.py +460 -0
- chrys/service/analytics/_timeline.py +434 -0
- chrys/service/analytics/_turn_graph.py +1237 -0
- chrys/service/analytics/_turns.py +1377 -0
- chrys/service/analytics/_workflows.py +171 -0
- chrys/service/analytics/aggregation.py +1474 -0
- chrys/service/analytics/classification.py +128 -0
- chrys/service/analytics/export.py +803 -0
- chrys/service/analytics/findings.py +282 -0
- chrys/service/analytics/math.py +79 -0
- chrys/service/analytics/model.py +886 -0
- chrys/service/analytics/reader.py +321 -0
- chrys/service/approval/__init__.py +3 -0
- chrys/service/approval/arbitration.py +102 -0
- chrys/service/approval/command_identity.py +109 -0
- chrys/service/approval/correlation.py +57 -0
- chrys/service/approval/grant_store.py +108 -0
- chrys/service/approval/judge.py +589 -0
- chrys/service/approval/level.py +15 -0
- chrys/service/approval/policy.py +209 -0
- chrys/service/approval/reuse.py +223 -0
- chrys/service/approval/reuse_binding.py +133 -0
- chrys/service/approval/safety_classifier.py +536 -0
- chrys/service/approval/turn_context.py +49 -0
- chrys/service/context/__init__.py +3 -0
- chrys/service/context/compaction/__init__.py +38 -0
- chrys/service/context/compaction/breaker.py +156 -0
- chrys/service/context/compaction/budgets.py +93 -0
- chrys/service/context/compaction/calibration.py +77 -0
- chrys/service/context/compaction/compression.py +690 -0
- chrys/service/context/compaction/current_turn_drop.py +458 -0
- chrys/service/context/compaction/events.py +201 -0
- chrys/service/context/compaction/exclusions.py +261 -0
- chrys/service/context/compaction/groups.py +361 -0
- chrys/service/context/compaction/last_words.py +1996 -0
- chrys/service/context/compaction/last_words_state.py +752 -0
- chrys/service/context/compaction/scoped.py +472 -0
- chrys/service/context/compaction/spill.py +1947 -0
- chrys/service/context/compaction/strategy.py +1116 -0
- chrys/service/context/compaction/summaries.py +319 -0
- chrys/service/context/compaction/turns.py +375 -0
- chrys/service/context/manager.py +174 -0
- chrys/service/context/memory_loader.py +792 -0
- chrys/service/context/middleware/__init__.py +3 -0
- chrys/service/context/middleware/usage.py +332 -0
- chrys/service/context/providers/__init__.py +3 -0
- chrys/service/context/providers/context_management.py +432 -0
- chrys/service/context/providers/history.py +647 -0
- chrys/service/context/providers/memory.py +50 -0
- chrys/service/context/tool_content.py +22 -0
- chrys/service/hooks/__init__.py +37 -0
- chrys/service/hooks/detached_worker.py +118 -0
- chrys/service/hooks/docs/README.md +14 -0
- chrys/service/hooks/docs/authoring.md +684 -0
- chrys/service/hooks/docs/configuration.md +499 -0
- chrys/service/hooks/docs/design.md +359 -0
- chrys/service/hooks/docs/project-hooks.md +348 -0
- chrys/service/hooks/events.py +157 -0
- chrys/service/hooks/loader.py +665 -0
- chrys/service/hooks/manager.py +807 -0
- chrys/service/hooks/matcher.py +111 -0
- chrys/service/hooks/outbox.py +294 -0
- chrys/service/hooks/runner.py +572 -0
- chrys/service/hooks/schema.py +407 -0
- chrys/service/llm/__init__.py +7 -0
- chrys/service/llm/_structured_outputs.py +192 -0
- chrys/service/llm/anthropic_messages/__init__.py +9 -0
- chrys/service/llm/anthropic_messages/client.py +254 -0
- chrys/service/llm/anthropic_messages/decode.py +331 -0
- chrys/service/llm/anthropic_messages/history.py +279 -0
- chrys/service/llm/anthropic_messages/request.py +323 -0
- chrys/service/llm/anthropic_messages/server_tools.py +319 -0
- chrys/service/llm/anthropic_messages/stream.py +297 -0
- chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
- chrys/service/llm/chat_completions/__init__.py +9 -0
- chrys/service/llm/chat_completions/client.py +369 -0
- chrys/service/llm/chat_completions/decode.py +338 -0
- chrys/service/llm/chat_completions/history.py +657 -0
- chrys/service/llm/chat_completions/reasoning.py +244 -0
- chrys/service/llm/chat_completions/request.py +192 -0
- chrys/service/llm/chat_completions/stream.py +546 -0
- chrys/service/llm/chat_completions/validation.py +308 -0
- chrys/service/llm/clients.py +613 -0
- chrys/service/llm/images.py +65 -0
- chrys/service/llm/mock.py +413 -0
- chrys/service/llm/observer.py +446 -0
- chrys/service/llm/one_shot.py +106 -0
- chrys/service/llm/openai_exceptions.py +93 -0
- chrys/service/llm/openai_responses/__init__.py +10 -0
- chrys/service/llm/openai_responses/client.py +323 -0
- chrys/service/llm/openai_responses/decode.py +432 -0
- chrys/service/llm/openai_responses/history.py +519 -0
- chrys/service/llm/openai_responses/hosted.py +625 -0
- chrys/service/llm/openai_responses/replay.py +629 -0
- chrys/service/llm/openai_responses/request.py +342 -0
- chrys/service/llm/openai_responses/stream.py +847 -0
- chrys/service/llm/openai_timestamps.py +57 -0
- chrys/service/llm/providers.py +107 -0
- chrys/service/llm/proxy_route.py +54 -0
- chrys/service/llm/raw_http_log.py +309 -0
- chrys/service/llm/route_facts.py +63 -0
- chrys/service/llm/route_sessions.py +49 -0
- chrys/service/llm/sdk_anthropic.py +18 -0
- chrys/service/llm/sdk_headers.py +47 -0
- chrys/service/llm/sdk_openai.py +18 -0
- chrys/service/llm/sdk_retry.py +59 -0
- chrys/service/llm/wire_client.py +246 -0
- chrys/service/mcp/__init__.py +3 -0
- chrys/service/mcp/_connection.py +235 -0
- chrys/service/mcp/_http_transport.py +350 -0
- chrys/service/mcp/_progressive.py +459 -0
- chrys/service/mcp/_stdio_transport.py +614 -0
- chrys/service/mcp/_tool_mixins.py +96 -0
- chrys/service/mcp/adapter.py +1031 -0
- chrys/service/mcp/cache.py +476 -0
- chrys/service/mcp/content.py +32 -0
- chrys/service/mcp/errors.py +169 -0
- chrys/service/mcp/owned.py +1330 -0
- chrys/service/mcp/result_limits.py +168 -0
- chrys/service/mcp/thinking_warning.py +95 -0
- chrys/service/mcp/validation.py +74 -0
- chrys/service/mutations/__init__.py +84 -0
- chrys/service/mutations/coordination.py +894 -0
- chrys/service/mutations/detector.py +458 -0
- chrys/service/mutations/git_calibrator.py +397 -0
- chrys/service/mutations/git_state.py +647 -0
- chrys/service/mutations/pipeline.py +779 -0
- chrys/service/mutations/scanner.py +466 -0
- chrys/service/mutations/snapshot_files.py +233 -0
- chrys/service/mutations/store.py +625 -0
- chrys/service/mutations/tool_names.py +15 -0
- chrys/service/mutations/trace.py +246 -0
- chrys/service/mutations/tracker.py +1289 -0
- chrys/service/mutations/types.py +559 -0
- chrys/service/mutations/workspace_changes.py +1583 -0
- chrys/service/profiles/__init__.py +2 -0
- chrys/service/profiles/agents/__init__.py +3 -0
- chrys/service/profiles/agents/builtins/Code.yaml +199 -0
- chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
- chrys/service/profiles/agents/builtins/General.yaml +114 -0
- chrys/service/profiles/agents/builtins/QA.yaml +128 -0
- chrys/service/profiles/agents/loader.py +808 -0
- chrys/service/profiles/agents/registry.py +727 -0
- chrys/service/profiles/agents/schema.py +371 -0
- chrys/service/profiles/agents/serializer.py +196 -0
- chrys/service/profiles/coercion.py +22 -0
- chrys/service/profiles/models/__init__.py +3 -0
- chrys/service/profiles/models/env_bridge.py +157 -0
- chrys/service/profiles/models/loader.py +282 -0
- chrys/service/profiles/models/options.py +438 -0
- chrys/service/profiles/models/registry.py +104 -0
- chrys/service/profiles/models/resolver.py +286 -0
- chrys/service/profiles/models/schema.py +91 -0
- chrys/service/profiles/models/serializer.py +76 -0
- chrys/service/session/__init__.py +3 -0
- chrys/service/session/checkpoint.py +156 -0
- chrys/service/session/history.py +1616 -0
- chrys/service/session/message_metadata.py +151 -0
- chrys/service/session/persistence.py +435 -0
- chrys/service/session/runtime_metadata.py +210 -0
- chrys/service/session/sub_agent_logs.py +1069 -0
- chrys/service/session/sub_agent_transcript.py +654 -0
- chrys/service/skills/__init__.py +3 -0
- chrys/service/skills/adapter.py +408 -0
- chrys/service/skills/constants.py +8 -0
- chrys/service/skills/loader.py +575 -0
- chrys/service/skills/model.py +130 -0
- chrys/service/skills/provider.py +841 -0
- chrys/service/skills/runner.py +390 -0
- chrys/service/state/__init__.py +3 -0
- chrys/service/state/_fork.py +710 -0
- chrys/service/state/_session_files.py +175 -0
- chrys/service/state/_session_meta.py +552 -0
- chrys/service/state/locks.py +154 -0
- chrys/service/state/serializers.py +156 -0
- chrys/service/state/session_catalog.py +361 -0
- chrys/service/state/session_listing.py +84 -0
- chrys/service/state/session_migration.py +446 -0
- chrys/service/state/session_mru.py +315 -0
- chrys/service/state/store.py +2132 -0
- chrys/service/state/workflow.py +158 -0
- chrys/service/text_blocks.py +47 -0
- chrys/service/todos/__init__.py +3 -0
- chrys/service/todos/reminder.py +31 -0
- chrys/service/todos/tracker.py +58 -0
- chrys/service/tools/__init__.py +3 -0
- chrys/service/tools/approval_targets.py +92 -0
- chrys/service/tools/builtins/__init__.py +3 -0
- chrys/service/tools/builtins/ask_user.py +326 -0
- chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
- chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
- chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
- chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
- chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
- chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
- chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
- chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
- chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
- chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
- chrys/service/tools/builtins/doc_converter/registry.py +55 -0
- chrys/service/tools/builtins/filesystem.py +1071 -0
- chrys/service/tools/builtins/search.py +1265 -0
- chrys/service/tools/builtins/shell.py +967 -0
- chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
- chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
- chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
- chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
- chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
- chrys/service/tools/builtins/shell_filter.py +24 -0
- chrys/service/tools/builtins/sleep.py +91 -0
- chrys/service/tools/builtins/todo.py +64 -0
- chrys/service/tools/builtins/web/__init__.py +6 -0
- chrys/service/tools/builtins/web/build.py +268 -0
- chrys/service/tools/builtins/web/config.py +338 -0
- chrys/service/tools/builtins/web/credentials.py +37 -0
- chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
- chrys/service/tools/builtins/web/fetch/cache.py +68 -0
- chrys/service/tools/builtins/web/fetch/convert.py +56 -0
- chrys/service/tools/builtins/web/fetch/tool.py +223 -0
- chrys/service/tools/builtins/web/hosted.py +61 -0
- chrys/service/tools/builtins/web/http.py +330 -0
- chrys/service/tools/builtins/web/progress.py +18 -0
- chrys/service/tools/builtins/web/search/__init__.py +3 -0
- chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
- chrys/service/tools/builtins/web/search/html.py +152 -0
- chrys/service/tools/builtins/web/search/json_api.py +227 -0
- chrys/service/tools/builtins/web/search/providers.py +77 -0
- chrys/service/tools/builtins/web/search/tool.py +218 -0
- chrys/service/tools/builtins/web/search/types.py +28 -0
- chrys/service/tools/builtins/web/tls_identity.py +66 -0
- chrys/service/tools/kinds.py +115 -0
- chrys/service/tools/names.py +47 -0
- chrys/service/tools/registry.py +242 -0
- chrys/service/tools/result_metadata.py +103 -0
- chrys/service/tools/session_artifacts.py +196 -0
- chrys/service/tools/spill.py +218 -0
- chrys/service/tools/workspace_paths.py +81 -0
- chrys/service/trajectory/__init__.py +11 -0
- chrys/service/trajectory/approvals.py +201 -0
- chrys/service/trajectory/compaction.py +420 -0
- chrys/service/trajectory/fork.py +75 -0
- chrys/service/trajectory/hooks.py +217 -0
- chrys/service/trajectory/items.py +41 -0
- chrys/service/trajectory/preparation.py +374 -0
- chrys/service/trajectory/retries.py +95 -0
- chrys/service/trajectory/revisions.py +129 -0
- chrys/service/trajectory/segmented.py +163 -0
- chrys/service/trajectory/session.py +762 -0
- chrys/service/trajectory/state.py +83 -0
- chrys/service/trajectory/sub_agents.py +169 -0
- chrys/service/trajectory/tombstone.py +262 -0
- chrys/service/trajectory/tools.py +323 -0
- chrys/service/trajectory/validation.py +130 -0
- chrys/service/trajectory/waits.py +161 -0
- chrys/service/trajectory/workflow.py +148 -0
- chrys/service/vision.py +128 -0
- chrys/service/workflows/__init__.py +3 -0
- chrys/service/workflows/admission.py +151 -0
- chrys/service/workflows/artifacts.py +109 -0
- chrys/service/workflows/asks.py +130 -0
- chrys/service/workflows/builtins/__init__.py +12 -0
- chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
- chrys/service/workflows/builtins/demo-workflow.py +756 -0
- chrys/service/workflows/discovery.py +554 -0
- chrys/service/workflows/environment.py +284 -0
- chrys/service/workflows/graph.py +430 -0
- chrys/service/workflows/history.py +82 -0
- chrys/service/workflows/interpreter.py +107 -0
- chrys/service/workflows/journal.py +334 -0
- chrys/service/workflows/layout.py +56 -0
- chrys/service/workflows/ledger.py +149 -0
- chrys/service/workflows/model_selection.py +18 -0
- chrys/service/workflows/orphans.py +209 -0
- chrys/service/workflows/outcomes.py +46 -0
- chrys/service/workflows/protocol.py +210 -0
- chrys/service/workflows/records.py +142 -0
- chrys/service/workflows/scheduler.py +770 -0
- chrys/service/workflows/sdk/__init__.py +51 -0
- chrys/service/workflows/sdk/_ask.py +167 -0
- chrys/service/workflows/sdk/_builder.py +781 -0
- chrys/service/workflows/sdk/_values.py +92 -0
- chrys/service/workflows/sdk_artifact.py +74 -0
- chrys/service/workflows/store.py +494 -0
- chrys/service/workflows/transcript.py +102 -0
- chrys/service/workflows/values.py +73 -0
- chrys/service/workflows/worker_host.py +1820 -0
- chrys/workflows.py +38 -0
- icode_tui-0.29.1.dist-info/METADATA +104 -0
- icode_tui-0.29.1.dist-info/RECORD +1138 -0
- icode_tui-0.29.1.dist-info/WHEEL +4 -0
- icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
- icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
- icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
|
@@ -0,0 +1,1532 @@
|
|
|
1
|
+
# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Diff viewer screen — full-screen file change viewer with per-turn tabs.
|
|
4
|
+
|
|
5
|
+
Layout:
|
|
6
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
7
|
+
│ #app-header │
|
|
8
|
+
├──────────────────────────────────────────────────────────────┤
|
|
9
|
+
│ TabbedContent: T2 │ T5 │ T6 (active) │
|
|
10
|
+
├──────────────────────────────────────────────────────────────┤
|
|
11
|
+
│ ┌──────────────┬───────────────────────────────────────────┐│
|
|
12
|
+
│ │ TreeView │ DiffView / Message ││
|
|
13
|
+
│ │ (directory │ (3/4 width) ││
|
|
14
|
+
│ │ structure) │ ││
|
|
15
|
+
│ │ ─ ─ ─ ─ ─ ─ │ ││
|
|
16
|
+
│ │ External │ ││
|
|
17
|
+
│ │ (flat list) │ ││
|
|
18
|
+
│ └──────────────┴───────────────────────────────────────────┘│
|
|
19
|
+
├──────────────────────────────────────────────────────────────┤
|
|
20
|
+
│ Footer: Esc Back │ Space Split View │ Ctrl+G Change List │
|
|
21
|
+
└──────────────────────────────────────────────────────────────┘
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import functools
|
|
27
|
+
import os
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from contextlib import suppress
|
|
30
|
+
from dataclasses import dataclass, replace
|
|
31
|
+
from pathlib import PurePath
|
|
32
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
33
|
+
|
|
34
|
+
from rich.style import Style
|
|
35
|
+
from rich.text import Text
|
|
36
|
+
from textual import events, on
|
|
37
|
+
from textual.containers import HorizontalGroup, VerticalGroup
|
|
38
|
+
from textual.content import Content
|
|
39
|
+
from textual.css.query import NoMatches
|
|
40
|
+
from textual.message import Message
|
|
41
|
+
from textual.screen import Screen
|
|
42
|
+
from textual.widget import Widget
|
|
43
|
+
from textual.widgets import Static, TabbedContent, TabPane, Tree
|
|
44
|
+
|
|
45
|
+
from chrys.app.tui.behaviors.right_click_copy import RightClickScreenCopyMixin
|
|
46
|
+
from chrys.app.tui.binding_display import localized_binding
|
|
47
|
+
from chrys.app.tui.i18n import LocaleController, render_str, widget_localizer
|
|
48
|
+
from chrys.app.tui.util.diff_entries import DiffFileEntry, DiffLoadResult, entry_has_visible_change
|
|
49
|
+
from chrys.app.tui.util.mutation_sources import merge_mutation_source as _merge_source
|
|
50
|
+
from chrys.app.tui.util.removal import remove_children_shielded, remove_shielded
|
|
51
|
+
from chrys.app.tui.widgets import ChrysLoadingIndicator
|
|
52
|
+
from chrys.app.tui.widgets.chrome.footer import ChrysFooter
|
|
53
|
+
from chrys.foundation.i18n import DisplayPath, MessageDef, MessageRef, msg
|
|
54
|
+
from chrys.foundation.i18n.formatting import format_message
|
|
55
|
+
from chrys.foundation.models.mutation_scope import MutationScope, WorkflowRunScope
|
|
56
|
+
from chrys.foundation.platform.paths import display_relpath
|
|
57
|
+
from chrys.service.mutations.types import MutationOp, MutationProvenance, MutationSource, SnapshotSkipReason
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from textual.app import ComposeResult
|
|
61
|
+
from textual.widgets._tree import TreeNode
|
|
62
|
+
|
|
63
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
64
|
+
from chrys.service.state.store import StateStore
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Key used in Rich ``Style.meta`` to mark the ``[x]``/``[ ]``/``[~]``
|
|
68
|
+
# span of a tree node label as "click here to toggle the checkbox".
|
|
69
|
+
# Rich merges ``meta`` dicts on style addition, so this key survives
|
|
70
|
+
# Textual's line-level stylize pass — see ``CheckableTree._on_click``.
|
|
71
|
+
_CHECK_TOGGLE_META_KEY = "chrys_check_toggle"
|
|
72
|
+
_CHECK_TOGGLE_META = {_CHECK_TOGGLE_META_KEY: True}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Constants
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
_BACK_BINDING = msg("tui.binding.back", fallback="Back")
|
|
80
|
+
_TOGGLE_SPLIT_VIEW_BINDING = msg("tui.binding.toggle_split_view", fallback="Toggle Split View")
|
|
81
|
+
_TOGGLE_CHANGE_LIST_BINDING = msg("tui.binding.toggle_change_list", fallback="Toggle Change List")
|
|
82
|
+
_DIFF_VIEWER_TITLE = msg("tui.diff.title", fallback="Diff Viewer")
|
|
83
|
+
_NO_FILE_CHANGES = msg("tui.diff.no_file_changes", fallback="No file changes to show")
|
|
84
|
+
_NO_EOL = msg("tui.diff.eol.none", fallback="No EOL")
|
|
85
|
+
_MIXED_EOL = msg("tui.diff.eol.mixed", fallback="Mixed")
|
|
86
|
+
_BADGE_MODIFIED_INTERNALLY_AND_EXTERNALLY = msg(
|
|
87
|
+
"tui.diff.badge.modified_internally_and_externally",
|
|
88
|
+
fallback="modified internally and externally",
|
|
89
|
+
)
|
|
90
|
+
_BADGE_IMPLICITLY_DETECTED = msg("tui.diff.badge.implicitly_detected", fallback="implicitly detected")
|
|
91
|
+
_BADGE_AUTHORSHIP_UNVERIFIED = msg("tui.diff.badge.authorship_unverified", fallback="authorship unverified")
|
|
92
|
+
_SKIP_FILE_TOO_LARGE = msg("tui.diff.skip_reason.file_too_large", fallback="file too large")
|
|
93
|
+
_SKIP_BINARY_FILE = msg("tui.diff.skip_reason.binary_file", fallback="binary file")
|
|
94
|
+
_SKIP_UNREADABLE = msg("tui.diff.skip_reason.unreadable", fallback="file unreadable")
|
|
95
|
+
_CHANGE_LIST = msg("tui.diff.change_list", fallback="Change List")
|
|
96
|
+
_SELECT_FILE = msg("tui.diff.select_file", fallback="Select a file to view changes")
|
|
97
|
+
_NO_BACKUP = msg("tui.diff.no_backup", fallback=" (no backup)")
|
|
98
|
+
_CONTENT_NOT_BACKED_UP = msg(
|
|
99
|
+
"tui.diff.content_not_backed_up",
|
|
100
|
+
fallback="Content not backed up ({reason}): {path}. No diff to display.",
|
|
101
|
+
)
|
|
102
|
+
_BINARY_FILE = msg("tui.diff.binary_file", fallback="Binary file: {path}")
|
|
103
|
+
_FILE_MOVED = msg("tui.diff.file_moved", fallback="File moved: {old_path} → {new_path}")
|
|
104
|
+
_ONLY_LINE_ENDINGS_CHANGED = msg(
|
|
105
|
+
"tui.diff.only_line_endings_changed",
|
|
106
|
+
fallback="Only line endings changed. No line content changes to display.",
|
|
107
|
+
)
|
|
108
|
+
_ONLY_BYTE_REPRESENTATION_CHANGED = msg(
|
|
109
|
+
"tui.diff.only_byte_representation_changed",
|
|
110
|
+
fallback="Only file encoding or byte-level representation changed. No line content changes to display.",
|
|
111
|
+
)
|
|
112
|
+
_DIFF_VIEWER_SESSION_TITLE = msg(
|
|
113
|
+
"tui.diff.session_title",
|
|
114
|
+
fallback="Diff Viewer - Session: {session_id}",
|
|
115
|
+
)
|
|
116
|
+
_LOAD_ERROR = msg(
|
|
117
|
+
"tui.diff.load_error",
|
|
118
|
+
fallback="Error: unable to load diff viewer — {error}",
|
|
119
|
+
)
|
|
120
|
+
_EXTERNAL_TREE = msg("tui.diff.external_tree", fallback="External")
|
|
121
|
+
_TURN_TAB = msg("tui.diff.tab.turn", fallback="T{turn_id}")
|
|
122
|
+
_RUN_TAB = msg("tui.diff.tab.run", fallback="Run {number}")
|
|
123
|
+
_ALL_TAB = msg("tui.diff.tab.all", fallback="All")
|
|
124
|
+
|
|
125
|
+
_OP_SEGMENTS: dict[MutationOp, list[tuple[str, str]]] = {
|
|
126
|
+
MutationOp.MODIFY: [("[", "bold orange1"), ("+", "bold green"), ("-", "bold red"), ("]", "bold orange1")],
|
|
127
|
+
MutationOp.CREATE: [("[+]", "bold green")],
|
|
128
|
+
MutationOp.DELETE: [("[-]", "bold red")],
|
|
129
|
+
MutationOp.MOVE: [("[→]", "bold cyan")],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Checkbox prefixes used when DiffPeriodPane runs in checkable mode
|
|
133
|
+
# (currently only from the rollback modal). Tri-state on folders:
|
|
134
|
+
# [x] all descendants checked, [ ] none, [~] mixed. Each entry is a
|
|
135
|
+
# ``(glyph, base_style)`` pair; the label builder merges in the
|
|
136
|
+
# click-toggle meta so the span is identifiable as a click target.
|
|
137
|
+
_CHECK_SEGMENTS: dict[str, tuple[str, str]] = {
|
|
138
|
+
"on": ("[x]", "bold green"),
|
|
139
|
+
"off": ("[ ]", "bold white"),
|
|
140
|
+
"mixed": ("[~]", "bold yellow"),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _entry_is_eol_only_change(entry: DiffFileEntry) -> bool:
|
|
145
|
+
"""Return whether a MODIFY changed only line endings / final newline."""
|
|
146
|
+
return (
|
|
147
|
+
not entry.is_binary
|
|
148
|
+
and entry.operation is MutationOp.MODIFY
|
|
149
|
+
and entry.before_text != entry.after_text
|
|
150
|
+
and entry.before_text.splitlines() == entry.after_text.splitlines()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _entry_is_metadata_only_change(entry: DiffFileEntry) -> bool:
|
|
155
|
+
"""Return whether bytes changed but decoded line content did not."""
|
|
156
|
+
return (
|
|
157
|
+
not entry.is_binary
|
|
158
|
+
and entry.operation is MutationOp.MODIFY
|
|
159
|
+
and entry.bytes_changed
|
|
160
|
+
and entry.before_text == entry.after_text
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _text_eol_label(
|
|
165
|
+
text: str,
|
|
166
|
+
render_message: Callable[[MessageRef], str] = format_message,
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Return a compact label for the line endings present in ``text``."""
|
|
169
|
+
crlf = text.count("\r\n")
|
|
170
|
+
cr = text.count("\r") - crlf
|
|
171
|
+
lf = text.count("\n") - crlf
|
|
172
|
+
kinds = int(crlf > 0) + int(cr > 0) + int(lf > 0)
|
|
173
|
+
if kinds == 0:
|
|
174
|
+
return render_message(_NO_EOL.bind())
|
|
175
|
+
if kinds > 1:
|
|
176
|
+
return render_message(_MIXED_EOL.bind())
|
|
177
|
+
if crlf:
|
|
178
|
+
return "CRLF"
|
|
179
|
+
if cr:
|
|
180
|
+
return "CR"
|
|
181
|
+
return "LF"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _entry_eol_text(entry: DiffFileEntry) -> str:
|
|
185
|
+
"""Return the entry text that represents the displayed file state."""
|
|
186
|
+
if entry.operation is MutationOp.DELETE:
|
|
187
|
+
return entry.before_text
|
|
188
|
+
return entry.after_text
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# One-line legend per provenance badge, shown in the diff-content
|
|
192
|
+
# subtitle so the markers are self-explanatory in place.
|
|
193
|
+
_BADGE_LEGENDS: dict[str, MessageDef] = {
|
|
194
|
+
"!": _BADGE_MODIFIED_INTERNALLY_AND_EXTERNALLY,
|
|
195
|
+
"?": _BADGE_IMPLICITLY_DETECTED,
|
|
196
|
+
"~": _BADGE_AUTHORSHIP_UNVERIFIED,
|
|
197
|
+
}
|
|
198
|
+
_BADGE_LEGEND_ORDER = "!?~"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _entry_content_subtitle(
|
|
202
|
+
entry: DiffFileEntry,
|
|
203
|
+
render_message: Callable[[MessageRef], str] = format_message,
|
|
204
|
+
) -> Text:
|
|
205
|
+
"""Build the diff-content subtitle: badge legend + EOL and encoding.
|
|
206
|
+
|
|
207
|
+
Format: ``!: legend - ?: legend - ~: legend - LF - UTF-8``, with
|
|
208
|
+
only the badges the entry actually carries.
|
|
209
|
+
"""
|
|
210
|
+
encoding = entry.encoding.upper()
|
|
211
|
+
tail = encoding if entry.is_binary else f"{_text_eol_label(_entry_eol_text(entry), render_message)} - {encoding}"
|
|
212
|
+
parts: list[Text] = []
|
|
213
|
+
for marker, style in sorted(_entry_source_suffix(entry), key=lambda ms: _BADGE_LEGEND_ORDER.index(ms[0])):
|
|
214
|
+
legend = Text()
|
|
215
|
+
legend.append(marker, style)
|
|
216
|
+
legend.append(f": {render_message(_BADGE_LEGENDS[marker].bind())}")
|
|
217
|
+
parts.append(legend)
|
|
218
|
+
parts.append(Text(tail))
|
|
219
|
+
return Text(" - ").join(parts)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Human-readable labels for ``DiffFileEntry.content_omitted`` values
|
|
223
|
+
# (``SnapshotSkipReason`` — why SnapshotPolicy withheld the backup).
|
|
224
|
+
_SKIP_REASON_LABELS: dict[str, MessageDef] = {
|
|
225
|
+
SnapshotSkipReason.TOO_LARGE.value: _SKIP_FILE_TOO_LARGE,
|
|
226
|
+
SnapshotSkipReason.BINARY.value: _SKIP_BINARY_FILE,
|
|
227
|
+
SnapshotSkipReason.UNREADABLE.value: _SKIP_UNREADABLE,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _entry_source_suffix(entry: DiffFileEntry) -> list[tuple[str, str]]:
|
|
232
|
+
"""Provenance badges rendered right-aligned at the end of a leaf row.
|
|
233
|
+
|
|
234
|
+
``?`` implicit detection, ``~`` window-diff inference (assumed
|
|
235
|
+
provenance), ``!`` contested — a peer chrys session also wrote the
|
|
236
|
+
path, so disk may differ from "our" after state and rollback will
|
|
237
|
+
exclude it. ``!`` is dim while the row's own attribution is itself
|
|
238
|
+
inference (``?``/``~`` rows) and bold yellow on proven rows, where
|
|
239
|
+
it flags a hard both-sessions-wrote conflict.
|
|
240
|
+
"""
|
|
241
|
+
badges: list[tuple[str, str]] = []
|
|
242
|
+
implicit = entry.source == MutationSource.IMPLICIT.value
|
|
243
|
+
if implicit:
|
|
244
|
+
badges.append(("?", "bold yellow"))
|
|
245
|
+
elif entry.inferred:
|
|
246
|
+
badges.append(("~", "dim"))
|
|
247
|
+
if entry.contested:
|
|
248
|
+
badges.append(("!", "dim" if (implicit or entry.inferred) else "bold yellow"))
|
|
249
|
+
return badges
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _checkbox_text(state: str) -> Text:
|
|
253
|
+
"""Build the Rich ``Text`` for a checkbox glyph carrying toggle meta.
|
|
254
|
+
|
|
255
|
+
The meta key is inspected by :class:`CheckableTree._on_click` to
|
|
256
|
+
detect mouse clicks that land on the ``[x]`` span so they can be
|
|
257
|
+
routed to a toggle action instead of the default node-select.
|
|
258
|
+
"""
|
|
259
|
+
glyph, style_str = _CHECK_SEGMENTS[state]
|
|
260
|
+
return Text(glyph, style=Style.parse(style_str) + Style(meta=_CHECK_TOGGLE_META))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class _FolderNodeData:
|
|
265
|
+
"""Sentinel attached to directory tree nodes so we can distinguish
|
|
266
|
+
them from leaf (file) nodes without resorting to label parsing."""
|
|
267
|
+
|
|
268
|
+
name: str
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class CheckableTree(Tree):
|
|
272
|
+
"""``Tree`` subclass that posts a :class:`CheckToggled` message when
|
|
273
|
+
the user clicks on a checkbox span in a node label.
|
|
274
|
+
|
|
275
|
+
The checkbox span is identified by the
|
|
276
|
+
:data:`_CHECK_TOGGLE_META_KEY` entry in the Rich ``Style.meta`` of
|
|
277
|
+
the clicked character. When detected we:
|
|
278
|
+
|
|
279
|
+
1. Move ``cursor_line`` to the clicked row (for visual feedback —
|
|
280
|
+
the cursor "follows" the checkbox the user toggled).
|
|
281
|
+
2. Post the message so the owning widget can mutate its own
|
|
282
|
+
selection state.
|
|
283
|
+
3. Stop propagation so the base Tree does **not** also run
|
|
284
|
+
``select_cursor`` — we don't want a checkbox click to also
|
|
285
|
+
switch which diff is shown in the preview pane.
|
|
286
|
+
|
|
287
|
+
Clicks that land outside the checkbox span fall through to the
|
|
288
|
+
default Tree click handler (select + show diff / expand / collapse).
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
class CheckToggled(Message):
|
|
292
|
+
"""Posted when the user clicks a checkbox span."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, node: TreeNode) -> None: # type: ignore[type-arg]
|
|
295
|
+
super().__init__()
|
|
296
|
+
self.node = node
|
|
297
|
+
|
|
298
|
+
async def _on_click(self, event: events.Click) -> None:
|
|
299
|
+
# ``prevent_default`` is critical here: Textual dispatches
|
|
300
|
+
# ``_on_click`` for *every* class in the MRO, so without it the
|
|
301
|
+
# base ``Tree._on_click`` would run in addition to ours. That
|
|
302
|
+
# caused two bugs:
|
|
303
|
+
# 1. Clicking ``[x]`` also fired ``select_cursor`` →
|
|
304
|
+
# ``NodeSelected`` → ``_expand_node_on_select`` (auto_expand
|
|
305
|
+
# is True by default), collapsing/expanding the folder.
|
|
306
|
+
# 2. Clicking the ▼/▶ guide toggled the node twice — once
|
|
307
|
+
# via our explicit ``super()._on_click`` call below and
|
|
308
|
+
# once via the MRO dispatch — so the visible state never
|
|
309
|
+
# changed.
|
|
310
|
+
# ``event.stop()`` only halts bubbling to parents; it does not
|
|
311
|
+
# stop sibling MRO handlers from being invoked on this widget.
|
|
312
|
+
event.prevent_default()
|
|
313
|
+
meta = event.style.meta
|
|
314
|
+
line = meta.get("line")
|
|
315
|
+
if meta.get(_CHECK_TOGGLE_META_KEY) and line is not None:
|
|
316
|
+
node = self.get_node_at_line(line)
|
|
317
|
+
if node is not None:
|
|
318
|
+
# Move the cursor so the user sees which row they just
|
|
319
|
+
# toggled; skip scrolling so it's a pure visual hint.
|
|
320
|
+
self.cursor_line = line
|
|
321
|
+
self.post_message(self.CheckToggled(node))
|
|
322
|
+
event.stop()
|
|
323
|
+
return
|
|
324
|
+
# Not a checkbox click — let the base class do its thing
|
|
325
|
+
# (select + fire NodeSelected, or expand/collapse the guide).
|
|
326
|
+
await super()._on_click(event)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Data loading helpers
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_period_entries(tracker, turn, cwd: str) -> list[DiffFileEntry]:
|
|
335
|
+
"""Build DiffFileEntry list for a single turn from a deserialized tracker."""
|
|
336
|
+
from chrys.foundation.text.encoding import EncodingDetector, decode_bytes
|
|
337
|
+
|
|
338
|
+
last_op: dict[str, MutationOp] = {}
|
|
339
|
+
old_paths: dict[str, str | None] = {}
|
|
340
|
+
sources: dict[str, str] = {}
|
|
341
|
+
for m in turn.mutations:
|
|
342
|
+
# Foreign rows are excluded from the summary fold below; keep
|
|
343
|
+
# this metadata consistent or a peer's op/old_path/source leaks
|
|
344
|
+
# onto a row whose hashes came from our rows only.
|
|
345
|
+
if m.provenance is MutationProvenance.FOREIGN:
|
|
346
|
+
continue
|
|
347
|
+
last_op[m.path] = m.operation
|
|
348
|
+
old_paths[m.path] = m.old_path
|
|
349
|
+
sources[m.path] = _merge_source(sources.get(m.path, ""), m.source.value)
|
|
350
|
+
if m.old_path:
|
|
351
|
+
sources[m.old_path] = _merge_source(sources.get(m.old_path, ""), m.source.value)
|
|
352
|
+
|
|
353
|
+
detector = EncodingDetector()
|
|
354
|
+
file_summary = {
|
|
355
|
+
path: diff
|
|
356
|
+
for path, diff in tracker.get_period_file_summary(turn.scope).items()
|
|
357
|
+
# A skip-marked diff is not net-zero even with (None, None)
|
|
358
|
+
# hashes — the content exists but was never backed up.
|
|
359
|
+
if not diff.is_net_zero or diff.content_unavailable or last_op.get(path) is MutationOp.MOVE
|
|
360
|
+
}
|
|
361
|
+
if not file_summary:
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
# A MOVE card already represents both paths. Keep the complete two-path
|
|
365
|
+
# summary for rollback/analytics, but avoid a duplicate DELETE card unless
|
|
366
|
+
# the source was subsequently recreated or the destination no longer moves.
|
|
367
|
+
moved_sources = {
|
|
368
|
+
old_path
|
|
369
|
+
for path, old_path in old_paths.items()
|
|
370
|
+
if old_path is not None and path in file_summary and last_op.get(path) is MutationOp.MOVE
|
|
371
|
+
}
|
|
372
|
+
entries: list[DiffFileEntry] = []
|
|
373
|
+
store = tracker.store
|
|
374
|
+
for file_path, diff in file_summary.items():
|
|
375
|
+
if file_path in moved_sources and diff.after is None and diff.after_skip is None:
|
|
376
|
+
continue
|
|
377
|
+
# Content withheld by SnapshotPolicy (too large / binary): no
|
|
378
|
+
# blobs to render, but the mutation still happened — keep the
|
|
379
|
+
# row visible with a ``content_omitted`` marker (matching the
|
|
380
|
+
# live /diff behavior) instead of silently dropping the event.
|
|
381
|
+
# MOVE keeps its plain rename row — that info is content-free.
|
|
382
|
+
content_omitted = ""
|
|
383
|
+
if diff.content_unavailable and last_op.get(file_path) is not MutationOp.MOVE:
|
|
384
|
+
skip = diff.after_skip or diff.before_skip
|
|
385
|
+
content_omitted = skip.value if skip else ""
|
|
386
|
+
|
|
387
|
+
before_bytes = store.read_blob(diff.before) if diff.before else None
|
|
388
|
+
after_bytes = store.read_blob(diff.after) if diff.after else None
|
|
389
|
+
|
|
390
|
+
binary = bool(
|
|
391
|
+
(before_bytes and EncodingDetector.looks_binary(before_bytes))
|
|
392
|
+
or (after_bytes and EncodingDetector.looks_binary(after_bytes))
|
|
393
|
+
)
|
|
394
|
+
before_text = "" if binary or before_bytes is None else decode_bytes(before_bytes)
|
|
395
|
+
after_text = "" if binary or after_bytes is None else decode_bytes(after_bytes)
|
|
396
|
+
|
|
397
|
+
# Detect encoding from the latest available content
|
|
398
|
+
enc = "utf-8"
|
|
399
|
+
content_for_detect = after_bytes or before_bytes
|
|
400
|
+
if content_for_detect and not binary:
|
|
401
|
+
result = detector.detect_from_bytes(content_for_detect)
|
|
402
|
+
enc = result.encoding or "utf-8"
|
|
403
|
+
|
|
404
|
+
op = last_op.get(file_path, MutationOp.MODIFY)
|
|
405
|
+
rel = display_relpath(file_path, cwd)
|
|
406
|
+
|
|
407
|
+
entry = DiffFileEntry(
|
|
408
|
+
path=file_path,
|
|
409
|
+
rel_path=rel,
|
|
410
|
+
operation=op,
|
|
411
|
+
old_path=old_paths.get(file_path),
|
|
412
|
+
before_text=before_text,
|
|
413
|
+
after_text=after_text,
|
|
414
|
+
is_binary=binary,
|
|
415
|
+
encoding=enc,
|
|
416
|
+
bytes_changed=not diff.is_net_zero or bool(content_omitted),
|
|
417
|
+
before_hash=diff.before,
|
|
418
|
+
after_hash=diff.after,
|
|
419
|
+
source=sources.get(file_path, ""),
|
|
420
|
+
content_omitted=content_omitted,
|
|
421
|
+
contested=diff.contested,
|
|
422
|
+
inferred=diff.inferred,
|
|
423
|
+
)
|
|
424
|
+
if entry_has_visible_change(entry):
|
|
425
|
+
entries.append(entry)
|
|
426
|
+
|
|
427
|
+
entries.sort(key=lambda e: e.rel_path)
|
|
428
|
+
return entries
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _build_session_entries(tracker, cwd: str) -> list[DiffFileEntry]:
|
|
432
|
+
"""Build DiffFileEntry list for the net session-wide changeset.
|
|
433
|
+
|
|
434
|
+
Aggregates mutations across all turns: each file's ``before_text`` is
|
|
435
|
+
its pre-session state (from the earliest turn that touched it) and
|
|
436
|
+
``after_text`` is its latest state after the last mutation.
|
|
437
|
+
Net-zero churn is already filtered by ``get_session_file_summary``.
|
|
438
|
+
"""
|
|
439
|
+
from chrys.foundation.text.encoding import EncodingDetector, decode_bytes
|
|
440
|
+
|
|
441
|
+
detector = EncodingDetector()
|
|
442
|
+
file_summary = tracker.get_session_file_summary()
|
|
443
|
+
if not file_summary:
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
# Track last-seen old_path across the session (for MOVE display).
|
|
447
|
+
# Iterate in turn_id order so "last-seen" is chronological — turns
|
|
448
|
+
# can be appended with non-monotonic ids via session restore/retry.
|
|
449
|
+
old_paths: dict[str, str | None] = {}
|
|
450
|
+
sources: dict[str, str] = {}
|
|
451
|
+
for turn in sorted(tracker.get_all_periods(), key=lambda period: period.period_index):
|
|
452
|
+
for m in turn.mutations:
|
|
453
|
+
# Match the summary fold: foreign rows must not contribute
|
|
454
|
+
# a MOVE's old_path or their source marker to our entries.
|
|
455
|
+
if m.provenance is MutationProvenance.FOREIGN:
|
|
456
|
+
continue
|
|
457
|
+
sources[m.path] = _merge_source(sources.get(m.path, ""), m.source.value)
|
|
458
|
+
if m.old_path:
|
|
459
|
+
old_paths[m.path] = m.old_path
|
|
460
|
+
sources[m.old_path] = _merge_source(sources.get(m.old_path, ""), m.source.value)
|
|
461
|
+
|
|
462
|
+
entries: list[DiffFileEntry] = []
|
|
463
|
+
store = tracker.store
|
|
464
|
+
for file_path, diff in file_summary.items():
|
|
465
|
+
# Content withheld by SnapshotPolicy on either side: keep the
|
|
466
|
+
# row (it IS a net change — fully-skipped files are already
|
|
467
|
+
# netted out by the tracker) but mark it so the pane renders a
|
|
468
|
+
# message instead of an untruthful empty diff.
|
|
469
|
+
content_omitted = ""
|
|
470
|
+
if diff.content_unavailable:
|
|
471
|
+
skip = diff.after_skip or diff.before_skip
|
|
472
|
+
content_omitted = skip.value if skip else ""
|
|
473
|
+
|
|
474
|
+
before_bytes = store.read_blob(diff.before) if diff.before else None
|
|
475
|
+
after_bytes = store.read_blob(diff.after) if diff.after else None
|
|
476
|
+
|
|
477
|
+
binary = bool(
|
|
478
|
+
(before_bytes and EncodingDetector.looks_binary(before_bytes))
|
|
479
|
+
or (after_bytes and EncodingDetector.looks_binary(after_bytes))
|
|
480
|
+
)
|
|
481
|
+
before_text = "" if binary or before_bytes is None else decode_bytes(before_bytes)
|
|
482
|
+
after_text = "" if binary or after_bytes is None else decode_bytes(after_bytes)
|
|
483
|
+
|
|
484
|
+
# Derive net op from skip-aware existence (a side with a
|
|
485
|
+
# withheld backup still exists on disk). Net-zero
|
|
486
|
+
# (before == after) is already filtered by the tracker, so if
|
|
487
|
+
# both hashes are present here, they necessarily differ → MODIFY.
|
|
488
|
+
# This is more accurate than the last-recorded op, which for a
|
|
489
|
+
# pre-existing-file DELETE→CREATE sequence would be CREATE but
|
|
490
|
+
# the true net effect is MODIFY.
|
|
491
|
+
if not diff.before_exists and diff.after_exists:
|
|
492
|
+
op = MutationOp.CREATE
|
|
493
|
+
elif diff.before_exists and not diff.after_exists:
|
|
494
|
+
op = MutationOp.DELETE
|
|
495
|
+
else:
|
|
496
|
+
op = MutationOp.MODIFY
|
|
497
|
+
|
|
498
|
+
enc = "utf-8"
|
|
499
|
+
content_for_detect = after_bytes or before_bytes
|
|
500
|
+
if content_for_detect and not binary:
|
|
501
|
+
det = detector.detect_from_bytes(content_for_detect)
|
|
502
|
+
enc = det.encoding or "utf-8"
|
|
503
|
+
|
|
504
|
+
rel = display_relpath(file_path, cwd)
|
|
505
|
+
|
|
506
|
+
entry = DiffFileEntry(
|
|
507
|
+
path=file_path,
|
|
508
|
+
rel_path=rel,
|
|
509
|
+
operation=op,
|
|
510
|
+
old_path=old_paths.get(file_path),
|
|
511
|
+
before_text=before_text,
|
|
512
|
+
after_text=after_text,
|
|
513
|
+
is_binary=binary,
|
|
514
|
+
encoding=enc,
|
|
515
|
+
bytes_changed=True,
|
|
516
|
+
before_hash=diff.before,
|
|
517
|
+
after_hash=diff.after,
|
|
518
|
+
source=sources.get(file_path, ""),
|
|
519
|
+
content_omitted=content_omitted,
|
|
520
|
+
contested=diff.contested,
|
|
521
|
+
inferred=diff.inferred,
|
|
522
|
+
)
|
|
523
|
+
if entry_has_visible_change(entry):
|
|
524
|
+
entries.append(entry)
|
|
525
|
+
|
|
526
|
+
entries.sort(key=lambda e: e.rel_path)
|
|
527
|
+
return entries
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def load_diff_entries_by_period(
|
|
531
|
+
state_store: StateStore,
|
|
532
|
+
session_id: str,
|
|
533
|
+
cwd: str,
|
|
534
|
+
*,
|
|
535
|
+
mutations_data: dict | None = None,
|
|
536
|
+
) -> DiffLoadResult:
|
|
537
|
+
"""Build execution-scoped and session-wide diffs from persisted state."""
|
|
538
|
+
import json
|
|
539
|
+
|
|
540
|
+
from chrys.service.mutations.store import SnapshotStore
|
|
541
|
+
from chrys.service.mutations.tracker import MutationTracker
|
|
542
|
+
from chrys.service.state.store import SESSION_FILE_NAME
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
path = state_store.session_dir(session_id) / SESSION_FILE_NAME
|
|
546
|
+
if mutations_data is None:
|
|
547
|
+
if not path.exists():
|
|
548
|
+
return DiffLoadResult.empty()
|
|
549
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
550
|
+
state = raw.get("state", {})
|
|
551
|
+
mutations_data = state.get("chrys_mutations")
|
|
552
|
+
if not mutations_data:
|
|
553
|
+
return DiffLoadResult.empty()
|
|
554
|
+
|
|
555
|
+
# ``path`` came from ``state_store._session_file(session_id)``, so
|
|
556
|
+
# ``path.parent`` is already the canonical session folder. No
|
|
557
|
+
# need to rederive it — which would re-sanitize the id and break
|
|
558
|
+
# for canonical (dashed) UUIDs whose folder name is the 12-char
|
|
559
|
+
# dashless projection, not the raw id.
|
|
560
|
+
tracker = MutationTracker.deserialize(mutations_data, SnapshotStore(path.parent))
|
|
561
|
+
except Exception:
|
|
562
|
+
return DiffLoadResult.empty()
|
|
563
|
+
|
|
564
|
+
periods = sorted(tracker.get_all_periods(), key=lambda period: period.period_index)
|
|
565
|
+
entries_by_period: dict[int, list[DiffFileEntry]] = {}
|
|
566
|
+
scopes: dict[int, MutationScope] = {}
|
|
567
|
+
for number, period in enumerate(periods, start=1):
|
|
568
|
+
scopes[number] = period.scope
|
|
569
|
+
entries = _build_period_entries(tracker, period, cwd) if period.mutations else []
|
|
570
|
+
if entries:
|
|
571
|
+
entries_by_period[number] = entries
|
|
572
|
+
|
|
573
|
+
return DiffLoadResult(
|
|
574
|
+
all_entries=_build_session_entries(tracker, cwd),
|
|
575
|
+
per_period_entries=entries_by_period,
|
|
576
|
+
total_periods=len(periods),
|
|
577
|
+
scopes=scopes,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# DiffPeriodPane — per-turn content widget
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _is_within_cwd(rel_path: str) -> bool:
|
|
587
|
+
"""Check if a relative path stays within the CWD (doesn't escape with ..)."""
|
|
588
|
+
return not rel_path.startswith("..") and not os.path.isabs(rel_path)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _entry_display_key(entry: DiffFileEntry) -> str:
|
|
592
|
+
"""Key matching where an entry renders in the change-list tree."""
|
|
593
|
+
display_path = entry.rel_path if _is_within_cwd(entry.rel_path) else entry.path
|
|
594
|
+
return os.path.normpath(display_path)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def filter_and_dedupe_entries(entries: list[DiffFileEntry]) -> list[DiffFileEntry]:
|
|
598
|
+
"""Collapse duplicate tree rows and drop entries with no visible change."""
|
|
599
|
+
latest_by_key: dict[str, DiffFileEntry] = {}
|
|
600
|
+
key_order: list[str] = []
|
|
601
|
+
for entry in entries:
|
|
602
|
+
key = _entry_display_key(entry)
|
|
603
|
+
if key not in latest_by_key:
|
|
604
|
+
key_order.append(key)
|
|
605
|
+
latest_by_key[key] = entry
|
|
606
|
+
continue
|
|
607
|
+
# Builders should already aggregate by file. If an older session
|
|
608
|
+
# or live source still hands us duplicates, the later entry reflects
|
|
609
|
+
# the newest net state for that tree row. Badges are sticky
|
|
610
|
+
# across the collapse (any contested/inferred row marks the key).
|
|
611
|
+
prev = latest_by_key[key]
|
|
612
|
+
latest_by_key[key] = replace(
|
|
613
|
+
entry,
|
|
614
|
+
source=_merge_source(prev.source, entry.source),
|
|
615
|
+
contested=prev.contested or entry.contested,
|
|
616
|
+
inferred=prev.inferred or entry.inferred,
|
|
617
|
+
)
|
|
618
|
+
return [latest_by_key[key] for key in key_order if entry_has_visible_change(latest_by_key[key])]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _entry_all_tab_key(entry: DiffFileEntry) -> tuple[str, str, MutationOp, str | None, str, str, str]:
|
|
622
|
+
"""Projected entry identity for deciding whether the All tab is redundant."""
|
|
623
|
+
return (
|
|
624
|
+
entry.path,
|
|
625
|
+
entry.rel_path,
|
|
626
|
+
entry.operation,
|
|
627
|
+
entry.old_path,
|
|
628
|
+
entry.before_text,
|
|
629
|
+
entry.after_text,
|
|
630
|
+
entry.source,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _should_show_all_tab(all_entries: list[DiffFileEntry], periods_data: dict[int, list[DiffFileEntry]]) -> bool:
|
|
635
|
+
"""Return whether the session-wide All tab adds distinct information."""
|
|
636
|
+
if not all_entries:
|
|
637
|
+
return False
|
|
638
|
+
if len(periods_data) != 1:
|
|
639
|
+
return True
|
|
640
|
+
all_keys = [_entry_all_tab_key(entry) for entry in filter_and_dedupe_entries(all_entries)]
|
|
641
|
+
only_turn_keys = [
|
|
642
|
+
_entry_all_tab_key(entry) for entry in filter_and_dedupe_entries(next(iter(periods_data.values())))
|
|
643
|
+
]
|
|
644
|
+
return all_keys != only_turn_keys
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class DiffPeriodPane(Widget):
|
|
648
|
+
"""Per-turn changelist + diff viewer.
|
|
649
|
+
|
|
650
|
+
Contains a directory-structured tree for workspace files, an optional
|
|
651
|
+
flat list for external files, and a DiffView panel on the right.
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
class SelectionChanged(Message):
|
|
655
|
+
"""Posted whenever the set of checked leaves changes.
|
|
656
|
+
|
|
657
|
+
Only fires in checkable mode — the ``/diff`` screen never sees
|
|
658
|
+
this. Consumers (currently the rollback modal) use it to keep
|
|
659
|
+
a "Revert N files" affordance in sync with the tree. Both
|
|
660
|
+
totals are included so the consumer doesn't have to track
|
|
661
|
+
``len(entries)`` separately.
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
def __init__(self, selected_count: int, total_count: int) -> None:
|
|
665
|
+
super().__init__()
|
|
666
|
+
self.selected_count = selected_count
|
|
667
|
+
self.total_count = total_count
|
|
668
|
+
|
|
669
|
+
def __init__(self, entries: list[DiffFileEntry], *, checkable: bool = False, lazy: bool = False) -> None:
|
|
670
|
+
super().__init__()
|
|
671
|
+
self._entries = filter_and_dedupe_entries(entries)
|
|
672
|
+
self._diff_cache: dict[str, DiffView] = {} # path → DiffView widget
|
|
673
|
+
self._active_path: str | None = None
|
|
674
|
+
self._view_overrides: dict[str, bool] = {} # path → user-set split mode
|
|
675
|
+
self._cwd_entries = [e for e in self._entries if _is_within_cwd(e.rel_path)]
|
|
676
|
+
self._ext_entries = [e for e in self._entries if not _is_within_cwd(e.rel_path)]
|
|
677
|
+
# Checkable mode: leaves render with a [x]/[ ] prefix and folders
|
|
678
|
+
# with [x]/[~]/[ ]. ``_unchecked`` stores leaf paths the user has
|
|
679
|
+
# deselected; default empty = all checked. Toggled in place via
|
|
680
|
+
# ``toggle_check_node`` and re-rendered via ``_relabel_all``.
|
|
681
|
+
self._checkable = checkable
|
|
682
|
+
self._unchecked: set[str] = set()
|
|
683
|
+
# path → spaces between the label body and its badge column
|
|
684
|
+
# (computed per tree in ``_assign_badge_pads``).
|
|
685
|
+
self._badge_pad: dict[str, int] = {}
|
|
686
|
+
self._lazy = lazy
|
|
687
|
+
self._initialized = False
|
|
688
|
+
self._initializing = False
|
|
689
|
+
|
|
690
|
+
def _render_message(self, reference: MessageRef) -> str:
|
|
691
|
+
return render_str(widget_localizer(self), reference)
|
|
692
|
+
|
|
693
|
+
def compose(self) -> ComposeResult:
|
|
694
|
+
with HorizontalGroup(classes="diff-body"):
|
|
695
|
+
changelist = VerticalGroup(classes="diff-changelist")
|
|
696
|
+
changelist.border_title = self._render_message(_CHANGE_LIST.bind())
|
|
697
|
+
with changelist:
|
|
698
|
+
# ``CheckableTree`` is a drop-in Tree that routes clicks
|
|
699
|
+
# on checkbox spans to a ``CheckToggled`` message. Its
|
|
700
|
+
# custom path is dormant unless labels carry the toggle
|
|
701
|
+
# meta, so using it in non-checkable mode too is safe.
|
|
702
|
+
workspace_tree: CheckableTree = CheckableTree("Workspace", classes="workspace-tree")
|
|
703
|
+
workspace_tree.show_root = False
|
|
704
|
+
workspace_tree.show_guides = True
|
|
705
|
+
workspace_tree.guide_depth = 2
|
|
706
|
+
yield workspace_tree
|
|
707
|
+
if self._ext_entries:
|
|
708
|
+
ext_tree: CheckableTree = CheckableTree(
|
|
709
|
+
self._render_message(_EXTERNAL_TREE.bind()), classes="external-tree"
|
|
710
|
+
)
|
|
711
|
+
ext_tree.show_root = True
|
|
712
|
+
ext_tree.show_guides = False
|
|
713
|
+
yield ext_tree
|
|
714
|
+
diff_content = VerticalGroup(classes="diff-content")
|
|
715
|
+
diff_content.border_title = ""
|
|
716
|
+
with diff_content:
|
|
717
|
+
yield Static(self._render_message(_SELECT_FILE.bind()), classes="diff-message")
|
|
718
|
+
|
|
719
|
+
async def on_mount(self) -> None:
|
|
720
|
+
if self._lazy:
|
|
721
|
+
return
|
|
722
|
+
await self.ensure_initialized()
|
|
723
|
+
|
|
724
|
+
async def ensure_initialized(self) -> None:
|
|
725
|
+
"""Build tree contents and mount the first file diff on demand."""
|
|
726
|
+
if self._initialized or self._initializing:
|
|
727
|
+
return
|
|
728
|
+
self._initializing = True
|
|
729
|
+
try:
|
|
730
|
+
await self._initialize()
|
|
731
|
+
self._initialized = True
|
|
732
|
+
finally:
|
|
733
|
+
self._initializing = False
|
|
734
|
+
# Tree regions can still be 0 while building (lazy tabs mount
|
|
735
|
+
# before layout); re-anchor once the layout has settled.
|
|
736
|
+
self.call_after_refresh(self._refresh_badge_alignment)
|
|
737
|
+
|
|
738
|
+
async def _initialize(self) -> None:
|
|
739
|
+
self._build_workspace_tree()
|
|
740
|
+
if self._ext_entries:
|
|
741
|
+
self._build_external_tree()
|
|
742
|
+
# Auto-select first file leaf
|
|
743
|
+
ws_tree = self.query_one(".workspace-tree", Tree)
|
|
744
|
+
first_leaf = self._find_first_leaf(ws_tree.root)
|
|
745
|
+
# Only auto-show if we actually landed on a file leaf — folder
|
|
746
|
+
# nodes now also carry data (``_FolderNodeData``) so a plain
|
|
747
|
+
# ``is not None`` check would incorrectly pass a folder into
|
|
748
|
+
# ``_show_file``.
|
|
749
|
+
if first_leaf and isinstance(first_leaf.data, DiffFileEntry):
|
|
750
|
+
# Newly added TreeNodes get render line numbers lazily; build them
|
|
751
|
+
# before moving the cursor so the visible selection lands on the file.
|
|
752
|
+
_ = ws_tree.last_line
|
|
753
|
+
ws_tree.move_cursor(first_leaf)
|
|
754
|
+
await self._show_file(first_leaf.data)
|
|
755
|
+
elif self._ext_entries:
|
|
756
|
+
ext_tree = self.query_one(".external-tree", Tree)
|
|
757
|
+
if ext_tree.root.children:
|
|
758
|
+
first = ext_tree.root.children[0]
|
|
759
|
+
# See workspace-tree selection above.
|
|
760
|
+
_ = ext_tree.last_line
|
|
761
|
+
ext_tree.move_cursor(first)
|
|
762
|
+
if isinstance(first.data, DiffFileEntry):
|
|
763
|
+
await self._show_file(first.data)
|
|
764
|
+
|
|
765
|
+
@staticmethod
|
|
766
|
+
def _find_first_leaf(node: TreeNode) -> TreeNode | None: # type: ignore[type-arg]
|
|
767
|
+
"""DFS to find the first leaf node carrying a ``DiffFileEntry``.
|
|
768
|
+
|
|
769
|
+
Checks the data type rather than ``is not None`` — folder nodes
|
|
770
|
+
also carry data (``_FolderNodeData``) in checkable mode, so we
|
|
771
|
+
have to discriminate or we'd pick a folder as the "first leaf".
|
|
772
|
+
"""
|
|
773
|
+
if isinstance(node.data, DiffFileEntry):
|
|
774
|
+
return node
|
|
775
|
+
for child in node.children:
|
|
776
|
+
result = DiffPeriodPane._find_first_leaf(child)
|
|
777
|
+
if result is not None:
|
|
778
|
+
return result
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
def _build_workspace_tree(self) -> None:
|
|
782
|
+
"""Build directory-structured tree for CWD files."""
|
|
783
|
+
tree = self.query_one(".workspace-tree", Tree)
|
|
784
|
+
tree.clear()
|
|
785
|
+
dir_nodes: dict[str, TreeNode] = {} # type: ignore[type-arg]
|
|
786
|
+
|
|
787
|
+
ordered = sorted(self._cwd_entries, key=lambda e: e.rel_path)
|
|
788
|
+
self._assign_badge_pads([(e, (len(PurePath(e.rel_path).parts) - 1) * tree.guide_depth) for e in ordered], tree)
|
|
789
|
+
for entry in ordered:
|
|
790
|
+
# ``PurePath`` is platform-aware: parses ``os.sep`` correctly on
|
|
791
|
+
# both Windows and POSIX, no manual separator munging needed.
|
|
792
|
+
parts = PurePath(entry.rel_path).parts
|
|
793
|
+
if not parts:
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
# Create/find parent directory nodes
|
|
797
|
+
current = tree.root
|
|
798
|
+
for i, part in enumerate(parts[:-1]):
|
|
799
|
+
key = "/".join(parts[: i + 1])
|
|
800
|
+
if key not in dir_nodes:
|
|
801
|
+
node = current.add(Text(part), expand=True, data=_FolderNodeData(name=part))
|
|
802
|
+
dir_nodes[key] = node
|
|
803
|
+
current = dir_nodes[key]
|
|
804
|
+
|
|
805
|
+
# Add file leaf (filename is derived from entry.rel_path inside _leaf_label).
|
|
806
|
+
current.add_leaf(self._leaf_label(entry), data=entry)
|
|
807
|
+
|
|
808
|
+
if self._checkable:
|
|
809
|
+
self._relabel_all()
|
|
810
|
+
|
|
811
|
+
def _build_external_tree(self) -> None:
|
|
812
|
+
"""Build flat list for files outside CWD."""
|
|
813
|
+
tree = self.query_one(".external-tree", Tree)
|
|
814
|
+
tree.clear() # idempotent: a retried _initialize() must not stack leaves
|
|
815
|
+
ordered = sorted(self._ext_entries, key=lambda e: e.path)
|
|
816
|
+
# External tree has no intermediate folder nodes; labels use
|
|
817
|
+
# absolute paths (via _leaf_display). Build via the shared leaf
|
|
818
|
+
# helper so the checkbox prefix (when enabled) is applied
|
|
819
|
+
# uniformly. All leaves sit at the same depth, so the badge
|
|
820
|
+
# column only needs a uniform indent term.
|
|
821
|
+
self._assign_badge_pads([(e, tree.guide_depth) for e in ordered], tree)
|
|
822
|
+
for entry in ordered:
|
|
823
|
+
tree.root.add_leaf(self._leaf_label(entry), data=entry)
|
|
824
|
+
tree.root.expand()
|
|
825
|
+
|
|
826
|
+
if self._checkable:
|
|
827
|
+
self._relabel_all()
|
|
828
|
+
|
|
829
|
+
@staticmethod
|
|
830
|
+
def _leaf_display(entry: DiffFileEntry) -> str:
|
|
831
|
+
"""Display name for a leaf: basename in the workspace tree, full
|
|
832
|
+
absolute path in the external tree (which has no folder nodes)."""
|
|
833
|
+
if _is_within_cwd(entry.rel_path):
|
|
834
|
+
return PurePath(entry.rel_path).name
|
|
835
|
+
return entry.path
|
|
836
|
+
|
|
837
|
+
def _leaf_body(self, entry: DiffFileEntry) -> Text:
|
|
838
|
+
"""Everything left of the badge column — checkbox, op, name, notes.
|
|
839
|
+
|
|
840
|
+
When the pane runs in checkable mode the label is prefixed with
|
|
841
|
+
a clickable ``[x]``/``[ ]`` span (the checkbox glyph carries
|
|
842
|
+
``_CHECK_TOGGLE_META`` so :class:`CheckableTree` can intercept
|
|
843
|
+
mouse clicks). The op badge ([+]/[-]/[→]/[+-]) follows the
|
|
844
|
+
checkbox so the checkbox column stays leftmost. The name is
|
|
845
|
+
never truncated — rows wider than the pane scroll horizontally.
|
|
846
|
+
"""
|
|
847
|
+
op_segments = _OP_SEGMENTS.get(entry.operation, [("[?]", "bold white")])
|
|
848
|
+
parts: list[Text | tuple[str, str] | str] = []
|
|
849
|
+
if self._checkable:
|
|
850
|
+
parts += [_checkbox_text("off" if entry.path in self._unchecked else "on"), " "]
|
|
851
|
+
parts += [*op_segments, " ", self._leaf_display(entry)]
|
|
852
|
+
if entry.content_omitted:
|
|
853
|
+
parts.append((self._render_message(_NO_BACKUP.bind()), "dim"))
|
|
854
|
+
return Text.assemble(*parts)
|
|
855
|
+
|
|
856
|
+
def _leaf_label(self, entry: DiffFileEntry) -> Text:
|
|
857
|
+
"""Leaf body plus provenance badges padded into a shared right column.
|
|
858
|
+
|
|
859
|
+
``_assign_badge_pads`` picks the column (one cell past the
|
|
860
|
+
widest body in the same tree, guide indentation included) so
|
|
861
|
+
``?``/``~``/``!`` line up vertically regardless of name length.
|
|
862
|
+
"""
|
|
863
|
+
label = self._leaf_body(entry)
|
|
864
|
+
badges = _entry_source_suffix(entry)
|
|
865
|
+
if badges:
|
|
866
|
+
label.append(" " * self._badge_pad.get(entry.path, 1))
|
|
867
|
+
for i, (marker, style) in enumerate(badges):
|
|
868
|
+
if i:
|
|
869
|
+
label.append(" ")
|
|
870
|
+
label.append(marker, style)
|
|
871
|
+
return label
|
|
872
|
+
|
|
873
|
+
def _assign_badge_pads(self, rows: list[tuple[DiffFileEntry, int]], tree: Tree) -> None:
|
|
874
|
+
"""Compute per-leaf padding that right-aligns badges to ``tree``'s edge.
|
|
875
|
+
|
|
876
|
+
``rows`` pair each entry with its guide indentation (in cells)
|
|
877
|
+
so alignment holds across tree depths, not just label-local.
|
|
878
|
+
Badge blocks END flush at the tree's visible right edge; a row
|
|
879
|
+
whose body is already wider keeps one trailing space instead
|
|
880
|
+
(names are never truncated — horizontal scroll reveals its
|
|
881
|
+
badge). Before layout (width 0) fall back to one cell past the
|
|
882
|
+
widest badge-carrying body so alignment still holds headless.
|
|
883
|
+
"""
|
|
884
|
+
measured: list[tuple[DiffFileEntry, int, int]] = []
|
|
885
|
+
for entry, indent in rows:
|
|
886
|
+
badges = _entry_source_suffix(entry)
|
|
887
|
+
if not badges:
|
|
888
|
+
continue
|
|
889
|
+
badge_width = 2 * len(badges) - 1 # markers joined by single spaces
|
|
890
|
+
measured.append((entry, indent + self._leaf_body(entry).cell_len, badge_width))
|
|
891
|
+
if not measured:
|
|
892
|
+
return
|
|
893
|
+
visible = tree.scrollable_content_region.width
|
|
894
|
+
end = visible if visible > 0 else max(w + 1 + bw for _, w, bw in measured)
|
|
895
|
+
for entry, width, badge_width in measured:
|
|
896
|
+
self._badge_pad[entry.path] = max(1, end - badge_width - width)
|
|
897
|
+
|
|
898
|
+
def _refresh_badge_alignment(self) -> None:
|
|
899
|
+
"""Re-anchor the badge column after the pane's width changes."""
|
|
900
|
+
if not self._initialized:
|
|
901
|
+
return
|
|
902
|
+
# After-refresh callbacks can land after this pane left the DOM
|
|
903
|
+
# (the rollback modal swaps panes on turn change while an
|
|
904
|
+
# init/resize callback is still pending) — the trees are
|
|
905
|
+
# unmounted with it, so there is nothing to re-anchor.
|
|
906
|
+
try:
|
|
907
|
+
ws_tree = self.query_one(".workspace-tree", Tree)
|
|
908
|
+
except NoMatches:
|
|
909
|
+
return
|
|
910
|
+
self._assign_badge_pads(
|
|
911
|
+
[(e, (len(PurePath(e.rel_path).parts) - 1) * ws_tree.guide_depth) for e in self._cwd_entries],
|
|
912
|
+
ws_tree,
|
|
913
|
+
)
|
|
914
|
+
if self._ext_entries:
|
|
915
|
+
ext_tree = self.query_one(".external-tree", Tree)
|
|
916
|
+
self._assign_badge_pads([(e, ext_tree.guide_depth) for e in self._ext_entries], ext_tree)
|
|
917
|
+
self._relabel_all()
|
|
918
|
+
|
|
919
|
+
def on_resize(self) -> None:
|
|
920
|
+
# After-refresh so tree regions reflect the settled layout.
|
|
921
|
+
self.call_after_refresh(self._refresh_badge_alignment)
|
|
922
|
+
|
|
923
|
+
def _folder_label(self, name: str, state: str) -> Text:
|
|
924
|
+
"""Build the Rich ``Text`` label for a directory node.
|
|
925
|
+
|
|
926
|
+
``state`` is one of ``"on"`` / ``"off"`` / ``"mixed"``. The
|
|
927
|
+
checkbox glyph carries the same toggle meta as leaf checkboxes,
|
|
928
|
+
so clicking a folder's ``[~]`` cascades to all descendants (see
|
|
929
|
+
:meth:`toggle_check_node`).
|
|
930
|
+
"""
|
|
931
|
+
if self._checkable:
|
|
932
|
+
return Text.assemble(_checkbox_text(state), " ", name)
|
|
933
|
+
return Text(name)
|
|
934
|
+
|
|
935
|
+
# ------------------------------------------------------------------
|
|
936
|
+
# Checkbox API (no-ops when ``checkable=False``)
|
|
937
|
+
# ------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
def _iter_trees(self) -> list[Tree]:
|
|
940
|
+
"""Return every Tree widget inside this pane (workspace + ext).
|
|
941
|
+
|
|
942
|
+
Queries on ``Tree`` (the base class) rather than ``CheckableTree``
|
|
943
|
+
so behaviour is unaffected if a subclass later swaps in a plain
|
|
944
|
+
Tree for some reason.
|
|
945
|
+
"""
|
|
946
|
+
return list(self.query(Tree))
|
|
947
|
+
|
|
948
|
+
@on(CheckableTree.CheckToggled)
|
|
949
|
+
def _on_check_toggled(self, event: CheckableTree.CheckToggled) -> None:
|
|
950
|
+
"""Mouse-click-to-toggle path — mirrors the ``x`` key action.
|
|
951
|
+
|
|
952
|
+
The tree subclass identifies which node the user clicked via
|
|
953
|
+
its checkbox meta; we just dispatch to the same cascade logic
|
|
954
|
+
the keyboard binding uses. Event is stopped so the message
|
|
955
|
+
doesn't bubble out past this pane.
|
|
956
|
+
"""
|
|
957
|
+
event.stop()
|
|
958
|
+
self.toggle_check_node(event.node)
|
|
959
|
+
|
|
960
|
+
def _relabel_all(self) -> None:
|
|
961
|
+
"""Recompute every leaf + folder label from ``self._unchecked``.
|
|
962
|
+
|
|
963
|
+
Folder tri-state is derived from descendant leaves: all checked
|
|
964
|
+
→ ``[x]``, all unchecked → ``[ ]``, anything in between → ``[~]``.
|
|
965
|
+
"""
|
|
966
|
+
for tree in self._iter_trees():
|
|
967
|
+
self._relabel_subtree(tree.root)
|
|
968
|
+
|
|
969
|
+
def _relabel_subtree(self, node: TreeNode) -> tuple[int, int]: # type: ignore[type-arg]
|
|
970
|
+
"""Relabel ``node`` and its descendants.
|
|
971
|
+
|
|
972
|
+
Returns ``(total_leaves, unchecked_leaves)`` so parent folders
|
|
973
|
+
can compute their tri-state in one walk.
|
|
974
|
+
"""
|
|
975
|
+
data = node.data
|
|
976
|
+
# Leaf node: a DiffFileEntry directly.
|
|
977
|
+
if isinstance(data, DiffFileEntry):
|
|
978
|
+
is_unchecked = data.path in self._unchecked
|
|
979
|
+
node.set_label(self._leaf_label(data))
|
|
980
|
+
return (1, 1 if is_unchecked else 0)
|
|
981
|
+
|
|
982
|
+
total = 0
|
|
983
|
+
unchecked = 0
|
|
984
|
+
for child in node.children:
|
|
985
|
+
ct, cu = self._relabel_subtree(child)
|
|
986
|
+
total += ct
|
|
987
|
+
unchecked += cu
|
|
988
|
+
|
|
989
|
+
# Update the folder label if this node represents a directory.
|
|
990
|
+
# The tree root has data=None (default) — skip it so the hidden
|
|
991
|
+
# root never gets a checkbox prefix.
|
|
992
|
+
if isinstance(data, _FolderNodeData) and total > 0:
|
|
993
|
+
if unchecked == 0:
|
|
994
|
+
state = "on"
|
|
995
|
+
elif unchecked == total:
|
|
996
|
+
state = "off"
|
|
997
|
+
else:
|
|
998
|
+
state = "mixed"
|
|
999
|
+
node.set_label(self._folder_label(data.name, state))
|
|
1000
|
+
|
|
1001
|
+
return (total, unchecked)
|
|
1002
|
+
|
|
1003
|
+
def toggle_check_node(self, node: TreeNode) -> None: # type: ignore[type-arg]
|
|
1004
|
+
"""Toggle the check state for ``node``.
|
|
1005
|
+
|
|
1006
|
+
Leaves flip their own state. Folders follow the common
|
|
1007
|
+
tri-state convention: if every descendant is already checked,
|
|
1008
|
+
uncheck them all; otherwise check them all (completing from
|
|
1009
|
+
``[ ]`` or ``[~]``). No-op when the pane isn't checkable.
|
|
1010
|
+
|
|
1011
|
+
Emits :class:`SelectionChanged` on every successful toggle so
|
|
1012
|
+
outside widgets (e.g. the rollback modal's "Revert N files"
|
|
1013
|
+
checkbox) can stay in sync.
|
|
1014
|
+
"""
|
|
1015
|
+
if not self._checkable:
|
|
1016
|
+
return
|
|
1017
|
+
|
|
1018
|
+
data = node.data
|
|
1019
|
+
if isinstance(data, DiffFileEntry):
|
|
1020
|
+
if data.path in self._unchecked:
|
|
1021
|
+
self._unchecked.discard(data.path)
|
|
1022
|
+
else:
|
|
1023
|
+
self._unchecked.add(data.path)
|
|
1024
|
+
self._relabel_all()
|
|
1025
|
+
self._post_selection_changed()
|
|
1026
|
+
return
|
|
1027
|
+
|
|
1028
|
+
# Folder / root: gather descendant leaves.
|
|
1029
|
+
leaves = self._collect_descendant_leaves(node)
|
|
1030
|
+
if not leaves:
|
|
1031
|
+
return
|
|
1032
|
+
all_checked = all(p not in self._unchecked for p in leaves)
|
|
1033
|
+
if all_checked:
|
|
1034
|
+
self._unchecked.update(leaves)
|
|
1035
|
+
else:
|
|
1036
|
+
for p in leaves:
|
|
1037
|
+
self._unchecked.discard(p)
|
|
1038
|
+
self._relabel_all()
|
|
1039
|
+
self._post_selection_changed()
|
|
1040
|
+
|
|
1041
|
+
def _post_selection_changed(self) -> None:
|
|
1042
|
+
"""Fire :class:`SelectionChanged` with the current totals."""
|
|
1043
|
+
self.post_message(
|
|
1044
|
+
self.SelectionChanged(
|
|
1045
|
+
selected_count=len(self.selected_paths()),
|
|
1046
|
+
total_count=len(self._entries),
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
def set_all_checked(self, checked: bool) -> None:
|
|
1051
|
+
"""Bulk check or uncheck every leaf in the pane.
|
|
1052
|
+
|
|
1053
|
+
Used by the rollback modal's footer checkbox to mirror the
|
|
1054
|
+
"select all / select none" intent onto the tree. Emits a
|
|
1055
|
+
:class:`SelectionChanged` message once at the end so consumers
|
|
1056
|
+
don't see intermediate per-leaf transitions. No-op when the
|
|
1057
|
+
pane isn't checkable or the request matches current state.
|
|
1058
|
+
"""
|
|
1059
|
+
if not self._checkable:
|
|
1060
|
+
return
|
|
1061
|
+
if checked:
|
|
1062
|
+
if not self._unchecked:
|
|
1063
|
+
return # already all-checked
|
|
1064
|
+
self._unchecked.clear()
|
|
1065
|
+
else:
|
|
1066
|
+
all_paths = {e.path for e in self._entries}
|
|
1067
|
+
if self._unchecked == all_paths:
|
|
1068
|
+
return # already all-unchecked
|
|
1069
|
+
self._unchecked = all_paths
|
|
1070
|
+
self._relabel_all()
|
|
1071
|
+
self._post_selection_changed()
|
|
1072
|
+
|
|
1073
|
+
def selection_counts(self) -> tuple[int, int]:
|
|
1074
|
+
"""Return ``(selected_count, total_count)`` for checkable consumers."""
|
|
1075
|
+
return (len(self.selected_paths()), len(self._entries))
|
|
1076
|
+
|
|
1077
|
+
def _collect_descendant_leaves(self, node: TreeNode) -> list[str]: # type: ignore[type-arg]
|
|
1078
|
+
"""Return every file path under ``node``."""
|
|
1079
|
+
result: list[str] = []
|
|
1080
|
+
|
|
1081
|
+
def walk(n: TreeNode) -> None: # type: ignore[type-arg]
|
|
1082
|
+
if isinstance(n.data, DiffFileEntry):
|
|
1083
|
+
result.append(n.data.path)
|
|
1084
|
+
return
|
|
1085
|
+
for child in n.children:
|
|
1086
|
+
walk(child)
|
|
1087
|
+
|
|
1088
|
+
walk(node)
|
|
1089
|
+
return result
|
|
1090
|
+
|
|
1091
|
+
def selected_paths(self) -> list[str]:
|
|
1092
|
+
"""Paths the user wants to include (i.e. NOT in ``_unchecked``).
|
|
1093
|
+
|
|
1094
|
+
Returns all entry paths when the pane isn't checkable — the
|
|
1095
|
+
rollback modal is currently the only caller, but leaving this
|
|
1096
|
+
always-callable keeps the API simple for future reuse.
|
|
1097
|
+
"""
|
|
1098
|
+
return [e.path for e in self._entries if e.path not in self._unchecked]
|
|
1099
|
+
|
|
1100
|
+
def set_change_list_visible(self, visible: bool) -> None:
|
|
1101
|
+
"""Show or hide the left-side file change tree."""
|
|
1102
|
+
self.query_one(".diff-changelist", VerticalGroup).display = visible
|
|
1103
|
+
|
|
1104
|
+
async def on_tree_node_selected(self, event: Tree.NodeSelected[object]) -> None:
|
|
1105
|
+
# Folder nodes carry ``_FolderNodeData`` — show_file only makes
|
|
1106
|
+
# sense for real file leaves.
|
|
1107
|
+
if isinstance(event.node.data, DiffFileEntry):
|
|
1108
|
+
await self._show_file(event.node.data)
|
|
1109
|
+
|
|
1110
|
+
async def _show_file(self, entry: DiffFileEntry) -> None:
|
|
1111
|
+
"""Display the diff for the selected file."""
|
|
1112
|
+
if entry.path == self._active_path:
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
message = self.query_one(".diff-message", Static)
|
|
1116
|
+
content_box = self.query_one(".diff-content", VerticalGroup)
|
|
1117
|
+
|
|
1118
|
+
# Handle non-diffable cases
|
|
1119
|
+
if entry.content_omitted:
|
|
1120
|
+
# Content backup withheld by SnapshotPolicy — the row is
|
|
1121
|
+
# still actionable (e.g. revert deletes a skipped create)
|
|
1122
|
+
# but there is nothing truthful to render as a diff.
|
|
1123
|
+
self._hide_all_diffs()
|
|
1124
|
+
reason_definition = _SKIP_REASON_LABELS.get(entry.content_omitted)
|
|
1125
|
+
reason = (
|
|
1126
|
+
self._render_message(reason_definition.bind())
|
|
1127
|
+
if reason_definition is not None
|
|
1128
|
+
else entry.content_omitted
|
|
1129
|
+
)
|
|
1130
|
+
message.update(
|
|
1131
|
+
Text(self._render_message(_CONTENT_NOT_BACKED_UP.bind(reason=reason, path=DisplayPath(entry.rel_path))))
|
|
1132
|
+
)
|
|
1133
|
+
message.display = True
|
|
1134
|
+
self._update_content_border(content_box, entry)
|
|
1135
|
+
self._active_path = entry.path
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
if entry.is_binary:
|
|
1139
|
+
self._hide_all_diffs()
|
|
1140
|
+
message.update(Text(self._render_message(_BINARY_FILE.bind(path=DisplayPath(entry.rel_path)))))
|
|
1141
|
+
message.display = True
|
|
1142
|
+
self._update_content_border(content_box, entry)
|
|
1143
|
+
self._active_path = entry.path
|
|
1144
|
+
return
|
|
1145
|
+
|
|
1146
|
+
if entry.operation == MutationOp.MOVE:
|
|
1147
|
+
self._hide_all_diffs()
|
|
1148
|
+
old_rel = entry.old_path or "?"
|
|
1149
|
+
message.update(
|
|
1150
|
+
Text(
|
|
1151
|
+
self._render_message(
|
|
1152
|
+
_FILE_MOVED.bind(
|
|
1153
|
+
old_path=DisplayPath(old_rel),
|
|
1154
|
+
new_path=DisplayPath(entry.rel_path),
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
)
|
|
1158
|
+
)
|
|
1159
|
+
message.display = True
|
|
1160
|
+
self._update_content_border(content_box, entry)
|
|
1161
|
+
self._active_path = entry.path
|
|
1162
|
+
return
|
|
1163
|
+
|
|
1164
|
+
if _entry_is_eol_only_change(entry):
|
|
1165
|
+
self._hide_all_diffs()
|
|
1166
|
+
message.update(Text(self._render_message(_ONLY_LINE_ENDINGS_CHANGED.bind())))
|
|
1167
|
+
message.display = True
|
|
1168
|
+
self._update_content_border(content_box, entry, counts=(0, 0))
|
|
1169
|
+
self._active_path = entry.path
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
if _entry_is_metadata_only_change(entry):
|
|
1173
|
+
self._hide_all_diffs()
|
|
1174
|
+
message.update(Text(self._render_message(_ONLY_BYTE_REPRESENTATION_CHANGED.bind())))
|
|
1175
|
+
message.display = True
|
|
1176
|
+
self._update_content_border(content_box, entry, counts=(0, 0))
|
|
1177
|
+
self._active_path = entry.path
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
# Hide message, show diff
|
|
1181
|
+
message.display = False
|
|
1182
|
+
|
|
1183
|
+
# Hide current active diff
|
|
1184
|
+
if self._active_path and self._active_path in self._diff_cache:
|
|
1185
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1186
|
+
|
|
1187
|
+
old_dv = self._diff_cache[self._active_path]
|
|
1188
|
+
if isinstance(old_dv, DiffView):
|
|
1189
|
+
old_dv.display = False
|
|
1190
|
+
|
|
1191
|
+
# Check cache or create new DiffView
|
|
1192
|
+
dv = None
|
|
1193
|
+
if entry.path in self._diff_cache:
|
|
1194
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1195
|
+
|
|
1196
|
+
dv = self._diff_cache[entry.path]
|
|
1197
|
+
if isinstance(dv, DiffView):
|
|
1198
|
+
dv.display = True
|
|
1199
|
+
else:
|
|
1200
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1201
|
+
|
|
1202
|
+
dv = DiffView(
|
|
1203
|
+
entry.path,
|
|
1204
|
+
entry.path,
|
|
1205
|
+
entry.before_text,
|
|
1206
|
+
entry.after_text,
|
|
1207
|
+
id=f"dv-{hash(entry.path) & 0xFFFFFFFF:08x}",
|
|
1208
|
+
)
|
|
1209
|
+
await dv.prepare()
|
|
1210
|
+
split = self._view_overrides.get(entry.path)
|
|
1211
|
+
if split is None:
|
|
1212
|
+
additions, removals = dv.counts
|
|
1213
|
+
split = additions > 0 and removals > 0
|
|
1214
|
+
dv.split = split
|
|
1215
|
+
await content_box.mount(dv, before=message)
|
|
1216
|
+
self._diff_cache[entry.path] = dv
|
|
1217
|
+
|
|
1218
|
+
self._update_content_border(content_box, entry, dv=dv)
|
|
1219
|
+
self._active_path = entry.path
|
|
1220
|
+
|
|
1221
|
+
def _update_content_border(
|
|
1222
|
+
self,
|
|
1223
|
+
content_box: VerticalGroup,
|
|
1224
|
+
entry: DiffFileEntry,
|
|
1225
|
+
dv: DiffView | None = None,
|
|
1226
|
+
counts: tuple[int, int] | None = None,
|
|
1227
|
+
) -> None:
|
|
1228
|
+
"""Update the diff-content border title with file path + stats and subtitle metadata."""
|
|
1229
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1230
|
+
|
|
1231
|
+
title = entry.path
|
|
1232
|
+
if counts is not None:
|
|
1233
|
+
adds, rems = counts
|
|
1234
|
+
title = f"{entry.path} (+{adds}, -{rems})"
|
|
1235
|
+
elif isinstance(dv, DiffView):
|
|
1236
|
+
adds, rems = dv.counts
|
|
1237
|
+
title = f"{entry.path} (+{adds}, -{rems})"
|
|
1238
|
+
content_box.border_title = Text(title)
|
|
1239
|
+
content_box.border_subtitle = _entry_content_subtitle(entry, self._render_message)
|
|
1240
|
+
|
|
1241
|
+
def _hide_all_diffs(self) -> None:
|
|
1242
|
+
"""Hide all cached DiffView widgets."""
|
|
1243
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1244
|
+
|
|
1245
|
+
for dv in self._diff_cache.values():
|
|
1246
|
+
if isinstance(dv, DiffView):
|
|
1247
|
+
dv.display = False
|
|
1248
|
+
|
|
1249
|
+
def toggle_view(self) -> None:
|
|
1250
|
+
"""Toggle split/unified for the active file."""
|
|
1251
|
+
from chrys.app.tui.widgets.diff_view import DiffView
|
|
1252
|
+
|
|
1253
|
+
if self._active_path and self._active_path in self._diff_cache:
|
|
1254
|
+
dv = self._diff_cache[self._active_path]
|
|
1255
|
+
if isinstance(dv, DiffView):
|
|
1256
|
+
new_split = not dv.split
|
|
1257
|
+
dv.split = new_split
|
|
1258
|
+
self._view_overrides[self._active_path] = new_split
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
# ---------------------------------------------------------------------------
|
|
1262
|
+
# DiffScreen — full screen
|
|
1263
|
+
# ---------------------------------------------------------------------------
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
class _DiffScreenContent(Widget):
|
|
1267
|
+
"""Deferred tab host mounted only after the outer screen has painted."""
|
|
1268
|
+
|
|
1269
|
+
def __init__(
|
|
1270
|
+
self,
|
|
1271
|
+
periods_data: dict[int, list[DiffFileEntry]],
|
|
1272
|
+
all_entries: list[DiffFileEntry],
|
|
1273
|
+
*,
|
|
1274
|
+
initial_tab_id: str,
|
|
1275
|
+
scopes: dict[int, MutationScope],
|
|
1276
|
+
) -> None:
|
|
1277
|
+
super().__init__(classes="diff-screen-content")
|
|
1278
|
+
self._periods_data = periods_data
|
|
1279
|
+
self._all_entries = all_entries
|
|
1280
|
+
self._initial_tab_id = initial_tab_id
|
|
1281
|
+
self._scopes = scopes
|
|
1282
|
+
|
|
1283
|
+
def compose(self) -> ComposeResult:
|
|
1284
|
+
render_message = functools.partial(render_str, widget_localizer(self))
|
|
1285
|
+
with TabbedContent(initial=self._initial_tab_id):
|
|
1286
|
+
for turn_id in sorted(self._periods_data):
|
|
1287
|
+
yield TabPane(
|
|
1288
|
+
Content.from_text(
|
|
1289
|
+
render_message(
|
|
1290
|
+
_RUN_TAB.bind(number=turn_id)
|
|
1291
|
+
if isinstance(self._scopes.get(turn_id), WorkflowRunScope)
|
|
1292
|
+
else _TURN_TAB.bind(turn_id=turn_id)
|
|
1293
|
+
),
|
|
1294
|
+
markup=False,
|
|
1295
|
+
),
|
|
1296
|
+
DiffPeriodPane(self._periods_data[turn_id], lazy=True),
|
|
1297
|
+
id=f"turn-{turn_id}",
|
|
1298
|
+
)
|
|
1299
|
+
# Keep the session-wide net changeset after the chronological
|
|
1300
|
+
# turn tabs. Hide it when it matches the sole visible turn.
|
|
1301
|
+
if _should_show_all_tab(self._all_entries, self._periods_data):
|
|
1302
|
+
yield TabPane(
|
|
1303
|
+
Content.from_text(render_message(_ALL_TAB.bind()), markup=False),
|
|
1304
|
+
DiffPeriodPane(self._all_entries, lazy=True),
|
|
1305
|
+
id="turn-all",
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
class DiffScreen(RightClickScreenCopyMixin, Screen):
|
|
1310
|
+
"""Full-screen diff viewer with Chat Turn or Workflow Run tabs."""
|
|
1311
|
+
|
|
1312
|
+
CSS_PATH = "screen.tcss"
|
|
1313
|
+
_CONTENT_ACTIONS: ClassVar[frozenset[str]] = frozenset({"toggle_view", "toggle_change_list"})
|
|
1314
|
+
|
|
1315
|
+
BINDINGS: ClassVar[list] = [
|
|
1316
|
+
localized_binding("escape", "go_back", _BACK_BINDING, priority=True),
|
|
1317
|
+
localized_binding("space", "toggle_view", _TOGGLE_SPLIT_VIEW_BINDING, priority=True),
|
|
1318
|
+
localized_binding("ctrl+g", "toggle_change_list", _TOGGLE_CHANGE_LIST_BINDING, priority=True),
|
|
1319
|
+
]
|
|
1320
|
+
|
|
1321
|
+
def __init__(
|
|
1322
|
+
self,
|
|
1323
|
+
periods_data: dict[int, list[DiffFileEntry]],
|
|
1324
|
+
cwd: str = "",
|
|
1325
|
+
subtitle_parts: tuple[str, ...] = (),
|
|
1326
|
+
session_id: str = "",
|
|
1327
|
+
all_entries: list[DiffFileEntry] | None = None,
|
|
1328
|
+
load_data: Callable[[], Awaitable[DiffLoadResult]] | None = None,
|
|
1329
|
+
locale_controller: LocaleController | None = None,
|
|
1330
|
+
scopes: dict[int, MutationScope] | None = None,
|
|
1331
|
+
) -> None:
|
|
1332
|
+
super().__init__()
|
|
1333
|
+
self._periods_data = periods_data
|
|
1334
|
+
self._scopes = scopes or {}
|
|
1335
|
+
self._cwd = cwd
|
|
1336
|
+
self._subtitle_parts = subtitle_parts
|
|
1337
|
+
self._session_id = session_id
|
|
1338
|
+
self._all_entries = all_entries or []
|
|
1339
|
+
self._load_data = load_data
|
|
1340
|
+
self._locale_controller = locale_controller
|
|
1341
|
+
self._change_list_visible = True
|
|
1342
|
+
self._content_loading = True
|
|
1343
|
+
self._content_ready = False
|
|
1344
|
+
self._close_when_current = False
|
|
1345
|
+
|
|
1346
|
+
def _render_toast(self, reference: MessageRef) -> str:
|
|
1347
|
+
controller = self._locale_controller
|
|
1348
|
+
return format_message(reference) if controller is None else render_str(controller.localizer, reference)
|
|
1349
|
+
|
|
1350
|
+
def _initial_tab_id(self) -> str:
|
|
1351
|
+
"""Return the tab that should be active when the screen opens."""
|
|
1352
|
+
if self._periods_data:
|
|
1353
|
+
return f"turn-{max(self._periods_data)}"
|
|
1354
|
+
if self._all_entries:
|
|
1355
|
+
return "turn-all"
|
|
1356
|
+
return ""
|
|
1357
|
+
|
|
1358
|
+
def compose(self) -> ComposeResult:
|
|
1359
|
+
from chrys.app.tui.widgets.chrome.app_header import AppHeader
|
|
1360
|
+
|
|
1361
|
+
header = AppHeader(show_approval_badge=False, locale_controller=self._locale_controller)
|
|
1362
|
+
yield header
|
|
1363
|
+
container = VerticalGroup(id="diff-container")
|
|
1364
|
+
from chrys.foundation.util.session_ids import session_short_id
|
|
1365
|
+
|
|
1366
|
+
title = (
|
|
1367
|
+
self._render_toast(_DIFF_VIEWER_SESSION_TITLE.bind(session_id=session_short_id(self._session_id)))
|
|
1368
|
+
if self._session_id
|
|
1369
|
+
else self._render_toast(_DIFF_VIEWER_TITLE.bind())
|
|
1370
|
+
)
|
|
1371
|
+
container.border_title = Text(title)
|
|
1372
|
+
container.border_subtitle = Text(self._cwd)
|
|
1373
|
+
with container, VerticalGroup(id="diff-loading-state"):
|
|
1374
|
+
yield ChrysLoadingIndicator(id="diff-loading")
|
|
1375
|
+
yield ChrysFooter(locale_controller=self._locale_controller)
|
|
1376
|
+
|
|
1377
|
+
def on_mount(self) -> None:
|
|
1378
|
+
# Propagate subtitle from the caller (platform info).
|
|
1379
|
+
if self._subtitle_parts:
|
|
1380
|
+
from chrys.app.tui.widgets.chrome.app_header import AppHeader
|
|
1381
|
+
|
|
1382
|
+
self.query_one(AppHeader).set_subtitle(*self._subtitle_parts)
|
|
1383
|
+
# Do not start loading until Textual has painted the header, outer
|
|
1384
|
+
# border, centered indicator, and footer at least once.
|
|
1385
|
+
self.call_after_refresh(self._start_content_loading)
|
|
1386
|
+
|
|
1387
|
+
def _start_content_loading(self) -> None:
|
|
1388
|
+
self.run_worker(self._load_and_mount_content(), exclusive=True, group="diff-screen-content")
|
|
1389
|
+
|
|
1390
|
+
async def _load_and_mount_content(self) -> None:
|
|
1391
|
+
"""Load data off-loop, prepare the active pane, then reveal it atomically."""
|
|
1392
|
+
try:
|
|
1393
|
+
if self._load_data is not None:
|
|
1394
|
+
result = await self._load_data()
|
|
1395
|
+
self._periods_data = result.per_period_entries
|
|
1396
|
+
self._scopes = result.scopes
|
|
1397
|
+
self._all_entries = result.all_entries
|
|
1398
|
+
if not self._periods_data and not self._all_entries:
|
|
1399
|
+
self._content_loading = False
|
|
1400
|
+
self.notify(
|
|
1401
|
+
self._render_toast(_NO_FILE_CHANGES.bind()),
|
|
1402
|
+
title=self._render_toast(_DIFF_VIEWER_TITLE.bind()),
|
|
1403
|
+
severity="warning",
|
|
1404
|
+
timeout=3,
|
|
1405
|
+
markup=False,
|
|
1406
|
+
)
|
|
1407
|
+
self._request_close_when_current()
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
content = _DiffScreenContent(
|
|
1411
|
+
self._periods_data,
|
|
1412
|
+
self._all_entries,
|
|
1413
|
+
initial_tab_id=self._initial_tab_id(),
|
|
1414
|
+
scopes=self._scopes,
|
|
1415
|
+
)
|
|
1416
|
+
content.display = False
|
|
1417
|
+
await self.query_one("#diff-container", VerticalGroup).mount(content)
|
|
1418
|
+
|
|
1419
|
+
# Keep the loading state visible until both the change tree and
|
|
1420
|
+
# first DiffView have completed their expensive preparation.
|
|
1421
|
+
await self._ensure_active_turn_pane_initialized()
|
|
1422
|
+
await remove_shielded(self.query_one("#diff-loading-state", VerticalGroup))
|
|
1423
|
+
content.display = True
|
|
1424
|
+
self._content_loading = False
|
|
1425
|
+
self._content_ready = True
|
|
1426
|
+
self.refresh_bindings()
|
|
1427
|
+
except Exception as exc:
|
|
1428
|
+
self._content_ready = False
|
|
1429
|
+
try:
|
|
1430
|
+
await self._show_load_error(exc)
|
|
1431
|
+
finally:
|
|
1432
|
+
self._content_loading = False
|
|
1433
|
+
with suppress(Exception):
|
|
1434
|
+
self.refresh_bindings()
|
|
1435
|
+
|
|
1436
|
+
async def _show_load_error(self, exc: Exception) -> None:
|
|
1437
|
+
"""Show a recoverable error whether or not the loading shell remains."""
|
|
1438
|
+
if not self.is_attached:
|
|
1439
|
+
return
|
|
1440
|
+
with suppress(Exception):
|
|
1441
|
+
message = Static(
|
|
1442
|
+
Text(self._render_toast(_LOAD_ERROR.bind(error=str(exc)))),
|
|
1443
|
+
id="diff-load-error",
|
|
1444
|
+
)
|
|
1445
|
+
loading_states = list(self.query("#diff-loading-state"))
|
|
1446
|
+
if loading_states:
|
|
1447
|
+
loading_state = loading_states[0]
|
|
1448
|
+
await remove_children_shielded(loading_state)
|
|
1449
|
+
await loading_state.mount(message)
|
|
1450
|
+
return
|
|
1451
|
+
for content in self.query(".diff-screen-content"):
|
|
1452
|
+
content.display = False
|
|
1453
|
+
await self.query_one("#diff-container", VerticalGroup).mount(message)
|
|
1454
|
+
|
|
1455
|
+
def _request_close_when_current(self) -> None:
|
|
1456
|
+
"""Close this screen now or after any transient screen above it."""
|
|
1457
|
+
self._close_when_current = True
|
|
1458
|
+
self._close_if_current()
|
|
1459
|
+
|
|
1460
|
+
def _close_if_current(self) -> None:
|
|
1461
|
+
"""Pop only when this exact screen owns the top stack slot."""
|
|
1462
|
+
# ``Screen.is_current`` also includes transparent background
|
|
1463
|
+
# screens, so it is not strong enough to protect the top modal.
|
|
1464
|
+
if self.is_attached and self.app.screen is self:
|
|
1465
|
+
self._close_when_current = False
|
|
1466
|
+
self.app.pop_screen()
|
|
1467
|
+
|
|
1468
|
+
def on_screen_resume(self, _event: events.ScreenResume) -> None:
|
|
1469
|
+
"""Honor a deferred close and recover stranded footer recomposition."""
|
|
1470
|
+
if self._close_when_current:
|
|
1471
|
+
self._close_if_current()
|
|
1472
|
+
return
|
|
1473
|
+
# A bindings signal that fires before this screen becomes active is
|
|
1474
|
+
# deliberately stranded by the Footer's active-screen guard; resuming
|
|
1475
|
+
# is the recovery point that replays it.
|
|
1476
|
+
self.query_one(ChrysFooter).sync_bindings()
|
|
1477
|
+
|
|
1478
|
+
@on(TabbedContent.TabActivated)
|
|
1479
|
+
async def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
1480
|
+
if self._content_loading:
|
|
1481
|
+
return
|
|
1482
|
+
if event.tabbed_content is not self.query_one(TabbedContent):
|
|
1483
|
+
return
|
|
1484
|
+
pane = event.pane.query_one(DiffPeriodPane)
|
|
1485
|
+
pane.set_change_list_visible(self._change_list_visible)
|
|
1486
|
+
await pane.ensure_initialized()
|
|
1487
|
+
|
|
1488
|
+
async def _ensure_active_turn_pane_initialized(self) -> None:
|
|
1489
|
+
"""Initialize the active turn pane without touching inactive tabs."""
|
|
1490
|
+
tc = self.query_one(TabbedContent)
|
|
1491
|
+
active = tc.active
|
|
1492
|
+
if not active:
|
|
1493
|
+
return
|
|
1494
|
+
active_pane = tc.query_one(f"#{active}", TabPane)
|
|
1495
|
+
turn_pane = active_pane.query_one(DiffPeriodPane)
|
|
1496
|
+
turn_pane.set_change_list_visible(self._change_list_visible)
|
|
1497
|
+
await turn_pane.ensure_initialized()
|
|
1498
|
+
|
|
1499
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
1500
|
+
"""Hide and disable content actions until the prepared view is visible."""
|
|
1501
|
+
if action in self._CONTENT_ACTIONS and not self._content_ready:
|
|
1502
|
+
return False
|
|
1503
|
+
return super().check_action(action, parameters)
|
|
1504
|
+
|
|
1505
|
+
def action_go_back(self) -> None:
|
|
1506
|
+
if self._content_loading:
|
|
1507
|
+
# Unmount also cancels screen-owned workers, but cancel first so
|
|
1508
|
+
# no loader continuation can race with the asynchronous pop.
|
|
1509
|
+
self.workers.cancel_group(self, "diff-screen-content")
|
|
1510
|
+
self.app.pop_screen()
|
|
1511
|
+
|
|
1512
|
+
def action_toggle_view(self) -> None:
|
|
1513
|
+
"""Toggle split/unified on the active tab's DiffPeriodPane."""
|
|
1514
|
+
if not self._content_ready:
|
|
1515
|
+
return
|
|
1516
|
+
try:
|
|
1517
|
+
tc = self.query_one(TabbedContent)
|
|
1518
|
+
except NoMatches:
|
|
1519
|
+
return
|
|
1520
|
+
active_pane = tc.query_one(f"#{tc.active}", TabPane)
|
|
1521
|
+
if active_pane is not None:
|
|
1522
|
+
turn_pane = active_pane.query_one(DiffPeriodPane)
|
|
1523
|
+
if turn_pane is not None:
|
|
1524
|
+
turn_pane.toggle_view()
|
|
1525
|
+
|
|
1526
|
+
def action_toggle_change_list(self) -> None:
|
|
1527
|
+
"""Toggle the file change list tree for every diff tab."""
|
|
1528
|
+
if not self._content_ready:
|
|
1529
|
+
return
|
|
1530
|
+
self._change_list_visible = not self._change_list_visible
|
|
1531
|
+
for turn_pane in self.query(DiffPeriodPane):
|
|
1532
|
+
turn_pane.set_change_list_visible(self._change_list_visible)
|