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,1751 @@
|
|
|
1
|
+
"""Message widgets for docagent-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from time import time
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from textual import on
|
|
15
|
+
from textual.containers import Vertical
|
|
16
|
+
from textual.content import Content
|
|
17
|
+
from textual.events import Click
|
|
18
|
+
from textual.reactive import var
|
|
19
|
+
from textual.widgets import Static
|
|
20
|
+
|
|
21
|
+
from docagent_cli import theme
|
|
22
|
+
from docagent_cli.config import (
|
|
23
|
+
MODE_DISPLAY_GLYPHS,
|
|
24
|
+
PREFIX_TO_MODE,
|
|
25
|
+
get_glyphs,
|
|
26
|
+
is_ascii_mode,
|
|
27
|
+
)
|
|
28
|
+
from docagent_cli.formatting import format_duration
|
|
29
|
+
from docagent_cli.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN
|
|
30
|
+
from docagent_cli.tool_display import format_tool_display
|
|
31
|
+
from docagent_cli.widgets._links import open_style_link
|
|
32
|
+
from docagent_cli.widgets.diff import compose_diff_lines
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from textual.app import ComposeResult
|
|
36
|
+
from textual.timer import Timer
|
|
37
|
+
from textual.widgets import Markdown
|
|
38
|
+
from textual.widgets._markdown import MarkdownStream
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _show_timestamp_toast(widget: Static | Vertical) -> None:
|
|
44
|
+
"""Show a toast with the message's creation timestamp.
|
|
45
|
+
|
|
46
|
+
No-ops silently if the widget is not mounted or has no associated message
|
|
47
|
+
data in the store.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
widget: The message widget whose timestamp to display.
|
|
51
|
+
"""
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
app = widget.app
|
|
56
|
+
except Exception: # noqa: BLE001 # Textual raises when widget has no app
|
|
57
|
+
return
|
|
58
|
+
if not widget.id:
|
|
59
|
+
return
|
|
60
|
+
store = app._message_store # type: ignore[attr-defined]
|
|
61
|
+
data = store.get_message(widget.id)
|
|
62
|
+
if not data:
|
|
63
|
+
return
|
|
64
|
+
dt = datetime.fromtimestamp(data.timestamp, tz=UTC).astimezone()
|
|
65
|
+
label = f"{dt:%b} {dt.day}, {dt.hour % 12 or 12}:{dt:%M:%S} {dt:%p}"
|
|
66
|
+
app.notify(label, timeout=3)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _TimestampClickMixin:
|
|
70
|
+
"""Mixin that shows a timestamp toast on click.
|
|
71
|
+
|
|
72
|
+
Add to any message widget that should display its creation timestamp when
|
|
73
|
+
clicked. Widgets needing additional click behavior (e.g. `ToolCallMessage`,
|
|
74
|
+
`AppMessage`) should override `on_click` and call `_show_timestamp_toast`
|
|
75
|
+
directly instead.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def on_click(self, event: Click) -> None: # noqa: ARG002 # Textual event handler
|
|
79
|
+
"""Show timestamp toast on click."""
|
|
80
|
+
_show_timestamp_toast(self) # type: ignore[arg-type]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _mode_color(mode: str | None, widget_or_app: object | None = None) -> str:
|
|
84
|
+
"""Return the hex color string for a mode, falling back to primary.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
mode: Mode name (e.g. `'shell'`, `'command'`) or `None`.
|
|
88
|
+
widget_or_app: Textual widget or `App` for theme-aware lookup.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Color string from the active theme's `ThemeColors`.
|
|
92
|
+
"""
|
|
93
|
+
colors = theme.get_theme_colors(widget_or_app)
|
|
94
|
+
if not mode:
|
|
95
|
+
return colors.primary
|
|
96
|
+
if mode == "shell":
|
|
97
|
+
return colors.mode_bash
|
|
98
|
+
if mode == "command":
|
|
99
|
+
return colors.mode_command
|
|
100
|
+
logger.warning("Missing color for mode '%s'; falling back to primary.", mode)
|
|
101
|
+
return colors.primary
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class FormattedOutput:
|
|
106
|
+
"""Result of formatting tool output for display."""
|
|
107
|
+
|
|
108
|
+
content: Content
|
|
109
|
+
"""Styled `Content` for the formatted output."""
|
|
110
|
+
|
|
111
|
+
truncation: str | None = None
|
|
112
|
+
"""Description of truncated content (e.g., "10 more lines"), or None if no
|
|
113
|
+
truncation occurred."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Maximum number of tool arguments to display inline
|
|
117
|
+
_MAX_INLINE_ARGS = 3
|
|
118
|
+
|
|
119
|
+
# Truncation limits for display
|
|
120
|
+
_MAX_TODO_CONTENT_LEN = 70
|
|
121
|
+
_MAX_WEB_CONTENT_LEN = 100
|
|
122
|
+
|
|
123
|
+
# Tools that have their key info already in the header (no need for args line)
|
|
124
|
+
_TOOLS_WITH_HEADER_INFO: set[str] = {
|
|
125
|
+
# Filesystem tools
|
|
126
|
+
"ls",
|
|
127
|
+
"read_file",
|
|
128
|
+
"write_file",
|
|
129
|
+
"edit_file",
|
|
130
|
+
"glob",
|
|
131
|
+
"grep",
|
|
132
|
+
"execute", # sandbox shell
|
|
133
|
+
# Shell tools
|
|
134
|
+
"shell", # local shell
|
|
135
|
+
# Web tools
|
|
136
|
+
"web_search",
|
|
137
|
+
"fetch_url",
|
|
138
|
+
# Agent tools
|
|
139
|
+
"task",
|
|
140
|
+
"write_todos",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UserMessage(_TimestampClickMixin, Static):
|
|
145
|
+
"""Widget displaying a user message."""
|
|
146
|
+
|
|
147
|
+
DEFAULT_CSS = """
|
|
148
|
+
UserMessage {
|
|
149
|
+
height: auto;
|
|
150
|
+
padding: 0 1;
|
|
151
|
+
margin: 0 0 1 0;
|
|
152
|
+
background: transparent;
|
|
153
|
+
border-left: wide $primary;
|
|
154
|
+
}
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, content: str, **kwargs: Any) -> None:
|
|
158
|
+
"""Initialize a user message.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
content: The message content
|
|
162
|
+
**kwargs: Additional arguments passed to parent
|
|
163
|
+
"""
|
|
164
|
+
super().__init__(**kwargs)
|
|
165
|
+
self._content = content
|
|
166
|
+
|
|
167
|
+
def on_mount(self) -> None:
|
|
168
|
+
"""Add CSS classes for mode-specific border and ASCII border type."""
|
|
169
|
+
mode = PREFIX_TO_MODE.get(self._content[:1]) if self._content else None
|
|
170
|
+
if mode:
|
|
171
|
+
self.add_class(f"-mode-{mode}")
|
|
172
|
+
if is_ascii_mode():
|
|
173
|
+
self.add_class("-ascii")
|
|
174
|
+
|
|
175
|
+
def render(self) -> Content:
|
|
176
|
+
"""Render the styled user message.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Styled Content with mode prefix and highlighted mentions.
|
|
180
|
+
"""
|
|
181
|
+
colors = theme.get_theme_colors(self)
|
|
182
|
+
parts: list[str | tuple[str, str]] = []
|
|
183
|
+
content = self._content
|
|
184
|
+
|
|
185
|
+
# Use mode-specific prefix indicator when content starts with a
|
|
186
|
+
# mode trigger character (e.g. "!" for shell, "/" for commands).
|
|
187
|
+
# The display glyph may differ from the trigger (e.g. "$" for shell).
|
|
188
|
+
mode = PREFIX_TO_MODE.get(content[:1]) if content else None
|
|
189
|
+
if mode:
|
|
190
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode, content[0])
|
|
191
|
+
parts.append((f"{glyph} ", f"bold {_mode_color(mode, self)}"))
|
|
192
|
+
content = content[1:]
|
|
193
|
+
else:
|
|
194
|
+
parts.append(("> ", f"bold {colors.primary}"))
|
|
195
|
+
|
|
196
|
+
# Highlight @mentions and /commands in the content
|
|
197
|
+
last_end = 0
|
|
198
|
+
for match in INPUT_HIGHLIGHT_PATTERN.finditer(content):
|
|
199
|
+
start, end = match.span()
|
|
200
|
+
token = match.group()
|
|
201
|
+
|
|
202
|
+
# Skip @mentions that look like email addresses
|
|
203
|
+
if token.startswith("@") and start > 0:
|
|
204
|
+
char_before = content[start - 1]
|
|
205
|
+
if EMAIL_PREFIX_PATTERN.match(char_before):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Add text before the match (unstyled)
|
|
209
|
+
if start > last_end:
|
|
210
|
+
parts.append(content[last_end:start])
|
|
211
|
+
|
|
212
|
+
# The regex only matches tokens starting with / or @
|
|
213
|
+
if token.startswith("/") and start == 0:
|
|
214
|
+
# /command at start
|
|
215
|
+
parts.append((token, f"bold {colors.warning}"))
|
|
216
|
+
elif token.startswith("@"):
|
|
217
|
+
# @file mention
|
|
218
|
+
parts.append((token, f"bold {colors.primary}"))
|
|
219
|
+
last_end = end
|
|
220
|
+
|
|
221
|
+
# Add remaining text after last match
|
|
222
|
+
if last_end < len(content):
|
|
223
|
+
parts.append(content[last_end:])
|
|
224
|
+
|
|
225
|
+
return Content.assemble(*parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class QueuedUserMessage(Static):
|
|
229
|
+
"""Widget displaying a queued (pending) user message in grey.
|
|
230
|
+
|
|
231
|
+
This is an ephemeral widget that gets removed when the message is dequeued.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
DEFAULT_CSS = """
|
|
235
|
+
QueuedUserMessage {
|
|
236
|
+
height: auto;
|
|
237
|
+
padding: 0 1;
|
|
238
|
+
margin: 0 0 1 0;
|
|
239
|
+
background: transparent;
|
|
240
|
+
border-left: wide $panel;
|
|
241
|
+
opacity: 0.6;
|
|
242
|
+
}
|
|
243
|
+
"""
|
|
244
|
+
"""Dimmed border + reduced opacity to distinguish queued messages from sent ones."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, content: str, **kwargs: Any) -> None:
|
|
247
|
+
"""Initialize a queued user message.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
content: The message content
|
|
251
|
+
**kwargs: Additional arguments passed to parent
|
|
252
|
+
"""
|
|
253
|
+
super().__init__(**kwargs)
|
|
254
|
+
self._content = content
|
|
255
|
+
|
|
256
|
+
def on_mount(self) -> None:
|
|
257
|
+
"""Add ASCII border class when in ASCII mode."""
|
|
258
|
+
if is_ascii_mode():
|
|
259
|
+
self.add_class("-ascii")
|
|
260
|
+
|
|
261
|
+
def render(self) -> Content:
|
|
262
|
+
"""Render the queued user message (greyed out).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Styled Content with dimmed prefix and body.
|
|
266
|
+
"""
|
|
267
|
+
colors = theme.get_theme_colors(self)
|
|
268
|
+
content = self._content
|
|
269
|
+
mode = PREFIX_TO_MODE.get(content[:1]) if content else None
|
|
270
|
+
if mode:
|
|
271
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode, content[0])
|
|
272
|
+
prefix = (f"{glyph} ", f"bold {colors.muted}")
|
|
273
|
+
content = content[1:]
|
|
274
|
+
else:
|
|
275
|
+
prefix = ("> ", f"bold {colors.muted}")
|
|
276
|
+
return Content.assemble(prefix, (content, colors.muted))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _strip_frontmatter(text: str) -> str:
|
|
280
|
+
"""Remove YAML frontmatter delimited by `---` markers.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
text: Raw `SKILL.md` content.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Body text with frontmatter removed and leading whitespace stripped.
|
|
287
|
+
"""
|
|
288
|
+
stripped = text.lstrip()
|
|
289
|
+
if not stripped.startswith("---"):
|
|
290
|
+
return text
|
|
291
|
+
# Find closing --- (skip the opening line)
|
|
292
|
+
end = stripped.find("\n---", 3)
|
|
293
|
+
if end == -1:
|
|
294
|
+
return text
|
|
295
|
+
# Skip past the closing --- and its trailing newline
|
|
296
|
+
after = end + 4 # len("\n---")
|
|
297
|
+
return stripped[after:].lstrip("\n")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _SkillToggle(Static):
|
|
301
|
+
"""Clickable header/hint area for toggling skill body expansion.
|
|
302
|
+
|
|
303
|
+
Referenced by name in `SkillMessage._on_toggle_click`'s `@on(Click)`
|
|
304
|
+
CSS selector — rename with care.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class SkillMessage(Vertical):
|
|
309
|
+
"""Widget displaying a skill invocation with collapsible body.
|
|
310
|
+
|
|
311
|
+
Shows skill name, source badge, description, and user args as a compact
|
|
312
|
+
header. The full SKILL.md body (frontmatter stripped) is hidden behind a
|
|
313
|
+
preview/expand toggle (click or Ctrl+O). The expanded view renders
|
|
314
|
+
markdown via Rich's `Markdown` inside a single `Static` widget.
|
|
315
|
+
|
|
316
|
+
Visibility is driven by a CSS class (`-expanded`) toggled via a Textual
|
|
317
|
+
reactive `var`. Click handlers are scoped to the header and hint widgets
|
|
318
|
+
(`_SkillToggle`) so clicks on the rendered markdown body do not trigger
|
|
319
|
+
expansion toggles (preserving text selection, for instance).
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
DEFAULT_CSS = """
|
|
323
|
+
SkillMessage {
|
|
324
|
+
height: auto;
|
|
325
|
+
padding: 0 1;
|
|
326
|
+
margin: 0 0 1 0;
|
|
327
|
+
background: transparent;
|
|
328
|
+
border-left: wide $skill;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
SkillMessage .skill-header {
|
|
332
|
+
height: auto;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
SkillMessage .skill-description {
|
|
336
|
+
color: $text-muted;
|
|
337
|
+
margin-left: 3;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
SkillMessage .skill-args {
|
|
341
|
+
margin-left: 3;
|
|
342
|
+
margin-top: 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
SkillMessage #skill-md {
|
|
346
|
+
margin-left: 3;
|
|
347
|
+
margin-top: 0;
|
|
348
|
+
padding: 0;
|
|
349
|
+
display: none;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
SkillMessage .skill-hint {
|
|
353
|
+
margin-left: 3;
|
|
354
|
+
color: $text-muted;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
SkillMessage.-expanded #skill-md {
|
|
358
|
+
display: block;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
SkillMessage:hover {
|
|
362
|
+
border-left: wide $skill-hover;
|
|
363
|
+
}
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
_PREVIEW_LINES = 4
|
|
367
|
+
_PREVIEW_CHARS = 300
|
|
368
|
+
|
|
369
|
+
_expanded: var[bool] = var(False, toggle_class="-expanded")
|
|
370
|
+
|
|
371
|
+
def __init__(
|
|
372
|
+
self,
|
|
373
|
+
skill_name: str,
|
|
374
|
+
description: str = "",
|
|
375
|
+
source: str = "",
|
|
376
|
+
body: str = "",
|
|
377
|
+
args: str = "",
|
|
378
|
+
**kwargs: Any,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Initialize a skill message.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
skill_name: Skill identifier.
|
|
384
|
+
description: Short description of the skill.
|
|
385
|
+
source: Origin label (e.g., `'built-in'`, `'user'`).
|
|
386
|
+
body: Full SKILL.md content (frontmatter included).
|
|
387
|
+
args: User-provided arguments.
|
|
388
|
+
**kwargs: Additional arguments passed to parent.
|
|
389
|
+
"""
|
|
390
|
+
super().__init__(**kwargs)
|
|
391
|
+
self._skill_name = skill_name
|
|
392
|
+
self._description = description
|
|
393
|
+
self._source = source
|
|
394
|
+
self._body = body
|
|
395
|
+
self._stripped_body = _strip_frontmatter(body)
|
|
396
|
+
self._args = args
|
|
397
|
+
self._md_widget: Static | None = None
|
|
398
|
+
self._hint_widget: _SkillToggle | None = None
|
|
399
|
+
self._deferred_expanded: bool = False
|
|
400
|
+
self._md_rendered: bool = False
|
|
401
|
+
|
|
402
|
+
def compose(self) -> ComposeResult:
|
|
403
|
+
"""Compose the skill message layout.
|
|
404
|
+
|
|
405
|
+
Yields:
|
|
406
|
+
Widgets for header, description, args, and collapsible body.
|
|
407
|
+
"""
|
|
408
|
+
colors = theme.get_theme_colors()
|
|
409
|
+
source_tag = f" [{self._source}]" if self._source else ""
|
|
410
|
+
yield _SkillToggle(
|
|
411
|
+
Content.styled(
|
|
412
|
+
f"/ skill:{self._skill_name}{source_tag}",
|
|
413
|
+
f"bold {colors.skill}",
|
|
414
|
+
),
|
|
415
|
+
classes="skill-header",
|
|
416
|
+
)
|
|
417
|
+
if self._description:
|
|
418
|
+
yield _SkillToggle(
|
|
419
|
+
Content.styled(self._description, "dim"),
|
|
420
|
+
classes="skill-description",
|
|
421
|
+
)
|
|
422
|
+
if self._args:
|
|
423
|
+
yield Static(
|
|
424
|
+
Content.assemble(
|
|
425
|
+
("User request: ", "bold"),
|
|
426
|
+
self._args,
|
|
427
|
+
),
|
|
428
|
+
classes="skill-args",
|
|
429
|
+
)
|
|
430
|
+
yield Static("", id="skill-md")
|
|
431
|
+
yield _SkillToggle("", classes="skill-hint", id="skill-hint")
|
|
432
|
+
|
|
433
|
+
def on_mount(self) -> None:
|
|
434
|
+
"""Cache widget references, render initial state.
|
|
435
|
+
|
|
436
|
+
Ordering matters: widget refs must be cached before `_prepare_body`
|
|
437
|
+
or `_deferred_expanded` assignment, because either may set
|
|
438
|
+
`_expanded` which fires `watch__expanded` synchronously.
|
|
439
|
+
"""
|
|
440
|
+
if is_ascii_mode():
|
|
441
|
+
colors = theme.get_theme_colors(self)
|
|
442
|
+
self.styles.border_left = ("ascii", colors.skill)
|
|
443
|
+
|
|
444
|
+
self._md_widget = self.query_one("#skill-md", Static)
|
|
445
|
+
self._hint_widget = self.query_one("#skill-hint", _SkillToggle)
|
|
446
|
+
|
|
447
|
+
body = self._stripped_body.strip()
|
|
448
|
+
if body:
|
|
449
|
+
self._prepare_body(body)
|
|
450
|
+
|
|
451
|
+
if self._deferred_expanded:
|
|
452
|
+
self._expanded = self._deferred_expanded
|
|
453
|
+
self._deferred_expanded = False
|
|
454
|
+
|
|
455
|
+
def _prepare_body(self, body: str) -> None:
|
|
456
|
+
"""Set initial hint text. Full body render is deferred to first expand.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
body: Stripped markdown body text.
|
|
460
|
+
"""
|
|
461
|
+
lines = body.split("\n")
|
|
462
|
+
total_lines = len(lines)
|
|
463
|
+
needs_truncation = (
|
|
464
|
+
total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if needs_truncation:
|
|
468
|
+
remaining = total_lines - self._PREVIEW_LINES
|
|
469
|
+
ellipsis = get_glyphs().ellipsis
|
|
470
|
+
if self._hint_widget:
|
|
471
|
+
self._hint_widget.update(
|
|
472
|
+
Content.styled(
|
|
473
|
+
f"{ellipsis} {remaining} more lines"
|
|
474
|
+
" — click or Ctrl+O to expand",
|
|
475
|
+
"dim",
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
else:
|
|
479
|
+
# Short body — show fully rendered, no preview needed.
|
|
480
|
+
self._ensure_md_rendered(body)
|
|
481
|
+
self._expanded = True
|
|
482
|
+
|
|
483
|
+
def _ensure_md_rendered(self, body: str) -> None:
|
|
484
|
+
"""Render markdown into the Static widget on first call, then no-op.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
body: Stripped markdown body text.
|
|
488
|
+
"""
|
|
489
|
+
if self._md_rendered or not self._md_widget:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
493
|
+
|
|
494
|
+
self._md_widget.update(RichMarkdown(body))
|
|
495
|
+
except Exception:
|
|
496
|
+
logger.warning(
|
|
497
|
+
"Failed to render skill body as markdown; falling back to plain text",
|
|
498
|
+
exc_info=True,
|
|
499
|
+
)
|
|
500
|
+
self._md_widget.update(body)
|
|
501
|
+
self._md_rendered = True
|
|
502
|
+
|
|
503
|
+
def toggle_body(self) -> None:
|
|
504
|
+
"""Toggle between preview and full body display."""
|
|
505
|
+
if not self._stripped_body.strip():
|
|
506
|
+
return
|
|
507
|
+
self._expanded = not self._expanded
|
|
508
|
+
|
|
509
|
+
def watch__expanded(self, expanded: bool) -> None:
|
|
510
|
+
"""Lazy-render markdown on first expand; update hint text."""
|
|
511
|
+
body = self._stripped_body.strip()
|
|
512
|
+
if not body:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
if expanded:
|
|
516
|
+
self._ensure_md_rendered(body)
|
|
517
|
+
|
|
518
|
+
if not self._hint_widget:
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
lines = body.split("\n")
|
|
522
|
+
total_lines = len(lines)
|
|
523
|
+
needs_truncation = (
|
|
524
|
+
total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if not needs_truncation:
|
|
528
|
+
# Short body — always fully visible, no hint needed.
|
|
529
|
+
self._hint_widget.display = False
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
if expanded:
|
|
533
|
+
self._hint_widget.update(
|
|
534
|
+
Content.styled("click or Ctrl+O to collapse", "dim italic")
|
|
535
|
+
)
|
|
536
|
+
else:
|
|
537
|
+
remaining = total_lines - self._PREVIEW_LINES
|
|
538
|
+
ellipsis = get_glyphs().ellipsis
|
|
539
|
+
self._hint_widget.update(
|
|
540
|
+
Content.styled(
|
|
541
|
+
f"{ellipsis} {remaining} more lines — click or Ctrl+O to expand",
|
|
542
|
+
"dim",
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
@on(Click, "_SkillToggle")
|
|
547
|
+
def _on_toggle_click(self, event: Click) -> None:
|
|
548
|
+
"""Toggle expansion when header or hint is clicked."""
|
|
549
|
+
event.stop()
|
|
550
|
+
if self._stripped_body.strip():
|
|
551
|
+
self.toggle_body()
|
|
552
|
+
else:
|
|
553
|
+
_show_timestamp_toast(self)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class AssistantMessage(_TimestampClickMixin, Vertical):
|
|
557
|
+
"""Widget displaying an assistant message with markdown support.
|
|
558
|
+
|
|
559
|
+
Uses MarkdownStream for smoother streaming instead of re-rendering
|
|
560
|
+
the full content on each update.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
DEFAULT_CSS = """
|
|
564
|
+
AssistantMessage {
|
|
565
|
+
height: auto;
|
|
566
|
+
padding: 0 1;
|
|
567
|
+
margin: 0 0 1 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
AssistantMessage Markdown {
|
|
571
|
+
padding: 0;
|
|
572
|
+
margin: 0;
|
|
573
|
+
}
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
def __init__(self, content: str = "", **kwargs: Any) -> None:
|
|
577
|
+
"""Initialize an assistant message.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
content: Initial markdown content
|
|
581
|
+
**kwargs: Additional arguments passed to parent
|
|
582
|
+
"""
|
|
583
|
+
super().__init__(**kwargs)
|
|
584
|
+
self._content = content
|
|
585
|
+
self._markdown: Markdown | None = None
|
|
586
|
+
self._stream: MarkdownStream | None = None
|
|
587
|
+
|
|
588
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
|
|
589
|
+
"""Compose the assistant message layout.
|
|
590
|
+
|
|
591
|
+
Yields:
|
|
592
|
+
Markdown widget for rendering assistant content.
|
|
593
|
+
"""
|
|
594
|
+
from textual.widgets import Markdown
|
|
595
|
+
|
|
596
|
+
yield Markdown("", id="assistant-content")
|
|
597
|
+
|
|
598
|
+
def on_mount(self) -> None:
|
|
599
|
+
"""Store reference to markdown widget."""
|
|
600
|
+
from textual.widgets import Markdown
|
|
601
|
+
|
|
602
|
+
self._markdown = self.query_one("#assistant-content", Markdown)
|
|
603
|
+
|
|
604
|
+
def _get_markdown(self) -> Markdown:
|
|
605
|
+
"""Get the markdown widget, querying if not cached.
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
The Markdown widget for this message.
|
|
609
|
+
"""
|
|
610
|
+
if self._markdown is None:
|
|
611
|
+
from textual.widgets import Markdown
|
|
612
|
+
|
|
613
|
+
self._markdown = self.query_one("#assistant-content", Markdown)
|
|
614
|
+
return self._markdown
|
|
615
|
+
|
|
616
|
+
def _ensure_stream(self) -> MarkdownStream:
|
|
617
|
+
"""Ensure the markdown stream is initialized.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
The MarkdownStream instance for streaming content.
|
|
621
|
+
"""
|
|
622
|
+
if self._stream is None:
|
|
623
|
+
from textual.widgets import Markdown
|
|
624
|
+
|
|
625
|
+
self._stream = Markdown.get_stream(self._get_markdown())
|
|
626
|
+
return self._stream
|
|
627
|
+
|
|
628
|
+
async def append_content(self, text: str) -> None:
|
|
629
|
+
"""Append content to the message (for streaming).
|
|
630
|
+
|
|
631
|
+
Uses MarkdownStream for smoother rendering instead of re-rendering
|
|
632
|
+
the full content on each chunk.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
text: Text to append
|
|
636
|
+
"""
|
|
637
|
+
if not text:
|
|
638
|
+
return
|
|
639
|
+
self._content += text
|
|
640
|
+
stream = self._ensure_stream()
|
|
641
|
+
await stream.write(text)
|
|
642
|
+
|
|
643
|
+
async def write_initial_content(self) -> None:
|
|
644
|
+
"""Write initial content if provided at construction time."""
|
|
645
|
+
if self._content:
|
|
646
|
+
stream = self._ensure_stream()
|
|
647
|
+
await stream.write(self._content)
|
|
648
|
+
|
|
649
|
+
async def stop_stream(self) -> None:
|
|
650
|
+
"""Stop the streaming and finalize the content."""
|
|
651
|
+
if self._stream is not None:
|
|
652
|
+
await self._stream.stop()
|
|
653
|
+
self._stream = None
|
|
654
|
+
|
|
655
|
+
async def set_content(self, content: str) -> None:
|
|
656
|
+
"""Set the full message content.
|
|
657
|
+
|
|
658
|
+
This stops any active stream and sets content directly.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
content: The markdown content to display
|
|
662
|
+
"""
|
|
663
|
+
await self.stop_stream()
|
|
664
|
+
self._content = content
|
|
665
|
+
if self._markdown:
|
|
666
|
+
await self._markdown.update(content)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class ToolCallMessage(Vertical):
|
|
670
|
+
"""Widget displaying a tool call with collapsible output.
|
|
671
|
+
|
|
672
|
+
Tool outputs are shown as a 3-line preview by default.
|
|
673
|
+
Press Ctrl+O to expand/collapse the full output.
|
|
674
|
+
Shows an animated "Running..." indicator while the tool is executing.
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
DEFAULT_CSS = """
|
|
678
|
+
ToolCallMessage {
|
|
679
|
+
height: auto;
|
|
680
|
+
padding: 0 1;
|
|
681
|
+
margin: 0 0 1 0;
|
|
682
|
+
background: transparent;
|
|
683
|
+
border-left: wide $tool;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
ToolCallMessage .tool-header {
|
|
687
|
+
height: auto;
|
|
688
|
+
color: $tool;
|
|
689
|
+
text-style: bold;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
ToolCallMessage .tool-task-desc {
|
|
693
|
+
color: $text-muted;
|
|
694
|
+
margin-left: 3;
|
|
695
|
+
text-style: italic;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
ToolCallMessage .tool-args {
|
|
699
|
+
color: $text-muted;
|
|
700
|
+
margin-left: 3;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
ToolCallMessage .tool-status {
|
|
704
|
+
margin-left: 3;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
ToolCallMessage .tool-status.pending {
|
|
708
|
+
color: $warning;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
ToolCallMessage .tool-status.success {
|
|
712
|
+
color: $success;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
ToolCallMessage .tool-status.error {
|
|
716
|
+
color: $error;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
ToolCallMessage .tool-status.rejected {
|
|
720
|
+
color: $warning;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
ToolCallMessage .tool-output {
|
|
724
|
+
margin-left: 0;
|
|
725
|
+
margin-top: 0;
|
|
726
|
+
padding: 0;
|
|
727
|
+
height: auto;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
ToolCallMessage .tool-output-preview {
|
|
731
|
+
margin-left: 0;
|
|
732
|
+
margin-top: 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
ToolCallMessage .tool-output-hint {
|
|
736
|
+
margin-left: 0;
|
|
737
|
+
color: $text-muted;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
ToolCallMessage:hover {
|
|
741
|
+
border-left: wide $tool-hover;
|
|
742
|
+
}
|
|
743
|
+
"""
|
|
744
|
+
"""Left border tracks tool lifecycle; hover brightens for interactivity."""
|
|
745
|
+
|
|
746
|
+
# Max lines/chars to show in preview mode
|
|
747
|
+
_PREVIEW_LINES = 6
|
|
748
|
+
_PREVIEW_CHARS = 400
|
|
749
|
+
|
|
750
|
+
def __init__(
|
|
751
|
+
self,
|
|
752
|
+
tool_name: str,
|
|
753
|
+
args: dict[str, Any] | None = None,
|
|
754
|
+
**kwargs: Any,
|
|
755
|
+
) -> None:
|
|
756
|
+
"""Initialize a tool call message.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
tool_name: Name of the tool being called
|
|
760
|
+
args: Tool arguments (optional)
|
|
761
|
+
**kwargs: Additional arguments passed to parent
|
|
762
|
+
"""
|
|
763
|
+
super().__init__(**kwargs)
|
|
764
|
+
self._tool_name = tool_name
|
|
765
|
+
self._args = args or {}
|
|
766
|
+
self._status = "pending" # Waiting for approval or auto-approve
|
|
767
|
+
self._output: str = ""
|
|
768
|
+
self._expanded: bool = False
|
|
769
|
+
# Widget references (set in on_mount)
|
|
770
|
+
self._status_widget: Static | None = None
|
|
771
|
+
self._preview_widget: Static | None = None
|
|
772
|
+
self._hint_widget: Static | None = None
|
|
773
|
+
self._full_widget: Static | None = None
|
|
774
|
+
# Animation state
|
|
775
|
+
self._spinner_position = 0
|
|
776
|
+
self._start_time: float | None = None
|
|
777
|
+
self._animation_timer: Timer | None = None
|
|
778
|
+
# Deferred state for hydration (set by MessageData.to_widget)
|
|
779
|
+
self._deferred_status: str | None = None
|
|
780
|
+
self._deferred_output: str | None = None
|
|
781
|
+
self._deferred_expanded: bool = False
|
|
782
|
+
|
|
783
|
+
def compose(self) -> ComposeResult:
|
|
784
|
+
"""Compose the tool call message layout.
|
|
785
|
+
|
|
786
|
+
Yields:
|
|
787
|
+
Widgets for header, arguments, status, and output display.
|
|
788
|
+
"""
|
|
789
|
+
tool_label = format_tool_display(self._tool_name, self._args)
|
|
790
|
+
yield Static(tool_label, markup=False, classes="tool-header")
|
|
791
|
+
# Task: dedicated description line (dim, truncated)
|
|
792
|
+
if self._tool_name == "task":
|
|
793
|
+
desc = self._args.get("description", "")
|
|
794
|
+
if desc:
|
|
795
|
+
max_len = 120
|
|
796
|
+
suffix = "..." if len(desc) > max_len else ""
|
|
797
|
+
truncated = desc[:max_len].rstrip() + suffix
|
|
798
|
+
yield Static(
|
|
799
|
+
Content.styled(truncated, "dim"),
|
|
800
|
+
classes="tool-task-desc",
|
|
801
|
+
)
|
|
802
|
+
# Only show args for tools where header doesn't capture the key info
|
|
803
|
+
elif self._tool_name not in _TOOLS_WITH_HEADER_INFO:
|
|
804
|
+
args = self._filtered_args()
|
|
805
|
+
if args:
|
|
806
|
+
args_str = ", ".join(
|
|
807
|
+
f"{k}={v!r}" for k, v in list(args.items())[:_MAX_INLINE_ARGS]
|
|
808
|
+
)
|
|
809
|
+
if len(args) > _MAX_INLINE_ARGS:
|
|
810
|
+
args_str += ", ..."
|
|
811
|
+
yield Static(
|
|
812
|
+
Content.from_markup("[dim]($args)[/dim]", args=args_str),
|
|
813
|
+
classes="tool-args",
|
|
814
|
+
)
|
|
815
|
+
# Status - shows running animation while pending, then final status
|
|
816
|
+
yield Static("", classes="tool-status", id="status")
|
|
817
|
+
# Output area - hidden initially, shown when output is set
|
|
818
|
+
yield Static("", classes="tool-output-preview", id="output-preview")
|
|
819
|
+
yield Static("", classes="tool-output", id="output-full")
|
|
820
|
+
yield Static("", classes="tool-output-hint", id="output-hint")
|
|
821
|
+
|
|
822
|
+
def on_mount(self) -> None:
|
|
823
|
+
"""Cache widget references and hide all status/output areas initially."""
|
|
824
|
+
if is_ascii_mode():
|
|
825
|
+
self.add_class("-ascii")
|
|
826
|
+
|
|
827
|
+
self._status_widget = self.query_one("#status", Static)
|
|
828
|
+
self._preview_widget = self.query_one("#output-preview", Static)
|
|
829
|
+
self._hint_widget = self.query_one("#output-hint", Static)
|
|
830
|
+
self._full_widget = self.query_one("#output-full", Static)
|
|
831
|
+
# Hide everything initially - status only shown when running or on error/reject
|
|
832
|
+
self._status_widget.display = False
|
|
833
|
+
self._preview_widget.display = False
|
|
834
|
+
self._hint_widget.display = False
|
|
835
|
+
self._full_widget.display = False
|
|
836
|
+
|
|
837
|
+
# Restore deferred state if this widget was hydrated from data
|
|
838
|
+
self._restore_deferred_state()
|
|
839
|
+
|
|
840
|
+
def _restore_deferred_state(self) -> None:
|
|
841
|
+
"""Restore state from deferred values (used when hydrating from data)."""
|
|
842
|
+
if self._deferred_status is None:
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
status = self._deferred_status
|
|
846
|
+
output = self._deferred_output or ""
|
|
847
|
+
self._expanded = self._deferred_expanded
|
|
848
|
+
|
|
849
|
+
# Clear deferred values
|
|
850
|
+
self._deferred_status = None
|
|
851
|
+
self._deferred_output = None
|
|
852
|
+
self._deferred_expanded = False
|
|
853
|
+
|
|
854
|
+
# Restore based on status (don't restart animations for running tools)
|
|
855
|
+
colors = theme.get_theme_colors(self)
|
|
856
|
+
match status:
|
|
857
|
+
case "success":
|
|
858
|
+
self._status = "success"
|
|
859
|
+
self._output = output
|
|
860
|
+
self._update_output_display()
|
|
861
|
+
case "error":
|
|
862
|
+
self._status = "error"
|
|
863
|
+
self._output = output
|
|
864
|
+
if self._status_widget:
|
|
865
|
+
self._status_widget.add_class("error")
|
|
866
|
+
error_icon = get_glyphs().error
|
|
867
|
+
self._status_widget.update(
|
|
868
|
+
Content.styled(f"{error_icon} Error", colors.error)
|
|
869
|
+
)
|
|
870
|
+
self._status_widget.display = True
|
|
871
|
+
self._update_output_display()
|
|
872
|
+
case "rejected":
|
|
873
|
+
self._status = "rejected"
|
|
874
|
+
if self._status_widget:
|
|
875
|
+
self._status_widget.add_class("rejected")
|
|
876
|
+
error_icon = get_glyphs().error
|
|
877
|
+
self._status_widget.update(
|
|
878
|
+
Content.styled(f"{error_icon} Rejected", colors.warning)
|
|
879
|
+
)
|
|
880
|
+
self._status_widget.display = True
|
|
881
|
+
case "skipped":
|
|
882
|
+
self._status = "skipped"
|
|
883
|
+
if self._status_widget:
|
|
884
|
+
self._status_widget.add_class("rejected")
|
|
885
|
+
self._status_widget.update(Content.styled("- Skipped", "dim"))
|
|
886
|
+
self._status_widget.display = True
|
|
887
|
+
case "running":
|
|
888
|
+
# For running tools, show static "Running..." without animation
|
|
889
|
+
# (animations shouldn't be restored for archived tools)
|
|
890
|
+
self._status = "running"
|
|
891
|
+
if self._status_widget:
|
|
892
|
+
self._status_widget.add_class("pending")
|
|
893
|
+
frame = get_glyphs().spinner_frames[0]
|
|
894
|
+
self._status_widget.update(
|
|
895
|
+
Content.styled(f"{frame} Running...", colors.warning)
|
|
896
|
+
)
|
|
897
|
+
self._status_widget.display = True
|
|
898
|
+
case _:
|
|
899
|
+
# pending or unknown - leave as default
|
|
900
|
+
pass
|
|
901
|
+
|
|
902
|
+
def set_running(self) -> None:
|
|
903
|
+
"""Mark the tool as running (approved and executing).
|
|
904
|
+
|
|
905
|
+
Call this when approval is granted to start the running animation.
|
|
906
|
+
"""
|
|
907
|
+
if self._status == "running":
|
|
908
|
+
return # Already running
|
|
909
|
+
|
|
910
|
+
self._status = "running"
|
|
911
|
+
self._start_time = time()
|
|
912
|
+
if self._status_widget:
|
|
913
|
+
self._status_widget.add_class("pending")
|
|
914
|
+
self._status_widget.display = True
|
|
915
|
+
self._update_running_animation()
|
|
916
|
+
self._animation_timer = self.set_interval(0.1, self._update_running_animation)
|
|
917
|
+
|
|
918
|
+
def _update_running_animation(self) -> None:
|
|
919
|
+
"""Update the running spinner animation."""
|
|
920
|
+
if self._status != "running" or self._status_widget is None:
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
spinner_frames = get_glyphs().spinner_frames
|
|
924
|
+
frame = spinner_frames[self._spinner_position]
|
|
925
|
+
self._spinner_position = (self._spinner_position + 1) % len(spinner_frames)
|
|
926
|
+
|
|
927
|
+
elapsed = ""
|
|
928
|
+
if self._start_time is not None:
|
|
929
|
+
elapsed_secs = int(time() - self._start_time)
|
|
930
|
+
elapsed = f" ({format_duration(elapsed_secs)})"
|
|
931
|
+
|
|
932
|
+
text = f"{frame} Running...{elapsed}"
|
|
933
|
+
self._status_widget.update(
|
|
934
|
+
Content.styled(text, theme.get_theme_colors(self).warning)
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _stop_animation(self) -> None:
|
|
938
|
+
"""Stop the running animation."""
|
|
939
|
+
if self._animation_timer is not None:
|
|
940
|
+
self._animation_timer.stop()
|
|
941
|
+
self._animation_timer = None
|
|
942
|
+
|
|
943
|
+
def set_success(self, result: str = "") -> None:
|
|
944
|
+
"""Mark the tool call as successful.
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
result: Tool output/result to display
|
|
948
|
+
"""
|
|
949
|
+
self._stop_animation()
|
|
950
|
+
self._status = "success"
|
|
951
|
+
self._output = result
|
|
952
|
+
if self._status_widget:
|
|
953
|
+
self._status_widget.remove_class("pending")
|
|
954
|
+
# Hide status on success - output speaks for itself
|
|
955
|
+
self._status_widget.display = False
|
|
956
|
+
self._update_output_display()
|
|
957
|
+
|
|
958
|
+
def set_error(self, error: str) -> None:
|
|
959
|
+
"""Mark the tool call as failed.
|
|
960
|
+
|
|
961
|
+
Args:
|
|
962
|
+
error: Error message
|
|
963
|
+
"""
|
|
964
|
+
self._stop_animation()
|
|
965
|
+
self._status = "error"
|
|
966
|
+
# For shell commands, prepend the full command so users can see what failed
|
|
967
|
+
command = (
|
|
968
|
+
self._args.get("command")
|
|
969
|
+
if self._tool_name in {"shell", "bash", "execute"}
|
|
970
|
+
else None
|
|
971
|
+
)
|
|
972
|
+
if command and isinstance(command, str) and command.strip():
|
|
973
|
+
self._output = f"$ {command}\n\n{error}"
|
|
974
|
+
else:
|
|
975
|
+
self._output = error
|
|
976
|
+
if self._status_widget:
|
|
977
|
+
self._status_widget.remove_class("pending")
|
|
978
|
+
self._status_widget.add_class("error")
|
|
979
|
+
error_icon = get_glyphs().error
|
|
980
|
+
colors = theme.get_theme_colors(self)
|
|
981
|
+
self._status_widget.update(
|
|
982
|
+
Content.styled(f"{error_icon} Error", colors.error)
|
|
983
|
+
)
|
|
984
|
+
self._status_widget.display = True
|
|
985
|
+
# Always show full error - errors should be visible
|
|
986
|
+
self._expanded = True
|
|
987
|
+
self._update_output_display()
|
|
988
|
+
|
|
989
|
+
def set_rejected(self) -> None:
|
|
990
|
+
"""Mark the tool call as rejected by user."""
|
|
991
|
+
self._stop_animation()
|
|
992
|
+
self._status = "rejected"
|
|
993
|
+
if self._status_widget:
|
|
994
|
+
self._status_widget.remove_class("pending")
|
|
995
|
+
self._status_widget.add_class("rejected")
|
|
996
|
+
error_icon = get_glyphs().error
|
|
997
|
+
text = f"{error_icon} Rejected"
|
|
998
|
+
colors = theme.get_theme_colors(self)
|
|
999
|
+
self._status_widget.update(Content.styled(text, colors.warning))
|
|
1000
|
+
self._status_widget.display = True
|
|
1001
|
+
|
|
1002
|
+
def set_skipped(self) -> None:
|
|
1003
|
+
"""Mark the tool call as skipped (due to another rejection)."""
|
|
1004
|
+
self._stop_animation()
|
|
1005
|
+
self._status = "skipped"
|
|
1006
|
+
if self._status_widget:
|
|
1007
|
+
self._status_widget.remove_class("pending")
|
|
1008
|
+
self._status_widget.add_class("rejected") # Use same styling as rejected
|
|
1009
|
+
self._status_widget.update(Content.styled("- Skipped", "dim"))
|
|
1010
|
+
self._status_widget.display = True
|
|
1011
|
+
|
|
1012
|
+
def toggle_output(self) -> None:
|
|
1013
|
+
"""Toggle between preview and full output display."""
|
|
1014
|
+
if not self._output:
|
|
1015
|
+
return
|
|
1016
|
+
self._expanded = not self._expanded
|
|
1017
|
+
self._update_output_display()
|
|
1018
|
+
|
|
1019
|
+
def on_click(self, event: Click) -> None:
|
|
1020
|
+
"""Toggle output expansion, or show timestamp if no output."""
|
|
1021
|
+
event.stop() # Prevent click from bubbling up and scrolling
|
|
1022
|
+
if self._output:
|
|
1023
|
+
self.toggle_output()
|
|
1024
|
+
else:
|
|
1025
|
+
_show_timestamp_toast(self)
|
|
1026
|
+
|
|
1027
|
+
def _format_output(
|
|
1028
|
+
self, output: str, *, is_preview: bool = False
|
|
1029
|
+
) -> FormattedOutput:
|
|
1030
|
+
"""Format tool output based on tool type for nicer display.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
output: Raw output string
|
|
1034
|
+
is_preview: Whether this is for preview (truncated) display
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
FormattedOutput with content and optional truncation info.
|
|
1038
|
+
"""
|
|
1039
|
+
output = output.strip()
|
|
1040
|
+
if not output:
|
|
1041
|
+
return FormattedOutput(content=Content(""))
|
|
1042
|
+
|
|
1043
|
+
# Tool-specific formatting using dispatch table
|
|
1044
|
+
formatters = {
|
|
1045
|
+
"write_todos": self._format_todos_output,
|
|
1046
|
+
"ls": self._format_ls_output,
|
|
1047
|
+
"read_file": self._format_file_output,
|
|
1048
|
+
"write_file": self._format_file_output,
|
|
1049
|
+
"edit_file": self._format_file_output,
|
|
1050
|
+
"grep": self._format_search_output,
|
|
1051
|
+
"glob": self._format_search_output,
|
|
1052
|
+
"shell": self._format_shell_output,
|
|
1053
|
+
"bash": self._format_shell_output,
|
|
1054
|
+
"execute": self._format_shell_output,
|
|
1055
|
+
"web_search": self._format_web_output,
|
|
1056
|
+
"fetch_url": self._format_web_output,
|
|
1057
|
+
"task": self._format_task_output,
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
formatter = formatters.get(self._tool_name)
|
|
1061
|
+
if formatter:
|
|
1062
|
+
return formatter(output, is_preview=is_preview)
|
|
1063
|
+
|
|
1064
|
+
if is_preview:
|
|
1065
|
+
# Fallback for unknown tools: use generic truncation
|
|
1066
|
+
lines = output.split("\n")
|
|
1067
|
+
if len(lines) > self._PREVIEW_LINES:
|
|
1068
|
+
return self._format_lines_output(lines, is_preview=True)
|
|
1069
|
+
if len(output) > self._PREVIEW_CHARS:
|
|
1070
|
+
truncated = output[: self._PREVIEW_CHARS]
|
|
1071
|
+
truncation = f"{len(output) - self._PREVIEW_CHARS} more chars"
|
|
1072
|
+
return FormattedOutput(
|
|
1073
|
+
content=Content(truncated), truncation=truncation
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
# Default: plain text (Content treats input as literal)
|
|
1077
|
+
return FormattedOutput(content=Content(output))
|
|
1078
|
+
|
|
1079
|
+
def _prefix_output(self, content: Content) -> Content: # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1080
|
+
"""Prefix output with output marker and indent continuation lines.
|
|
1081
|
+
|
|
1082
|
+
Args:
|
|
1083
|
+
content: The styled output content to prefix and indent.
|
|
1084
|
+
|
|
1085
|
+
Returns:
|
|
1086
|
+
`Content` with output prefix on first line and indented
|
|
1087
|
+
continuation.
|
|
1088
|
+
"""
|
|
1089
|
+
if not content.plain:
|
|
1090
|
+
return Content("")
|
|
1091
|
+
output_prefix = get_glyphs().output_prefix
|
|
1092
|
+
lines = content.split("\n")
|
|
1093
|
+
prefixed = [Content.assemble(f"{output_prefix} ", lines[0])]
|
|
1094
|
+
prefixed.extend(Content.assemble(" ", line) for line in lines[1:])
|
|
1095
|
+
return Content("\n").join(prefixed)
|
|
1096
|
+
|
|
1097
|
+
def _format_todos_output(
|
|
1098
|
+
self, output: str, *, is_preview: bool = False
|
|
1099
|
+
) -> FormattedOutput:
|
|
1100
|
+
"""Format write_todos output as a checklist.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
FormattedOutput with checklist content and optional truncation info.
|
|
1104
|
+
"""
|
|
1105
|
+
items = self._parse_todo_items(output)
|
|
1106
|
+
if items is None:
|
|
1107
|
+
return FormattedOutput(content=Content(output))
|
|
1108
|
+
|
|
1109
|
+
if not items:
|
|
1110
|
+
return FormattedOutput(content=Content.styled(" No todos", "dim"))
|
|
1111
|
+
|
|
1112
|
+
lines: list[Content] = []
|
|
1113
|
+
max_items = 4 if is_preview else len(items)
|
|
1114
|
+
|
|
1115
|
+
# Build stats header
|
|
1116
|
+
stats = self._build_todo_stats(items)
|
|
1117
|
+
if stats:
|
|
1118
|
+
lines.extend([Content.assemble(" ", stats), Content("")])
|
|
1119
|
+
|
|
1120
|
+
# Format each item
|
|
1121
|
+
lines.extend(self._format_single_todo(item) for item in items[:max_items])
|
|
1122
|
+
|
|
1123
|
+
truncation = None
|
|
1124
|
+
if is_preview and len(items) > max_items:
|
|
1125
|
+
truncation = f"{len(items) - max_items} more"
|
|
1126
|
+
|
|
1127
|
+
return FormattedOutput(content=Content("\n").join(lines), truncation=truncation)
|
|
1128
|
+
|
|
1129
|
+
def _parse_todo_items(self, output: str) -> list | None: # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1130
|
+
"""Parse todo items from output.
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
List of todo items, or None if parsing fails.
|
|
1134
|
+
"""
|
|
1135
|
+
list_match = re.search(r"\[(\{.*\})\]", output.replace("\n", " "), re.DOTALL)
|
|
1136
|
+
if list_match:
|
|
1137
|
+
try:
|
|
1138
|
+
return ast.literal_eval("[" + list_match.group(1) + "]")
|
|
1139
|
+
except (ValueError, SyntaxError):
|
|
1140
|
+
return None
|
|
1141
|
+
try:
|
|
1142
|
+
items = ast.literal_eval(output)
|
|
1143
|
+
return items if isinstance(items, list) else None
|
|
1144
|
+
except (ValueError, SyntaxError):
|
|
1145
|
+
return None
|
|
1146
|
+
|
|
1147
|
+
def _build_todo_stats(self, items: list) -> Content:
|
|
1148
|
+
"""Build stats content for todo list.
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
Styled `Content` showing active, pending, and completed counts.
|
|
1152
|
+
"""
|
|
1153
|
+
colors = theme.get_theme_colors(self)
|
|
1154
|
+
completed = sum(
|
|
1155
|
+
1 for i in items if isinstance(i, dict) and i.get("status") == "completed"
|
|
1156
|
+
)
|
|
1157
|
+
active = sum(
|
|
1158
|
+
1 for i in items if isinstance(i, dict) and i.get("status") == "in_progress"
|
|
1159
|
+
)
|
|
1160
|
+
pending = len(items) - completed - active
|
|
1161
|
+
|
|
1162
|
+
parts: list[Content] = []
|
|
1163
|
+
if active:
|
|
1164
|
+
parts.append(Content.styled(f"{active} active", colors.warning))
|
|
1165
|
+
if pending:
|
|
1166
|
+
parts.append(Content.styled(f"{pending} pending", "dim"))
|
|
1167
|
+
if completed:
|
|
1168
|
+
parts.append(Content.styled(f"{completed} done", colors.success))
|
|
1169
|
+
return Content.styled(" | ", "dim").join(parts) if parts else Content("")
|
|
1170
|
+
|
|
1171
|
+
def _format_single_todo(self, item: dict | str) -> Content:
|
|
1172
|
+
"""Format a single todo item.
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
Styled `Content` with checkbox and status styling.
|
|
1176
|
+
"""
|
|
1177
|
+
colors = theme.get_theme_colors(self)
|
|
1178
|
+
if isinstance(item, dict):
|
|
1179
|
+
text = item.get("content", str(item))
|
|
1180
|
+
status = item.get("status", "pending")
|
|
1181
|
+
else:
|
|
1182
|
+
text = str(item)
|
|
1183
|
+
status = "pending"
|
|
1184
|
+
|
|
1185
|
+
if len(text) > _MAX_TODO_CONTENT_LEN:
|
|
1186
|
+
text = text[: _MAX_TODO_CONTENT_LEN - 3] + "..."
|
|
1187
|
+
|
|
1188
|
+
glyphs = get_glyphs()
|
|
1189
|
+
if status == "completed":
|
|
1190
|
+
return Content.assemble(
|
|
1191
|
+
Content.styled(f" {glyphs.checkmark} done", colors.success),
|
|
1192
|
+
Content.styled(f" {text}", "dim"),
|
|
1193
|
+
)
|
|
1194
|
+
if status == "in_progress":
|
|
1195
|
+
return Content.assemble(
|
|
1196
|
+
Content.styled(f" {glyphs.circle_filled} active", colors.warning),
|
|
1197
|
+
f" {text}",
|
|
1198
|
+
)
|
|
1199
|
+
return Content.assemble(
|
|
1200
|
+
Content.styled(f" {glyphs.circle_empty} todo", "dim"),
|
|
1201
|
+
f" {text}",
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
def _format_ls_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1205
|
+
self, output: str, *, is_preview: bool = False
|
|
1206
|
+
) -> FormattedOutput:
|
|
1207
|
+
"""Format ls output as a clean directory listing.
|
|
1208
|
+
|
|
1209
|
+
Returns:
|
|
1210
|
+
FormattedOutput with directory listing and optional truncation info.
|
|
1211
|
+
"""
|
|
1212
|
+
# Try to parse as a Python list (common format)
|
|
1213
|
+
try:
|
|
1214
|
+
items = ast.literal_eval(output)
|
|
1215
|
+
if isinstance(items, list):
|
|
1216
|
+
lines: list[Content] = []
|
|
1217
|
+
max_items = 5 if is_preview else len(items)
|
|
1218
|
+
for item in items[:max_items]:
|
|
1219
|
+
path = Path(str(item))
|
|
1220
|
+
name = path.name
|
|
1221
|
+
if path.suffix in {".py", ".pyx"}:
|
|
1222
|
+
lines.append(Content.styled(f" {name}", theme.FILE_PYTHON))
|
|
1223
|
+
elif path.suffix in {".json", ".yaml", ".yml", ".toml"}:
|
|
1224
|
+
lines.append(Content.styled(f" {name}", theme.FILE_CONFIG))
|
|
1225
|
+
elif not path.suffix:
|
|
1226
|
+
lines.append(Content.styled(f" {name}/", theme.FILE_DIR))
|
|
1227
|
+
else:
|
|
1228
|
+
lines.append(Content(f" {name}"))
|
|
1229
|
+
|
|
1230
|
+
truncation = None
|
|
1231
|
+
if is_preview and len(items) > max_items:
|
|
1232
|
+
truncation = f"{len(items) - max_items} more"
|
|
1233
|
+
|
|
1234
|
+
return FormattedOutput(
|
|
1235
|
+
content=Content("\n").join(lines), truncation=truncation
|
|
1236
|
+
)
|
|
1237
|
+
except (ValueError, SyntaxError):
|
|
1238
|
+
pass
|
|
1239
|
+
|
|
1240
|
+
# Fallback: plain text
|
|
1241
|
+
return FormattedOutput(content=Content(output))
|
|
1242
|
+
|
|
1243
|
+
def _format_file_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1244
|
+
self, output: str, *, is_preview: bool = False
|
|
1245
|
+
) -> FormattedOutput:
|
|
1246
|
+
"""Format file read/write output.
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
FormattedOutput with file content and optional truncation info.
|
|
1250
|
+
"""
|
|
1251
|
+
lines = output.split("\n")
|
|
1252
|
+
max_lines = 4 if is_preview else len(lines)
|
|
1253
|
+
|
|
1254
|
+
parts = [Content(line) for line in lines[:max_lines]]
|
|
1255
|
+
content = Content("\n").join(parts)
|
|
1256
|
+
|
|
1257
|
+
truncation = None
|
|
1258
|
+
if is_preview and len(lines) > max_lines:
|
|
1259
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
1260
|
+
|
|
1261
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
1262
|
+
|
|
1263
|
+
def _format_search_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1264
|
+
self, output: str, *, is_preview: bool = False
|
|
1265
|
+
) -> FormattedOutput:
|
|
1266
|
+
"""Format grep/glob search output.
|
|
1267
|
+
|
|
1268
|
+
Returns:
|
|
1269
|
+
FormattedOutput with search results and optional truncation info.
|
|
1270
|
+
"""
|
|
1271
|
+
# Try to parse as a Python list (glob returns list of paths)
|
|
1272
|
+
try:
|
|
1273
|
+
items = ast.literal_eval(output.strip())
|
|
1274
|
+
if isinstance(items, list):
|
|
1275
|
+
parts: list[Content] = []
|
|
1276
|
+
max_items = 5 if is_preview else len(items)
|
|
1277
|
+
for item in items[:max_items]:
|
|
1278
|
+
path = Path(str(item))
|
|
1279
|
+
try:
|
|
1280
|
+
rel = path.relative_to(Path.cwd())
|
|
1281
|
+
display = str(rel)
|
|
1282
|
+
except ValueError:
|
|
1283
|
+
display = path.name
|
|
1284
|
+
parts.append(Content(f" {display}"))
|
|
1285
|
+
|
|
1286
|
+
truncation = None
|
|
1287
|
+
if is_preview and len(items) > max_items:
|
|
1288
|
+
truncation = f"{len(items) - max_items} more files"
|
|
1289
|
+
|
|
1290
|
+
return FormattedOutput(
|
|
1291
|
+
content=Content("\n").join(parts), truncation=truncation
|
|
1292
|
+
)
|
|
1293
|
+
except (ValueError, SyntaxError):
|
|
1294
|
+
pass
|
|
1295
|
+
|
|
1296
|
+
# Fallback: line-based output (grep results)
|
|
1297
|
+
lines = output.split("\n")
|
|
1298
|
+
max_lines = 5 if is_preview else len(lines)
|
|
1299
|
+
|
|
1300
|
+
parts = [
|
|
1301
|
+
Content(f" {raw_line.strip()}")
|
|
1302
|
+
for raw_line in lines[:max_lines]
|
|
1303
|
+
if raw_line.strip()
|
|
1304
|
+
]
|
|
1305
|
+
|
|
1306
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
1307
|
+
truncation = None
|
|
1308
|
+
if is_preview and len(lines) > max_lines:
|
|
1309
|
+
truncation = f"{len(lines) - max_lines} more"
|
|
1310
|
+
|
|
1311
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
1312
|
+
|
|
1313
|
+
def _format_shell_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1314
|
+
self, output: str, *, is_preview: bool = False
|
|
1315
|
+
) -> FormattedOutput:
|
|
1316
|
+
"""Format shell command output.
|
|
1317
|
+
|
|
1318
|
+
Returns:
|
|
1319
|
+
FormattedOutput with shell output and optional truncation info.
|
|
1320
|
+
"""
|
|
1321
|
+
lines = output.split("\n")
|
|
1322
|
+
max_lines = 4 if is_preview else len(lines)
|
|
1323
|
+
|
|
1324
|
+
parts: list[Content] = []
|
|
1325
|
+
for i, line in enumerate(lines[:max_lines]):
|
|
1326
|
+
if i == 0 and line.startswith("$ "):
|
|
1327
|
+
parts.append(Content.styled(line, "dim"))
|
|
1328
|
+
else:
|
|
1329
|
+
parts.append(Content(line))
|
|
1330
|
+
|
|
1331
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
1332
|
+
|
|
1333
|
+
truncation = None
|
|
1334
|
+
if is_preview and len(lines) > max_lines:
|
|
1335
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
1336
|
+
|
|
1337
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
1338
|
+
|
|
1339
|
+
def _format_web_output(
|
|
1340
|
+
self, output: str, *, is_preview: bool = False
|
|
1341
|
+
) -> FormattedOutput:
|
|
1342
|
+
"""Format web_search/fetch_url output.
|
|
1343
|
+
|
|
1344
|
+
Returns:
|
|
1345
|
+
FormattedOutput with web response and optional truncation info.
|
|
1346
|
+
"""
|
|
1347
|
+
data = self._try_parse_web_data(output)
|
|
1348
|
+
if isinstance(data, dict):
|
|
1349
|
+
return self._format_web_dict(data, is_preview=is_preview)
|
|
1350
|
+
|
|
1351
|
+
# Fallback: plain text
|
|
1352
|
+
return self._format_lines_output(output.split("\n"), is_preview=is_preview)
|
|
1353
|
+
|
|
1354
|
+
@staticmethod
|
|
1355
|
+
def _try_parse_web_data(output: str) -> dict | None:
|
|
1356
|
+
"""Try to parse web output as JSON or dict.
|
|
1357
|
+
|
|
1358
|
+
Returns:
|
|
1359
|
+
Parsed dict if successful, None otherwise.
|
|
1360
|
+
"""
|
|
1361
|
+
try:
|
|
1362
|
+
if output.strip().startswith("{"):
|
|
1363
|
+
return json.loads(output)
|
|
1364
|
+
return ast.literal_eval(output)
|
|
1365
|
+
except (ValueError, SyntaxError, json.JSONDecodeError):
|
|
1366
|
+
return None
|
|
1367
|
+
|
|
1368
|
+
def _format_web_dict(self, data: dict, *, is_preview: bool) -> FormattedOutput:
|
|
1369
|
+
"""Format a parsed web response dict.
|
|
1370
|
+
|
|
1371
|
+
Returns:
|
|
1372
|
+
FormattedOutput with web response content and optional truncation info.
|
|
1373
|
+
"""
|
|
1374
|
+
# Handle web_search results
|
|
1375
|
+
if "results" in data:
|
|
1376
|
+
return self._format_web_search_results(
|
|
1377
|
+
data.get("results", []), is_preview=is_preview
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
# Handle fetch_url response
|
|
1381
|
+
if "markdown_content" in data:
|
|
1382
|
+
lines = data["markdown_content"].split("\n")
|
|
1383
|
+
return self._format_lines_output(lines, is_preview=is_preview)
|
|
1384
|
+
|
|
1385
|
+
# Generic dict - show key fields
|
|
1386
|
+
parts: list[Content] = []
|
|
1387
|
+
max_keys = 3 if is_preview else len(data)
|
|
1388
|
+
for k, v in list(data.items())[:max_keys]:
|
|
1389
|
+
v_str = str(v)
|
|
1390
|
+
if is_preview and len(v_str) > _MAX_WEB_CONTENT_LEN:
|
|
1391
|
+
v_str = v_str[:_MAX_WEB_CONTENT_LEN] + "..."
|
|
1392
|
+
parts.append(Content(f" {k}: {v_str}"))
|
|
1393
|
+
truncation = None
|
|
1394
|
+
if is_preview and len(data) > max_keys:
|
|
1395
|
+
truncation = f"{len(data) - max_keys} more"
|
|
1396
|
+
return FormattedOutput(
|
|
1397
|
+
content=Content("\n").join(parts) if parts else Content(""),
|
|
1398
|
+
truncation=truncation,
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
def _format_web_search_results( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1402
|
+
self, results: list, *, is_preview: bool
|
|
1403
|
+
) -> FormattedOutput:
|
|
1404
|
+
"""Format web search results.
|
|
1405
|
+
|
|
1406
|
+
Returns:
|
|
1407
|
+
FormattedOutput with search results and optional truncation info.
|
|
1408
|
+
"""
|
|
1409
|
+
if not results:
|
|
1410
|
+
return FormattedOutput(content=Content.styled("No results", "dim"))
|
|
1411
|
+
parts: list[Content] = []
|
|
1412
|
+
max_results = 3 if is_preview else len(results)
|
|
1413
|
+
for r in results[:max_results]:
|
|
1414
|
+
title = r.get("title", "")
|
|
1415
|
+
url = r.get("url", "")
|
|
1416
|
+
parts.extend(
|
|
1417
|
+
[
|
|
1418
|
+
Content.styled(f" {title}", "bold"),
|
|
1419
|
+
Content.styled(f" {url}", "dim"),
|
|
1420
|
+
]
|
|
1421
|
+
)
|
|
1422
|
+
truncation = None
|
|
1423
|
+
if is_preview and len(results) > max_results:
|
|
1424
|
+
truncation = f"{len(results) - max_results} more results"
|
|
1425
|
+
return FormattedOutput(content=Content("\n").join(parts), truncation=truncation)
|
|
1426
|
+
|
|
1427
|
+
def _format_lines_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1428
|
+
self, lines: list[str], *, is_preview: bool
|
|
1429
|
+
) -> FormattedOutput:
|
|
1430
|
+
"""Format a list of lines with optional preview truncation.
|
|
1431
|
+
|
|
1432
|
+
Returns:
|
|
1433
|
+
FormattedOutput with lines content and optional truncation info.
|
|
1434
|
+
"""
|
|
1435
|
+
max_lines = 4 if is_preview else len(lines)
|
|
1436
|
+
parts = [Content(line) for line in lines[:max_lines]]
|
|
1437
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
1438
|
+
truncation = None
|
|
1439
|
+
if is_preview and len(lines) > max_lines:
|
|
1440
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
1441
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
1442
|
+
|
|
1443
|
+
def _format_task_output( # noqa: PLR6301 # Grouped as method for widget cohesion
|
|
1444
|
+
self, output: str, *, is_preview: bool = False
|
|
1445
|
+
) -> FormattedOutput:
|
|
1446
|
+
"""Format task (subagent) output.
|
|
1447
|
+
|
|
1448
|
+
Returns:
|
|
1449
|
+
FormattedOutput with task output and optional truncation info.
|
|
1450
|
+
"""
|
|
1451
|
+
lines = output.split("\n")
|
|
1452
|
+
max_lines = 4 if is_preview else len(lines)
|
|
1453
|
+
|
|
1454
|
+
parts = [Content(line) for line in lines[:max_lines]]
|
|
1455
|
+
content = Content("\n").join(parts) if parts else Content("")
|
|
1456
|
+
|
|
1457
|
+
truncation = None
|
|
1458
|
+
if is_preview and len(lines) > max_lines:
|
|
1459
|
+
truncation = f"{len(lines) - max_lines} more lines"
|
|
1460
|
+
|
|
1461
|
+
return FormattedOutput(content=content, truncation=truncation)
|
|
1462
|
+
|
|
1463
|
+
def _update_output_display(self) -> None:
|
|
1464
|
+
"""Update the output display based on expanded state."""
|
|
1465
|
+
# Guard: all widgets must be initialized before updating display state
|
|
1466
|
+
if (
|
|
1467
|
+
not self._output
|
|
1468
|
+
or not self._preview_widget
|
|
1469
|
+
or not self._full_widget
|
|
1470
|
+
or not self._hint_widget
|
|
1471
|
+
):
|
|
1472
|
+
return
|
|
1473
|
+
|
|
1474
|
+
output_stripped = self._output.strip()
|
|
1475
|
+
lines = output_stripped.split("\n")
|
|
1476
|
+
total_lines = len(lines)
|
|
1477
|
+
total_chars = len(output_stripped)
|
|
1478
|
+
|
|
1479
|
+
# Truncate if too many lines OR too many characters
|
|
1480
|
+
needs_truncation = (
|
|
1481
|
+
total_lines > self._PREVIEW_LINES or total_chars > self._PREVIEW_CHARS
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
if self._expanded:
|
|
1485
|
+
# Show full output with formatting
|
|
1486
|
+
self._preview_widget.display = False
|
|
1487
|
+
result = self._format_output(self._output, is_preview=False)
|
|
1488
|
+
prefixed = self._prefix_output(result.content)
|
|
1489
|
+
self._full_widget.update(prefixed)
|
|
1490
|
+
self._full_widget.display = True
|
|
1491
|
+
# Show collapse hint underneath
|
|
1492
|
+
self._hint_widget.update(
|
|
1493
|
+
Content.styled("click or Ctrl+O to collapse", "dim italic")
|
|
1494
|
+
)
|
|
1495
|
+
self._hint_widget.display = True
|
|
1496
|
+
else:
|
|
1497
|
+
# Show preview
|
|
1498
|
+
self._full_widget.display = False
|
|
1499
|
+
if needs_truncation:
|
|
1500
|
+
result = self._format_output(self._output, is_preview=True)
|
|
1501
|
+
prefixed = self._prefix_output(result.content)
|
|
1502
|
+
self._preview_widget.update(prefixed)
|
|
1503
|
+
self._preview_widget.display = True
|
|
1504
|
+
|
|
1505
|
+
# Build hint with truncation info if available
|
|
1506
|
+
if result.truncation:
|
|
1507
|
+
ellipsis = get_glyphs().ellipsis
|
|
1508
|
+
hint = Content.styled(
|
|
1509
|
+
f"{ellipsis} {result.truncation} — click or Ctrl+O to expand",
|
|
1510
|
+
"dim",
|
|
1511
|
+
)
|
|
1512
|
+
else:
|
|
1513
|
+
hint = Content.styled("click or Ctrl+O to expand", "dim italic")
|
|
1514
|
+
self._hint_widget.update(hint)
|
|
1515
|
+
self._hint_widget.display = True
|
|
1516
|
+
elif output_stripped:
|
|
1517
|
+
# Output fits in preview, show formatted
|
|
1518
|
+
result = self._format_output(output_stripped, is_preview=False)
|
|
1519
|
+
prefixed = self._prefix_output(result.content)
|
|
1520
|
+
self._preview_widget.update(prefixed)
|
|
1521
|
+
self._preview_widget.display = True
|
|
1522
|
+
self._hint_widget.display = False
|
|
1523
|
+
else:
|
|
1524
|
+
self._preview_widget.display = False
|
|
1525
|
+
self._hint_widget.display = False
|
|
1526
|
+
|
|
1527
|
+
@property
|
|
1528
|
+
def has_output(self) -> bool:
|
|
1529
|
+
"""Check if this tool message has output to display.
|
|
1530
|
+
|
|
1531
|
+
Returns:
|
|
1532
|
+
True if there is output content, False otherwise.
|
|
1533
|
+
"""
|
|
1534
|
+
return bool(self._output)
|
|
1535
|
+
|
|
1536
|
+
def _filtered_args(self) -> dict[str, Any]:
|
|
1537
|
+
"""Filter large tool args for display.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
Filtered args dict with only display-relevant keys for write/edit tools.
|
|
1541
|
+
"""
|
|
1542
|
+
if self._tool_name not in {"write_file", "edit_file"}:
|
|
1543
|
+
return self._args
|
|
1544
|
+
|
|
1545
|
+
filtered: dict[str, Any] = {}
|
|
1546
|
+
for key in ("file_path", "path", "replace_all"):
|
|
1547
|
+
if key in self._args:
|
|
1548
|
+
filtered[key] = self._args[key]
|
|
1549
|
+
return filtered
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
class DiffMessage(_TimestampClickMixin, Static):
|
|
1553
|
+
"""Widget displaying a diff with syntax highlighting."""
|
|
1554
|
+
|
|
1555
|
+
DEFAULT_CSS = """
|
|
1556
|
+
DiffMessage {
|
|
1557
|
+
height: auto;
|
|
1558
|
+
padding: 1;
|
|
1559
|
+
margin: 0 0 1 0;
|
|
1560
|
+
background: $surface;
|
|
1561
|
+
border: solid $primary;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
DiffMessage .diff-header {
|
|
1565
|
+
text-style: bold;
|
|
1566
|
+
margin-bottom: 1;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
DiffMessage .diff-add {
|
|
1570
|
+
color: $text-success;
|
|
1571
|
+
background: $success-muted;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
DiffMessage .diff-remove {
|
|
1575
|
+
color: $text-error;
|
|
1576
|
+
background: $error-muted;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
DiffMessage .diff-context {
|
|
1580
|
+
color: $text-muted;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
DiffMessage .diff-hunk {
|
|
1584
|
+
color: $secondary;
|
|
1585
|
+
text-style: bold;
|
|
1586
|
+
}
|
|
1587
|
+
"""
|
|
1588
|
+
"""Diff syntax coloring per theme: additions, removals, muted context."""
|
|
1589
|
+
|
|
1590
|
+
def __init__(self, diff_content: str, file_path: str = "", **kwargs: Any) -> None:
|
|
1591
|
+
"""Initialize a diff message.
|
|
1592
|
+
|
|
1593
|
+
Args:
|
|
1594
|
+
diff_content: The unified diff content
|
|
1595
|
+
file_path: Path to the file being modified
|
|
1596
|
+
**kwargs: Additional arguments passed to parent
|
|
1597
|
+
"""
|
|
1598
|
+
super().__init__(**kwargs)
|
|
1599
|
+
self._diff_content = diff_content
|
|
1600
|
+
self._file_path = file_path
|
|
1601
|
+
|
|
1602
|
+
def compose(self) -> ComposeResult:
|
|
1603
|
+
"""Compose the diff message layout.
|
|
1604
|
+
|
|
1605
|
+
Yields:
|
|
1606
|
+
Widgets displaying the diff header and formatted content.
|
|
1607
|
+
"""
|
|
1608
|
+
if self._file_path:
|
|
1609
|
+
yield Static(
|
|
1610
|
+
Content.from_markup("[bold]File: $path[/bold]", path=self._file_path),
|
|
1611
|
+
classes="diff-header",
|
|
1612
|
+
)
|
|
1613
|
+
|
|
1614
|
+
# Render the diff with per-line Statics (CSS-driven backgrounds)
|
|
1615
|
+
yield from compose_diff_lines(self._diff_content, max_lines=100)
|
|
1616
|
+
|
|
1617
|
+
def on_mount(self) -> None:
|
|
1618
|
+
"""Set border style based on charset mode."""
|
|
1619
|
+
if is_ascii_mode():
|
|
1620
|
+
colors = theme.get_theme_colors(self)
|
|
1621
|
+
self.styles.border = ("ascii", colors.primary)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
class ErrorMessage(_TimestampClickMixin, Static):
|
|
1625
|
+
"""Widget displaying an error message."""
|
|
1626
|
+
|
|
1627
|
+
DEFAULT_CSS = """
|
|
1628
|
+
ErrorMessage {
|
|
1629
|
+
height: auto;
|
|
1630
|
+
padding: 1;
|
|
1631
|
+
margin: 0 0 1 0;
|
|
1632
|
+
background: $error-muted;
|
|
1633
|
+
color: white;
|
|
1634
|
+
border-left: wide $error;
|
|
1635
|
+
}
|
|
1636
|
+
"""
|
|
1637
|
+
"""Tinted background + left border to visually separate errors from output."""
|
|
1638
|
+
|
|
1639
|
+
def __init__(self, error: str, **kwargs: Any) -> None:
|
|
1640
|
+
"""Initialize an error message.
|
|
1641
|
+
|
|
1642
|
+
Args:
|
|
1643
|
+
error: The error message
|
|
1644
|
+
**kwargs: Additional arguments passed to parent
|
|
1645
|
+
"""
|
|
1646
|
+
# Store raw content for serialization
|
|
1647
|
+
self._content = error
|
|
1648
|
+
super().__init__(**kwargs)
|
|
1649
|
+
|
|
1650
|
+
def render(self) -> Content:
|
|
1651
|
+
"""Render with theme-aware colors.
|
|
1652
|
+
|
|
1653
|
+
Returns:
|
|
1654
|
+
Styled error content with theme-appropriate color.
|
|
1655
|
+
"""
|
|
1656
|
+
colors = theme.get_theme_colors(self)
|
|
1657
|
+
return Content.assemble(
|
|
1658
|
+
Content.styled("Error: ", f"bold {colors.error}"),
|
|
1659
|
+
self._content,
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
def on_mount(self) -> None:
|
|
1663
|
+
"""Set border style based on charset mode."""
|
|
1664
|
+
if is_ascii_mode():
|
|
1665
|
+
colors = theme.get_theme_colors(self)
|
|
1666
|
+
self.styles.border_left = ("ascii", colors.error)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
class AppMessage(Static):
|
|
1670
|
+
"""Widget displaying an app message."""
|
|
1671
|
+
|
|
1672
|
+
# Disable Textual's auto_links to prevent a flicker cycle: Style.__add__
|
|
1673
|
+
# calls .copy() for linked styles, generating a fresh random _link_id on
|
|
1674
|
+
# each render. This means highlight_link_id never stabilizes, causing an
|
|
1675
|
+
# infinite hover-refresh loop.
|
|
1676
|
+
auto_links = False
|
|
1677
|
+
|
|
1678
|
+
DEFAULT_CSS = """
|
|
1679
|
+
AppMessage {
|
|
1680
|
+
height: auto;
|
|
1681
|
+
padding: 0 1;
|
|
1682
|
+
margin: 0 0 1 0;
|
|
1683
|
+
color: $text-muted;
|
|
1684
|
+
text-style: italic;
|
|
1685
|
+
}
|
|
1686
|
+
"""
|
|
1687
|
+
|
|
1688
|
+
def __init__(self, message: str | Content, **kwargs: Any) -> None:
|
|
1689
|
+
"""Initialize a system message.
|
|
1690
|
+
|
|
1691
|
+
Args:
|
|
1692
|
+
message: The system message as a string or pre-styled `Content`.
|
|
1693
|
+
**kwargs: Additional arguments passed to parent
|
|
1694
|
+
"""
|
|
1695
|
+
# Store raw content for serialization
|
|
1696
|
+
self._content = message
|
|
1697
|
+
rendered = (
|
|
1698
|
+
message
|
|
1699
|
+
if isinstance(message, Content)
|
|
1700
|
+
else Content.styled(message, "dim italic")
|
|
1701
|
+
)
|
|
1702
|
+
super().__init__(rendered, **kwargs)
|
|
1703
|
+
|
|
1704
|
+
def on_click(self, event: Click) -> None:
|
|
1705
|
+
"""Open style-embedded hyperlinks on single click and show timestamp."""
|
|
1706
|
+
open_style_link(event)
|
|
1707
|
+
_show_timestamp_toast(self)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
class SummarizationMessage(AppMessage):
|
|
1711
|
+
"""Widget displaying a summarization completion notification."""
|
|
1712
|
+
|
|
1713
|
+
DEFAULT_CSS = """
|
|
1714
|
+
SummarizationMessage {
|
|
1715
|
+
height: auto;
|
|
1716
|
+
padding: 0 1;
|
|
1717
|
+
margin: 0 0 1 0;
|
|
1718
|
+
color: $primary;
|
|
1719
|
+
background: $surface;
|
|
1720
|
+
border-left: wide $primary;
|
|
1721
|
+
text-style: bold;
|
|
1722
|
+
}
|
|
1723
|
+
"""
|
|
1724
|
+
|
|
1725
|
+
def __init__(self, message: str | Content | None = None, **kwargs: Any) -> None:
|
|
1726
|
+
"""Initialize a summarization notification message.
|
|
1727
|
+
|
|
1728
|
+
Args:
|
|
1729
|
+
message: Optional message override used when rehydrating from the
|
|
1730
|
+
message store.
|
|
1731
|
+
|
|
1732
|
+
Defaults to the standard summary notification.
|
|
1733
|
+
**kwargs: Additional arguments passed to parent.
|
|
1734
|
+
"""
|
|
1735
|
+
self._raw_message = message
|
|
1736
|
+
# Pass the default text to AppMessage for _content serialization;
|
|
1737
|
+
# render() supplies theme-aware styling at display time.
|
|
1738
|
+
super().__init__(message or "✓ Conversation offloaded", **kwargs)
|
|
1739
|
+
|
|
1740
|
+
def render(self) -> Content:
|
|
1741
|
+
"""Render with theme-aware colors.
|
|
1742
|
+
|
|
1743
|
+
Returns:
|
|
1744
|
+
Styled summarization content with theme-appropriate color.
|
|
1745
|
+
"""
|
|
1746
|
+
colors = theme.get_theme_colors(self)
|
|
1747
|
+
if self._raw_message is None:
|
|
1748
|
+
return Content.styled("✓ Conversation offloaded", f"bold {colors.primary}")
|
|
1749
|
+
if isinstance(self._raw_message, Content):
|
|
1750
|
+
return self._raw_message
|
|
1751
|
+
return Content.styled(self._raw_message, f"bold {colors.primary}")
|