docagent-cli 0.0.35__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.
- docagent_cli/__init__.py +36 -0
- docagent_cli/__main__.py +6 -0
- docagent_cli/_ask_user_types.py +90 -0
- docagent_cli/_cli_context.py +27 -0
- docagent_cli/_debug.py +52 -0
- docagent_cli/_env_vars.py +56 -0
- docagent_cli/_server_config.py +352 -0
- docagent_cli/_session_stats.py +114 -0
- docagent_cli/_testing_models.py +144 -0
- docagent_cli/_version.py +17 -0
- docagent_cli/agent.py +1193 -0
- docagent_cli/app.py +4979 -0
- docagent_cli/app.tcss +283 -0
- docagent_cli/ask_user.py +301 -0
- docagent_cli/built_in_skills/__init__.py +5 -0
- docagent_cli/built_in_skills/doc-coauthoring/SKILL.md +375 -0
- docagent_cli/built_in_skills/docx/LICENSE.txt +30 -0
- docagent_cli/built_in_skills/docx/SKILL.md +590 -0
- docagent_cli/built_in_skills/docx/scripts/__init__.py +1 -0
- docagent_cli/built_in_skills/docx/scripts/accept_changes.py +135 -0
- docagent_cli/built_in_skills/docx/scripts/comment.py +318 -0
- docagent_cli/built_in_skills/docx/scripts/office/helpers/__init__.py +0 -0
- docagent_cli/built_in_skills/docx/scripts/office/helpers/merge_runs.py +199 -0
- docagent_cli/built_in_skills/docx/scripts/office/helpers/simplify_redlines.py +197 -0
- docagent_cli/built_in_skills/docx/scripts/office/pack.py +159 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/mce/mc.xsd +75 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- docagent_cli/built_in_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- docagent_cli/built_in_skills/docx/scripts/office/soffice.py +183 -0
- docagent_cli/built_in_skills/docx/scripts/office/unpack.py +132 -0
- docagent_cli/built_in_skills/docx/scripts/office/validate.py +111 -0
- docagent_cli/built_in_skills/docx/scripts/office/validators/__init__.py +15 -0
- docagent_cli/built_in_skills/docx/scripts/office/validators/base.py +847 -0
- docagent_cli/built_in_skills/docx/scripts/office/validators/docx.py +446 -0
- docagent_cli/built_in_skills/docx/scripts/office/validators/pptx.py +275 -0
- docagent_cli/built_in_skills/docx/scripts/office/validators/redlining.py +247 -0
- docagent_cli/built_in_skills/docx/scripts/templates/comments.xml +3 -0
- docagent_cli/built_in_skills/docx/scripts/templates/commentsExtended.xml +3 -0
- docagent_cli/built_in_skills/docx/scripts/templates/commentsExtensible.xml +3 -0
- docagent_cli/built_in_skills/docx/scripts/templates/commentsIds.xml +3 -0
- docagent_cli/built_in_skills/docx/scripts/templates/people.xml +3 -0
- docagent_cli/built_in_skills/pdf/LICENSE.txt +30 -0
- docagent_cli/built_in_skills/pdf/SKILL.md +314 -0
- docagent_cli/built_in_skills/pdf/forms.md +294 -0
- docagent_cli/built_in_skills/pdf/reference.md +612 -0
- docagent_cli/built_in_skills/pdf/scripts/check_bounding_boxes.py +65 -0
- docagent_cli/built_in_skills/pdf/scripts/check_fillable_fields.py +11 -0
- docagent_cli/built_in_skills/pdf/scripts/convert_pdf_to_images.py +33 -0
- docagent_cli/built_in_skills/pdf/scripts/create_validation_image.py +37 -0
- docagent_cli/built_in_skills/pdf/scripts/extract_form_field_info.py +122 -0
- docagent_cli/built_in_skills/pdf/scripts/extract_form_structure.py +115 -0
- docagent_cli/built_in_skills/pdf/scripts/fill_fillable_fields.py +98 -0
- docagent_cli/built_in_skills/pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
- docagent_cli/built_in_skills/pptx/LICENSE.txt +30 -0
- docagent_cli/built_in_skills/pptx/SKILL.md +232 -0
- docagent_cli/built_in_skills/pptx/editing.md +205 -0
- docagent_cli/built_in_skills/pptx/pptxgenjs.md +420 -0
- docagent_cli/built_in_skills/pptx/scripts/__init__.py +0 -0
- docagent_cli/built_in_skills/pptx/scripts/add_slide.py +195 -0
- docagent_cli/built_in_skills/pptx/scripts/clean.py +286 -0
- docagent_cli/built_in_skills/pptx/scripts/office/helpers/__init__.py +0 -0
- docagent_cli/built_in_skills/pptx/scripts/office/helpers/merge_runs.py +199 -0
- docagent_cli/built_in_skills/pptx/scripts/office/helpers/simplify_redlines.py +197 -0
- docagent_cli/built_in_skills/pptx/scripts/office/pack.py +159 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/mce/mc.xsd +75 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- docagent_cli/built_in_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- docagent_cli/built_in_skills/pptx/scripts/office/soffice.py +183 -0
- docagent_cli/built_in_skills/pptx/scripts/office/unpack.py +132 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validate.py +111 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validators/__init__.py +15 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validators/base.py +847 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validators/docx.py +446 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validators/pptx.py +275 -0
- docagent_cli/built_in_skills/pptx/scripts/office/validators/redlining.py +247 -0
- docagent_cli/built_in_skills/pptx/scripts/thumbnail.py +289 -0
- docagent_cli/built_in_skills/remember/SKILL.md +118 -0
- docagent_cli/built_in_skills/skill-creator/LICENSE.txt +202 -0
- docagent_cli/built_in_skills/skill-creator/SKILL.md +485 -0
- docagent_cli/built_in_skills/skill-creator/agents/analyzer.md +274 -0
- docagent_cli/built_in_skills/skill-creator/agents/comparator.md +202 -0
- docagent_cli/built_in_skills/skill-creator/agents/grader.md +223 -0
- docagent_cli/built_in_skills/skill-creator/assets/eval_review.html +146 -0
- docagent_cli/built_in_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- docagent_cli/built_in_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- docagent_cli/built_in_skills/skill-creator/references/schemas.md +430 -0
- docagent_cli/built_in_skills/skill-creator/scripts/__init__.py +0 -0
- docagent_cli/built_in_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- docagent_cli/built_in_skills/skill-creator/scripts/generate_report.py +326 -0
- docagent_cli/built_in_skills/skill-creator/scripts/improve_description.py +247 -0
- docagent_cli/built_in_skills/skill-creator/scripts/package_skill.py +136 -0
- docagent_cli/built_in_skills/skill-creator/scripts/quick_validate.py +103 -0
- docagent_cli/built_in_skills/skill-creator/scripts/run_eval.py +310 -0
- docagent_cli/built_in_skills/skill-creator/scripts/run_loop.py +328 -0
- docagent_cli/built_in_skills/skill-creator/scripts/utils.py +47 -0
- docagent_cli/built_in_skills/theme-factory/LICENSE.txt +202 -0
- docagent_cli/built_in_skills/theme-factory/SKILL.md +59 -0
- docagent_cli/built_in_skills/theme-factory/theme-showcase.pdf +0 -0
- docagent_cli/built_in_skills/theme-factory/themes/arctic-frost.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/botanical-garden.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/desert-rose.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/forest-canopy.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/golden-hour.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/midnight-galaxy.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/modern-minimalist.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/ocean-depths.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/sunset-boulevard.md +19 -0
- docagent_cli/built_in_skills/theme-factory/themes/tech-innovation.md +19 -0
- docagent_cli/built_in_skills/xlsx/LICENSE.txt +30 -0
- docagent_cli/built_in_skills/xlsx/SKILL.md +292 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/helpers/__init__.py +0 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/helpers/merge_runs.py +199 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/pack.py +159 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/soffice.py +183 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/unpack.py +132 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validate.py +111 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validators/__init__.py +15 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validators/base.py +847 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validators/docx.py +446 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validators/pptx.py +275 -0
- docagent_cli/built_in_skills/xlsx/scripts/office/validators/redlining.py +247 -0
- docagent_cli/built_in_skills/xlsx/scripts/recalc.py +184 -0
- docagent_cli/clipboard.py +128 -0
- docagent_cli/command_registry.py +284 -0
- docagent_cli/config.py +2418 -0
- docagent_cli/configurable_model.py +162 -0
- docagent_cli/default_agent_prompt.md +12 -0
- docagent_cli/editor.py +142 -0
- docagent_cli/file_ops.py +473 -0
- docagent_cli/formatting.py +28 -0
- docagent_cli/hooks.py +206 -0
- docagent_cli/input.py +787 -0
- docagent_cli/integrations/__init__.py +1 -0
- docagent_cli/integrations/sandbox_factory.py +873 -0
- docagent_cli/integrations/sandbox_provider.py +71 -0
- docagent_cli/local_context.py +718 -0
- docagent_cli/main.py +1641 -0
- docagent_cli/mcp_tools.py +707 -0
- docagent_cli/mcp_trust.py +168 -0
- docagent_cli/media_utils.py +478 -0
- docagent_cli/model_config.py +1620 -0
- docagent_cli/non_interactive.py +948 -0
- docagent_cli/offload.py +371 -0
- docagent_cli/output.py +69 -0
- docagent_cli/project_utils.py +188 -0
- docagent_cli/py.typed +0 -0
- docagent_cli/remote_client.py +515 -0
- docagent_cli/server.py +520 -0
- docagent_cli/server_graph.py +196 -0
- docagent_cli/server_manager.py +365 -0
- docagent_cli/sessions.py +1262 -0
- docagent_cli/skills/__init__.py +18 -0
- docagent_cli/skills/commands.py +1090 -0
- docagent_cli/skills/load.py +192 -0
- docagent_cli/subagents.py +173 -0
- docagent_cli/system_prompt.md +247 -0
- docagent_cli/textual_adapter.py +1352 -0
- docagent_cli/theme.py +842 -0
- docagent_cli/token_state.py +31 -0
- docagent_cli/tool_display.py +298 -0
- docagent_cli/tools.py +236 -0
- docagent_cli/ui.py +420 -0
- docagent_cli/unicode_security.py +516 -0
- docagent_cli/update_check.py +454 -0
- docagent_cli/widgets/__init__.py +9 -0
- docagent_cli/widgets/_links.py +63 -0
- docagent_cli/widgets/approval.py +442 -0
- docagent_cli/widgets/ask_user.py +398 -0
- docagent_cli/widgets/autocomplete.py +691 -0
- docagent_cli/widgets/chat_input.py +1827 -0
- docagent_cli/widgets/diff.py +248 -0
- docagent_cli/widgets/history.py +188 -0
- docagent_cli/widgets/loading.py +177 -0
- docagent_cli/widgets/mcp_viewer.py +362 -0
- docagent_cli/widgets/message_store.py +675 -0
- docagent_cli/widgets/messages.py +1751 -0
- docagent_cli/widgets/model_selector.py +964 -0
- docagent_cli/widgets/status.py +372 -0
- docagent_cli/widgets/theme_selector.py +164 -0
- docagent_cli/widgets/thread_selector.py +1905 -0
- docagent_cli/widgets/tool_renderers.py +148 -0
- docagent_cli/widgets/tool_widgets.py +274 -0
- docagent_cli/widgets/welcome.py +339 -0
- docagent_cli-0.0.35.data/data/docagent_cli/default_agent_prompt.md +12 -0
- docagent_cli-0.0.35.dist-info/METADATA +200 -0
- docagent_cli-0.0.35.dist-info/RECORD +300 -0
- docagent_cli-0.0.35.dist-info/WHEEL +4 -0
- docagent_cli-0.0.35.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1827 @@
|
|
|
1
|
+
"""Chat input widget for docagent-cli with autocomplete and history support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
14
|
+
from textual.content import Content
|
|
15
|
+
from textual.css.query import NoMatches
|
|
16
|
+
from textual.message import Message
|
|
17
|
+
from textual.reactive import reactive
|
|
18
|
+
from textual.widgets import Static, TextArea
|
|
19
|
+
|
|
20
|
+
from docagent_cli import theme
|
|
21
|
+
from docagent_cli.command_registry import SLASH_COMMANDS
|
|
22
|
+
from docagent_cli.config import (
|
|
23
|
+
MODE_DISPLAY_GLYPHS,
|
|
24
|
+
MODE_PREFIXES,
|
|
25
|
+
PREFIX_TO_MODE,
|
|
26
|
+
get_glyphs,
|
|
27
|
+
is_ascii_mode,
|
|
28
|
+
)
|
|
29
|
+
from docagent_cli.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN
|
|
30
|
+
from docagent_cli.widgets.autocomplete import (
|
|
31
|
+
CompletionResult,
|
|
32
|
+
FuzzyFileController,
|
|
33
|
+
MultiCompletionManager,
|
|
34
|
+
SlashCommandController,
|
|
35
|
+
)
|
|
36
|
+
from docagent_cli.widgets.history import HistoryManager
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_history_path() -> Path:
|
|
42
|
+
"""Return the default history file path.
|
|
43
|
+
|
|
44
|
+
Extracted as a function so tests can monkeypatch it to a temp path,
|
|
45
|
+
preventing test runs from polluting `~/.docagent/history.jsonl`.
|
|
46
|
+
"""
|
|
47
|
+
return Path.home() / ".docagent" / "history.jsonl"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_PASTE_BURST_CHAR_GAP_SECONDS = 0.03
|
|
51
|
+
"""Maximum time between chars to treat input as a paste-like burst."""
|
|
52
|
+
|
|
53
|
+
_PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08
|
|
54
|
+
"""Idle timeout before flushing buffered burst text."""
|
|
55
|
+
|
|
56
|
+
_PASTE_BURST_START_CHARS = {"'", '"'}
|
|
57
|
+
"""Characters that can start dropped-path payloads."""
|
|
58
|
+
|
|
59
|
+
_BACKSLASH_ENTER_GAP_SECONDS = 0.15
|
|
60
|
+
"""Maximum gap between a `\\` key and a following `enter` key to treat the
|
|
61
|
+
pair as a terminal-emitted shift+enter sequence.
|
|
62
|
+
|
|
63
|
+
Some terminals (e.g. VSCode's built-in terminal) send a literal backslash
|
|
64
|
+
followed by enter when the user presses shift+enter. The gap is
|
|
65
|
+
generous (150 ms) because the terminal emits both characters nearly
|
|
66
|
+
simultaneously; a human deliberately typing `\\` then pressing Enter would
|
|
67
|
+
have a much larger gap."""
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from textual import events
|
|
71
|
+
from textual.app import ComposeResult
|
|
72
|
+
from textual.events import Click
|
|
73
|
+
from textual.timer import Timer
|
|
74
|
+
|
|
75
|
+
from docagent_cli.input import MediaTracker, ParsedPastedPathPayload
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CompletionOption(Static):
|
|
79
|
+
"""A clickable completion option in the autocomplete popup."""
|
|
80
|
+
|
|
81
|
+
DEFAULT_CSS = """
|
|
82
|
+
CompletionOption {
|
|
83
|
+
height: 1;
|
|
84
|
+
padding: 0 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
CompletionOption:hover {
|
|
88
|
+
background: $surface-lighten-1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
CompletionOption.completion-option-selected {
|
|
92
|
+
background: $primary;
|
|
93
|
+
color: $background;
|
|
94
|
+
text-style: bold;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
CompletionOption.completion-option-selected:hover {
|
|
98
|
+
background: $primary-lighten-1;
|
|
99
|
+
}
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
class Clicked(Message):
|
|
103
|
+
"""Message sent when a completion option is clicked."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, index: int) -> None:
|
|
106
|
+
"""Initialize with the clicked option index."""
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.index = index
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
label: str,
|
|
113
|
+
description: str,
|
|
114
|
+
index: int,
|
|
115
|
+
is_selected: bool = False,
|
|
116
|
+
**kwargs: Any,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize the completion option.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
label: The main label text (e.g., command name or file path)
|
|
122
|
+
description: Secondary description text
|
|
123
|
+
index: Index of this option in the suggestions list
|
|
124
|
+
is_selected: Whether this option is currently selected
|
|
125
|
+
**kwargs: Additional arguments for parent
|
|
126
|
+
"""
|
|
127
|
+
super().__init__(**kwargs)
|
|
128
|
+
self._label = label
|
|
129
|
+
self._description = description
|
|
130
|
+
self._index = index
|
|
131
|
+
self._is_selected = is_selected
|
|
132
|
+
|
|
133
|
+
def on_mount(self) -> None:
|
|
134
|
+
"""Set up the option display on mount."""
|
|
135
|
+
self._update_display()
|
|
136
|
+
|
|
137
|
+
def _update_display(self) -> None:
|
|
138
|
+
"""Update the display text and styling."""
|
|
139
|
+
glyphs = get_glyphs()
|
|
140
|
+
cursor = f"{glyphs.cursor} " if self._is_selected else " "
|
|
141
|
+
|
|
142
|
+
if self._description:
|
|
143
|
+
content = Content.from_markup(
|
|
144
|
+
f"{cursor}[bold]$label[/bold] [dim]$desc[/dim]",
|
|
145
|
+
label=self._label,
|
|
146
|
+
desc=self._description,
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
content = Content.from_markup(
|
|
150
|
+
f"{cursor}[bold]$label[/bold]", label=self._label
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.update(content)
|
|
154
|
+
|
|
155
|
+
if self._is_selected:
|
|
156
|
+
self.add_class("completion-option-selected")
|
|
157
|
+
else:
|
|
158
|
+
self.remove_class("completion-option-selected")
|
|
159
|
+
|
|
160
|
+
def set_selected(self, *, selected: bool) -> None:
|
|
161
|
+
"""Update the selected state of this option."""
|
|
162
|
+
if self._is_selected != selected:
|
|
163
|
+
self._is_selected = selected
|
|
164
|
+
self._update_display()
|
|
165
|
+
|
|
166
|
+
def set_content(
|
|
167
|
+
self, label: str, description: str, index: int, *, is_selected: bool
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Replace label, description, index, and selection in-place."""
|
|
170
|
+
self._label = label
|
|
171
|
+
self._description = description
|
|
172
|
+
self._index = index
|
|
173
|
+
self._is_selected = is_selected
|
|
174
|
+
self._update_display()
|
|
175
|
+
|
|
176
|
+
def on_click(self, event: Click) -> None:
|
|
177
|
+
"""Handle click on this option."""
|
|
178
|
+
event.stop()
|
|
179
|
+
self.post_message(self.Clicked(self._index))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class CompletionPopup(VerticalScroll):
|
|
183
|
+
"""Popup widget that displays completion suggestions as clickable options."""
|
|
184
|
+
|
|
185
|
+
DEFAULT_CSS = """
|
|
186
|
+
CompletionPopup {
|
|
187
|
+
display: none;
|
|
188
|
+
height: auto;
|
|
189
|
+
max-height: 12;
|
|
190
|
+
}
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
class OptionClicked(Message):
|
|
194
|
+
"""Message sent when a completion option is clicked."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, index: int) -> None:
|
|
197
|
+
"""Initialize with the clicked option index."""
|
|
198
|
+
super().__init__()
|
|
199
|
+
self.index = index
|
|
200
|
+
|
|
201
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
202
|
+
"""Initialize the completion popup."""
|
|
203
|
+
super().__init__(**kwargs)
|
|
204
|
+
self.can_focus = False
|
|
205
|
+
self._options: list[CompletionOption] = []
|
|
206
|
+
self._selected_index = 0
|
|
207
|
+
self._pending_suggestions: list[tuple[str, str]] = []
|
|
208
|
+
self._pending_selected: int = 0
|
|
209
|
+
self._rebuild_generation: int = 0
|
|
210
|
+
|
|
211
|
+
def update_suggestions(
|
|
212
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Update the popup with new suggestions."""
|
|
215
|
+
if not suggestions:
|
|
216
|
+
self.hide()
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
self._selected_index = selected_index
|
|
220
|
+
self._pending_suggestions = suggestions
|
|
221
|
+
self._pending_selected = selected_index
|
|
222
|
+
# Increment generation so stale callbacks from prior calls are skipped.
|
|
223
|
+
self._rebuild_generation += 1
|
|
224
|
+
gen = self._rebuild_generation
|
|
225
|
+
# show() deferred to _rebuild_options to avoid a flash of stale content.
|
|
226
|
+
self.call_after_refresh(lambda: self._rebuild_options(gen))
|
|
227
|
+
|
|
228
|
+
async def _rebuild_options(self, generation: int) -> None:
|
|
229
|
+
"""Rebuild option widgets from pending suggestions.
|
|
230
|
+
|
|
231
|
+
Reuses existing DOM nodes where possible to avoid flicker from
|
|
232
|
+
a full teardown/mount cycle while the popup is visible.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
generation: Caller's generation counter; skipped if superseded.
|
|
236
|
+
"""
|
|
237
|
+
if generation != self._rebuild_generation:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
suggestions = self._pending_suggestions
|
|
241
|
+
selected_index = self._pending_selected
|
|
242
|
+
|
|
243
|
+
if not suggestions:
|
|
244
|
+
self.hide()
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
existing = len(self._options)
|
|
248
|
+
needed = len(suggestions)
|
|
249
|
+
|
|
250
|
+
# Update existing widgets in-place
|
|
251
|
+
for i in range(min(existing, needed)):
|
|
252
|
+
label, desc = suggestions[i]
|
|
253
|
+
self._options[i].set_content(
|
|
254
|
+
label, desc, i, is_selected=(i == selected_index)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# DOM mutations: trim extras / mount new widgets
|
|
258
|
+
try:
|
|
259
|
+
if existing > needed:
|
|
260
|
+
for option in self._options[needed:]:
|
|
261
|
+
await option.remove()
|
|
262
|
+
del self._options[needed:]
|
|
263
|
+
|
|
264
|
+
if needed > existing:
|
|
265
|
+
new_widgets: list[CompletionOption] = []
|
|
266
|
+
for idx in range(existing, needed):
|
|
267
|
+
label, desc = suggestions[idx]
|
|
268
|
+
option = CompletionOption(
|
|
269
|
+
label=label,
|
|
270
|
+
description=desc,
|
|
271
|
+
index=idx,
|
|
272
|
+
is_selected=(idx == selected_index),
|
|
273
|
+
)
|
|
274
|
+
new_widgets.append(option)
|
|
275
|
+
self._options.extend(new_widgets)
|
|
276
|
+
await self.mount(*new_widgets)
|
|
277
|
+
except Exception:
|
|
278
|
+
logger.exception("Failed to rebuild completion popup; hiding to recover")
|
|
279
|
+
self._options = []
|
|
280
|
+
with contextlib.suppress(Exception):
|
|
281
|
+
await self.remove_children()
|
|
282
|
+
self.hide()
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
self.show()
|
|
286
|
+
|
|
287
|
+
if 0 <= selected_index < len(self._options):
|
|
288
|
+
self._options[selected_index].scroll_visible()
|
|
289
|
+
|
|
290
|
+
def update_selection(self, selected_index: int) -> None:
|
|
291
|
+
"""Update which option is selected without rebuilding the list."""
|
|
292
|
+
# Keep pending state in sync so an in-flight _rebuild_options uses
|
|
293
|
+
# the latest selection.
|
|
294
|
+
self._pending_selected = selected_index
|
|
295
|
+
|
|
296
|
+
if self._selected_index == selected_index:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# Deselect previous
|
|
300
|
+
if 0 <= self._selected_index < len(self._options):
|
|
301
|
+
self._options[self._selected_index].set_selected(selected=False)
|
|
302
|
+
|
|
303
|
+
# Select new
|
|
304
|
+
self._selected_index = selected_index
|
|
305
|
+
if 0 <= selected_index < len(self._options):
|
|
306
|
+
self._options[selected_index].set_selected(selected=True)
|
|
307
|
+
self._options[selected_index].scroll_visible()
|
|
308
|
+
|
|
309
|
+
def on_completion_option_clicked(self, event: CompletionOption.Clicked) -> None:
|
|
310
|
+
"""Handle click on a completion option."""
|
|
311
|
+
event.stop()
|
|
312
|
+
self.post_message(self.OptionClicked(event.index))
|
|
313
|
+
|
|
314
|
+
def hide(self) -> None:
|
|
315
|
+
"""Hide the popup."""
|
|
316
|
+
self._pending_suggestions = []
|
|
317
|
+
self._rebuild_generation += 1 # Cancel any in-flight rebuild
|
|
318
|
+
self.styles.display = "none" # type: ignore[assignment] # Textual accepts string display values at runtime
|
|
319
|
+
|
|
320
|
+
def show(self) -> None:
|
|
321
|
+
"""Show the popup."""
|
|
322
|
+
self.styles.display = "block"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ChatTextArea(TextArea):
|
|
326
|
+
"""TextArea subclass with custom key handling for chat input."""
|
|
327
|
+
|
|
328
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
329
|
+
Binding(
|
|
330
|
+
"shift+enter,alt+enter,ctrl+enter",
|
|
331
|
+
"insert_newline",
|
|
332
|
+
"New Line",
|
|
333
|
+
show=False,
|
|
334
|
+
priority=True,
|
|
335
|
+
),
|
|
336
|
+
]
|
|
337
|
+
"""Key bindings for the chat text area.
|
|
338
|
+
|
|
339
|
+
These are the single source of truth for shortcut keys. `_NEWLINE_KEYS`
|
|
340
|
+
is derived from this list so that `_on_key` stays in sync automatically.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
_NEWLINE_KEYS: ClassVar[frozenset[str]] = frozenset(
|
|
344
|
+
key
|
|
345
|
+
for b in BINDINGS
|
|
346
|
+
if b.action == "insert_newline"
|
|
347
|
+
for key in b.key.split(",")
|
|
348
|
+
)
|
|
349
|
+
"""Flattened set of keys that insert a newline, derived from `BINDINGS`."""
|
|
350
|
+
|
|
351
|
+
_skip_history_change_events: int
|
|
352
|
+
"""Counter incremented before a history-driven text replacement so the
|
|
353
|
+
resulting `TextArea.Changed` event (which fires on the next message-loop
|
|
354
|
+
iteration) can be suppressed. `ChatInput.on_text_area_changed` decrements
|
|
355
|
+
the counter.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
_in_history: bool
|
|
359
|
+
"""Persistent flag that stays `True` while the user is browsing history.
|
|
360
|
+
|
|
361
|
+
Relaxes cursor-boundary checks so Up/Down work from either end of
|
|
362
|
+
the text.
|
|
363
|
+
|
|
364
|
+
Reset to `False` when navigating past the newest entry, submitting,
|
|
365
|
+
or clearing.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
class Submitted(Message):
|
|
369
|
+
"""Message sent when text is submitted."""
|
|
370
|
+
|
|
371
|
+
def __init__(self, value: str) -> None:
|
|
372
|
+
"""Initialize with submitted value."""
|
|
373
|
+
self.value = value
|
|
374
|
+
super().__init__()
|
|
375
|
+
|
|
376
|
+
class HistoryPrevious(Message):
|
|
377
|
+
"""Request previous history entry."""
|
|
378
|
+
|
|
379
|
+
def __init__(self, current_text: str) -> None:
|
|
380
|
+
"""Initialize with current text for saving."""
|
|
381
|
+
self.current_text = current_text
|
|
382
|
+
super().__init__()
|
|
383
|
+
|
|
384
|
+
class HistoryNext(Message):
|
|
385
|
+
"""Request next history entry."""
|
|
386
|
+
|
|
387
|
+
class PastedPaths(Message):
|
|
388
|
+
"""Message sent when paste payload resolves to file paths."""
|
|
389
|
+
|
|
390
|
+
def __init__(self, raw_text: str, paths: list[Path]) -> None:
|
|
391
|
+
"""Initialize with raw pasted text and parsed file paths."""
|
|
392
|
+
self.raw_text = raw_text
|
|
393
|
+
self.paths = paths
|
|
394
|
+
super().__init__()
|
|
395
|
+
|
|
396
|
+
class Typing(Message):
|
|
397
|
+
"""Posted when the user presses a printable key or backspace.
|
|
398
|
+
|
|
399
|
+
Relayed by `ChatInput` as `ChatInput.Typing` for the app to track
|
|
400
|
+
typing activity.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
404
|
+
"""Initialize the chat text area."""
|
|
405
|
+
# Remove placeholder if passed, TextArea doesn't support it the same way
|
|
406
|
+
kwargs.pop("placeholder", None)
|
|
407
|
+
super().__init__(**kwargs)
|
|
408
|
+
self._skip_history_change_events = 0
|
|
409
|
+
self._in_history = False
|
|
410
|
+
self._completion_active = False
|
|
411
|
+
# Buffer quote-prefixed high-frequency key bursts from terminals that
|
|
412
|
+
# emulate paste via rapid key events instead of dispatching a paste
|
|
413
|
+
# event.
|
|
414
|
+
self._paste_burst_buffer = ""
|
|
415
|
+
self._paste_burst_last_char_time: float | None = None
|
|
416
|
+
self._paste_burst_timer: Timer | None = None
|
|
417
|
+
# See _BACKSLASH_ENTER_GAP_SECONDS for context.
|
|
418
|
+
self._backslash_pending_time: float | None = None
|
|
419
|
+
|
|
420
|
+
def set_app_focus(self, *, has_focus: bool) -> None:
|
|
421
|
+
"""Set whether the app should show the cursor as active.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
has_focus: Whether the app input should be focused.
|
|
425
|
+
"""
|
|
426
|
+
self._backslash_pending_time = None
|
|
427
|
+
if has_focus and not self.has_focus:
|
|
428
|
+
self.call_after_refresh(self.focus)
|
|
429
|
+
|
|
430
|
+
def set_completion_active(self, *, active: bool) -> None:
|
|
431
|
+
"""Set whether completion suggestions are visible."""
|
|
432
|
+
self._completion_active = active
|
|
433
|
+
|
|
434
|
+
def action_insert_newline(self) -> None:
|
|
435
|
+
"""Insert a newline character."""
|
|
436
|
+
self.insert("\n")
|
|
437
|
+
|
|
438
|
+
def _cancel_paste_burst_timer(self) -> None:
|
|
439
|
+
"""Cancel any scheduled paste-burst flush timer."""
|
|
440
|
+
if self._paste_burst_timer is None:
|
|
441
|
+
return
|
|
442
|
+
self._paste_burst_timer.stop()
|
|
443
|
+
self._paste_burst_timer = None
|
|
444
|
+
|
|
445
|
+
def _schedule_paste_burst_flush(self) -> None:
|
|
446
|
+
"""Schedule idle-time flush for buffered paste-burst text."""
|
|
447
|
+
self._cancel_paste_burst_timer()
|
|
448
|
+
self._paste_burst_timer = self.set_timer(
|
|
449
|
+
_PASTE_BURST_FLUSH_DELAY_SECONDS, self._flush_paste_burst
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
def _start_paste_burst(self, char: str, now: float) -> None:
|
|
453
|
+
"""Start buffering a paste-like keystroke burst."""
|
|
454
|
+
self._paste_burst_buffer = char
|
|
455
|
+
self._paste_burst_last_char_time = now
|
|
456
|
+
self._schedule_paste_burst_flush()
|
|
457
|
+
|
|
458
|
+
def _append_paste_burst(self, text: str, now: float) -> None:
|
|
459
|
+
"""Append text to an active paste-burst buffer."""
|
|
460
|
+
if not self._paste_burst_buffer:
|
|
461
|
+
self._start_paste_burst(text, now)
|
|
462
|
+
return
|
|
463
|
+
self._paste_burst_buffer += text
|
|
464
|
+
self._paste_burst_last_char_time = now
|
|
465
|
+
self._schedule_paste_burst_flush()
|
|
466
|
+
|
|
467
|
+
def _should_start_paste_burst(self, char: str) -> bool:
|
|
468
|
+
"""Return whether a keypress should start paste-burst buffering.
|
|
469
|
+
|
|
470
|
+
Restricting to quote-prefixed input at an empty cursor reduces false
|
|
471
|
+
positives for normal typing and slash-command entry.
|
|
472
|
+
"""
|
|
473
|
+
if char not in _PASTE_BURST_START_CHARS:
|
|
474
|
+
return False
|
|
475
|
+
if self.text or not self.selection.is_empty:
|
|
476
|
+
return False
|
|
477
|
+
row, col = self.cursor_location
|
|
478
|
+
return row == 0 and col == 0
|
|
479
|
+
|
|
480
|
+
async def _flush_paste_burst(self) -> None:
|
|
481
|
+
"""Flush buffered burst text through dropped-path parsing.
|
|
482
|
+
|
|
483
|
+
When parsing fails, the buffered text is inserted unchanged so regular
|
|
484
|
+
typing behavior is preserved.
|
|
485
|
+
"""
|
|
486
|
+
payload = self._paste_burst_buffer
|
|
487
|
+
self._paste_burst_buffer = ""
|
|
488
|
+
self._paste_burst_last_char_time = None
|
|
489
|
+
self._cancel_paste_burst_timer()
|
|
490
|
+
if not payload:
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
from docagent_cli.input import parse_pasted_path_payload
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
parsed = await asyncio.to_thread(parse_pasted_path_payload, payload)
|
|
497
|
+
except Exception: # noqa: BLE001 # Treat thread failure as non-path text
|
|
498
|
+
parsed = None
|
|
499
|
+
if parsed is not None:
|
|
500
|
+
self.post_message(self.PastedPaths(payload, parsed.paths))
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
self.insert(payload)
|
|
504
|
+
|
|
505
|
+
def _delete_preceding_backslash(self) -> bool:
|
|
506
|
+
"""Delete the backslash character immediately before the cursor.
|
|
507
|
+
|
|
508
|
+
Caller must ensure a backslash is expected at this position. The
|
|
509
|
+
method verifies the character before deleting it.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
`True` if a backslash was found and deleted, `False` otherwise.
|
|
513
|
+
"""
|
|
514
|
+
row, col = self.cursor_location
|
|
515
|
+
if col > 0:
|
|
516
|
+
start = (row, col - 1)
|
|
517
|
+
if self.document.get_text_range(start, self.cursor_location) == "\\":
|
|
518
|
+
self.delete(start, self.cursor_location)
|
|
519
|
+
return True
|
|
520
|
+
elif row > 0:
|
|
521
|
+
prev_line = self.document.get_line(row - 1)
|
|
522
|
+
start = (row - 1, len(prev_line) - 1)
|
|
523
|
+
end = (row - 1, len(prev_line))
|
|
524
|
+
if self.document.get_text_range(start, end) == "\\":
|
|
525
|
+
self.delete(start, self.cursor_location)
|
|
526
|
+
return True
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
530
|
+
"""Handle key events."""
|
|
531
|
+
# VS Code 1.110 incorrectly sends space as a CSI u escape code
|
|
532
|
+
# (`\x1b[32u`) instead of a plain ` ` character. Textual parses
|
|
533
|
+
# this as Key(key='space', character=None, is_printable=False), so
|
|
534
|
+
# the TextArea never inserts the space. Per the kitty keyboard
|
|
535
|
+
# protocol spec, keys that generate text (like space) should NOT
|
|
536
|
+
# use CSI u encoding — VS Code is the outlier here.
|
|
537
|
+
#
|
|
538
|
+
# This workaround should be safe to keep indefinitely: once VS Code or
|
|
539
|
+
# Textual fixes the issue upstream, `character` will be `' '` and
|
|
540
|
+
# this branch simply won't match.
|
|
541
|
+
#
|
|
542
|
+
# Upstream: https://github.com/Textualize/textual/issues/6408
|
|
543
|
+
if event.key == "space" and event.character is None:
|
|
544
|
+
event.prevent_default()
|
|
545
|
+
event.stop()
|
|
546
|
+
self.insert(" ")
|
|
547
|
+
self.post_message(self.Typing())
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
now = time.monotonic()
|
|
551
|
+
|
|
552
|
+
# Signal typing activity for printable keys and backspace so the app
|
|
553
|
+
# can defer approval widgets while the user is actively editing.
|
|
554
|
+
if event.is_printable or event.key == "backspace":
|
|
555
|
+
self.post_message(self.Typing())
|
|
556
|
+
|
|
557
|
+
if self._paste_burst_buffer:
|
|
558
|
+
if event.key == "enter":
|
|
559
|
+
self._append_paste_burst("\n", now)
|
|
560
|
+
event.prevent_default()
|
|
561
|
+
event.stop()
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if event.is_printable and event.character is not None:
|
|
565
|
+
last_time = self._paste_burst_last_char_time
|
|
566
|
+
if (
|
|
567
|
+
last_time is not None
|
|
568
|
+
and (now - last_time) <= _PASTE_BURST_CHAR_GAP_SECONDS
|
|
569
|
+
):
|
|
570
|
+
self._append_paste_burst(event.character, now)
|
|
571
|
+
event.prevent_default()
|
|
572
|
+
event.stop()
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
await self._flush_paste_burst()
|
|
576
|
+
|
|
577
|
+
if (
|
|
578
|
+
event.is_printable
|
|
579
|
+
and event.character is not None
|
|
580
|
+
and self._should_start_paste_burst(event.character)
|
|
581
|
+
):
|
|
582
|
+
self._start_paste_burst(event.character, now)
|
|
583
|
+
event.prevent_default()
|
|
584
|
+
event.stop()
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
# Some terminals (e.g. VSCode built-in) send a literal backslash
|
|
588
|
+
# followed by enter for shift+enter. When enter arrives shortly
|
|
589
|
+
# after a backslash, delete the backslash and insert a newline.
|
|
590
|
+
if (
|
|
591
|
+
event.key == "enter"
|
|
592
|
+
and not self._completion_active
|
|
593
|
+
and self._backslash_pending_time is not None
|
|
594
|
+
and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS
|
|
595
|
+
):
|
|
596
|
+
self._backslash_pending_time = None
|
|
597
|
+
if self._delete_preceding_backslash():
|
|
598
|
+
event.prevent_default()
|
|
599
|
+
event.stop()
|
|
600
|
+
self.insert("\n")
|
|
601
|
+
return
|
|
602
|
+
self._backslash_pending_time = None
|
|
603
|
+
|
|
604
|
+
if event.key == "backslash" and event.character == "\\":
|
|
605
|
+
self._backslash_pending_time = now
|
|
606
|
+
|
|
607
|
+
# Modifier+Enter inserts newline — keys derived from BINDINGS
|
|
608
|
+
if event.key in self._NEWLINE_KEYS:
|
|
609
|
+
event.prevent_default()
|
|
610
|
+
event.stop()
|
|
611
|
+
self.insert("\n")
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
if event.key == "backspace" and self._delete_image_placeholder(backwards=True):
|
|
615
|
+
event.prevent_default()
|
|
616
|
+
event.stop()
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
if event.key == "delete" and self._delete_image_placeholder(backwards=False):
|
|
620
|
+
event.prevent_default()
|
|
621
|
+
event.stop()
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
# If completion is active, let parent handle navigation keys
|
|
625
|
+
if self._completion_active and event.key in {"up", "down", "tab", "enter"}:
|
|
626
|
+
# Prevent TextArea's default behavior (e.g., Enter inserting newline)
|
|
627
|
+
# but let event bubble to ChatInput for completion handling
|
|
628
|
+
event.prevent_default()
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
# Plain Enter submits
|
|
632
|
+
if event.key == "enter":
|
|
633
|
+
event.prevent_default()
|
|
634
|
+
event.stop()
|
|
635
|
+
value = self.text.strip()
|
|
636
|
+
if value:
|
|
637
|
+
self.post_message(self.Submitted(value))
|
|
638
|
+
return
|
|
639
|
+
|
|
640
|
+
# Up/Down arrow: only navigate history at input boundaries.
|
|
641
|
+
# Up requires cursor at position (0, 0); Down requires cursor at
|
|
642
|
+
# the very end. When already browsing history, either boundary
|
|
643
|
+
# allows navigation in both directions.
|
|
644
|
+
if event.key in {"up", "down"}:
|
|
645
|
+
row, col = self.cursor_location
|
|
646
|
+
text = self.text
|
|
647
|
+
lines = text.split("\n")
|
|
648
|
+
last_row = len(lines) - 1
|
|
649
|
+
at_start = row == 0 and col == 0
|
|
650
|
+
at_end = row == last_row and col == len(lines[last_row])
|
|
651
|
+
navigate = (
|
|
652
|
+
event.key == "up" and (at_start or (self._in_history and at_end))
|
|
653
|
+
) or (event.key == "down" and (at_end or (self._in_history and at_start)))
|
|
654
|
+
|
|
655
|
+
if navigate:
|
|
656
|
+
event.prevent_default()
|
|
657
|
+
event.stop()
|
|
658
|
+
if event.key == "up":
|
|
659
|
+
self.post_message(self.HistoryPrevious(self.text))
|
|
660
|
+
else:
|
|
661
|
+
self.post_message(self.HistoryNext())
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
await super()._on_key(event)
|
|
665
|
+
|
|
666
|
+
def _delete_image_placeholder(self, *, backwards: bool) -> bool:
|
|
667
|
+
"""Delete a full image placeholder token in one keypress.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
backwards: Whether the delete action is backwards (`backspace`) or
|
|
671
|
+
forwards (`delete`).
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
`True` when a placeholder token was deleted.
|
|
675
|
+
"""
|
|
676
|
+
if not self.text or not self.selection.is_empty:
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
cursor_offset = self.document.get_index_from_location(self.cursor_location) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
|
|
680
|
+
span = self._find_image_placeholder_span(cursor_offset, backwards=backwards)
|
|
681
|
+
if span is None:
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
start, end = span
|
|
685
|
+
start_location = self.document.get_location_from_index(start) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
|
|
686
|
+
end_location = self.document.get_location_from_index(end) # type: ignore[attr-defined]
|
|
687
|
+
self.delete(start_location, end_location)
|
|
688
|
+
self.move_cursor(start_location)
|
|
689
|
+
return True
|
|
690
|
+
|
|
691
|
+
def _find_image_placeholder_span(
|
|
692
|
+
self, cursor_offset: int, *, backwards: bool
|
|
693
|
+
) -> tuple[int, int] | None:
|
|
694
|
+
"""Return placeholder span to delete for current cursor and key direction.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
cursor_offset: Character offset of the cursor from the start of text.
|
|
698
|
+
backwards: Whether the delete action is backwards (backspace) or
|
|
699
|
+
forwards (delete).
|
|
700
|
+
"""
|
|
701
|
+
text = self.text
|
|
702
|
+
# Check both image and video placeholders
|
|
703
|
+
for pattern in (IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN):
|
|
704
|
+
for match in pattern.finditer(text):
|
|
705
|
+
start, end = match.span()
|
|
706
|
+
if backwards:
|
|
707
|
+
# Cursor is inside token or right after a trailing space inserted
|
|
708
|
+
# with the token.
|
|
709
|
+
if start < cursor_offset <= end:
|
|
710
|
+
return start, end
|
|
711
|
+
if cursor_offset > 0:
|
|
712
|
+
previous_index = cursor_offset - 1
|
|
713
|
+
if (
|
|
714
|
+
previous_index < len(text)
|
|
715
|
+
and previous_index == end
|
|
716
|
+
and text[previous_index].isspace()
|
|
717
|
+
):
|
|
718
|
+
return start, cursor_offset
|
|
719
|
+
elif start <= cursor_offset < end:
|
|
720
|
+
return start, end
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
async def _on_paste(self, event: events.Paste) -> None:
|
|
724
|
+
"""Handle paste events and detect dragged file paths."""
|
|
725
|
+
self._backslash_pending_time = None
|
|
726
|
+
if self._paste_burst_buffer:
|
|
727
|
+
await self._flush_paste_burst()
|
|
728
|
+
|
|
729
|
+
from docagent_cli.input import parse_pasted_path_payload
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
parsed = await asyncio.to_thread(parse_pasted_path_payload, event.text)
|
|
733
|
+
except Exception: # noqa: BLE001 # Treat thread failure as non-path text
|
|
734
|
+
parsed = None
|
|
735
|
+
if parsed is None:
|
|
736
|
+
# Don't call super() here — Textual's MRO dispatch already calls
|
|
737
|
+
# TextArea._on_paste after this handler returns. Calling super()
|
|
738
|
+
# would insert the text a second time, duplicating the paste.
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
event.prevent_default()
|
|
742
|
+
event.stop()
|
|
743
|
+
self.post_message(self.PastedPaths(event.text, parsed.paths))
|
|
744
|
+
|
|
745
|
+
def set_text_from_history(self, text: str) -> None:
|
|
746
|
+
"""Set text from history navigation."""
|
|
747
|
+
self._paste_burst_buffer = ""
|
|
748
|
+
self._paste_burst_last_char_time = None
|
|
749
|
+
self._cancel_paste_burst_timer()
|
|
750
|
+
self._backslash_pending_time = None
|
|
751
|
+
self._skip_history_change_events += 1
|
|
752
|
+
self.text = text
|
|
753
|
+
# Move cursor to end
|
|
754
|
+
lines = text.split("\n")
|
|
755
|
+
last_row = len(lines) - 1
|
|
756
|
+
last_col = len(lines[last_row])
|
|
757
|
+
self.move_cursor((last_row, last_col))
|
|
758
|
+
|
|
759
|
+
def clear_text(self) -> None:
|
|
760
|
+
"""Clear the text area."""
|
|
761
|
+
self._in_history = False
|
|
762
|
+
# Increment (not reset) so any pending Changed event from a prior
|
|
763
|
+
# set_text_from_history is still suppressed, plus one for the
|
|
764
|
+
# self.text = "" assignment below.
|
|
765
|
+
self._skip_history_change_events += 1
|
|
766
|
+
self._paste_burst_buffer = ""
|
|
767
|
+
self._paste_burst_last_char_time = None
|
|
768
|
+
self._cancel_paste_burst_timer()
|
|
769
|
+
self._backslash_pending_time = None
|
|
770
|
+
self.text = ""
|
|
771
|
+
self.move_cursor((0, 0))
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
class _CompletionViewAdapter:
|
|
775
|
+
"""Translate completion-space replacements to text-area coordinates."""
|
|
776
|
+
|
|
777
|
+
def __init__(self, chat_input: ChatInput) -> None:
|
|
778
|
+
"""Initialize adapter with its owning `ChatInput`."""
|
|
779
|
+
self._chat_input = chat_input
|
|
780
|
+
|
|
781
|
+
def render_completion_suggestions(
|
|
782
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
783
|
+
) -> None:
|
|
784
|
+
"""Delegate suggestion rendering to `ChatInput`."""
|
|
785
|
+
self._chat_input.render_completion_suggestions(suggestions, selected_index)
|
|
786
|
+
|
|
787
|
+
def clear_completion_suggestions(self) -> None:
|
|
788
|
+
"""Delegate completion clearing to `ChatInput`."""
|
|
789
|
+
self._chat_input.clear_completion_suggestions()
|
|
790
|
+
|
|
791
|
+
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
792
|
+
"""Map completion indices to text-area indices before replacing text."""
|
|
793
|
+
self._chat_input.replace_completion_range(
|
|
794
|
+
self._chat_input._completion_index_to_text_index(start),
|
|
795
|
+
self._chat_input._completion_index_to_text_index(end),
|
|
796
|
+
replacement,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class ChatInput(Vertical):
|
|
801
|
+
"""Chat input widget with prompt, multi-line text, autocomplete, and history.
|
|
802
|
+
|
|
803
|
+
Features:
|
|
804
|
+
- Multi-line input with TextArea
|
|
805
|
+
- Enter to submit, modifier key for newlines (see `config.newline_shortcut`)
|
|
806
|
+
- Up/Down arrows for command history at input boundaries (start/end of text)
|
|
807
|
+
- Autocomplete for @ (files) and / (commands)
|
|
808
|
+
"""
|
|
809
|
+
|
|
810
|
+
DEFAULT_CSS = """
|
|
811
|
+
ChatInput {
|
|
812
|
+
height: auto;
|
|
813
|
+
min-height: 3;
|
|
814
|
+
max-height: 25;
|
|
815
|
+
padding: 0;
|
|
816
|
+
background: $surface;
|
|
817
|
+
border: solid $primary;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
ChatInput.mode-shell {
|
|
821
|
+
border: solid $mode-bash;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
ChatInput.mode-command {
|
|
825
|
+
border: solid $mode-command;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
ChatInput .input-row {
|
|
829
|
+
height: auto;
|
|
830
|
+
width: 100%;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
ChatInput .input-prompt {
|
|
834
|
+
width: 3;
|
|
835
|
+
height: 1;
|
|
836
|
+
padding: 0 1;
|
|
837
|
+
color: $primary;
|
|
838
|
+
text-style: bold;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
ChatInput.mode-shell .input-prompt {
|
|
842
|
+
color: $mode-bash;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
ChatInput.mode-command .input-prompt {
|
|
846
|
+
color: $mode-command;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
ChatInput ChatTextArea {
|
|
850
|
+
width: 1fr;
|
|
851
|
+
height: auto;
|
|
852
|
+
min-height: 1;
|
|
853
|
+
max-height: 8;
|
|
854
|
+
border: none;
|
|
855
|
+
background: transparent;
|
|
856
|
+
padding: 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
ChatInput ChatTextArea:focus {
|
|
860
|
+
border: none;
|
|
861
|
+
}
|
|
862
|
+
"""
|
|
863
|
+
"""Border and prompt glyph change color per mode for immediate visual feedback."""
|
|
864
|
+
|
|
865
|
+
class Submitted(Message):
|
|
866
|
+
"""Message sent when input is submitted."""
|
|
867
|
+
|
|
868
|
+
def __init__(self, value: str, mode: str = "normal") -> None:
|
|
869
|
+
"""Initialize with value and mode."""
|
|
870
|
+
super().__init__()
|
|
871
|
+
self.value = value
|
|
872
|
+
self.mode = mode
|
|
873
|
+
|
|
874
|
+
class ModeChanged(Message):
|
|
875
|
+
"""Message sent when input mode changes."""
|
|
876
|
+
|
|
877
|
+
def __init__(self, mode: str) -> None:
|
|
878
|
+
"""Initialize with new mode."""
|
|
879
|
+
super().__init__()
|
|
880
|
+
self.mode = mode
|
|
881
|
+
|
|
882
|
+
class Typing(Message):
|
|
883
|
+
"""Posted when the user presses a printable key or backspace in the input.
|
|
884
|
+
|
|
885
|
+
The app uses this to delay approval widgets while the user is actively
|
|
886
|
+
typing, preventing accidental key presses (e.g. `y`, `n`) from
|
|
887
|
+
triggering approval decisions.
|
|
888
|
+
"""
|
|
889
|
+
|
|
890
|
+
mode: reactive[str] = reactive("normal")
|
|
891
|
+
|
|
892
|
+
def __init__(
|
|
893
|
+
self,
|
|
894
|
+
cwd: str | Path | None = None,
|
|
895
|
+
history_file: Path | None = None,
|
|
896
|
+
image_tracker: MediaTracker | None = None,
|
|
897
|
+
**kwargs: Any,
|
|
898
|
+
) -> None:
|
|
899
|
+
"""Initialize the chat input widget.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
cwd: Current working directory for file completion
|
|
903
|
+
history_file: Path to history file (default: ~/.docagent/history.jsonl)
|
|
904
|
+
image_tracker: Optional tracker for attached images
|
|
905
|
+
**kwargs: Additional arguments for parent
|
|
906
|
+
"""
|
|
907
|
+
super().__init__(**kwargs)
|
|
908
|
+
self._cwd = Path(cwd) if cwd else Path.cwd()
|
|
909
|
+
self._image_tracker = image_tracker
|
|
910
|
+
self._text_area: ChatTextArea | None = None
|
|
911
|
+
self._popup: CompletionPopup | None = None
|
|
912
|
+
self._completion_manager: MultiCompletionManager | None = None
|
|
913
|
+
self._completion_view: _CompletionViewAdapter | None = None
|
|
914
|
+
self._slash_controller: SlashCommandController | None = None
|
|
915
|
+
|
|
916
|
+
# Guard flag: set True before programmatically stripping the mode
|
|
917
|
+
# prefix character so the resulting text-change event does not
|
|
918
|
+
# re-evaluate mode.
|
|
919
|
+
self._stripping_prefix = False
|
|
920
|
+
|
|
921
|
+
# When the user submits, we clear the text area which fires a
|
|
922
|
+
# text-change event. Without this guard the tracker would see the
|
|
923
|
+
# now-empty text, assume all media were deleted, and discard them
|
|
924
|
+
# before the app has a chance to send them. Each submit bumps the
|
|
925
|
+
# counter by one; the next text-change event decrements it and
|
|
926
|
+
# skips the sync.
|
|
927
|
+
self._skip_media_sync_events = 0
|
|
928
|
+
|
|
929
|
+
# Number of virtual prefix characters currently injected for
|
|
930
|
+
# completion controller calls (0 for normal, 1 for shell/command).
|
|
931
|
+
self._completion_prefix_len = 0
|
|
932
|
+
|
|
933
|
+
# Guard flag: set while replacing a dropped path payload with an
|
|
934
|
+
# inline image placeholder so the resulting change event doesn't
|
|
935
|
+
# immediately recurse into the same replacement path.
|
|
936
|
+
self._applying_inline_path_replacement = False
|
|
937
|
+
|
|
938
|
+
# Track current suggestions for click handling
|
|
939
|
+
self._current_suggestions: list[tuple[str, str]] = []
|
|
940
|
+
self._current_selected_index = 0
|
|
941
|
+
|
|
942
|
+
# Set up history manager
|
|
943
|
+
if history_file is None:
|
|
944
|
+
history_file = _default_history_path()
|
|
945
|
+
self._history = HistoryManager(history_file)
|
|
946
|
+
|
|
947
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
|
|
948
|
+
"""Compose the chat input layout.
|
|
949
|
+
|
|
950
|
+
Yields:
|
|
951
|
+
Widgets for the input row and completion popup.
|
|
952
|
+
"""
|
|
953
|
+
with Horizontal(classes="input-row"):
|
|
954
|
+
yield Static(">", classes="input-prompt", id="prompt")
|
|
955
|
+
yield ChatTextArea(id="chat-input")
|
|
956
|
+
|
|
957
|
+
yield CompletionPopup(id="completion-popup")
|
|
958
|
+
|
|
959
|
+
def on_mount(self) -> None:
|
|
960
|
+
"""Initialize components after mount."""
|
|
961
|
+
if is_ascii_mode():
|
|
962
|
+
colors = theme.get_theme_colors(self)
|
|
963
|
+
self.styles.border = ("ascii", colors.primary)
|
|
964
|
+
|
|
965
|
+
self._text_area = self.query_one("#chat-input", ChatTextArea)
|
|
966
|
+
self._popup = self.query_one("#completion-popup", CompletionPopup)
|
|
967
|
+
|
|
968
|
+
# Both controllers implement the CompletionController protocol but have
|
|
969
|
+
# different concrete types; the list-item warning is a false positive.
|
|
970
|
+
self._completion_view = _CompletionViewAdapter(self)
|
|
971
|
+
self._file_controller = FuzzyFileController(
|
|
972
|
+
self._completion_view, cwd=self._cwd
|
|
973
|
+
)
|
|
974
|
+
self._slash_controller = SlashCommandController(
|
|
975
|
+
SLASH_COMMANDS, self._completion_view
|
|
976
|
+
)
|
|
977
|
+
self._completion_manager = MultiCompletionManager(
|
|
978
|
+
[
|
|
979
|
+
self._slash_controller,
|
|
980
|
+
self._file_controller,
|
|
981
|
+
] # type: ignore[list-item] # Controller types are compatible at runtime
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
self.run_worker(
|
|
985
|
+
self._file_controller.warm_cache(),
|
|
986
|
+
exclusive=False,
|
|
987
|
+
exit_on_error=False,
|
|
988
|
+
)
|
|
989
|
+
self._text_area.focus()
|
|
990
|
+
|
|
991
|
+
def update_slash_commands(self, commands: list[tuple[str, str, str]]) -> None:
|
|
992
|
+
"""Update the slash command controller's command list.
|
|
993
|
+
|
|
994
|
+
Called by the app after discovering skills to merge static
|
|
995
|
+
commands with dynamic `/skill:` entries.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
commands: Full list of `(command, description, hidden_keywords)` tuples.
|
|
999
|
+
"""
|
|
1000
|
+
if self._slash_controller:
|
|
1001
|
+
self._slash_controller.update_commands(commands)
|
|
1002
|
+
else:
|
|
1003
|
+
logger.warning(
|
|
1004
|
+
"Cannot update slash commands: controller not initialized "
|
|
1005
|
+
"(widget not yet mounted)"
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
1009
|
+
"""Detect input mode and update completions."""
|
|
1010
|
+
text = event.text_area.text
|
|
1011
|
+
self._sync_media_tracker_to_text(text)
|
|
1012
|
+
|
|
1013
|
+
# History handlers explicitly decide mode and stripped display text.
|
|
1014
|
+
# Skip mode detection here so recalled entries don't inherit stale mode.
|
|
1015
|
+
if self._text_area and self._text_area._skip_history_change_events > 0:
|
|
1016
|
+
self._text_area._skip_history_change_events -= 1
|
|
1017
|
+
if self._completion_manager:
|
|
1018
|
+
self._completion_manager.reset()
|
|
1019
|
+
self.scroll_visible()
|
|
1020
|
+
return
|
|
1021
|
+
if self._text_area and self._text_area._skip_history_change_events < 0:
|
|
1022
|
+
logger.warning(
|
|
1023
|
+
"_skip_history_change_events is negative (%d); resetting to 0",
|
|
1024
|
+
self._text_area._skip_history_change_events,
|
|
1025
|
+
)
|
|
1026
|
+
self._text_area._skip_history_change_events = 0
|
|
1027
|
+
|
|
1028
|
+
if self._applying_inline_path_replacement:
|
|
1029
|
+
self._applying_inline_path_replacement = False
|
|
1030
|
+
elif self._apply_inline_dropped_path_replacement(text):
|
|
1031
|
+
return
|
|
1032
|
+
|
|
1033
|
+
# Checked after the guards above so we skip the (potentially slow)
|
|
1034
|
+
# filesystem lookup when the text change came from history navigation
|
|
1035
|
+
# or prefix stripping, which never need path detection.
|
|
1036
|
+
is_path_payload = self._is_dropped_path_payload(text)
|
|
1037
|
+
|
|
1038
|
+
# Guard: skip mode re-detection after we programmatically stripped
|
|
1039
|
+
# a prefix character.
|
|
1040
|
+
if self._stripping_prefix:
|
|
1041
|
+
self._stripping_prefix = False
|
|
1042
|
+
elif text and text[0] in PREFIX_TO_MODE:
|
|
1043
|
+
if text[0] == "/" and is_path_payload:
|
|
1044
|
+
# Absolute dropped paths stay normal input, not slash-command mode.
|
|
1045
|
+
if self.mode != "normal":
|
|
1046
|
+
self.mode = "normal"
|
|
1047
|
+
else:
|
|
1048
|
+
# Detected a mode-trigger prefix (e.g. "!" or "/").
|
|
1049
|
+
# Strip it unconditionally -- even when already in the correct
|
|
1050
|
+
# mode -- because completion controllers may write replacement
|
|
1051
|
+
# text that re-includes the trigger character. The
|
|
1052
|
+
# _stripping_prefix guard prevents the resulting change event
|
|
1053
|
+
# from looping back here.
|
|
1054
|
+
detected = PREFIX_TO_MODE[text[0]]
|
|
1055
|
+
if self.mode != detected:
|
|
1056
|
+
self.mode = detected
|
|
1057
|
+
self._strip_mode_prefix()
|
|
1058
|
+
return
|
|
1059
|
+
# Update completion suggestions using completion-space text/cursor.
|
|
1060
|
+
if self._completion_manager and self._text_area:
|
|
1061
|
+
if is_path_payload:
|
|
1062
|
+
self._completion_manager.reset()
|
|
1063
|
+
else:
|
|
1064
|
+
vtext, vcursor = self._completion_text_and_cursor()
|
|
1065
|
+
self._completion_manager.on_text_changed(vtext, vcursor)
|
|
1066
|
+
|
|
1067
|
+
# Scroll input into view when content changes (handles text wrap)
|
|
1068
|
+
self.scroll_visible()
|
|
1069
|
+
|
|
1070
|
+
@staticmethod
|
|
1071
|
+
def _parse_dropped_path_payload(
|
|
1072
|
+
text: str, *, allow_leading_path: bool = False
|
|
1073
|
+
) -> ParsedPastedPathPayload | None:
|
|
1074
|
+
"""Parse dropped-path payload text through a single parser entrypoint.
|
|
1075
|
+
|
|
1076
|
+
Returns:
|
|
1077
|
+
Parsed payload details, otherwise `None`.
|
|
1078
|
+
"""
|
|
1079
|
+
from docagent_cli.input import parse_pasted_path_payload
|
|
1080
|
+
|
|
1081
|
+
return parse_pasted_path_payload(text, allow_leading_path=allow_leading_path)
|
|
1082
|
+
|
|
1083
|
+
def _parse_dropped_path_payload_with_command_recovery(
|
|
1084
|
+
self, text: str, *, allow_leading_path: bool = False
|
|
1085
|
+
) -> tuple[str, ParsedPastedPathPayload | None]:
|
|
1086
|
+
"""Parse payload and recover stripped leading slash in command mode.
|
|
1087
|
+
|
|
1088
|
+
Args:
|
|
1089
|
+
text: Input text to parse.
|
|
1090
|
+
allow_leading_path: Whether to parse leading path + suffix payloads.
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
Tuple of `(candidate_text, parsed_payload)`.
|
|
1094
|
+
"""
|
|
1095
|
+
candidate = text
|
|
1096
|
+
parsed = self._parse_dropped_path_payload(
|
|
1097
|
+
text, allow_leading_path=allow_leading_path
|
|
1098
|
+
)
|
|
1099
|
+
if parsed is not None:
|
|
1100
|
+
return candidate, parsed
|
|
1101
|
+
|
|
1102
|
+
if self.mode != "command":
|
|
1103
|
+
return candidate, None
|
|
1104
|
+
|
|
1105
|
+
prefixed = f"/{text.lstrip('/')}"
|
|
1106
|
+
parsed = self._parse_dropped_path_payload(
|
|
1107
|
+
prefixed, allow_leading_path=allow_leading_path
|
|
1108
|
+
)
|
|
1109
|
+
if parsed is None:
|
|
1110
|
+
return candidate, None
|
|
1111
|
+
|
|
1112
|
+
logger.debug(
|
|
1113
|
+
"Recovering stripped absolute path; resetting mode from "
|
|
1114
|
+
"'command' to 'normal'"
|
|
1115
|
+
)
|
|
1116
|
+
self.mode = "normal"
|
|
1117
|
+
return prefixed, parsed
|
|
1118
|
+
|
|
1119
|
+
def _extract_leading_dropped_path_with_command_recovery(
|
|
1120
|
+
self, text: str
|
|
1121
|
+
) -> tuple[str, tuple[Path, int] | None]:
|
|
1122
|
+
"""Extract a leading dropped-path token with command-mode recovery.
|
|
1123
|
+
|
|
1124
|
+
Args:
|
|
1125
|
+
text: Input text to parse.
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Tuple of `(candidate_text, leading_match)`, where `leading_match` is
|
|
1129
|
+
`(path, token_end)` when extraction succeeds, otherwise `None`.
|
|
1130
|
+
"""
|
|
1131
|
+
from docagent_cli.input import extract_leading_pasted_file_path
|
|
1132
|
+
|
|
1133
|
+
leading_match = extract_leading_pasted_file_path(text)
|
|
1134
|
+
candidate = text
|
|
1135
|
+
if leading_match is not None:
|
|
1136
|
+
return candidate, leading_match
|
|
1137
|
+
|
|
1138
|
+
if self.mode != "command":
|
|
1139
|
+
return candidate, None
|
|
1140
|
+
|
|
1141
|
+
prefixed = f"/{text.lstrip('/')}"
|
|
1142
|
+
leading_match = extract_leading_pasted_file_path(prefixed)
|
|
1143
|
+
if leading_match is None:
|
|
1144
|
+
return candidate, None
|
|
1145
|
+
|
|
1146
|
+
logger.debug(
|
|
1147
|
+
"Recovering stripped absolute leading path; resetting mode "
|
|
1148
|
+
"from 'command' to 'normal'"
|
|
1149
|
+
)
|
|
1150
|
+
self.mode = "normal"
|
|
1151
|
+
return prefixed, leading_match
|
|
1152
|
+
|
|
1153
|
+
@staticmethod
|
|
1154
|
+
def _is_existing_path_payload(text: str) -> bool:
|
|
1155
|
+
"""Return whether text is a dropped-path payload for existing files."""
|
|
1156
|
+
if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char
|
|
1157
|
+
return False
|
|
1158
|
+
from docagent_cli.input import parse_pasted_path_payload
|
|
1159
|
+
|
|
1160
|
+
return parse_pasted_path_payload(text, allow_leading_path=True) is not None
|
|
1161
|
+
|
|
1162
|
+
def _is_dropped_path_payload(self, text: str) -> bool:
|
|
1163
|
+
"""Return whether current text looks like a dropped file-path payload."""
|
|
1164
|
+
if not text:
|
|
1165
|
+
return False
|
|
1166
|
+
if self._is_existing_path_payload(text):
|
|
1167
|
+
return True
|
|
1168
|
+
if self.mode == "command":
|
|
1169
|
+
candidate = f"/{text.lstrip('/')}"
|
|
1170
|
+
return self._is_existing_path_payload(candidate)
|
|
1171
|
+
return False
|
|
1172
|
+
|
|
1173
|
+
def _strip_mode_prefix(self) -> None:
|
|
1174
|
+
"""Remove the first character (mode trigger) from the text area.
|
|
1175
|
+
|
|
1176
|
+
Sets the `_stripping_prefix` guard so the resulting text-change event is
|
|
1177
|
+
not misinterpreted as new input.
|
|
1178
|
+
"""
|
|
1179
|
+
if not self._text_area:
|
|
1180
|
+
return
|
|
1181
|
+
if self._stripping_prefix:
|
|
1182
|
+
logger.warning(
|
|
1183
|
+
"Previous _stripping_prefix guard was never cleared; "
|
|
1184
|
+
"resetting. This may indicate a missed text-change event."
|
|
1185
|
+
)
|
|
1186
|
+
text = self._text_area.text
|
|
1187
|
+
if not text:
|
|
1188
|
+
return
|
|
1189
|
+
row, col = self._text_area.cursor_location
|
|
1190
|
+
self._stripping_prefix = True
|
|
1191
|
+
self._text_area.text = text[1:]
|
|
1192
|
+
if row == 0 and col > 0:
|
|
1193
|
+
col -= 1
|
|
1194
|
+
self._text_area.move_cursor((row, col))
|
|
1195
|
+
|
|
1196
|
+
def _completion_text_and_cursor(self) -> tuple[str, int]:
|
|
1197
|
+
"""Return controller-facing text/cursor in completion space.
|
|
1198
|
+
|
|
1199
|
+
Also updates `_completion_prefix_len` so that subsequent calls to
|
|
1200
|
+
`_completion_index_to_text_index` use the matching offset.
|
|
1201
|
+
"""
|
|
1202
|
+
if not self._text_area:
|
|
1203
|
+
self._completion_prefix_len = 0
|
|
1204
|
+
return "", 0
|
|
1205
|
+
|
|
1206
|
+
text = self._text_area.text
|
|
1207
|
+
cursor = self._get_cursor_offset()
|
|
1208
|
+
prefix = MODE_PREFIXES.get(self.mode, "")
|
|
1209
|
+
self._completion_prefix_len = len(prefix)
|
|
1210
|
+
|
|
1211
|
+
if prefix:
|
|
1212
|
+
return prefix + text, cursor + len(prefix)
|
|
1213
|
+
return text, cursor
|
|
1214
|
+
|
|
1215
|
+
def _completion_index_to_text_index(self, index: int) -> int:
|
|
1216
|
+
"""Translate completion-space index into text-area index.
|
|
1217
|
+
|
|
1218
|
+
Args:
|
|
1219
|
+
index: Cursor/index position in completion space.
|
|
1220
|
+
|
|
1221
|
+
Returns:
|
|
1222
|
+
Clamped index in text-area space.
|
|
1223
|
+
"""
|
|
1224
|
+
if not self._text_area:
|
|
1225
|
+
return 0
|
|
1226
|
+
|
|
1227
|
+
mapped = index - self._completion_prefix_len
|
|
1228
|
+
text_len = len(self._text_area.text)
|
|
1229
|
+
if mapped < 0 or mapped > text_len:
|
|
1230
|
+
logger.warning(
|
|
1231
|
+
"Completion index %d mapped to %d, outside [0, %d]; "
|
|
1232
|
+
"clamping (prefix_len=%d, mode=%s)",
|
|
1233
|
+
index,
|
|
1234
|
+
mapped,
|
|
1235
|
+
text_len,
|
|
1236
|
+
self._completion_prefix_len,
|
|
1237
|
+
self.mode,
|
|
1238
|
+
)
|
|
1239
|
+
return max(0, min(mapped, text_len))
|
|
1240
|
+
|
|
1241
|
+
def _submit_value(self, value: str) -> None:
|
|
1242
|
+
"""Prepend mode prefix, save to history, post message, and reset input.
|
|
1243
|
+
|
|
1244
|
+
This is the single path for all submission flows so the prefix-prepend +
|
|
1245
|
+
history + post + clear + mode-reset logic stays in one place.
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
value: The stripped text to submit (without mode prefix).
|
|
1249
|
+
"""
|
|
1250
|
+
if not value:
|
|
1251
|
+
return
|
|
1252
|
+
|
|
1253
|
+
if self._completion_manager:
|
|
1254
|
+
self._completion_manager.reset()
|
|
1255
|
+
|
|
1256
|
+
value = self._replace_submitted_paths_with_images(value)
|
|
1257
|
+
|
|
1258
|
+
# Prepend mode prefix so the app layer receives the original trigger
|
|
1259
|
+
# form (e.g. "!ls", "/help"). The value may already contain the prefix
|
|
1260
|
+
# when a completion controller wrote it back into the text area before
|
|
1261
|
+
# the strip handler ran.
|
|
1262
|
+
prefix = MODE_PREFIXES.get(self.mode, "")
|
|
1263
|
+
if prefix and not value.startswith(prefix):
|
|
1264
|
+
value = prefix + value
|
|
1265
|
+
|
|
1266
|
+
self._history.add(value)
|
|
1267
|
+
self.post_message(self.Submitted(value, self.mode))
|
|
1268
|
+
|
|
1269
|
+
if self._text_area:
|
|
1270
|
+
# Preserve submission-time attachments until adapter consumes them.
|
|
1271
|
+
self._skip_media_sync_events += 1
|
|
1272
|
+
self._text_area.clear_text()
|
|
1273
|
+
self.mode = "normal"
|
|
1274
|
+
|
|
1275
|
+
def _sync_media_tracker_to_text(self, text: str) -> None:
|
|
1276
|
+
"""Keep tracked media aligned with placeholder tokens in input text.
|
|
1277
|
+
|
|
1278
|
+
Args:
|
|
1279
|
+
text: Current text in the input area.
|
|
1280
|
+
"""
|
|
1281
|
+
if not self._image_tracker:
|
|
1282
|
+
return
|
|
1283
|
+
if self._skip_media_sync_events:
|
|
1284
|
+
if self._skip_media_sync_events < 0:
|
|
1285
|
+
logger.warning(
|
|
1286
|
+
"_skip_media_sync_events is negative (%d); resetting to 0",
|
|
1287
|
+
self._skip_media_sync_events,
|
|
1288
|
+
)
|
|
1289
|
+
self._skip_media_sync_events = 0
|
|
1290
|
+
else:
|
|
1291
|
+
self._skip_media_sync_events -= 1
|
|
1292
|
+
return
|
|
1293
|
+
self._image_tracker.sync_to_text(text)
|
|
1294
|
+
|
|
1295
|
+
def on_chat_text_area_typing(
|
|
1296
|
+
self,
|
|
1297
|
+
event: ChatTextArea.Typing, # noqa: ARG002 # Textual event handler signature
|
|
1298
|
+
) -> None:
|
|
1299
|
+
"""Relay typing activity to the app as `ChatInput.Typing`."""
|
|
1300
|
+
self.post_message(self.Typing())
|
|
1301
|
+
|
|
1302
|
+
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
|
1303
|
+
"""Handle text submission.
|
|
1304
|
+
|
|
1305
|
+
Always posts the Submitted event - the app layer decides whether to
|
|
1306
|
+
process immediately or queue based on agent status.
|
|
1307
|
+
"""
|
|
1308
|
+
self._submit_value(event.value)
|
|
1309
|
+
|
|
1310
|
+
def on_chat_text_area_history_previous(
|
|
1311
|
+
self, event: ChatTextArea.HistoryPrevious
|
|
1312
|
+
) -> None:
|
|
1313
|
+
"""Handle history previous request."""
|
|
1314
|
+
entry = self._history.get_previous(event.current_text, query=event.current_text)
|
|
1315
|
+
if entry is not None and self._text_area:
|
|
1316
|
+
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
1317
|
+
self.mode = mode
|
|
1318
|
+
self._text_area.set_text_from_history(display_text)
|
|
1319
|
+
# No-match path: don't reset the counter — a pending Changed event
|
|
1320
|
+
# from a prior set_text_from_history call may still be in flight.
|
|
1321
|
+
# Keep text area's _in_history in sync with the history manager.
|
|
1322
|
+
if self._text_area:
|
|
1323
|
+
self._text_area._in_history = self._history.in_history
|
|
1324
|
+
|
|
1325
|
+
def on_chat_text_area_history_next(
|
|
1326
|
+
self,
|
|
1327
|
+
event: ChatTextArea.HistoryNext, # noqa: ARG002 # Textual event handler signature
|
|
1328
|
+
) -> None:
|
|
1329
|
+
"""Handle history next request."""
|
|
1330
|
+
entry = self._history.get_next()
|
|
1331
|
+
if entry is not None and self._text_area:
|
|
1332
|
+
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
1333
|
+
self.mode = mode
|
|
1334
|
+
self._text_area.set_text_from_history(display_text)
|
|
1335
|
+
# No-match path: don't reset the counter — a pending Changed event
|
|
1336
|
+
# from a prior set_text_from_history call may still be in flight.
|
|
1337
|
+
# Keep text area's _in_history in sync with the history manager.
|
|
1338
|
+
# When the user presses Down past the newest entry, get_next()
|
|
1339
|
+
# resets navigation internally, so in_history becomes False.
|
|
1340
|
+
if self._text_area:
|
|
1341
|
+
self._text_area._in_history = self._history.in_history
|
|
1342
|
+
|
|
1343
|
+
def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None:
|
|
1344
|
+
"""Handle paste payloads that resolve to dropped file paths."""
|
|
1345
|
+
if not self._text_area:
|
|
1346
|
+
return
|
|
1347
|
+
|
|
1348
|
+
self._insert_pasted_paths(event.raw_text, event.paths)
|
|
1349
|
+
|
|
1350
|
+
def handle_external_paste(self, pasted: str) -> bool:
|
|
1351
|
+
"""Handle paste text from app-level routing when input is not focused.
|
|
1352
|
+
|
|
1353
|
+
When the text area is mounted, the paste is always consumed: file paths
|
|
1354
|
+
are attached as images, and plain text is inserted directly.
|
|
1355
|
+
|
|
1356
|
+
Args:
|
|
1357
|
+
pasted: Raw pasted text payload.
|
|
1358
|
+
|
|
1359
|
+
Returns:
|
|
1360
|
+
`True` when the text area is mounted and the paste was inserted,
|
|
1361
|
+
`False` if the widget is not yet composed.
|
|
1362
|
+
"""
|
|
1363
|
+
if not self._text_area:
|
|
1364
|
+
return False
|
|
1365
|
+
|
|
1366
|
+
parsed = self._parse_dropped_path_payload(pasted)
|
|
1367
|
+
if parsed is None:
|
|
1368
|
+
self._text_area.insert(pasted)
|
|
1369
|
+
else:
|
|
1370
|
+
self._insert_pasted_paths(pasted, parsed.paths)
|
|
1371
|
+
|
|
1372
|
+
self._text_area.focus()
|
|
1373
|
+
return True
|
|
1374
|
+
|
|
1375
|
+
def _apply_inline_dropped_path_replacement(self, text: str) -> bool:
|
|
1376
|
+
"""Replace full dropped-path payload text with image placeholders.
|
|
1377
|
+
|
|
1378
|
+
Some terminals insert drag-and-drop payloads as plain text rather than
|
|
1379
|
+
dispatching a dedicated paste event. When the current text resolves to
|
|
1380
|
+
one or more file paths and at least one path is an image, rewrite the
|
|
1381
|
+
text inline to `[image N]` placeholders.
|
|
1382
|
+
|
|
1383
|
+
Args:
|
|
1384
|
+
text: Current text area content.
|
|
1385
|
+
|
|
1386
|
+
Returns:
|
|
1387
|
+
`True` if text was rewritten inline, otherwise `False`.
|
|
1388
|
+
"""
|
|
1389
|
+
if not self._text_area:
|
|
1390
|
+
return False
|
|
1391
|
+
|
|
1392
|
+
parsed = self._parse_dropped_path_payload(text)
|
|
1393
|
+
if parsed is None:
|
|
1394
|
+
return False
|
|
1395
|
+
|
|
1396
|
+
replacement, attached = self._build_path_replacement(
|
|
1397
|
+
text, parsed.paths, add_trailing_space=True
|
|
1398
|
+
)
|
|
1399
|
+
if not attached or replacement == text:
|
|
1400
|
+
return False
|
|
1401
|
+
|
|
1402
|
+
self._applying_inline_path_replacement = True
|
|
1403
|
+
self._text_area.text = replacement
|
|
1404
|
+
lines = replacement.split("\n")
|
|
1405
|
+
self._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
|
|
1406
|
+
return True
|
|
1407
|
+
|
|
1408
|
+
def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None:
|
|
1409
|
+
"""Insert pasted path payload, attaching images when possible.
|
|
1410
|
+
|
|
1411
|
+
Args:
|
|
1412
|
+
raw_text: Original paste payload text.
|
|
1413
|
+
paths: Resolved file paths parsed from the payload.
|
|
1414
|
+
"""
|
|
1415
|
+
if not self._text_area:
|
|
1416
|
+
return
|
|
1417
|
+
replacement, attached = self._build_path_replacement(
|
|
1418
|
+
raw_text, paths, add_trailing_space=True
|
|
1419
|
+
)
|
|
1420
|
+
if attached:
|
|
1421
|
+
self._text_area.insert(replacement)
|
|
1422
|
+
return
|
|
1423
|
+
self._text_area.insert(raw_text)
|
|
1424
|
+
|
|
1425
|
+
def _build_path_replacement(
|
|
1426
|
+
self,
|
|
1427
|
+
raw_text: str,
|
|
1428
|
+
paths: list[Path],
|
|
1429
|
+
*,
|
|
1430
|
+
add_trailing_space: bool,
|
|
1431
|
+
) -> tuple[str, bool]:
|
|
1432
|
+
"""Build replacement text for dropped paths and attach any images.
|
|
1433
|
+
|
|
1434
|
+
Args:
|
|
1435
|
+
raw_text: Original paste payload text.
|
|
1436
|
+
paths: Resolved file paths parsed from the payload.
|
|
1437
|
+
add_trailing_space: Whether to append a trailing space after the
|
|
1438
|
+
last token when paths are separated by spaces.
|
|
1439
|
+
|
|
1440
|
+
Returns:
|
|
1441
|
+
Tuple of `(replacement, attached)` where `attached` indicates whether
|
|
1442
|
+
at least one media attachment (image or video) was created.
|
|
1443
|
+
"""
|
|
1444
|
+
if not self._image_tracker:
|
|
1445
|
+
return raw_text, False
|
|
1446
|
+
|
|
1447
|
+
from docagent_cli.media_utils import (
|
|
1448
|
+
IMAGE_EXTENSIONS,
|
|
1449
|
+
MAX_MEDIA_BYTES,
|
|
1450
|
+
VIDEO_EXTENSIONS,
|
|
1451
|
+
ImageData,
|
|
1452
|
+
get_media_from_path,
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
parts: list[str] = []
|
|
1456
|
+
attached = False
|
|
1457
|
+
for path in paths:
|
|
1458
|
+
media = get_media_from_path(path)
|
|
1459
|
+
if media is not None:
|
|
1460
|
+
kind = "image" if isinstance(media, ImageData) else "video"
|
|
1461
|
+
parts.append(self._image_tracker.add_media(media, kind))
|
|
1462
|
+
attached = True
|
|
1463
|
+
continue
|
|
1464
|
+
|
|
1465
|
+
# Check if it looked like media but failed validation
|
|
1466
|
+
suffix = path.suffix.lower()
|
|
1467
|
+
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
|
|
1468
|
+
label = "Video" if suffix in VIDEO_EXTENSIONS else "Image"
|
|
1469
|
+
try:
|
|
1470
|
+
size = path.stat().st_size
|
|
1471
|
+
if size > MAX_MEDIA_BYTES:
|
|
1472
|
+
msg = (
|
|
1473
|
+
f"{label} too large: {path.name} "
|
|
1474
|
+
f"({size // (1024 * 1024)} MB, max "
|
|
1475
|
+
f"{MAX_MEDIA_BYTES // (1024 * 1024)} MB)"
|
|
1476
|
+
)
|
|
1477
|
+
else:
|
|
1478
|
+
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
1479
|
+
except OSError as exc:
|
|
1480
|
+
logger.debug("Failed to stat media file %s: %s", path, exc)
|
|
1481
|
+
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
1482
|
+
self.app.notify(msg, severity="warning", timeout=5, markup=False)
|
|
1483
|
+
|
|
1484
|
+
# Not a supported media file, keep as path
|
|
1485
|
+
logger.debug("Could not load media from dropped path: %s", path)
|
|
1486
|
+
parts.append(str(path))
|
|
1487
|
+
|
|
1488
|
+
if not attached:
|
|
1489
|
+
return raw_text, False
|
|
1490
|
+
|
|
1491
|
+
separator = "\n" if "\n" in raw_text else " "
|
|
1492
|
+
replacement = separator.join(parts)
|
|
1493
|
+
if separator == " " and add_trailing_space:
|
|
1494
|
+
replacement += " "
|
|
1495
|
+
return replacement, True
|
|
1496
|
+
|
|
1497
|
+
def _replace_submitted_paths_with_images(self, value: str) -> str:
|
|
1498
|
+
"""Replace dropped-path payloads in submitted text with image placeholders.
|
|
1499
|
+
|
|
1500
|
+
Handles both full-path payloads and leading-path-with-suffix payloads
|
|
1501
|
+
(for example, `'<path>' what is this?`). When command mode previously
|
|
1502
|
+
stripped a leading slash, this method also retries with the slash
|
|
1503
|
+
restored before giving up.
|
|
1504
|
+
|
|
1505
|
+
Args:
|
|
1506
|
+
value: Stripped submitted text (without mode prefix).
|
|
1507
|
+
|
|
1508
|
+
Returns:
|
|
1509
|
+
Submitted text with image placeholders when attachment succeeded.
|
|
1510
|
+
"""
|
|
1511
|
+
candidate, parsed = self._parse_dropped_path_payload_with_command_recovery(
|
|
1512
|
+
value, allow_leading_path=True
|
|
1513
|
+
)
|
|
1514
|
+
if parsed is None:
|
|
1515
|
+
return value
|
|
1516
|
+
|
|
1517
|
+
if parsed.token_end is None:
|
|
1518
|
+
replacement, attached = self._build_path_replacement(
|
|
1519
|
+
candidate, parsed.paths, add_trailing_space=False
|
|
1520
|
+
)
|
|
1521
|
+
if attached:
|
|
1522
|
+
return replacement.strip()
|
|
1523
|
+
# Even when full-payload parsing resolves, still retry explicit
|
|
1524
|
+
# leading-token extraction before giving up.
|
|
1525
|
+
candidate, leading_match = (
|
|
1526
|
+
self._extract_leading_dropped_path_with_command_recovery(value)
|
|
1527
|
+
)
|
|
1528
|
+
if leading_match is None:
|
|
1529
|
+
return value
|
|
1530
|
+
leading_path, token_end = leading_match
|
|
1531
|
+
else:
|
|
1532
|
+
leading_path = parsed.paths[0]
|
|
1533
|
+
token_end = parsed.token_end
|
|
1534
|
+
|
|
1535
|
+
replacement, attached = self._build_path_replacement(
|
|
1536
|
+
str(leading_path), [leading_path], add_trailing_space=False
|
|
1537
|
+
)
|
|
1538
|
+
if attached:
|
|
1539
|
+
suffix = candidate[token_end:].lstrip()
|
|
1540
|
+
if suffix:
|
|
1541
|
+
return f"{replacement.strip()} {suffix}".strip()
|
|
1542
|
+
return replacement.strip()
|
|
1543
|
+
return value
|
|
1544
|
+
|
|
1545
|
+
@staticmethod
|
|
1546
|
+
def _history_entry_mode_and_text(entry: str) -> tuple[str, str]:
|
|
1547
|
+
"""Return mode and stripped display text for a history entry.
|
|
1548
|
+
|
|
1549
|
+
Args:
|
|
1550
|
+
entry: Raw entry value read from history storage.
|
|
1551
|
+
|
|
1552
|
+
Returns:
|
|
1553
|
+
Tuple of `(mode, display_text)` where mode-trigger prefixes are
|
|
1554
|
+
removed from `display_text`.
|
|
1555
|
+
"""
|
|
1556
|
+
for prefix, mode in PREFIX_TO_MODE.items():
|
|
1557
|
+
# Small dict; loop is fine. No need to over-engineer right now
|
|
1558
|
+
if entry.startswith(prefix):
|
|
1559
|
+
return mode, entry[len(prefix) :]
|
|
1560
|
+
return "normal", entry
|
|
1561
|
+
|
|
1562
|
+
async def on_key(self, event: events.Key) -> None:
|
|
1563
|
+
"""Handle key events for completion navigation."""
|
|
1564
|
+
if not self._completion_manager or not self._text_area:
|
|
1565
|
+
return
|
|
1566
|
+
|
|
1567
|
+
# Backspace at cursor position 0 (or on empty input) exits the
|
|
1568
|
+
# current mode (e.g. command/shell). When the cursor is at the very
|
|
1569
|
+
# start of the text area, backspace is a no-op for the underlying
|
|
1570
|
+
# widget, so without this guard the user would be stuck in the mode.
|
|
1571
|
+
if (
|
|
1572
|
+
event.key == "backspace"
|
|
1573
|
+
and self.mode != "normal"
|
|
1574
|
+
and self._get_cursor_offset() == 0
|
|
1575
|
+
):
|
|
1576
|
+
self._completion_manager.reset()
|
|
1577
|
+
self.mode = "normal"
|
|
1578
|
+
event.prevent_default()
|
|
1579
|
+
event.stop()
|
|
1580
|
+
return
|
|
1581
|
+
|
|
1582
|
+
text, cursor = self._completion_text_and_cursor()
|
|
1583
|
+
result = self._completion_manager.on_key(event, text, cursor)
|
|
1584
|
+
|
|
1585
|
+
match result:
|
|
1586
|
+
case CompletionResult.HANDLED:
|
|
1587
|
+
event.prevent_default()
|
|
1588
|
+
event.stop()
|
|
1589
|
+
case CompletionResult.SUBMIT:
|
|
1590
|
+
event.prevent_default()
|
|
1591
|
+
event.stop()
|
|
1592
|
+
self._submit_value(self._text_area.text.strip())
|
|
1593
|
+
case CompletionResult.IGNORED if event.key == "enter":
|
|
1594
|
+
# Handle Enter when completion is not active (shell/normal modes)
|
|
1595
|
+
value = self._text_area.text.strip()
|
|
1596
|
+
if value:
|
|
1597
|
+
event.prevent_default()
|
|
1598
|
+
event.stop()
|
|
1599
|
+
self._submit_value(value)
|
|
1600
|
+
|
|
1601
|
+
def _get_cursor_offset(self) -> int:
|
|
1602
|
+
"""Get the cursor offset as a single integer.
|
|
1603
|
+
|
|
1604
|
+
Returns:
|
|
1605
|
+
Cursor position as character offset from start of text.
|
|
1606
|
+
"""
|
|
1607
|
+
if not self._text_area:
|
|
1608
|
+
return 0
|
|
1609
|
+
|
|
1610
|
+
text = self._text_area.text
|
|
1611
|
+
row, col = self._text_area.cursor_location
|
|
1612
|
+
|
|
1613
|
+
if not text:
|
|
1614
|
+
return 0
|
|
1615
|
+
|
|
1616
|
+
lines = text.split("\n")
|
|
1617
|
+
row = max(0, min(row, len(lines) - 1))
|
|
1618
|
+
col = max(0, col)
|
|
1619
|
+
|
|
1620
|
+
offset = sum(len(lines[i]) + 1 for i in range(row))
|
|
1621
|
+
return offset + min(col, len(lines[row]))
|
|
1622
|
+
|
|
1623
|
+
def watch_mode(self, mode: str) -> None:
|
|
1624
|
+
"""Post mode changed message and update prompt indicator."""
|
|
1625
|
+
try:
|
|
1626
|
+
prompt = self.query_one("#prompt", Static)
|
|
1627
|
+
except NoMatches:
|
|
1628
|
+
logger.warning("watch_mode: #prompt widget not found")
|
|
1629
|
+
self.post_message(self.ModeChanged(mode))
|
|
1630
|
+
return
|
|
1631
|
+
self.remove_class("mode-shell", "mode-command")
|
|
1632
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode)
|
|
1633
|
+
if glyph:
|
|
1634
|
+
prompt.update(glyph)
|
|
1635
|
+
self.add_class(f"mode-{mode}")
|
|
1636
|
+
else:
|
|
1637
|
+
if mode != "normal":
|
|
1638
|
+
logger.warning(
|
|
1639
|
+
"No display glyph for mode %r; falling back to '>'",
|
|
1640
|
+
mode,
|
|
1641
|
+
)
|
|
1642
|
+
prompt.update(">")
|
|
1643
|
+
self.post_message(self.ModeChanged(mode))
|
|
1644
|
+
|
|
1645
|
+
def focus_input(self) -> None:
|
|
1646
|
+
"""Focus the input field."""
|
|
1647
|
+
if self._text_area:
|
|
1648
|
+
self._text_area.focus()
|
|
1649
|
+
|
|
1650
|
+
@property
|
|
1651
|
+
def value(self) -> str:
|
|
1652
|
+
"""Get the current input value.
|
|
1653
|
+
|
|
1654
|
+
Returns:
|
|
1655
|
+
Current text in the input field.
|
|
1656
|
+
"""
|
|
1657
|
+
if self._text_area:
|
|
1658
|
+
return self._text_area.text
|
|
1659
|
+
return ""
|
|
1660
|
+
|
|
1661
|
+
@value.setter
|
|
1662
|
+
def value(self, val: str) -> None:
|
|
1663
|
+
"""Set the input value."""
|
|
1664
|
+
if self._text_area:
|
|
1665
|
+
self._text_area.text = val
|
|
1666
|
+
|
|
1667
|
+
@property
|
|
1668
|
+
def input_widget(self) -> ChatTextArea | None:
|
|
1669
|
+
"""Get the underlying TextArea widget.
|
|
1670
|
+
|
|
1671
|
+
Returns:
|
|
1672
|
+
The ChatTextArea widget or None if not mounted.
|
|
1673
|
+
"""
|
|
1674
|
+
return self._text_area
|
|
1675
|
+
|
|
1676
|
+
def set_disabled(self, *, disabled: bool) -> None:
|
|
1677
|
+
"""Enable or disable the input widget."""
|
|
1678
|
+
if self._text_area:
|
|
1679
|
+
self._text_area.disabled = disabled
|
|
1680
|
+
if disabled:
|
|
1681
|
+
self._text_area.blur()
|
|
1682
|
+
if self._completion_manager:
|
|
1683
|
+
self._completion_manager.reset()
|
|
1684
|
+
|
|
1685
|
+
def set_cursor_active(self, *, active: bool) -> None:
|
|
1686
|
+
"""Toggle input focus state (e.g., unfocus while agent is working).
|
|
1687
|
+
|
|
1688
|
+
Args:
|
|
1689
|
+
active: Whether the input should be focused and accepting input.
|
|
1690
|
+
"""
|
|
1691
|
+
if self._text_area:
|
|
1692
|
+
self._text_area.set_app_focus(has_focus=active)
|
|
1693
|
+
|
|
1694
|
+
def exit_mode(self) -> bool:
|
|
1695
|
+
"""Exit the current input mode (command/shell) back to normal.
|
|
1696
|
+
|
|
1697
|
+
Returns:
|
|
1698
|
+
True if mode was non-normal and has been reset.
|
|
1699
|
+
"""
|
|
1700
|
+
if self.mode == "normal":
|
|
1701
|
+
return False
|
|
1702
|
+
self.mode = "normal"
|
|
1703
|
+
if self._completion_manager:
|
|
1704
|
+
self._completion_manager.reset()
|
|
1705
|
+
self.clear_completion_suggestions()
|
|
1706
|
+
return True
|
|
1707
|
+
|
|
1708
|
+
def dismiss_completion(self) -> bool:
|
|
1709
|
+
"""Dismiss completion: clear view and reset controller state.
|
|
1710
|
+
|
|
1711
|
+
Returns:
|
|
1712
|
+
True if completion was active and has been dismissed.
|
|
1713
|
+
"""
|
|
1714
|
+
if not self._current_suggestions:
|
|
1715
|
+
return False
|
|
1716
|
+
if self._completion_manager:
|
|
1717
|
+
self._completion_manager.reset()
|
|
1718
|
+
# Always clear local state so the popup is hidden even if the
|
|
1719
|
+
# manager's active controller was already None (no-op reset).
|
|
1720
|
+
self.clear_completion_suggestions()
|
|
1721
|
+
return True
|
|
1722
|
+
|
|
1723
|
+
# =========================================================================
|
|
1724
|
+
# CompletionView protocol implementation
|
|
1725
|
+
# =========================================================================
|
|
1726
|
+
|
|
1727
|
+
def render_completion_suggestions(
|
|
1728
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
1729
|
+
) -> None:
|
|
1730
|
+
"""Render completion suggestions in the popup."""
|
|
1731
|
+
prev_suggestions = self._current_suggestions
|
|
1732
|
+
self._current_suggestions = suggestions
|
|
1733
|
+
self._current_selected_index = selected_index
|
|
1734
|
+
|
|
1735
|
+
if self._popup:
|
|
1736
|
+
# If only the selection changed (same items), skip full rebuild
|
|
1737
|
+
if suggestions == prev_suggestions:
|
|
1738
|
+
self._popup.update_selection(selected_index)
|
|
1739
|
+
else:
|
|
1740
|
+
self._popup.update_suggestions(suggestions, selected_index)
|
|
1741
|
+
# Tell TextArea that completion is active so it yields navigation keys
|
|
1742
|
+
if self._text_area:
|
|
1743
|
+
self._text_area.set_completion_active(active=bool(suggestions))
|
|
1744
|
+
|
|
1745
|
+
def clear_completion_suggestions(self) -> None:
|
|
1746
|
+
"""Clear/hide the completion popup."""
|
|
1747
|
+
self._current_suggestions = []
|
|
1748
|
+
self._current_selected_index = 0
|
|
1749
|
+
|
|
1750
|
+
if self._popup:
|
|
1751
|
+
self._popup.hide()
|
|
1752
|
+
# Tell TextArea that completion is no longer active
|
|
1753
|
+
if self._text_area:
|
|
1754
|
+
self._text_area.set_completion_active(active=False)
|
|
1755
|
+
|
|
1756
|
+
def on_completion_popup_option_clicked(
|
|
1757
|
+
self, event: CompletionPopup.OptionClicked
|
|
1758
|
+
) -> None:
|
|
1759
|
+
"""Handle click on a completion option."""
|
|
1760
|
+
if not self._current_suggestions or not self._text_area:
|
|
1761
|
+
return
|
|
1762
|
+
|
|
1763
|
+
index = event.index
|
|
1764
|
+
if index < 0 or index >= len(self._current_suggestions):
|
|
1765
|
+
return
|
|
1766
|
+
|
|
1767
|
+
# Get the selected completion
|
|
1768
|
+
label, _ = self._current_suggestions[index]
|
|
1769
|
+
text = self._text_area.text
|
|
1770
|
+
cursor = self._get_cursor_offset()
|
|
1771
|
+
|
|
1772
|
+
# Determine replacement range based on completion type.
|
|
1773
|
+
# Slash completions use completion-space coordinates and are translated
|
|
1774
|
+
# through the completion view adapter.
|
|
1775
|
+
if label.startswith("/"):
|
|
1776
|
+
if self._completion_view is None:
|
|
1777
|
+
logger.warning(
|
|
1778
|
+
"Slash completion clicked but _completion_view is not "
|
|
1779
|
+
"initialized; this indicates a widget lifecycle issue."
|
|
1780
|
+
)
|
|
1781
|
+
return
|
|
1782
|
+
_, virtual_cursor = self._completion_text_and_cursor()
|
|
1783
|
+
self._completion_view.replace_completion_range(0, virtual_cursor, label)
|
|
1784
|
+
elif label.startswith("@"):
|
|
1785
|
+
# File mention: replace from @ to cursor
|
|
1786
|
+
at_index = text[:cursor].rfind("@")
|
|
1787
|
+
if at_index >= 0:
|
|
1788
|
+
self.replace_completion_range(at_index, cursor, label)
|
|
1789
|
+
|
|
1790
|
+
# Reset completion state
|
|
1791
|
+
if self._completion_manager:
|
|
1792
|
+
self._completion_manager.reset()
|
|
1793
|
+
|
|
1794
|
+
# Re-focus the text input after click
|
|
1795
|
+
self._text_area.focus()
|
|
1796
|
+
|
|
1797
|
+
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
1798
|
+
"""Replace text in the input field."""
|
|
1799
|
+
if not self._text_area:
|
|
1800
|
+
return
|
|
1801
|
+
|
|
1802
|
+
text = self._text_area.text
|
|
1803
|
+
|
|
1804
|
+
start = max(0, min(start, len(text)))
|
|
1805
|
+
end = max(start, min(end, len(text)))
|
|
1806
|
+
|
|
1807
|
+
prefix = text[:start]
|
|
1808
|
+
suffix = text[end:]
|
|
1809
|
+
|
|
1810
|
+
# Add space after completion unless it's a directory path
|
|
1811
|
+
if replacement.endswith("/"):
|
|
1812
|
+
insertion = replacement
|
|
1813
|
+
else:
|
|
1814
|
+
insertion = replacement + " " if not suffix.startswith(" ") else replacement
|
|
1815
|
+
|
|
1816
|
+
new_text = f"{prefix}{insertion}{suffix}"
|
|
1817
|
+
self._text_area.text = new_text
|
|
1818
|
+
|
|
1819
|
+
# Calculate new cursor position and move cursor
|
|
1820
|
+
new_offset = start + len(insertion)
|
|
1821
|
+
lines = new_text.split("\n")
|
|
1822
|
+
remaining = new_offset
|
|
1823
|
+
for row, line in enumerate(lines):
|
|
1824
|
+
if remaining <= len(line):
|
|
1825
|
+
self._text_area.move_cursor((row, remaining))
|
|
1826
|
+
break
|
|
1827
|
+
remaining -= len(line) + 1
|