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