alloy-runtime-cli 0.1.0__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.
- alloy_runtime_cli-0.1.0.dist-info/METADATA +61 -0
- alloy_runtime_cli-0.1.0.dist-info/RECORD +451 -0
- alloy_runtime_cli-0.1.0.dist-info/WHEEL +5 -0
- alloy_runtime_cli-0.1.0.dist-info/entry_points.txt +2 -0
- alloy_runtime_cli-0.1.0.dist-info/top_level.txt +1 -0
- cli/__init__.py +0 -0
- cli/commands/__init__.py +0 -0
- cli/commands/admin/__init__.py +0 -0
- cli/commands/admin/bootstrap_command.py +118 -0
- cli/commands/admin/credentials/__init__.py +0 -0
- cli/commands/admin/credentials/create/__init__.py +0 -0
- cli/commands/admin/credentials/create/command.py +148 -0
- cli/commands/admin/credentials/create/presenter.py +16 -0
- cli/commands/admin/credentials/grant/__init__.py +0 -0
- cli/commands/admin/credentials/grant/command.py +119 -0
- cli/commands/admin/credentials/grant/fields.py +33 -0
- cli/commands/admin/credentials/grant/presenter.py +23 -0
- cli/commands/agents/__init__.py +0 -0
- cli/commands/agents/create/__init__.py +0 -0
- cli/commands/agents/create/command.py +475 -0
- cli/commands/agents/create/fields.py +64 -0
- cli/commands/agents/create/presenter.py +68 -0
- cli/commands/agents/delete/__init__.py +0 -0
- cli/commands/agents/delete/command.py +47 -0
- cli/commands/agents/delete/presenter.py +16 -0
- cli/commands/agents/get/command.py +37 -0
- cli/commands/agents/get/presenter.py +32 -0
- cli/commands/agents/list/__init__.py +1 -0
- cli/commands/agents/list/command.py +54 -0
- cli/commands/agents/list/presenter.py +82 -0
- cli/commands/agents/update/__init__.py +0 -0
- cli/commands/agents/update/command.py +435 -0
- cli/commands/agents/update/fields.py +40 -0
- cli/commands/agents/update/presenter.py +68 -0
- cli/commands/audio/__init__.py +0 -0
- cli/commands/audio/transcribe/__init__.py +0 -0
- cli/commands/audio/transcribe/command.py +144 -0
- cli/commands/audio/transcribe/presenter.py +15 -0
- cli/commands/auth/__init__.py +0 -0
- cli/commands/auth/login/__init__.py +0 -0
- cli/commands/auth/login/command.py +80 -0
- cli/commands/auth/signup/__init__.py +0 -0
- cli/commands/auth/signup/command.py +115 -0
- cli/commands/billing/__init__.py +1 -0
- cli/commands/billing/costs/__init__.py +1 -0
- cli/commands/billing/costs/by_agent/__init__.py +1 -0
- cli/commands/billing/costs/by_agent/command.py +57 -0
- cli/commands/billing/costs/by_agent/presenter.py +81 -0
- cli/commands/billing/costs/by_model/__init__.py +1 -0
- cli/commands/billing/costs/by_model/command.py +57 -0
- cli/commands/billing/costs/by_model/presenter.py +80 -0
- cli/commands/billing/costs/daily/__init__.py +1 -0
- cli/commands/billing/costs/daily/command.py +55 -0
- cli/commands/billing/costs/daily/presenter.py +75 -0
- cli/commands/billing/costs/summary/__init__.py +1 -0
- cli/commands/billing/costs/summary/command.py +57 -0
- cli/commands/billing/costs/summary/presenter.py +42 -0
- cli/commands/billing/projects/__init__.py +1 -0
- cli/commands/billing/projects/create/__init__.py +1 -0
- cli/commands/billing/projects/create/command.py +60 -0
- cli/commands/billing/projects/create/presenter.py +26 -0
- cli/commands/billing/projects/get/__init__.py +1 -0
- cli/commands/billing/projects/get/command.py +33 -0
- cli/commands/billing/projects/get/presenter.py +32 -0
- cli/commands/billing/projects/list/__init__.py +1 -0
- cli/commands/billing/projects/list/command.py +40 -0
- cli/commands/billing/projects/list/presenter.py +57 -0
- cli/commands/content/__init__.py +1 -0
- cli/commands/content/delete/__init__.py +0 -0
- cli/commands/content/delete/command.py +49 -0
- cli/commands/content/delete/presenter.py +18 -0
- cli/commands/content/edit/__init__.py +1 -0
- cli/commands/content/edit/command.py +155 -0
- cli/commands/content/edit/editor.py +150 -0
- cli/commands/content/edit/presenter.py +146 -0
- cli/commands/content/get/__init__.py +1 -0
- cli/commands/content/get/command.py +39 -0
- cli/commands/content/get/presenter.py +176 -0
- cli/commands/content/list/__init__.py +1 -0
- cli/commands/content/list/command.py +347 -0
- cli/commands/content/list/export_formatters.py +409 -0
- cli/commands/content/list/export_handler.py +165 -0
- cli/commands/content/list/presenter.py +190 -0
- cli/commands/credentials/__init__.py +0 -0
- cli/commands/credentials/create/__init__.py +0 -0
- cli/commands/credentials/create/command.py +165 -0
- cli/commands/credentials/create/fields.py +38 -0
- cli/commands/credentials/create/presenter.py +20 -0
- cli/commands/credentials/update/__init__.py +0 -0
- cli/commands/credentials/update/command.py +53 -0
- cli/commands/credentials/update/fields.py +71 -0
- cli/commands/credentials/update/presenter.py +16 -0
- cli/commands/flag_utils.py +366 -0
- cli/commands/generate/__init__.py +0 -0
- cli/commands/generate/cancel/__init__.py +1 -0
- cli/commands/generate/cancel/command.py +44 -0
- cli/commands/generate/cancel/presenter.py +26 -0
- cli/commands/generate/status/__init__.py +1 -0
- cli/commands/generate/status/command.py +58 -0
- cli/commands/generate/status/presenter.py +78 -0
- cli/commands/generate/text/__init__.py +0 -0
- cli/commands/generate/text/command.py +1325 -0
- cli/commands/generate/text/concurrent_renderer.py +355 -0
- cli/commands/generate/text/presenter.py +287 -0
- cli/commands/generate/text/stream_renderer.py +129 -0
- cli/commands/knowledge/__init__.py +0 -0
- cli/commands/knowledge/collections/__init__.py +0 -0
- cli/commands/knowledge/collections/cluster/__init__.py +0 -0
- cli/commands/knowledge/collections/cluster/command.py +64 -0
- cli/commands/knowledge/collections/cluster/presenter.py +74 -0
- cli/commands/knowledge/collections/cluster_status/__init__.py +0 -0
- cli/commands/knowledge/collections/cluster_status/command.py +46 -0
- cli/commands/knowledge/collections/cluster_status/presenter.py +10 -0
- cli/commands/knowledge/collections/create/__init__.py +0 -0
- cli/commands/knowledge/collections/create/command.py +137 -0
- cli/commands/knowledge/collections/create/presenter.py +38 -0
- cli/commands/knowledge/collections/delete/__init__.py +1 -0
- cli/commands/knowledge/collections/delete/command.py +47 -0
- cli/commands/knowledge/collections/delete/presenter.py +20 -0
- cli/commands/knowledge/collections/get/__init__.py +1 -0
- cli/commands/knowledge/collections/get/command.py +30 -0
- cli/commands/knowledge/collections/get/presenter.py +44 -0
- cli/commands/knowledge/collections/list/__init__.py +1 -0
- cli/commands/knowledge/collections/list/command.py +41 -0
- cli/commands/knowledge/collections/list/presenter.py +68 -0
- cli/commands/knowledge/collections/update/__init__.py +0 -0
- cli/commands/knowledge/collections/update/command.py +97 -0
- cli/commands/knowledge/collections/update/presenter.py +42 -0
- cli/commands/knowledge/documents/__init__.py +0 -0
- cli/commands/knowledge/documents/bulk_metadata/__init__.py +0 -0
- cli/commands/knowledge/documents/bulk_metadata/command.py +119 -0
- cli/commands/knowledge/documents/bulk_metadata/presenter.py +36 -0
- cli/commands/knowledge/documents/delete/__init__.py +0 -0
- cli/commands/knowledge/documents/delete/command.py +47 -0
- cli/commands/knowledge/documents/delete/presenter.py +20 -0
- cli/commands/knowledge/documents/get/__init__.py +0 -0
- cli/commands/knowledge/documents/get/command.py +39 -0
- cli/commands/knowledge/documents/get/presenter.py +78 -0
- cli/commands/knowledge/documents/ingest/__init__.py +0 -0
- cli/commands/knowledge/documents/ingest/command.py +222 -0
- cli/commands/knowledge/documents/ingest/presenter.py +41 -0
- cli/commands/knowledge/documents/list/__init__.py +0 -0
- cli/commands/knowledge/documents/list/command.py +69 -0
- cli/commands/knowledge/documents/list/presenter.py +86 -0
- cli/commands/knowledge/documents/reingest/__init__.py +0 -0
- cli/commands/knowledge/documents/reingest/command.py +102 -0
- cli/commands/knowledge/documents/reingest/presenter.py +70 -0
- cli/commands/knowledge/documents/update/__init__.py +0 -0
- cli/commands/knowledge/documents/update/command.py +85 -0
- cli/commands/knowledge/documents/update/presenter.py +37 -0
- cli/commands/knowledge/recover/__init__.py +0 -0
- cli/commands/knowledge/recover/command.py +46 -0
- cli/commands/knowledge/recover/presenter.py +79 -0
- cli/commands/knowledge/search/__init__.py +0 -0
- cli/commands/knowledge/search/command.py +218 -0
- cli/commands/knowledge/search/presenter.py +111 -0
- cli/commands/knowledge/synthesis/__init__.py +0 -0
- cli/commands/knowledge/synthesis/create/__init__.py +0 -0
- cli/commands/knowledge/synthesis/create/command.py +127 -0
- cli/commands/knowledge/synthesis/create/presenter.py +33 -0
- cli/commands/knowledge/synthesis/delete/__init__.py +0 -0
- cli/commands/knowledge/synthesis/delete/command.py +53 -0
- cli/commands/knowledge/synthesis/delete/presenter.py +31 -0
- cli/commands/knowledge/synthesis/get/__init__.py +0 -0
- cli/commands/knowledge/synthesis/get/command.py +55 -0
- cli/commands/knowledge/synthesis/get/presenter.py +114 -0
- cli/commands/knowledge/synthesis/list/__init__.py +0 -0
- cli/commands/knowledge/synthesis/list/command.py +132 -0
- cli/commands/knowledge/synthesis/list/presenter.py +84 -0
- cli/commands/knowledge/synthesis/refresh/__init__.py +0 -0
- cli/commands/knowledge/synthesis/refresh/command.py +42 -0
- cli/commands/knowledge/synthesis/refresh/presenter.py +33 -0
- cli/commands/knowledge/synthesis/update/__init__.py +0 -0
- cli/commands/knowledge/synthesis/update/command.py +76 -0
- cli/commands/knowledge/synthesis/update/presenter.py +41 -0
- cli/commands/models/__init__.py +0 -0
- cli/commands/models/list/__init__.py +0 -0
- cli/commands/models/list/command.py +84 -0
- cli/commands/models/list/presenter.py +114 -0
- cli/commands/organizations/__init__.py +0 -0
- cli/commands/organizations/create/command.py +32 -0
- cli/commands/organizations/create/presenter.py +9 -0
- cli/commands/pipelines/__init__.py +1 -0
- cli/commands/pipelines/approvals/__init__.py +1 -0
- cli/commands/pipelines/approvals/decide_command.py +77 -0
- cli/commands/pipelines/approvals/get_command.py +44 -0
- cli/commands/pipelines/approvals/presenter.py +56 -0
- cli/commands/pipelines/costs/__init__.py +1 -0
- cli/commands/pipelines/costs/command.py +57 -0
- cli/commands/pipelines/costs/daily_command.py +54 -0
- cli/commands/pipelines/costs/daily_presenter.py +59 -0
- cli/commands/pipelines/costs/presenter.py +37 -0
- cli/commands/pipelines/create/__init__.py +1 -0
- cli/commands/pipelines/create/command.py +103 -0
- cli/commands/pipelines/create/presenter.py +22 -0
- cli/commands/pipelines/env_vars/__init__.py +1 -0
- cli/commands/pipelines/env_vars/command.py +51 -0
- cli/commands/pipelines/env_vars/presenter.py +16 -0
- cli/commands/pipelines/execute/__init__.py +1 -0
- cli/commands/pipelines/execute/command.py +142 -0
- cli/commands/pipelines/execute/presenter.py +47 -0
- cli/commands/pipelines/executions/__init__.py +1 -0
- cli/commands/pipelines/executions/costs/__init__.py +1 -0
- cli/commands/pipelines/executions/costs/command.py +48 -0
- cli/commands/pipelines/executions/costs/presenter.py +29 -0
- cli/commands/pipelines/executions/costs_by_model/__init__.py +1 -0
- cli/commands/pipelines/executions/costs_by_model/command.py +50 -0
- cli/commands/pipelines/executions/costs_by_model/presenter.py +78 -0
- cli/commands/pipelines/executions/costs_by_step/__init__.py +1 -0
- cli/commands/pipelines/executions/costs_by_step/command.py +50 -0
- cli/commands/pipelines/executions/costs_by_step/presenter.py +72 -0
- cli/commands/pipelines/executions/get_command.py +38 -0
- cli/commands/pipelines/executions/list_command.py +123 -0
- cli/commands/pipelines/executions/presenter.py +131 -0
- cli/commands/pipelines/executions/rerun_command.py +41 -0
- cli/commands/pipelines/executions/update/__init__.py +1 -0
- cli/commands/pipelines/executions/update/command.py +110 -0
- cli/commands/pipelines/executions/update/presenter.py +28 -0
- cli/commands/pipelines/get/__init__.py +1 -0
- cli/commands/pipelines/get/command.py +33 -0
- cli/commands/pipelines/get/presenter.py +48 -0
- cli/commands/pipelines/list/__init__.py +1 -0
- cli/commands/pipelines/list/command.py +53 -0
- cli/commands/pipelines/list/presenter.py +66 -0
- cli/commands/pipelines/schedules/__init__.py +1 -0
- cli/commands/pipelines/schedules/create_command.py +119 -0
- cli/commands/pipelines/schedules/create_presenter.py +35 -0
- cli/commands/pipelines/schedules/delete_command.py +52 -0
- cli/commands/pipelines/schedules/env_vars_command.py +59 -0
- cli/commands/pipelines/schedules/env_vars_presenter.py +16 -0
- cli/commands/pipelines/schedules/get_command.py +38 -0
- cli/commands/pipelines/schedules/list_command.py +33 -0
- cli/commands/pipelines/schedules/once_command.py +90 -0
- cli/commands/pipelines/schedules/once_presenter.py +30 -0
- cli/commands/pipelines/schedules/presenter.py +104 -0
- cli/commands/pipelines/schedules/update_command.py +139 -0
- cli/commands/pipelines/schedules/update_presenter.py +29 -0
- cli/commands/render/__init__.py +0 -0
- cli/commands/render/html_to_image/__init__.py +0 -0
- cli/commands/render/html_to_image/command.py +170 -0
- cli/commands/schemas/__init__.py +0 -0
- cli/commands/schemas/create/__init__.py +0 -0
- cli/commands/schemas/create/command.py +122 -0
- cli/commands/schemas/create/presenter.py +53 -0
- cli/commands/schemas/delete/command.py +45 -0
- cli/commands/schemas/delete/presenter.py +9 -0
- cli/commands/schemas/get/__init__.py +0 -0
- cli/commands/schemas/get/command.py +56 -0
- cli/commands/schemas/get/presenter.py +129 -0
- cli/commands/schemas/list/__init__.py +0 -0
- cli/commands/schemas/list/command.py +64 -0
- cli/commands/schemas/list/presenter.py +133 -0
- cli/commands/schemas/update/__init__.py +0 -0
- cli/commands/schemas/update/command.py +369 -0
- cli/commands/schemas/update/presenter.py +53 -0
- cli/commands/sessions/__init__.py +1 -0
- cli/commands/sessions/delete/__init__.py +1 -0
- cli/commands/sessions/delete/command.py +47 -0
- cli/commands/sessions/delete/presenter.py +10 -0
- cli/commands/sessions/get/__init__.py +1 -0
- cli/commands/sessions/get/command.py +42 -0
- cli/commands/sessions/get/presenter.py +59 -0
- cli/commands/sessions/list/__init__.py +1 -0
- cli/commands/sessions/list/command.py +61 -0
- cli/commands/sessions/list/presenter.py +68 -0
- cli/commands/sessions/messages/__init__.py +1 -0
- cli/commands/sessions/messages/command.py +78 -0
- cli/commands/sessions/messages/presenter.py +79 -0
- cli/commands/shared_flags.py +500 -0
- cli/commands/sync/__init__.py +0 -0
- cli/commands/sync/command.py +45 -0
- cli/commands/sync/presenter.py +49 -0
- cli/commands/tags/__init__.py +1 -0
- cli/commands/tags/create/__init__.py +1 -0
- cli/commands/tags/create/command.py +60 -0
- cli/commands/tags/delete/__init__.py +1 -0
- cli/commands/tags/delete/command.py +47 -0
- cli/commands/tags/delete/presenter.py +10 -0
- cli/commands/tags/get/command.py +31 -0
- cli/commands/tags/get/presenter.py +23 -0
- cli/commands/tags/list/__init__.py +1 -0
- cli/commands/tags/list/command.py +52 -0
- cli/commands/tags/list/presenter.py +49 -0
- cli/commands/tags/update/command.py +64 -0
- cli/commands/tags/update/presenter.py +9 -0
- cli/commands/templates/__init__.py +0 -0
- cli/commands/templates/create/__init__.py +0 -0
- cli/commands/templates/create/command.py +152 -0
- cli/commands/templates/create/presenter.py +86 -0
- cli/commands/templates/delete/__init__.py +0 -0
- cli/commands/templates/delete/command.py +47 -0
- cli/commands/templates/delete/presenter.py +16 -0
- cli/commands/templates/get/__init__.py +0 -0
- cli/commands/templates/get/command.py +52 -0
- cli/commands/templates/get/presenter.py +233 -0
- cli/commands/templates/get_by_version/command.py +32 -0
- cli/commands/templates/get_by_version/presenter.py +30 -0
- cli/commands/templates/list/__init__.py +1 -0
- cli/commands/templates/list/command.py +102 -0
- cli/commands/templates/list/presenter.py +93 -0
- cli/commands/templates/render/__init__.py +0 -0
- cli/commands/templates/render/command.py +115 -0
- cli/commands/templates/render/presenter.py +276 -0
- cli/commands/templates/update/__init__.py +0 -0
- cli/commands/templates/update/command.py +199 -0
- cli/commands/templates/update/presenter.py +94 -0
- cli/commands/templates/version/__init__.py +1 -0
- cli/commands/templates/version/command.py +116 -0
- cli/commands/templates/version/presenter.py +100 -0
- cli/commands/tool_configs/__init__.py +0 -0
- cli/commands/tool_configs/create/__init__.py +0 -0
- cli/commands/tool_configs/create/command.py +118 -0
- cli/commands/tool_configs/create/presenter.py +53 -0
- cli/commands/tool_configs/delete/__init__.py +0 -0
- cli/commands/tool_configs/delete/command.py +47 -0
- cli/commands/tool_configs/delete/presenter.py +18 -0
- cli/commands/tool_configs/get/__init__.py +0 -0
- cli/commands/tool_configs/get/command.py +31 -0
- cli/commands/tool_configs/get/presenter.py +62 -0
- cli/commands/tool_configs/list/__init__.py +0 -0
- cli/commands/tool_configs/list/command.py +59 -0
- cli/commands/tool_configs/list/presenter.py +60 -0
- cli/commands/tool_configs/update/__init__.py +0 -0
- cli/commands/tool_configs/update/command.py +128 -0
- cli/commands/tool_configs/update/presenter.py +53 -0
- cli/commands/tools/__init__.py +1 -0
- cli/commands/tools/get/__init__.py +1 -0
- cli/commands/tools/get/command.py +42 -0
- cli/commands/tools/get/presenter.py +45 -0
- cli/commands/tools/list/__init__.py +1 -0
- cli/commands/tools/list/command.py +56 -0
- cli/commands/tools/list/presenter.py +44 -0
- cli/commands/users/__init__.py +0 -0
- cli/commands/users/create/command.py +53 -0
- cli/commands/users/create/presenter.py +9 -0
- cli/commands/whoami/__init__.py +0 -0
- cli/commands/whoami/command.py +42 -0
- cli/infrastructure/__init__.py +0 -0
- cli/infrastructure/auth_storage.py +71 -0
- cli/infrastructure/client_factory.py +36 -0
- cli/infrastructure/command.py +75 -0
- cli/infrastructure/config.py +188 -0
- cli/infrastructure/console.py +27 -0
- cli/infrastructure/editor.py +138 -0
- cli/infrastructure/error_display.py +178 -0
- cli/infrastructure/field_extractor.py +360 -0
- cli/infrastructure/file_content.py +210 -0
- cli/infrastructure/filter_parser.py +256 -0
- cli/infrastructure/formatters/__init__.py +0 -0
- cli/infrastructure/formatters/base.py +99 -0
- cli/infrastructure/formatters/compact_formatter.py +245 -0
- cli/infrastructure/formatters/json_formatter.py +84 -0
- cli/infrastructure/formatters/lines_formatter.py +102 -0
- cli/infrastructure/formatting/__init__.py +0 -0
- cli/infrastructure/formatting/fields.py +193 -0
- cli/infrastructure/forms/__init__.py +0 -0
- cli/infrastructure/forms/agent_picker.py +123 -0
- cli/infrastructure/forms/agent_tool_editor.py +384 -0
- cli/infrastructure/forms/agent_tools_manager.py +212 -0
- cli/infrastructure/forms/base_picker.py +469 -0
- cli/infrastructure/forms/components.py +126 -0
- cli/infrastructure/forms/json_schema_builder.py +149 -0
- cli/infrastructure/forms/model_picker.py +134 -0
- cli/infrastructure/forms/parsers.py +173 -0
- cli/infrastructure/forms/resolution_modal.py +302 -0
- cli/infrastructure/forms/schema_picker.py +137 -0
- cli/infrastructure/forms/tag_management_modal.py +103 -0
- cli/infrastructure/forms/tag_picker.py +207 -0
- cli/infrastructure/forms/template_picker.py +131 -0
- cli/infrastructure/forms/tool_config_picker.py +130 -0
- cli/infrastructure/forms/tool_picker.py +103 -0
- cli/infrastructure/injection/__init__.py +0 -0
- cli/infrastructure/injection/parser.py +302 -0
- cli/infrastructure/injection/resolver.py +399 -0
- cli/infrastructure/kv_parser.py +130 -0
- cli/infrastructure/local_storage.py +227 -0
- cli/infrastructure/macro_parser.py +215 -0
- cli/infrastructure/output.py +192 -0
- cli/infrastructure/provider_setup.py +81 -0
- cli/infrastructure/renderers/__init__.py +0 -0
- cli/infrastructure/renderers/entity_renderer.py +77 -0
- cli/infrastructure/renderers/list_renderer.py +114 -0
- cli/infrastructure/scope_utils.py +47 -0
- cli/infrastructure/spinner.py +101 -0
- cli/infrastructure/tui/__init__.py +0 -0
- cli/infrastructure/tui/clipboard.py +41 -0
- cli/infrastructure/tui/formatters.py +105 -0
- cli/infrastructure/tui/preview.py +14 -0
- cli/infrastructure/tui/selectable.py +198 -0
- cli/infrastructure/validation/__init__.py +0 -0
- cli/infrastructure/validation/tag_validation.py +74 -0
- cli/main.py +759 -0
- cli/tui/__init__.py +0 -0
- cli/tui/app.py +199 -0
- cli/tui/app_store.py +73 -0
- cli/tui/chat/__init__.py +0 -0
- cli/tui/chat/commands/__init__.py +0 -0
- cli/tui/chat/commands/base.py +65 -0
- cli/tui/chat/commands/create_session.py +135 -0
- cli/tui/chat/commands/load_session.py +119 -0
- cli/tui/chat/commands/regenerate.py +120 -0
- cli/tui/chat/commands/reload_session.py +63 -0
- cli/tui/chat/commands/send_message.py +190 -0
- cli/tui/chat/commands/undo.py +66 -0
- cli/tui/chat/editor.py +71 -0
- cli/tui/chat/messages.py +223 -0
- cli/tui/chat/pane.py +141 -0
- cli/tui/chat/renderers/__init__.py +0 -0
- cli/tui/chat/renderers/base.py +72 -0
- cli/tui/chat/renderers/markdown.py +250 -0
- cli/tui/chat/renderers/plain.py +83 -0
- cli/tui/chat/screen.py +1155 -0
- cli/tui/chat/services/__init__.py +0 -0
- cli/tui/chat/services/injection.py +386 -0
- cli/tui/chat/services/name_generator.py +256 -0
- cli/tui/chat/slash_commands.py +424 -0
- cli/tui/chat/store.py +280 -0
- cli/tui/chat/types.py +220 -0
- cli/tui/chat/widgets/__init__.py +0 -0
- cli/tui/chat/widgets/chat_header.py +75 -0
- cli/tui/chat/widgets/chat_input.py +362 -0
- cli/tui/chat/widgets/injection_popup.py +161 -0
- cli/tui/chat/widgets/message_display.py +287 -0
- cli/tui/chat/widgets/session_sidebar.py +214 -0
- cli/tui/chat/widgets/welcome_screen.py +290 -0
- cli/tui/screens/__init__.py +0 -0
- cli/tui/screens/agents.py +344 -0
- cli/tui/screens/base.py +301 -0
- cli/tui/screens/content.py +508 -0
- cli/tui/screens/dashboard.py +89 -0
- cli/tui/screens/models.py +96 -0
- cli/tui/screens/nav_screen.py +186 -0
- cli/tui/screens/schemas.py +522 -0
- cli/tui/screens/templates.py +734 -0
- cli/tui/screens/tool_configs.py +335 -0
- cli/tui/styles/__init__.py +0 -0
- cli/tui/widgets/__init__.py +0 -0
- cli/tui/widgets/agent_create_modal.py +139 -0
- cli/tui/widgets/agent_form_modal.py +659 -0
- cli/tui/widgets/agent_update_modal.py +299 -0
- cli/tui/widgets/base_form_modal.py +77 -0
- cli/tui/widgets/confirm_modal.py +75 -0
- cli/tui/widgets/help_modal.py +145 -0
- cli/tui/widgets/new_session_modal.py +328 -0
- cli/tui/widgets/schema_create_modal.py +271 -0
- cli/tui/widgets/schema_update_modal.py +188 -0
- cli/tui/widgets/status_footer.py +147 -0
- cli/tui/widgets/template_create_modal.py +502 -0
- cli/tui/widgets/template_update_modal.py +308 -0
- cli/tui/widgets/tool_config_create_modal.py +216 -0
- cli/tui/widgets/tool_config_update_modal.py +208 -0
cli/tui/chat/screen.py
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
"""ChatScreen - the main chat screen with split-pane support.
|
|
2
|
+
|
|
3
|
+
This is the main chat interface that:
|
|
4
|
+
1. Composes child widgets (sidebar + pane(s))
|
|
5
|
+
2. Handles Messages from children
|
|
6
|
+
3. Dispatches commands for async operations
|
|
7
|
+
4. Coordinates with AppStore for API access
|
|
8
|
+
5. Supports split-pane view for comparing models
|
|
9
|
+
|
|
10
|
+
Now implemented as a proper Textual Screen for proper keybinding isolation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from textual import on, work
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.binding import Binding
|
|
19
|
+
from textual.containers import Horizontal
|
|
20
|
+
from textual.message import Message
|
|
21
|
+
|
|
22
|
+
from alloy_runtime_sdk.api_client.client import ApiClient
|
|
23
|
+
from alloy_runtime_types.dtos.sessions import UpdateSessionRequest
|
|
24
|
+
|
|
25
|
+
from cli.infrastructure.injection.resolver import ResolvedMessage
|
|
26
|
+
from cli.infrastructure.local_storage import get_storage
|
|
27
|
+
from cli.infrastructure.tui.clipboard import copy_to_clipboard
|
|
28
|
+
from cli.tui.chat.commands.create_session import CreateSessionCommand
|
|
29
|
+
from cli.tui.chat.commands.load_session import LoadSessionCommand
|
|
30
|
+
from cli.tui.chat.commands.regenerate import RegenerateCommand
|
|
31
|
+
from cli.tui.chat.commands.reload_session import ReloadSessionCommand
|
|
32
|
+
from cli.tui.chat.commands.send_message import SendMessageCommand
|
|
33
|
+
from cli.tui.chat.commands.undo import UndoCommand
|
|
34
|
+
from cli.tui.chat.messages import (
|
|
35
|
+
AgentQuickSelected,
|
|
36
|
+
CopyToClipboardRequested,
|
|
37
|
+
FocusInputRequested,
|
|
38
|
+
FocusSessionSearchRequested,
|
|
39
|
+
NewSessionRequested,
|
|
40
|
+
OpenEditorRequested,
|
|
41
|
+
RecentSessionSelected,
|
|
42
|
+
RegenerateRequested,
|
|
43
|
+
SendMessageRequested,
|
|
44
|
+
SessionSelected,
|
|
45
|
+
SessionsLoadError,
|
|
46
|
+
SlashCommandExecuted,
|
|
47
|
+
UndoRequested,
|
|
48
|
+
UnknownSlashCommand,
|
|
49
|
+
)
|
|
50
|
+
from cli.tui.chat.editor import EditorError, open_in_editor
|
|
51
|
+
from cli.tui.chat.pane import ChatPane
|
|
52
|
+
from cli.tui.chat.services.injection import InjectionService
|
|
53
|
+
from cli.tui.chat.services.name_generator import SessionNameGenerator
|
|
54
|
+
from cli.tui.chat.store import ChatStore
|
|
55
|
+
from cli.tui.chat.types import ChatPhase, extract_message_text
|
|
56
|
+
from cli.tui.chat.widgets.chat_input import ChatInput
|
|
57
|
+
from cli.tui.chat.widgets.message_display import MessageDisplay
|
|
58
|
+
from cli.tui.chat.widgets.session_sidebar import SessionSidebar
|
|
59
|
+
from cli.tui.screens.nav_screen import NavScreen
|
|
60
|
+
from cli.tui.widgets.new_session_modal import NewSessionConfig, NewSessionModal
|
|
61
|
+
from alloy_runtime_sdk.logging.config import get_logger
|
|
62
|
+
|
|
63
|
+
logger = get_logger(__name__)
|
|
64
|
+
|
|
65
|
+
# Storage keys for preferences
|
|
66
|
+
PREF_AUTO_NAME_SESSIONS = "auto_name_sessions"
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from cli.tui.app import AlloyRuntimeApp
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PaneFocusChanged(Message):
|
|
73
|
+
"""Message posted when the active pane changes in split view."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, pane_id: str) -> None:
|
|
76
|
+
self.pane_id = pane_id
|
|
77
|
+
super().__init__()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ChatScreen(NavScreen):
|
|
81
|
+
"""Main chat screen - orchestrates child widgets and handles commands.
|
|
82
|
+
|
|
83
|
+
Supports both single-pane and split-pane modes for comparing models.
|
|
84
|
+
|
|
85
|
+
Architecture:
|
|
86
|
+
- ChatStore: Centralized reactive state (one per pane)
|
|
87
|
+
- SessionSidebar: Session list with search (shared, controls active pane)
|
|
88
|
+
- ChatPane: Individual chat area (header, messages, input)
|
|
89
|
+
|
|
90
|
+
Split View:
|
|
91
|
+
- Toggle with Ctrl+Shift+S to enable/disable split view
|
|
92
|
+
- Switch between panes with Ctrl+Tab
|
|
93
|
+
- Sync mode sends messages to both panes simultaneously
|
|
94
|
+
|
|
95
|
+
Responsibilities:
|
|
96
|
+
- Compose child widgets with shared/separate stores
|
|
97
|
+
- Handle Messages from children
|
|
98
|
+
- Execute commands for async API operations
|
|
99
|
+
- Coordinate state transitions
|
|
100
|
+
- Manage split-pane layout and focus
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
app: "AlloyRuntimeApp"
|
|
104
|
+
|
|
105
|
+
SCREEN_ID = "chat"
|
|
106
|
+
|
|
107
|
+
DEFAULT_CSS = """
|
|
108
|
+
ChatScreen #screen-root {
|
|
109
|
+
height: 1fr;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
ChatScreen #chat-root {
|
|
113
|
+
height: 100%;
|
|
114
|
+
width: 100%;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ChatScreen #split-container {
|
|
118
|
+
width: 1fr;
|
|
119
|
+
height: 100%;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ChatScreen #panes-container {
|
|
123
|
+
width: 1fr;
|
|
124
|
+
height: 100%;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ChatScreen .sync-indicator {
|
|
128
|
+
dock: bottom;
|
|
129
|
+
height: 1;
|
|
130
|
+
background: $warning;
|
|
131
|
+
color: $text;
|
|
132
|
+
text-align: center;
|
|
133
|
+
display: none;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ChatScreen .sync-indicator.visible {
|
|
137
|
+
display: block;
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# Screen-specific bindings (Ctrl+ prefixed to not conflict with typing)
|
|
142
|
+
# NavScreen.BINDINGS provides global navigation (c, a, t, s, m, d, ?)
|
|
143
|
+
BINDINGS = NavScreen.BINDINGS + [
|
|
144
|
+
# Session management
|
|
145
|
+
Binding("ctrl+n", "new_session", "New Chat", show=True),
|
|
146
|
+
Binding("ctrl+l", "clear_display", "Clear", show=False),
|
|
147
|
+
# Chat operations
|
|
148
|
+
Binding("ctrl+r", "regenerate", "Regenerate", show=True),
|
|
149
|
+
Binding("ctrl+u", "undo", "Undo", show=True),
|
|
150
|
+
Binding("ctrl+y", "copy_last", "Copy", show=True),
|
|
151
|
+
# Navigation - use escape to focus input
|
|
152
|
+
Binding("escape", "cancel_or_focus", "Cancel/Focus", show=False),
|
|
153
|
+
# UI toggles
|
|
154
|
+
Binding("ctrl+b", "toggle_sidebar", "Sidebar", show=True),
|
|
155
|
+
# Split view (Ctrl+Shift to avoid conflicts)
|
|
156
|
+
Binding("ctrl+shift+s", "toggle_split", "Split View", show=False),
|
|
157
|
+
Binding("ctrl+shift+tab", "switch_pane", "Switch Pane", show=False),
|
|
158
|
+
Binding("ctrl+shift+y", "toggle_sync", "Sync Input", show=False),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
def __init__(self) -> None:
|
|
162
|
+
"""Initialize the chat screen with stores for both panes."""
|
|
163
|
+
super().__init__()
|
|
164
|
+
# Primary (left) pane store - always exists
|
|
165
|
+
self._left_store = ChatStore()
|
|
166
|
+
# Secondary (right) pane store - only used in split mode
|
|
167
|
+
self._right_store = ChatStore()
|
|
168
|
+
|
|
169
|
+
# Split view state
|
|
170
|
+
self._split_mode = False
|
|
171
|
+
self._active_pane: str = "left" # "left" or "right"
|
|
172
|
+
self._sync_mode = False # Send to both panes when True
|
|
173
|
+
|
|
174
|
+
# Injection service (initialized on first use)
|
|
175
|
+
self._injection_service: InjectionService | None = None
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def _store(self) -> ChatStore:
|
|
179
|
+
"""Get the active pane's store."""
|
|
180
|
+
return self._left_store if self._active_pane == "left" else self._right_store
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def _active_pane_widget(self) -> ChatPane | None:
|
|
184
|
+
"""Get the currently active ChatPane widget."""
|
|
185
|
+
pane_id = "left-pane" if self._active_pane == "left" else "right-pane"
|
|
186
|
+
try:
|
|
187
|
+
return self.query_one(f"#{pane_id}", ChatPane)
|
|
188
|
+
except Exception:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def compose_content(self) -> ComposeResult:
|
|
192
|
+
"""Compose the chat layout with child widgets."""
|
|
193
|
+
with Horizontal(id="chat-root"):
|
|
194
|
+
yield SessionSidebar(self._left_store)
|
|
195
|
+
with Horizontal(id="panes-container"):
|
|
196
|
+
# Left pane (always visible)
|
|
197
|
+
yield ChatPane(
|
|
198
|
+
self._left_store,
|
|
199
|
+
pane_id="left",
|
|
200
|
+
show_border=False,
|
|
201
|
+
id="left-pane",
|
|
202
|
+
)
|
|
203
|
+
# Right pane (hidden by default, shown in split mode)
|
|
204
|
+
right_pane = ChatPane(
|
|
205
|
+
self._right_store,
|
|
206
|
+
pane_id="right",
|
|
207
|
+
show_border=True,
|
|
208
|
+
id="right-pane",
|
|
209
|
+
)
|
|
210
|
+
right_pane.display = False
|
|
211
|
+
yield right_pane
|
|
212
|
+
|
|
213
|
+
def on_mount(self) -> None:
|
|
214
|
+
"""Initialize pane focus state and injection service."""
|
|
215
|
+
self._update_pane_focus_indicators()
|
|
216
|
+
# Initialize injection service in background
|
|
217
|
+
self._initialize_injection_service()
|
|
218
|
+
|
|
219
|
+
def on_screen_resume(self) -> None:
|
|
220
|
+
"""Called when screen becomes active - focus the chat input."""
|
|
221
|
+
self.call_after_refresh(self.focus_input)
|
|
222
|
+
|
|
223
|
+
# =========================================================================
|
|
224
|
+
# Split View Management
|
|
225
|
+
# =========================================================================
|
|
226
|
+
|
|
227
|
+
def action_toggle_split(self) -> None:
|
|
228
|
+
"""Toggle split-pane view."""
|
|
229
|
+
self._split_mode = not self._split_mode
|
|
230
|
+
|
|
231
|
+
# Show/hide right pane
|
|
232
|
+
try:
|
|
233
|
+
right_pane = self.query_one("#right-pane", ChatPane)
|
|
234
|
+
left_pane = self.query_one("#left-pane", ChatPane)
|
|
235
|
+
|
|
236
|
+
right_pane.display = self._split_mode
|
|
237
|
+
|
|
238
|
+
# Update border visibility
|
|
239
|
+
left_pane.set_show_border(self._split_mode)
|
|
240
|
+
if self._split_mode:
|
|
241
|
+
left_pane.add_class(
|
|
242
|
+
"focused-pane" if self._active_pane == "left" else "unfocused-pane"
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
left_pane.remove_class("focused-pane")
|
|
246
|
+
left_pane.remove_class("unfocused-pane")
|
|
247
|
+
# Reset to left pane when disabling split
|
|
248
|
+
self._active_pane = "left"
|
|
249
|
+
self._sync_mode = False
|
|
250
|
+
|
|
251
|
+
self._update_pane_focus_indicators()
|
|
252
|
+
|
|
253
|
+
status = "enabled" if self._split_mode else "disabled"
|
|
254
|
+
self.app.notify(f"Split view {status}", severity="information")
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.debug(f"Failed to toggle split view: {e}")
|
|
258
|
+
|
|
259
|
+
def action_switch_pane(self) -> None:
|
|
260
|
+
"""Switch focus between left and right panes."""
|
|
261
|
+
if not self._split_mode:
|
|
262
|
+
self.app.notify(
|
|
263
|
+
"Split view not enabled. Press Ctrl+Shift+S to enable.",
|
|
264
|
+
severity="warning",
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Toggle active pane
|
|
269
|
+
self._active_pane = "right" if self._active_pane == "left" else "left"
|
|
270
|
+
self._update_pane_focus_indicators()
|
|
271
|
+
|
|
272
|
+
# Update sidebar to use new active pane's store
|
|
273
|
+
self._update_sidebar_store()
|
|
274
|
+
|
|
275
|
+
# Focus the input in the new active pane
|
|
276
|
+
pane = self._active_pane_widget
|
|
277
|
+
if pane:
|
|
278
|
+
pane.focus_input()
|
|
279
|
+
|
|
280
|
+
self.app.notify(f"Switched to {self._active_pane} pane", severity="information")
|
|
281
|
+
|
|
282
|
+
def action_toggle_sync(self) -> None:
|
|
283
|
+
"""Toggle synchronized input mode (send to both panes)."""
|
|
284
|
+
if not self._split_mode:
|
|
285
|
+
self.app.notify(
|
|
286
|
+
"Split view not enabled. Press Ctrl+Shift+S to enable.",
|
|
287
|
+
severity="warning",
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
self._sync_mode = not self._sync_mode
|
|
292
|
+
status = "enabled" if self._sync_mode else "disabled"
|
|
293
|
+
self.app.notify(
|
|
294
|
+
f"Sync mode {status} - messages sent to {'both panes' if self._sync_mode else 'active pane only'}",
|
|
295
|
+
severity="information",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _update_pane_focus_indicators(self) -> None:
|
|
299
|
+
"""Update visual focus indicators on panes."""
|
|
300
|
+
if not self._split_mode:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
left_pane = self.query_one("#left-pane", ChatPane)
|
|
305
|
+
right_pane = self.query_one("#right-pane", ChatPane)
|
|
306
|
+
|
|
307
|
+
left_pane.set_focused(self._active_pane == "left")
|
|
308
|
+
right_pane.set_focused(self._active_pane == "right")
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def _update_sidebar_store(self) -> None:
|
|
313
|
+
"""Update sidebar to reflect the active pane's store.
|
|
314
|
+
|
|
315
|
+
Note: This is a limitation - the sidebar is created with left_store.
|
|
316
|
+
For full implementation, we'd need to make the sidebar reactive to store changes.
|
|
317
|
+
For now, session selection affects the active pane.
|
|
318
|
+
"""
|
|
319
|
+
# The sidebar always shows sessions, but selection loads into active pane
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
# =========================================================================
|
|
323
|
+
# Message Handlers (from child widgets)
|
|
324
|
+
# =========================================================================
|
|
325
|
+
|
|
326
|
+
@on(SessionSelected)
|
|
327
|
+
def handle_session_selected(self, message: SessionSelected) -> None:
|
|
328
|
+
"""Load messages when a session is selected."""
|
|
329
|
+
logger.info("session_selected", session_id=str(message.session_id))
|
|
330
|
+
self._execute_load_session(message.session_id)
|
|
331
|
+
|
|
332
|
+
@on(AgentQuickSelected)
|
|
333
|
+
def handle_agent_quick_selected(self, message: AgentQuickSelected) -> None:
|
|
334
|
+
"""Create a new session with the selected agent immediately."""
|
|
335
|
+
logger.info(
|
|
336
|
+
"agent_quick_selected",
|
|
337
|
+
agent_id=str(message.agent_id),
|
|
338
|
+
agent_name=message.agent_name,
|
|
339
|
+
)
|
|
340
|
+
self._execute_quick_agent_session(message.agent_id, message.agent_name)
|
|
341
|
+
|
|
342
|
+
@on(RecentSessionSelected)
|
|
343
|
+
def handle_recent_session_selected(self, message: RecentSessionSelected) -> None:
|
|
344
|
+
"""Load a recent session from the welcome screen."""
|
|
345
|
+
logger.info("recent_session_selected", session_id=str(message.session_id))
|
|
346
|
+
self._execute_load_session(message.session_id)
|
|
347
|
+
|
|
348
|
+
@on(SendMessageRequested)
|
|
349
|
+
def handle_send_message(self, message: SendMessageRequested) -> None:
|
|
350
|
+
"""Send a message and stream the response."""
|
|
351
|
+
if message.content:
|
|
352
|
+
logger.info(
|
|
353
|
+
"send_message_requested",
|
|
354
|
+
content_length=len(message.content),
|
|
355
|
+
sync_mode=self._sync_mode,
|
|
356
|
+
split_mode=self._split_mode,
|
|
357
|
+
)
|
|
358
|
+
if self._sync_mode and self._split_mode:
|
|
359
|
+
# Send to both panes
|
|
360
|
+
self._execute_send_message_sync(message.content)
|
|
361
|
+
else:
|
|
362
|
+
# Send to active pane only
|
|
363
|
+
self._execute_send_message(message.content)
|
|
364
|
+
|
|
365
|
+
@on(RegenerateRequested)
|
|
366
|
+
def handle_regenerate(self, message: RegenerateRequested) -> None:
|
|
367
|
+
"""Regenerate the last response."""
|
|
368
|
+
self.action_regenerate()
|
|
369
|
+
|
|
370
|
+
@on(UndoRequested)
|
|
371
|
+
def handle_undo(self, message: UndoRequested) -> None:
|
|
372
|
+
"""Undo the last conversation turn."""
|
|
373
|
+
self.action_undo()
|
|
374
|
+
|
|
375
|
+
@on(CopyToClipboardRequested)
|
|
376
|
+
def handle_copy(self, message: CopyToClipboardRequested) -> None:
|
|
377
|
+
"""Copy content to clipboard.
|
|
378
|
+
|
|
379
|
+
If the label is '_copy_last_request_', this is a request to fetch
|
|
380
|
+
and copy the last assistant message from the server.
|
|
381
|
+
"""
|
|
382
|
+
if message.label == "_copy_last_request_":
|
|
383
|
+
# This is a copy_last request from ChatTextArea
|
|
384
|
+
self.action_copy_last()
|
|
385
|
+
else:
|
|
386
|
+
# Regular copy request with content
|
|
387
|
+
copy_to_clipboard(self.app, message.content, message.label)
|
|
388
|
+
|
|
389
|
+
@on(FocusInputRequested)
|
|
390
|
+
def handle_focus_input(self, message: FocusInputRequested) -> None:
|
|
391
|
+
"""Focus the chat input."""
|
|
392
|
+
self.action_focus_input()
|
|
393
|
+
|
|
394
|
+
@on(FocusSessionSearchRequested)
|
|
395
|
+
def handle_focus_search(self, message: FocusSessionSearchRequested) -> None:
|
|
396
|
+
"""Focus the session search."""
|
|
397
|
+
self.action_focus_session_search()
|
|
398
|
+
|
|
399
|
+
@on(NewSessionRequested)
|
|
400
|
+
def handle_new_session(self, message: NewSessionRequested) -> None:
|
|
401
|
+
"""Open new session modal."""
|
|
402
|
+
self.action_new_session()
|
|
403
|
+
|
|
404
|
+
@on(SessionsLoadError)
|
|
405
|
+
def handle_sessions_error(self, message: SessionsLoadError) -> None:
|
|
406
|
+
"""Handle session loading errors."""
|
|
407
|
+
self.app.notify(f"Failed to load sessions: {message.error}", severity="error")
|
|
408
|
+
|
|
409
|
+
@on(SlashCommandExecuted)
|
|
410
|
+
def handle_slash_command(self, message: SlashCommandExecuted) -> None:
|
|
411
|
+
"""Handle slash commands that require screen-level actions."""
|
|
412
|
+
logger.debug(
|
|
413
|
+
"slash_command_executed", command=message.command_name, args=message.args
|
|
414
|
+
)
|
|
415
|
+
command_name = message.command_name
|
|
416
|
+
|
|
417
|
+
# Map command names to actions
|
|
418
|
+
if command_name in ("new", "n"):
|
|
419
|
+
self.action_new_session()
|
|
420
|
+
elif command_name in ("sessions", "s", "list"):
|
|
421
|
+
self.action_focus_session_search()
|
|
422
|
+
elif command_name in ("undo", "u", "z"):
|
|
423
|
+
self.action_undo()
|
|
424
|
+
elif command_name in ("regenerate", "r", "regen"):
|
|
425
|
+
self.action_regenerate()
|
|
426
|
+
elif command_name in ("split",):
|
|
427
|
+
self.action_toggle_split()
|
|
428
|
+
elif command_name in ("sync",):
|
|
429
|
+
self.action_toggle_sync()
|
|
430
|
+
elif command_name in ("switch", "sw"):
|
|
431
|
+
self.action_switch_pane()
|
|
432
|
+
# Note: clear, help, copy are handled directly in ChatInput
|
|
433
|
+
|
|
434
|
+
@on(UnknownSlashCommand)
|
|
435
|
+
def handle_unknown_command(self, message: UnknownSlashCommand) -> None:
|
|
436
|
+
"""Handle unknown slash commands."""
|
|
437
|
+
self.app.notify(
|
|
438
|
+
f"Unknown command: {message.command_text}. Type /help for available commands.",
|
|
439
|
+
severity="warning",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
@on(OpenEditorRequested)
|
|
443
|
+
def handle_open_editor(self, message: OpenEditorRequested) -> None:
|
|
444
|
+
"""Open external editor for message composition."""
|
|
445
|
+
self._open_external_editor(message.current_text)
|
|
446
|
+
|
|
447
|
+
# =========================================================================
|
|
448
|
+
# Actions (triggered by bindings)
|
|
449
|
+
# =========================================================================
|
|
450
|
+
|
|
451
|
+
def action_new_session(self) -> None:
|
|
452
|
+
"""Create a new chat session via modal."""
|
|
453
|
+
self._show_new_session_modal()
|
|
454
|
+
|
|
455
|
+
def action_regenerate(self) -> None:
|
|
456
|
+
"""Regenerate the last AI response."""
|
|
457
|
+
state = self._store.state
|
|
458
|
+
session = state.session
|
|
459
|
+
|
|
460
|
+
if not session:
|
|
461
|
+
self.app.notify("No active session", severity="warning")
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if state.phase != ChatPhase.IDLE:
|
|
465
|
+
self.app.notify("Please wait for the current operation", severity="warning")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
if not session.is_agent_mode and not session.is_model_mode:
|
|
469
|
+
self.app.notify(
|
|
470
|
+
"No agent or model configured for this session", severity="warning"
|
|
471
|
+
)
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
self._execute_regenerate()
|
|
475
|
+
|
|
476
|
+
def action_undo(self) -> None:
|
|
477
|
+
"""Undo the last conversation turn."""
|
|
478
|
+
state = self._store.state
|
|
479
|
+
|
|
480
|
+
if not state.session:
|
|
481
|
+
self.app.notify("No active session", severity="warning")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
if state.phase != ChatPhase.IDLE:
|
|
485
|
+
self.app.notify("Please wait for the current operation", severity="warning")
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
messages = state.messages
|
|
489
|
+
if len(messages) < 2:
|
|
490
|
+
self.app.notify("Nothing to undo", severity="warning")
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
# Check last two messages form a user->assistant turn
|
|
494
|
+
last = messages[-1]
|
|
495
|
+
second_last = messages[-2]
|
|
496
|
+
if not (second_last.role == "user" and last.role == "assistant"):
|
|
497
|
+
self.app.notify("No complete turn to undo", severity="warning")
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
self._execute_undo()
|
|
501
|
+
|
|
502
|
+
def action_copy_last(self) -> None:
|
|
503
|
+
"""Copy the last assistant response to clipboard (fetches from server)."""
|
|
504
|
+
state = self._store.state
|
|
505
|
+
|
|
506
|
+
if not state.session:
|
|
507
|
+
self.app.notify("No active session", severity="warning")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
# Fetch from server to get post-processed content
|
|
511
|
+
self._execute_copy_last()
|
|
512
|
+
|
|
513
|
+
def action_clear_display(self) -> None:
|
|
514
|
+
"""Clear the current session and show welcome."""
|
|
515
|
+
self._store.clear_session()
|
|
516
|
+
pane = self._active_pane_widget
|
|
517
|
+
if pane:
|
|
518
|
+
message_display = pane.query_one(MessageDisplay)
|
|
519
|
+
message_display.show_welcome()
|
|
520
|
+
|
|
521
|
+
def action_focus_session_search(self) -> None:
|
|
522
|
+
"""Focus the session search input."""
|
|
523
|
+
# Make sure sidebar is visible first
|
|
524
|
+
if not self._left_store.state.sidebar_visible:
|
|
525
|
+
self._left_store.set_sidebar_visible(True)
|
|
526
|
+
sidebar = self.query_one(SessionSidebar)
|
|
527
|
+
sidebar.action_focus_search()
|
|
528
|
+
|
|
529
|
+
def action_toggle_sidebar(self) -> None:
|
|
530
|
+
"""Toggle the session sidebar visibility."""
|
|
531
|
+
self._left_store.toggle_sidebar()
|
|
532
|
+
visible = self._left_store.state.sidebar_visible
|
|
533
|
+
self.app.notify(
|
|
534
|
+
f"Sidebar {'shown' if visible else 'hidden'}",
|
|
535
|
+
severity="information",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def action_focus_input(self) -> None:
|
|
539
|
+
"""Focus the chat input in the active pane."""
|
|
540
|
+
self.focus_input()
|
|
541
|
+
|
|
542
|
+
def action_cancel_or_focus(self) -> None:
|
|
543
|
+
"""Cancel streaming if active, otherwise focus the chat input.
|
|
544
|
+
|
|
545
|
+
This provides a clean escape key experience:
|
|
546
|
+
- During streaming: Cancel the generation
|
|
547
|
+
- Otherwise: Focus the input for typing
|
|
548
|
+
"""
|
|
549
|
+
state = self._store.state
|
|
550
|
+
if state.phase == ChatPhase.STREAMING:
|
|
551
|
+
self._store.request_cancel()
|
|
552
|
+
self.app.notify("Generation cancelled", severity="warning")
|
|
553
|
+
else:
|
|
554
|
+
self.focus_input()
|
|
555
|
+
|
|
556
|
+
def focus_input(self) -> None:
|
|
557
|
+
"""Focus the chat input in the active pane.
|
|
558
|
+
|
|
559
|
+
Public method for external callers (e.g., when switching to chat tab).
|
|
560
|
+
"""
|
|
561
|
+
pane = self._active_pane_widget
|
|
562
|
+
if pane:
|
|
563
|
+
pane.focus_input()
|
|
564
|
+
|
|
565
|
+
def _open_external_editor(self, current_text: str) -> None:
|
|
566
|
+
"""Open text in external editor and update input with result.
|
|
567
|
+
|
|
568
|
+
Uses Textual's suspend() to temporarily yield control to the editor.
|
|
569
|
+
"""
|
|
570
|
+
from cli.tui.chat.widgets.chat_input import ChatTextArea
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
with self.app.suspend():
|
|
574
|
+
edited_text = open_in_editor(current_text)
|
|
575
|
+
|
|
576
|
+
if edited_text is not None:
|
|
577
|
+
# Update the chat input with edited content in active pane
|
|
578
|
+
pane = self._active_pane_widget
|
|
579
|
+
if pane:
|
|
580
|
+
chat_input = pane.query_one(ChatInput)
|
|
581
|
+
text_area = chat_input.query_one("#chat-input", ChatTextArea)
|
|
582
|
+
text_area.text = edited_text
|
|
583
|
+
# Move cursor to end
|
|
584
|
+
text_area.move_cursor(text_area.document.end)
|
|
585
|
+
chat_input.focus_input()
|
|
586
|
+
self.app.notify("Editor content loaded", severity="information")
|
|
587
|
+
except EditorError as e:
|
|
588
|
+
self.app.notify(str(e), severity="error")
|
|
589
|
+
|
|
590
|
+
# =========================================================================
|
|
591
|
+
# Command Execution (async operations)
|
|
592
|
+
# =========================================================================
|
|
593
|
+
|
|
594
|
+
async def _get_client(self) -> ApiClient:
|
|
595
|
+
"""Get the shared server client."""
|
|
596
|
+
return await self.app.store.get_client()
|
|
597
|
+
|
|
598
|
+
async def _get_injection_service(self) -> InjectionService:
|
|
599
|
+
"""Get or create the injection service."""
|
|
600
|
+
if self._injection_service is None:
|
|
601
|
+
client = await self._get_client()
|
|
602
|
+
self._injection_service = InjectionService(client)
|
|
603
|
+
return self._injection_service
|
|
604
|
+
|
|
605
|
+
@work(exclusive=False, group="injection_init")
|
|
606
|
+
async def _initialize_injection_service(self) -> None:
|
|
607
|
+
"""Initialize the injection service and refresh its cache.
|
|
608
|
+
|
|
609
|
+
Called on mount to pre-populate autocomplete data.
|
|
610
|
+
"""
|
|
611
|
+
try:
|
|
612
|
+
service = await self._get_injection_service()
|
|
613
|
+
await service.refresh_cache()
|
|
614
|
+
|
|
615
|
+
# Pass service to chat input widgets in both panes
|
|
616
|
+
self._update_chat_inputs_with_injection_service(service)
|
|
617
|
+
|
|
618
|
+
logger.debug(
|
|
619
|
+
"injection_service_initialized",
|
|
620
|
+
extra={
|
|
621
|
+
"fragments": len(service.fragments),
|
|
622
|
+
"content": len(service.content_parts),
|
|
623
|
+
"schemas": len(service.schemas),
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.debug(
|
|
628
|
+
"injection_service_init_failed",
|
|
629
|
+
extra={"error": str(e)},
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
def _update_chat_inputs_with_injection_service(
|
|
633
|
+
self, service: InjectionService
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Update ChatInput widgets with the injection service."""
|
|
636
|
+
try:
|
|
637
|
+
# Update left pane
|
|
638
|
+
left_pane = self.query_one("#left-pane", ChatPane)
|
|
639
|
+
left_input = left_pane.query_one(ChatInput)
|
|
640
|
+
left_input.set_injection_service(service)
|
|
641
|
+
|
|
642
|
+
# Update right pane (if exists)
|
|
643
|
+
try:
|
|
644
|
+
right_pane = self.query_one("#right-pane", ChatPane)
|
|
645
|
+
right_input = right_pane.query_one(ChatInput)
|
|
646
|
+
right_input.set_injection_service(service)
|
|
647
|
+
except Exception:
|
|
648
|
+
pass # Right pane may not exist yet
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.debug(f"Failed to update chat inputs with injection service: {e}")
|
|
651
|
+
|
|
652
|
+
@work(exclusive=True)
|
|
653
|
+
async def _show_new_session_modal(self) -> None:
|
|
654
|
+
"""Show the new session modal."""
|
|
655
|
+
client = await self._get_client()
|
|
656
|
+
modal = NewSessionModal(client=client)
|
|
657
|
+
self.app.push_screen(modal, self._on_new_session_created)
|
|
658
|
+
|
|
659
|
+
def _on_new_session_created(self, config: NewSessionConfig | None) -> None:
|
|
660
|
+
"""Handle result from new session modal."""
|
|
661
|
+
if config is None:
|
|
662
|
+
return # Cancelled
|
|
663
|
+
|
|
664
|
+
# Validate: must have either agent or model selected
|
|
665
|
+
if not config.is_agent_mode and not config.is_model_mode:
|
|
666
|
+
self.app.notify(
|
|
667
|
+
"Please select an agent or model to start a chat session",
|
|
668
|
+
severity="warning",
|
|
669
|
+
)
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
self._execute_create_session(config)
|
|
673
|
+
|
|
674
|
+
@work(exclusive=True)
|
|
675
|
+
async def _execute_create_session(self, config: NewSessionConfig) -> None:
|
|
676
|
+
"""Execute the create session command."""
|
|
677
|
+
client = await self._get_client()
|
|
678
|
+
|
|
679
|
+
command = CreateSessionCommand(
|
|
680
|
+
client=client,
|
|
681
|
+
store=self._store,
|
|
682
|
+
name=config.name,
|
|
683
|
+
# Agent mode
|
|
684
|
+
agent_id=config.agent_id,
|
|
685
|
+
agent_name=config.agent_name,
|
|
686
|
+
# Model mode
|
|
687
|
+
provider_key=config.provider_key,
|
|
688
|
+
provider_model_name=config.provider_model_name,
|
|
689
|
+
system_instruction=config.system_instruction,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
result = await command.execute()
|
|
693
|
+
|
|
694
|
+
if result.success:
|
|
695
|
+
# Focus input in active pane and refresh sidebar
|
|
696
|
+
pane = self._active_pane_widget
|
|
697
|
+
if pane:
|
|
698
|
+
pane.focus_input()
|
|
699
|
+
|
|
700
|
+
sidebar = self.query_one(SessionSidebar)
|
|
701
|
+
sidebar.refresh_sessions()
|
|
702
|
+
|
|
703
|
+
mode = "agent" if config.is_agent_mode else "model"
|
|
704
|
+
self.app.notify(f"Session created ({mode} mode)!", severity="information")
|
|
705
|
+
else:
|
|
706
|
+
self.app.notify(
|
|
707
|
+
f"Failed to create session: {result.error}", severity="error"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
@work(exclusive=True)
|
|
711
|
+
async def _execute_quick_agent_session(
|
|
712
|
+
self, agent_id: UUID, agent_name: str
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Create a new session with the selected agent (no modal, immediate creation).
|
|
715
|
+
|
|
716
|
+
This is triggered from the welcome screen quick start list.
|
|
717
|
+
"""
|
|
718
|
+
client = await self._get_client()
|
|
719
|
+
|
|
720
|
+
command = CreateSessionCommand(
|
|
721
|
+
client=client,
|
|
722
|
+
store=self._store,
|
|
723
|
+
name=None, # Will be auto-named after first message
|
|
724
|
+
agent_id=agent_id,
|
|
725
|
+
agent_name=agent_name,
|
|
726
|
+
# Model mode params not used
|
|
727
|
+
provider_key=None,
|
|
728
|
+
provider_model_name=None,
|
|
729
|
+
system_instruction=None,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
result = await command.execute()
|
|
733
|
+
|
|
734
|
+
if result.success:
|
|
735
|
+
# Focus input in active pane and refresh sidebar
|
|
736
|
+
pane = self._active_pane_widget
|
|
737
|
+
if pane:
|
|
738
|
+
pane.focus_input()
|
|
739
|
+
|
|
740
|
+
sidebar = self.query_one(SessionSidebar)
|
|
741
|
+
sidebar.refresh_sessions()
|
|
742
|
+
|
|
743
|
+
self.app.notify(f"Chat started with {agent_name}", severity="information")
|
|
744
|
+
else:
|
|
745
|
+
self.app.notify(f"Failed to start chat: {result.error}", severity="error")
|
|
746
|
+
|
|
747
|
+
@work(exclusive=True)
|
|
748
|
+
async def _execute_load_session(self, session_id: UUID) -> None:
|
|
749
|
+
"""Execute the load session command."""
|
|
750
|
+
client = await self._get_client()
|
|
751
|
+
|
|
752
|
+
command = LoadSessionCommand(
|
|
753
|
+
client=client,
|
|
754
|
+
store=self._store,
|
|
755
|
+
session_id=session_id,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
result = await command.execute()
|
|
759
|
+
|
|
760
|
+
if result.success:
|
|
761
|
+
# Focus input in active pane
|
|
762
|
+
pane = self._active_pane_widget
|
|
763
|
+
if pane:
|
|
764
|
+
pane.focus_input()
|
|
765
|
+
else:
|
|
766
|
+
self.app.notify(f"Failed to load session: {result.error}", severity="error")
|
|
767
|
+
|
|
768
|
+
@work(exclusive=True)
|
|
769
|
+
async def _execute_send_message(self, user_message: str) -> None:
|
|
770
|
+
"""Execute the send message command for the active pane."""
|
|
771
|
+
state = self._store.state
|
|
772
|
+
session = state.session
|
|
773
|
+
|
|
774
|
+
if not session:
|
|
775
|
+
self.app.notify("No active session", severity="warning")
|
|
776
|
+
return
|
|
777
|
+
|
|
778
|
+
# Check that we have either agent or model configured
|
|
779
|
+
if not session.is_agent_mode and not session.is_model_mode:
|
|
780
|
+
self.app.notify(
|
|
781
|
+
"No agent or model configured for this session", severity="warning"
|
|
782
|
+
)
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
client = await self._get_client()
|
|
786
|
+
injection_service = await self._get_injection_service()
|
|
787
|
+
|
|
788
|
+
def on_injection_resolved(resolved: ResolvedMessage) -> None:
|
|
789
|
+
"""Callback when injections are resolved."""
|
|
790
|
+
injected_items: list[str] = []
|
|
791
|
+
if resolved.fragments_used:
|
|
792
|
+
injected_items.append(f"{len(resolved.fragments_used)} fragment(s)")
|
|
793
|
+
if resolved.content_used:
|
|
794
|
+
injected_items.append(f"{len(resolved.content_used)} content item(s)")
|
|
795
|
+
if resolved.schemas_used:
|
|
796
|
+
injected_items.append(f"{len(resolved.schemas_used)} schema(s)")
|
|
797
|
+
if injected_items:
|
|
798
|
+
self.app.notify(
|
|
799
|
+
f"Injected: {', '.join(injected_items)}", severity="information"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
command = SendMessageCommand(
|
|
803
|
+
client=client,
|
|
804
|
+
store=self._store,
|
|
805
|
+
session_id=session.session_id,
|
|
806
|
+
user_message=user_message,
|
|
807
|
+
on_chunk=lambda c: self._store.append_streaming_content(c),
|
|
808
|
+
on_thinking_chunk=lambda c: self._store.append_streaming_thinking_content(
|
|
809
|
+
c
|
|
810
|
+
),
|
|
811
|
+
# Agent mode
|
|
812
|
+
agent_id=session.agent_id,
|
|
813
|
+
# Model mode
|
|
814
|
+
provider_key=session.provider_key,
|
|
815
|
+
provider_model_name=session.model_name,
|
|
816
|
+
system_instruction=session.system_instruction,
|
|
817
|
+
# Injection support
|
|
818
|
+
injection_service=injection_service,
|
|
819
|
+
on_injection_resolved=on_injection_resolved,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
result = await command.execute()
|
|
823
|
+
|
|
824
|
+
if result.success:
|
|
825
|
+
# Reload to get proper message objects
|
|
826
|
+
await self._reload_current_session()
|
|
827
|
+
|
|
828
|
+
# Trigger auto-naming for sessions without a name (non-blocking)
|
|
829
|
+
self._maybe_generate_session_name(user_message)
|
|
830
|
+
else:
|
|
831
|
+
self.app.notify(f"Error: {result.error}", severity="error")
|
|
832
|
+
|
|
833
|
+
@work(exclusive=False, group="sync_send")
|
|
834
|
+
async def _execute_send_message_sync(self, user_message: str) -> None:
|
|
835
|
+
"""Execute the send message command for both panes simultaneously."""
|
|
836
|
+
client = await self._get_client()
|
|
837
|
+
|
|
838
|
+
# Send to left pane
|
|
839
|
+
left_state = self._left_store.state
|
|
840
|
+
if left_state.session and (
|
|
841
|
+
left_state.session.is_agent_mode or left_state.session.is_model_mode
|
|
842
|
+
):
|
|
843
|
+
self._send_to_pane(client, self._left_store, user_message)
|
|
844
|
+
|
|
845
|
+
# Send to right pane
|
|
846
|
+
right_state = self._right_store.state
|
|
847
|
+
if right_state.session and (
|
|
848
|
+
right_state.session.is_agent_mode or right_state.session.is_model_mode
|
|
849
|
+
):
|
|
850
|
+
self._send_to_pane(client, self._right_store, user_message)
|
|
851
|
+
|
|
852
|
+
@work(exclusive=False, group="pane_send")
|
|
853
|
+
async def _send_to_pane(
|
|
854
|
+
self, client: ApiClient, store: ChatStore, user_message: str
|
|
855
|
+
) -> None:
|
|
856
|
+
"""Send a message to a specific pane's session."""
|
|
857
|
+
state = store.state
|
|
858
|
+
session = state.session
|
|
859
|
+
|
|
860
|
+
if not session:
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
command = SendMessageCommand(
|
|
864
|
+
client=client,
|
|
865
|
+
store=store,
|
|
866
|
+
session_id=session.session_id,
|
|
867
|
+
user_message=user_message,
|
|
868
|
+
on_chunk=lambda c: store.append_streaming_content(c),
|
|
869
|
+
on_thinking_chunk=lambda c: store.append_streaming_thinking_content(c),
|
|
870
|
+
agent_id=session.agent_id,
|
|
871
|
+
provider_key=session.provider_key,
|
|
872
|
+
provider_model_name=session.model_name,
|
|
873
|
+
system_instruction=session.system_instruction,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
result = await command.execute()
|
|
877
|
+
|
|
878
|
+
if result.success:
|
|
879
|
+
# Reload session
|
|
880
|
+
reload_cmd = ReloadSessionCommand(
|
|
881
|
+
client=client,
|
|
882
|
+
store=store,
|
|
883
|
+
session_id=session.session_id,
|
|
884
|
+
)
|
|
885
|
+
await reload_cmd.execute()
|
|
886
|
+
|
|
887
|
+
@work(exclusive=False, group="name_generation")
|
|
888
|
+
async def _maybe_generate_session_name(self, first_message: str) -> None:
|
|
889
|
+
"""Generate a session name if this is the first message and auto-naming is enabled.
|
|
890
|
+
|
|
891
|
+
This runs in a separate worker group so it doesn't block the main chat flow.
|
|
892
|
+
|
|
893
|
+
Args:
|
|
894
|
+
first_message: The user's first message in the session.
|
|
895
|
+
"""
|
|
896
|
+
logger.info(
|
|
897
|
+
"session_auto_naming_worker_started",
|
|
898
|
+
first_message_preview=first_message[:50] if first_message else None,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
# Re-read state fresh (worker runs async, state may have changed)
|
|
902
|
+
state = self._store.state
|
|
903
|
+
session = state.session
|
|
904
|
+
|
|
905
|
+
if not session:
|
|
906
|
+
logger.info("session_auto_naming_skipped_no_session")
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
logger.info(
|
|
910
|
+
"session_auto_naming_session_found",
|
|
911
|
+
session_id=str(session.session_id),
|
|
912
|
+
session_name=session.session_name,
|
|
913
|
+
provider_key=session.provider_key,
|
|
914
|
+
model_name=session.model_name,
|
|
915
|
+
agent_id=str(session.agent_id) if session.agent_id else None,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
# Skip if session already has a name
|
|
919
|
+
if session.session_name:
|
|
920
|
+
logger.info(
|
|
921
|
+
"session_auto_naming_skipped_has_name",
|
|
922
|
+
session_name=session.session_name,
|
|
923
|
+
)
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
# Check if this is the first exchange (should have exactly 1 user and 1 assistant message)
|
|
927
|
+
# Count by role to handle cases where there might be system messages or other roles
|
|
928
|
+
user_messages = sum(1 for m in state.messages if m.role == "user")
|
|
929
|
+
assistant_messages = sum(1 for m in state.messages if m.role == "assistant")
|
|
930
|
+
logger.info(
|
|
931
|
+
"session_auto_naming_message_count",
|
|
932
|
+
total_messages=len(state.messages),
|
|
933
|
+
user_messages=user_messages,
|
|
934
|
+
assistant_messages=assistant_messages,
|
|
935
|
+
session_id=str(session.session_id),
|
|
936
|
+
)
|
|
937
|
+
if user_messages != 1 or assistant_messages != 1:
|
|
938
|
+
logger.info(
|
|
939
|
+
"session_auto_naming_skipped_not_first_exchange",
|
|
940
|
+
user_messages=user_messages,
|
|
941
|
+
assistant_messages=assistant_messages,
|
|
942
|
+
)
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
# Check if auto-naming is enabled (default: True)
|
|
946
|
+
storage = get_storage()
|
|
947
|
+
auto_naming_enabled = storage.get("preferences", PREF_AUTO_NAME_SESSIONS, True)
|
|
948
|
+
logger.info(
|
|
949
|
+
"session_auto_naming_preference_check",
|
|
950
|
+
auto_naming_enabled=auto_naming_enabled,
|
|
951
|
+
)
|
|
952
|
+
if not auto_naming_enabled:
|
|
953
|
+
logger.info("session_auto_naming_skipped_disabled_in_preferences")
|
|
954
|
+
return
|
|
955
|
+
|
|
956
|
+
logger.info(
|
|
957
|
+
"session_auto_naming_calling_generator",
|
|
958
|
+
session_id=str(session.session_id),
|
|
959
|
+
first_message_length=len(first_message),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
client = await self._get_client()
|
|
964
|
+
generator = SessionNameGenerator()
|
|
965
|
+
generated_name = await generator.generate_name(
|
|
966
|
+
client,
|
|
967
|
+
first_message,
|
|
968
|
+
session_provider_key=session.provider_key,
|
|
969
|
+
session_model_name=session.model_name,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
logger.info(
|
|
973
|
+
"session_auto_naming_generator_returned",
|
|
974
|
+
generated_name=generated_name,
|
|
975
|
+
session_id=str(session.session_id),
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
if generated_name:
|
|
979
|
+
# Update session on server
|
|
980
|
+
logger.info(
|
|
981
|
+
"session_auto_naming_updating_server",
|
|
982
|
+
session_id=str(session.session_id),
|
|
983
|
+
generated_name=generated_name,
|
|
984
|
+
)
|
|
985
|
+
await client.update_session(
|
|
986
|
+
session.session_id,
|
|
987
|
+
UpdateSessionRequest(name=generated_name),
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
# Re-read current state to get latest message count
|
|
991
|
+
current_state = self._store.state
|
|
992
|
+
|
|
993
|
+
# Update local store with new name
|
|
994
|
+
self._store.set_session(
|
|
995
|
+
session_id=session.session_id,
|
|
996
|
+
session_name=generated_name,
|
|
997
|
+
agent_id=session.agent_id,
|
|
998
|
+
agent_name=session.agent_name,
|
|
999
|
+
message_count=len(current_state.messages),
|
|
1000
|
+
provider_key=session.provider_key,
|
|
1001
|
+
model_name=session.model_name,
|
|
1002
|
+
system_instruction=session.system_instruction,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
# Refresh sidebar to show new name
|
|
1006
|
+
sidebar = self.query_one(SessionSidebar)
|
|
1007
|
+
sidebar.refresh_sessions()
|
|
1008
|
+
|
|
1009
|
+
# Notify user that session was named
|
|
1010
|
+
self.app.notify(
|
|
1011
|
+
f"Session named: {generated_name}", severity="information"
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
logger.info(
|
|
1015
|
+
"session_auto_naming_completed",
|
|
1016
|
+
session_id=str(session.session_id),
|
|
1017
|
+
generated_name=generated_name,
|
|
1018
|
+
)
|
|
1019
|
+
else:
|
|
1020
|
+
logger.warning(
|
|
1021
|
+
"session_auto_naming_no_name_generated",
|
|
1022
|
+
session_id=str(session.session_id),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
# Log error but don't show to user - auto-naming is a nice-to-have feature
|
|
1027
|
+
logger.warning(
|
|
1028
|
+
"session_auto_naming_failed",
|
|
1029
|
+
session_id=str(session.session_id),
|
|
1030
|
+
error=str(e),
|
|
1031
|
+
error_type=type(e).__name__,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
@work(exclusive=True)
|
|
1035
|
+
async def _execute_regenerate(self) -> None:
|
|
1036
|
+
"""Execute the regenerate command."""
|
|
1037
|
+
state = self._store.state
|
|
1038
|
+
session = state.session
|
|
1039
|
+
|
|
1040
|
+
if not session:
|
|
1041
|
+
return
|
|
1042
|
+
|
|
1043
|
+
# Check that we have either agent or model configured
|
|
1044
|
+
if not session.is_agent_mode and not session.is_model_mode:
|
|
1045
|
+
return
|
|
1046
|
+
|
|
1047
|
+
client = await self._get_client()
|
|
1048
|
+
|
|
1049
|
+
command = RegenerateCommand(
|
|
1050
|
+
client=client,
|
|
1051
|
+
store=self._store,
|
|
1052
|
+
session_id=session.session_id,
|
|
1053
|
+
on_chunk=lambda c: self._store.append_streaming_content(c),
|
|
1054
|
+
on_thinking_chunk=lambda c: self._store.append_streaming_thinking_content(
|
|
1055
|
+
c
|
|
1056
|
+
),
|
|
1057
|
+
# Agent mode
|
|
1058
|
+
agent_id=session.agent_id,
|
|
1059
|
+
# Model mode
|
|
1060
|
+
provider_key=session.provider_key,
|
|
1061
|
+
provider_model_name=session.model_name,
|
|
1062
|
+
system_instruction=session.system_instruction,
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
result = await command.execute()
|
|
1066
|
+
|
|
1067
|
+
if result.success:
|
|
1068
|
+
await self._reload_current_session()
|
|
1069
|
+
self.app.notify("Response regenerated", severity="information")
|
|
1070
|
+
else:
|
|
1071
|
+
self.app.notify(f"Regenerate failed: {result.error}", severity="error")
|
|
1072
|
+
|
|
1073
|
+
@work(exclusive=True)
|
|
1074
|
+
async def _execute_undo(self) -> None:
|
|
1075
|
+
"""Execute the undo command."""
|
|
1076
|
+
state = self._store.state
|
|
1077
|
+
messages = state.messages
|
|
1078
|
+
|
|
1079
|
+
if len(messages) < 2:
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
client = await self._get_client()
|
|
1083
|
+
|
|
1084
|
+
command = UndoCommand(
|
|
1085
|
+
client=client,
|
|
1086
|
+
user_message=messages[-2],
|
|
1087
|
+
assistant_message=messages[-1],
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
result = await command.execute()
|
|
1091
|
+
|
|
1092
|
+
if result.success and result.data:
|
|
1093
|
+
await self._reload_current_session()
|
|
1094
|
+
self.app.notify(
|
|
1095
|
+
f"Undone: {result.data.user_preview}", severity="information"
|
|
1096
|
+
)
|
|
1097
|
+
else:
|
|
1098
|
+
self.app.notify(f"Undo failed: {result.error}", severity="error")
|
|
1099
|
+
|
|
1100
|
+
async def _reload_current_session(self) -> None:
|
|
1101
|
+
"""Reload the current session messages."""
|
|
1102
|
+
state = self._store.state
|
|
1103
|
+
if not state.session:
|
|
1104
|
+
return
|
|
1105
|
+
|
|
1106
|
+
client = await self._get_client()
|
|
1107
|
+
|
|
1108
|
+
command = ReloadSessionCommand(
|
|
1109
|
+
client=client,
|
|
1110
|
+
store=self._store,
|
|
1111
|
+
session_id=state.session.session_id,
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
# Silently execute - we already showed the response
|
|
1115
|
+
await command.execute()
|
|
1116
|
+
|
|
1117
|
+
@work(exclusive=True)
|
|
1118
|
+
async def _execute_copy_last(self) -> None:
|
|
1119
|
+
"""Fetch the last assistant message from server and copy to clipboard.
|
|
1120
|
+
|
|
1121
|
+
Fetches from server to ensure we get post-processed content
|
|
1122
|
+
(e.g., after output schema regex processing).
|
|
1123
|
+
"""
|
|
1124
|
+
state = self._store.state
|
|
1125
|
+
if not state.session:
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
client = await self._get_client()
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
# Fetch last assistant message from server with role filter
|
|
1132
|
+
response = await client.list_session_messages(
|
|
1133
|
+
session_id=state.session.session_id,
|
|
1134
|
+
role="assistant",
|
|
1135
|
+
limit=1,
|
|
1136
|
+
order="desc",
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
if not response.messages:
|
|
1140
|
+
self.app.notify("No assistant message to copy", severity="warning")
|
|
1141
|
+
return
|
|
1142
|
+
|
|
1143
|
+
# Extract text from content parts (excludes thinking content)
|
|
1144
|
+
message = response.messages[0]
|
|
1145
|
+
text = extract_message_text(message)
|
|
1146
|
+
|
|
1147
|
+
if not text:
|
|
1148
|
+
self.app.notify("Assistant message is empty", severity="warning")
|
|
1149
|
+
return
|
|
1150
|
+
|
|
1151
|
+
# Copy to clipboard
|
|
1152
|
+
copy_to_clipboard(self.app, text, "Last response")
|
|
1153
|
+
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
self.app.notify(f"Failed to copy: {e}", severity="error")
|