@pheem49/mint 1.17.0 → 1.18.0

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 (421) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +2 -2
  3. package/README.md +8 -2
  4. package/Release_Note.md +222 -71
  5. package/crates/mint-cli/src/agent/diff_render.rs +66 -44
  6. package/crates/mint-cli/src/agent/live_status.rs +21 -4
  7. package/crates/mint-cli/src/agent/markdown_render.rs +342 -5
  8. package/crates/mint-cli/src/agent.rs +25 -10
  9. package/crates/mint-cli/src/commands/agent.rs +4 -92
  10. package/crates/mint-cli/src/commands/browser.rs +2 -15
  11. package/crates/mint-cli/src/commands/integrations.rs +3 -3
  12. package/crates/mint-cli/src/commands/mod.rs +0 -3
  13. package/crates/mint-cli/src/commands/tailnet_web.rs +1 -0
  14. package/crates/mint-cli/src/interactive/chat_tui/dialog.rs +572 -0
  15. package/crates/mint-cli/src/interactive/chat_tui/mod.rs +1427 -0
  16. package/crates/mint-cli/src/interactive/chat_tui/render.rs +1524 -0
  17. package/crates/mint-cli/src/interactive/chat_tui/selection.rs +401 -0
  18. package/crates/mint-cli/src/interactive/chat_tui/state.rs +1833 -0
  19. package/crates/mint-cli/src/interactive/chat_tui/tests.rs +3414 -0
  20. package/crates/mint-cli/src/interactive/chat_tui/thought.rs +202 -0
  21. package/crates/mint-cli/src/interactive/chat_tui/transcript.rs +728 -0
  22. package/crates/mint-cli/src/interactive/chat_tui/watermark.rs +148 -0
  23. package/crates/mint-cli/src/interactive/command_ui.rs +53 -5
  24. package/crates/mint-cli/src/interactive/composer.rs +186 -0
  25. package/crates/mint-cli/src/interactive/format.rs +78 -3
  26. package/crates/mint-cli/src/interactive/input_box.rs +61 -1
  27. package/crates/mint-cli/src/interactive/resume_picker.rs +367 -25
  28. package/crates/mint-cli/src/interactive/slash_commands.rs +648 -371
  29. package/crates/mint-cli/src/interactive/tool_activity.rs +93 -20
  30. package/crates/mint-cli/src/interactive.rs +37 -0
  31. package/crates/mint-cli/src/main.rs +6 -3
  32. package/crates/mint-cli/src/markdown.rs +2 -37
  33. package/crates/mint-cli/src/onboard.rs +700 -282
  34. package/crates/mint-cli/src/setup.rs +1342 -624
  35. package/crates/mint-cli/src/terminal_theme.rs +159 -35
  36. package/crates/mint-cli/tests/library_intake.rs +1 -0
  37. package/crates/mint-core/src/agent/chat/anthropic.rs +318 -0
  38. package/crates/mint-core/src/agent/chat/custom.rs +149 -0
  39. package/crates/mint-core/src/agent/chat/gemini.rs +518 -0
  40. package/crates/mint-core/src/agent/chat/mod.rs +857 -0
  41. package/crates/mint-core/src/agent/chat/ollama.rs +321 -0
  42. package/crates/mint-core/src/agent/chat/openai.rs +582 -0
  43. package/crates/mint-core/src/agent/chat/streaming.rs +469 -0
  44. package/crates/mint-core/src/agent/chat/tests.rs +1479 -0
  45. package/crates/mint-core/src/agent/memory.rs +56 -2
  46. package/crates/mint-core/src/agent/tasks.rs +2 -2
  47. package/crates/mint-core/src/api_server/routes/chat.rs +24 -2
  48. package/crates/mint-core/src/api_server/routes/gemini_live.rs +14 -1
  49. package/crates/mint-core/src/api_server/routes/html_preview.rs +1 -8
  50. package/crates/mint-core/src/api_server/routes/sessions.rs +21 -0
  51. package/crates/mint-core/src/avatar_bridge.rs +0 -22
  52. package/crates/mint-core/src/browser/cdp.rs +10 -4
  53. package/crates/mint-core/src/browser/headless.rs +582 -0
  54. package/crates/mint-core/src/browser/input.rs +7 -62
  55. package/crates/mint-core/src/browser/interact.rs +0 -14
  56. package/crates/mint-core/src/browser/mod.rs +8 -29
  57. package/crates/mint-core/src/browser/navigate.rs +2 -36
  58. package/crates/mint-core/src/browser/runtime.rs +2 -4
  59. package/crates/mint-core/src/browser/session.rs +50 -466
  60. package/crates/mint-core/src/browser/workflow.rs +142 -32
  61. package/crates/mint-core/src/cron/store.rs +2 -2
  62. package/crates/mint-core/src/integrations/mcp.rs +6 -8
  63. package/crates/mint-core/src/lib.rs +0 -5
  64. package/crates/mint-core/src/media/gemini_live.rs +636 -86
  65. package/crates/mint-core/src/media/library/migration.rs +26 -0
  66. package/crates/mint-core/src/media/library/tests.rs +1 -1
  67. package/crates/mint-core/src/media/library/thumbnails.rs +8 -2
  68. package/crates/mint-core/src/media/pictures.rs +1 -1
  69. package/crates/mint-core/src/media/tool_images.rs +92 -1
  70. package/crates/mint-core/src/mods/manager.rs +101 -35
  71. package/crates/mint-core/src/orchestration/context_render.rs +441 -24
  72. package/crates/mint-core/src/orchestration/decision_parsing.rs +531 -16
  73. package/crates/mint-core/src/orchestration/full_skill_result_tests.rs +12 -0
  74. package/crates/mint-core/src/orchestration/loop_guard.rs +145 -0
  75. package/crates/mint-core/src/orchestration/mcp_outcome_tests.rs +864 -0
  76. package/crates/mint-core/src/orchestration/memory_skill.rs +88 -28
  77. package/crates/mint-core/src/orchestration/mod.rs +320 -4249
  78. package/crates/mint-core/src/orchestration/reliability.rs +67 -27
  79. package/crates/mint-core/src/orchestration/review_regressions.rs +5 -0
  80. package/crates/mint-core/src/orchestration/tests.rs +1665 -0
  81. package/crates/mint-core/src/orchestration/tools/dispatch.rs +967 -0
  82. package/crates/mint-core/src/orchestration/tools/files.rs +1 -0
  83. package/crates/mint-core/src/orchestration/tools/mod.rs +5 -1
  84. package/crates/mint-core/src/orchestration/tools/planning.rs +102 -42
  85. package/crates/mint-core/src/orchestration/tools/shell.rs +129 -58
  86. package/crates/mint-core/src/orchestration/truncation.rs +170 -13
  87. package/crates/mint-core/src/orchestration/types.rs +648 -0
  88. package/crates/mint-core/src/orchestration/verification.rs +0 -3
  89. package/crates/mint-core/src/orchestration/workspace_helpers.rs +24 -6
  90. package/crates/mint-core/src/prompts/agent.rs +52 -76
  91. package/crates/mint-core/src/prompts/tool_catalog.rs +8 -151
  92. package/crates/mint-core/src/search/files.rs +20 -0
  93. package/crates/mint-core/src/slash/mod.rs +228 -265
  94. package/crates/mint-core/src/slash/models.rs +6 -1
  95. package/crates/mint-core/src/system/bg_shell.rs +1 -0
  96. package/crates/mint-core/src/system/config.rs +176 -34
  97. package/crates/mint-mod-runtime/src/lib.rs +37 -23
  98. package/out/web/assets/ArtifactPreviewPanel-Cx6BVOOF.js +2 -0
  99. package/out/web/assets/CalculationCard-D2E74u5q.js +1 -0
  100. package/out/web/assets/CliSessionsView-ZbdtSjvK.css +1 -0
  101. package/out/web/assets/{CliSessionsView-MI8kXMAb.js → CliSessionsView-z5i6qGyb.js} +1 -1
  102. package/out/web/assets/CodeReviewPage-BK2bOScd.css +1 -0
  103. package/out/web/assets/CodeReviewPage-Dp6zHuHu.js +2 -0
  104. package/out/web/assets/{CommandPalette-BD8SlTE5.js → CommandPalette-DaKlzbKs.js} +1 -1
  105. package/out/web/assets/ImageGenCard-BMCJHP9O.js +1 -0
  106. package/out/web/assets/ImageSearchCard-Cld_SOUg.js +1 -0
  107. package/out/web/assets/{ImageStudioPanel-BP7N4rLr.js → ImageStudioPanel-B5N7TjeB.js} +1 -1
  108. package/out/web/assets/LibraryPage-CMB89rJ0.css +1 -0
  109. package/out/web/assets/LibraryPage-DbZEi4W_.js +2 -0
  110. package/out/web/assets/LinkedFoldersView-9B9DIPoZ.js +1 -0
  111. package/out/web/assets/{McpRegistryPicker-CoS83vtc.js → McpRegistryPicker-DnXPlzI7.js} +1 -1
  112. package/out/web/assets/McpServersView-CiNBNUl4.js +1 -0
  113. package/out/web/assets/MermaidCard-BBDNEXbY.js +2 -0
  114. package/out/web/assets/MintDashboard-C_UYPoNV.js +119 -0
  115. package/out/web/assets/MintDashboard-DTd8C_c8.css +1 -0
  116. package/out/web/assets/PlanCard-C2x5pajq.js +1 -0
  117. package/out/web/assets/{PluginsView-jBYmcKh4.js → PluginsView-DoTg9MVa.js} +1 -1
  118. package/out/web/assets/RewindModal-B5oowrR0.js +1 -0
  119. package/out/web/assets/{ScheduledTasksView-5uw9-V9H.js → ScheduledTasksView-BD5DVPkZ.js} +1 -1
  120. package/out/web/assets/{SelectField-DXBQtrUK.js → SelectField-Cy6CghrU.js} +1 -1
  121. package/out/web/assets/SettingsWindow-BYl_oCFh.js +4 -0
  122. package/out/web/assets/SettingsWindow-Ckcgnjt8.css +1 -0
  123. package/out/web/assets/{SkillsView-C-gp9J7m.js → SkillsView-CIeWwq5z.js} +1 -1
  124. package/out/web/assets/StockCard-DxUciKyp.js +1 -0
  125. package/out/web/assets/ToolSurfacePage-qBgs_VHq.css +1 -0
  126. package/out/web/assets/ToolSurfacePage-saVbVrkE.js +38 -0
  127. package/out/web/assets/{UiFeatureCard-6BwxTOCe.js → UiFeatureCard-CdY0hGWy.js} +1 -1
  128. package/out/web/assets/{UiGridCard-DvZcLNxb.js → UiGridCard-BBK3I5hC.js} +1 -1
  129. package/out/web/assets/UiMockupWidget-CpR9EDuc.js +1 -0
  130. package/out/web/assets/{VeoStudioPanel-DOj7_WR3.css → VeoStudioPanel-9kU9YLEN.css} +1 -1
  131. package/out/web/assets/{VeoStudioPanel-DfsOJKv0.js → VeoStudioPanel-Co6-wEWM.js} +2 -2
  132. package/out/web/assets/WeatherCard-Buzi7Vfu.js +1 -0
  133. package/out/web/assets/WorkspacePdfPreview-Bi8Zzyir.js +2 -0
  134. package/out/web/assets/{abnfDiagram-VCTEODGH-BOo3Ckng.js → abnfDiagram-VCTEODGH-CMfR95N2.js} +1 -1
  135. package/out/web/assets/{architecture-7GRP2DOG-ZOwheW3n.js → architecture-7GRP2DOG-DhAM4HyO.js} +1 -1
  136. package/out/web/assets/{architectureDiagram-5GKGNRK7-DC2FDzVd.js → architectureDiagram-5GKGNRK7-Yk3TUJ_F.js} +1 -1
  137. package/out/web/assets/{blockDiagram-I7D4REHJ-BrKFWdAH.js → blockDiagram-I7D4REHJ-X9fBgJik.js} +1 -1
  138. package/out/web/assets/{c4Diagram-7LVT6UL2-BHTHK1Yd.js → c4Diagram-7LVT6UL2-Bk9x7N-8.js} +1 -1
  139. package/out/web/assets/{chunk-4HAMMTFA-BYFkZwNj.js → chunk-4HAMMTFA-Dx5fk1_0.js} +1 -1
  140. package/out/web/assets/{chunk-75Z2AOVW-BSKfSKSA.js → chunk-75Z2AOVW-BszTQoM4.js} +1 -1
  141. package/out/web/assets/{chunk-DU6HZSFF-vIhsiW6n.js → chunk-DU6HZSFF-DK-As3rk.js} +1 -1
  142. package/out/web/assets/{chunk-F27PBJKO-BlYpW9xq.js → chunk-F27PBJKO-LmncZZIS.js} +1 -1
  143. package/out/web/assets/{chunk-GMAD6QVW-BIBz_Ug5.js → chunk-GMAD6QVW-DSQ_sDRJ.js} +1 -1
  144. package/out/web/assets/{chunk-GVQU2GXP-NPSHxeqC.js → chunk-GVQU2GXP-Cno3ulje.js} +1 -1
  145. package/out/web/assets/{chunk-IMKFNOWR-YlJK8_Fp.js → chunk-IMKFNOWR-bIzfY9Jc.js} +1 -1
  146. package/out/web/assets/{chunk-L3NEJ4N5-DMYi4-af.js → chunk-L3NEJ4N5-Dl6eOa0D.js} +1 -1
  147. package/out/web/assets/{chunk-OSK3NFVY-Begd3-jA.js → chunk-OSK3NFVY-BaUGJ-cM.js} +1 -1
  148. package/out/web/assets/{chunk-P2QGCYS3-D1I4xFHN.js → chunk-P2QGCYS3-_066ndhH.js} +1 -1
  149. package/out/web/assets/{chunk-POPQ4Y6H-CafZZzNE.js → chunk-POPQ4Y6H-YT1AKhTv.js} +1 -1
  150. package/out/web/assets/{chunk-PWAF6VOD-Dwh6_Lnt.js → chunk-PWAF6VOD-CrEgDsxh.js} +1 -1
  151. package/out/web/assets/{chunk-SHT3W25Y-B0XrCSEb.js → chunk-SHT3W25Y-_KwaRLOl.js} +1 -1
  152. package/out/web/assets/{chunk-SVP7TREG-CxLsddxC.js → chunk-SVP7TREG-BQ1qb32L.js} +1 -1
  153. package/out/web/assets/{chunk-TICWLB2K-y156QInb.js → chunk-TICWLB2K-BDyehCtg.js} +1 -1
  154. package/out/web/assets/classDiagram-ZZMXUADV-BoSpzRCJ.js +1 -0
  155. package/out/web/assets/classDiagram-v2-VYDZK3BY-BoSpzRCJ.js +1 -0
  156. package/out/web/assets/{config-DqXJXeZM.css → config-2suVFPZg.css} +1 -1
  157. package/out/web/assets/config-CkRCdkxn.js +2 -0
  158. package/out/web/assets/{cynefin-OW5HDTMX-SMVvrLf-.js → cynefin-OW5HDTMX-Dl_31aaN.js} +1 -1
  159. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-rZznN24T.js → cynefinDiagram-5FMLGOSQ-Bpir8jha.js} +1 -1
  160. package/out/web/assets/{dagre-GXQ25YYZ-BOToydFu.js → dagre-GXQ25YYZ-D9xu3nsq.js} +1 -1
  161. package/out/web/assets/{diagram-S7CK7UJ4-TMVaGQEJ.js → diagram-S7CK7UJ4-DeveoEeB.js} +1 -1
  162. package/out/web/assets/{diagram-UQ7AKVKN-C2JK-GP2.js → diagram-UQ7AKVKN-p4xWRESV.js} +1 -1
  163. package/out/web/assets/{diagram-VSXAHHWV-Cw8VSi2K.js → diagram-VSXAHHWV-C7qz9NDF.js} +1 -1
  164. package/out/web/assets/{diagram-VX7I27RA-DXp4fKk9.js → diagram-VX7I27RA-DRiCa3Vd.js} +1 -1
  165. package/out/web/assets/{diagram-Z3DM3KII-CzSicYiW.js → diagram-Z3DM3KII-Bt7zduQX.js} +1 -1
  166. package/out/web/assets/{ebnfDiagram-PWID7BFC-Dqj-zqPu.js → ebnfDiagram-PWID7BFC-DV9WpwdR.js} +1 -1
  167. package/out/web/assets/{erDiagram-RLTQ6QDP-DZwFZAuD.js → erDiagram-RLTQ6QDP-BJ_nwG2m.js} +1 -1
  168. package/out/web/assets/{eventmodeling-NTZA5JFV-C0O-Lvgp.js → eventmodeling-NTZA5JFV-BTBMrfVa.js} +1 -1
  169. package/out/web/assets/flowDiagram-HODETNUW-Cdlq3cJL.js +1 -0
  170. package/out/web/assets/{ganttDiagram-EL5Y4UJY-Da4ULXTl.js → ganttDiagram-EL5Y4UJY-DQLdsXrC.js} +1 -1
  171. package/out/web/assets/{gitGraph-4MIJSDKK-B9wNFZIP.js → gitGraph-4MIJSDKK-CL1OEOQ_.js} +1 -1
  172. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-RjrhB0MP.js → gitGraphDiagram-WWUBYQGX-8cSi42H7.js} +1 -1
  173. package/out/web/assets/index-BocxnrDG.js +168 -0
  174. package/out/web/assets/index-yeh_r55a.css +1 -0
  175. package/out/web/assets/{info-A6RAGUB7-B3dfCpeY.js → info-A6RAGUB7-C1zsX5sc.js} +1 -1
  176. package/out/web/assets/{infoDiagram-27XIBGKW-QVWkLVx7.js → infoDiagram-27XIBGKW-DsRYtIyI.js} +1 -1
  177. package/out/web/assets/{ishikawaDiagram-5VMMS53U-CLFNIRI9.js → ishikawaDiagram-5VMMS53U-B2crwIXj.js} +1 -1
  178. package/out/web/assets/{journeyDiagram-3NMN7TZE-fgOQadYF.js → journeyDiagram-3NMN7TZE-BTH-GyVR.js} +1 -1
  179. package/out/web/assets/{kanban-definition-UXKFOSKX-G3Q_9g9O.js → kanban-definition-UXKFOSKX-BeRfCnuA.js} +1 -1
  180. package/out/web/assets/{mermaid-parser.core-v3Jm7nco.js → mermaid-parser.core-BJwgCBBc.js} +3 -3
  181. package/out/web/assets/{mermaid.core-DTEjLUA1.js → mermaid.core-CsDDodN8.js} +4 -4
  182. package/out/web/assets/{mindmap-definition-YA3MSWOX-C_D42_fF.js → mindmap-definition-YA3MSWOX-B42efOQ1.js} +1 -1
  183. package/out/web/assets/{modelManager-BPLycQrN.js → modelManager-B3ZFMh8v.js} +2 -2
  184. package/out/web/assets/{oauthManager-BUK7ic7q.js → oauthManager-Ch5Du2fF.js} +1 -1
  185. package/out/web/assets/{packet-AYTQ26CC-B-R-SHlF.js → packet-AYTQ26CC-B6xUyE6r.js} +1 -1
  186. package/out/web/assets/{pegDiagram-XKGWAZYB-Da2q_giz.js → pegDiagram-XKGWAZYB-1LabVycL.js} +1 -1
  187. package/out/web/assets/{pie-WAS4IAKB-BC8F0N7-.js → pie-WAS4IAKB-DJwo-nf_.js} +1 -1
  188. package/out/web/assets/{pieDiagram-E7YTZNPT-Iy9REJNC.js → pieDiagram-E7YTZNPT-icmAqzy4.js} +1 -1
  189. package/out/web/assets/{quadrantDiagram-AXDQQJYC-ztsiYw7h.js → quadrantDiagram-AXDQQJYC-CZ-wEx41.js} +1 -1
  190. package/out/web/assets/{radar-RG4KPBEZ-B_1P8Z_z.js → radar-RG4KPBEZ-D7d-Xriw.js} +1 -1
  191. package/out/web/assets/{railroad-74A4TZTK-DuTqczSo.js → railroad-74A4TZTK-viuhdYbs.js} +1 -1
  192. package/out/web/assets/{railroad-abnf-HS5TGJTU-Df29WLmp.js → railroad-abnf-HS5TGJTU-DNXiCNea.js} +1 -1
  193. package/out/web/assets/{railroad-ebnf-LZEXJU2U-ePFMo-qp.js → railroad-ebnf-LZEXJU2U-nkSu1-0C.js} +1 -1
  194. package/out/web/assets/{railroad-peg-WCYAUIDC-DYXLW4_E.js → railroad-peg-WCYAUIDC-BhbMeQzz.js} +1 -1
  195. package/out/web/assets/{railroadDiagram-O6MQD6OU-LStnZf2S.js → railroadDiagram-O6MQD6OU-CLpG1jUX.js} +1 -1
  196. package/out/web/assets/{requirementDiagram-BXWQKSXE-DmzonMH-.js → requirementDiagram-BXWQKSXE-kRDrvWrc.js} +1 -1
  197. package/out/web/assets/{sankeyDiagram-P5KCCOFB-B8vjmUU1.js → sankeyDiagram-P5KCCOFB-DxAwmq4x.js} +1 -1
  198. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-CTnplOAX.js → sequenceDiagram-WJ2MYXX4-aLgY-6Wz.js} +1 -1
  199. package/out/web/assets/{stateDiagram-D77RDMKH-BFrIaLdI.js → stateDiagram-D77RDMKH-D5QWfOhp.js} +1 -1
  200. package/out/web/assets/stateDiagram-v2-MP3YSRHH-D6OiZoAP.js +1 -0
  201. package/out/web/assets/{swimlanes-42K2YHIH-BXGngUNU.js → swimlanes-42K2YHIH-CRs5UyA-.js} +1 -1
  202. package/out/web/assets/swimlanesDiagram-VR7AAH4N-UgEXIsV0.js +8 -0
  203. package/out/web/assets/syntaxHighlight-C-wAGT84.js +3 -0
  204. package/out/web/assets/{timeline-definition-24CTP7MA-CegH7n43.js → timeline-definition-24CTP7MA-Denqd9qb.js} +1 -1
  205. package/out/web/assets/{treeView-Q6P3EWNA-DTpJv2Tw.js → treeView-Q6P3EWNA-0my3Z3s4.js} +1 -1
  206. package/out/web/assets/{treemap-WGGIJYW6-i8M9iJU6.js → treemap-WGGIJYW6-B_EjkLYq.js} +1 -1
  207. package/out/web/assets/{vennDiagram-4TSXK5OY-Dpzo1S8U.js → vennDiagram-4TSXK5OY-DxVg3Ewr.js} +1 -1
  208. package/out/web/assets/{wardley-WFR3VGLG-BrNsgtfw.js → wardley-WFR3VGLG-CGD-Tj4p.js} +1 -1
  209. package/out/web/assets/{wardleyDiagram-VM6X3IG4-lQ92LFE1.js → wardleyDiagram-VM6X3IG4-CmfS39Xm.js} +1 -1
  210. package/out/web/assets/{xychartDiagram-S5SC5T6Z-BaOaEoBd.js → xychartDiagram-S5SC5T6Z-BVM9bKis.js} +1 -1
  211. package/out/web/index-web.html +2 -2
  212. package/out/web/index.html +2 -2
  213. package/package.json +2 -2
  214. package/shared-modules/CODEOWNERS +3 -3
  215. package/shared-modules/SDL2/SDL2_mixer.json +27 -0
  216. package/shared-modules/appstream/README.md +13 -0
  217. package/shared-modules/appstream/appstream-compose.json +111 -0
  218. package/shared-modules/dbus-glib/dbus-glib.json +2 -2
  219. package/shared-modules/libportal/libportal.json +23 -0
  220. package/shared-modules/linux-audio/fluidsynth2-static.json +2 -2
  221. package/shared-modules/linux-audio/fluidsynth2.json +2 -2
  222. package/shared-modules/linux-audio/libinstpatch.json +2 -2
  223. package/shared-modules/linux-audio/liblo-static.json +3 -0
  224. package/shared-modules/luajit/luajit.json +2 -2
  225. package/shared-modules/squashfs-tools/squashfs-tools.json +0 -3
  226. package/slash-commands.json +7 -2
  227. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  228. package/src/renderer/shared/agentProgress.ts +51 -3
  229. package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -56
  230. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +15 -1
  231. package/src/renderer/shared/components/Settings/GeneralTab.tsx +25 -12
  232. package/src/renderer/shared/components/Settings/ThemeTab.test.tsx +45 -0
  233. package/src/renderer/shared/components/Settings/ThemeTab.tsx +106 -3
  234. package/src/renderer/shared/components/activity/AgentActivityDrawer.tsx +67 -17
  235. package/src/renderer/shared/components/activity/AgentActivityTable.tsx +376 -134
  236. package/src/renderer/shared/components/activity/AskUserActivityCard.test.tsx +122 -0
  237. package/src/renderer/shared/components/activity/AskUserActivityCard.tsx +70 -0
  238. package/src/renderer/shared/components/activity/McpToolResults.tsx +39 -2
  239. package/src/renderer/shared/components/activity/SubagentDagView.tsx +7 -7
  240. package/src/renderer/shared/components/activity/ViewedImages.tsx +41 -2
  241. package/src/renderer/shared/components/app/ChunkErrorBoundary.tsx +3 -3
  242. package/src/renderer/shared/components/app/CommandPalette.tsx +7 -7
  243. package/src/renderer/shared/components/app/DesktopTitlebar.tsx +2 -2
  244. package/src/renderer/shared/components/app/MintDashboard.tsx +27 -17
  245. package/src/renderer/shared/components/app/ModalErrorBoundary.tsx +3 -3
  246. package/src/renderer/shared/components/cards/CalculationCard.tsx +3 -3
  247. package/src/renderer/shared/components/cards/ImageGenCard.tsx +2 -2
  248. package/src/renderer/shared/components/cards/ImageSearchCard.tsx +2 -2
  249. package/src/renderer/shared/components/cards/MermaidCard.tsx +1 -1
  250. package/src/renderer/shared/components/cards/PlanCard.test.tsx +41 -0
  251. package/src/renderer/shared/components/cards/PlanCard.tsx +32 -0
  252. package/src/renderer/shared/components/cards/ReleaseNotesCard.test.tsx +29 -0
  253. package/src/renderer/shared/components/cards/ReleaseNotesCard.tsx +137 -0
  254. package/src/renderer/shared/components/cards/StockCard.tsx +7 -7
  255. package/src/renderer/shared/components/cards/ToolResultCards.tsx +11 -3
  256. package/src/renderer/shared/components/cards/UiMockupWidget.tsx +4 -4
  257. package/src/renderer/shared/components/cards/WeatherCard.tsx +5 -5
  258. package/src/renderer/shared/components/chat/ApprovalCard.test.tsx +11 -0
  259. package/src/renderer/shared/components/chat/ApprovalCard.tsx +21 -2
  260. package/src/renderer/shared/components/chat/ArtifactCards.test.tsx +69 -0
  261. package/src/renderer/shared/components/chat/ArtifactCards.tsx +238 -0
  262. package/src/renderer/shared/components/chat/AttachedDocumentCard.test.tsx +39 -0
  263. package/src/renderer/shared/components/chat/AttachedDocumentCard.tsx +220 -0
  264. package/src/renderer/shared/components/chat/ChatCodeBlock.test.tsx +33 -0
  265. package/src/renderer/shared/components/chat/ChatCodeBlock.tsx +15 -1
  266. package/src/renderer/shared/components/chat/ChatMessageItem.test.tsx +40 -2
  267. package/src/renderer/shared/components/chat/ChatMessageItem.tsx +180 -34
  268. package/src/renderer/shared/components/chat/ChatModeSelector.css +227 -0
  269. package/src/renderer/shared/components/chat/ChatModeSelector.test.tsx +45 -0
  270. package/src/renderer/shared/components/chat/ChatModeSelector.tsx +198 -0
  271. package/src/renderer/shared/components/chat/ChatPanel.test.tsx +22 -0
  272. package/src/renderer/shared/components/chat/ChatPanel.tsx +227 -53
  273. package/src/renderer/shared/components/chat/DiffReviewModal.tsx +6 -6
  274. package/src/renderer/shared/components/chat/GeminiLiveOverlay.test.tsx +142 -1
  275. package/src/renderer/shared/components/chat/GeminiLiveOverlay.tsx +45 -3
  276. package/src/renderer/shared/components/chat/ModelSelectorPopover.tsx +42 -14
  277. package/src/renderer/shared/components/chat/RewindModal.tsx +10 -10
  278. package/src/renderer/shared/components/chat/SlashSuggestions.test.tsx +14 -0
  279. package/src/renderer/shared/components/chat/SourcesBlock.test.tsx +53 -0
  280. package/src/renderer/shared/components/chat/SourcesBlock.tsx +236 -90
  281. package/src/renderer/shared/components/chat/TurnFileChanges.test.tsx +45 -1
  282. package/src/renderer/shared/components/chat/TurnFileChanges.tsx +202 -169
  283. package/src/renderer/shared/components/extensions/CliSessionsView.tsx +5 -4
  284. package/src/renderer/shared/components/extensions/McpRegistryPicker.tsx +3 -3
  285. package/src/renderer/shared/components/studio/LibraryPage.tsx +48 -5
  286. package/src/renderer/shared/components/workspace/ArtifactPreviewPanel.tsx +125 -13
  287. package/src/renderer/shared/components/workspace/CodeReviewPage.test.tsx +50 -0
  288. package/src/renderer/shared/components/workspace/CodeReviewPage.tsx +77 -16
  289. package/src/renderer/shared/components/workspace/GitBranchSelector.tsx +78 -22
  290. package/src/renderer/shared/components/workspace/ToolSurfacePage.test.tsx +13 -0
  291. package/src/renderer/shared/components/workspace/ToolSurfacePage.tsx +48 -28
  292. package/src/renderer/shared/components/workspace/WorkspacePdfPreview.tsx +266 -47
  293. package/src/renderer/shared/components/workspace/WorkspacePreviewActions.test.tsx +18 -6
  294. package/src/renderer/shared/components/workspace/background-terminals.css +11 -11
  295. package/src/renderer/shared/constants/config.ts +1 -5
  296. package/src/renderer/shared/constants/models.test.ts +51 -0
  297. package/src/renderer/shared/constants/models.ts +139 -32
  298. package/src/renderer/shared/conversation/lifecycle.test.ts +91 -3
  299. package/src/renderer/shared/conversation/lifecycle.ts +104 -5
  300. package/src/renderer/shared/conversation/progressiveStream.test.ts +83 -0
  301. package/src/renderer/shared/conversation/progressiveStream.ts +219 -0
  302. package/src/renderer/shared/css/agent-observability.css +133 -0
  303. package/src/renderer/shared/css/chat-ui-cards.css +13 -13
  304. package/src/renderer/shared/css/code-review.css +63 -34
  305. package/src/renderer/shared/css/code-sessions.css +47 -18
  306. package/src/renderer/shared/css/collapsed-paste.css +1 -1
  307. package/src/renderer/shared/css/error-notice.css +1 -1
  308. package/src/renderer/shared/css/file-changes.css +165 -80
  309. package/src/renderer/shared/css/library.css +105 -10
  310. package/src/renderer/shared/css/management-views.css +1 -1
  311. package/src/renderer/shared/css/mcp-results.css +5 -5
  312. package/src/renderer/shared/css/model-selector.css +18 -18
  313. package/src/renderer/shared/css/mods.css +7 -7
  314. package/src/renderer/shared/css/notification-notice.css +2 -2
  315. package/src/renderer/shared/css/queued-messages.css +5 -5
  316. package/src/renderer/shared/css/settings/general.css +18 -13
  317. package/src/renderer/shared/css/settings/profile.css +5 -5
  318. package/src/renderer/shared/css/settings/theme.css +5 -5
  319. package/src/renderer/shared/css/slash-choice.css +2 -2
  320. package/src/renderer/shared/css/slash-suggestions.css +3 -3
  321. package/src/renderer/shared/css/sources-block.css +41 -2
  322. package/src/renderer/shared/css/tools-panel.css +2 -2
  323. package/src/renderer/shared/css/ui-tailwind.css +8 -0
  324. package/src/renderer/shared/css/veo-studio.css +2 -2
  325. package/src/renderer/shared/css/viewed-images.css +7 -7
  326. package/src/renderer/shared/platform.ts +2 -1
  327. package/src/renderer/shared/theme/themeManager.test.ts +17 -0
  328. package/src/renderer/shared/theme/themeManager.ts +21 -4
  329. package/src/renderer/shared/theme/themes.ts +54 -0
  330. package/src/renderer/shared/theme/tokens.css +6 -4
  331. package/src/renderer/shared/types.ts +1 -1
  332. package/src/renderer/shared/utils/agentActivity.test.ts +468 -2
  333. package/src/renderer/shared/utils/agentActivity.ts +602 -35
  334. package/src/renderer/shared/utils/artifactBrowser.test.ts +37 -32
  335. package/src/renderer/shared/utils/artifactBrowser.ts +17 -19
  336. package/src/renderer/shared/utils/attachedDocument.test.ts +73 -0
  337. package/src/renderer/shared/utils/attachedDocument.ts +49 -0
  338. package/src/renderer/shared/utils/fileIcons.test.ts +59 -0
  339. package/src/renderer/shared/utils/fileIcons.ts +149 -16
  340. package/src/renderer/shared/utils/markdown.tsx +72 -19
  341. package/src/renderer/shared/utils/markdown.webImages.test.tsx +16 -0
  342. package/src/renderer/shared/utils/mcpResults.test.tsx +29 -0
  343. package/src/renderer/shared/utils/slashCommandProcessor.ts +4 -1
  344. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +11 -1
  345. package/src/renderer/shared/utils/syntaxHighlight.tsx +51 -3
  346. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +324 -51
  347. package/src/renderer/shared/utils/workspaceDrag.test.ts +153 -0
  348. package/src/renderer/shared/utils/workspaceDrag.ts +142 -0
  349. package/src/renderer/shared/utils/workspaceMedia.ts +10 -2
  350. package/src/renderer/src/App.tsx +14 -8
  351. package/src/renderer/src/agentProgress.ts +1 -0
  352. package/src/renderer/src/components/ScreenPicker.css +1 -1
  353. package/src/renderer/src/components/WorkspacePanel.tsx +6 -5
  354. package/src/renderer/src/css/app-shell.css +4 -4
  355. package/src/renderer/src/css/chat.css +1261 -241
  356. package/src/renderer/src/css/desktop-tools.css +11 -11
  357. package/src/renderer/src/css/sidebar-workspace.css +2 -2
  358. package/src/renderer/src/css/tool-surfaces.css +12 -12
  359. package/src/renderer/src/index.css +38 -20
  360. package/src/renderer/src/tauri.ts +22 -2
  361. package/src/renderer/src-web/App.tsx +14 -0
  362. package/src/renderer/src-web/agentProgress.ts +1 -0
  363. package/src/renderer/src-web/css/app-shell.css +4 -4
  364. package/src/renderer/src-web/css/chat.css +1278 -190
  365. package/src/renderer/src-web/css/mobile.css +29 -13
  366. package/src/renderer/src-web/css/settings/mobile.css +2 -2
  367. package/src/renderer/src-web/css/sidebar-workspace.css +1 -1
  368. package/src/renderer/src-web/index.css +38 -20
  369. package/src/renderer/src-web/tauri.ts +22 -2
  370. package/src/renderer/tests/browserArtifacts.browser.tsx +1 -1
  371. package/src/renderer/tests/library.browser.tsx +3 -0
  372. package/src/renderer/tests/liveVoice.browser.html +1 -0
  373. package/src/renderer/tests/workspaceDrop.browser.tsx +1 -1
  374. package/src-tauri/src/app/lifecycle.rs +1 -14
  375. package/src-tauri/src/background/headless.rs +14 -8
  376. package/src-tauri/src/commands/conversation.rs +38 -0
  377. package/src-tauri/src/native/mod.rs +0 -1
  378. package/src-tauri/src/native/system.rs +4 -47
  379. package/crates/mint-cli/src/interactive/chat_tui.rs +0 -8855
  380. package/crates/mint-core/src/agent/chat.rs +0 -4394
  381. package/crates/mint-core/src/browser/lifecycle.rs +0 -291
  382. package/crates/mint-core/src/browser/logging.rs +0 -20
  383. package/crates/mint-core/src/orchestration/tools/browser.rs +0 -31
  384. package/out/web/assets/ArtifactPreviewPanel-S0dPfYFx.js +0 -2
  385. package/out/web/assets/CalculationCard-CRjP3E1j.js +0 -1
  386. package/out/web/assets/CliSessionsView-C3Br2v5Y.css +0 -1
  387. package/out/web/assets/CodeReviewPage-CUJceQme.css +0 -1
  388. package/out/web/assets/CodeReviewPage-VCUgPla_.js +0 -2
  389. package/out/web/assets/ImageGenCard-BCXxkVJX.js +0 -1
  390. package/out/web/assets/ImageSearchCard-N3yQOr8Z.js +0 -1
  391. package/out/web/assets/LibraryPage-DAxUTAg7.css +0 -1
  392. package/out/web/assets/LibraryPage-WlviRdR8.js +0 -2
  393. package/out/web/assets/LinkedFoldersView-CKRgroWK.js +0 -1
  394. package/out/web/assets/McpServersView-D3wHnuHg.js +0 -1
  395. package/out/web/assets/MermaidCard-NqrHdyJW.js +0 -2
  396. package/out/web/assets/MintDashboard-B2DJsDDO.css +0 -1
  397. package/out/web/assets/MintDashboard-COw_xol7.js +0 -63
  398. package/out/web/assets/RewindModal-DempuDod.js +0 -1
  399. package/out/web/assets/SettingsWindow-2YP3yj7s.js +0 -4
  400. package/out/web/assets/SettingsWindow-Dz8LQVgD.css +0 -1
  401. package/out/web/assets/StockCard-BDMCe7mx.js +0 -1
  402. package/out/web/assets/ToolSurfacePage-B0J_q0Jl.js +0 -38
  403. package/out/web/assets/ToolSurfacePage-CRIQ1owD.css +0 -1
  404. package/out/web/assets/UiMockupWidget-DD73QWg1.js +0 -1
  405. package/out/web/assets/WeatherCard-CPucIfq8.js +0 -1
  406. package/out/web/assets/WorkspacePdfPreview-Dx_lkjNu.js +0 -2
  407. package/out/web/assets/classDiagram-ZZMXUADV-BgJ5WtSp.js +0 -1
  408. package/out/web/assets/classDiagram-v2-VYDZK3BY-BgJ5WtSp.js +0 -1
  409. package/out/web/assets/config-CvIO5hkn.js +0 -2
  410. package/out/web/assets/flowDiagram-HODETNUW-uqCHJond.js +0 -1
  411. package/out/web/assets/index-BmpulIkg.js +0 -168
  412. package/out/web/assets/index-CkvBwhX_.css +0 -1
  413. package/out/web/assets/stateDiagram-v2-MP3YSRHH-uf3kdNW4.js +0 -1
  414. package/out/web/assets/swimlanesDiagram-VR7AAH4N-Y8q5bQce.js +0 -8
  415. package/shared-modules/libsecret/libsecret.json +0 -23
  416. package/shared-modules/libusb/libusb.json +0 -27
  417. package/shared-modules/lzo/lzo.json +0 -27
  418. package/src/renderer/src/components/BrowserShell.tsx +0 -108
  419. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +0 -91
  420. package/src/renderer/src/components/browser-shell.css +0 -185
  421. package/src-tauri/src/native/browser.rs +0 -252
@@ -0,0 +1,1833 @@
1
+ //! ChatViewState, TuiHandle, composer editing, tool progress, and SQLite transcript sync.
2
+ use super::*;
3
+
4
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
5
+ pub(crate) enum SwarmUiStatus {
6
+ Running,
7
+ Success,
8
+ Failed,
9
+ }
10
+
11
+ #[derive(Debug, Clone)]
12
+ pub(crate) struct SwarmAgentUiState {
13
+ pub label: String,
14
+ pub profile: String,
15
+ pub task: String,
16
+ pub current_action: Option<String>,
17
+ pub status: SwarmUiStatus,
18
+ pub started_at: std::time::Instant,
19
+ pub finished_elapsed_ms: Option<u64>,
20
+ pub summary_preview: Option<String>,
21
+ }
22
+
23
+ pub(super) fn clip_text(text: &str, width: usize) -> String {
24
+ super::format::truncate_to_width(text, width)
25
+ }
26
+
27
+ pub(super) fn format_subagent_tool_action(action: &str, input: &serde_json::Value) -> String {
28
+ super::tool_activity::ToolActivity::subagent_action_summary(action, input)
29
+ }
30
+
31
+ #[derive(Debug, Default)]
32
+ pub(crate) struct ChatViewState {
33
+ pub(crate) transcript: Vec<TranscriptEntry>,
34
+ pub(crate) swarm_agents: Vec<SwarmAgentUiState>,
35
+ pub(super) chat_id: String,
36
+ pub(super) mods: Vec<serde_json::Value>,
37
+ pub(super) mods_open: bool,
38
+ pub(super) mod_focus: usize,
39
+ pub(super) mod_input: Option<(String, String)>,
40
+ pub(super) sync_cursor: i64,
41
+ pub(super) last_interaction_id: i64,
42
+ pub(super) composer: Vec<char>,
43
+ pub(super) collapsed_pastes: Vec<CollapsedPaste>,
44
+ pub(super) expanded_paste: Option<std::ops::Range<usize>>,
45
+ pub(super) cursor: usize,
46
+ pub(super) history: Vec<String>,
47
+ pub(super) history_index: Option<usize>,
48
+ pub(super) draft_before_history: Vec<char>,
49
+ pub(super) slash_selected: usize,
50
+ pub(super) status: Vec<String>,
51
+ pub(super) model: String,
52
+ pub(super) thinking_status: String,
53
+ pub(super) provider: String,
54
+ pub(super) workspace: String,
55
+ pub(super) current_dir: std::path::PathBuf,
56
+ pub(super) plan_mode: bool,
57
+ pub(super) scroll_from_bottom: usize,
58
+ pub(super) selection_mode: bool,
59
+ pub(super) selection_anchor: usize,
60
+ pub(super) selection_head: usize,
61
+ pub(super) notice: Option<(String, std::time::Instant)>,
62
+ pub(super) editing_turn: Option<(i64, usize)>,
63
+ pub(super) dialog: Option<DialogState>,
64
+ pub(super) thought_modal: Option<ThoughtModalState>,
65
+ pub(super) thoughts: Vec<ThinkingSegment>,
66
+ pub(super) tools: Vec<super::tool_activity::ToolActivity>,
67
+ pub(super) tool_viewer: Option<usize>,
68
+ pub(super) watermark_frame: u8,
69
+ pub(super) watermark_animating: bool,
70
+ pub(crate) queued_count: usize,
71
+ pub(crate) queued_preview: Option<String>,
72
+ pub(crate) theme: crate::terminal_theme::TuiTheme,
73
+ pub(super) assistant_pacer: Option<crate::agent::ProgressiveMarkdownPacer>,
74
+ pub(super) active_assistant_entry: Option<usize>,
75
+ pub(super) active_plan_entry: Option<usize>,
76
+ pub(super) pending_post_assistant_notices: Vec<String>,
77
+ pub(super) last_assistant_tick: Option<std::time::Instant>,
78
+ }
79
+
80
+ #[derive(Clone, Debug)]
81
+ pub(crate) struct TuiHandle {
82
+ pub(crate) state: Arc<Mutex<ChatViewState>>,
83
+ pub(super) interrupted: Arc<AtomicBool>,
84
+ pub(super) queued: Arc<Mutex<Vec<String>>>,
85
+ }
86
+
87
+ impl TuiHandle {
88
+ pub fn tool_progress(&self, progress: &mint_core::AgentProgress) {
89
+ if let Ok(mut state) = self.state.lock() {
90
+ state.record_tool_progress(progress);
91
+ }
92
+ }
93
+
94
+ pub fn reset_thoughts(&self) {
95
+ if let Ok(mut state) = self.state.lock() {
96
+ state.finish_live_thought();
97
+ state.thought_modal = None;
98
+ }
99
+ }
100
+
101
+ pub fn append_thought_delta(&self, id: &str, delta: &str, elapsed: std::time::Duration) {
102
+ if delta.is_empty() {
103
+ return;
104
+ }
105
+ if let Ok(mut state) = self.state.lock() {
106
+ if let Some(thought) = state.thoughts.last_mut()
107
+ && thought.id.as_deref() == Some(id)
108
+ && thought.updated_at.is_some()
109
+ {
110
+ thought.text.push_str(delta);
111
+ thought.elapsed = elapsed.max(thought.elapsed());
112
+ thought.updated_at = Some(std::time::Instant::now());
113
+ } else {
114
+ state.finish_live_thought();
115
+ state.thoughts.push(ThinkingSegment {
116
+ id: Some(id.to_owned()),
117
+ text: delta.to_owned(),
118
+ elapsed,
119
+ elapsed_known: true,
120
+ updated_at: Some(std::time::Instant::now()),
121
+ });
122
+ }
123
+ state.refresh_thought_modal();
124
+ }
125
+ }
126
+
127
+ pub fn finish_thought(&self, id: Option<&str>, text: &str, elapsed: std::time::Duration) {
128
+ if let Ok(mut state) = self.state.lock() {
129
+ if let Some(thought) = state.thoughts.last_mut()
130
+ && thought.updated_at.is_some()
131
+ && (id.is_none() || thought.id.as_deref() == id)
132
+ {
133
+ if !text.trim().is_empty() {
134
+ thought.text = text.to_owned();
135
+ }
136
+ thought.elapsed = elapsed;
137
+ thought.updated_at = None;
138
+ } else if let Some(thought) = state.thoughts.last_mut()
139
+ && thought.updated_at.is_none()
140
+ && id.is_some()
141
+ && thought.id.as_deref() == id
142
+ {
143
+ if !text.trim().is_empty() && !thought.text.contains(text.trim()) {
144
+ if thought.text.trim().is_empty() {
145
+ thought.text = text.to_owned();
146
+ } else {
147
+ thought.text = format!("{}\n\n{}", thought.text, text.trim());
148
+ }
149
+ }
150
+ thought.elapsed = elapsed.max(thought.elapsed);
151
+ state.refresh_thought_modal();
152
+ return;
153
+ } else if !text.trim().is_empty() {
154
+ // Close any in-flight live thought segment without emitting a separate
155
+ // duplicate "Thought · Xs (Ctrl+T to view)" notice before our own notice.
156
+ if let Some(live) = state.thoughts.last_mut()
157
+ && live.updated_at.is_some()
158
+ {
159
+ live.elapsed = live.elapsed();
160
+ live.updated_at = None;
161
+ }
162
+ state.thoughts.push(ThinkingSegment {
163
+ id: id.map(str::to_owned),
164
+ text: text.to_owned(),
165
+ elapsed,
166
+ elapsed_known: true,
167
+ updated_at: None,
168
+ });
169
+ } else {
170
+ state.finish_live_thought();
171
+ return;
172
+ }
173
+ state.push_notice(format!(
174
+ "Thought · {} (Ctrl+T to view)",
175
+ super::format_thought_elapsed(elapsed)
176
+ ));
177
+ state.refresh_thought_modal();
178
+ }
179
+ }
180
+
181
+ pub fn set_status(&self, lines: Vec<String>) {
182
+ if let Ok(mut state) = self.state.lock() {
183
+ state.status = lines;
184
+ }
185
+ }
186
+ pub fn clear_status(&self) {
187
+ self.set_status(Vec::new());
188
+ }
189
+ pub fn push_notice(&self, text: impl Into<String>) {
190
+ if let Ok(mut state) = self.state.lock() {
191
+ state.push_notice(text);
192
+ }
193
+ }
194
+ pub fn append_assistant_delta(&self, delta: &str) {
195
+ if delta.is_empty() {
196
+ return;
197
+ }
198
+ if let Ok(mut state) = self.state.lock() {
199
+ state.append_assistant_delta(delta);
200
+ }
201
+ }
202
+ pub fn begin_assistant_reveal(&self, summary: String, post_notices: Vec<String>) {
203
+ if let Ok(mut state) = self.state.lock() {
204
+ state.begin_assistant_reveal(summary, post_notices);
205
+ }
206
+ }
207
+ pub fn push_command(&self, text: impl Into<String>) {
208
+ if let Ok(mut state) = self.state.lock() {
209
+ state
210
+ .transcript
211
+ .push(TranscriptEntry::new(TranscriptRole::Command, text));
212
+ state.scroll_from_bottom = 0;
213
+ }
214
+ }
215
+ pub fn push_release_notes(&self, text: impl Into<String>) {
216
+ if let Ok(mut state) = self.state.lock() {
217
+ state
218
+ .transcript
219
+ .push(TranscriptEntry::new(TranscriptRole::ReleaseNotes, text));
220
+ state.scroll_from_bottom = 0;
221
+ }
222
+ }
223
+ pub fn push_plan(&self, text: impl Into<String>) {
224
+ if let Ok(mut state) = self.state.lock() {
225
+ state.push_plan(text);
226
+ }
227
+ }
228
+ #[allow(dead_code)]
229
+ pub fn push_system(&self, text: impl Into<String>) {
230
+ if let Ok(mut state) = self.state.lock() {
231
+ state
232
+ .transcript
233
+ .push(TranscriptEntry::new(TranscriptRole::System, text));
234
+ state.scroll_from_bottom = 0;
235
+ }
236
+ }
237
+ pub fn set_notice(&self, text: impl Into<String>) {
238
+ if let Ok(mut state) = self.state.lock() {
239
+ state.set_notice(text);
240
+ }
241
+ }
242
+ pub fn take_interrupted(&self) -> bool {
243
+ self.interrupted.swap(false, Ordering::Relaxed)
244
+ }
245
+ pub fn take_queued(&self) -> Vec<String> {
246
+ self.queued
247
+ .lock()
248
+ .map(|mut queued| std::mem::take(&mut *queued))
249
+ .unwrap_or_default()
250
+ }
251
+ pub fn draft(&self) -> Option<String> {
252
+ self.state
253
+ .lock()
254
+ .ok()
255
+ .and_then(|state| (!state.composer.is_empty()).then(|| state.composer_text()))
256
+ }
257
+ pub fn choose(
258
+ &self,
259
+ title: impl Into<String>,
260
+ body: impl Into<String>,
261
+ options: Vec<String>,
262
+ ) -> Option<usize> {
263
+ let (reply, response) = mpsc::channel();
264
+ if let Ok(mut state) = self.state.lock() {
265
+ state.dialog = Some(DialogState::new_choice(title, body, options, None, reply));
266
+ } else {
267
+ return None;
268
+ }
269
+ match response.recv() {
270
+ Ok(DialogAnswer::Choice(choice)) => Some(choice),
271
+ _ => None,
272
+ }
273
+ }
274
+ pub fn choose_many(
275
+ &self,
276
+ title: impl Into<String>,
277
+ body: impl Into<String>,
278
+ options: Vec<String>,
279
+ ) -> Vec<usize> {
280
+ let (reply, response) = mpsc::channel();
281
+ let checked = vec![false; options.len()];
282
+ if let Ok(mut state) = self.state.lock() {
283
+ state.dialog = Some(DialogState::new_choice(
284
+ title,
285
+ body,
286
+ options,
287
+ Some(checked),
288
+ reply,
289
+ ));
290
+ } else {
291
+ return Vec::new();
292
+ }
293
+ match response.recv() {
294
+ Ok(DialogAnswer::Choices(choices)) => choices,
295
+ _ => Vec::new(),
296
+ }
297
+ }
298
+ pub fn prompt_text(&self, title: impl Into<String>, body: impl Into<String>) -> Option<String> {
299
+ let (reply, response) = mpsc::channel();
300
+ if let Ok(mut state) = self.state.lock() {
301
+ state.dialog = Some(DialogState::new_text(title, body, reply));
302
+ } else {
303
+ return None;
304
+ }
305
+ match response.recv() {
306
+ Ok(DialogAnswer::Text(text)) if !text.trim().is_empty() => Some(text),
307
+ _ => None,
308
+ }
309
+ }
310
+ }
311
+
312
+ impl ChatViewState {
313
+ pub(super) fn record_tool_progress(
314
+ &mut self,
315
+ progress: &mint_core::AgentProgress,
316
+ ) -> Option<usize> {
317
+ use mint_core::AgentProgress;
318
+ match progress {
319
+ AgentProgress::SubagentStarted {
320
+ subagent,
321
+ profile,
322
+ task,
323
+ } => {
324
+ if let Some(existing) = self.swarm_agents.iter_mut().find(|a| a.label == *subagent)
325
+ {
326
+ existing.profile = profile.clone();
327
+ existing.task = task.clone();
328
+ existing.status = SwarmUiStatus::Running;
329
+ existing.started_at = std::time::Instant::now();
330
+ } else {
331
+ self.swarm_agents.push(SwarmAgentUiState {
332
+ label: subagent.clone(),
333
+ profile: profile.clone(),
334
+ task: task.clone(),
335
+ current_action: None,
336
+ status: SwarmUiStatus::Running,
337
+ started_at: std::time::Instant::now(),
338
+ finished_elapsed_ms: None,
339
+ summary_preview: None,
340
+ });
341
+ }
342
+ return None;
343
+ }
344
+ AgentProgress::SubagentFinished {
345
+ subagent,
346
+ status,
347
+ elapsed_ms,
348
+ summary_preview,
349
+ ..
350
+ } => {
351
+ let ui_status = if status == "failed" {
352
+ SwarmUiStatus::Failed
353
+ } else {
354
+ SwarmUiStatus::Success
355
+ };
356
+ if let Some(existing) = self.swarm_agents.iter_mut().find(|a| a.label == *subagent)
357
+ {
358
+ existing.status = ui_status;
359
+ existing.finished_elapsed_ms = Some(*elapsed_ms);
360
+ if !summary_preview.is_empty() {
361
+ existing.summary_preview = Some(summary_preview.clone());
362
+ }
363
+ } else {
364
+ self.swarm_agents.push(SwarmAgentUiState {
365
+ label: subagent.clone(),
366
+ profile: String::new(),
367
+ task: String::new(),
368
+ current_action: None,
369
+ status: ui_status,
370
+ started_at: std::time::Instant::now(),
371
+ finished_elapsed_ms: Some(*elapsed_ms),
372
+ summary_preview: if summary_preview.is_empty() {
373
+ None
374
+ } else {
375
+ Some(summary_preview.clone())
376
+ },
377
+ });
378
+ }
379
+ return None;
380
+ }
381
+ AgentProgress::ToolStart {
382
+ subagent: Some(agent_label),
383
+ action,
384
+ input,
385
+ ..
386
+ } => {
387
+ if let Some(agent) = self
388
+ .swarm_agents
389
+ .iter_mut()
390
+ .find(|a| a.label == *agent_label)
391
+ {
392
+ agent.current_action = Some(format_subagent_tool_action(action, input));
393
+ }
394
+ return None;
395
+ }
396
+ AgentProgress::ToolEnd {
397
+ subagent: Some(_), ..
398
+ } => {
399
+ return None;
400
+ }
401
+ AgentProgress::PlanUpdated { plan } => {
402
+ self.upsert_active_plan_card(plan);
403
+ return None;
404
+ }
405
+ _ => {}
406
+ }
407
+ let (call_id, action, input, subagent, end) = match progress {
408
+ AgentProgress::ToolStart {
409
+ call_id,
410
+ action,
411
+ input,
412
+ subagent,
413
+ } => (call_id, action, input, subagent, None),
414
+ AgentProgress::ToolEnd {
415
+ call_id,
416
+ action,
417
+ input,
418
+ subagent,
419
+ result,
420
+ status,
421
+ elapsed_ms,
422
+ } => (
423
+ call_id,
424
+ action,
425
+ input,
426
+ subagent,
427
+ Some((result, status, elapsed_ms)),
428
+ ),
429
+ _ => return None,
430
+ };
431
+ if action == "exit_plan_mode" {
432
+ return None;
433
+ }
434
+ let existing = self.tools.iter().position(|tool| {
435
+ (end.is_some() || call_id.is_some())
436
+ && tool.running()
437
+ && tool.matches(call_id.as_deref(), action, input, subagent.as_deref())
438
+ });
439
+ let index = existing.unwrap_or_else(|| {
440
+ let entry = if let Some(idx) = self.active_assistant_entry
441
+ && idx <= self.transcript.len()
442
+ {
443
+ self.transcript
444
+ .insert(idx, TranscriptEntry::new(TranscriptRole::Notice, ""));
445
+ self.active_assistant_entry = Some(idx + 1);
446
+ if let Some(plan_idx) = &mut self.active_plan_entry
447
+ && *plan_idx >= idx
448
+ {
449
+ *plan_idx += 1;
450
+ }
451
+ for tool in &mut self.tools {
452
+ if tool.entry >= idx {
453
+ tool.entry += 1;
454
+ }
455
+ }
456
+ idx
457
+ } else {
458
+ let entry = self.transcript.len();
459
+ self.transcript
460
+ .push(TranscriptEntry::new(TranscriptRole::Notice, ""));
461
+ entry
462
+ };
463
+ self.tools.push(super::tool_activity::ToolActivity {
464
+ entry,
465
+ call_id: call_id.clone(),
466
+ action: action.clone(),
467
+ input: input.clone(),
468
+ subagent: subagent.clone(),
469
+ started: std::time::Instant::now(),
470
+ elapsed: None,
471
+ result: None,
472
+ status: None,
473
+ });
474
+ self.tools.len() - 1
475
+ });
476
+ if let Some((result, status, elapsed_ms)) = end {
477
+ self.tools[index].finish(result, status.clone());
478
+ if let Some(elapsed_ms) = elapsed_ms {
479
+ self.tools[index].elapsed = Some(std::time::Duration::from_millis(*elapsed_ms));
480
+ }
481
+ }
482
+ self.refresh_tool_card(index);
483
+ if self.tool_viewer == Some(index) {
484
+ self.refresh_tool_viewer(false);
485
+ }
486
+ Some(index)
487
+ }
488
+
489
+ pub(super) fn refresh_tool_card(&mut self, index: usize) {
490
+ let (default_width, _) = crate::markdown::terminal_size_or_default();
491
+ let tool = &self.tools[index];
492
+ let lines = tool.lines(default_width);
493
+ let unclipped_lines = tool.lines(u16::MAX);
494
+ let plain: Vec<String> = unclipped_lines
495
+ .iter()
496
+ .map(|line| {
497
+ line.spans
498
+ .iter()
499
+ .map(|span| span.content.as_ref())
500
+ .collect()
501
+ })
502
+ .collect();
503
+ if let Some(entry) = self.transcript.get_mut(tool.entry) {
504
+ *entry = TranscriptEntry {
505
+ role: TranscriptRole::Notice,
506
+ text: plain.join("\n"),
507
+ rendered_lines: lines,
508
+ plain_lines: plain,
509
+ rendered_width: default_width,
510
+ };
511
+ }
512
+ }
513
+
514
+ pub(super) fn finish_running_tools(&mut self) {
515
+ for index in 0..self.tools.len() {
516
+ if self.tools[index].running() {
517
+ self.tools[index].finish(
518
+ "Interrupted before a result was received.",
519
+ Some(mint_core::integrations::mcp_result::ToolStatus::Cancelled),
520
+ );
521
+ self.refresh_tool_card(index);
522
+ }
523
+ }
524
+ if self.tool_viewer.is_some() {
525
+ self.refresh_tool_viewer(false);
526
+ }
527
+ }
528
+
529
+ pub(super) fn refresh_tool_viewer(&mut self, reset_scroll: bool) {
530
+ if self
531
+ .dialog
532
+ .as_ref()
533
+ .is_some_and(|dialog| !dialog.is_tool_details())
534
+ {
535
+ self.tool_viewer = None;
536
+ return;
537
+ }
538
+ let Some(index) = self.tool_viewer else {
539
+ return;
540
+ };
541
+ let Some(tool) = self.tools.get(index) else {
542
+ return;
543
+ };
544
+ let body = tool.details();
545
+ if let Some(dialog) = self.dialog.as_mut() {
546
+ dialog.title = format!("Tool details · {}/{}", index + 1, self.tools.len());
547
+ dialog.body = body;
548
+ if reset_scroll {
549
+ dialog.body_scroll = 0;
550
+ }
551
+ } else {
552
+ let (reply, _) = mpsc::channel();
553
+ self.dialog = Some(DialogState::new_tool_details(
554
+ format!("Tool details · {}/{}", index + 1, self.tools.len()),
555
+ body,
556
+ reply,
557
+ ));
558
+ }
559
+ }
560
+
561
+ pub(super) fn handle_tool_key(&mut self, key: event::KeyEvent) -> bool {
562
+ if self
563
+ .dialog
564
+ .as_ref()
565
+ .is_some_and(|dialog| !dialog.is_tool_details())
566
+ {
567
+ self.tool_viewer = None;
568
+ return false;
569
+ }
570
+ use event::{KeyCode, KeyModifiers};
571
+ if self.dialog.is_none() {
572
+ self.tool_viewer = None;
573
+ }
574
+ let toggle =
575
+ key.code == KeyCode::Char('o') && key.modifiers.contains(KeyModifiers::CONTROL);
576
+ if let Some(index) = self.tool_viewer {
577
+ if toggle || key.code == KeyCode::Esc {
578
+ self.tool_viewer = None;
579
+ self.dialog = None;
580
+ return true;
581
+ }
582
+ if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) {
583
+ self.tool_viewer = Some(if key.code == KeyCode::Tab {
584
+ (index + 1) % self.tools.len()
585
+ } else {
586
+ (index + self.tools.len() - 1) % self.tools.len()
587
+ });
588
+ self.refresh_tool_viewer(true);
589
+ return true;
590
+ }
591
+ } else if toggle && self.dialog.is_none() && self.thought_modal.is_none() {
592
+ if self.tools.is_empty() {
593
+ self.set_notice("No tool activity recorded yet");
594
+ } else {
595
+ let target = self.preferred_tool_viewer_index();
596
+ self.tool_viewer = Some(target);
597
+ self.refresh_tool_viewer(true);
598
+ }
599
+ return true;
600
+ }
601
+ false
602
+ }
603
+
604
+ pub(super) fn preferred_tool_viewer_index(&self) -> usize {
605
+ let has_recent_sources = self.transcript.iter().rev().take(6).any(|entry| {
606
+ entry.text.starts_with("Sources:\n") || entry.text.contains("Ctrl+O view web_search")
607
+ });
608
+
609
+ if has_recent_sources {
610
+ if let Some(pos) = self
611
+ .tools
612
+ .iter()
613
+ .rposition(|tool| tool.action == "web_search")
614
+ {
615
+ return pos;
616
+ }
617
+ }
618
+ self.tools.len() - 1
619
+ }
620
+
621
+ pub fn from_session(session: &InteractiveSession) -> Self {
622
+ let mut state = Self {
623
+ model: active_model(&session.config.ai_provider, &session.config).to_owned(),
624
+ thinking_status: format_thinking_status(&session.config),
625
+ provider: format_provider_display_name(&session.config.ai_provider, &session.config),
626
+ workspace: format_workspace_with_branch(&session.current_dir),
627
+ current_dir: session.current_dir.clone(),
628
+ theme: crate::terminal_theme::TuiTheme::from_config(&session.config.tui_theme),
629
+ plan_mode: session.plan_mode,
630
+ history: session.history.clone(),
631
+ ..Self::default()
632
+ };
633
+ state.reload_transcript(&session.chat_id, &session.current_dir);
634
+ state
635
+ }
636
+ pub fn set_draft(&mut self, draft: String) {
637
+ self.composer = normalize_prompt_newlines(&draft).chars().collect();
638
+ self.collapsed_pastes.clear();
639
+ self.expanded_paste = None;
640
+ self.cursor = self.composer.len();
641
+ }
642
+ pub(super) fn composer_text(&self) -> String {
643
+ self.composer_text_range(0..self.composer.len())
644
+ }
645
+ pub(super) fn composer_text_range(&self, range: std::ops::Range<usize>) -> String {
646
+ let mut text = String::new();
647
+ let mut start = range.start;
648
+ for paste in self
649
+ .collapsed_pastes
650
+ .iter()
651
+ .filter(|paste| paste.range.start >= range.start && paste.range.end <= range.end)
652
+ {
653
+ text.extend(self.composer[start..paste.range.start].iter());
654
+ text.push_str(&paste.text);
655
+ start = paste.range.end;
656
+ }
657
+ text.extend(self.composer[start..range.end].iter());
658
+ text
659
+ }
660
+ pub(super) fn take_prompt(&mut self) -> String {
661
+ let text = self.composer_text();
662
+ self.composer.clear();
663
+ self.collapsed_pastes.clear();
664
+ self.expanded_paste = None;
665
+ self.cursor = 0;
666
+ text
667
+ }
668
+ pub(super) fn open_paste(&mut self) -> bool {
669
+ let Some(index) = self
670
+ .collapsed_pastes
671
+ .iter()
672
+ .enumerate()
673
+ .min_by_key(|(_, paste)| {
674
+ if self.cursor < paste.range.start {
675
+ paste.range.start - self.cursor
676
+ } else {
677
+ self.cursor.saturating_sub(paste.range.end)
678
+ }
679
+ })
680
+ .map(|(index, _)| index)
681
+ else {
682
+ return false;
683
+ };
684
+ self.expand_paste(index);
685
+ true
686
+ }
687
+ pub(super) fn expand_paste(&mut self, index: usize) {
688
+ let paste = self.collapsed_pastes.remove(index);
689
+ let start = paste.range.start;
690
+ self.replace_composer(paste.range, &paste.text);
691
+ self.expanded_paste = Some(start..self.cursor);
692
+ self.cursor = start;
693
+ }
694
+ pub(super) fn toggle_paste(&mut self) {
695
+ if let Some(range) = self.expanded_paste.take() {
696
+ let text = self.composer_text_range(range.clone());
697
+ let start = range.start;
698
+ let badge = format!("[Pasted Content {} chars]", text.chars().count());
699
+ self.replace_composer(range, &badge);
700
+ self.collapsed_pastes.push(CollapsedPaste {
701
+ range: start..self.cursor,
702
+ text,
703
+ });
704
+ self.collapsed_pastes.sort_by_key(|paste| paste.range.start);
705
+ } else {
706
+ self.open_paste();
707
+ }
708
+ }
709
+ pub(super) fn replace_composer(&mut self, mut range: std::ops::Range<usize>, text: &str) {
710
+ // Badges are atomic. Deleting any part removes the attached paste;
711
+ // inserting inside one opens it before editing the actual text.
712
+ if range.is_empty() {
713
+ if let Some(index) = self
714
+ .collapsed_pastes
715
+ .iter()
716
+ .position(|paste| range.start > paste.range.start && range.start < paste.range.end)
717
+ {
718
+ self.expand_paste(index);
719
+ range = self.cursor..self.cursor;
720
+ }
721
+ } else {
722
+ for paste in &self.collapsed_pastes {
723
+ if range.start < paste.range.end && range.end > paste.range.start {
724
+ range.start = range.start.min(paste.range.start);
725
+ range.end = range.end.max(paste.range.end);
726
+ }
727
+ }
728
+ }
729
+ let start = range.start;
730
+ let removed = range.end - start;
731
+ let added = text.chars().count();
732
+ self.collapsed_pastes.retain_mut(|paste| {
733
+ if start < paste.range.end && range.end > paste.range.start {
734
+ return false;
735
+ }
736
+ if paste.range.start >= range.end {
737
+ paste.range.start = paste.range.start - removed + added;
738
+ paste.range.end = paste.range.end - removed + added;
739
+ }
740
+ true
741
+ });
742
+ // Keep the open block's bounds attached to edits, including insertions
743
+ // at either edge. Fully deleting it must also remove the F2 target.
744
+ if let Some(open) = self.expanded_paste.as_mut() {
745
+ if !range.is_empty() && start <= open.start && range.end >= open.end {
746
+ self.expanded_paste = None;
747
+ } else {
748
+ if open.start > range.end || (!range.is_empty() && open.start == range.end) {
749
+ open.start = open.start - removed + added;
750
+ } else if open.start >= start {
751
+ open.start = start;
752
+ }
753
+ if open.end >= range.end {
754
+ open.end = open.end - removed + added;
755
+ } else if open.end >= start {
756
+ open.end = start + added;
757
+ }
758
+ }
759
+ }
760
+ self.composer.splice(range, text.chars());
761
+ self.cursor = start + added;
762
+ self.slash_selected = 0;
763
+ }
764
+ pub(super) fn edit_composer_key(&mut self, key: event::KeyEvent) -> bool {
765
+ use event::{KeyCode, KeyModifiers};
766
+ match key.code {
767
+ KeyCode::F(2) => {
768
+ self.toggle_paste();
769
+ }
770
+ KeyCode::Left => {
771
+ self.cursor = self
772
+ .collapsed_pastes
773
+ .iter()
774
+ .find(|paste| self.cursor > paste.range.start && self.cursor <= paste.range.end)
775
+ .map_or(self.cursor.saturating_sub(1), |paste| paste.range.start);
776
+ }
777
+ KeyCode::Right => {
778
+ self.cursor = self
779
+ .collapsed_pastes
780
+ .iter()
781
+ .find(|paste| self.cursor >= paste.range.start && self.cursor < paste.range.end)
782
+ .map_or((self.cursor + 1).min(self.composer.len()), |paste| {
783
+ paste.range.end
784
+ });
785
+ }
786
+ KeyCode::Backspace if self.cursor > 0 => {
787
+ self.replace_composer(self.cursor - 1..self.cursor, "");
788
+ }
789
+ KeyCode::Delete if self.cursor < self.composer.len() => {
790
+ self.replace_composer(self.cursor..self.cursor + 1, "");
791
+ }
792
+ KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => {
793
+ self.replace_composer(self.cursor..self.cursor, "\n");
794
+ }
795
+ KeyCode::Char(character)
796
+ if !key
797
+ .modifiers
798
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
799
+ {
800
+ if self.active_notice().is_some_and(|notice| {
801
+ !notice.starts_with("Terminal too small") && !notice.starts_with("[Mode: Edit")
802
+ }) {
803
+ self.clear_notice();
804
+ }
805
+ self.replace_composer(self.cursor..self.cursor, &character.to_string());
806
+ }
807
+ _ => return false,
808
+ }
809
+ true
810
+ }
811
+ pub(super) fn insert_paste(&mut self, text: &str) {
812
+ let text = normalize_prompt_newlines(text);
813
+ if let Some(index) = self
814
+ .collapsed_pastes
815
+ .iter()
816
+ .position(|paste| self.cursor > paste.range.start && self.cursor < paste.range.end)
817
+ {
818
+ self.expand_paste(index);
819
+ }
820
+ let cursor = self.cursor.min(self.composer.len());
821
+ if text.lines().count() > 6 {
822
+ let badge = format!("[Pasted Content {} chars]", text.chars().count());
823
+ self.replace_composer(cursor..cursor, &badge);
824
+ self.collapsed_pastes.push(CollapsedPaste {
825
+ range: cursor..self.cursor,
826
+ text: text.clone(),
827
+ });
828
+ self.collapsed_pastes.sort_by_key(|paste| paste.range.start);
829
+ } else {
830
+ self.replace_composer(cursor..cursor, &text);
831
+ }
832
+ self.slash_selected = 0;
833
+ }
834
+ pub(super) fn composer_width(&self, area: Rect) -> usize {
835
+ let (main, _) = mod_layout(
836
+ area,
837
+ self.mods_open && self.dialog.is_none() && self.thought_modal.is_none(),
838
+ );
839
+ main.width.saturating_sub(4).max(1) as usize
840
+ }
841
+ pub(super) fn navigate_composer(&mut self, key: event::KeyEvent, row_width: usize) -> bool {
842
+ use event::{KeyCode, KeyModifiers};
843
+ if key
844
+ .modifiers
845
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
846
+ {
847
+ return false;
848
+ }
849
+ // Honor the transcript's visible End shortcut before draft navigation.
850
+ if key.code == KeyCode::End && self.scroll_from_bottom > 0 {
851
+ self.scroll_from_bottom = 0;
852
+ return true;
853
+ }
854
+ if self.composer.is_empty() {
855
+ return false;
856
+ }
857
+ let (rows, row, col) = wrap_input_visual_into_rows(&self.composer, row_width, self.cursor);
858
+ let target_row = match key.code {
859
+ KeyCode::Up if rows.len() > 1 => row.saturating_sub(1),
860
+ KeyCode::Down if rows.len() > 1 => (row + 1).min(rows.len() - 1),
861
+ KeyCode::Home | KeyCode::End => row,
862
+ _ => return false,
863
+ };
864
+ let start = input_row_start(&self.composer, &rows, target_row);
865
+ let target: Vec<char> = rows[target_row].chars().collect();
866
+ self.cursor = match key.code {
867
+ KeyCode::Home => start,
868
+ KeyCode::End => start + target.len(),
869
+ _ => {
870
+ let column = UnicodeWidthStr::width(
871
+ rows[row].chars().take(col).collect::<String>().as_str(),
872
+ );
873
+ let mut width = 0;
874
+ let mut count = 0;
875
+ for character in target {
876
+ let next = UnicodeWidthStr::width(character.to_string().as_str());
877
+ if width + next > column {
878
+ break;
879
+ }
880
+ width += next;
881
+ count += 1;
882
+ }
883
+ start + count
884
+ }
885
+ };
886
+ true
887
+ }
888
+ pub fn reload_transcript(&mut self, chat_id: &str, workspace: &Path) {
889
+ if self.chat_id != chat_id || self.current_dir != workspace {
890
+ self.stop_mods();
891
+ }
892
+ if let Ok(memory) = mint_core::MemoryStore::open_default() {
893
+ self.reload_transcript_with(&memory, chat_id, workspace);
894
+ } else {
895
+ self.chat_id = chat_id.to_owned();
896
+ self.transcript.clear();
897
+ self.tools.clear();
898
+ if self
899
+ .dialog
900
+ .as_ref()
901
+ .is_some_and(|dialog| dialog.is_tool_details())
902
+ {
903
+ self.dialog = None;
904
+ }
905
+ self.thoughts.clear();
906
+ self.thought_modal = None;
907
+ self.tool_viewer = None;
908
+ self.assistant_pacer = None;
909
+ self.active_assistant_entry = None;
910
+ self.active_plan_entry = None;
911
+ self.pending_post_assistant_notices.clear();
912
+ self.last_interaction_id = 0;
913
+ self.sync_cursor = 0;
914
+ }
915
+ }
916
+
917
+ pub(super) fn push_saved_activity(
918
+ &mut self,
919
+ activity: Option<&serde_json::Value>,
920
+ turn_status: &str,
921
+ ) -> Vec<(String, String)> {
922
+ let mut sources = Vec::new();
923
+ let Some(events) = activity.and_then(serde_json::Value::as_array) else {
924
+ return sources;
925
+ };
926
+ self.active_plan_entry = None;
927
+ let first_tool = self.tools.len();
928
+ for value in events {
929
+ let Ok(event) = serde_json::from_value::<mint_core::AgentProgress>(value.clone())
930
+ else {
931
+ continue;
932
+ };
933
+ // In-flight external turns use the shared-sync placeholder until a terminal snapshot arrives.
934
+ if !matches!(turn_status, "running" | "queued") {
935
+ match &event {
936
+ mint_core::AgentProgress::ToolStart { action, .. } => {
937
+ if action != "exit_plan_mode" {
938
+ self.record_tool_progress(&event);
939
+ }
940
+ }
941
+ mint_core::AgentProgress::ToolEnd {
942
+ action,
943
+ input,
944
+ result,
945
+ ..
946
+ } => {
947
+ if action == "exit_plan_mode" {
948
+ let plan_str = input
949
+ .get("plan")
950
+ .and_then(|p| p.as_str())
951
+ .or_else(|| value["data"]["input"]["plan"].as_str());
952
+ if let Some(plan) = plan_str {
953
+ if !plan.trim().is_empty() {
954
+ self.push_plan(plan);
955
+ }
956
+ }
957
+ } else {
958
+ if let Some(index) = self.record_tool_progress(&event) {
959
+ self.tools[index].elapsed = value["data"]["elapsed_ms"]
960
+ .as_u64()
961
+ .or_else(|| value["data"]["elapsedMs"].as_u64())
962
+ .map(std::time::Duration::from_millis);
963
+ self.refresh_tool_card(index);
964
+ }
965
+ if action == "web_search"
966
+ && !result.starts_with("Web search error:")
967
+ && result != "No web search results found."
968
+ {
969
+ sources.extend(crate::agent::parse_web_search_sources(result));
970
+ }
971
+ }
972
+ }
973
+ mint_core::AgentProgress::SubagentStarted { .. }
974
+ | mint_core::AgentProgress::SubagentFinished { .. } => {
975
+ self.record_tool_progress(&event);
976
+ }
977
+ mint_core::AgentProgress::ExtendedThinking {
978
+ id,
979
+ thought,
980
+ elapsed_ms,
981
+ } if !thought.trim().is_empty() => {
982
+ self.thoughts.push(ThinkingSegment {
983
+ id: id.clone(),
984
+ text: thought.clone(),
985
+ elapsed: std::time::Duration::from_millis(elapsed_ms.unwrap_or(0)),
986
+ elapsed_known: elapsed_ms.is_some(),
987
+ updated_at: None,
988
+ });
989
+ let elapsed = self.thoughts.last().unwrap().elapsed_label();
990
+ self.push_notice(format!("Thought · {elapsed} (Ctrl+T to view)"));
991
+ }
992
+ mint_core::AgentProgress::Thought { thought } if !thought.trim().is_empty() => {
993
+ if mint_core::orchestration::is_internal_cot(thought) {
994
+ self.thoughts.push(ThinkingSegment {
995
+ id: None,
996
+ text: thought.clone(),
997
+ elapsed: std::time::Duration::ZERO,
998
+ elapsed_known: false,
999
+ updated_at: None,
1000
+ });
1001
+ self.push_notice("Thought · time unavailable (Ctrl+T to view)");
1002
+ } else {
1003
+ self.push_notice(thought.clone());
1004
+ }
1005
+ }
1006
+ mint_core::AgentProgress::RunCompleted { summary } => {
1007
+ self.push_notice(crate::agent::run_summary_lines(summary).join("\n"));
1008
+ }
1009
+ mint_core::AgentProgress::PlanUpdated { plan } => {
1010
+ self.upsert_active_plan_card(plan);
1011
+ }
1012
+ _ => {}
1013
+ }
1014
+ }
1015
+ // Suppress redundant ExecutionUpdate notices in TUI (handled by ToolActivity cards)
1016
+ if !matches!(event, mint_core::AgentProgress::ExecutionUpdate { .. }) {
1017
+ if let Some(notes) = crate::agent::mcp_progress_notes(&event) {
1018
+ for note in notes {
1019
+ self.push_notice(note);
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ for index in first_tool..self.tools.len() {
1025
+ if self.tools[index].running() {
1026
+ self.tools[index].finish(
1027
+ "Interrupted before a result was recorded.",
1028
+ Some(mint_core::integrations::mcp_result::ToolStatus::Cancelled),
1029
+ );
1030
+ self.tools[index].elapsed = None;
1031
+ self.refresh_tool_card(index);
1032
+ }
1033
+ }
1034
+ sources
1035
+ }
1036
+
1037
+ pub(super) fn append_interaction_row(&mut self, row: &mint_core::InteractionMemory) {
1038
+ let user_display = format_user_transcript_text(&row.user_text);
1039
+ self.transcript
1040
+ .push(TranscriptEntry::new(TranscriptRole::User, user_display));
1041
+ let sources = self.push_saved_activity(row.agent_activity.as_ref(), &row.status);
1042
+ match row.status.as_str() {
1043
+ "completed" => {
1044
+ self.transcript.push(TranscriptEntry::new(
1045
+ TranscriptRole::Assistant,
1046
+ &row.ai_text,
1047
+ ));
1048
+ if !sources.is_empty() {
1049
+ let text = crate::agent::format_tui_web_sources(
1050
+ &sources,
1051
+ crate::agent::MAX_TUI_WEB_SOURCES,
1052
+ );
1053
+ if !text.is_empty() {
1054
+ self.transcript
1055
+ .push(TranscriptEntry::new(TranscriptRole::Notice, text));
1056
+ }
1057
+ }
1058
+ }
1059
+ "queued" => self.transcript.push(TranscriptEntry::new(
1060
+ TranscriptRole::Notice,
1061
+ "Queued for this session…",
1062
+ )),
1063
+ "running" => self.transcript.push(TranscriptEntry::new(
1064
+ TranscriptRole::Notice,
1065
+ "Mint is responding…",
1066
+ )),
1067
+ "failed" => self.transcript.push(TranscriptEntry::new(
1068
+ TranscriptRole::Notice,
1069
+ "This turn failed. Send it again to retry.",
1070
+ )),
1071
+ _ => self.transcript.push(TranscriptEntry::new(
1072
+ TranscriptRole::Notice,
1073
+ "This turn was interrupted. Send it again to retry.",
1074
+ )),
1075
+ }
1076
+ }
1077
+
1078
+ pub fn reload_transcript_with(
1079
+ &mut self,
1080
+ memory: &mint_core::MemoryStore,
1081
+ chat_id: &str,
1082
+ workspace: &Path,
1083
+ ) {
1084
+ if self.chat_id != chat_id || self.current_dir != workspace {
1085
+ self.stop_mods();
1086
+ }
1087
+ self.chat_id = chat_id.to_owned();
1088
+ self.transcript.clear();
1089
+ self.tools.clear();
1090
+ if self
1091
+ .dialog
1092
+ .as_ref()
1093
+ .is_some_and(|dialog| dialog.is_tool_details())
1094
+ {
1095
+ self.dialog = None;
1096
+ }
1097
+ self.thoughts.clear();
1098
+ self.thought_modal = None;
1099
+ self.tool_viewer = None;
1100
+ self.assistant_pacer = None;
1101
+ self.active_assistant_entry = None;
1102
+ self.active_plan_entry = None;
1103
+ self.pending_post_assistant_notices.clear();
1104
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
1105
+ self.sync_cursor = memory.latest_conversation_sequence(&scoped).unwrap_or(0);
1106
+ if let Ok(rows) = memory.interactions_for_chat(&scoped) {
1107
+ self.last_interaction_id = rows.last().map(|row| row.id).unwrap_or(0);
1108
+ for row in &rows {
1109
+ self.append_interaction_row(row);
1110
+ }
1111
+ } else {
1112
+ self.last_interaction_id = 0;
1113
+ }
1114
+ }
1115
+
1116
+ pub fn advance_sync_cursor(&mut self) {
1117
+ if let Ok(memory) = mint_core::MemoryStore::open_default() {
1118
+ self.advance_sync_cursor_with(&memory);
1119
+ }
1120
+ }
1121
+
1122
+ pub fn advance_sync_cursor_with(&mut self, memory: &mint_core::MemoryStore) {
1123
+ if self.chat_id.is_empty() {
1124
+ return;
1125
+ }
1126
+ let scoped =
1127
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1128
+ if let Ok(seq) = memory.latest_conversation_sequence(&scoped) {
1129
+ self.sync_cursor = seq;
1130
+ }
1131
+ if let Ok(rows) = memory.interactions_for_chat(&scoped) {
1132
+ if let Some(last) = rows.last() {
1133
+ self.last_interaction_id = last.id;
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ pub(super) fn refresh_shared_transcript(&mut self) -> bool {
1139
+ if self.chat_id.is_empty() {
1140
+ return false;
1141
+ }
1142
+ let Ok(memory) = mint_core::MemoryStore::open_default() else {
1143
+ return false;
1144
+ };
1145
+ self.refresh_shared_transcript_with(&memory)
1146
+ }
1147
+
1148
+ pub fn refresh_shared_transcript_with(&mut self, memory: &mint_core::MemoryStore) -> bool {
1149
+ if self.chat_id.is_empty() {
1150
+ return false;
1151
+ }
1152
+ let scoped =
1153
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1154
+ let Ok(changes) = memory.conversation_changes(&scoped, self.sync_cursor, 200) else {
1155
+ return false;
1156
+ };
1157
+ if changes.changes.is_empty() {
1158
+ return false;
1159
+ }
1160
+
1161
+ // If any interaction was deleted (e.g. /clear on Web or Desktop), do a full reload.
1162
+ if changes
1163
+ .changes
1164
+ .iter()
1165
+ .any(|change| change.interaction.is_none())
1166
+ {
1167
+ let chat_id = self.chat_id.clone();
1168
+ let workspace = self.current_dir.clone();
1169
+ self.reload_transcript_with(memory, &chat_id, &workspace);
1170
+ return true;
1171
+ }
1172
+
1173
+ let mut changed = false;
1174
+ for change in &changes.changes {
1175
+ let Some(row) = &change.interaction else {
1176
+ continue;
1177
+ };
1178
+ if row.id > self.last_interaction_id {
1179
+ // Incoming new interaction from an external client (Web/Desktop)
1180
+ self.append_interaction_row(row);
1181
+ self.last_interaction_id = row.id;
1182
+ changed = true;
1183
+ } else if row.id == self.last_interaction_id {
1184
+ // Status update for an external interaction that was previously queued/running
1185
+ if row.status == "completed" {
1186
+ let mut append = false;
1187
+ if let Some(last) = self.transcript.last() {
1188
+ if last.role == TranscriptRole::Notice
1189
+ && (last.text.starts_with("Mint is responding")
1190
+ || last.text.starts_with("Queued for this session"))
1191
+ {
1192
+ self.transcript.pop();
1193
+ append = true;
1194
+ } else if last.role != TranscriptRole::Assistant {
1195
+ append = true;
1196
+ }
1197
+ }
1198
+ if append {
1199
+ let sources =
1200
+ self.push_saved_activity(row.agent_activity.as_ref(), &row.status);
1201
+ self.transcript.push(TranscriptEntry::new(
1202
+ TranscriptRole::Assistant,
1203
+ &row.ai_text,
1204
+ ));
1205
+ if !sources.is_empty() {
1206
+ let text = crate::agent::format_tui_web_sources(
1207
+ &sources,
1208
+ crate::agent::MAX_TUI_WEB_SOURCES,
1209
+ );
1210
+ if !text.is_empty() {
1211
+ self.transcript
1212
+ .push(TranscriptEntry::new(TranscriptRole::Notice, text));
1213
+ }
1214
+ }
1215
+ changed = true;
1216
+ }
1217
+ } else if row.status == "running" {
1218
+ if let Some(last) = self.transcript.last_mut() {
1219
+ if last.role == TranscriptRole::Notice
1220
+ && last.text.starts_with("Queued for this session")
1221
+ {
1222
+ *last =
1223
+ TranscriptEntry::new(TranscriptRole::Notice, "Mint is responding…");
1224
+ changed = true;
1225
+ }
1226
+ }
1227
+ } else if matches!(row.status.as_str(), "failed" | "interrupted") {
1228
+ let msg = if row.status == "failed" {
1229
+ "This turn failed. Send it again to retry."
1230
+ } else {
1231
+ "This turn was interrupted. Send it again to retry."
1232
+ };
1233
+ if let Some(last) = self.transcript.last_mut() {
1234
+ if last.role == TranscriptRole::Notice
1235
+ && (last.text.starts_with("Mint is responding")
1236
+ || last.text.starts_with("Queued for this session"))
1237
+ {
1238
+ *last = TranscriptEntry::new(TranscriptRole::Notice, msg);
1239
+ changed = true;
1240
+ self.push_saved_activity(row.agent_activity.as_ref(), &row.status);
1241
+ }
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ self.sync_cursor = changes.cursor;
1248
+ changed
1249
+ }
1250
+ pub fn push_user(&mut self, text: String) {
1251
+ self.flush_assistant_reveal();
1252
+ self.transcript
1253
+ .push(TranscriptEntry::new(TranscriptRole::User, text));
1254
+ self.scroll_from_bottom = 0;
1255
+ self.swarm_agents.clear();
1256
+ self.active_plan_entry = None;
1257
+ }
1258
+ fn format_plan_checklist_card(plan: &mint_core::ActivePlan) -> String {
1259
+ if plan.tasks.is_empty() {
1260
+ return String::new();
1261
+ }
1262
+ let completed = plan
1263
+ .tasks
1264
+ .iter()
1265
+ .filter(|task| task.status == "completed")
1266
+ .count();
1267
+ let total = plan.tasks.len();
1268
+ let percent = (completed * 100) / total;
1269
+ let objective = if plan.objective.trim().is_empty() {
1270
+ "Implementation Plan"
1271
+ } else {
1272
+ plan.objective.trim()
1273
+ };
1274
+ let mut lines = Vec::with_capacity(plan.tasks.len() + 1);
1275
+ lines.push(format!(
1276
+ "{}📋 Plan: {}{} \x1b[90m· {completed}/{total} ({percent}%)\x1b[0m",
1277
+ crate::terminal_theme::ANSI_ACCENT_BOLD,
1278
+ objective,
1279
+ crate::terminal_theme::ANSI_RESET
1280
+ ));
1281
+ for task in &plan.tasks {
1282
+ let mark = match task.status.as_str() {
1283
+ "completed" => "\x1b[32m[✓]\x1b[0m",
1284
+ "in_progress" => "\x1b[33m[>]\x1b[0m",
1285
+ "failed" => "\x1b[31m[✗]\x1b[0m",
1286
+ _ => "\x1b[90m[ ]\x1b[0m",
1287
+ };
1288
+ lines.push(format!(" {} {}", mark, task.title));
1289
+ }
1290
+ lines.join("\n")
1291
+ }
1292
+ pub(super) fn upsert_active_plan_card(&mut self, plan: &mint_core::ActivePlan) {
1293
+ let text = Self::format_plan_checklist_card(plan);
1294
+ if text.is_empty() {
1295
+ return;
1296
+ }
1297
+ let entry = TranscriptEntry::new(TranscriptRole::Notice, text);
1298
+ if let Some(idx) = self.active_plan_entry
1299
+ && idx < self.transcript.len()
1300
+ {
1301
+ self.transcript[idx] = entry;
1302
+ } else if let Some(idx) = self.active_assistant_entry
1303
+ && idx <= self.transcript.len()
1304
+ {
1305
+ self.transcript.insert(idx, entry);
1306
+ self.active_plan_entry = Some(idx);
1307
+ self.active_assistant_entry = Some(idx + 1);
1308
+ for tool in &mut self.tools {
1309
+ if tool.entry >= idx {
1310
+ tool.entry += 1;
1311
+ }
1312
+ }
1313
+ } else {
1314
+ let idx = self.transcript.len();
1315
+ self.transcript.push(entry);
1316
+ self.active_plan_entry = Some(idx);
1317
+ }
1318
+ self.scroll_from_bottom = 0;
1319
+ }
1320
+ pub fn push_notice(&mut self, text: impl Into<String>) {
1321
+ let entry = TranscriptEntry::new(TranscriptRole::Notice, text);
1322
+ if let Some(idx) = self.active_assistant_entry
1323
+ && idx <= self.transcript.len()
1324
+ {
1325
+ self.transcript.insert(idx, entry);
1326
+ self.active_assistant_entry = Some(idx + 1);
1327
+ if let Some(plan_idx) = &mut self.active_plan_entry
1328
+ && *plan_idx >= idx
1329
+ {
1330
+ *plan_idx += 1;
1331
+ }
1332
+ for tool in &mut self.tools {
1333
+ if tool.entry >= idx {
1334
+ tool.entry += 1;
1335
+ }
1336
+ }
1337
+ } else {
1338
+ self.transcript.push(entry);
1339
+ }
1340
+ self.scroll_from_bottom = 0;
1341
+ }
1342
+ pub fn push_plan(&mut self, text: impl Into<String>) {
1343
+ self.transcript
1344
+ .push(TranscriptEntry::new(TranscriptRole::Plan, text));
1345
+ self.scroll_from_bottom = 0;
1346
+ }
1347
+ #[cfg(test)]
1348
+ pub fn push_assistant(&mut self, text: impl Into<String>) {
1349
+ self.flush_assistant_reveal();
1350
+ self.transcript
1351
+ .push(TranscriptEntry::new(TranscriptRole::Assistant, text));
1352
+ self.scroll_from_bottom = 0;
1353
+ }
1354
+ fn upsert_active_assistant_entry(&mut self, text: String) {
1355
+ if let Some(idx) = self.active_assistant_entry
1356
+ && idx < self.transcript.len()
1357
+ && self.transcript[idx].role == TranscriptRole::Assistant
1358
+ {
1359
+ self.transcript[idx] = TranscriptEntry::new(TranscriptRole::Assistant, text);
1360
+ } else {
1361
+ let idx = self.transcript.len();
1362
+ self.transcript
1363
+ .push(TranscriptEntry::new(TranscriptRole::Assistant, text));
1364
+ self.active_assistant_entry = Some(idx);
1365
+ }
1366
+ }
1367
+ pub fn append_assistant_delta(&mut self, delta: &str) {
1368
+ if delta.is_empty() {
1369
+ return;
1370
+ }
1371
+ let pacer = self
1372
+ .assistant_pacer
1373
+ .get_or_insert_with(crate::agent::ProgressiveMarkdownPacer::default);
1374
+ pacer.push_delta(delta);
1375
+ if self.active_assistant_entry.is_none() {
1376
+ self.tick_assistant_reveal();
1377
+ } else {
1378
+ self.tick_assistant_reveal_if_due(std::time::Duration::from_millis(18));
1379
+ }
1380
+ }
1381
+ pub fn begin_assistant_reveal(&mut self, summary: String, post_notices: Vec<String>) {
1382
+ let pacer = self
1383
+ .assistant_pacer
1384
+ .get_or_insert_with(crate::agent::ProgressiveMarkdownPacer::default);
1385
+ pacer.finish_with_authoritative(&summary);
1386
+ self.pending_post_assistant_notices = post_notices;
1387
+ self.tick_assistant_reveal();
1388
+ }
1389
+ pub fn tick_assistant_reveal(&mut self) -> bool {
1390
+ let Some(mut pacer) = self.assistant_pacer.take() else {
1391
+ return false;
1392
+ };
1393
+ let revealed = pacer.step();
1394
+ let done = pacer.is_done();
1395
+ let mut changed = false;
1396
+ if let Some(text) = revealed {
1397
+ self.upsert_active_assistant_entry(text);
1398
+ self.last_assistant_tick = Some(std::time::Instant::now());
1399
+ changed = true;
1400
+ }
1401
+ if done {
1402
+ self.active_assistant_entry = None;
1403
+ self.last_assistant_tick = None;
1404
+ let notices = std::mem::take(&mut self.pending_post_assistant_notices);
1405
+ for notice in notices {
1406
+ self.transcript
1407
+ .push(TranscriptEntry::new(TranscriptRole::Notice, notice));
1408
+ changed = true;
1409
+ }
1410
+ } else {
1411
+ self.assistant_pacer = Some(pacer);
1412
+ }
1413
+ changed
1414
+ }
1415
+ pub(super) fn tick_assistant_reveal_if_due(
1416
+ &mut self,
1417
+ min_interval: std::time::Duration,
1418
+ ) -> bool {
1419
+ if self.assistant_pacer.is_none() {
1420
+ return false;
1421
+ }
1422
+ if self
1423
+ .last_assistant_tick
1424
+ .is_some_and(|last| last.elapsed() < min_interval)
1425
+ {
1426
+ return false;
1427
+ }
1428
+ self.tick_assistant_reveal()
1429
+ }
1430
+ pub fn flush_assistant_reveal(&mut self) {
1431
+ if let Some(mut pacer) = self.assistant_pacer.take()
1432
+ && let Some(text) = pacer.flush_all()
1433
+ {
1434
+ self.upsert_active_assistant_entry(text);
1435
+ }
1436
+ self.active_assistant_entry = None;
1437
+ self.last_assistant_tick = None;
1438
+ let notices = std::mem::take(&mut self.pending_post_assistant_notices);
1439
+ for notice in notices {
1440
+ self.transcript
1441
+ .push(TranscriptEntry::new(TranscriptRole::Notice, notice));
1442
+ }
1443
+ }
1444
+ pub fn has_pending_assistant_reveal(&self) -> bool {
1445
+ self.assistant_pacer.is_some()
1446
+ }
1447
+ pub(super) fn has_unfinished_assistant_stream(&self) -> bool {
1448
+ self.assistant_pacer
1449
+ .as_ref()
1450
+ .is_some_and(|pacer| !pacer.is_completed())
1451
+ }
1452
+ pub fn clear_transcript(&mut self) {
1453
+ self.transcript.clear();
1454
+ self.tools.clear();
1455
+ self.swarm_agents.clear();
1456
+ self.assistant_pacer = None;
1457
+ self.active_assistant_entry = None;
1458
+ self.active_plan_entry = None;
1459
+ self.pending_post_assistant_notices.clear();
1460
+ if self
1461
+ .dialog
1462
+ .as_ref()
1463
+ .is_some_and(|dialog| dialog.is_tool_details())
1464
+ {
1465
+ self.dialog = None;
1466
+ }
1467
+ self.thoughts.clear();
1468
+ self.thought_modal = None;
1469
+ self.tool_viewer = None;
1470
+ self.scroll_from_bottom = 0;
1471
+ self.selection_mode = false;
1472
+ }
1473
+ pub fn sync_session(&mut self, session: &InteractiveSession) {
1474
+ if self.current_dir != session.current_dir {
1475
+ self.stop_mods();
1476
+ }
1477
+ self.model = active_model(&session.config.ai_provider, &session.config).to_owned();
1478
+ self.thinking_status = format_thinking_status(&session.config);
1479
+ self.provider = format_provider_display_name(&session.config.ai_provider, &session.config);
1480
+ self.workspace = format_workspace_with_branch(&session.current_dir);
1481
+ self.current_dir = session.current_dir.clone();
1482
+ self.plan_mode = session.plan_mode;
1483
+ self.history = session.history.clone();
1484
+ }
1485
+ pub(super) fn stop_mods(&mut self) {
1486
+ if !self.chat_id.is_empty() {
1487
+ let chat =
1488
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1489
+ mint_core::mods::close_chat(&self.current_dir, &chat);
1490
+ }
1491
+ self.mods.clear();
1492
+ self.mods_open = false;
1493
+ }
1494
+ pub(super) fn mod_input_value<'a>(&'a self, control: &'a crate::mods_ui::Control) -> &'a str {
1495
+ self.mod_input
1496
+ .as_ref()
1497
+ .filter(|(initial, _)| initial == &control.value)
1498
+ .map_or(&control.value, |(_, draft)| draft)
1499
+ }
1500
+ pub(super) fn mod_input_draft(&mut self, control: &crate::mods_ui::Control) -> &mut String {
1501
+ if self
1502
+ .mod_input
1503
+ .as_ref()
1504
+ .is_none_or(|(initial, _)| initial != &control.value)
1505
+ {
1506
+ self.mod_input = Some((control.value.clone(), control.value.clone()));
1507
+ }
1508
+ &mut self.mod_input.as_mut().unwrap().1
1509
+ }
1510
+ pub(super) fn history_previous(&mut self) {
1511
+ if self.history.is_empty() {
1512
+ return;
1513
+ }
1514
+ if self.history_index.is_none() {
1515
+ self.draft_before_history = self.composer_text().chars().collect();
1516
+ }
1517
+ let index = self
1518
+ .history_index
1519
+ .map(|index| index.saturating_sub(1))
1520
+ .unwrap_or(self.history.len() - 1);
1521
+ self.history_index = Some(index);
1522
+ self.set_draft(self.history[index].clone());
1523
+ }
1524
+ pub(super) fn history_next(&mut self) {
1525
+ let Some(index) = self.history_index else {
1526
+ return;
1527
+ };
1528
+ if index + 1 < self.history.len() {
1529
+ let next = index + 1;
1530
+ self.history_index = Some(next);
1531
+ self.set_draft(self.history[next].clone());
1532
+ } else {
1533
+ self.history_index = None;
1534
+ self.composer = std::mem::take(&mut self.draft_before_history);
1535
+ }
1536
+ self.cursor = self.composer.len();
1537
+ }
1538
+ pub(super) fn suggestions(&self) -> Vec<(String, String)> {
1539
+ match_composer_suggestions(&self.composer, self.cursor, &self.current_dir)
1540
+ }
1541
+ pub(super) fn apply_selected_suggestion(&mut self) -> bool {
1542
+ let suggestions = self.suggestions();
1543
+ let Some((token, _)) = suggestions.get(self.slash_selected % suggestions.len().max(1))
1544
+ else {
1545
+ return false;
1546
+ };
1547
+ let cursor = self.cursor.min(self.composer.len());
1548
+ let start = if token.starts_with('@') {
1549
+ self.composer[..cursor]
1550
+ .iter()
1551
+ .rposition(|c| c.is_whitespace())
1552
+ .map_or(0, |index| index + 1)
1553
+ } else {
1554
+ 0
1555
+ };
1556
+ let word_end = self.composer[cursor..]
1557
+ .iter()
1558
+ .position(|c| c.is_whitespace())
1559
+ .map_or(self.composer.len(), |offset| cursor + offset);
1560
+ let paste_start = self
1561
+ .collapsed_pastes
1562
+ .iter()
1563
+ .find(|paste| paste.range.start >= cursor)
1564
+ .map(|paste| paste.range.start);
1565
+ let end = paste_start.map_or(word_end, |start| word_end.min(start));
1566
+ let needs_space = end == self.composer.len() || paste_start == Some(end);
1567
+ let replacement = if needs_space {
1568
+ format!("{token} ")
1569
+ } else {
1570
+ token.clone()
1571
+ };
1572
+ self.replace_composer(start..end, &replacement);
1573
+ if !needs_space {
1574
+ self.cursor += 1;
1575
+ }
1576
+ self.slash_selected = 0;
1577
+ true
1578
+ }
1579
+ pub fn set_notice(&mut self, text: impl Into<String>) {
1580
+ self.notice = Some((text.into(), std::time::Instant::now()));
1581
+ }
1582
+ pub fn clear_notice(&mut self) {
1583
+ self.notice = None;
1584
+ }
1585
+ pub fn active_notice(&self) -> Option<&str> {
1586
+ self.notice.as_ref().and_then(|(text, created)| {
1587
+ if text.starts_with("Terminal too small")
1588
+ || self.editing_turn.is_some()
1589
+ || created.elapsed() < std::time::Duration::from_millis(2500)
1590
+ {
1591
+ Some(text.as_str())
1592
+ } else {
1593
+ None
1594
+ }
1595
+ })
1596
+ }
1597
+
1598
+ pub(super) fn enter_edit_turn(&mut self) -> bool {
1599
+ let scoped =
1600
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1601
+ let Ok(memory) = mint_core::MemoryStore::open_default() else {
1602
+ self.set_notice("Failed to open conversation memory");
1603
+ return false;
1604
+ };
1605
+ let Ok(rows) = memory.interactions_for_chat(&scoped) else {
1606
+ self.set_notice("No previous turn found to edit or retry");
1607
+ return false;
1608
+ };
1609
+ if let Some(last) = rows.last() {
1610
+ let turn_num = rows.len();
1611
+ self.editing_turn = Some((last.id, turn_num));
1612
+ self.set_draft(clean_user_prompt(&last.user_text));
1613
+ self.cursor = self.composer.len();
1614
+ self.set_notice(format!(
1615
+ "[Mode: Edit Turn #{} — Resending will replace this turn and clear its previous response | Esc to cancel]",
1616
+ turn_num
1617
+ ));
1618
+ true
1619
+ } else {
1620
+ self.set_notice("No previous turn found to edit or retry");
1621
+ false
1622
+ }
1623
+ }
1624
+
1625
+ pub(super) fn cancel_edit_turn(&mut self) {
1626
+ self.editing_turn = None;
1627
+ self.take_prompt();
1628
+ self.clear_notice();
1629
+ }
1630
+ pub(super) fn notice_visibility_changed_since_draw(&self, painted_visible: bool) -> bool {
1631
+ painted_visible != self.active_notice().is_some()
1632
+ }
1633
+ pub(super) fn transcript_text(&self, width: u16) -> Text<'static> {
1634
+ let mut lines = Vec::new();
1635
+ let selected_start = self.selection_anchor.min(self.selection_head);
1636
+ let selected_end = self.selection_anchor.max(self.selection_head);
1637
+ let mut line_index = 0usize;
1638
+ for (entry_index, entry) in self.transcript.iter().enumerate() {
1639
+ let role_header = match entry.role {
1640
+ TranscriptRole::User => None,
1641
+ TranscriptRole::Assistant => Some(("Mint", crate::terminal_theme::ACCENT)),
1642
+ TranscriptRole::Notice => None,
1643
+ TranscriptRole::Command => Some(("Command", crate::terminal_theme::ACCENT)),
1644
+ TranscriptRole::ReleaseNotes | TranscriptRole::Plan => None,
1645
+ TranscriptRole::System => Some(("System", crate::terminal_theme::WARNING)),
1646
+ };
1647
+ if let Some((label, color)) = role_header {
1648
+ let selected =
1649
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1650
+ let header = Line::from(Span::styled(
1651
+ format!("{label} ›"),
1652
+ Style::default()
1653
+ .fg(color)
1654
+ .add_modifier(Modifier::BOLD)
1655
+ .add_modifier(if selected {
1656
+ Modifier::REVERSED
1657
+ } else {
1658
+ Modifier::empty()
1659
+ }),
1660
+ ));
1661
+ lines.push(header);
1662
+ line_index += 1;
1663
+ }
1664
+ let tool_lines = self
1665
+ .tools
1666
+ .iter()
1667
+ .find(|tool| tool.entry == entry_index)
1668
+ .map(|tool| tool.lines(width));
1669
+ let reflowed_entry_lines = entry.lines_for_width(width);
1670
+ let empty_user_line = [Line::default()];
1671
+ let content_lines = if let Some(lines) = tool_lines.as_ref() {
1672
+ lines.as_slice()
1673
+ } else if entry.role == TranscriptRole::User && reflowed_entry_lines.is_empty() {
1674
+ &empty_user_line[..]
1675
+ } else {
1676
+ reflowed_entry_lines.as_ref()
1677
+ };
1678
+ for (content_index, content_line) in content_lines.iter().enumerate() {
1679
+ let selected =
1680
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1681
+ let mut sel_line = content_line.clone();
1682
+ if entry.role == TranscriptRole::User && content_index == 0 {
1683
+ sel_line.spans.insert(
1684
+ 0,
1685
+ Span::styled(
1686
+ "You › ",
1687
+ Style::default()
1688
+ .fg(crate::terminal_theme::BLUE)
1689
+ .add_modifier(Modifier::BOLD),
1690
+ ),
1691
+ );
1692
+ }
1693
+ if entry.role == TranscriptRole::Notice {
1694
+ for span in &mut sel_line.spans {
1695
+ if span.style.fg.is_none() {
1696
+ span.style = span.style.fg(crate::terminal_theme::TEXT);
1697
+ }
1698
+ }
1699
+ }
1700
+ // Short AI text replies have no explicit ANSI color — default
1701
+ // them to plain white so they don't appear in the terminal's
1702
+ // dim default foreground.
1703
+ if entry.role == TranscriptRole::Assistant {
1704
+ for span in &mut sel_line.spans {
1705
+ if span.style.fg.is_none() {
1706
+ span.style = span.style.fg(crate::terminal_theme::TEXT);
1707
+ }
1708
+ }
1709
+ }
1710
+ if selected {
1711
+ for span in &mut sel_line.spans {
1712
+ span.style = span.style.add_modifier(Modifier::REVERSED);
1713
+ }
1714
+ }
1715
+ if entry.role == TranscriptRole::User {
1716
+ lines.extend(highlighted_user_content(sel_line, width));
1717
+ } else {
1718
+ lines.push(sel_line);
1719
+ }
1720
+ line_index += 1;
1721
+ }
1722
+ let selected =
1723
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1724
+ let mut style = Style::default();
1725
+ if selected {
1726
+ style = style.add_modifier(Modifier::REVERSED);
1727
+ }
1728
+ lines.push(Line::styled("", style));
1729
+ line_index += 1;
1730
+ }
1731
+ Text::from(lines)
1732
+ }
1733
+ pub(super) fn plain_transcript_lines(&self) -> Vec<String> {
1734
+ let mut lines = Vec::new();
1735
+ for entry in &self.transcript {
1736
+ if entry.role == TranscriptRole::User {
1737
+ let mut content = entry.plain_lines.iter();
1738
+ lines.push(format!(
1739
+ "You › {}",
1740
+ content.next().map_or("", String::as_str)
1741
+ ));
1742
+ lines.extend(content.cloned());
1743
+ lines.push(String::new());
1744
+ continue;
1745
+ }
1746
+ if let Some(header) = match entry.role {
1747
+ TranscriptRole::User => None,
1748
+ TranscriptRole::Assistant => Some("Mint ›"),
1749
+ TranscriptRole::Notice => None,
1750
+ TranscriptRole::Command => Some("Command ›"),
1751
+ TranscriptRole::ReleaseNotes | TranscriptRole::Plan => None,
1752
+ TranscriptRole::System => Some("System ›"),
1753
+ } {
1754
+ lines.push(header.to_owned());
1755
+ }
1756
+ lines.extend(entry.plain_lines.iter().cloned());
1757
+ lines.push(String::new());
1758
+ }
1759
+ lines
1760
+ }
1761
+ pub(super) fn selected_text(&self) -> String {
1762
+ let lines = self.plain_transcript_lines();
1763
+ if lines.is_empty() {
1764
+ return String::new();
1765
+ }
1766
+ let start = self
1767
+ .selection_anchor
1768
+ .min(self.selection_head)
1769
+ .min(lines.len() - 1);
1770
+ let end = self
1771
+ .selection_anchor
1772
+ .max(self.selection_head)
1773
+ .min(lines.len() - 1);
1774
+ lines[start..=end].join("\n")
1775
+ }
1776
+ pub(super) fn mod_viewport(&self, area: Rect) -> mint_core::mods::Viewport {
1777
+ let (body, _) = mod_layout(
1778
+ area,
1779
+ self.mods_open && self.dialog.is_none() && self.thought_modal.is_none(),
1780
+ );
1781
+ mint_core::mods::Viewport {
1782
+ surface: Some("terminal".into()),
1783
+ columns: (body.width > 0).then_some(body.width),
1784
+ rows: (body.height > 0).then_some(body.height),
1785
+ side_panel_open: self.mods_open,
1786
+ ..Default::default()
1787
+ }
1788
+ }
1789
+ }
1790
+
1791
+ pub(crate) fn format_user_transcript_text(raw: &str) -> String {
1792
+ if let Some(doc_idx) = raw.find("Attached document:") {
1793
+ let prefix = raw[..doc_idx].trim();
1794
+ let rest = &raw[doc_idx..];
1795
+ let filename = rest
1796
+ .strip_prefix("Attached document:")
1797
+ .and_then(|s| s.lines().next())
1798
+ .map(|s| s.trim())
1799
+ .unwrap_or("document");
1800
+
1801
+ let line_count = if let Some(start_extract) = rest.find("--- Extracted document text ---\n")
1802
+ {
1803
+ let content_start = start_extract + "--- Extracted document text ---\n".len();
1804
+ if let Some(end_extract) =
1805
+ rest[content_start..].find("\n--- End extracted document text ---")
1806
+ {
1807
+ rest[content_start..content_start + end_extract]
1808
+ .lines()
1809
+ .count()
1810
+ } else {
1811
+ 0
1812
+ }
1813
+ } else {
1814
+ 0
1815
+ };
1816
+
1817
+ if prefix.is_empty() {
1818
+ format!("[Attached document: {filename} ({line_count} lines)]")
1819
+ } else {
1820
+ format!("{prefix}\n\n[Attached document: {filename} ({line_count} lines)]")
1821
+ }
1822
+ } else {
1823
+ raw.to_string()
1824
+ }
1825
+ }
1826
+
1827
+ pub(crate) fn clean_user_prompt(raw: &str) -> String {
1828
+ if let Some(doc_idx) = raw.find("Attached document:") {
1829
+ raw[..doc_idx].trim().to_string()
1830
+ } else {
1831
+ raw.to_string()
1832
+ }
1833
+ }