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,1905 @@
|
|
|
1
|
+
"""Interactive thread selector screen for /threads command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from typing import TYPE_CHECKING, ClassVar, cast
|
|
10
|
+
|
|
11
|
+
from rich.cells import cell_len
|
|
12
|
+
from textual.binding import Binding, BindingType
|
|
13
|
+
from textual.color import Color as TColor
|
|
14
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
15
|
+
from textual.content import Content
|
|
16
|
+
from textual.css.query import NoMatches
|
|
17
|
+
from textual.fuzzy import Matcher
|
|
18
|
+
from textual.message import Message
|
|
19
|
+
from textual.screen import ModalScreen
|
|
20
|
+
from textual.style import Style as TStyle
|
|
21
|
+
from textual.widgets import Checkbox, Input, Static
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
|
|
26
|
+
from textual.app import ComposeResult
|
|
27
|
+
from textual.events import Click, Key
|
|
28
|
+
|
|
29
|
+
from docagent_cli.sessions import ThreadInfo
|
|
30
|
+
|
|
31
|
+
from docagent_cli import theme
|
|
32
|
+
from docagent_cli.config import (
|
|
33
|
+
build_langsmith_thread_url,
|
|
34
|
+
get_glyphs,
|
|
35
|
+
is_ascii_mode,
|
|
36
|
+
)
|
|
37
|
+
from docagent_cli.widgets._links import open_style_link
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_URL_FETCH_TIMEOUT = 2.0
|
|
42
|
+
"""Seconds to wait for LangSmith thread-URL resolution before giving up."""
|
|
43
|
+
|
|
44
|
+
_column_widths_cache: (
|
|
45
|
+
tuple[
|
|
46
|
+
tuple[tuple[str, str | None], ...], # (thread_id, checkpoint_id) fingerprint
|
|
47
|
+
frozenset[str], # visible column keys
|
|
48
|
+
bool, # relative_time
|
|
49
|
+
dict[str, int | None], # computed widths
|
|
50
|
+
]
|
|
51
|
+
| None
|
|
52
|
+
) = None
|
|
53
|
+
"""Module-level cache so repeated `/threads` opens skip column-width computation
|
|
54
|
+
when the inputs (thread data + config) haven't changed."""
|
|
55
|
+
|
|
56
|
+
_COL_TID = 10
|
|
57
|
+
_COL_AGENT = 12
|
|
58
|
+
_COL_MSGS = 4
|
|
59
|
+
_COL_BRANCH = 16
|
|
60
|
+
_COL_TIMESTAMP = None
|
|
61
|
+
_MAX_SEARCH_TEXT_LEN = 200
|
|
62
|
+
_COL_PROMPT = None
|
|
63
|
+
_AUTO_WIDTH_COLUMNS = {"agent_name", "created_at", "updated_at", "cwd"}
|
|
64
|
+
_COLUMN_ORDER = (
|
|
65
|
+
"thread_id",
|
|
66
|
+
"agent_name",
|
|
67
|
+
"messages",
|
|
68
|
+
"created_at",
|
|
69
|
+
"updated_at",
|
|
70
|
+
"git_branch",
|
|
71
|
+
"cwd",
|
|
72
|
+
"initial_prompt",
|
|
73
|
+
)
|
|
74
|
+
_COLUMN_WIDTHS: dict[str, int | None] = {
|
|
75
|
+
"thread_id": _COL_TID,
|
|
76
|
+
"agent_name": _COL_AGENT,
|
|
77
|
+
"messages": _COL_MSGS,
|
|
78
|
+
"created_at": _COL_TIMESTAMP,
|
|
79
|
+
"updated_at": _COL_TIMESTAMP,
|
|
80
|
+
"git_branch": _COL_BRANCH,
|
|
81
|
+
"cwd": None,
|
|
82
|
+
"initial_prompt": _COL_PROMPT,
|
|
83
|
+
}
|
|
84
|
+
_COLUMN_LABELS = {
|
|
85
|
+
"thread_id": "Thread ID",
|
|
86
|
+
"agent_name": "Agent",
|
|
87
|
+
"messages": "Msgs",
|
|
88
|
+
"created_at": "Created",
|
|
89
|
+
"updated_at": "Updated",
|
|
90
|
+
"git_branch": "Branch",
|
|
91
|
+
"cwd": "Location",
|
|
92
|
+
"initial_prompt": "Prompt",
|
|
93
|
+
}
|
|
94
|
+
_COLUMN_TOGGLE_LABELS = {
|
|
95
|
+
"thread_id": "Thread ID",
|
|
96
|
+
"agent_name": "Agent Name",
|
|
97
|
+
"messages": "# Messages",
|
|
98
|
+
"created_at": "Created At",
|
|
99
|
+
"updated_at": "Updated At",
|
|
100
|
+
"git_branch": "Git Branch",
|
|
101
|
+
"cwd": "Working Directory",
|
|
102
|
+
"initial_prompt": "Initial Prompt",
|
|
103
|
+
}
|
|
104
|
+
# Reserved for future right-aligned columns (e.g., message counts).
|
|
105
|
+
_RIGHT_ALIGNED_COLUMNS: set[str] = set()
|
|
106
|
+
_SWITCH_ID_PREFIX = "thread-column-"
|
|
107
|
+
_SORT_SWITCH_ID = "thread-sort-toggle"
|
|
108
|
+
_RELATIVE_TIME_SWITCH_ID = "thread-relative-time"
|
|
109
|
+
_CELL_PADDING_RIGHT = 1
|
|
110
|
+
|
|
111
|
+
_FormatFns = tuple[
|
|
112
|
+
"Callable[[str | None], str]", # format_path
|
|
113
|
+
"Callable[[str | None], str]", # format_relative_timestamp
|
|
114
|
+
"Callable[[str | None], str]", # format_timestamp
|
|
115
|
+
]
|
|
116
|
+
"""Cached `(format_path, format_relative_timestamp, format_timestamp)`.
|
|
117
|
+
|
|
118
|
+
Resolved once on first use via `_get_format_fns()` to avoid the overhead of
|
|
119
|
+
a per-call deferred import inside the hot `_format_column_value` loop.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
_format_fns_cache: _FormatFns | None = None
|
|
123
|
+
"""Cached format functions, populated on first call to `_get_format_fns()`."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_format_fns() -> _FormatFns:
|
|
127
|
+
"""Return cached `(format_path, format_relative_timestamp, format_timestamp)`."""
|
|
128
|
+
global _format_fns_cache # noqa: PLW0603
|
|
129
|
+
if _format_fns_cache is not None:
|
|
130
|
+
return _format_fns_cache
|
|
131
|
+
from docagent_cli.sessions import (
|
|
132
|
+
format_path,
|
|
133
|
+
format_relative_timestamp,
|
|
134
|
+
format_timestamp,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
_format_fns_cache = (format_path, format_relative_timestamp, format_timestamp)
|
|
138
|
+
return _format_fns_cache
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _apply_column_width(
|
|
142
|
+
cell: Static, key: str, column_widths: Mapping[str, int | None]
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Apply an explicit width to a table cell when one is configured.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
cell: The cell widget to size.
|
|
148
|
+
key: Column key for the cell.
|
|
149
|
+
column_widths: Effective column widths for the current table state.
|
|
150
|
+
"""
|
|
151
|
+
width = column_widths.get(key)
|
|
152
|
+
if width is not None:
|
|
153
|
+
cell.styles.width = width
|
|
154
|
+
if key in _AUTO_WIDTH_COLUMNS:
|
|
155
|
+
cell.styles.min_width = width
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _active_sort_key(sort_by_updated: bool) -> str:
|
|
159
|
+
"""Return the active timestamp field used for sorting."""
|
|
160
|
+
return "updated_at" if sort_by_updated else "created_at"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _visible_column_keys(columns: dict[str, bool]) -> list[str]:
|
|
164
|
+
"""Return visible columns in the on-screen order.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
columns: Column visibility settings keyed by column name.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Visible column keys in display order.
|
|
171
|
+
"""
|
|
172
|
+
return [key for key in _COLUMN_ORDER if columns.get(key)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _collapse_whitespace(value: str) -> str:
|
|
176
|
+
"""Normalize a text value onto a single display line.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
value: Raw text to display in a single cell.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The input text collapsed to a single line.
|
|
183
|
+
"""
|
|
184
|
+
return " ".join(value.split())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _truncate_value(value: str, width: int | None) -> str:
|
|
188
|
+
"""Trim text to fit a fixed-width column.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
value: Raw cell text.
|
|
192
|
+
width: Maximum column width, or `None` for no truncation.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The possibly truncated display string.
|
|
196
|
+
"""
|
|
197
|
+
if width is None:
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
display = _collapse_whitespace(value)
|
|
201
|
+
if len(display) <= width:
|
|
202
|
+
return display
|
|
203
|
+
|
|
204
|
+
glyphs = get_glyphs()
|
|
205
|
+
ellipsis = glyphs.ellipsis
|
|
206
|
+
if width <= len(ellipsis):
|
|
207
|
+
return display[:width]
|
|
208
|
+
return display[: width - len(ellipsis)] + ellipsis
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _format_column_value(
|
|
212
|
+
thread: ThreadInfo, key: str, *, relative_time: bool = False
|
|
213
|
+
) -> str:
|
|
214
|
+
"""Return the display text for one thread column.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
thread: Thread metadata for the row.
|
|
218
|
+
key: Column key to format.
|
|
219
|
+
relative_time: Use relative timestamps instead of absolute.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Formatted display text for the column cell.
|
|
223
|
+
"""
|
|
224
|
+
format_path, format_relative_ts, format_ts = _get_format_fns()
|
|
225
|
+
fmt = format_relative_ts if relative_time else format_ts
|
|
226
|
+
|
|
227
|
+
value: str
|
|
228
|
+
if key == "thread_id":
|
|
229
|
+
# Strip UUID separators in the compact table preview so truncation
|
|
230
|
+
# never leaves a dangling trailing hyphen in the thread ID column.
|
|
231
|
+
value = thread["thread_id"].replace("-", "")
|
|
232
|
+
elif key == "agent_name":
|
|
233
|
+
value = thread.get("agent_name") or "unknown"
|
|
234
|
+
elif key == "messages":
|
|
235
|
+
raw_count = thread.get("message_count")
|
|
236
|
+
value = str(raw_count) if raw_count is not None else "..."
|
|
237
|
+
elif key == "created_at":
|
|
238
|
+
value = fmt(thread.get("created_at"))
|
|
239
|
+
elif key == "updated_at":
|
|
240
|
+
value = fmt(thread.get("updated_at"))
|
|
241
|
+
elif key == "git_branch":
|
|
242
|
+
value = thread.get("git_branch") or ""
|
|
243
|
+
elif key == "cwd":
|
|
244
|
+
value = format_path(thread.get("cwd"))
|
|
245
|
+
elif key == "initial_prompt":
|
|
246
|
+
value = _collapse_whitespace(thread.get("initial_prompt") or "")
|
|
247
|
+
else:
|
|
248
|
+
value = ""
|
|
249
|
+
|
|
250
|
+
return _truncate_value(value, _COLUMN_WIDTHS[key])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _format_header_label(key: str) -> str:
|
|
254
|
+
"""Return the rendered header label for a column."""
|
|
255
|
+
return _truncate_value(_COLUMN_LABELS[key], _COLUMN_WIDTHS[key])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _header_cell_classes(key: str, *, sort_key: str) -> str:
|
|
259
|
+
"""Return CSS classes for a header cell.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
key: Column key for the header cell.
|
|
263
|
+
sort_key: Currently active sort column.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Space-delimited classes for the header cell widget.
|
|
267
|
+
"""
|
|
268
|
+
classes = f"thread-cell thread-cell-{key}"
|
|
269
|
+
if key == sort_key:
|
|
270
|
+
classes += " thread-cell-sorted"
|
|
271
|
+
return classes
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ThreadOption(Horizontal):
|
|
275
|
+
"""A clickable thread option in the selector."""
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
thread: ThreadInfo,
|
|
280
|
+
index: int,
|
|
281
|
+
*,
|
|
282
|
+
columns: dict[str, bool],
|
|
283
|
+
column_widths: Mapping[str, int | None],
|
|
284
|
+
selected: bool,
|
|
285
|
+
current: bool,
|
|
286
|
+
relative_time: bool = False,
|
|
287
|
+
cell_text: dict[tuple[str, str], str] | None = None,
|
|
288
|
+
classes: str = "",
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Initialize a thread option row.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
thread: Thread metadata for the row.
|
|
294
|
+
index: The index of this option in the filtered list.
|
|
295
|
+
columns: Column visibility settings.
|
|
296
|
+
column_widths: Effective widths for the visible columns.
|
|
297
|
+
selected: Whether the row is highlighted.
|
|
298
|
+
current: Whether the row is the active thread.
|
|
299
|
+
relative_time: Use relative timestamps.
|
|
300
|
+
cell_text: Pre-formatted cell values keyed by `(thread_id, key)`.
|
|
301
|
+
classes: CSS classes for styling.
|
|
302
|
+
"""
|
|
303
|
+
super().__init__(classes=classes)
|
|
304
|
+
self.thread = thread
|
|
305
|
+
self.thread_id = thread["thread_id"]
|
|
306
|
+
self.index = index
|
|
307
|
+
self._columns = dict(columns)
|
|
308
|
+
self._column_widths = dict(column_widths)
|
|
309
|
+
self._selected = selected
|
|
310
|
+
self._current = current
|
|
311
|
+
self._relative_time = relative_time
|
|
312
|
+
self._cell_text = cell_text
|
|
313
|
+
|
|
314
|
+
class Clicked(Message):
|
|
315
|
+
"""Message sent when a thread option is clicked."""
|
|
316
|
+
|
|
317
|
+
def __init__(self, thread_id: str, index: int) -> None:
|
|
318
|
+
"""Initialize the Clicked message.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
thread_id: The thread identifier.
|
|
322
|
+
index: The index of the clicked option.
|
|
323
|
+
"""
|
|
324
|
+
super().__init__()
|
|
325
|
+
self.thread_id = thread_id
|
|
326
|
+
self.index = index
|
|
327
|
+
|
|
328
|
+
def compose(self) -> ComposeResult:
|
|
329
|
+
"""Compose the row cells.
|
|
330
|
+
|
|
331
|
+
Yields:
|
|
332
|
+
Static cells for each visible column.
|
|
333
|
+
"""
|
|
334
|
+
yield Static(
|
|
335
|
+
self._cursor_text(),
|
|
336
|
+
classes="thread-cell thread-cell-cursor",
|
|
337
|
+
markup=False,
|
|
338
|
+
)
|
|
339
|
+
tid = self.thread_id
|
|
340
|
+
for key in _visible_column_keys(self._columns):
|
|
341
|
+
if self._cell_text is not None and (tid, key) in self._cell_text:
|
|
342
|
+
text = self._cell_text[tid, key]
|
|
343
|
+
else:
|
|
344
|
+
text = _format_column_value(
|
|
345
|
+
self.thread, key, relative_time=self._relative_time
|
|
346
|
+
)
|
|
347
|
+
cell = Static(
|
|
348
|
+
text,
|
|
349
|
+
classes=f"thread-cell thread-cell-{key}",
|
|
350
|
+
expand=key == "initial_prompt",
|
|
351
|
+
markup=False,
|
|
352
|
+
)
|
|
353
|
+
_apply_column_width(cell, key, self._column_widths)
|
|
354
|
+
yield cell
|
|
355
|
+
|
|
356
|
+
def _cursor_text(self) -> str:
|
|
357
|
+
"""Return the cursor indicator for the row."""
|
|
358
|
+
return get_glyphs().cursor if self._selected else ""
|
|
359
|
+
|
|
360
|
+
def set_selected(self, selected: bool) -> None:
|
|
361
|
+
"""Update row selection styling without rebuilding the row.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
selected: Whether the row should be highlighted.
|
|
365
|
+
"""
|
|
366
|
+
self._selected = selected
|
|
367
|
+
if selected:
|
|
368
|
+
self.add_class("thread-option-selected")
|
|
369
|
+
else:
|
|
370
|
+
self.remove_class("thread-option-selected")
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
cursor = self.query_one(".thread-cell-cursor", Static)
|
|
374
|
+
except NoMatches:
|
|
375
|
+
return
|
|
376
|
+
cursor.update(self._cursor_text())
|
|
377
|
+
|
|
378
|
+
def on_click(self, event: Click) -> None:
|
|
379
|
+
"""Handle click on this option.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
event: The click event.
|
|
383
|
+
"""
|
|
384
|
+
event.stop()
|
|
385
|
+
self.post_message(self.Clicked(self.thread_id, self.index))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class DeleteThreadConfirmScreen(ModalScreen[bool]):
|
|
389
|
+
"""Confirmation modal shown before deleting a thread."""
|
|
390
|
+
|
|
391
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
392
|
+
Binding("enter", "confirm", "Confirm", show=False, priority=True),
|
|
393
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
CSS = """
|
|
397
|
+
DeleteThreadConfirmScreen {
|
|
398
|
+
align: center middle;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
DeleteThreadConfirmScreen > Vertical {
|
|
402
|
+
width: 50;
|
|
403
|
+
height: auto;
|
|
404
|
+
background: $surface;
|
|
405
|
+
border: solid red;
|
|
406
|
+
padding: 1 2;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
DeleteThreadConfirmScreen .thread-confirm-text {
|
|
410
|
+
text-align: center;
|
|
411
|
+
margin-bottom: 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
DeleteThreadConfirmScreen .thread-confirm-help {
|
|
415
|
+
text-align: center;
|
|
416
|
+
color: $text-muted;
|
|
417
|
+
text-style: italic;
|
|
418
|
+
}
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def __init__(self, thread_id: str) -> None:
|
|
422
|
+
"""Initialize the confirmation modal.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
thread_id: Thread ID the user is being asked to delete.
|
|
426
|
+
"""
|
|
427
|
+
super().__init__()
|
|
428
|
+
self._delete_thread_id = thread_id
|
|
429
|
+
|
|
430
|
+
def compose(self) -> ComposeResult:
|
|
431
|
+
"""Compose the confirmation dialog.
|
|
432
|
+
|
|
433
|
+
Yields:
|
|
434
|
+
Widgets for the delete confirmation prompt.
|
|
435
|
+
"""
|
|
436
|
+
with Vertical(id="delete-confirm"):
|
|
437
|
+
yield Static(
|
|
438
|
+
Content.from_markup(
|
|
439
|
+
"Delete thread [bold]$tid[/bold]?",
|
|
440
|
+
tid=self._delete_thread_id,
|
|
441
|
+
),
|
|
442
|
+
classes="thread-confirm-text",
|
|
443
|
+
)
|
|
444
|
+
yield Static(
|
|
445
|
+
"Enter to confirm, Esc to cancel",
|
|
446
|
+
classes="thread-confirm-help",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def action_confirm(self) -> None:
|
|
450
|
+
"""Confirm deletion."""
|
|
451
|
+
self.dismiss(True)
|
|
452
|
+
|
|
453
|
+
def action_cancel(self) -> None:
|
|
454
|
+
"""Cancel deletion."""
|
|
455
|
+
self.dismiss(False)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class ThreadSelectorScreen(ModalScreen[str | None]):
|
|
459
|
+
"""Modal dialog for browsing and resuming threads.
|
|
460
|
+
|
|
461
|
+
Displays recent threads with keyboard navigation, fuzzy search,
|
|
462
|
+
configurable columns, and delete support.
|
|
463
|
+
|
|
464
|
+
Returns a `thread_id` string on selection, or `None` on cancel.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
468
|
+
Binding("up", "move_up", "Up", show=False, priority=True),
|
|
469
|
+
Binding("k", "move_up", "Up", show=False, priority=True),
|
|
470
|
+
Binding("down", "move_down", "Down", show=False, priority=True),
|
|
471
|
+
Binding("j", "move_down", "Down", show=False, priority=True),
|
|
472
|
+
Binding("pageup", "page_up", "Page up", show=False, priority=True),
|
|
473
|
+
Binding("pagedown", "page_down", "Page down", show=False, priority=True),
|
|
474
|
+
Binding("enter", "select", "Select", show=False, priority=True),
|
|
475
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
476
|
+
Binding("ctrl+d", "delete_thread", "Delete", show=False, priority=True),
|
|
477
|
+
Binding("tab", "focus_next_filter", "Next filter", show=False, priority=True),
|
|
478
|
+
Binding(
|
|
479
|
+
"shift+tab",
|
|
480
|
+
"focus_previous_filter",
|
|
481
|
+
"Previous filter",
|
|
482
|
+
show=False,
|
|
483
|
+
priority=True,
|
|
484
|
+
),
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
CSS = """
|
|
488
|
+
ThreadSelectorScreen {
|
|
489
|
+
align: center middle;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
ThreadSelectorScreen #thread-selector-shell {
|
|
493
|
+
width: 100%;
|
|
494
|
+
max-width: 98%;
|
|
495
|
+
height: 90%;
|
|
496
|
+
background: $surface;
|
|
497
|
+
border: solid $primary;
|
|
498
|
+
padding: 1 2;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
ThreadSelectorScreen .thread-selector-title {
|
|
502
|
+
text-style: bold;
|
|
503
|
+
color: $primary;
|
|
504
|
+
text-align: center;
|
|
505
|
+
margin-bottom: 1;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
ThreadSelectorScreen #thread-filter {
|
|
509
|
+
margin-bottom: 1;
|
|
510
|
+
border: solid $primary-lighten-2;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
ThreadSelectorScreen #thread-filter:focus {
|
|
514
|
+
border: solid $primary;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
ThreadSelectorScreen .thread-selector-body {
|
|
518
|
+
height: 1fr;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
ThreadSelectorScreen .thread-table-pane {
|
|
522
|
+
width: 1fr;
|
|
523
|
+
min-width: 40;
|
|
524
|
+
height: 1fr;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
ThreadSelectorScreen .thread-controls {
|
|
528
|
+
width: 28;
|
|
529
|
+
min-width: 24;
|
|
530
|
+
height: 1fr;
|
|
531
|
+
margin-left: 1;
|
|
532
|
+
padding-left: 1;
|
|
533
|
+
border-left: solid $primary-lighten-2;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
ThreadSelectorScreen .thread-controls-title {
|
|
537
|
+
text-style: bold;
|
|
538
|
+
color: $primary;
|
|
539
|
+
margin-bottom: 1;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
ThreadSelectorScreen .thread-controls-help {
|
|
543
|
+
color: $text-muted;
|
|
544
|
+
margin-bottom: 1;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
ThreadSelectorScreen .thread-column-toggle {
|
|
548
|
+
width: 1fr;
|
|
549
|
+
height: auto;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
ThreadSelectorScreen .thread-list-header {
|
|
553
|
+
height: 1;
|
|
554
|
+
padding: 0 1;
|
|
555
|
+
color: $text-muted;
|
|
556
|
+
text-style: bold;
|
|
557
|
+
width: 100%;
|
|
558
|
+
overflow-x: hidden;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
ThreadSelectorScreen .thread-list-header .thread-cell-sorted {
|
|
562
|
+
color: $primary;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
ThreadSelectorScreen .thread-list {
|
|
566
|
+
height: 1fr;
|
|
567
|
+
min-height: 5;
|
|
568
|
+
scrollbar-gutter: stable;
|
|
569
|
+
background: $background;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
ThreadSelectorScreen .thread-option {
|
|
573
|
+
height: 1;
|
|
574
|
+
width: 100%;
|
|
575
|
+
padding: 0 1;
|
|
576
|
+
overflow-x: hidden;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
ThreadSelectorScreen .thread-option:hover {
|
|
580
|
+
background: $surface-lighten-1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
ThreadSelectorScreen .thread-option-selected {
|
|
584
|
+
background: $primary;
|
|
585
|
+
color: $background;
|
|
586
|
+
text-style: bold;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
ThreadSelectorScreen .thread-option-selected:hover {
|
|
590
|
+
background: $primary-lighten-1;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
ThreadSelectorScreen .thread-option-current {
|
|
594
|
+
text-style: italic;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
ThreadSelectorScreen .thread-cell {
|
|
598
|
+
height: 1;
|
|
599
|
+
padding-right: 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
ThreadSelectorScreen .thread-cell-cursor {
|
|
603
|
+
width: 2;
|
|
604
|
+
color: $primary;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
ThreadSelectorScreen .thread-cell-thread_id {
|
|
608
|
+
width: 10;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
ThreadSelectorScreen .thread-cell-agent_name {
|
|
612
|
+
width: auto;
|
|
613
|
+
overflow-x: hidden;
|
|
614
|
+
text-wrap: nowrap;
|
|
615
|
+
text-overflow: ellipsis;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
ThreadSelectorScreen .thread-cell-messages {
|
|
619
|
+
width: 4;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
ThreadSelectorScreen .thread-cell-created_at,
|
|
623
|
+
ThreadSelectorScreen .thread-cell-updated_at {
|
|
624
|
+
width: auto;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
ThreadSelectorScreen .thread-cell-git_branch {
|
|
628
|
+
width: 17;
|
|
629
|
+
overflow-x: hidden;
|
|
630
|
+
text-wrap: nowrap;
|
|
631
|
+
text-overflow: ellipsis;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
ThreadSelectorScreen .thread-cell-initial_prompt {
|
|
635
|
+
width: 1fr;
|
|
636
|
+
min-width: 1;
|
|
637
|
+
overflow-x: hidden;
|
|
638
|
+
text-wrap: nowrap;
|
|
639
|
+
text-overflow: ellipsis;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
ThreadSelectorScreen .thread-selector-help {
|
|
643
|
+
height: auto;
|
|
644
|
+
color: $text-muted;
|
|
645
|
+
text-style: italic;
|
|
646
|
+
margin-top: 1;
|
|
647
|
+
text-align: center;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
ThreadSelectorScreen .thread-empty {
|
|
651
|
+
color: $text-muted;
|
|
652
|
+
text-align: center;
|
|
653
|
+
margin-top: 2;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
def __init__(
|
|
659
|
+
self,
|
|
660
|
+
current_thread: str | None = None,
|
|
661
|
+
*,
|
|
662
|
+
thread_limit: int | None = None,
|
|
663
|
+
initial_threads: list[ThreadInfo] | None = None,
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Initialize the `ThreadSelectorScreen`.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
current_thread: The currently active thread ID (to highlight).
|
|
669
|
+
thread_limit: Maximum number of rows to fetch when querying DB.
|
|
670
|
+
initial_threads: Optional preloaded rows to render immediately.
|
|
671
|
+
"""
|
|
672
|
+
super().__init__()
|
|
673
|
+
self._current_thread = current_thread
|
|
674
|
+
self._thread_limit = thread_limit
|
|
675
|
+
self._threads: list[ThreadInfo] = (
|
|
676
|
+
list(initial_threads) if initial_threads is not None else []
|
|
677
|
+
)
|
|
678
|
+
self._filtered_threads: list[ThreadInfo] = list(self._threads)
|
|
679
|
+
self._has_initial_threads = initial_threads is not None
|
|
680
|
+
self._selected_index = 0
|
|
681
|
+
self._option_widgets: list[ThreadOption] = []
|
|
682
|
+
self._filter_text = ""
|
|
683
|
+
self._confirming_delete = False
|
|
684
|
+
self._render_lock = asyncio.Lock()
|
|
685
|
+
self._filter_input: Input | None = None
|
|
686
|
+
self._filter_controls: list[Input | Checkbox] | None = None
|
|
687
|
+
self._cell_text: dict[tuple[str, str], str] = {}
|
|
688
|
+
|
|
689
|
+
from docagent_cli.model_config import load_thread_config
|
|
690
|
+
|
|
691
|
+
cfg = load_thread_config()
|
|
692
|
+
self._columns = dict(cfg.columns)
|
|
693
|
+
self._relative_time = cfg.relative_time
|
|
694
|
+
self._sort_by_updated = cfg.sort_order == "updated_at"
|
|
695
|
+
|
|
696
|
+
# Cached threads are pre-sorted by updated_at DESC (the only sort
|
|
697
|
+
# order the cache stores). Skip the O(n log n) re-sort when that
|
|
698
|
+
# matches the user's preference.
|
|
699
|
+
if not (self._has_initial_threads and self._sort_by_updated):
|
|
700
|
+
self._apply_sort()
|
|
701
|
+
self._sync_selected_index()
|
|
702
|
+
self._column_widths = self._compute_column_widths()
|
|
703
|
+
|
|
704
|
+
@staticmethod
|
|
705
|
+
def _switch_id(column_key: str) -> str:
|
|
706
|
+
"""Return the DOM id for a column toggle switch."""
|
|
707
|
+
return f"{_SWITCH_ID_PREFIX}{column_key}"
|
|
708
|
+
|
|
709
|
+
@staticmethod
|
|
710
|
+
def _switch_column_key(switch_id: str | None) -> str | None:
|
|
711
|
+
"""Extract the column key from a switch id.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
switch_id: Widget id for a switch in the control panel.
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
The corresponding column key, or `None` for unrelated ids.
|
|
718
|
+
"""
|
|
719
|
+
if not switch_id or not switch_id.startswith(_SWITCH_ID_PREFIX):
|
|
720
|
+
return None
|
|
721
|
+
return switch_id.removeprefix(_SWITCH_ID_PREFIX)
|
|
722
|
+
|
|
723
|
+
def _sync_selected_index(self) -> None:
|
|
724
|
+
"""Select the current thread when it exists in the loaded rows."""
|
|
725
|
+
self._selected_index = 0
|
|
726
|
+
for i, thread in enumerate(self._filtered_threads):
|
|
727
|
+
if thread["thread_id"] == self._current_thread:
|
|
728
|
+
self._selected_index = i
|
|
729
|
+
break
|
|
730
|
+
|
|
731
|
+
def _build_title(self, thread_url: str | None = None) -> str | Content:
|
|
732
|
+
"""Build the title, optionally with a clickable thread ID link.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
thread_url: LangSmith thread URL. When provided, the thread ID is
|
|
736
|
+
rendered as a clickable hyperlink.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
Plain string or `Content` with an embedded hyperlink.
|
|
740
|
+
"""
|
|
741
|
+
if not self._current_thread:
|
|
742
|
+
return "Select Thread"
|
|
743
|
+
if thread_url:
|
|
744
|
+
return Content.assemble(
|
|
745
|
+
"Select Thread (current: ",
|
|
746
|
+
(
|
|
747
|
+
self._current_thread,
|
|
748
|
+
TStyle(
|
|
749
|
+
foreground=TColor.parse(theme.get_theme_colors(self).primary),
|
|
750
|
+
link=thread_url,
|
|
751
|
+
),
|
|
752
|
+
),
|
|
753
|
+
")",
|
|
754
|
+
)
|
|
755
|
+
return f"Select Thread (current: {self._current_thread})"
|
|
756
|
+
|
|
757
|
+
def _build_help_text(self) -> str:
|
|
758
|
+
"""Build the footer help text for the selector.
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
Footer guidance for the active selector bindings.
|
|
762
|
+
"""
|
|
763
|
+
glyphs = get_glyphs()
|
|
764
|
+
lines = (
|
|
765
|
+
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate"
|
|
766
|
+
f" {glyphs.bullet} Enter select"
|
|
767
|
+
f" {glyphs.bullet} Tab/Shift+Tab focus options"
|
|
768
|
+
f" {glyphs.bullet} Space toggle option"
|
|
769
|
+
f" {glyphs.bullet} Ctrl+D delete"
|
|
770
|
+
f" {glyphs.bullet} Esc cancel"
|
|
771
|
+
)
|
|
772
|
+
limit = self._effective_thread_limit()
|
|
773
|
+
if len(self._threads) >= limit:
|
|
774
|
+
lines += (
|
|
775
|
+
f"\nShowing last {limit} threads. "
|
|
776
|
+
"Set DA_CLI_RECENT_THREADS to override."
|
|
777
|
+
)
|
|
778
|
+
return lines
|
|
779
|
+
|
|
780
|
+
def _effective_thread_limit(self) -> int:
|
|
781
|
+
"""Return the resolved thread limit for display purposes."""
|
|
782
|
+
if self._thread_limit is not None:
|
|
783
|
+
return self._thread_limit
|
|
784
|
+
from docagent_cli.sessions import get_thread_limit
|
|
785
|
+
|
|
786
|
+
return get_thread_limit()
|
|
787
|
+
|
|
788
|
+
def _format_sort_toggle_label(self) -> str:
|
|
789
|
+
"""Return the control-panel sort label for the toggle switch."""
|
|
790
|
+
label = "Updated At" if self._sort_by_updated else "Created At"
|
|
791
|
+
return f"Sort by {label}"
|
|
792
|
+
|
|
793
|
+
def _get_filter_input(self) -> Input:
|
|
794
|
+
"""Return the cached search input widget."""
|
|
795
|
+
if self._filter_input is None:
|
|
796
|
+
self._filter_input = self.query_one("#thread-filter", Input)
|
|
797
|
+
return self._filter_input
|
|
798
|
+
|
|
799
|
+
def _filter_focus_order(self) -> list[Input | Checkbox]:
|
|
800
|
+
"""Return the cached tab order for filter controls in the side panel."""
|
|
801
|
+
if self._filter_controls is None:
|
|
802
|
+
filter_input = self._get_filter_input()
|
|
803
|
+
sort_switch = self.query_one(f"#{_SORT_SWITCH_ID}", Checkbox)
|
|
804
|
+
relative_switch = self.query_one(f"#{_RELATIVE_TIME_SWITCH_ID}", Checkbox)
|
|
805
|
+
column_switches = [
|
|
806
|
+
self.query_one(f"#{self._switch_id(key)}", Checkbox)
|
|
807
|
+
for key in _COLUMN_ORDER
|
|
808
|
+
]
|
|
809
|
+
self._filter_controls = [
|
|
810
|
+
filter_input,
|
|
811
|
+
sort_switch,
|
|
812
|
+
relative_switch,
|
|
813
|
+
*column_switches,
|
|
814
|
+
]
|
|
815
|
+
return self._filter_controls
|
|
816
|
+
|
|
817
|
+
def compose(self) -> ComposeResult:
|
|
818
|
+
"""Compose the screen layout.
|
|
819
|
+
|
|
820
|
+
Yields:
|
|
821
|
+
Widgets for the thread selector UI.
|
|
822
|
+
"""
|
|
823
|
+
with Vertical(id="thread-selector-shell"):
|
|
824
|
+
yield Static(
|
|
825
|
+
self._build_title(), classes="thread-selector-title", id="thread-title"
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
yield Input(
|
|
829
|
+
placeholder="Type to search threads...",
|
|
830
|
+
select_on_focus=False,
|
|
831
|
+
id="thread-filter",
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
with Horizontal(classes="thread-selector-body"):
|
|
835
|
+
with Vertical(classes="thread-table-pane"):
|
|
836
|
+
with Horizontal(
|
|
837
|
+
classes="thread-list-header",
|
|
838
|
+
id="thread-header",
|
|
839
|
+
):
|
|
840
|
+
yield Static("", classes="thread-cell thread-cell-cursor")
|
|
841
|
+
sort_key = _active_sort_key(self._sort_by_updated)
|
|
842
|
+
for key in _visible_column_keys(self._columns):
|
|
843
|
+
cell = Static(
|
|
844
|
+
_format_header_label(key),
|
|
845
|
+
classes=_header_cell_classes(key, sort_key=sort_key),
|
|
846
|
+
expand=key == "initial_prompt",
|
|
847
|
+
markup=False,
|
|
848
|
+
)
|
|
849
|
+
_apply_column_width(cell, key, self._column_widths)
|
|
850
|
+
yield cell
|
|
851
|
+
|
|
852
|
+
with VerticalScroll(classes="thread-list"):
|
|
853
|
+
if self._has_initial_threads:
|
|
854
|
+
if self._filtered_threads:
|
|
855
|
+
self._option_widgets, _ = self._create_option_widgets()
|
|
856
|
+
yield from self._option_widgets
|
|
857
|
+
else:
|
|
858
|
+
yield Static(
|
|
859
|
+
Content.styled("No threads found", "dim"),
|
|
860
|
+
classes="thread-empty",
|
|
861
|
+
)
|
|
862
|
+
else:
|
|
863
|
+
yield Static(
|
|
864
|
+
Content.styled("Loading threads...", "dim"),
|
|
865
|
+
classes="thread-empty",
|
|
866
|
+
id="thread-loading",
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
with Vertical(classes="thread-controls"):
|
|
870
|
+
yield Static("Options", classes="thread-controls-title")
|
|
871
|
+
yield Static(
|
|
872
|
+
(
|
|
873
|
+
"Tab through sort and column toggles. "
|
|
874
|
+
"Column visibility persists between sessions."
|
|
875
|
+
),
|
|
876
|
+
classes="thread-controls-help",
|
|
877
|
+
markup=False,
|
|
878
|
+
)
|
|
879
|
+
yield Checkbox(
|
|
880
|
+
self._format_sort_toggle_label(),
|
|
881
|
+
self._sort_by_updated,
|
|
882
|
+
id=_SORT_SWITCH_ID,
|
|
883
|
+
classes="thread-column-toggle",
|
|
884
|
+
compact=True,
|
|
885
|
+
)
|
|
886
|
+
yield Checkbox(
|
|
887
|
+
"Relative Timestamps",
|
|
888
|
+
self._relative_time,
|
|
889
|
+
id=_RELATIVE_TIME_SWITCH_ID,
|
|
890
|
+
classes="thread-column-toggle",
|
|
891
|
+
compact=True,
|
|
892
|
+
)
|
|
893
|
+
for key in _COLUMN_ORDER:
|
|
894
|
+
yield Checkbox(
|
|
895
|
+
_COLUMN_TOGGLE_LABELS[key],
|
|
896
|
+
self._columns.get(key, False),
|
|
897
|
+
id=self._switch_id(key),
|
|
898
|
+
classes="thread-column-toggle",
|
|
899
|
+
compact=True,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
yield Static(
|
|
903
|
+
self._build_help_text(),
|
|
904
|
+
classes="thread-selector-help",
|
|
905
|
+
id="thread-help",
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
async def on_mount(self) -> None:
|
|
909
|
+
"""Fetch threads, configure border for ASCII terminals, and build the list."""
|
|
910
|
+
if is_ascii_mode():
|
|
911
|
+
container = self.query_one("#thread-selector-shell", Vertical)
|
|
912
|
+
colors = theme.get_theme_colors(self)
|
|
913
|
+
container.styles.border = ("ascii", colors.success)
|
|
914
|
+
|
|
915
|
+
filter_input = self._get_filter_input()
|
|
916
|
+
self._filter_focus_order()
|
|
917
|
+
filter_input.focus()
|
|
918
|
+
|
|
919
|
+
if self._has_initial_threads:
|
|
920
|
+
self.call_after_refresh(self._scroll_selected_into_view)
|
|
921
|
+
if self._current_thread:
|
|
922
|
+
self._resolve_thread_url()
|
|
923
|
+
|
|
924
|
+
if self._has_initial_threads:
|
|
925
|
+
# Defer by one message cycle so Textual finishes processing
|
|
926
|
+
# mount messages before we start the DB refresh.
|
|
927
|
+
self.call_after_refresh(self._start_thread_load)
|
|
928
|
+
else:
|
|
929
|
+
# _load_threads replaces self._threads and schedules background
|
|
930
|
+
# enrichment (message counts, initial prompts) after load
|
|
931
|
+
# completes. Launch immediately when there are no cached rows
|
|
932
|
+
# to render.
|
|
933
|
+
self.run_worker(
|
|
934
|
+
self._load_threads, exclusive=True, group="thread-selector-load"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def _start_thread_load(self) -> None:
|
|
938
|
+
"""Launch the thread-load worker after the initial layout pass."""
|
|
939
|
+
if not self.is_attached:
|
|
940
|
+
return
|
|
941
|
+
self.run_worker(
|
|
942
|
+
self._load_threads, exclusive=True, group="thread-selector-load"
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
946
|
+
"""Filter threads as user types.
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
event: The input changed event.
|
|
950
|
+
"""
|
|
951
|
+
self._filter_text = event.value
|
|
952
|
+
self._schedule_filter_and_rebuild()
|
|
953
|
+
|
|
954
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
955
|
+
"""Handle Enter key when filter input is focused.
|
|
956
|
+
|
|
957
|
+
Args:
|
|
958
|
+
event: The input submitted event.
|
|
959
|
+
"""
|
|
960
|
+
event.stop()
|
|
961
|
+
self.action_select()
|
|
962
|
+
|
|
963
|
+
def on_key(self, event: Key) -> None:
|
|
964
|
+
"""Return focus to search when letters are typed from other controls.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
event: The key event.
|
|
968
|
+
"""
|
|
969
|
+
if self._confirming_delete:
|
|
970
|
+
return
|
|
971
|
+
|
|
972
|
+
filter_input = self._get_filter_input()
|
|
973
|
+
if filter_input.has_focus:
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
character = event.character
|
|
977
|
+
if not character or not character.isalpha():
|
|
978
|
+
return
|
|
979
|
+
|
|
980
|
+
filter_input.focus()
|
|
981
|
+
filter_input.insert_text_at_cursor(character)
|
|
982
|
+
self.set_timer(0.01, self._collapse_search_selection)
|
|
983
|
+
event.stop()
|
|
984
|
+
|
|
985
|
+
def _collapse_search_selection(self) -> None:
|
|
986
|
+
"""Place the search cursor at the end without an active selection."""
|
|
987
|
+
filter_input = self._get_filter_input()
|
|
988
|
+
filter_input.selection = type(filter_input.selection).cursor(
|
|
989
|
+
len(filter_input.value)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
993
|
+
"""Route sort, relative-time, and column-visibility checkbox changes.
|
|
994
|
+
|
|
995
|
+
Args:
|
|
996
|
+
event: The checkbox change event.
|
|
997
|
+
"""
|
|
998
|
+
if event.checkbox.id == _SORT_SWITCH_ID:
|
|
999
|
+
if self._sort_by_updated == event.value:
|
|
1000
|
+
return
|
|
1001
|
+
self._sort_by_updated = event.value
|
|
1002
|
+
self._apply_sort()
|
|
1003
|
+
self._sync_selected_index()
|
|
1004
|
+
self._update_help_widgets()
|
|
1005
|
+
self._schedule_list_rebuild()
|
|
1006
|
+
|
|
1007
|
+
self._persist_sort_order("updated_at" if event.value else "created_at")
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
if event.checkbox.id == _RELATIVE_TIME_SWITCH_ID:
|
|
1011
|
+
if self._relative_time == event.value:
|
|
1012
|
+
return
|
|
1013
|
+
self._relative_time = event.value
|
|
1014
|
+
|
|
1015
|
+
from docagent_cli.model_config import save_thread_relative_time
|
|
1016
|
+
|
|
1017
|
+
self.run_worker(
|
|
1018
|
+
asyncio.to_thread(save_thread_relative_time, event.value),
|
|
1019
|
+
group="thread-selector-save",
|
|
1020
|
+
)
|
|
1021
|
+
self._schedule_list_rebuild()
|
|
1022
|
+
return
|
|
1023
|
+
|
|
1024
|
+
column_key = self._switch_column_key(event.checkbox.id)
|
|
1025
|
+
if column_key is None or column_key not in self._columns:
|
|
1026
|
+
return
|
|
1027
|
+
if self._columns[column_key] == event.value:
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
self._columns[column_key] = event.value
|
|
1031
|
+
self._apply_sort()
|
|
1032
|
+
self._sync_selected_index()
|
|
1033
|
+
self._update_help_widgets()
|
|
1034
|
+
if event.value and column_key in {"messages", "initial_prompt"}:
|
|
1035
|
+
self._schedule_checkpoint_enrichment()
|
|
1036
|
+
|
|
1037
|
+
from docagent_cli.model_config import save_thread_columns
|
|
1038
|
+
|
|
1039
|
+
snapshot = dict(self._columns)
|
|
1040
|
+
self.run_worker(
|
|
1041
|
+
asyncio.to_thread(save_thread_columns, snapshot),
|
|
1042
|
+
group="thread-selector-save",
|
|
1043
|
+
)
|
|
1044
|
+
self._schedule_list_rebuild()
|
|
1045
|
+
|
|
1046
|
+
def _update_filtered_list(self) -> None:
|
|
1047
|
+
"""Update filtered threads based on search text using fuzzy matching."""
|
|
1048
|
+
query = self._filter_text.strip()
|
|
1049
|
+
if not query:
|
|
1050
|
+
self._filtered_threads = list(self._threads)
|
|
1051
|
+
self._apply_sort()
|
|
1052
|
+
self._sync_selected_index()
|
|
1053
|
+
self._column_widths = self._compute_column_widths()
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
tokens = query.split()
|
|
1057
|
+
try:
|
|
1058
|
+
matchers = [Matcher(token, case_sensitive=False) for token in tokens]
|
|
1059
|
+
scored: list[tuple[float, ThreadInfo]] = []
|
|
1060
|
+
for thread in self._threads:
|
|
1061
|
+
search_text = self._get_search_text(thread)
|
|
1062
|
+
scores = [matcher.match(search_text) for matcher in matchers]
|
|
1063
|
+
if all(score > 0 for score in scores):
|
|
1064
|
+
scored.append((min(scores), thread))
|
|
1065
|
+
except Exception:
|
|
1066
|
+
logger.warning(
|
|
1067
|
+
"Fuzzy matcher failed for query %r, falling back to full list",
|
|
1068
|
+
query,
|
|
1069
|
+
exc_info=True,
|
|
1070
|
+
)
|
|
1071
|
+
self._filtered_threads = list(self._threads)
|
|
1072
|
+
self._apply_sort()
|
|
1073
|
+
self._sync_selected_index()
|
|
1074
|
+
self._column_widths = self._compute_column_widths()
|
|
1075
|
+
return
|
|
1076
|
+
|
|
1077
|
+
sort_key = _active_sort_key(self._sort_by_updated)
|
|
1078
|
+
self._filtered_threads = [
|
|
1079
|
+
thread
|
|
1080
|
+
for _, thread in sorted(
|
|
1081
|
+
scored,
|
|
1082
|
+
key=lambda item: (
|
|
1083
|
+
item[0],
|
|
1084
|
+
item[1].get(sort_key) or "",
|
|
1085
|
+
item[1].get("updated_at") or "",
|
|
1086
|
+
item[1]["thread_id"],
|
|
1087
|
+
),
|
|
1088
|
+
reverse=True,
|
|
1089
|
+
)
|
|
1090
|
+
]
|
|
1091
|
+
self._selected_index = 0
|
|
1092
|
+
self._column_widths = self._compute_column_widths()
|
|
1093
|
+
|
|
1094
|
+
def _compute_column_widths(self) -> dict[str, int | None]:
|
|
1095
|
+
"""Return effective widths for the current table state.
|
|
1096
|
+
|
|
1097
|
+
Textual's `width: auto` computes per-widget widths, so this method
|
|
1098
|
+
derives shared widths from the visible data instead. Also populates
|
|
1099
|
+
`self._cell_text` as a side effect so that `ThreadOption.compose()` can
|
|
1100
|
+
reuse the formatted strings.
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
Dict mapping column keys to their effective cell widths, with
|
|
1104
|
+
`None` for flex columns.
|
|
1105
|
+
"""
|
|
1106
|
+
global _column_widths_cache # noqa: PLW0603 # Module-level cache requires global statement
|
|
1107
|
+
|
|
1108
|
+
visible_keys = _visible_column_keys(self._columns)
|
|
1109
|
+
visible = frozenset(visible_keys)
|
|
1110
|
+
fingerprint = tuple(
|
|
1111
|
+
(t["thread_id"], t.get("latest_checkpoint_id"))
|
|
1112
|
+
for t in self._filtered_threads
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
if _column_widths_cache is not None:
|
|
1116
|
+
fp, vis, rel, cached_widths = _column_widths_cache
|
|
1117
|
+
if (
|
|
1118
|
+
fp == fingerprint
|
|
1119
|
+
and vis == visible
|
|
1120
|
+
and rel == self._relative_time
|
|
1121
|
+
and self._cell_text
|
|
1122
|
+
):
|
|
1123
|
+
return dict(cached_widths)
|
|
1124
|
+
|
|
1125
|
+
# Pre-format every visible cell in one pass.
|
|
1126
|
+
cell_text: dict[tuple[str, str], str] = {}
|
|
1127
|
+
for thread in self._filtered_threads:
|
|
1128
|
+
tid = thread["thread_id"]
|
|
1129
|
+
for key in visible_keys:
|
|
1130
|
+
cell_text[tid, key] = _format_column_value(
|
|
1131
|
+
thread, key, relative_time=self._relative_time
|
|
1132
|
+
)
|
|
1133
|
+
self._cell_text = cell_text
|
|
1134
|
+
|
|
1135
|
+
# Derive auto-widths from the pre-formatted values.
|
|
1136
|
+
widths = dict(_COLUMN_WIDTHS)
|
|
1137
|
+
for key in _AUTO_WIDTH_COLUMNS:
|
|
1138
|
+
if key not in visible:
|
|
1139
|
+
continue
|
|
1140
|
+
header_len = cell_len(_format_header_label(key))
|
|
1141
|
+
max_cell = max(
|
|
1142
|
+
(
|
|
1143
|
+
cell_len(cell_text[t["thread_id"], key])
|
|
1144
|
+
for t in self._filtered_threads
|
|
1145
|
+
),
|
|
1146
|
+
default=0,
|
|
1147
|
+
)
|
|
1148
|
+
widths[key] = max(header_len, max_cell) + _CELL_PADDING_RIGHT
|
|
1149
|
+
|
|
1150
|
+
_column_widths_cache = (fingerprint, visible, self._relative_time, widths)
|
|
1151
|
+
return widths
|
|
1152
|
+
|
|
1153
|
+
@staticmethod
|
|
1154
|
+
def _get_search_text(thread: ThreadInfo) -> str:
|
|
1155
|
+
"""Build searchable text from thread fields.
|
|
1156
|
+
|
|
1157
|
+
The result is capped at `_MAX_SEARCH_TEXT_LEN` characters so that
|
|
1158
|
+
Textual's fuzzy `Matcher` (which uses recursive backtracking) does
|
|
1159
|
+
not hit exponential performance on long initial prompts with
|
|
1160
|
+
repeated characters.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
thread: Thread metadata.
|
|
1164
|
+
|
|
1165
|
+
Returns:
|
|
1166
|
+
Concatenated searchable string, truncated to a safe length.
|
|
1167
|
+
"""
|
|
1168
|
+
parts = [
|
|
1169
|
+
thread["thread_id"],
|
|
1170
|
+
thread.get("agent_name") or "",
|
|
1171
|
+
thread.get("git_branch") or "",
|
|
1172
|
+
thread.get("initial_prompt") or "",
|
|
1173
|
+
]
|
|
1174
|
+
text = " ".join(parts)
|
|
1175
|
+
return text[:_MAX_SEARCH_TEXT_LEN]
|
|
1176
|
+
|
|
1177
|
+
def _schedule_filter_and_rebuild(self) -> None:
|
|
1178
|
+
"""Queue a filter + rebuild, coalescing rapid keystrokes."""
|
|
1179
|
+
self.run_worker(
|
|
1180
|
+
self._filter_and_build,
|
|
1181
|
+
exclusive=True,
|
|
1182
|
+
group="thread-selector-render",
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
async def _filter_and_build(self) -> None:
|
|
1186
|
+
"""Run fuzzy filtering in a thread then rebuild the list."""
|
|
1187
|
+
query = self._filter_text.strip()
|
|
1188
|
+
threads = list(self._threads)
|
|
1189
|
+
sort_by_updated = self._sort_by_updated
|
|
1190
|
+
|
|
1191
|
+
filtered = await asyncio.to_thread(
|
|
1192
|
+
self._compute_filtered, query, threads, sort_by_updated
|
|
1193
|
+
)
|
|
1194
|
+
self._filtered_threads = filtered
|
|
1195
|
+
if query:
|
|
1196
|
+
self._selected_index = 0
|
|
1197
|
+
else:
|
|
1198
|
+
self._sync_selected_index()
|
|
1199
|
+
self._column_widths = self._compute_column_widths()
|
|
1200
|
+
await self._build_list(recompute_widths=False)
|
|
1201
|
+
|
|
1202
|
+
@staticmethod
|
|
1203
|
+
def _compute_filtered(
|
|
1204
|
+
query: str,
|
|
1205
|
+
threads: list[ThreadInfo],
|
|
1206
|
+
sort_by_updated: bool,
|
|
1207
|
+
) -> list[ThreadInfo]:
|
|
1208
|
+
"""Compute filtered thread list off the main thread.
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
query: Current search query text.
|
|
1212
|
+
threads: Full thread list snapshot.
|
|
1213
|
+
sort_by_updated: Whether to sort by `updated_at`.
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
Filtered and sorted thread list.
|
|
1217
|
+
"""
|
|
1218
|
+
sort_key = _active_sort_key(sort_by_updated)
|
|
1219
|
+
|
|
1220
|
+
if not query:
|
|
1221
|
+
result = list(threads)
|
|
1222
|
+
result.sort(key=lambda t: t.get(sort_key) or "", reverse=True)
|
|
1223
|
+
return result
|
|
1224
|
+
|
|
1225
|
+
tokens = query.split()
|
|
1226
|
+
try:
|
|
1227
|
+
matchers = [Matcher(token, case_sensitive=False) for token in tokens]
|
|
1228
|
+
scored: list[tuple[float, ThreadInfo]] = []
|
|
1229
|
+
for thread in threads:
|
|
1230
|
+
search_text = ThreadSelectorScreen._get_search_text(thread)
|
|
1231
|
+
scores = [matcher.match(search_text) for matcher in matchers]
|
|
1232
|
+
if all(score > 0 for score in scores):
|
|
1233
|
+
scored.append((min(scores), thread))
|
|
1234
|
+
except Exception:
|
|
1235
|
+
logger.warning(
|
|
1236
|
+
"Fuzzy matcher failed for query %r, falling back to full list",
|
|
1237
|
+
query,
|
|
1238
|
+
exc_info=True,
|
|
1239
|
+
)
|
|
1240
|
+
result = list(threads)
|
|
1241
|
+
result.sort(key=lambda t: t.get(sort_key) or "", reverse=True)
|
|
1242
|
+
return result
|
|
1243
|
+
|
|
1244
|
+
return [
|
|
1245
|
+
thread
|
|
1246
|
+
for _, thread in sorted(
|
|
1247
|
+
scored,
|
|
1248
|
+
key=lambda item: (
|
|
1249
|
+
item[0],
|
|
1250
|
+
item[1].get(sort_key) or "",
|
|
1251
|
+
item[1].get("updated_at") or "",
|
|
1252
|
+
item[1]["thread_id"],
|
|
1253
|
+
),
|
|
1254
|
+
reverse=True,
|
|
1255
|
+
)
|
|
1256
|
+
]
|
|
1257
|
+
|
|
1258
|
+
def _schedule_list_rebuild(self) -> None:
|
|
1259
|
+
"""Queue a list rebuild, coalescing rapid updates."""
|
|
1260
|
+
self.run_worker(
|
|
1261
|
+
self._build_list,
|
|
1262
|
+
exclusive=True,
|
|
1263
|
+
group="thread-selector-render",
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
def _pending_checkpoint_fields(self) -> tuple[bool, bool]:
|
|
1267
|
+
"""Return which visible checkpoint-derived fields still need loading."""
|
|
1268
|
+
load_counts = self._columns.get("messages", False) and any(
|
|
1269
|
+
"message_count" not in thread for thread in self._threads
|
|
1270
|
+
)
|
|
1271
|
+
load_prompts = self._columns.get("initial_prompt", False) and any(
|
|
1272
|
+
"initial_prompt" not in thread for thread in self._threads
|
|
1273
|
+
)
|
|
1274
|
+
return load_counts, load_prompts
|
|
1275
|
+
|
|
1276
|
+
async def _populate_visible_checkpoint_details(self) -> tuple[bool, bool]:
|
|
1277
|
+
"""Load any still-missing checkpoint-derived fields for visible columns.
|
|
1278
|
+
|
|
1279
|
+
Returns:
|
|
1280
|
+
Tuple indicating whether message counts and prompts were requested.
|
|
1281
|
+
"""
|
|
1282
|
+
from docagent_cli.sessions import populate_thread_checkpoint_details
|
|
1283
|
+
|
|
1284
|
+
load_counts, load_prompts = self._pending_checkpoint_fields()
|
|
1285
|
+
if not load_counts and not load_prompts:
|
|
1286
|
+
return False, False
|
|
1287
|
+
|
|
1288
|
+
await populate_thread_checkpoint_details(
|
|
1289
|
+
self._threads,
|
|
1290
|
+
include_message_count=load_counts,
|
|
1291
|
+
include_initial_prompt=load_prompts,
|
|
1292
|
+
)
|
|
1293
|
+
return load_counts, load_prompts
|
|
1294
|
+
|
|
1295
|
+
def _schedule_checkpoint_enrichment(self) -> None:
|
|
1296
|
+
"""Schedule one checkpoint-enrichment pass for missing row fields."""
|
|
1297
|
+
has_missing_counts, has_missing_prompts = self._pending_checkpoint_fields()
|
|
1298
|
+
if not has_missing_counts and not has_missing_prompts:
|
|
1299
|
+
return
|
|
1300
|
+
self.run_worker(
|
|
1301
|
+
self._load_checkpoint_details,
|
|
1302
|
+
exclusive=True,
|
|
1303
|
+
group="thread-selector-checkpoints",
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
@staticmethod
|
|
1307
|
+
def _threads_match(old: list[ThreadInfo], new: list[ThreadInfo]) -> bool:
|
|
1308
|
+
"""Check whether two thread lists have the same IDs and checkpoints in order.
|
|
1309
|
+
|
|
1310
|
+
Args:
|
|
1311
|
+
old: Previous thread list.
|
|
1312
|
+
new: Fresh thread list.
|
|
1313
|
+
|
|
1314
|
+
Returns:
|
|
1315
|
+
True if both lists have identical thread/checkpoint ID pairs.
|
|
1316
|
+
"""
|
|
1317
|
+
if len(old) != len(new):
|
|
1318
|
+
return False
|
|
1319
|
+
for a, b in zip(old, new, strict=True):
|
|
1320
|
+
if a["thread_id"] != b["thread_id"]:
|
|
1321
|
+
return False
|
|
1322
|
+
if a.get("latest_checkpoint_id") != b.get("latest_checkpoint_id"):
|
|
1323
|
+
return False
|
|
1324
|
+
return True
|
|
1325
|
+
|
|
1326
|
+
async def _load_threads(self) -> None:
|
|
1327
|
+
"""Load thread rows first, then kick off background enrichment."""
|
|
1328
|
+
from docagent_cli.sessions import (
|
|
1329
|
+
apply_cached_thread_initial_prompts,
|
|
1330
|
+
apply_cached_thread_message_counts,
|
|
1331
|
+
list_threads,
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
old_threads = list(self._threads)
|
|
1335
|
+
|
|
1336
|
+
try:
|
|
1337
|
+
limit = self._thread_limit
|
|
1338
|
+
if limit is None:
|
|
1339
|
+
from docagent_cli.sessions import get_thread_limit
|
|
1340
|
+
|
|
1341
|
+
limit = get_thread_limit()
|
|
1342
|
+
sort_by = "updated" if self._sort_by_updated else "created"
|
|
1343
|
+
self._threads = await list_threads(
|
|
1344
|
+
limit=limit, include_message_count=False, sort_by=sort_by
|
|
1345
|
+
)
|
|
1346
|
+
except (OSError, sqlite3.Error) as exc:
|
|
1347
|
+
logger.exception("Failed to load threads for thread selector")
|
|
1348
|
+
await self._show_mount_error(str(exc))
|
|
1349
|
+
return
|
|
1350
|
+
except Exception as exc:
|
|
1351
|
+
logger.exception("Unexpected error loading threads for thread selector")
|
|
1352
|
+
await self._show_mount_error(str(exc))
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
apply_cached_thread_message_counts(self._threads)
|
|
1356
|
+
apply_cached_thread_initial_prompts(self._threads)
|
|
1357
|
+
if not self._has_initial_threads:
|
|
1358
|
+
try:
|
|
1359
|
+
await self._populate_visible_checkpoint_details()
|
|
1360
|
+
except (OSError, sqlite3.Error):
|
|
1361
|
+
logger.debug(
|
|
1362
|
+
"Could not preload checkpoint details for thread selector",
|
|
1363
|
+
exc_info=True,
|
|
1364
|
+
)
|
|
1365
|
+
except Exception:
|
|
1366
|
+
logger.warning(
|
|
1367
|
+
"Unexpected error preloading checkpoint details "
|
|
1368
|
+
"for thread selector",
|
|
1369
|
+
exc_info=True,
|
|
1370
|
+
)
|
|
1371
|
+
self._update_filtered_list()
|
|
1372
|
+
self._sync_selected_index()
|
|
1373
|
+
|
|
1374
|
+
# Short-circuit: when the fresh data matches what is already rendered,
|
|
1375
|
+
# update widget references and cell labels without tearing down the DOM.
|
|
1376
|
+
if (
|
|
1377
|
+
self._has_initial_threads
|
|
1378
|
+
and self._option_widgets
|
|
1379
|
+
and self._threads_match(old_threads, self._filtered_threads)
|
|
1380
|
+
):
|
|
1381
|
+
for widget, thread in zip(
|
|
1382
|
+
self._option_widgets,
|
|
1383
|
+
self._filtered_threads,
|
|
1384
|
+
strict=True,
|
|
1385
|
+
):
|
|
1386
|
+
widget.thread = thread
|
|
1387
|
+
self._refresh_cell_labels()
|
|
1388
|
+
else:
|
|
1389
|
+
await self._build_list()
|
|
1390
|
+
|
|
1391
|
+
self._schedule_checkpoint_enrichment()
|
|
1392
|
+
|
|
1393
|
+
if self._current_thread:
|
|
1394
|
+
self._resolve_thread_url()
|
|
1395
|
+
|
|
1396
|
+
async def _load_checkpoint_details(self) -> None:
|
|
1397
|
+
"""Populate checkpoint-derived thread fields in one background pass."""
|
|
1398
|
+
if not self._threads:
|
|
1399
|
+
return
|
|
1400
|
+
|
|
1401
|
+
try:
|
|
1402
|
+
_, load_prompts = await self._populate_visible_checkpoint_details()
|
|
1403
|
+
except (OSError, sqlite3.Error):
|
|
1404
|
+
logger.debug(
|
|
1405
|
+
"Could not load checkpoint details for thread selector",
|
|
1406
|
+
exc_info=True,
|
|
1407
|
+
)
|
|
1408
|
+
return
|
|
1409
|
+
except Exception:
|
|
1410
|
+
logger.warning(
|
|
1411
|
+
"Unexpected error loading checkpoint details for thread selector",
|
|
1412
|
+
exc_info=True,
|
|
1413
|
+
)
|
|
1414
|
+
return
|
|
1415
|
+
|
|
1416
|
+
if load_prompts and self._filter_text.strip():
|
|
1417
|
+
# Prompts may affect fuzzy match results; rebuild the filtered
|
|
1418
|
+
# list but preserve the user's cursor position.
|
|
1419
|
+
saved_tid = (
|
|
1420
|
+
self._filtered_threads[self._selected_index]["thread_id"]
|
|
1421
|
+
if self._selected_index < len(self._filtered_threads)
|
|
1422
|
+
else None
|
|
1423
|
+
)
|
|
1424
|
+
self._update_filtered_list()
|
|
1425
|
+
if saved_tid is not None:
|
|
1426
|
+
for i, thread in enumerate(self._filtered_threads):
|
|
1427
|
+
if thread["thread_id"] == saved_tid:
|
|
1428
|
+
self._selected_index = i
|
|
1429
|
+
break
|
|
1430
|
+
self._schedule_list_rebuild()
|
|
1431
|
+
else:
|
|
1432
|
+
self._refresh_cell_labels()
|
|
1433
|
+
|
|
1434
|
+
def _refresh_cell_labels(self) -> None:
|
|
1435
|
+
"""Update visible cell text in-place without rebuilding the DOM."""
|
|
1436
|
+
visible_keys = _visible_column_keys(self._columns)
|
|
1437
|
+
|
|
1438
|
+
# Recompute because thread data may have changed since
|
|
1439
|
+
# _compute_column_widths populated the cache.
|
|
1440
|
+
cell_text: dict[tuple[str, str], str] = {}
|
|
1441
|
+
for thread in self._filtered_threads:
|
|
1442
|
+
tid = thread["thread_id"]
|
|
1443
|
+
for key in visible_keys:
|
|
1444
|
+
cell_text[tid, key] = _format_column_value(
|
|
1445
|
+
thread, key, relative_time=self._relative_time
|
|
1446
|
+
)
|
|
1447
|
+
self._cell_text = cell_text
|
|
1448
|
+
|
|
1449
|
+
for widget in self._option_widgets:
|
|
1450
|
+
tid = widget.thread_id
|
|
1451
|
+
for key in visible_keys:
|
|
1452
|
+
try:
|
|
1453
|
+
cell = widget.query_one(f".thread-cell-{key}", Static)
|
|
1454
|
+
except NoMatches:
|
|
1455
|
+
continue
|
|
1456
|
+
cell.update(cell_text[tid, key])
|
|
1457
|
+
|
|
1458
|
+
def _resolve_thread_url(self) -> None:
|
|
1459
|
+
"""Start exclusive background worker to resolve LangSmith thread URL."""
|
|
1460
|
+
self.run_worker(
|
|
1461
|
+
self._fetch_thread_url, exclusive=True, group="thread-selector-url"
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
async def _fetch_thread_url(self) -> None:
|
|
1465
|
+
"""Resolve the LangSmith URL and update the title with a clickable link."""
|
|
1466
|
+
if not self._current_thread:
|
|
1467
|
+
return
|
|
1468
|
+
try:
|
|
1469
|
+
thread_url = await asyncio.wait_for(
|
|
1470
|
+
asyncio.to_thread(build_langsmith_thread_url, self._current_thread),
|
|
1471
|
+
timeout=_URL_FETCH_TIMEOUT,
|
|
1472
|
+
)
|
|
1473
|
+
except (TimeoutError, OSError):
|
|
1474
|
+
logger.debug(
|
|
1475
|
+
"Could not resolve LangSmith thread URL for '%s'",
|
|
1476
|
+
self._current_thread,
|
|
1477
|
+
exc_info=True,
|
|
1478
|
+
)
|
|
1479
|
+
return
|
|
1480
|
+
except Exception:
|
|
1481
|
+
logger.debug(
|
|
1482
|
+
"Unexpected error resolving LangSmith thread URL for '%s'",
|
|
1483
|
+
self._current_thread,
|
|
1484
|
+
exc_info=True,
|
|
1485
|
+
)
|
|
1486
|
+
return
|
|
1487
|
+
if thread_url:
|
|
1488
|
+
try:
|
|
1489
|
+
title_widget = self.query_one("#thread-title", Static)
|
|
1490
|
+
title_widget.update(self._build_title(thread_url))
|
|
1491
|
+
except NoMatches:
|
|
1492
|
+
logger.debug(
|
|
1493
|
+
"Title widget #thread-title not found; "
|
|
1494
|
+
"thread selector may have been dismissed during URL resolution"
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
async def _show_mount_error(self, detail: str) -> None:
|
|
1498
|
+
"""Display an error message inside the thread list and refocus.
|
|
1499
|
+
|
|
1500
|
+
Args:
|
|
1501
|
+
detail: Human-readable error detail to show.
|
|
1502
|
+
"""
|
|
1503
|
+
try:
|
|
1504
|
+
async with self._render_lock:
|
|
1505
|
+
scroll = self.query_one(".thread-list", VerticalScroll)
|
|
1506
|
+
await scroll.remove_children()
|
|
1507
|
+
await scroll.mount(
|
|
1508
|
+
Static(
|
|
1509
|
+
Content.from_markup(
|
|
1510
|
+
"[red]Failed to load threads: $detail. "
|
|
1511
|
+
"Press Esc to close.[/red]",
|
|
1512
|
+
detail=detail,
|
|
1513
|
+
),
|
|
1514
|
+
classes="thread-empty",
|
|
1515
|
+
)
|
|
1516
|
+
)
|
|
1517
|
+
except Exception:
|
|
1518
|
+
logger.warning(
|
|
1519
|
+
"Could not display error message in thread selector UI",
|
|
1520
|
+
exc_info=True,
|
|
1521
|
+
)
|
|
1522
|
+
self.focus()
|
|
1523
|
+
|
|
1524
|
+
async def _build_list(self, *, recompute_widths: bool = True) -> None:
|
|
1525
|
+
"""Build the thread option widgets.
|
|
1526
|
+
|
|
1527
|
+
Args:
|
|
1528
|
+
recompute_widths: Whether to recalculate shared column widths first.
|
|
1529
|
+
"""
|
|
1530
|
+
async with self._render_lock:
|
|
1531
|
+
try:
|
|
1532
|
+
scroll = self.query_one(".thread-list", VerticalScroll)
|
|
1533
|
+
except NoMatches:
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
if recompute_widths:
|
|
1537
|
+
self._column_widths = self._compute_column_widths()
|
|
1538
|
+
with self.app.batch_update():
|
|
1539
|
+
await scroll.remove_children()
|
|
1540
|
+
self._update_help_widgets()
|
|
1541
|
+
|
|
1542
|
+
if not self._filtered_threads:
|
|
1543
|
+
self._option_widgets = []
|
|
1544
|
+
await scroll.mount(
|
|
1545
|
+
Static(
|
|
1546
|
+
Content.styled("No threads found", "dim"),
|
|
1547
|
+
classes="thread-empty",
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
return
|
|
1551
|
+
|
|
1552
|
+
self._option_widgets, selected_widget = self._create_option_widgets()
|
|
1553
|
+
await scroll.mount(*self._option_widgets)
|
|
1554
|
+
|
|
1555
|
+
if selected_widget:
|
|
1556
|
+
self.call_after_refresh(self._scroll_selected_into_view)
|
|
1557
|
+
|
|
1558
|
+
def _create_option_widgets(self) -> tuple[list[ThreadOption], ThreadOption | None]:
|
|
1559
|
+
"""Build option widgets from filtered threads without mounting.
|
|
1560
|
+
|
|
1561
|
+
Returns:
|
|
1562
|
+
Tuple of all option widgets and the currently selected widget.
|
|
1563
|
+
"""
|
|
1564
|
+
widgets: list[ThreadOption] = []
|
|
1565
|
+
selected_widget: ThreadOption | None = None
|
|
1566
|
+
|
|
1567
|
+
for i, thread in enumerate(self._filtered_threads):
|
|
1568
|
+
is_current = thread["thread_id"] == self._current_thread
|
|
1569
|
+
is_selected = i == self._selected_index
|
|
1570
|
+
|
|
1571
|
+
classes = "thread-option"
|
|
1572
|
+
if is_selected:
|
|
1573
|
+
classes += " thread-option-selected"
|
|
1574
|
+
if is_current:
|
|
1575
|
+
classes += " thread-option-current"
|
|
1576
|
+
|
|
1577
|
+
widget = ThreadOption(
|
|
1578
|
+
thread=thread,
|
|
1579
|
+
index=i,
|
|
1580
|
+
columns=self._columns,
|
|
1581
|
+
column_widths=self._column_widths,
|
|
1582
|
+
selected=is_selected,
|
|
1583
|
+
current=is_current,
|
|
1584
|
+
relative_time=self._relative_time,
|
|
1585
|
+
cell_text=self._cell_text or None,
|
|
1586
|
+
classes=classes,
|
|
1587
|
+
)
|
|
1588
|
+
widgets.append(widget)
|
|
1589
|
+
if is_selected:
|
|
1590
|
+
selected_widget = widget
|
|
1591
|
+
|
|
1592
|
+
return widgets, selected_widget
|
|
1593
|
+
|
|
1594
|
+
def _scroll_selected_into_view(self) -> None:
|
|
1595
|
+
"""Scroll selected option into view without animation."""
|
|
1596
|
+
if not self._option_widgets:
|
|
1597
|
+
return
|
|
1598
|
+
if self._selected_index >= len(self._option_widgets):
|
|
1599
|
+
return
|
|
1600
|
+
try:
|
|
1601
|
+
scroll = self.query_one(".thread-list", VerticalScroll)
|
|
1602
|
+
except NoMatches:
|
|
1603
|
+
return
|
|
1604
|
+
|
|
1605
|
+
if self._selected_index == 0:
|
|
1606
|
+
scroll.scroll_home(animate=False)
|
|
1607
|
+
else:
|
|
1608
|
+
self._option_widgets[self._selected_index].scroll_visible(animate=False)
|
|
1609
|
+
|
|
1610
|
+
def _update_help_widgets(self) -> None:
|
|
1611
|
+
"""Update visible header and help text after state changes."""
|
|
1612
|
+
self._schedule_header_rebuild()
|
|
1613
|
+
|
|
1614
|
+
try:
|
|
1615
|
+
help_widget = self.query_one("#thread-help", Static)
|
|
1616
|
+
help_widget.update(self._build_help_text())
|
|
1617
|
+
except NoMatches:
|
|
1618
|
+
logger.debug("Help widget #thread-help not found during update")
|
|
1619
|
+
|
|
1620
|
+
with contextlib.suppress(NoMatches):
|
|
1621
|
+
sort_checkbox = self.query_one(f"#{_SORT_SWITCH_ID}", Checkbox)
|
|
1622
|
+
sort_checkbox.label = self._format_sort_toggle_label()
|
|
1623
|
+
if sort_checkbox.value != self._sort_by_updated:
|
|
1624
|
+
sort_checkbox.value = self._sort_by_updated
|
|
1625
|
+
|
|
1626
|
+
def _schedule_header_rebuild(self) -> None:
|
|
1627
|
+
"""Queue a header rebuild to reflect column/sort changes."""
|
|
1628
|
+
self.run_worker(
|
|
1629
|
+
self._rebuild_header,
|
|
1630
|
+
exclusive=True,
|
|
1631
|
+
group="thread-selector-header",
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
async def _rebuild_header(self) -> None:
|
|
1635
|
+
"""Replace header cells to match current visible columns."""
|
|
1636
|
+
try:
|
|
1637
|
+
header = self.query_one("#thread-header", Horizontal)
|
|
1638
|
+
except NoMatches:
|
|
1639
|
+
return
|
|
1640
|
+
sort_key = _active_sort_key(self._sort_by_updated)
|
|
1641
|
+
self._column_widths = self._compute_column_widths()
|
|
1642
|
+
with self.app.batch_update():
|
|
1643
|
+
await header.remove_children()
|
|
1644
|
+
cells: list[Static] = [Static("", classes="thread-cell thread-cell-cursor")]
|
|
1645
|
+
for key in _visible_column_keys(self._columns):
|
|
1646
|
+
cell = Static(
|
|
1647
|
+
_format_header_label(key),
|
|
1648
|
+
classes=_header_cell_classes(key, sort_key=sort_key),
|
|
1649
|
+
expand=key == "initial_prompt",
|
|
1650
|
+
markup=False,
|
|
1651
|
+
)
|
|
1652
|
+
_apply_column_width(cell, key, self._column_widths)
|
|
1653
|
+
cells.append(cell)
|
|
1654
|
+
await header.mount(*cells)
|
|
1655
|
+
|
|
1656
|
+
def _apply_sort(self) -> None:
|
|
1657
|
+
"""Sort filtered threads by the active sort key."""
|
|
1658
|
+
key = _active_sort_key(self._sort_by_updated)
|
|
1659
|
+
self._filtered_threads.sort(
|
|
1660
|
+
key=lambda thread: thread.get(key) or "", reverse=True
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
def _move_selection(self, delta: int) -> None:
|
|
1664
|
+
"""Move selection by delta, updating only the affected rows.
|
|
1665
|
+
|
|
1666
|
+
Args:
|
|
1667
|
+
delta: Positions to move (negative for up, positive for down).
|
|
1668
|
+
"""
|
|
1669
|
+
if not self._filtered_threads or not self._option_widgets:
|
|
1670
|
+
return
|
|
1671
|
+
|
|
1672
|
+
count = len(self._filtered_threads)
|
|
1673
|
+
old_index = self._selected_index
|
|
1674
|
+
new_index = (old_index + delta) % count
|
|
1675
|
+
self._selected_index = new_index
|
|
1676
|
+
|
|
1677
|
+
self._option_widgets[old_index].set_selected(False)
|
|
1678
|
+
self._option_widgets[new_index].set_selected(True)
|
|
1679
|
+
|
|
1680
|
+
if new_index == 0:
|
|
1681
|
+
scroll = self.query_one(".thread-list", VerticalScroll)
|
|
1682
|
+
scroll.scroll_home(animate=False)
|
|
1683
|
+
else:
|
|
1684
|
+
self._option_widgets[new_index].scroll_visible()
|
|
1685
|
+
|
|
1686
|
+
def action_move_up(self) -> None:
|
|
1687
|
+
"""Move selection up."""
|
|
1688
|
+
if self._confirming_delete:
|
|
1689
|
+
return
|
|
1690
|
+
self._move_selection(-1)
|
|
1691
|
+
|
|
1692
|
+
def action_move_down(self) -> None:
|
|
1693
|
+
"""Move selection down."""
|
|
1694
|
+
if self._confirming_delete:
|
|
1695
|
+
return
|
|
1696
|
+
self._move_selection(1)
|
|
1697
|
+
|
|
1698
|
+
def _visible_page_size(self) -> int:
|
|
1699
|
+
"""Return the number of thread options that fit in one visual page.
|
|
1700
|
+
|
|
1701
|
+
Returns:
|
|
1702
|
+
Number of thread options per page, at least 1.
|
|
1703
|
+
"""
|
|
1704
|
+
default_page_size = 10
|
|
1705
|
+
try:
|
|
1706
|
+
scroll = self.query_one(".thread-list", VerticalScroll)
|
|
1707
|
+
height = scroll.size.height
|
|
1708
|
+
except NoMatches:
|
|
1709
|
+
logger.debug(
|
|
1710
|
+
"Thread list widget not found in _visible_page_size; "
|
|
1711
|
+
"using default page size %d",
|
|
1712
|
+
default_page_size,
|
|
1713
|
+
)
|
|
1714
|
+
return default_page_size
|
|
1715
|
+
if height <= 0:
|
|
1716
|
+
return default_page_size
|
|
1717
|
+
return max(1, height)
|
|
1718
|
+
|
|
1719
|
+
def action_page_up(self) -> None:
|
|
1720
|
+
"""Move selection up by one visible page."""
|
|
1721
|
+
if self._confirming_delete or not self._filtered_threads:
|
|
1722
|
+
return
|
|
1723
|
+
page = self._visible_page_size()
|
|
1724
|
+
target = max(0, self._selected_index - page)
|
|
1725
|
+
delta = target - self._selected_index
|
|
1726
|
+
if delta != 0:
|
|
1727
|
+
self._move_selection(delta)
|
|
1728
|
+
|
|
1729
|
+
def action_page_down(self) -> None:
|
|
1730
|
+
"""Move selection down by one visible page."""
|
|
1731
|
+
if self._confirming_delete or not self._filtered_threads:
|
|
1732
|
+
return
|
|
1733
|
+
count = len(self._filtered_threads)
|
|
1734
|
+
page = self._visible_page_size()
|
|
1735
|
+
target = min(count - 1, self._selected_index + page)
|
|
1736
|
+
delta = target - self._selected_index
|
|
1737
|
+
if delta != 0:
|
|
1738
|
+
self._move_selection(delta)
|
|
1739
|
+
|
|
1740
|
+
def action_select(self) -> None:
|
|
1741
|
+
"""Confirm the highlighted thread and dismiss the selector."""
|
|
1742
|
+
if self._confirming_delete:
|
|
1743
|
+
return
|
|
1744
|
+
if self._filtered_threads:
|
|
1745
|
+
thread_id = self._filtered_threads[self._selected_index]["thread_id"]
|
|
1746
|
+
self.dismiss(thread_id)
|
|
1747
|
+
|
|
1748
|
+
def action_focus_next_filter(self) -> None:
|
|
1749
|
+
"""Move focus through the filter and column-toggle controls."""
|
|
1750
|
+
if self._confirming_delete:
|
|
1751
|
+
return
|
|
1752
|
+
controls = self._filter_focus_order()
|
|
1753
|
+
focused = self.focused
|
|
1754
|
+
if focused not in controls:
|
|
1755
|
+
controls[0].focus()
|
|
1756
|
+
return
|
|
1757
|
+
|
|
1758
|
+
index = controls.index(cast("Input | Checkbox", focused))
|
|
1759
|
+
controls[(index + 1) % len(controls)].focus()
|
|
1760
|
+
|
|
1761
|
+
def action_focus_previous_filter(self) -> None:
|
|
1762
|
+
"""Move focus backward through the filter and column-toggle controls."""
|
|
1763
|
+
if self._confirming_delete:
|
|
1764
|
+
return
|
|
1765
|
+
controls = self._filter_focus_order()
|
|
1766
|
+
focused = self.focused
|
|
1767
|
+
if focused not in controls:
|
|
1768
|
+
controls[-1].focus()
|
|
1769
|
+
return
|
|
1770
|
+
|
|
1771
|
+
index = controls.index(cast("Input | Checkbox", focused))
|
|
1772
|
+
controls[(index - 1) % len(controls)].focus()
|
|
1773
|
+
|
|
1774
|
+
def action_toggle_sort(self) -> None:
|
|
1775
|
+
"""Toggle sort between updated_at and created_at."""
|
|
1776
|
+
if self._confirming_delete:
|
|
1777
|
+
return
|
|
1778
|
+
self._sort_by_updated = not self._sort_by_updated
|
|
1779
|
+
self._apply_sort()
|
|
1780
|
+
self._sync_selected_index()
|
|
1781
|
+
self._update_help_widgets()
|
|
1782
|
+
self._schedule_list_rebuild()
|
|
1783
|
+
|
|
1784
|
+
self._persist_sort_order(
|
|
1785
|
+
"updated_at" if self._sort_by_updated else "created_at"
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
def _persist_sort_order(self, order: str) -> None:
|
|
1789
|
+
"""Save sort-order preference to config, notifying on failure."""
|
|
1790
|
+
|
|
1791
|
+
async def _save() -> None:
|
|
1792
|
+
from docagent_cli.model_config import save_thread_sort_order
|
|
1793
|
+
|
|
1794
|
+
ok = await asyncio.to_thread(save_thread_sort_order, order)
|
|
1795
|
+
if not ok:
|
|
1796
|
+
self.app.notify("Could not save sort preference", severity="warning")
|
|
1797
|
+
|
|
1798
|
+
self.run_worker(_save(), group="thread-selector-save")
|
|
1799
|
+
|
|
1800
|
+
def action_delete_thread(self) -> None:
|
|
1801
|
+
"""Show delete confirmation for the highlighted thread."""
|
|
1802
|
+
if self._confirming_delete:
|
|
1803
|
+
return
|
|
1804
|
+
if not self._filtered_threads:
|
|
1805
|
+
# Nothing to delete — fall through to quit. Using exit() instead of
|
|
1806
|
+
# dismiss() is intentional: dismiss() would just close the modal
|
|
1807
|
+
# silently, re-swallowing ctrl+d.
|
|
1808
|
+
self.app.exit()
|
|
1809
|
+
return
|
|
1810
|
+
self._confirming_delete = True
|
|
1811
|
+
thread = self._filtered_threads[self._selected_index]
|
|
1812
|
+
tid = thread["thread_id"]
|
|
1813
|
+
self.app.push_screen(
|
|
1814
|
+
DeleteThreadConfirmScreen(tid),
|
|
1815
|
+
lambda confirmed: self._on_delete_confirmed(tid, confirmed),
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
@property
|
|
1819
|
+
def is_delete_confirmation_open(self) -> bool:
|
|
1820
|
+
"""Return whether the delete confirmation overlay is visible."""
|
|
1821
|
+
return self._confirming_delete
|
|
1822
|
+
|
|
1823
|
+
def _on_delete_confirmed(self, thread_id: str, confirmed: bool | None) -> None:
|
|
1824
|
+
"""Handle the result from the delete confirmation modal.
|
|
1825
|
+
|
|
1826
|
+
Args:
|
|
1827
|
+
thread_id: Thread ID that was targeted.
|
|
1828
|
+
confirmed: Whether deletion was confirmed.
|
|
1829
|
+
"""
|
|
1830
|
+
self._confirming_delete = False
|
|
1831
|
+
if confirmed:
|
|
1832
|
+
self.run_worker(
|
|
1833
|
+
self._handle_delete_confirm(thread_id),
|
|
1834
|
+
group="thread-delete-execute",
|
|
1835
|
+
)
|
|
1836
|
+
return
|
|
1837
|
+
with contextlib.suppress(NoMatches):
|
|
1838
|
+
self._get_filter_input().focus()
|
|
1839
|
+
|
|
1840
|
+
async def _handle_delete_confirm(self, thread_id: str) -> None:
|
|
1841
|
+
"""Execute thread deletion after confirmation.
|
|
1842
|
+
|
|
1843
|
+
Args:
|
|
1844
|
+
thread_id: Thread ID to delete.
|
|
1845
|
+
"""
|
|
1846
|
+
from docagent_cli.sessions import delete_thread
|
|
1847
|
+
|
|
1848
|
+
preferred_thread_id: str | None = None
|
|
1849
|
+
if self._selected_index + 1 < len(self._filtered_threads):
|
|
1850
|
+
preferred_thread_id = self._filtered_threads[self._selected_index + 1][
|
|
1851
|
+
"thread_id"
|
|
1852
|
+
]
|
|
1853
|
+
elif self._selected_index > 0:
|
|
1854
|
+
preferred_thread_id = self._filtered_threads[self._selected_index - 1][
|
|
1855
|
+
"thread_id"
|
|
1856
|
+
]
|
|
1857
|
+
|
|
1858
|
+
try:
|
|
1859
|
+
await delete_thread(thread_id)
|
|
1860
|
+
except (OSError, sqlite3.Error):
|
|
1861
|
+
logger.warning("Failed to delete thread %s", thread_id, exc_info=True)
|
|
1862
|
+
self.app.notify(
|
|
1863
|
+
f"Failed to delete thread {thread_id[:8]}",
|
|
1864
|
+
severity="error",
|
|
1865
|
+
timeout=3,
|
|
1866
|
+
markup=False,
|
|
1867
|
+
)
|
|
1868
|
+
with contextlib.suppress(NoMatches):
|
|
1869
|
+
self.query_one("#thread-filter", Input).focus()
|
|
1870
|
+
return
|
|
1871
|
+
|
|
1872
|
+
self._threads = [
|
|
1873
|
+
thread for thread in self._threads if thread["thread_id"] != thread_id
|
|
1874
|
+
]
|
|
1875
|
+
self._update_filtered_list()
|
|
1876
|
+
if preferred_thread_id is not None:
|
|
1877
|
+
for index, thread in enumerate(self._filtered_threads):
|
|
1878
|
+
if thread["thread_id"] == preferred_thread_id:
|
|
1879
|
+
self._selected_index = index
|
|
1880
|
+
break
|
|
1881
|
+
if self._selected_index >= len(self._filtered_threads):
|
|
1882
|
+
self._selected_index = max(0, len(self._filtered_threads) - 1)
|
|
1883
|
+
await self._build_list()
|
|
1884
|
+
with contextlib.suppress(NoMatches):
|
|
1885
|
+
self.query_one("#thread-filter", Input).focus()
|
|
1886
|
+
|
|
1887
|
+
def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler
|
|
1888
|
+
"""Open Rich-style hyperlinks on single click."""
|
|
1889
|
+
open_style_link(event)
|
|
1890
|
+
|
|
1891
|
+
def on_thread_option_clicked(self, event: ThreadOption.Clicked) -> None:
|
|
1892
|
+
"""Handle click on a thread option.
|
|
1893
|
+
|
|
1894
|
+
Args:
|
|
1895
|
+
event: The clicked message with thread ID and index.
|
|
1896
|
+
"""
|
|
1897
|
+
if self._confirming_delete:
|
|
1898
|
+
return
|
|
1899
|
+
if 0 <= event.index < len(self._filtered_threads):
|
|
1900
|
+
self._selected_index = event.index
|
|
1901
|
+
self.dismiss(event.thread_id)
|
|
1902
|
+
|
|
1903
|
+
def action_cancel(self) -> None:
|
|
1904
|
+
"""Cancel the selection."""
|
|
1905
|
+
self.dismiss(None)
|