@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,3414 @@
1
+ //! Unit tests for the Full-Screen ChatTui module.
2
+ use super::*;
3
+
4
+ #[cfg(test)]
5
+ mod dialog_tests {
6
+ use super::*;
7
+ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
8
+ use ratatui::backend::TestBackend;
9
+ use ratatui::layout::Rect;
10
+
11
+ fn saved_activity_fixture(
12
+ activity: serde_json::Value,
13
+ status: &str,
14
+ ) -> (std::path::PathBuf, mint_core::MemoryStore) {
15
+ let root = std::env::temp_dir().join(format!(
16
+ "mint-resume-{}-{}",
17
+ std::process::id(),
18
+ std::time::SystemTime::now()
19
+ .duration_since(std::time::UNIX_EPOCH)
20
+ .unwrap()
21
+ .as_nanos()
22
+ ));
23
+ std::fs::create_dir_all(&root).unwrap();
24
+ let memory = mint_core::MemoryStore::open(root.join("memory.sqlite"));
25
+ let turn = memory.start_turn("resume-details", "Check files").unwrap();
26
+ memory.claim_turn("resume-details", turn).unwrap();
27
+ memory
28
+ .set_interaction_agent_activity_json(turn, &activity.to_string())
29
+ .unwrap();
30
+ if status == "completed" {
31
+ memory
32
+ .finish_turn(turn, "Saved answer", "test", "model", None)
33
+ .unwrap();
34
+ } else {
35
+ memory.end_turn(turn, status).unwrap();
36
+ }
37
+ (root, memory)
38
+ }
39
+
40
+ #[test]
41
+ fn resume_restores_tool_results_thoughts_and_run_summary_without_duplicates() {
42
+ let command = "python3 - <<'EOF'\nprint('complete command')\nEOF";
43
+ let activity = serde_json::json!([
44
+ {"type":"ExtendedThinking","data":{"id":"thought-one","thought":"Saved reasoning","elapsed_ms":1900}},
45
+ {"type":"ToolStart","data":{"callId":"one","action":"run_shell","input":{"command":command}}},
46
+ {"type":"ToolEnd","data":{"callId":"one","action":"run_shell","input":{"command":command},"result":"exit: 2\nstderr:\nfull saved error", "elapsed_ms":3400}},
47
+ {"type":"RunCompleted","data":{"summary":{"runId":"r1","task":"Check files","outcome":"FAILED","totalTokens":100,"toolCallsCount":1,"filesChanged":[],"durationSecs":16.2,"toolTimeline":[{"step":1,"action":"run_shell","target":"python3","success":false,"retried":false}],"retriesCount":0}}}
48
+ ]);
49
+ let (root, memory) = saved_activity_fixture(activity, "completed");
50
+ let mut state = ChatViewState::default();
51
+ for _ in 0..2 {
52
+ state.reload_transcript_with(&memory, "resume-details", &root);
53
+ assert_eq!(state.tools.len(), 1);
54
+ assert_eq!(state.thoughts.len(), 1);
55
+ let text = state
56
+ .transcript
57
+ .iter()
58
+ .map(|entry| entry.text.as_str())
59
+ .collect::<Vec<_>>()
60
+ .join("\n");
61
+ assert!(text.contains("run_shell failed · 3.4s · exit 2"));
62
+ assert_eq!(text.matches("Thought · 1.9s").count(), 1);
63
+ assert!(text.contains("✗ Failed"));
64
+ assert!(text.contains("16.2s"));
65
+ assert!(!state.tools[0].running());
66
+ assert!(state.handle_thought_key(ctrl_t()));
67
+ assert_eq!(
68
+ state.thought_modal.as_ref().unwrap().lines[1..],
69
+ vec!["Saved reasoning"]
70
+ );
71
+ state.handle_thought_key(make_key(KeyCode::Esc));
72
+ let mut open = make_key(KeyCode::Char('o'));
73
+ open.modifiers = KeyModifiers::CONTROL;
74
+ assert!(state.handle_tool_key(open));
75
+ let body = &state.dialog.as_ref().unwrap().body;
76
+ assert!(body.contains(command));
77
+ assert!(body.contains("full saved error"));
78
+ state.handle_tool_key(make_key(KeyCode::Esc));
79
+ }
80
+ assert!(!state.refresh_shared_transcript_with(&memory));
81
+ std::fs::remove_dir_all(root).unwrap();
82
+ }
83
+
84
+ #[test]
85
+ fn resume_restores_plan_card_in_transcript() {
86
+ let plan_text = "# Implementation Plan\n\n## Steps\n1. Do step one\n2. Do step two";
87
+ let activity = serde_json::json!([
88
+ {
89
+ "type": "ToolStart",
90
+ "data": {
91
+ "callId": "plan-call",
92
+ "action": "exit_plan_mode",
93
+ "input": {
94
+ "plan": plan_text
95
+ }
96
+ }
97
+ },
98
+ {
99
+ "type": "ToolEnd",
100
+ "data": {
101
+ "callId": "plan-call",
102
+ "action": "exit_plan_mode",
103
+ "input": {
104
+ "plan": plan_text
105
+ },
106
+ "result": "Plan approved by the user."
107
+ }
108
+ }
109
+ ]);
110
+ let (root, memory) = saved_activity_fixture(activity, "completed");
111
+ let mut state = ChatViewState::default();
112
+ state.reload_transcript_with(&memory, "resume-details", &root);
113
+
114
+ let plan_entry = state
115
+ .transcript
116
+ .iter()
117
+ .find(|entry| entry.role == TranscriptRole::Plan);
118
+ assert!(
119
+ plan_entry.is_some(),
120
+ "Plan card must be restored in transcript upon resume"
121
+ );
122
+ assert_eq!(plan_entry.unwrap().text, plan_text);
123
+
124
+ // exit_plan_mode should not create an extra generic tool activity card
125
+ assert!(state.tools.iter().all(|t| t.action != "exit_plan_mode"));
126
+
127
+ std::fs::remove_dir_all(root).unwrap();
128
+ }
129
+
130
+ #[test]
131
+ fn resume_switching_sessions_closes_previous_tool_details() {
132
+ let (root, memory) = saved_activity_fixture(
133
+ serde_json::json!([
134
+ {"type":"ToolEnd","data":{"callId":"old","action":"run_shell","input":{"command":"echo previous-session"},"result":"exit: 0"}}
135
+ ]),
136
+ "completed",
137
+ );
138
+ let mut state = ChatViewState::default();
139
+ state.reload_transcript_with(&memory, "resume-details", &root);
140
+ let mut key = make_key(KeyCode::Char('o'));
141
+ key.modifiers = KeyModifiers::CONTROL;
142
+ assert!(state.handle_tool_key(key));
143
+ assert!(state.dialog.is_some());
144
+ state.reload_transcript_with(&memory, "different-session", &root);
145
+ assert!(
146
+ state.dialog.is_none(),
147
+ "previous session tool details remained open"
148
+ );
149
+ std::fs::remove_dir_all(root).unwrap();
150
+ }
151
+
152
+ #[test]
153
+ fn resume_legacy_activity_does_not_invent_zero_second_tool_duration() {
154
+ let (root, memory) = saved_activity_fixture(
155
+ serde_json::json!([
156
+ {"type":"ToolStart","data":{"action":"run_shell","input":{"command":"echo legacy"}}},
157
+ {"type":"ToolEnd","data":{"action":"run_shell","input":{"command":"echo legacy"},"result":"exit: 0\nlegacy output"}}
158
+ ]),
159
+ "completed",
160
+ );
161
+ let mut state = ChatViewState::default();
162
+ state.reload_transcript_with(&memory, "resume-details", &root);
163
+ assert_eq!(state.tools.len(), 1);
164
+ assert!(state.tools[0].heading().contains("time unavailable"));
165
+ assert!(!state.tools[0].running());
166
+ std::fs::remove_dir_all(root).unwrap();
167
+ }
168
+
169
+ #[test]
170
+ fn resume_restores_web_search_sources_in_transcript() {
171
+ let (root, memory) = saved_activity_fixture(
172
+ serde_json::json!([
173
+ {"type":"ToolStart","data":{"callId":"call_1","action":"web_search","input":{"query":"rust ratatui"}}},
174
+ {"type":"ToolEnd","data":{"callId":"call_1","action":"web_search","input":{"query":"rust ratatui"},"result":"1. Ratatui Docs\n URL: https://ratatui.rs\n Terminal UI library"}}
175
+ ]),
176
+ "completed",
177
+ );
178
+ let mut state = ChatViewState::default();
179
+ state.reload_transcript_with(&memory, "resume-details", &root);
180
+ let sources_entry = state.transcript.iter().find(|entry| {
181
+ entry.role == TranscriptRole::Notice && entry.text.starts_with("Sources:\n")
182
+ });
183
+ assert!(
184
+ sources_entry.is_some(),
185
+ "Sources notice should be reconstructed upon resume"
186
+ );
187
+ let text = &sources_entry.unwrap().text;
188
+ assert!(text.contains("1. Ratatui Docs\n https://ratatui.rs"));
189
+ std::fs::remove_dir_all(root).unwrap();
190
+ }
191
+
192
+ #[test]
193
+ fn resume_interrupted_activity_keeps_partial_thought_and_never_restarts_spinner() {
194
+ let events: Vec<mint_core::AgentProgress> = serde_json::from_value(serde_json::json!([
195
+ {"type":"ThinkingDelta","data":{"id":"partial","delta":"unfinished ","elapsed_ms":400}},
196
+ {"type":"ThinkingDelta","data":{"id":"partial","delta":"reasoning","elapsed_ms":700}},
197
+ {"type":"ToolStart","data":{"callId":"unfinished","action":"run_shell","input":{"command":"sleep 100"}}}
198
+ ])).unwrap();
199
+ let activity =
200
+ serde_json::to_value(mint_core::orchestration::compact_agent_progress(&events))
201
+ .unwrap();
202
+ let (root, memory) = saved_activity_fixture(activity, "interrupted");
203
+ let mut state = ChatViewState::default();
204
+ state.reload_transcript_with(&memory, "resume-details", &root);
205
+ assert_eq!(state.tools.len(), 1);
206
+ assert!(!state.tools[0].running());
207
+ assert!(state.tools[0].heading().contains("interrupted"));
208
+ assert!(super::super::tool_activity::running_lines(&state.tools, 80, 0).is_empty());
209
+ assert!(state.handle_thought_key(ctrl_t()));
210
+ assert_eq!(
211
+ state.thought_modal.as_ref().unwrap().lines[1..],
212
+ vec!["unfinished reasoning"]
213
+ );
214
+ state.reload_transcript_with(&memory, "another-session", &root);
215
+ assert!(state.thoughts.is_empty());
216
+ assert!(state.tools.is_empty());
217
+ std::fs::remove_dir_all(root).unwrap();
218
+ }
219
+
220
+ #[test]
221
+ fn device_saved_activity_survives_tui_reload_and_external_sync() {
222
+ let root = std::env::temp_dir().join(format!(
223
+ "mint-device-tui-{}",
224
+ std::time::SystemTime::now()
225
+ .duration_since(std::time::UNIX_EPOCH)
226
+ .unwrap()
227
+ .as_nanos()
228
+ ));
229
+ std::fs::create_dir_all(&root).unwrap();
230
+ let memory = mint_core::MemoryStore::open(root.join("memory.sqlite"));
231
+ let chat = "device-history";
232
+ let mut state = ChatViewState {
233
+ chat_id: chat.into(),
234
+ current_dir: root.clone(),
235
+ ..Default::default()
236
+ };
237
+ state.reload_transcript_with(&memory, chat, &root);
238
+ let turn = memory.start_turn(chat, "Move motor").unwrap();
239
+ memory.claim_turn(chat, turn).unwrap();
240
+ let activity = serde_json::json!([{"type":"DeviceState","data":{"callId":"one","server":"motor","tool":"move","update":{"state":"verified","target":{"rpm":100},"actual":{"rpm":100},"elapsedSecs":2,"message":"Observed motor"}}}]);
241
+ memory
242
+ .set_interaction_agent_activity_json(turn, &activity.to_string())
243
+ .unwrap();
244
+ memory
245
+ .finish_turn(turn, "done", "test", "model", None)
246
+ .unwrap();
247
+ assert!(state.refresh_shared_transcript_with(&memory));
248
+ assert!(
249
+ state
250
+ .transcript
251
+ .iter()
252
+ .any(|entry| entry.text.contains("Verified")),
253
+ "external sync omitted device activity"
254
+ );
255
+ assert!(!state.refresh_shared_transcript_with(&memory));
256
+ state.reload_transcript_with(&memory, chat, &root);
257
+ assert_eq!(
258
+ state
259
+ .transcript
260
+ .iter()
261
+ .filter(|entry| entry.text.contains("Verified"))
262
+ .count(),
263
+ 1
264
+ );
265
+ let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
266
+ terminal
267
+ .draw(|frame| {
268
+ state.render(frame);
269
+ })
270
+ .unwrap();
271
+ let rendered: String = terminal
272
+ .backend()
273
+ .buffer()
274
+ .content
275
+ .iter()
276
+ .map(|cell| cell.symbol())
277
+ .collect();
278
+ assert!(rendered.contains("Verified"));
279
+ assert!(rendered.contains("rpm"));
280
+ assert!(rendered.contains("100"));
281
+ std::fs::remove_dir_all(root).unwrap();
282
+ }
283
+
284
+ #[test]
285
+ fn device_and_discovery_events_render_inside_full_screen_tui() {
286
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
287
+ let tui = TuiHandle {
288
+ state: Arc::clone(&state),
289
+ interrupted: Arc::new(AtomicBool::new(false)),
290
+ queued: Arc::new(Mutex::new(Vec::new())),
291
+ };
292
+ let mut events = vec![
293
+ serde_json::json!({"type":"ToolDiscovery","data":{"server":"motor","tool":"set_speed","message":"Reading device schema"}}),
294
+ ];
295
+ for phase in ["accepted", "verified"] {
296
+ events.push(serde_json::json!({"type":"DeviceState","data":{"callId":"one","server":"motor","tool":"set_speed","update":{"state":phase,"target":{"rpm":100},"actual":{"rpm":100},"elapsedSecs":2,"message":"Observed motor"}}}));
297
+ }
298
+ events.push(serde_json::json!({"type":"ToolArtifacts","data":{"callId":"one","artifacts":[],"warnings":["Sample attachment unavailable"]}}));
299
+ for event in events {
300
+ crate::agent::publish_mcp_progress(Some(&tui), &serde_json::from_value(event).unwrap());
301
+ }
302
+ let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
303
+ terminal
304
+ .draw(|frame| {
305
+ state.lock().unwrap().render(frame);
306
+ })
307
+ .unwrap();
308
+ let rendered: String = terminal
309
+ .backend()
310
+ .buffer()
311
+ .content
312
+ .iter()
313
+ .map(|cell| cell.symbol())
314
+ .collect();
315
+ assert!(rendered.contains("Reading device schema"));
316
+ assert!(rendered.contains("Accepted"));
317
+ assert!(rendered.contains("Verified"));
318
+ assert!(rendered.contains("target"));
319
+ assert!(rendered.contains("actual"));
320
+ assert!(rendered.contains("Sample attachment unavailable"));
321
+ }
322
+
323
+ #[test]
324
+ fn tui_suppresses_redundant_execution_update_notices() {
325
+ use mint_core::{ExecutionEvidence, ExecutionState};
326
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
327
+ let tui = TuiHandle {
328
+ state: Arc::clone(&state),
329
+ interrupted: Arc::new(AtomicBool::new(false)),
330
+ queued: Arc::new(Mutex::new(Vec::new())),
331
+ };
332
+ let update = mint_core::AgentProgress::ExecutionUpdate {
333
+ call_id: "test".into(),
334
+ executions: vec![ExecutionEvidence {
335
+ call_id: "test1".into(),
336
+ command: "ls -la".into(),
337
+ state: ExecutionState::Succeeded,
338
+ exit_code: Some(0),
339
+ job_id: None,
340
+ revision: 1,
341
+ check: false,
342
+ condition_verified: None,
343
+ }],
344
+ };
345
+ let published = crate::agent::publish_mcp_progress(Some(&tui), &update);
346
+ assert!(!published, "ExecutionUpdate should be suppressed in TUI");
347
+ let transcript = state.lock().unwrap().transcript.clone();
348
+ assert!(
349
+ !transcript
350
+ .iter()
351
+ .any(|entry| entry.text.contains("[Command]")),
352
+ "TUI transcript must not receive redundant [Command] notices"
353
+ );
354
+ }
355
+
356
+ #[test]
357
+ fn mint_watermark_frames_fit_available_terminal_height() {
358
+ for height in [3, 7, WATERMARK_HEIGHT as usize] {
359
+ for frame in 0..WATERMARK_FRAME_COUNT {
360
+ let text = mint_m_watermark(Some(frame), height);
361
+ assert_eq!(text.lines.len(), height);
362
+ assert!(text.lines.iter().all(|line| {
363
+ line.spans
364
+ .iter()
365
+ .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
366
+ .sum::<usize>()
367
+ == WATERMARK_WIDTH as usize
368
+ }));
369
+ }
370
+ }
371
+ assert_ne!(
372
+ mint_m_watermark(Some(0), WATERMARK_HEIGHT as usize),
373
+ mint_m_watermark(Some(6), WATERMARK_HEIGHT as usize)
374
+ );
375
+ let front = mint_m_watermark(None, WATERMARK_HEIGHT as usize);
376
+ assert_eq!(front.lines[8].spans[19].content.as_ref(), " ");
377
+ let back = mint_m_watermark(Some(WATERMARK_FRAME_COUNT / 2), WATERMARK_HEIGHT as usize);
378
+ assert_eq!(back.lines[8].spans[19].content.as_ref(), "⣿");
379
+ }
380
+
381
+ #[test]
382
+ fn watermark_animation_eases_to_leaf_holds_then_continues_forward_to_m() {
383
+ let at = |ms| watermark_animation_at(std::time::Duration::from_millis(ms));
384
+ assert_eq!(at(0).0, Some(0));
385
+ // Slower movement near the endpoints, faster through the middle.
386
+ assert_eq!(at(300).0, Some(2));
387
+ assert_eq!(at(1500).0, Some(50));
388
+ assert_eq!(at(2700).0, Some(98));
389
+ assert_eq!(
390
+ at(3000),
391
+ (Some(100), Some(std::time::Duration::from_millis(4000)))
392
+ );
393
+ assert_eq!(
394
+ at(3999),
395
+ (Some(100), Some(std::time::Duration::from_millis(4000)))
396
+ );
397
+ assert_eq!(at(4000).0, Some(100));
398
+ assert_eq!(at(4300).0, Some(102));
399
+ assert_eq!(at(5500).0, Some(150));
400
+ assert_eq!(at(6700).0, Some(198));
401
+ assert_eq!(
402
+ at(6999),
403
+ (Some(0), Some(std::time::Duration::from_millis(7000)))
404
+ );
405
+ assert_eq!(at(7000), (None, None));
406
+ }
407
+
408
+ #[test]
409
+ fn short_sent_user_message_starts_after_label_on_the_same_row() {
410
+ let mut state = ChatViewState::default();
411
+ state
412
+ .transcript
413
+ .push(TranscriptEntry::new(TranscriptRole::User, "ok เข้าใจละ"));
414
+ let text = state.transcript_text(58);
415
+ let mut terminal = Terminal::new(TestBackend::new(58, 4)).unwrap();
416
+ terminal
417
+ .draw(|frame| {
418
+ frame.render_widget(
419
+ Paragraph::new(text.clone()).wrap(Wrap { trim: false }),
420
+ frame.area(),
421
+ );
422
+ })
423
+ .unwrap();
424
+ let buffer = terminal.backend().buffer();
425
+ let first: String = (0..58).map(|x| buffer[(x, 0)].symbol()).collect();
426
+ assert!(first.contains("You › ok เข้าใจละ"));
427
+ assert_eq!(
428
+ buffer[(1, 1)].bg,
429
+ Color::Reset,
430
+ "short messages should occupy one shaded row"
431
+ );
432
+ assert_eq!(state.plain_transcript_lines(), vec!["You › ok เข้าใจละ", ""]);
433
+ }
434
+
435
+ #[test]
436
+ fn attached_document_in_user_prompt_is_condensed_in_tui_transcript() {
437
+ let raw = "@generate_harness_pdf.py ขอปรับหน่อยอะ\n\nAttached document: LICENSE.txt\nUse the extracted document text below when answering.\n\n--- Extracted document text ---\nLine 1\nLine 2\nLine 3\n--- End extracted document text ---";
438
+ let formatted = super::state::format_user_transcript_text(raw);
439
+ assert!(formatted.contains("@generate_harness_pdf.py ขอปรับหน่อยอะ"));
440
+ assert!(formatted.contains("[Attached document: LICENSE.txt (3 lines)]"));
441
+ assert!(!formatted.contains("Use the extracted document text below when answering."));
442
+ assert!(!formatted.contains("Line 1\nLine 2\nLine 3"));
443
+
444
+ let clean = super::state::clean_user_prompt(raw);
445
+ assert_eq!(clean, "@generate_harness_pdf.py ขอปรับหน่อยอะ");
446
+ }
447
+
448
+ #[test]
449
+ fn sent_user_message_background_covers_wrapped_text_at_terminal_width() {
450
+ let mut state = ChatViewState::default();
451
+ state.transcript.push(TranscriptEntry::new(
452
+ TranscriptRole::User,
453
+ format!("{}\nข้อความของฉัน", "hello ".repeat(24)),
454
+ ));
455
+ state
456
+ .transcript
457
+ .push(TranscriptEntry::new(TranscriptRole::Assistant, "Reply"));
458
+ for width in [60, 100] {
459
+ let mut terminal = Terminal::new(TestBackend::new(width, 40)).unwrap();
460
+ terminal
461
+ .draw(|frame| {
462
+ state.render(frame);
463
+ })
464
+ .unwrap();
465
+ let buffer = terminal.backend().buffer();
466
+ let rows: Vec<String> = (0..40)
467
+ .map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect())
468
+ .collect();
469
+ let start = rows.iter().position(|row| row.contains("You ›")).unwrap();
470
+ let end = rows.iter().position(|row| row.contains("Mint ›")).unwrap();
471
+ let padding = if width >= 80 { 2 } else { 1 };
472
+ for y in start..end - 1 {
473
+ for x in padding..width - padding {
474
+ assert_eq!(
475
+ buffer[(x, y as u16)].bg,
476
+ Color::Rgb(64, 64, 64),
477
+ "sent message background must fill every visual row, including padding"
478
+ );
479
+ }
480
+ assert!(!rows[y].contains(['╭', '╮', '╰', '╯', '│']));
481
+ }
482
+ assert_eq!(
483
+ buffer[(padding, end as u16)].bg,
484
+ Color::Reset,
485
+ "assistant messages must remain outside the user background"
486
+ );
487
+ let mut light_buffer = buffer.clone();
488
+ crate::terminal_theme::apply_tui_theme(
489
+ &mut light_buffer,
490
+ crate::terminal_theme::TuiTheme::Light,
491
+ );
492
+ assert_eq!(
493
+ light_buffer[(padding, start as u16)].bg,
494
+ Color::Rgb(225, 225, 225)
495
+ );
496
+ let mut auto_buffer = buffer.clone();
497
+ crate::terminal_theme::apply_tui_theme(
498
+ &mut auto_buffer,
499
+ crate::terminal_theme::TuiTheme::Auto,
500
+ );
501
+ assert_eq!(
502
+ auto_buffer[(padding + 1, (start + 1) as u16)].fg,
503
+ Color::White,
504
+ "text on the dark user background must keep its contrast in Auto theme"
505
+ );
506
+ assert!(
507
+ end - start > 3,
508
+ "long user text must wrap within the background"
509
+ );
510
+ assert_eq!(
511
+ rows[start..end - 1]
512
+ .iter()
513
+ .map(|row| row.matches("hello").count())
514
+ .sum::<usize>(),
515
+ 24
516
+ );
517
+ assert!(
518
+ rows[start..end - 1]
519
+ .iter()
520
+ .any(|row| row.contains("ข้อความของฉัน"))
521
+ );
522
+ }
523
+ // Decorative boundaries must not enter keyboard copy or shift selection indices.
524
+ state.selection_anchor = 0;
525
+ state.selection_head = 1;
526
+ assert_eq!(
527
+ state.selected_text(),
528
+ format!("You › {}\nข้อความของฉัน", "hello ".repeat(24))
529
+ );
530
+ }
531
+
532
+ fn make_key(code: KeyCode) -> KeyEvent {
533
+ KeyEvent {
534
+ code,
535
+ modifiers: KeyModifiers::empty(),
536
+ kind: KeyEventKind::Press,
537
+ state: KeyEventState::empty(),
538
+ }
539
+ }
540
+
541
+ #[test]
542
+ fn slash_suggestion_finds_description_and_completes_executable_token() {
543
+ let mut state = ChatViewState::default();
544
+ state.set_draft("/statistics".to_owned());
545
+ assert_eq!(
546
+ state.suggestions().first().map(|item| item.0.as_str()),
547
+ Some("/stats")
548
+ );
549
+ assert!(state.apply_selected_suggestion());
550
+ assert_eq!(state.composer_text(), "/stats ");
551
+ state.set_draft("/reasoning".to_owned());
552
+ assert!(
553
+ state
554
+ .suggestions()
555
+ .iter()
556
+ .all(|(token, _)| token.starts_with('/'))
557
+ );
558
+ state.set_draft("/remember a durable fact".to_owned());
559
+ assert!(state.suggestions().is_empty());
560
+ state.set_draft("/st".to_owned());
561
+ assert_eq!(
562
+ state.suggestions().first().map(|item| item.0.as_str()),
563
+ Some("/stats")
564
+ );
565
+ }
566
+
567
+ #[test]
568
+ fn skill_suggestion_completes_name_before_task() {
569
+ let workspace = std::env::temp_dir().join(format!(
570
+ "mint-tui-skill-{}-{}",
571
+ std::process::id(),
572
+ std::time::SystemTime::now()
573
+ .duration_since(std::time::UNIX_EPOCH)
574
+ .unwrap()
575
+ .as_nanos()
576
+ ));
577
+ let skill_dir = workspace.join("skills").join("tui-check-skill");
578
+ std::fs::create_dir_all(&skill_dir).unwrap();
579
+ std::fs::write(
580
+ skill_dir.join("SKILL.md"),
581
+ "---\ndescription: Test skill\n---\n",
582
+ )
583
+ .unwrap();
584
+ let mut state = ChatViewState {
585
+ current_dir: workspace.clone(),
586
+ ..ChatViewState::default()
587
+ };
588
+ state.set_draft("$tui-check".to_owned());
589
+ assert!(
590
+ state
591
+ .suggestions()
592
+ .iter()
593
+ .any(|(name, _)| name == "$tui-check-skill")
594
+ );
595
+ assert!(state.apply_selected_suggestion());
596
+ assert_eq!(
597
+ state.composer.iter().collect::<String>(),
598
+ "$tui-check-skill "
599
+ );
600
+ std::fs::remove_dir_all(workspace).unwrap();
601
+ }
602
+
603
+ #[test]
604
+ fn mention_suggestion_replaces_only_the_word_at_cursor() {
605
+ let mut state = ChatViewState {
606
+ current_dir: std::env::current_dir().unwrap(),
607
+ ..ChatViewState::default()
608
+ };
609
+ state.set_draft("Please check @work today".to_owned());
610
+ state.cursor = "Please check @work".chars().count();
611
+ assert!(
612
+ state
613
+ .suggestions()
614
+ .iter()
615
+ .any(|(name, _)| name == "@workspace")
616
+ );
617
+ assert!(state.apply_selected_suggestion());
618
+ assert_eq!(
619
+ state.composer.iter().collect::<String>(),
620
+ "Please check @workspace today"
621
+ );
622
+ assert_eq!(state.cursor, "Please check @workspace ".chars().count());
623
+ }
624
+
625
+ #[test]
626
+ fn three_choice_dialog_shows_every_option_with_three_body_lines() {
627
+ let (reply, _) = mpsc::channel();
628
+ let mut state = ChatViewState::default();
629
+ state.dialog = Some(DialogState::new_choice(
630
+ "Local shell command",
631
+ "Command: uname -r && uname -a\nMode: mutating\nBackground: no",
632
+ vec![
633
+ "Yes".to_owned(),
634
+ "Yes, allow for this session".to_owned(),
635
+ "No".to_owned(),
636
+ ],
637
+ None,
638
+ reply,
639
+ ));
640
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
641
+ terminal
642
+ .draw(|frame| {
643
+ state.render(frame);
644
+ })
645
+ .unwrap();
646
+ let rendered: String = terminal
647
+ .backend()
648
+ .buffer()
649
+ .content
650
+ .iter()
651
+ .map(|cell| cell.symbol())
652
+ .collect();
653
+ assert!(rendered.contains("3. No"));
654
+ assert!(!rendered.contains("more below"));
655
+ }
656
+
657
+ #[test]
658
+ fn choice_dialog_uses_available_height_for_more_than_eight_options() {
659
+ let (reply, _) = mpsc::channel();
660
+ let mut state = ChatViewState::default();
661
+ state.dialog = Some(DialogState::new_choice(
662
+ "Choose one",
663
+ "",
664
+ (1..=9).map(|index| format!("Option {index}")).collect(),
665
+ None,
666
+ reply,
667
+ ));
668
+ let mut terminal = Terminal::new(TestBackend::new(80, 32)).unwrap();
669
+ terminal
670
+ .draw(|frame| {
671
+ state.render(frame);
672
+ })
673
+ .unwrap();
674
+ let rendered: String = terminal
675
+ .backend()
676
+ .buffer()
677
+ .content
678
+ .iter()
679
+ .map(|cell| cell.symbol())
680
+ .collect();
681
+ assert!(rendered.contains("Option 9"));
682
+ assert!(!rendered.contains("more below"));
683
+ }
684
+
685
+ #[test]
686
+ fn long_skill_diff_keeps_decisions_visible_and_can_be_reviewed_to_the_end() {
687
+ let (reply, _) = mpsc::channel();
688
+ let mut state = ChatViewState::default();
689
+ state.dialog = Some(DialogState::new_choice(
690
+ "Save skill?",
691
+ format!(
692
+ "Changes instructions used in future tasks.\nPath: /workspace/.agents/skills/example/SKILL.md\n\n{}",
693
+ (1..=100)
694
+ .map(|i| format!("+ Skill instruction {i:03}: {}", "รายละเอียด ".repeat(8)))
695
+ .collect::<Vec<_>>()
696
+ .join("\n")
697
+ ),
698
+ vec!["Approve".into(), "Deny".into()],
699
+ None,
700
+ reply,
701
+ ));
702
+ for (width, height) in [(80, 24), (160, 42), (60, 16)] {
703
+ state.dialog.as_mut().unwrap().selected = 0;
704
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
705
+ terminal
706
+ .draw(|frame| {
707
+ state.render(frame);
708
+ })
709
+ .unwrap();
710
+ let visible = |buffer: &Buffer| {
711
+ buffer
712
+ .content
713
+ .iter()
714
+ .map(|cell| cell.symbol())
715
+ .collect::<String>()
716
+ };
717
+ let rendered = visible(terminal.backend().buffer());
718
+ assert!(
719
+ rendered.contains("1. Approve"),
720
+ "{width}x{height}: approve hidden"
721
+ );
722
+ assert!(
723
+ rendered.contains("2. Deny"),
724
+ "{width}x{height}: deny hidden"
725
+ );
726
+ assert!(
727
+ rendered.contains("Enter select"),
728
+ "{width}x{height}: hint hidden"
729
+ );
730
+ assert!(rendered.contains("PgUp/PgDn"));
731
+ assert!(rendered.contains("Changes instructions"));
732
+ let dialog = state.dialog.as_mut().unwrap();
733
+ assert_eq!(
734
+ dialog.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
735
+ None
736
+ );
737
+ assert_eq!(dialog.selected, 0);
738
+ terminal
739
+ .draw(|frame| {
740
+ state.render(frame);
741
+ })
742
+ .unwrap();
743
+ let rendered = visible(terminal.backend().buffer());
744
+ assert!(
745
+ rendered.contains("Skill instruction 100"),
746
+ "{width}x{height}: last diff line hidden"
747
+ );
748
+ assert!(rendered.contains("2. Deny"));
749
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollDown, None, None);
750
+ terminal
751
+ .draw(|frame| {
752
+ state.render(frame);
753
+ })
754
+ .unwrap();
755
+ assert!(visible(terminal.backend().buffer()).contains("Skill instruction 100"));
756
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp, None, None);
757
+ assert_eq!(
758
+ state.scroll_from_bottom, 0,
759
+ "wheel must review the dialog, not chat"
760
+ );
761
+ let dialog = state.dialog.as_mut().unwrap();
762
+ dialog.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
763
+ terminal
764
+ .draw(|frame| {
765
+ state.render(frame);
766
+ })
767
+ .unwrap();
768
+ assert!(visible(terminal.backend().buffer()).contains("Changes instructions"));
769
+ let dialog = state.dialog.as_mut().unwrap();
770
+ dialog.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
771
+ terminal
772
+ .draw(|frame| {
773
+ state.render(frame);
774
+ })
775
+ .unwrap();
776
+ assert!(!visible(terminal.backend().buffer()).contains("Changes instructions"));
777
+ state
778
+ .dialog
779
+ .as_mut()
780
+ .unwrap()
781
+ .handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
782
+ terminal
783
+ .draw(|frame| {
784
+ state.render(frame);
785
+ })
786
+ .unwrap();
787
+ assert!(visible(terminal.backend().buffer()).contains("Changes instructions"));
788
+ let dialog = state.dialog.as_mut().unwrap();
789
+ dialog.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
790
+ assert_eq!(
791
+ dialog.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
792
+ Some(DialogAnswer::Choice(1))
793
+ );
794
+ dialog.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
795
+ }
796
+ }
797
+
798
+ #[test]
799
+ fn long_approval_command_keeps_choices_and_hint_visible_in_narrow_terminal() {
800
+ let (reply, _) = mpsc::channel();
801
+ let mut state = ChatViewState::default();
802
+ state.status = vec!["Running shell command".to_owned(); 6];
803
+ state.dialog = Some(DialogState::new_choice(
804
+ "Local shell command",
805
+ concat!(
806
+ "Command: who -a; echo '--- LOGINCTL ---'; loginctl list-sessions 2>&1; ",
807
+ "echo '--- SEATS/USERS ---'; loginctl list-users 2>&1; ",
808
+ "echo '--- UPTIME ---'; uptime -p; uptime -s; ",
809
+ "echo '--- SESSION ENV ---'; echo TYPE=$XDG_SESSION_TYPE ",
810
+ "DESKTOP=$XDG_CURRENT_DESKTOP SESSION=$DESKTOP_SESSION ",
811
+ "WAYLAND=$WAYLAND_DISPLAY DISPLAY=$DISPLAY; ",
812
+ "echo '--- TTY/USER PROCS (top 25 by count) ---'; ",
813
+ "ps -u $USER -o comm= | sort | uniq -c | sort -rn | head -25\n",
814
+ "Mode: mutating\nBackground: no",
815
+ ),
816
+ vec![
817
+ "Yes".to_owned(),
818
+ "Yes, allow for this session".to_owned(),
819
+ "No".to_owned(),
820
+ ],
821
+ None,
822
+ reply,
823
+ ));
824
+ let mut terminal = Terminal::new(TestBackend::new(94, 30)).unwrap();
825
+ terminal
826
+ .draw(|frame| {
827
+ state.render(frame);
828
+ })
829
+ .unwrap();
830
+ let rendered: String = terminal
831
+ .backend()
832
+ .buffer()
833
+ .content
834
+ .iter()
835
+ .map(|cell| cell.symbol())
836
+ .collect();
837
+ assert!(rendered.contains("3. No"));
838
+ assert!(rendered.contains("Enter select"));
839
+ assert!(!rendered.contains("more below"));
840
+ }
841
+
842
+ #[test]
843
+ fn background_count_updates_above_composer_and_uses_the_actual_workspace() {
844
+ let root = std::env::temp_dir().join(format!(
845
+ "mint-tui-bg-{}-{}",
846
+ std::process::id(),
847
+ std::time::SystemTime::now()
848
+ .duration_since(std::time::UNIX_EPOCH)
849
+ .unwrap()
850
+ .as_nanos()
851
+ ));
852
+ std::fs::create_dir(&root).unwrap();
853
+ let config = mint_core::MintConfig {
854
+ safety_enabled: false,
855
+ sandbox_mode: "off".into(),
856
+ ..Default::default()
857
+ };
858
+ let first = mint_core::bg_shell::start_background(&root, &config, "sleep 30").unwrap();
859
+ let second = mint_core::bg_shell::start_background(&root, &config, "sleep 30").unwrap();
860
+ struct Cleanup(Vec<String>, std::path::PathBuf);
861
+ impl Drop for Cleanup {
862
+ fn drop(&mut self) {
863
+ for id in &self.0 {
864
+ let _ = mint_core::bg_shell::kill_job(id);
865
+ }
866
+ let _ = std::fs::remove_dir(&self.1);
867
+ }
868
+ }
869
+ let _cleanup = Cleanup(vec![first.id.clone(), second.id.clone()], root.clone());
870
+ let mut state = ChatViewState::default();
871
+ state.current_dir = root.clone();
872
+ state.workspace = format!("{} · branch: Rust", root.display());
873
+ state.set_notice("A temporary notice");
874
+ for width in [160, 40] {
875
+ let expected = "2 Background Terminals running";
876
+ let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
877
+ let mut composer = None;
878
+ terminal
879
+ .draw(|frame| composer = state.render(frame).composer)
880
+ .unwrap();
881
+ let area = composer.unwrap();
882
+ let rendered: String = (area.x..area.right())
883
+ .map(|x| {
884
+ terminal
885
+ .backend()
886
+ .buffer()
887
+ .cell(Position::new(x, area.y))
888
+ .unwrap()
889
+ .symbol()
890
+ })
891
+ .collect();
892
+ assert!(
893
+ rendered.contains(expected),
894
+ "missing count above composer: {rendered}"
895
+ );
896
+ assert!(
897
+ rendered.trim_end_matches([' ', '─']).ends_with("running"),
898
+ "count must align right: {rendered}"
899
+ );
900
+ let rendered: String = terminal
901
+ .backend()
902
+ .buffer()
903
+ .content
904
+ .iter()
905
+ .map(|cell| cell.symbol())
906
+ .collect();
907
+ assert!(
908
+ rendered.contains(expected),
909
+ "missing job count at width {width}: {rendered}"
910
+ );
911
+ }
912
+ let wait_for_count = |expected| {
913
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
914
+ while mint_core::bg_shell::running_count_for(&root) != expected {
915
+ assert!(std::time::Instant::now() < deadline);
916
+ std::thread::sleep(std::time::Duration::from_millis(20));
917
+ }
918
+ };
919
+ mint_core::bg_shell::kill_job(&first.id).unwrap();
920
+ wait_for_count(1);
921
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
922
+ terminal
923
+ .draw(|frame| {
924
+ state.render(frame);
925
+ })
926
+ .unwrap();
927
+ let rendered: String = terminal
928
+ .backend()
929
+ .buffer()
930
+ .content
931
+ .iter()
932
+ .map(|cell| cell.symbol())
933
+ .collect();
934
+ assert!(rendered.contains("1 Background Terminal running"));
935
+ mint_core::bg_shell::kill_job(&second.id).unwrap();
936
+ wait_for_count(0);
937
+ terminal
938
+ .draw(|frame| {
939
+ state.render(frame);
940
+ })
941
+ .unwrap();
942
+ let rendered: String = terminal
943
+ .backend()
944
+ .buffer()
945
+ .content
946
+ .iter()
947
+ .map(|cell| cell.symbol())
948
+ .collect();
949
+ assert!(!rendered.contains("Background Terminal"));
950
+ }
951
+
952
+ #[test]
953
+ fn live_shell_status_remains_visible_above_a_long_transcript() {
954
+ let mut state = ChatViewState::default();
955
+ state.transcript.push(TranscriptEntry::new(
956
+ TranscriptRole::Assistant,
957
+ "Earlier output\n".repeat(80),
958
+ ));
959
+ state.status = vec![
960
+ " ● Running 1 shell command…".to_owned(),
961
+ " └ ⠋ [run_shell] Running command: sleep 1".to_owned(),
962
+ " Unpacking…".to_owned(),
963
+ ];
964
+ let mut terminal = Terminal::new(TestBackend::new(94, 30)).unwrap();
965
+ terminal
966
+ .draw(|frame| {
967
+ state.render(frame);
968
+ })
969
+ .unwrap();
970
+ let rendered: String = terminal
971
+ .backend()
972
+ .buffer()
973
+ .content
974
+ .iter()
975
+ .map(|cell| cell.symbol())
976
+ .collect();
977
+ assert!(rendered.contains("Running 1 shell command"));
978
+ assert!(rendered.contains("[run_shell] Running command"));
979
+ }
980
+
981
+ fn point(column: u16, row: u16) -> ScreenPoint {
982
+ ScreenPoint { row, column }
983
+ }
984
+
985
+ struct FakeClipboard {
986
+ fail: bool,
987
+ }
988
+
989
+ impl NativeClipboard for FakeClipboard {
990
+ fn set_text(&mut self, _text: String) -> std::result::Result<(), String> {
991
+ if self.fail {
992
+ Err("unavailable".to_owned())
993
+ } else {
994
+ Ok(())
995
+ }
996
+ }
997
+ }
998
+
999
+ #[test]
1000
+ fn transcript_selection_copies_across_render_chunks_without_losing_rows() {
1001
+ let text = format!("start\n{}end", "middle\n".repeat(64));
1002
+ let area = Rect::new(0, 3, 8, 3);
1003
+ let mut drag =
1004
+ TranscriptSelection::from_text(Text::from(text.clone()), area, 0, point(0, 3));
1005
+ drag.update(point(7, 5));
1006
+ let mut scroll = 63;
1007
+ for _ in 0..70 {
1008
+ drag.scroll(&mut scroll);
1009
+ }
1010
+ assert_eq!(drag.text(), text);
1011
+ }
1012
+
1013
+ #[test]
1014
+ fn transcript_selection_can_copy_the_visible_tail_beyond_u16_rows() {
1015
+ let mut lines = vec![Line::raw("earlier"); 66_000];
1016
+ lines.extend([
1017
+ Line::raw("tail one"),
1018
+ Line::raw("tail two"),
1019
+ Line::raw("tail end"),
1020
+ ]);
1021
+ let area = Rect::new(0, 3, 8, 3);
1022
+ let mut drag = TranscriptSelection::from_text(Text::from(lines), area, 66_000, point(0, 3));
1023
+ drag.update(point(7, 5));
1024
+ assert_eq!(drag.text(), "tail one\ntail two\ntail end");
1025
+ }
1026
+
1027
+ #[test]
1028
+ fn transcript_selection_keeps_the_back_button_and_scrollbar_visible() {
1029
+ let area = Rect::new(0, 3, 8, 3);
1030
+ let drag = TranscriptSelection::from_text(
1031
+ Text::from(
1032
+ (0..9)
1033
+ .map(|row| Line::raw(format!("line{row}")))
1034
+ .collect::<Vec<_>>(),
1035
+ ),
1036
+ area,
1037
+ 0,
1038
+ point(0, 4),
1039
+ );
1040
+ let mut screen = Buffer::empty(Rect::new(0, 0, 8, 8));
1041
+ screen.set_string(2, 5, "Back", Style::default());
1042
+ screen.set_string(7, 3, "▲", Style::default());
1043
+ drag.paint(&mut screen, Some(Rect::new(2, 5, 4, 1)));
1044
+ assert_eq!(screen.cell(Position::new(7, 3)).unwrap().symbol(), "▲");
1045
+ assert_eq!(screen.cell(Position::new(2, 5)).unwrap().symbol(), "B");
1046
+ }
1047
+
1048
+ #[test]
1049
+ fn transcript_drag_scrolls_both_edges_and_keeps_offscreen_text() {
1050
+ let area = Rect::new(0, 3, 8, 3);
1051
+ let mut drag = TranscriptSelection::from_text(
1052
+ Text::from(
1053
+ (0..9)
1054
+ .map(|row| Line::raw(format!("line{row}")))
1055
+ .collect::<Vec<_>>(),
1056
+ ),
1057
+ area,
1058
+ 0,
1059
+ point(0, 4),
1060
+ );
1061
+ drag.update(point(4, 5));
1062
+ let mut scroll = 6;
1063
+ for _ in 0..10 {
1064
+ drag.scroll(&mut scroll);
1065
+ }
1066
+ assert_eq!(scroll, 0);
1067
+ assert_eq!(
1068
+ drag.text(),
1069
+ "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8"
1070
+ );
1071
+ let mut screen = Buffer::empty(Rect::new(0, 0, 8, 8));
1072
+ screen.set_string(0, 3, "new text", Style::default());
1073
+ drag.paint(&mut screen, None);
1074
+ assert_eq!(screen.cell(Position::new(0, 3)).unwrap().symbol(), "l");
1075
+ assert_eq!(
1076
+ screen.cell(Position::new(0, 3)).unwrap().bg,
1077
+ crate::terminal_theme::BLUE
1078
+ );
1079
+ assert_eq!(screen.cell(Position::new(0, 2)).unwrap().symbol(), " ");
1080
+ drag.update(point(0, 3));
1081
+ for _ in 0..10 {
1082
+ drag.scroll(&mut scroll);
1083
+ }
1084
+ assert_eq!(scroll, 6);
1085
+ assert_eq!(drag.text(), "line0\nl");
1086
+ drag.dragging = false;
1087
+ drag.update(point(4, 5));
1088
+ assert!(!drag.scroll(&mut scroll));
1089
+ assert_eq!(scroll, 6);
1090
+ }
1091
+
1092
+ #[test]
1093
+ fn transcript_drag_interior_does_not_scroll() {
1094
+ let area = Rect::new(0, 3, 8, 3);
1095
+ let mut drag = TranscriptSelection::from_text(
1096
+ Text::from(vec![Line::raw(""); 9]),
1097
+ area,
1098
+ 2,
1099
+ point(0, 4),
1100
+ );
1101
+ drag.update(point(4, 4));
1102
+ let mut scroll = 4;
1103
+ assert!(!drag.scroll(&mut scroll));
1104
+ assert_eq!(scroll, 4);
1105
+ }
1106
+
1107
+ #[test]
1108
+ fn mouse_selection_requires_a_drag_and_normalizes_direction() {
1109
+ let mut selection = MouseSelection::default();
1110
+ selection.start(point(7, 4));
1111
+ assert!(!selection.finish(point(7, 4)));
1112
+ assert_eq!(selection.range(), None);
1113
+
1114
+ selection.start(point(7, 4));
1115
+ selection.update(point(2, 1));
1116
+ assert!(selection.finish(point(2, 1)));
1117
+ assert_eq!(selection.range(), Some((point(2, 1), point(7, 4))));
1118
+ }
1119
+
1120
+ #[test]
1121
+ fn screen_selection_copies_visual_rows_and_trims_only_the_end() {
1122
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
1123
+ buffer.set_string(0, 0, " hello", Style::default());
1124
+ buffer.set_string(0, 1, "world", Style::default());
1125
+ let selection = MouseSelection {
1126
+ anchor: Some(point(1, 0)),
1127
+ head: Some(point(2, 1)),
1128
+ dragging: false,
1129
+ had_drag: true,
1130
+ };
1131
+
1132
+ assert_eq!(selected_screen_text(&buffer, selection), "hello\nwor");
1133
+ }
1134
+
1135
+ #[test]
1136
+ fn screen_selection_includes_a_wide_grapheme_from_its_continuation_cell() {
1137
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 1));
1138
+ buffer.set_string(0, 0, "A🙂B ก้", Style::default());
1139
+ let selection = MouseSelection {
1140
+ anchor: Some(point(2, 0)),
1141
+ head: Some(point(2, 0)),
1142
+ dragging: false,
1143
+ had_drag: true,
1144
+ };
1145
+
1146
+ assert_eq!(selected_screen_text(&buffer, selection), "🙂");
1147
+ }
1148
+
1149
+ #[test]
1150
+ fn selection_overlay_changes_style_without_changing_symbols() {
1151
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
1152
+ buffer.set_string(
1153
+ 0,
1154
+ 0,
1155
+ "Mint",
1156
+ Style::default().fg(crate::terminal_theme::ACCENT),
1157
+ );
1158
+ let before = buffer
1159
+ .content
1160
+ .iter()
1161
+ .map(|cell| cell.symbol().to_owned())
1162
+ .collect::<Vec<_>>();
1163
+ let selection = MouseSelection {
1164
+ anchor: Some(point(1, 0)),
1165
+ head: Some(point(2, 0)),
1166
+ dragging: false,
1167
+ had_drag: true,
1168
+ };
1169
+
1170
+ apply_mouse_selection(&mut buffer, selection);
1171
+
1172
+ let after = buffer
1173
+ .content
1174
+ .iter()
1175
+ .map(|cell| cell.symbol().to_owned())
1176
+ .collect::<Vec<_>>();
1177
+ assert_eq!(after, before);
1178
+ assert!(!buffer[(0, 0)].modifier.contains(Modifier::REVERSED));
1179
+ assert!(buffer[(1, 0)].modifier.contains(Modifier::REVERSED));
1180
+ assert!(buffer[(2, 0)].modifier.contains(Modifier::REVERSED));
1181
+ assert!(!buffer[(3, 0)].modifier.contains(Modifier::REVERSED));
1182
+ }
1183
+
1184
+ #[test]
1185
+ fn completed_selection_is_invalidated_when_selected_cells_change() {
1186
+ let mut previous = Buffer::empty(Rect::new(0, 0, 5, 1));
1187
+ previous.set_string(0, 0, "Mint", Style::default());
1188
+ let selection = MouseSelection {
1189
+ anchor: Some(point(1, 0)),
1190
+ head: Some(point(2, 0)),
1191
+ dragging: false,
1192
+ had_drag: true,
1193
+ };
1194
+ let unchanged = previous.clone();
1195
+ assert!(selected_cells_match(&previous, &unchanged, selection));
1196
+
1197
+ let mut changed_inside = previous.clone();
1198
+ changed_inside.set_string(1, 0, "XX", Style::default());
1199
+ assert!(!selected_cells_match(&previous, &changed_inside, selection));
1200
+
1201
+ let mut changed_outside = previous.clone();
1202
+ changed_outside.set_string(4, 0, "!", Style::default());
1203
+ assert!(selected_cells_match(&previous, &changed_outside, selection));
1204
+ }
1205
+
1206
+ #[test]
1207
+ fn clipboard_prefers_native_and_falls_back_to_osc52() {
1208
+ let mut native = TextClipboard {
1209
+ native: Some(Box::new(FakeClipboard { fail: false })),
1210
+ initialization_attempted: true,
1211
+ };
1212
+ let mut native_output = Vec::new();
1213
+ assert_eq!(
1214
+ native.copy("Mint", &mut native_output).unwrap(),
1215
+ CopyMethod::Native
1216
+ );
1217
+ assert!(native_output.is_empty());
1218
+
1219
+ let mut fallback = TextClipboard {
1220
+ native: Some(Box::new(FakeClipboard { fail: true })),
1221
+ initialization_attempted: true,
1222
+ };
1223
+ let mut osc52_output = Vec::new();
1224
+ assert_eq!(
1225
+ fallback.copy("Mint", &mut osc52_output).unwrap(),
1226
+ CopyMethod::Osc52
1227
+ );
1228
+ assert_eq!(
1229
+ String::from_utf8(osc52_output).unwrap(),
1230
+ "\u{1b}]52;c;TWludA==\u{7}"
1231
+ );
1232
+ }
1233
+
1234
+ #[test]
1235
+ fn terminal_setup_keeps_wheel_and_history_navigation_distinct() {
1236
+ let mut output = Vec::new();
1237
+ setup_terminal_output(&mut output).expect("terminal setup should render ANSI commands");
1238
+
1239
+ let output = String::from_utf8(output).expect("terminal commands should be UTF-8");
1240
+ assert!(output.contains("\u{1b}[?1049h"));
1241
+ assert!(
1242
+ output.contains("\u{1b}[?1000h")
1243
+ && output.contains("\u{1b}[?1002h")
1244
+ && output.contains("\u{1b}[?1003h")
1245
+ && output.contains("\u{1b}[?1006h"),
1246
+ "mouse reporting is required to distinguish wheel events from Up/Down keys"
1247
+ );
1248
+ assert!(output.contains("\u{1b}[?2004h"));
1249
+ }
1250
+
1251
+ #[test]
1252
+ fn mouse_wheel_routes_to_transcript_or_open_thought_panel() {
1253
+ let mut state = ChatViewState::default();
1254
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp, None, None);
1255
+ assert_eq!(state.scroll_from_bottom, 3);
1256
+
1257
+ state.thought_modal = Some(ThoughtModalState {
1258
+ elapsed_str: "1.0s".to_owned(),
1259
+ lines: vec!["thought".to_owned()],
1260
+ scroll: 4,
1261
+ });
1262
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp, None, None);
1263
+ assert_eq!(state.thought_modal.as_ref().unwrap().scroll, 1);
1264
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollDown, None, None);
1265
+ assert_eq!(state.thought_modal.as_ref().unwrap().scroll, 4);
1266
+ }
1267
+
1268
+ #[test]
1269
+ fn mouse_wheel_scrolls_transcript_during_unscrollable_dialog_or_when_hovering_transcript() {
1270
+ use std::sync::mpsc;
1271
+ let mut state = ChatViewState::default();
1272
+ let (tx, _rx) = mpsc::channel();
1273
+ state.dialog = Some(DialogState::new_choice(
1274
+ "Review plan",
1275
+ "Do you want to proceed with this plan?",
1276
+ vec!["Approve".into(), "Deny".into()],
1277
+ None,
1278
+ tx,
1279
+ ));
1280
+ assert_eq!(state.dialog.as_ref().unwrap().body_max_scroll.get(), 0);
1281
+
1282
+ // When dialog body cannot scroll, mouse wheel anywhere scrolls transcript
1283
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp, None, None);
1284
+ assert_eq!(state.scroll_from_bottom, 3);
1285
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollDown, None, None);
1286
+ assert_eq!(state.scroll_from_bottom, 0);
1287
+
1288
+ // Even with a scrollable dialog, cursor hovering inside transcript scrolls transcript
1289
+ let (tx2, _rx2) = mpsc::channel();
1290
+ let scrollable_dialog = DialogState::new_choice(
1291
+ "Long command",
1292
+ "Lots of output...",
1293
+ vec!["Approve".into(), "Deny".into()],
1294
+ None,
1295
+ tx2,
1296
+ );
1297
+ scrollable_dialog.body_max_scroll.set(50);
1298
+ state.dialog = Some(scrollable_dialog);
1299
+
1300
+ let transcript_area = Rect::new(0, 0, 80, 20);
1301
+ let cursor_in_transcript = Position::new(10, 10);
1302
+ let cursor_in_dialog = Position::new(10, 22);
1303
+
1304
+ // Hover in transcript -> scrolls transcript
1305
+ route_mouse_wheel(
1306
+ &mut state,
1307
+ event::MouseEventKind::ScrollUp,
1308
+ Some(cursor_in_transcript),
1309
+ Some(transcript_area),
1310
+ );
1311
+ assert_eq!(state.scroll_from_bottom, 3);
1312
+ assert_eq!(state.dialog.as_ref().unwrap().body_scroll, 0);
1313
+
1314
+ // Hover in dialog -> scrolls dialog body
1315
+ route_mouse_wheel(
1316
+ &mut state,
1317
+ event::MouseEventKind::ScrollDown,
1318
+ Some(cursor_in_dialog),
1319
+ Some(transcript_area),
1320
+ );
1321
+ assert_eq!(state.scroll_from_bottom, 3);
1322
+ assert_eq!(state.dialog.as_ref().unwrap().body_scroll, 3);
1323
+ }
1324
+
1325
+ #[test]
1326
+ fn back_to_bottom_appears_only_while_scrolled_up_and_click_returns_to_latest() {
1327
+ let mut state = ChatViewState::default();
1328
+ state.push_user(
1329
+ (0..60)
1330
+ .map(|n| format!("line {n}"))
1331
+ .collect::<Vec<_>>()
1332
+ .join("\n"),
1333
+ );
1334
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
1335
+ let mut button = None;
1336
+ terminal
1337
+ .draw(|frame| button = state.render(frame).back_to_bottom)
1338
+ .unwrap();
1339
+ assert!(button.is_none());
1340
+
1341
+ state.scroll_from_bottom = 10;
1342
+ terminal
1343
+ .draw(|frame| button = state.render(frame).back_to_bottom)
1344
+ .unwrap();
1345
+ let area = button.expect("button should appear above the composer");
1346
+ let rendered: String = terminal
1347
+ .backend()
1348
+ .buffer()
1349
+ .content
1350
+ .iter()
1351
+ .map(|cell| cell.symbol())
1352
+ .collect();
1353
+ assert!(rendered.contains("Back to bottom"));
1354
+ assert!(!activate_back_to_bottom(
1355
+ &mut state,
1356
+ button,
1357
+ event::MouseEventKind::Down(event::MouseButton::Left),
1358
+ ScreenPoint {
1359
+ row: area.y,
1360
+ column: area.x.saturating_sub(1)
1361
+ },
1362
+ ));
1363
+ assert_eq!(state.scroll_from_bottom, 10);
1364
+ assert!(activate_back_to_bottom(
1365
+ &mut state,
1366
+ button,
1367
+ event::MouseEventKind::Down(event::MouseButton::Left),
1368
+ ScreenPoint {
1369
+ row: area.y,
1370
+ column: area.x
1371
+ },
1372
+ ));
1373
+ assert_eq!(state.scroll_from_bottom, 0);
1374
+ terminal
1375
+ .draw(|frame| button = state.render(frame).back_to_bottom)
1376
+ .unwrap();
1377
+ assert!(button.is_none());
1378
+ }
1379
+
1380
+ fn thought_screen(state: &ChatViewState, width: u16, height: u16) -> Vec<String> {
1381
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1382
+ terminal
1383
+ .draw(|frame| {
1384
+ state.render(frame);
1385
+ })
1386
+ .unwrap();
1387
+ terminal
1388
+ .backend()
1389
+ .buffer()
1390
+ .content
1391
+ .chunks(width as usize)
1392
+ .map(|cells| cells.iter().map(|cell| cell.symbol()).collect())
1393
+ .collect()
1394
+ }
1395
+
1396
+ fn ctrl_t() -> KeyEvent {
1397
+ let mut key = make_key(KeyCode::Char('t'));
1398
+ key.modifiers = KeyModifiers::CONTROL;
1399
+ key
1400
+ }
1401
+
1402
+ fn tool_event(value: serde_json::Value) -> mint_core::AgentProgress {
1403
+ serde_json::from_value(value).unwrap()
1404
+ }
1405
+
1406
+ #[test]
1407
+ fn running_tool_status_stays_above_composer_with_command_and_spinner() {
1408
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1409
+ let tui = ChatTui::handle(state.clone());
1410
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolStart","data":{"callId":"a","action":"run_shell","input":{"command":"sleep 5 # live-command-marker"}}})));
1411
+ {
1412
+ let mut state = state.lock().unwrap();
1413
+ for _ in 0..35 {
1414
+ state.push_notice("older transcript row");
1415
+ }
1416
+ state.scroll_from_bottom = 20;
1417
+ state.status = (0..12).map(|index| format!("Plan row {index}")).collect();
1418
+ let rows = thought_screen(&state, 100, 30);
1419
+ let pinned = rows[22..26].join("\n");
1420
+ assert!(
1421
+ pinned.contains("Running 1 shell command"),
1422
+ "missing active summary: {pinned}"
1423
+ );
1424
+ assert!(
1425
+ pinned.contains("live-command-marker"),
1426
+ "missing running command: {pinned}"
1427
+ );
1428
+ assert!(
1429
+ pinned.chars().any(|ch| "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(ch)),
1430
+ "missing animated spinner: {pinned}"
1431
+ );
1432
+ }
1433
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolEnd","data":{"callId":"a","action":"run_shell","input":{"command":"sleep 5 # live-command-marker"},"result":"exit: 0"}})));
1434
+ let state = state.lock().unwrap();
1435
+ assert!(
1436
+ !thought_screen(&state, 100, 30)[22..26]
1437
+ .join("\n")
1438
+ .contains("live-command-marker")
1439
+ );
1440
+ }
1441
+
1442
+ #[test]
1443
+ fn compact_tools_details_cannot_replace_an_approval_dialog() {
1444
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1445
+ let tui = ChatTui::handle(state.clone());
1446
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolStart","data":{"callId":"a","action":"run_shell","input":{"command":"echo hi"}}})));
1447
+ {
1448
+ let mut state = state.lock().unwrap();
1449
+ let mut key = make_key(KeyCode::Char('o'));
1450
+ key.modifiers = KeyModifiers::CONTROL;
1451
+ assert!(state.handle_tool_key(key));
1452
+ let (reply, _) = mpsc::channel();
1453
+ state.dialog = Some(DialogState::new_choice(
1454
+ "Local shell command",
1455
+ "Approval required",
1456
+ vec!["Yes".into(), "No".into()],
1457
+ None,
1458
+ reply,
1459
+ ));
1460
+ }
1461
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolEnd","data":{"callId":"a","action":"run_shell","input":{"command":"echo hi"},"result":"exit: 0"}})));
1462
+ let state = state.lock().unwrap();
1463
+ assert_eq!(state.dialog.as_ref().unwrap().title, "Local shell command");
1464
+ assert!(state.tool_viewer.is_none());
1465
+ }
1466
+
1467
+ #[test]
1468
+ fn identical_legacy_starts_keep_separate_running_cards() {
1469
+ let mut state = ChatViewState::default();
1470
+ let start = tool_event(
1471
+ serde_json::json!({"type":"ToolStart","data":{"action":"read_file","input":{"path":"same"}}}),
1472
+ );
1473
+ state.record_tool_progress(&start);
1474
+ state.record_tool_progress(&start);
1475
+ assert_eq!(state.tools.len(), 2);
1476
+ let end = tool_event(
1477
+ serde_json::json!({"type":"ToolEnd","data":{"action":"read_file","input":{"path":"same"},"result":"contents"}}),
1478
+ );
1479
+ state.record_tool_progress(&end);
1480
+ assert_eq!(state.tools.iter().filter(|tool| tool.running()).count(), 1);
1481
+ state.record_tool_progress(&end);
1482
+ assert_eq!(state.tools.len(), 2);
1483
+ assert!(state.tools.iter().all(|tool| !tool.running()));
1484
+ }
1485
+
1486
+ #[test]
1487
+ fn compact_tools_count_only_running_calls_and_update_existing_card() {
1488
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1489
+ let tui = ChatTui::handle(state.clone());
1490
+ for (id, action) in [("a", "run_shell"), ("b", "run_shell"), ("c", "read_file")] {
1491
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolStart","data":{"callId":id,"action":action,"input":{"command":"echo hi"}}})));
1492
+ }
1493
+ assert!(
1494
+ thought_screen(&state.lock().unwrap(), 100, 30)
1495
+ .join("\n")
1496
+ .contains("Running 3 tasks · 2 shell commands · 1 tool")
1497
+ );
1498
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolEnd","data":{"callId":"a","action":"run_shell","input":{"command":"echo hi"},"result":"exit: 0\nstdout:\nhi"}})));
1499
+ let state = state.lock().unwrap();
1500
+ assert_eq!(state.transcript.len(), 3);
1501
+ let screen = thought_screen(&state, 100, 30).join("\n");
1502
+ assert!(screen.contains("run_shell completed"));
1503
+ assert!(screen.contains("Running 2 tasks · 1 shell command · 1 tool"));
1504
+ assert!(!screen.contains("stdout:"));
1505
+ }
1506
+
1507
+ #[test]
1508
+ fn compact_tools_hide_multiline_commands_and_keep_full_details_accessible() {
1509
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1510
+ let tui = ChatTui::handle(state.clone());
1511
+ let command = format!(
1512
+ "python3 - <<'EOF'\n{}\nEOF",
1513
+ "print('secret-detail')\n".repeat(40)
1514
+ );
1515
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolStart","data":{"callId":"a","action":"run_shell","input":{"command":command}}})));
1516
+ let mut state = state.lock().unwrap();
1517
+ assert!(
1518
+ !thought_screen(&state, 80, 24)
1519
+ .join("\n")
1520
+ .contains("secret-detail")
1521
+ );
1522
+ let mut key = make_key(KeyCode::Char('o'));
1523
+ key.modifiers = KeyModifiers::CONTROL;
1524
+ assert!(state.handle_tool_key(key));
1525
+ assert!(state.dialog.as_ref().unwrap().body.contains(&command));
1526
+ assert!(state.handle_tool_key(make_key(KeyCode::Esc)));
1527
+ assert!(state.dialog.is_none());
1528
+ }
1529
+
1530
+ #[test]
1531
+ fn compact_tools_match_parallel_results_by_id_and_keep_failures_red() {
1532
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1533
+ let tui = ChatTui::handle(state.clone());
1534
+ for id in ["a", "b"] {
1535
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolStart","data":{"callId":id,"action":"run_shell","input":{"command":"same-command"}}})));
1536
+ }
1537
+ tui.tool_progress(&tool_event(serde_json::json!({"type":"ToolEnd","data":{"callId":"b","action":"run_shell","input":{"command":"same-command"},"result":"exit: 2\nstderr:\nfull failure"}})));
1538
+ let state = state.lock().unwrap();
1539
+ assert_eq!(state.transcript.len(), 2);
1540
+ assert_eq!(
1541
+ state.transcript[1].rendered_lines[0].spans[0].style.fg,
1542
+ Some(Color::Red)
1543
+ );
1544
+ assert!(
1545
+ thought_screen(&state, 80, 24)
1546
+ .join("\n")
1547
+ .contains("Running 1 shell command")
1548
+ );
1549
+ }
1550
+
1551
+ #[test]
1552
+ fn live_thought_timer_advances_without_deltas_and_preview_fits_small_terminals() {
1553
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1554
+ let handle = ChatTui::handle(state.clone());
1555
+ handle.append_thought_delta("one", "checking code", std::time::Duration::ZERO);
1556
+ let mut state = state.lock().unwrap();
1557
+ state.thoughts.last_mut().unwrap().updated_at =
1558
+ Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
1559
+ assert!(
1560
+ thought_screen(&state, 80, 24)
1561
+ .join("\n")
1562
+ .contains("Thinking · 3.0s")
1563
+ );
1564
+ for (width, height) in [(40, 16), (60, 20), (80, 24)] {
1565
+ let rows = thought_screen(&state, width, height);
1566
+ let top = rows
1567
+ .iter()
1568
+ .position(|line| line.trim_start().starts_with('┌'))
1569
+ .unwrap();
1570
+ let bottom = rows
1571
+ .iter()
1572
+ .enumerate()
1573
+ .skip(top + 1)
1574
+ .find(|(_, line)| line.trim_start().starts_with('└'))
1575
+ .unwrap()
1576
+ .0;
1577
+ assert!(bottom - top < 6);
1578
+ assert!(rows.join("\n").contains("checking code"));
1579
+ }
1580
+ assert!(state.handle_thought_key(ctrl_t()));
1581
+ for (width, height) in [(20, 8), (40, 12), (80, 24)] {
1582
+ thought_screen(&state, width, height);
1583
+ }
1584
+ }
1585
+
1586
+ #[test]
1587
+ fn live_thought_history_keeps_steps_once_and_remains_accessible_next_turn() {
1588
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1589
+ let handle = ChatTui::handle(state.clone());
1590
+ handle.append_thought_delta("one", "first partial", std::time::Duration::ZERO);
1591
+ handle.finish_thought(
1592
+ Some("one"),
1593
+ "first complete",
1594
+ std::time::Duration::from_secs(1),
1595
+ );
1596
+ handle.append_thought_delta("two", "second partial", std::time::Duration::ZERO);
1597
+ assert!(state.lock().unwrap().handle_thought_key(ctrl_t()));
1598
+ handle.finish_thought(
1599
+ Some("two"),
1600
+ "second complete",
1601
+ std::time::Duration::from_secs(2),
1602
+ );
1603
+ {
1604
+ let state = state.lock().unwrap();
1605
+ let text = state.thought_modal.as_ref().unwrap().lines.join("\n");
1606
+ assert_eq!(text.matches("first complete").count(), 1);
1607
+ assert_eq!(text.matches("second complete").count(), 1);
1608
+ assert!(!text.contains("partial"));
1609
+ let screen = thought_screen(&state, 100, 30).join("\n");
1610
+ assert!(screen.contains("Thought Process · 3.0s"), "{screen}");
1611
+ assert!(screen.contains("Thought 1 · 1.0s"), "{screen}");
1612
+ assert!(screen.contains("Thought 2 · 2.0s"), "{screen}");
1613
+ assert_eq!(state.transcript.len(), 2);
1614
+ }
1615
+ handle.reset_thoughts();
1616
+ let mut state = state.lock().unwrap();
1617
+ assert!(state.thought_modal.is_none());
1618
+ assert!(state.handle_thought_key(ctrl_t()));
1619
+ assert!(
1620
+ state
1621
+ .thought_modal
1622
+ .as_ref()
1623
+ .unwrap()
1624
+ .lines
1625
+ .join("\n")
1626
+ .contains("first complete")
1627
+ );
1628
+ }
1629
+
1630
+ #[test]
1631
+ fn live_thought_wrapped_tail_preserves_latest_unicode_text() {
1632
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1633
+ let handle = ChatTui::handle(state.clone());
1634
+ handle.append_thought_delta(
1635
+ "one",
1636
+ &format!("{}\nlatest ข้อความ 🙂", "older words ".repeat(30)),
1637
+ std::time::Duration::ZERO,
1638
+ );
1639
+ let state = state.lock().unwrap();
1640
+ let screen = thought_screen(&state, 40, 24).join("\n");
1641
+ assert!(screen.contains("latest ข้อความ 🙂"));
1642
+ assert!(!screen.contains("older words older words older words older words"));
1643
+ }
1644
+
1645
+ #[test]
1646
+ fn live_thought_preview_keeps_only_latest_rows_without_appending_transcript() {
1647
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1648
+ let handle = ChatTui::handle(state.clone());
1649
+ assert!(
1650
+ !thought_screen(&state.lock().unwrap(), 80, 24)
1651
+ .join("\n")
1652
+ .contains("Thinking ·")
1653
+ );
1654
+ for index in 1..=20 {
1655
+ handle.append_thought_delta(
1656
+ "one",
1657
+ &format!("reasoning row {index:02}\n"),
1658
+ std::time::Duration::from_millis(1200),
1659
+ );
1660
+ }
1661
+ let state = state.lock().unwrap();
1662
+ let rows = thought_screen(&state, 80, 24);
1663
+ let title = rows
1664
+ .iter()
1665
+ .position(|line| line.contains("Thinking · 1.2s"))
1666
+ .expect("live thinking header");
1667
+ let latest = rows
1668
+ .iter()
1669
+ .position(|line| line.contains("reasoning row 20"))
1670
+ .expect("latest streamed row");
1671
+ assert!(latest > title && latest - title < 6);
1672
+ assert!(!rows.join("\n").contains("reasoning row 01"));
1673
+ assert!(state.transcript.is_empty());
1674
+ }
1675
+
1676
+ #[test]
1677
+ fn live_thought_finishes_as_one_collapsed_summary_and_preserves_full_text() {
1678
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1679
+ let handle = ChatTui::handle(state.clone());
1680
+ handle.append_thought_delta("one", "partial", std::time::Duration::from_millis(1200));
1681
+ handle.finish_thought(
1682
+ Some("one"),
1683
+ "complete reasoning",
1684
+ std::time::Duration::from_millis(1900),
1685
+ );
1686
+ let mut state = state.lock().unwrap();
1687
+ let rendered = thought_screen(&state, 80, 24).join("\n");
1688
+ assert!(rendered.contains("Thought · 1.9s (Ctrl+T to view)"));
1689
+ assert!(!rendered.contains("Thinking ·"));
1690
+ assert!(!rendered.contains("complete reasoning"));
1691
+ assert_eq!(state.transcript.len(), 1);
1692
+ assert!(state.handle_thought_key(ctrl_t()));
1693
+ assert_eq!(
1694
+ state.thought_modal.as_ref().unwrap().lines[1..],
1695
+ vec!["complete reasoning"]
1696
+ );
1697
+ }
1698
+
1699
+ #[test]
1700
+ fn live_thought_modal_refreshes_deltas_and_closes_without_cancelling_agent() {
1701
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1702
+ let handle = ChatTui::handle(state.clone());
1703
+ handle.append_thought_delta("one", "first", std::time::Duration::ZERO);
1704
+ assert!(state.lock().unwrap().handle_thought_key(ctrl_t()));
1705
+ handle.append_thought_delta("one", " second", std::time::Duration::from_secs(1));
1706
+ let mut state = state.lock().unwrap();
1707
+ assert_eq!(
1708
+ state.thought_modal.as_ref().unwrap().lines[1..],
1709
+ vec!["first second"]
1710
+ );
1711
+ assert!(state.handle_thought_key(make_key(KeyCode::Esc)));
1712
+ assert!(state.thought_modal.is_none());
1713
+ assert!(!handle.take_interrupted());
1714
+ }
1715
+
1716
+ #[test]
1717
+ fn live_thought_interruption_preserves_partial_text_and_collapses_preview() {
1718
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
1719
+ let handle = ChatTui::handle(state.clone());
1720
+ handle.append_thought_delta(
1721
+ "one",
1722
+ "unfinished reasoning",
1723
+ std::time::Duration::from_millis(1900),
1724
+ );
1725
+ let mut state = state.lock().unwrap();
1726
+ state.finish_live_thought();
1727
+ assert!(
1728
+ !thought_screen(&state, 80, 24)
1729
+ .join("\n")
1730
+ .contains("Thinking ·")
1731
+ );
1732
+ assert!(state.handle_thought_key(ctrl_t()));
1733
+ assert_eq!(
1734
+ state.thought_modal.as_ref().unwrap().lines[1..],
1735
+ vec!["unfinished reasoning"]
1736
+ );
1737
+ assert_eq!(state.transcript.len(), 1);
1738
+ }
1739
+
1740
+ #[test]
1741
+ fn queued_message_shows_in_composer_bottom_title() {
1742
+ let mut state = ChatViewState::default();
1743
+ state.queued_count = 1;
1744
+ state.queued_preview = Some("run next test".into());
1745
+ let rendered = thought_screen(&state, 80, 24).join("\n");
1746
+ assert!(rendered.contains("Queued (1): \"run next test\" · Ctrl+D drop"));
1747
+
1748
+ state.queued_count = 2;
1749
+ state.queued_preview = Some("deploy app".into());
1750
+ let rendered2 = thought_screen(&state, 80, 24).join("\n");
1751
+ assert!(rendered2.contains("Queued (2): \"deploy app\" · Ctrl+D drop"));
1752
+ }
1753
+
1754
+ #[test]
1755
+ fn update_dialog_requires_explicit_selection_before_installing() {
1756
+ let (tx, _rx) = mpsc::channel();
1757
+ let mut dialog = DialogState::new_update("1.0.0", "2.0.0", tx);
1758
+ assert_eq!(
1759
+ dialog.handle_key(make_key(KeyCode::Enter)),
1760
+ Some(DialogAnswer::Choice(1))
1761
+ );
1762
+ dialog.handle_key(make_key(KeyCode::Up));
1763
+ assert_eq!(
1764
+ dialog.handle_key(make_key(KeyCode::Enter)),
1765
+ Some(DialogAnswer::Choice(0))
1766
+ );
1767
+ assert_eq!(
1768
+ dialog.handle_key(make_key(KeyCode::Esc)),
1769
+ Some(DialogAnswer::Cancel)
1770
+ );
1771
+ }
1772
+
1773
+ #[test]
1774
+ fn update_dialog_can_skip_version_and_cannot_filter_away_skip() {
1775
+ let (tx, _rx) = mpsc::channel();
1776
+ let mut dialog = DialogState::new_update("1.0.0", "2.0.0", tx);
1777
+ dialog.handle_key(make_key(KeyCode::Char('x')));
1778
+ assert_eq!(
1779
+ dialog.handle_key(make_key(KeyCode::Esc)),
1780
+ Some(DialogAnswer::Cancel)
1781
+ );
1782
+ assert_eq!(dialog.handle_key(make_key(KeyCode::Char('1'))), None);
1783
+ assert_eq!(
1784
+ dialog.handle_key(make_key(KeyCode::Enter)),
1785
+ Some(DialogAnswer::Choice(0))
1786
+ );
1787
+ dialog.handle_key(make_key(KeyCode::Down));
1788
+ dialog.handle_key(make_key(KeyCode::Down));
1789
+ assert_eq!(
1790
+ dialog.handle_key(make_key(KeyCode::Enter)),
1791
+ Some(DialogAnswer::Choice(2))
1792
+ );
1793
+ }
1794
+
1795
+ #[test]
1796
+ fn test_dialog_filtering() {
1797
+ let (tx, _rx) = mpsc::channel();
1798
+ let options = vec![
1799
+ "gpt-4o".to_string(),
1800
+ "claude-3-5-sonnet".to_string(),
1801
+ "gemini-2.0-flash".to_string(),
1802
+ "deepseek-r1".to_string(),
1803
+ ];
1804
+ let mut dialog = DialogState::new_choice("Pick model", "", options, None, tx);
1805
+
1806
+ assert_eq!(dialog.filtered_indices(), vec![0, 1, 2, 3]);
1807
+
1808
+ // Type 'g'
1809
+ dialog.handle_key(make_key(KeyCode::Char('g')));
1810
+ assert_eq!(dialog.filtered_indices(), vec![0, 2]);
1811
+
1812
+ // Type 'e' -> "ge"
1813
+ dialog.handle_key(make_key(KeyCode::Char('e')));
1814
+ assert_eq!(dialog.filtered_indices(), vec![2]);
1815
+
1816
+ // Backspace -> "g"
1817
+ dialog.handle_key(make_key(KeyCode::Backspace));
1818
+ assert_eq!(dialog.filtered_indices(), vec![0, 2]);
1819
+
1820
+ // Esc clears filter
1821
+ dialog.handle_key(make_key(KeyCode::Esc));
1822
+ assert_eq!(dialog.filtered_indices(), vec![0, 1, 2, 3]);
1823
+ }
1824
+
1825
+ #[test]
1826
+ fn test_dialog_navigation_and_numeric_shortcuts() {
1827
+ let (tx, _rx) = mpsc::channel();
1828
+ let options = vec![
1829
+ "Option 1".to_string(),
1830
+ "Option 2".to_string(),
1831
+ "Option 3".to_string(),
1832
+ ];
1833
+ let mut dialog = DialogState::new_choice("Pick one", "", options, None, tx);
1834
+
1835
+ assert_eq!(dialog.selected, 0);
1836
+
1837
+ dialog.handle_key(make_key(KeyCode::Down));
1838
+ assert_eq!(dialog.selected, 1);
1839
+
1840
+ dialog.handle_key(make_key(KeyCode::Down));
1841
+ assert_eq!(dialog.selected, 2);
1842
+
1843
+ // Clamped at bottom
1844
+ dialog.handle_key(make_key(KeyCode::Down));
1845
+ assert_eq!(dialog.selected, 2);
1846
+
1847
+ dialog.handle_key(make_key(KeyCode::Up));
1848
+ assert_eq!(dialog.selected, 1);
1849
+
1850
+ // Numeric shortcut: '1' selects index 0 and returns Choice(0)
1851
+ let answer = dialog.handle_key(make_key(KeyCode::Char('1')));
1852
+ assert_eq!(answer, Some(DialogAnswer::Choice(0)));
1853
+
1854
+ // Numeric shortcut: '3' selects index 2 and returns Choice(2)
1855
+ let answer = dialog.handle_key(make_key(KeyCode::Char('3')));
1856
+ assert_eq!(answer, Some(DialogAnswer::Choice(2)));
1857
+ }
1858
+
1859
+ #[test]
1860
+ fn test_dialog_multiselect() {
1861
+ let (tx, _rx) = mpsc::channel();
1862
+ let options = vec!["A".to_string(), "B".to_string(), "C".to_string()];
1863
+ let checked = vec![false, false, false];
1864
+ let mut dialog = DialogState::new_choice("Multi", "", options, Some(checked), tx);
1865
+
1866
+ // Toggle first option with Space
1867
+ dialog.handle_key(make_key(KeyCode::Char(' ')));
1868
+ assert_eq!(dialog.checked.as_ref().unwrap(), &vec![true, false, false]);
1869
+
1870
+ // Move to second option and toggle
1871
+ dialog.handle_key(make_key(KeyCode::Down));
1872
+ dialog.handle_key(make_key(KeyCode::Char(' ')));
1873
+ assert_eq!(dialog.checked.as_ref().unwrap(), &vec![true, true, false]);
1874
+
1875
+ // Enter submits selected choices
1876
+ let answer = dialog.handle_key(make_key(KeyCode::Enter));
1877
+ assert_eq!(answer, Some(DialogAnswer::Choices(vec![0, 1])));
1878
+ }
1879
+
1880
+ #[test]
1881
+ fn test_dialog_text_input() {
1882
+ let (tx, _rx) = mpsc::channel();
1883
+ let mut dialog = DialogState::new_text("Input", "Prompt", tx);
1884
+
1885
+ dialog.handle_key(make_key(KeyCode::Char('h')));
1886
+ dialog.handle_key(make_key(KeyCode::Char('i')));
1887
+ assert_eq!(dialog.input.as_ref().unwrap(), &vec!['h', 'i']);
1888
+
1889
+ dialog.handle_key(make_key(KeyCode::Backspace));
1890
+ assert_eq!(dialog.input.as_ref().unwrap(), &vec!['h']);
1891
+
1892
+ let answer = dialog.handle_key(make_key(KeyCode::Enter));
1893
+ assert_eq!(answer, Some(DialogAnswer::Text("h".to_string())));
1894
+ }
1895
+
1896
+ #[test]
1897
+ fn test_dialog_cancellation() {
1898
+ let (tx, _rx) = mpsc::channel();
1899
+ let mut dialog =
1900
+ DialogState::new_choice("Pick", "", vec!["A".into(), "B".into()], None, tx);
1901
+
1902
+ let answer = dialog.handle_key(make_key(KeyCode::Esc));
1903
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
1904
+
1905
+ let ctrl_c = KeyEvent {
1906
+ code: KeyCode::Char('c'),
1907
+ modifiers: KeyModifiers::CONTROL,
1908
+ kind: KeyEventKind::Press,
1909
+ state: KeyEventState::empty(),
1910
+ };
1911
+ let answer = dialog.handle_key(ctrl_c);
1912
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
1913
+
1914
+ let ctrl_d = KeyEvent {
1915
+ code: KeyCode::Char('d'),
1916
+ modifiers: KeyModifiers::CONTROL,
1917
+ kind: KeyEventKind::Press,
1918
+ state: KeyEventState::empty(),
1919
+ };
1920
+ let answer = dialog.handle_key(ctrl_d);
1921
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
1922
+ }
1923
+
1924
+ #[test]
1925
+ fn test_transcript_entry_markdown_rendering() {
1926
+ let text = "**Bold Header**\n- Item 1\n---\n> [!TIP]\n> Pro tip here";
1927
+ let entry = TranscriptEntry::new(TranscriptRole::Assistant, text);
1928
+
1929
+ // Check line synchronization
1930
+ assert_eq!(entry.rendered_lines.len(), entry.plain_lines.len());
1931
+
1932
+ // Check bold rendering
1933
+ let first_rendered = &entry.rendered_lines[0];
1934
+ let has_bold = first_rendered
1935
+ .spans
1936
+ .iter()
1937
+ .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
1938
+ assert!(has_bold, "First line should contain bold modifier");
1939
+
1940
+ // Check divider line rendering (converted --- into sleek box drawing ─)
1941
+ let divider_line = &entry.plain_lines[2];
1942
+ assert!(
1943
+ divider_line.contains('─'),
1944
+ "--- should be converted to ─ divider: {divider_line}"
1945
+ );
1946
+
1947
+ // Check TIP alert formatting
1948
+ let alert_line = &entry.plain_lines[3];
1949
+ assert!(
1950
+ alert_line.contains("│ TIP:"),
1951
+ "Alert should format as │ TIP: : {alert_line}"
1952
+ );
1953
+
1954
+ // Check bullet formatting
1955
+ let bullet_line = &entry.plain_lines[1];
1956
+ assert!(
1957
+ bullet_line.contains('•'),
1958
+ "List item should format with bullet • : {bullet_line}"
1959
+ );
1960
+ }
1961
+
1962
+ #[test]
1963
+ fn test_thought_modal_state_and_scrolling() {
1964
+ let mut modal = ThoughtModalState {
1965
+ elapsed_str: "3.5s".to_string(),
1966
+ lines: vec!["line 1".into(), "line 2".into(), "line 3".into()],
1967
+ scroll: 0,
1968
+ };
1969
+
1970
+ assert_eq!(modal.scroll, 0);
1971
+ modal.scroll = modal.scroll.saturating_add(1);
1972
+ assert_eq!(modal.scroll, 1);
1973
+ modal.scroll = modal.scroll.saturating_sub(1);
1974
+ assert_eq!(modal.scroll, 0);
1975
+ }
1976
+
1977
+ #[test]
1978
+ fn test_notice_expiration_and_clear() {
1979
+ let mut state = ChatViewState::default();
1980
+ assert_eq!(state.active_notice(), None);
1981
+
1982
+ state.set_notice("Image attached");
1983
+ assert_eq!(state.active_notice(), Some("Image attached"));
1984
+
1985
+ // Terminal too small warning doesn't auto-expire
1986
+ state.set_notice("Terminal too small: 40x10");
1987
+ assert_eq!(state.active_notice(), Some("Terminal too small: 40x10"));
1988
+
1989
+ state.clear_notice();
1990
+ assert_eq!(state.active_notice(), None);
1991
+
1992
+ // Manually test expiration logic
1993
+ state.notice = Some((
1994
+ "Old notice".into(),
1995
+ std::time::Instant::now() - std::time::Duration::from_millis(3000),
1996
+ ));
1997
+ assert_eq!(state.active_notice(), None);
1998
+ assert!(state.notice_visibility_changed_since_draw(true));
1999
+ assert!(!state.notice_visibility_changed_since_draw(false));
2000
+ }
2001
+
2002
+ #[test]
2003
+ fn test_refresh_shared_transcript_preserves_tool_notice_order() {
2004
+ let temp_dir = std::env::temp_dir().join(format!(
2005
+ "mint-test-tui-sync-{}",
2006
+ std::time::SystemTime::now()
2007
+ .duration_since(std::time::UNIX_EPOCH)
2008
+ .unwrap()
2009
+ .as_nanos()
2010
+ ));
2011
+ let _ = std::fs::create_dir_all(&temp_dir);
2012
+ let db_path = temp_dir.join("test_memory.db");
2013
+ let memory = mint_core::MemoryStore::open(db_path);
2014
+ let workspace = temp_dir.clone();
2015
+ let chat_id = "test-session";
2016
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
2017
+
2018
+ let mut state = ChatViewState {
2019
+ chat_id: chat_id.to_string(),
2020
+ current_dir: workspace.clone(),
2021
+ ..ChatViewState::default()
2022
+ };
2023
+ state.reload_transcript_with(&memory, chat_id, &workspace);
2024
+
2025
+ // Turn 1 runs locally in CLI:
2026
+ state.push_user("List files please".into());
2027
+ state.push_notice("• Listing 2 directories...");
2028
+ state.push_notice("✓ Completed in 1.2s • 1 tool • 0 files changed");
2029
+ state.push_assistant("Found 2 files.");
2030
+ state.push_notice("─ Worked for 1s • test ──────────────────────────────");
2031
+
2032
+ // In DB, TurnLease records this interaction:
2033
+ let turn_id = memory.start_turn(&scoped, "List files please").unwrap();
2034
+ memory.claim_turn(&scoped, turn_id).unwrap();
2035
+ memory
2036
+ .finish_turn(turn_id, "Found 2 files.", "test", "model", None)
2037
+ .unwrap();
2038
+
2039
+ // Advance cursor as local turn completion would:
2040
+ state.advance_sync_cursor_with(&memory);
2041
+
2042
+ // Refresh shared transcript:
2043
+ let redrawn = state.refresh_shared_transcript_with(&memory);
2044
+ assert!(
2045
+ !redrawn,
2046
+ "Should not redraw since changes were from local turn"
2047
+ );
2048
+
2049
+ // Now simulate an external message arriving from Web/Desktop on the same session:
2050
+ let ext_id = memory.start_turn(&scoped, "Message from Web").unwrap();
2051
+ memory.claim_turn(&scoped, ext_id).unwrap();
2052
+ memory
2053
+ .finish_turn(ext_id, "Reply to Web", "test", "model", None)
2054
+ .unwrap();
2055
+
2056
+ let redrawn2 = state.refresh_shared_transcript_with(&memory);
2057
+ assert!(redrawn2, "Should redraw when external turn arrives");
2058
+
2059
+ // Verify transcript order:
2060
+ // Turn 1's tool notices must still be BEFORE Turn 1's assistant reply!
2061
+ // Not pushed to the very bottom!
2062
+ let texts: Vec<&str> = state.transcript.iter().map(|e| e.text.as_str()).collect();
2063
+ assert_eq!(texts.len(), 7);
2064
+ assert_eq!(texts[0], "List files please");
2065
+ assert_eq!(texts[1], "• Listing 2 directories...");
2066
+ assert_eq!(texts[2], "✓ Completed in 1.2s • 1 tool • 0 files changed");
2067
+ assert_eq!(texts[3], "Found 2 files.");
2068
+ assert_eq!(
2069
+ texts[4],
2070
+ "─ Worked for 1s • test ──────────────────────────────"
2071
+ );
2072
+ assert_eq!(texts[5], "Message from Web");
2073
+ assert_eq!(texts[6], "Reply to Web");
2074
+
2075
+ let _ = std::fs::remove_dir_all(&temp_dir);
2076
+ }
2077
+
2078
+ #[test]
2079
+ fn test_refresh_shared_transcript_external_turn_lifecycle() {
2080
+ let temp_dir = std::env::temp_dir().join(format!(
2081
+ "mint-test-tui-sync-lifecycle-{}",
2082
+ std::time::SystemTime::now()
2083
+ .duration_since(std::time::UNIX_EPOCH)
2084
+ .unwrap()
2085
+ .as_nanos()
2086
+ ));
2087
+ let _ = std::fs::create_dir_all(&temp_dir);
2088
+ let db_path = temp_dir.join("test_memory.db");
2089
+ let memory = mint_core::MemoryStore::open(db_path);
2090
+ let workspace = temp_dir.clone();
2091
+ let chat_id = "test-session-2";
2092
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
2093
+
2094
+ let mut state = ChatViewState {
2095
+ chat_id: chat_id.to_string(),
2096
+ current_dir: workspace.clone(),
2097
+ ..ChatViewState::default()
2098
+ };
2099
+ state.reload_transcript_with(&memory, chat_id, &workspace);
2100
+
2101
+ // Turn arrives from Web (status: queued):
2102
+ let ext_id = memory.start_turn(&scoped, "External prompt").unwrap();
2103
+ let redrawn = state.refresh_shared_transcript_with(&memory);
2104
+ assert!(redrawn);
2105
+ assert_eq!(state.transcript.len(), 2);
2106
+ assert_eq!(state.transcript[0].text, "External prompt");
2107
+ assert_eq!(state.transcript[1].text, "Queued for this session…");
2108
+
2109
+ // External turn updates to running:
2110
+ memory.claim_turn(&scoped, ext_id).unwrap();
2111
+ let redrawn = state.refresh_shared_transcript_with(&memory);
2112
+ assert!(redrawn);
2113
+ assert_eq!(state.transcript.len(), 2);
2114
+ assert_eq!(state.transcript[0].text, "External prompt");
2115
+ assert_eq!(state.transcript[1].text, "Mint is responding…");
2116
+
2117
+ // External turn completes:
2118
+ memory
2119
+ .finish_turn(ext_id, "External answer", "provider", "model", None)
2120
+ .unwrap();
2121
+ let redrawn = state.refresh_shared_transcript_with(&memory);
2122
+ assert!(redrawn);
2123
+ assert_eq!(state.transcript.len(), 2);
2124
+ assert_eq!(state.transcript[0].text, "External prompt");
2125
+ assert_eq!(state.transcript[1].text, "External answer");
2126
+ assert_eq!(state.transcript[1].role, TranscriptRole::Assistant);
2127
+
2128
+ let _ = std::fs::remove_dir_all(&temp_dir);
2129
+ }
2130
+ }
2131
+
2132
+ #[cfg(test)]
2133
+ mod context_compaction_shimmer_tests {
2134
+ use super::*;
2135
+ #[test]
2136
+ fn full_screen_compaction_shimmers_only_the_label() {
2137
+ let text = "✦ Compacting context · 12s • Esc to interrupt";
2138
+ let first = shimmer_thinking_line(text, 0);
2139
+ let next = shimmer_thinking_line(text, 12);
2140
+ assert_ne!(first, next);
2141
+ assert_eq!(
2142
+ first.spans.last().unwrap().content,
2143
+ " · 12s • Esc to interrupt"
2144
+ );
2145
+ assert_eq!(first.spans.last(), next.spans.last());
2146
+ }
2147
+ }
2148
+
2149
+ #[cfg(test)]
2150
+ mod collapsed_paste_tests {
2151
+ use super::*;
2152
+
2153
+ #[test]
2154
+ fn f2_toggles_an_edited_paste_without_folding_surrounding_instructions() {
2155
+ let mut state = ChatViewState::default();
2156
+ state.set_draft("before ".into());
2157
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2158
+ state.replace_composer(state.cursor..state.cursor, " after");
2159
+ let f2 = event::KeyEvent::new(event::KeyCode::F(2), event::KeyModifiers::NONE);
2160
+ state.edit_composer_key(f2);
2161
+ state.edit_composer_key(event::KeyEvent::new(
2162
+ event::KeyCode::Char('ก'),
2163
+ event::KeyModifiers::NONE,
2164
+ ));
2165
+ state.edit_composer_key(f2);
2166
+ assert_eq!(
2167
+ state.composer.iter().collect::<String>(),
2168
+ "before [Pasted Content 14 chars] after"
2169
+ );
2170
+ state.edit_composer_key(f2);
2171
+ assert_eq!(
2172
+ state.composer.iter().collect::<String>(),
2173
+ "before กa\nb\nc\nd\ne\nf\ng after"
2174
+ );
2175
+ state.edit_composer_key(f2);
2176
+ assert_eq!(state.take_prompt(), "before กa\nb\nc\nd\ne\nf\ng after");
2177
+ state.set_draft("fresh".into());
2178
+ state.edit_composer_key(f2);
2179
+ assert_eq!(state.take_prompt(), "fresh");
2180
+ }
2181
+
2182
+ #[test]
2183
+ fn f2_preserves_nested_pastes_and_edits_before_the_open_block() {
2184
+ let mut state = ChatViewState::default();
2185
+ state.set_draft("before ".into());
2186
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2187
+ let f2 = event::KeyEvent::new(event::KeyCode::F(2), event::KeyModifiers::NONE);
2188
+ state.edit_composer_key(f2);
2189
+ state.replace_composer(0..0, "prefix ");
2190
+ state.cursor = 15;
2191
+ state.insert_paste("1\n2\n3\n4\n5\n6\n7");
2192
+ state.edit_composer_key(f2);
2193
+ assert_eq!(
2194
+ state.composer.iter().collect::<String>(),
2195
+ "prefix before [Pasted Content 26 chars]"
2196
+ );
2197
+ assert_eq!(
2198
+ state.take_prompt(),
2199
+ "prefix before a1\n2\n3\n4\n5\n6\n7\nb\nc\nd\ne\nf\ng"
2200
+ );
2201
+ }
2202
+
2203
+ #[test]
2204
+ fn deleting_an_open_paste_drops_its_toggle_state() {
2205
+ let mut state = ChatViewState::default();
2206
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2207
+ let f2 = event::KeyEvent::new(event::KeyCode::F(2), event::KeyModifiers::NONE);
2208
+ state.edit_composer_key(f2);
2209
+ state.replace_composer(0..13, "");
2210
+ state.edit_composer_key(f2);
2211
+ assert!(state.composer.is_empty());
2212
+ }
2213
+
2214
+ #[test]
2215
+ fn seven_pasted_lines_render_as_a_badge_instead_of_filling_the_composer() {
2216
+ let mut state = ChatViewState::default();
2217
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2218
+ let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 32)).unwrap();
2219
+ let mut composer = None;
2220
+ terminal
2221
+ .draw(|frame| composer = state.render(frame).composer)
2222
+ .unwrap();
2223
+ let area = composer.unwrap();
2224
+ let buffer = terminal.backend().buffer();
2225
+ let rendered: String = (area.y..area.bottom())
2226
+ .flat_map(|y| (area.x..area.right()).map(move |x| buffer[(x, y)].symbol()))
2227
+ .collect();
2228
+ assert!(rendered.contains("[Pasted Content 13 chars]"), "{rendered}");
2229
+ assert!(rendered.contains("F2"));
2230
+ assert_eq!(area.height, 3, "A collapsed paste should use one input row");
2231
+ }
2232
+
2233
+ #[test]
2234
+ fn six_pasted_lines_stay_editable_inline() {
2235
+ let mut state = ChatViewState::default();
2236
+ state.insert_paste("a\r\nb\rc\nd\ne\nf");
2237
+ assert_eq!(
2238
+ state.composer.iter().collect::<String>(),
2239
+ "a\nb\nc\nd\ne\nf"
2240
+ );
2241
+ }
2242
+
2243
+ #[test]
2244
+ fn autocomplete_keeps_the_adjacent_collapsed_paste_as_separate_content() {
2245
+ let mut state = ChatViewState::default();
2246
+ state.set_draft("/hel".into());
2247
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2248
+ state.cursor = 4;
2249
+ state.slash_selected = state
2250
+ .suggestions()
2251
+ .iter()
2252
+ .position(|(token, _)| token == "/help")
2253
+ .unwrap();
2254
+ assert!(state.apply_selected_suggestion());
2255
+ assert_eq!(state.take_prompt(), "/help a\nb\nc\nd\ne\nf\ng");
2256
+ }
2257
+
2258
+ #[test]
2259
+ fn clipboard_trailing_newlines_survive_both_inline_and_collapsed_submit() {
2260
+ let mut state = ChatViewState::default();
2261
+ state.insert_paste("a\nb\nc\nd\ne\nf\n");
2262
+ assert_eq!(
2263
+ state.composer.iter().collect::<String>(),
2264
+ "a\nb\nc\nd\ne\nf\n"
2265
+ );
2266
+ assert_eq!(state.take_prompt(), "a\nb\nc\nd\ne\nf\n");
2267
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng\n\n");
2268
+ assert_eq!(
2269
+ state.composer.iter().collect::<String>(),
2270
+ "[Pasted Content 15 chars]"
2271
+ );
2272
+ assert_eq!(state.take_prompt(), "a\nb\nc\nd\ne\nf\ng\n\n");
2273
+ }
2274
+
2275
+ #[test]
2276
+ fn submit_expands_each_paste_without_replacing_literal_badge_text() {
2277
+ let mut state = ChatViewState::default();
2278
+ state.set_draft("literal [Pasted Content 13 chars]: ".into());
2279
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2280
+ state.replace_composer(state.cursor..state.cursor, " then ");
2281
+ state.insert_paste("1\n2\n3\n4\n5\n6\n7");
2282
+ assert_eq!(
2283
+ state.take_prompt(),
2284
+ "literal [Pasted Content 13 chars]: a\nb\nc\nd\ne\nf\ng then 1\n2\n3\n4\n5\n6\n7"
2285
+ );
2286
+ assert!(state.composer.is_empty());
2287
+ state.set_draft("fresh".into());
2288
+ assert_eq!(state.take_prompt(), "fresh");
2289
+ }
2290
+
2291
+ #[test]
2292
+ fn opening_a_paste_reveals_its_first_line_and_edits_reach_submit() {
2293
+ let mut state = ChatViewState::default();
2294
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2295
+ assert!(state.open_paste());
2296
+ assert_eq!(state.cursor, 0);
2297
+ assert_eq!(
2298
+ state.composer.iter().collect::<String>(),
2299
+ "a\nb\nc\nd\ne\nf\ng"
2300
+ );
2301
+ state.replace_composer(1..1, " edited");
2302
+ assert_eq!(state.take_prompt(), "a edited\nb\nc\nd\ne\nf\ng");
2303
+ }
2304
+
2305
+ #[test]
2306
+ fn deleting_part_of_a_badge_removes_only_that_paste() {
2307
+ let mut state = ChatViewState::default();
2308
+ state.set_draft("before ".into());
2309
+ state.insert_paste("a\nb\nc\nd\ne\nf\ng");
2310
+ state.replace_composer(state.cursor..state.cursor, " after");
2311
+ state.replace_composer(8..9, "");
2312
+ assert_eq!(state.take_prompt(), "before after");
2313
+ }
2314
+
2315
+ #[test]
2316
+ fn unicode_character_count_and_history_restore_keep_the_real_content() {
2317
+ let mut state = ChatViewState {
2318
+ history: vec!["older".into()],
2319
+ ..Default::default()
2320
+ };
2321
+ state.insert_paste("ก\rข\rค\rง\rจ\rฉ\rช");
2322
+ assert_eq!(
2323
+ state.composer.iter().collect::<String>(),
2324
+ "[Pasted Content 13 chars]"
2325
+ );
2326
+ state.history_previous();
2327
+ state.history_next();
2328
+ assert_eq!(state.composer_text(), "ก\nข\nค\nง\nจ\nฉ\nช");
2329
+ }
2330
+ }
2331
+
2332
+ #[cfg(test)]
2333
+ mod pasted_prompt_render_tests {
2334
+ use super::*;
2335
+ use ratatui::backend::TestBackend;
2336
+
2337
+ const PROMPT: &str = "Make me a Mint mod called token-weather.\r\rWhat it should show:\r- ☀ Clear (yellow), ☁ Cloudy (cyan), ☂ Showers (blue)\r- 134.4k / 200k\r- ▁▂▃▄▅▆▇█\r- ▲ +98.3k last turn\r\rIt should update after every turn.";
2338
+
2339
+ #[test]
2340
+ fn pasted_prompt_draft_renders_line_breaks_without_carriage_return_cells() {
2341
+ let mut state = ChatViewState::default();
2342
+ state.set_draft(PROMPT.into());
2343
+ state.cursor = 0;
2344
+ let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
2345
+ let mut composer = None;
2346
+ terminal
2347
+ .draw(|frame| composer = state.render(frame).composer)
2348
+ .unwrap();
2349
+ let area = composer.unwrap();
2350
+ let buffer = terminal.backend().buffer();
2351
+ let rows: Vec<String> = (area.y + 1..area.bottom() - 1)
2352
+ .map(|y| {
2353
+ (area.x..area.right())
2354
+ .map(|x| buffer[(x, y)].symbol())
2355
+ .collect()
2356
+ })
2357
+ .collect();
2358
+ assert!(
2359
+ rows.iter().all(|row| !row.contains('\r')),
2360
+ "CR must become a line break, not a terminal cursor movement: {rows:?}"
2361
+ );
2362
+ assert!(rows[0].contains("Make me a Mint mod called token-weather."));
2363
+ assert!(rows[2].contains("What it should show:"), "{rows:?}");
2364
+ assert!(rows[3].contains("☀ Clear"));
2365
+ }
2366
+
2367
+ #[test]
2368
+ fn sent_pasted_prompt_preserves_all_lines_and_weather_symbols() {
2369
+ let mut state = ChatViewState::default();
2370
+ state.push_user(PROMPT.into());
2371
+ state.push_assistant("Reading the authoring guide.");
2372
+ let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
2373
+ terminal
2374
+ .draw(|frame| {
2375
+ state.render(frame);
2376
+ })
2377
+ .unwrap();
2378
+ let buffer = terminal.backend().buffer();
2379
+ let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
2380
+ assert!(
2381
+ !rendered.contains('\r'),
2382
+ "Sent user text contains terminal cursor movements"
2383
+ );
2384
+ for label in [
2385
+ "token-weather.",
2386
+ "What it should show:",
2387
+ "☀ Clear",
2388
+ "134.4k / 200k",
2389
+ "▁▂▃▄▅▆▇█",
2390
+ "▲ +98.3k last turn",
2391
+ "It should update after every turn.",
2392
+ ] {
2393
+ assert!(rendered.contains(label), "Missing sent text: {label}");
2394
+ }
2395
+ assert_eq!(state.plain_transcript_lines()[2], "What it should show:");
2396
+ }
2397
+
2398
+ #[test]
2399
+ fn reused_prompt_history_normalizes_crlf_and_lone_cr() {
2400
+ let mut state = ChatViewState {
2401
+ history: vec!["first\r\nsecond\rthird".into()],
2402
+ ..Default::default()
2403
+ };
2404
+ state.history_previous();
2405
+ assert_eq!(
2406
+ state.composer.iter().collect::<String>(),
2407
+ "first\nsecond\nthird"
2408
+ );
2409
+ assert_eq!(state.cursor, 18);
2410
+ }
2411
+
2412
+ #[test]
2413
+ fn end_returns_scrolled_transcript_to_bottom_before_moving_draft_cursor() {
2414
+ use event::{KeyCode, KeyEvent, KeyModifiers};
2415
+ let mut state = ChatViewState::default();
2416
+ state.set_draft("first line\nsecond line".into());
2417
+ state.cursor = 2;
2418
+ state.scroll_from_bottom = 12;
2419
+ let end = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
2420
+ assert!(state.navigate_composer(end, 20));
2421
+ assert_eq!(state.scroll_from_bottom, 0);
2422
+ assert_eq!(state.cursor, 2);
2423
+ assert_eq!(state.composer_text(), "first line\nsecond line");
2424
+ assert!(state.navigate_composer(end, 20));
2425
+ assert_eq!(state.cursor, 10);
2426
+ }
2427
+
2428
+ #[test]
2429
+ fn composer_navigation_uses_visual_rows_for_wrapped_and_wide_text() {
2430
+ use event::{KeyCode, KeyEvent, KeyModifiers};
2431
+ let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
2432
+ let mut state = ChatViewState::default();
2433
+ state.set_draft("0123456789abcdefghijABCDEFGHIJKLMNOPQRSTUVWXYZ".into());
2434
+ assert!(state.navigate_composer(key(KeyCode::Up), 20));
2435
+ assert_eq!(state.cursor, 26);
2436
+ assert!(state.navigate_composer(key(KeyCode::Home), 20));
2437
+ assert_eq!(state.cursor, 20);
2438
+ assert!(state.navigate_composer(key(KeyCode::Down), 20));
2439
+ assert_eq!(state.cursor, 40);
2440
+ assert!(state.navigate_composer(key(KeyCode::End), 20));
2441
+ assert_eq!(state.cursor, 46);
2442
+ state.set_draft("界界\naaaa".into());
2443
+ assert!(state.navigate_composer(key(KeyCode::Up), 20));
2444
+ assert_eq!(state.cursor, 2);
2445
+ assert!(state.navigate_composer(key(KeyCode::Down), 20));
2446
+ assert_eq!(state.cursor, 7);
2447
+ }
2448
+
2449
+ #[test]
2450
+ fn long_pasted_prompt_can_be_inspected_from_first_to_last_line() {
2451
+ use event::{KeyCode, KeyEvent, KeyModifiers};
2452
+ let mut state = ChatViewState::default();
2453
+ state.insert_paste(
2454
+ &(1..=12)
2455
+ .map(|i| format!("Prompt line {i:02}"))
2456
+ .collect::<Vec<_>>()
2457
+ .join("\r"),
2458
+ );
2459
+ assert!(state.edit_composer_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)));
2460
+ assert_eq!(state.cursor, 0);
2461
+ state.cursor = state.composer.len();
2462
+ let mut terminal = Terminal::new(TestBackend::new(80, 32)).unwrap();
2463
+ let mut composer = None;
2464
+ terminal
2465
+ .draw(|frame| composer = state.render(frame).composer)
2466
+ .unwrap();
2467
+ let area = composer.unwrap();
2468
+ let visible = |buffer: &Buffer| -> String {
2469
+ (area.y + 1..area.bottom() - 1)
2470
+ .flat_map(|y| (area.x..area.right()).map(move |x| buffer[(x, y)].symbol()))
2471
+ .collect()
2472
+ };
2473
+ assert!(visible(terminal.backend().buffer()).contains("Prompt line 12"));
2474
+ for _ in 0..11 {
2475
+ assert!(state.navigate_composer(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 72));
2476
+ }
2477
+ terminal
2478
+ .draw(|frame| {
2479
+ state.render(frame);
2480
+ })
2481
+ .unwrap();
2482
+ assert!(visible(terminal.backend().buffer()).contains("Prompt line 01"));
2483
+ assert!(visible(terminal.backend().buffer()).contains("Prompt line 06"));
2484
+ }
2485
+ }
2486
+
2487
+ #[cfg(test)]
2488
+ mod mod_surface_tests {
2489
+ use super::*;
2490
+ #[test]
2491
+ fn focused_mod_input_displays_the_current_value_before_editing() {
2492
+ let mut state = ChatViewState::default();
2493
+ state.mods_open = true;
2494
+ state.mods = vec![
2495
+ serde_json::json!({"id":"draft","enabled":true,"snapshot":{"ui":{"side_panel":{"type":"input","label":"Draft","action":"save","value":"existing"}}}}),
2496
+ ];
2497
+ let backend = ratatui::backend::TestBackend::new(120, 35);
2498
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
2499
+ terminal
2500
+ .draw(|frame| {
2501
+ state.render(frame);
2502
+ })
2503
+ .unwrap();
2504
+ let screen = terminal
2505
+ .backend()
2506
+ .buffer()
2507
+ .content
2508
+ .iter()
2509
+ .map(|cell| cell.symbol())
2510
+ .collect::<String>();
2511
+ assert!(screen.contains("› Draft: existing"));
2512
+ }
2513
+ #[test]
2514
+ fn terminal_shows_reload_diagnostics_while_the_old_mod_stays_active() {
2515
+ let mut state = ChatViewState::default();
2516
+ state.mods_open = true;
2517
+ state.mods = vec![
2518
+ serde_json::json!({"id":"counter","name":"Counter","enabled":true,"reloadError":"Invalid update","snapshot":{"ui":{"side_panel":{"type":"text","text":"Working revision"}}}}),
2519
+ ];
2520
+ let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 35)).unwrap();
2521
+ terminal
2522
+ .draw(|frame| {
2523
+ state.render(frame);
2524
+ })
2525
+ .unwrap();
2526
+ let text = terminal
2527
+ .backend()
2528
+ .buffer()
2529
+ .content
2530
+ .iter()
2531
+ .map(|cell| cell.symbol())
2532
+ .collect::<String>();
2533
+ assert!(text.contains("Reload: Invalid update"));
2534
+ assert!(text.contains("Working revision"));
2535
+ }
2536
+ #[test]
2537
+ fn terminal_viewport_tracks_docked_panel_width_and_overlay_fallback() {
2538
+ let mut state = ChatViewState::default();
2539
+ state.mods_open = true;
2540
+ let wide = state.mod_viewport(Rect::new(0, 0, 120, 35));
2541
+ assert_eq!(wide.columns, Some(78));
2542
+ assert_eq!(wide.surface.as_deref(), Some("terminal"));
2543
+ let narrow = state.mod_viewport(Rect::new(0, 0, 80, 35));
2544
+ assert_eq!(narrow.columns, Some(76));
2545
+ state.mods_open = false;
2546
+ assert_eq!(
2547
+ state.mod_viewport(Rect::new(0, 0, 120, 35)).columns,
2548
+ Some(116)
2549
+ );
2550
+ }
2551
+ #[test]
2552
+ fn terminal_weather_band_keeps_color_and_metrics_on_one_row() {
2553
+ let mut state = ChatViewState::default();
2554
+ state.mods = vec![
2555
+ serde_json::json!({"id":"weather","name":"Weather","enabled":true,"snapshot":{"ui":{"above_composer":{"type":"stack","direction":"row","children":[{"type":"text","text":"Storm","color":"magenta"},{"type":"text","text":"80% 160k / 200k"}]}}}}),
2556
+ ];
2557
+ let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 35)).unwrap();
2558
+ terminal
2559
+ .draw(|frame| {
2560
+ state.render(frame);
2561
+ })
2562
+ .unwrap();
2563
+ let buffer = terminal.backend().buffer();
2564
+ let mut rows = Vec::new();
2565
+ for y in 0..35 {
2566
+ rows.push(
2567
+ (0..120)
2568
+ .map(|x| buffer[(x, y)].symbol())
2569
+ .collect::<String>(),
2570
+ );
2571
+ }
2572
+ let row = rows
2573
+ .iter()
2574
+ .position(|r| r.contains("Storm 80% 160k / 200k"))
2575
+ .expect("Weather and counts share one row");
2576
+ let x = rows[row].find("Storm").unwrap();
2577
+ assert_eq!(
2578
+ buffer[(x as u16, row as u16)].fg,
2579
+ ratatui::style::Color::Magenta
2580
+ );
2581
+ }
2582
+ #[test]
2583
+ fn terminal_renders_mod_band_and_side_panel() {
2584
+ let mut state = ChatViewState::default();
2585
+ state.mods = vec![
2586
+ serde_json::json!({"id":"meter","name":"Meter","enabled":true,"snapshot":{"ui":{"above_composer":{"type":"text","text":"123 tokens"},"side_panel":{"type":"table","columns":["Task"],"rows":[["Write tests"]]}}}}),
2587
+ ];
2588
+ state.mods_open = true;
2589
+ let backend = ratatui::backend::TestBackend::new(120, 35);
2590
+ let mut terminal = Terminal::new(backend).unwrap();
2591
+ terminal
2592
+ .draw(|frame| {
2593
+ state.render(frame);
2594
+ })
2595
+ .unwrap();
2596
+ let buffer = terminal.backend().buffer();
2597
+ let text = buffer
2598
+ .content
2599
+ .iter()
2600
+ .map(|c| c.symbol())
2601
+ .collect::<String>();
2602
+ assert!(text.contains("123 tokens"));
2603
+ assert!(text.contains("Write tests"));
2604
+ }
2605
+ }
2606
+
2607
+ #[cfg(test)]
2608
+ mod swarm_panel_tests {
2609
+ use super::*;
2610
+
2611
+ #[test]
2612
+ fn swarm_panel_is_hidden_when_empty() {
2613
+ let state = ChatViewState::default();
2614
+ let backend = ratatui::backend::TestBackend::new(100, 30);
2615
+ let mut terminal = Terminal::new(backend).unwrap();
2616
+ terminal
2617
+ .draw(|frame| {
2618
+ state.render(frame);
2619
+ })
2620
+ .unwrap();
2621
+ let text = terminal
2622
+ .backend()
2623
+ .buffer()
2624
+ .content
2625
+ .iter()
2626
+ .map(|c| c.symbol())
2627
+ .collect::<String>();
2628
+ assert!(!text.contains("Subagents"));
2629
+ }
2630
+
2631
+ #[test]
2632
+ fn swarm_panel_renders_running_and_completed_subagents() {
2633
+ let mut state = ChatViewState::default();
2634
+ state.swarm_agents.push(SwarmAgentUiState {
2635
+ label: "recon".to_string(),
2636
+ profile: "explorer".to_string(),
2637
+ task: "Searching for payment webhook endpoints...".to_string(),
2638
+ current_action: None,
2639
+ status: SwarmUiStatus::Running,
2640
+ started_at: std::time::Instant::now(),
2641
+ finished_elapsed_ms: None,
2642
+ summary_preview: None,
2643
+ });
2644
+ state.swarm_agents.push(SwarmAgentUiState {
2645
+ label: "config".to_string(),
2646
+ profile: "explorer".to_string(),
2647
+ task: "Checking config files".to_string(),
2648
+ current_action: None,
2649
+ status: SwarmUiStatus::Success,
2650
+ started_at: std::time::Instant::now(),
2651
+ finished_elapsed_ms: Some(3000),
2652
+ summary_preview: Some("Checked stripe API keys in .env".to_string()),
2653
+ });
2654
+
2655
+ let backend = ratatui::backend::TestBackend::new(100, 30);
2656
+ let mut terminal = Terminal::new(backend).unwrap();
2657
+ terminal
2658
+ .draw(|frame| {
2659
+ state.render(frame);
2660
+ })
2661
+ .unwrap();
2662
+ let text = terminal
2663
+ .backend()
2664
+ .buffer()
2665
+ .content
2666
+ .iter()
2667
+ .map(|c| c.symbol())
2668
+ .collect::<String>();
2669
+ assert!(text.contains("Running Subagents"));
2670
+ assert!(text.contains("[recon]"));
2671
+ assert!(text.contains("Searching for payment webhook endpoints..."));
2672
+ assert!(text.contains("[config]"));
2673
+ assert!(text.contains("Checked stripe API keys in .env"));
2674
+ assert!(text.contains("done 3s"));
2675
+ }
2676
+
2677
+ #[test]
2678
+ fn swarm_panel_handles_more_than_5_subagents_with_overflow() {
2679
+ let mut state = ChatViewState::default();
2680
+ for i in 1..=7 {
2681
+ state.swarm_agents.push(SwarmAgentUiState {
2682
+ label: format!("agent-{}", i),
2683
+ profile: "explorer".to_string(),
2684
+ task: format!("Task description for agent {}", i),
2685
+ current_action: None,
2686
+ status: SwarmUiStatus::Running,
2687
+ started_at: std::time::Instant::now(),
2688
+ finished_elapsed_ms: None,
2689
+ summary_preview: None,
2690
+ });
2691
+ }
2692
+
2693
+ let backend = ratatui::backend::TestBackend::new(100, 30);
2694
+ let mut terminal = Terminal::new(backend).unwrap();
2695
+ terminal
2696
+ .draw(|frame| {
2697
+ state.render(frame);
2698
+ })
2699
+ .unwrap();
2700
+ let text = terminal
2701
+ .backend()
2702
+ .buffer()
2703
+ .content
2704
+ .iter()
2705
+ .map(|c| c.symbol())
2706
+ .collect::<String>();
2707
+ assert!(text.contains("+3 more subagents..."));
2708
+ }
2709
+
2710
+ #[test]
2711
+ fn swarm_panel_compact_mode_on_short_screen() {
2712
+ let mut state = ChatViewState::default();
2713
+ state.swarm_agents.push(SwarmAgentUiState {
2714
+ label: "recon".to_string(),
2715
+ profile: "explorer".to_string(),
2716
+ task: "Searching files".to_string(),
2717
+ current_action: None,
2718
+ status: SwarmUiStatus::Running,
2719
+ started_at: std::time::Instant::now(),
2720
+ finished_elapsed_ms: None,
2721
+ summary_preview: None,
2722
+ });
2723
+
2724
+ let backend = ratatui::backend::TestBackend::new(80, 20);
2725
+ let mut terminal = Terminal::new(backend).unwrap();
2726
+ terminal
2727
+ .draw(|frame| {
2728
+ state.render(frame);
2729
+ })
2730
+ .unwrap();
2731
+ let text = terminal
2732
+ .backend()
2733
+ .buffer()
2734
+ .content
2735
+ .iter()
2736
+ .map(|c| c.symbol())
2737
+ .collect::<String>();
2738
+ assert!(text.contains("Running 1 subagent"));
2739
+ }
2740
+
2741
+ #[test]
2742
+ fn swarm_agents_cleared_on_user_input_and_clear() {
2743
+ let mut state = ChatViewState::default();
2744
+ state.swarm_agents.push(SwarmAgentUiState {
2745
+ label: "recon".to_string(),
2746
+ profile: "explorer".to_string(),
2747
+ task: "Searching files".to_string(),
2748
+ current_action: None,
2749
+ status: SwarmUiStatus::Running,
2750
+ started_at: std::time::Instant::now(),
2751
+ finished_elapsed_ms: None,
2752
+ summary_preview: None,
2753
+ });
2754
+ assert_eq!(state.swarm_agents.len(), 1);
2755
+
2756
+ state.push_user("hello".to_string());
2757
+ assert!(state.swarm_agents.is_empty());
2758
+
2759
+ state.swarm_agents.push(SwarmAgentUiState {
2760
+ label: "recon".to_string(),
2761
+ profile: "explorer".to_string(),
2762
+ task: "Searching files".to_string(),
2763
+ current_action: None,
2764
+ status: SwarmUiStatus::Running,
2765
+ started_at: std::time::Instant::now(),
2766
+ finished_elapsed_ms: None,
2767
+ summary_preview: None,
2768
+ });
2769
+ state.clear_transcript();
2770
+ assert!(state.swarm_agents.is_empty());
2771
+ }
2772
+
2773
+ #[test]
2774
+ fn record_tool_progress_updates_swarm_state() {
2775
+ let mut state = ChatViewState::default();
2776
+ let start_event = mint_core::AgentProgress::SubagentStarted {
2777
+ subagent: "worker".to_string(),
2778
+ profile: "explorer".to_string(),
2779
+ task: "Investigate database queries".to_string(),
2780
+ };
2781
+ state.record_tool_progress(&start_event);
2782
+ assert_eq!(state.swarm_agents.len(), 1);
2783
+ assert_eq!(state.swarm_agents[0].label, "worker");
2784
+ assert_eq!(state.swarm_agents[0].status, SwarmUiStatus::Running);
2785
+
2786
+ let tool_start = mint_core::AgentProgress::ToolStart {
2787
+ call_id: Some("call-1".to_string()),
2788
+ action: "run_shell".to_string(),
2789
+ input: serde_json::json!({"command": "cargo test"}),
2790
+ subagent: Some("worker".to_string()),
2791
+ };
2792
+ state.record_tool_progress(&tool_start);
2793
+ assert_eq!(
2794
+ state.swarm_agents[0].current_action.as_deref(),
2795
+ Some("Running `cargo test`")
2796
+ );
2797
+ // Subagent tools must NOT be pushed to the main transcript or tools list!
2798
+ assert!(
2799
+ state.transcript.is_empty(),
2800
+ "transcript must stay clean without subagent tool spam"
2801
+ );
2802
+ assert!(
2803
+ state.tools.is_empty(),
2804
+ "tools list must not include subagent micro-tools"
2805
+ );
2806
+
2807
+ let tool_end = mint_core::AgentProgress::ToolEnd {
2808
+ call_id: Some("call-1".to_string()),
2809
+ action: "run_shell".to_string(),
2810
+ input: serde_json::json!({"command": "cargo test"}),
2811
+ result: "test result ok".to_string(),
2812
+ status: Some(mint_core::integrations::mcp_result::ToolStatus::Success),
2813
+ subagent: Some("worker".to_string()),
2814
+ elapsed_ms: Some(120),
2815
+ };
2816
+ state.record_tool_progress(&tool_end);
2817
+ assert!(state.transcript.is_empty());
2818
+ assert!(state.tools.is_empty());
2819
+
2820
+ let finish_event = mint_core::AgentProgress::SubagentFinished {
2821
+ subagent: "worker".to_string(),
2822
+ status: "completed".to_string(),
2823
+ elapsed_ms: 4500,
2824
+ steps: 3,
2825
+ summary_preview: "All queries optimized".to_string(),
2826
+ };
2827
+ state.record_tool_progress(&finish_event);
2828
+ assert_eq!(state.swarm_agents[0].status, SwarmUiStatus::Success);
2829
+ assert_eq!(state.swarm_agents[0].finished_elapsed_ms, Some(4500));
2830
+ assert_eq!(
2831
+ state.swarm_agents[0].summary_preview.as_deref(),
2832
+ Some("All queries optimized")
2833
+ );
2834
+ assert!(state.transcript.is_empty());
2835
+ }
2836
+
2837
+ #[test]
2838
+ fn swarm_panel_renders_smoothly_in_plan_mode() {
2839
+ let mut state = ChatViewState::default();
2840
+ state.plan_mode = true;
2841
+ state.swarm_agents.push(SwarmAgentUiState {
2842
+ label: "recon".to_string(),
2843
+ profile: "explorer".to_string(),
2844
+ task: "Investigating schema for migration".to_string(),
2845
+ current_action: Some("Reading `schema.rs`".to_string()),
2846
+ status: SwarmUiStatus::Running,
2847
+ started_at: std::time::Instant::now(),
2848
+ finished_elapsed_ms: None,
2849
+ summary_preview: None,
2850
+ });
2851
+
2852
+ let backend = ratatui::backend::TestBackend::new(100, 30);
2853
+ let mut terminal = Terminal::new(backend).unwrap();
2854
+ terminal
2855
+ .draw(|frame| {
2856
+ state.render(frame);
2857
+ })
2858
+ .unwrap();
2859
+ let text = terminal
2860
+ .backend()
2861
+ .buffer()
2862
+ .content
2863
+ .iter()
2864
+ .map(|c| c.symbol())
2865
+ .collect::<String>();
2866
+ assert!(text.contains("Running Subagents"));
2867
+ assert!(text.contains("[recon]"));
2868
+ assert!(text.contains("Reading `schema.rs`"));
2869
+ assert!(text.contains("[Plan]"));
2870
+ }
2871
+
2872
+ #[test]
2873
+ fn tool_viewer_smart_jumps_to_web_search_when_sources_present() {
2874
+ use crate::interactive::tool_activity::ToolActivity;
2875
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2876
+ let mut state = ChatViewState::default();
2877
+ // Tool 0: web_search
2878
+ state.tools.push(ToolActivity {
2879
+ entry: 0,
2880
+ call_id: Some("call_web".into()),
2881
+ action: "web_search".into(),
2882
+ input: serde_json::json!({"query": "cosplay 4khd"}),
2883
+ subagent: None,
2884
+ started: std::time::Instant::now(),
2885
+ elapsed: Some(std::time::Duration::from_millis(1200)),
2886
+ result: Some("1. 4KHD Cosplay\n URL: https://4khd.com\n Preview text".into()),
2887
+ status: Some(mint_core::integrations::mcp_result::ToolStatus::Success),
2888
+ });
2889
+ // Tool 1: run_shell (ran afterwards)
2890
+ state.tools.push(ToolActivity {
2891
+ entry: 1,
2892
+ call_id: Some("call_shell".into()),
2893
+ action: "run_shell".into(),
2894
+ input: serde_json::json!({"command": "echo done"}),
2895
+ subagent: None,
2896
+ started: std::time::Instant::now(),
2897
+ elapsed: Some(std::time::Duration::from_millis(500)),
2898
+ result: Some("exit: 0\nstdout:\ndone\nstderr:".into()),
2899
+ status: Some(mint_core::integrations::mcp_result::ToolStatus::Success),
2900
+ });
2901
+
2902
+ let make_key = |code: KeyCode, modifiers: KeyModifiers| KeyEvent::new(code, modifiers);
2903
+
2904
+ // Without Sources notice, Ctrl+O opens the latest tool (index 1: run_shell)
2905
+ let key = make_key(KeyCode::Char('o'), KeyModifiers::CONTROL);
2906
+ assert!(state.handle_tool_key(key));
2907
+ assert_eq!(state.tool_viewer, Some(1));
2908
+ state.handle_tool_key(make_key(KeyCode::Esc, KeyModifiers::NONE));
2909
+ assert_eq!(state.tool_viewer, None);
2910
+
2911
+ // When Sources notice is in recent transcript, Ctrl+O smart jumps to web_search (index 0)
2912
+ state.push_notice("Sources:\n1. 4KHD Cosplay\n https://4khd.com\n … [+10 more sources] · Ctrl+O view web_search");
2913
+ state.push_notice("─ Worked for 10s • test ──────────────────────────────");
2914
+
2915
+ assert!(state.handle_tool_key(key));
2916
+ assert_eq!(state.tool_viewer, Some(0));
2917
+ assert_eq!(state.tools[state.tool_viewer.unwrap()].action, "web_search");
2918
+ }
2919
+
2920
+ #[test]
2921
+ fn format_tui_web_sources_renders_with_muted_and_blue_spans_in_transcript_entry() {
2922
+ let sources: Vec<(String, String)> = (1..=10)
2923
+ .map(|i| (format!("Title {i}"), format!("https://example.com/{i}")))
2924
+ .collect();
2925
+ let formatted = crate::agent::format_tui_web_sources(&sources, 5);
2926
+ let entry = TranscriptEntry::new(TranscriptRole::Notice, formatted);
2927
+
2928
+ let more_line = entry
2929
+ .rendered_lines
2930
+ .iter()
2931
+ .find(|line| {
2932
+ line.spans
2933
+ .iter()
2934
+ .any(|s| s.content.contains("more sources] · "))
2935
+ })
2936
+ .expect("more sources line should be present");
2937
+
2938
+ let muted_span = more_line
2939
+ .spans
2940
+ .iter()
2941
+ .find(|s| s.content.contains("more sources] · "))
2942
+ .expect("muted span found");
2943
+ assert_eq!(muted_span.style.fg, Some(crate::terminal_theme::MUTED));
2944
+
2945
+ let blue_span = more_line
2946
+ .spans
2947
+ .iter()
2948
+ .find(|s| s.content.contains("Ctrl+O view web_search"))
2949
+ .expect("blue span found");
2950
+ assert_eq!(blue_span.style.fg, Some(crate::terminal_theme::BLUE));
2951
+ }
2952
+
2953
+ #[test]
2954
+ fn transcript_entry_lines_for_width_reflows_markdown_tables() {
2955
+ let md = "| Module | Description |\n| --- | --- |\n| chat_tui | Full-screen Ratatui interactive view |\n| slash_commands | Interactive slash command router and handlers |";
2956
+ let entry = TranscriptEntry::new_with_width(TranscriptRole::Assistant, md, 120);
2957
+ let wide_lines = entry.lines_for_width(120);
2958
+ let narrow_lines = entry.lines_for_width(35);
2959
+ assert_ne!(
2960
+ wide_lines.as_ref(),
2961
+ narrow_lines.as_ref(),
2962
+ "markdown table should reflow when terminal width narrows"
2963
+ );
2964
+ }
2965
+
2966
+ #[test]
2967
+ fn resume_picker_core_supports_space_preview_and_ctrl_r_rename_state() {
2968
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2969
+ let session = mint_core::ChatSession {
2970
+ id: "s1".to_string(),
2971
+ title: "Original Title".to_string(),
2972
+ kind: "chat".to_string(),
2973
+ workspace_path: Some("/workspace/mint".to_string()),
2974
+ git_branch: Some("main".to_string()),
2975
+ main_language: Some("rust".to_string()),
2976
+ updated_at: "2026-01-01 00:00:00".to_string(),
2977
+ created_at: "2026-01-01 00:00:00".to_string(),
2978
+ message_count: 2,
2979
+ total_bytes: 128,
2980
+ };
2981
+ let (tx, _rx) = mpsc::channel();
2982
+ let mut dialog = DialogState::new_resume_picker(
2983
+ vec![session],
2984
+ "/workspace/mint".to_string(),
2985
+ Some("main".to_string()),
2986
+ "other".to_string(),
2987
+ tx,
2988
+ );
2989
+ let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
2990
+ assert_eq!(dialog.handle_key(space), None);
2991
+ assert!(dialog.resume_picker.as_ref().unwrap().showing_preview);
2992
+ assert_eq!(dialog.handle_key(space), None);
2993
+ assert!(!dialog.resume_picker.as_ref().unwrap().showing_preview);
2994
+
2995
+ let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
2996
+ assert_eq!(dialog.handle_key(ctrl_r), None);
2997
+ assert!(dialog.resume_picker.as_ref().unwrap().renaming);
2998
+ let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
2999
+ assert_eq!(dialog.handle_key(esc), None);
3000
+ assert!(!dialog.resume_picker.as_ref().unwrap().renaming);
3001
+ }
3002
+
3003
+ #[test]
3004
+ fn progressive_assistant_reveal_streams_lines_and_appends_notices_on_completion() {
3005
+ let mut state = ChatViewState::default();
3006
+ state.push_user("Explain and show table".into());
3007
+
3008
+ // Stream an intro line plus an incomplete table
3009
+ state.append_assistant_delta("First line\n| A | B |\n| - | - |\n");
3010
+ assert_eq!(state.transcript.len(), 2);
3011
+ assert_eq!(state.transcript[1].role, TranscriptRole::Assistant);
3012
+ assert_eq!(state.transcript[1].text, "First line");
3013
+
3014
+ // User scrolls up while streaming; scroll_from_bottom should be respected
3015
+ state.scroll_from_bottom = 5;
3016
+
3017
+ // Finish turn with authoritative summary + post-turn notices (Sources + Worked line)
3018
+ state.begin_assistant_reveal(
3019
+ "First line\n| A | B |\n| - | - |\n| 1 | 2 |\nFinal line".into(),
3020
+ vec![
3021
+ "Sources:\n1. Example\n https://example.com".into(),
3022
+ "─ Worked for 2s • test".into(),
3023
+ ],
3024
+ );
3025
+
3026
+ // First tick inside begin_assistant_reveal reveals the completed table block
3027
+ assert_eq!(
3028
+ state.transcript[1].text,
3029
+ "First line\n| A | B |\n| - | - |\n| 1 | 2 |"
3030
+ );
3031
+ assert_eq!(state.scroll_from_bottom, 5);
3032
+ assert!(state.has_pending_assistant_reveal());
3033
+
3034
+ // Next tick reveals Final line and flushes the notices
3035
+ assert!(state.tick_assistant_reveal());
3036
+ assert!(!state.has_pending_assistant_reveal());
3037
+ assert_eq!(
3038
+ state.transcript[1].text,
3039
+ "First line\n| A | B |\n| - | - |\n| 1 | 2 |\nFinal line"
3040
+ );
3041
+ assert_eq!(state.transcript.len(), 4);
3042
+ assert_eq!(state.transcript[2].role, TranscriptRole::Notice);
3043
+ assert!(state.transcript[2].text.starts_with("Sources:"));
3044
+ assert_eq!(state.transcript[3].role, TranscriptRole::Notice);
3045
+ assert!(state.transcript[3].text.contains("Worked for 2s"));
3046
+ }
3047
+
3048
+ #[test]
3049
+ fn progressive_assistant_reveal_inserts_mid_turn_notices_before_active_assistant_entry() {
3050
+ let mut state = ChatViewState::default();
3051
+ state.push_user("Question".into());
3052
+ state.append_assistant_delta("Line 1\n");
3053
+ assert_eq!(state.transcript.len(), 2);
3054
+ assert_eq!(state.transcript[1].role, TranscriptRole::Assistant);
3055
+
3056
+ // When ExtendedThinking finishes right after streaming chunks, its notice should sit above Assistant
3057
+ state.push_notice("Thought · 1.2s (Ctrl+T to view)");
3058
+ assert_eq!(state.transcript.len(), 3);
3059
+ assert_eq!(state.transcript[1].role, TranscriptRole::Notice);
3060
+ assert_eq!(state.transcript[1].text, "Thought · 1.2s (Ctrl+T to view)");
3061
+ assert_eq!(state.transcript[2].role, TranscriptRole::Assistant);
3062
+ assert_eq!(state.transcript[2].text, "Line 1");
3063
+
3064
+ state.begin_assistant_reveal("Line 1\nLine 2".into(), vec!["─ Worked for 1s".into()]);
3065
+ assert!(!state.has_pending_assistant_reveal());
3066
+ assert_eq!(state.transcript[2].text, "Line 1\nLine 2");
3067
+ assert_eq!(state.transcript[3].role, TranscriptRole::Notice);
3068
+ assert_eq!(state.transcript[3].text, "─ Worked for 1s");
3069
+ }
3070
+
3071
+ #[test]
3072
+ fn release_notes_renders_as_framed_inline_card_with_background() {
3073
+ let sample = "# Release Notes - Mint Agent v1.18.0\n\n## Smart Thinking\n- Item 1\n\n## Section 2\n- Item 2";
3074
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
3075
+ let tui = ChatTui::handle(state.clone());
3076
+ tui.push_release_notes(sample);
3077
+
3078
+ let locked = state.lock().unwrap();
3079
+ assert_eq!(locked.transcript.len(), 1);
3080
+ assert_eq!(locked.transcript[0].role, TranscriptRole::ReleaseNotes);
3081
+
3082
+ let lines = locked.plain_transcript_lines();
3083
+ let plain_joined = lines.join("\n");
3084
+ assert!(
3085
+ !plain_joined.contains("Command ›"),
3086
+ "must not display Command › header"
3087
+ );
3088
+ assert!(
3089
+ plain_joined.contains("Release Notes"),
3090
+ "must mention Release Notes"
3091
+ );
3092
+ assert!(plain_joined.contains("v1.18.0"), "must mention version");
3093
+ assert!(
3094
+ plain_joined.contains("Smart Thinking"),
3095
+ "must show section heading"
3096
+ );
3097
+
3098
+ let formatted = locked.transcript_text(80);
3099
+ let formatted_text = formatted
3100
+ .lines
3101
+ .iter()
3102
+ .map(|l| l.to_string())
3103
+ .collect::<Vec<_>>()
3104
+ .join("\n");
3105
+ assert!(
3106
+ !formatted_text.contains('╭') && !formatted_text.contains('┌'),
3107
+ "must not render top border characters"
3108
+ );
3109
+ assert!(
3110
+ !formatted_text.contains('╰') && !formatted_text.contains('└'),
3111
+ "must not render bottom border characters"
3112
+ );
3113
+ assert!(
3114
+ !formatted_text.contains('│'),
3115
+ "must not render side border characters"
3116
+ );
3117
+
3118
+ use ratatui::widgets::Widget;
3119
+ let layout = TranscriptLayout::new(formatted.clone(), 80);
3120
+ let p = layout.paragraph_at(0);
3121
+ let mut final_buf = Buffer::empty(Rect::new(0, 0, 80, 11));
3122
+ p.render(Rect::new(0, 0, 80, 11), &mut final_buf);
3123
+ for y in 0..11 {
3124
+ assert_eq!(
3125
+ final_buf[(5, y)].style().bg,
3126
+ Some(ratatui::style::Color::Rgb(64, 64, 64)),
3127
+ "row {y} must have continuous solid gray background"
3128
+ );
3129
+ }
3130
+
3131
+ let has_card_bg = formatted.lines.iter().any(|l| {
3132
+ l.spans
3133
+ .iter()
3134
+ .any(|s| s.style.bg == Some(crate::terminal_theme::CARD_BACKGROUND))
3135
+ });
3136
+ assert!(has_card_bg, "must use gray CARD_BACKGROUND fill");
3137
+ }
3138
+
3139
+ #[test]
3140
+ fn plan_renders_as_borderless_inline_card_with_background() {
3141
+ let sample = "# Implementation Plan: Borderless Plan Card\n\n## Objectives\n- [ ] Task 1: Create TranscriptRole::Plan\n- [x] Task 2: Completed step\n\n## Steps\n1. First step\n2. Second step\n\nFinal note paragraph.";
3142
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
3143
+ let tui = ChatTui::handle(state.clone());
3144
+ tui.push_plan(sample);
3145
+
3146
+ let locked = state.lock().unwrap();
3147
+ assert_eq!(locked.transcript.len(), 1);
3148
+ assert_eq!(locked.transcript[0].role, TranscriptRole::Plan);
3149
+
3150
+ let lines = locked.plain_transcript_lines();
3151
+ let plain_joined = lines.join("\n");
3152
+ assert!(
3153
+ !plain_joined.contains("Command ›"),
3154
+ "must not display Command › header"
3155
+ );
3156
+ assert!(
3157
+ !plain_joined.contains("Mint ›"),
3158
+ "must not display Mint › header"
3159
+ );
3160
+ assert!(
3161
+ plain_joined.contains("Proposed Plan"),
3162
+ "must mention Proposed Plan"
3163
+ );
3164
+ assert!(
3165
+ plain_joined.contains("Borderless Plan Card"),
3166
+ "must mention title"
3167
+ );
3168
+ assert!(
3169
+ plain_joined.contains("Objectives"),
3170
+ "must show section heading"
3171
+ );
3172
+ assert!(
3173
+ plain_joined.contains("[ ]"),
3174
+ "must show unchecked checklist icon"
3175
+ );
3176
+ assert!(
3177
+ plain_joined.contains("[✓]"),
3178
+ "must show checked checklist icon"
3179
+ );
3180
+ assert!(
3181
+ plain_joined.contains("1. First step"),
3182
+ "must show numbered list"
3183
+ );
3184
+
3185
+ let formatted = locked.transcript_text(80);
3186
+ let formatted_text = formatted
3187
+ .lines
3188
+ .iter()
3189
+ .map(|l| l.to_string())
3190
+ .collect::<Vec<_>>()
3191
+ .join("\n");
3192
+ assert!(
3193
+ !formatted_text.contains('╭') && !formatted_text.contains('┌'),
3194
+ "must not render top border characters"
3195
+ );
3196
+ assert!(
3197
+ !formatted_text.contains('╰') && !formatted_text.contains('└'),
3198
+ "must not render bottom border characters"
3199
+ );
3200
+ assert!(
3201
+ !formatted_text.contains('│'),
3202
+ "must not render side border characters"
3203
+ );
3204
+
3205
+ use ratatui::widgets::Widget;
3206
+ let layout = TranscriptLayout::new(formatted.clone(), 80);
3207
+ let p = layout.paragraph_at(0);
3208
+ let card_height = locked.transcript[0].rendered_lines.len() as u16;
3209
+ let mut final_buf = Buffer::empty(Rect::new(0, 0, 80, card_height));
3210
+ p.render(Rect::new(0, 0, 80, card_height), &mut final_buf);
3211
+ for y in 0..card_height {
3212
+ assert_eq!(
3213
+ final_buf[(5, y)].style().bg,
3214
+ Some(ratatui::style::Color::Rgb(64, 64, 64)),
3215
+ "row {y} must have continuous solid gray background"
3216
+ );
3217
+ }
3218
+
3219
+ let has_card_bg = formatted.lines.iter().any(|l| {
3220
+ l.spans
3221
+ .iter()
3222
+ .any(|s| s.style.bg == Some(crate::terminal_theme::CARD_BACKGROUND))
3223
+ });
3224
+ assert!(has_card_bg, "must use gray CARD_BACKGROUND fill");
3225
+ }
3226
+
3227
+ #[test]
3228
+ fn test_enter_and_cancel_edit_turn() {
3229
+ let mut state = ChatViewState::default();
3230
+ state.chat_id = "test-chat".to_string();
3231
+ state.editing_turn = Some((42, 2));
3232
+ state.set_draft("first prompt to edit".to_string());
3233
+ state.set_notice("[Mode: Edit Turn #2 — Resending will replace this turn and clear its previous response | Esc to cancel]");
3234
+ assert!(state.editing_turn.is_some());
3235
+ assert_eq!(state.composer_text(), "first prompt to edit");
3236
+ assert!(
3237
+ state
3238
+ .active_notice()
3239
+ .unwrap()
3240
+ .contains("[Mode: Edit Turn #2")
3241
+ );
3242
+
3243
+ state.cancel_edit_turn();
3244
+ assert!(state.editing_turn.is_none());
3245
+ assert_eq!(state.composer_text(), "");
3246
+ assert!(state.active_notice().is_none());
3247
+ }
3248
+
3249
+ #[test]
3250
+ fn plan_updated_renders_and_updates_checklist_in_tui_and_saved_activity() {
3251
+ let mut state = ChatViewState::default();
3252
+ state.push_user("Help me refactor streaming".to_string());
3253
+
3254
+ let initial_plan = mint_core::ActivePlan {
3255
+ objective: "Refactor Streaming".to_string(),
3256
+ tasks: vec![
3257
+ mint_core::PlanTaskItem {
3258
+ id: "step-1".to_string(),
3259
+ title: "Stream native direct text".to_string(),
3260
+ status: "in_progress".to_string(),
3261
+ },
3262
+ mint_core::PlanTaskItem {
3263
+ id: "step-2".to_string(),
3264
+ title: "Render PlanUpdated in TUI".to_string(),
3265
+ status: "pending".to_string(),
3266
+ },
3267
+ ],
3268
+ };
3269
+ state.record_tool_progress(&mint_core::AgentProgress::PlanUpdated { plan: initial_plan });
3270
+
3271
+ let plain = state.plain_transcript_lines().join("\n");
3272
+ assert!(
3273
+ plain.contains("Plan: Refactor Streaming · 0/2 (0%)"),
3274
+ "expected plan header with 0/2 (0%), got:\n{plain}"
3275
+ );
3276
+ assert!(plain.contains("[>] Stream native direct text"));
3277
+ assert!(plain.contains("[ ] Render PlanUpdated in TUI"));
3278
+
3279
+ let updated_plan = mint_core::ActivePlan {
3280
+ objective: "Refactor Streaming".to_string(),
3281
+ tasks: vec![
3282
+ mint_core::PlanTaskItem {
3283
+ id: "step-1".to_string(),
3284
+ title: "Stream native direct text".to_string(),
3285
+ status: "completed".to_string(),
3286
+ },
3287
+ mint_core::PlanTaskItem {
3288
+ id: "step-2".to_string(),
3289
+ title: "Render PlanUpdated in TUI".to_string(),
3290
+ status: "in_progress".to_string(),
3291
+ },
3292
+ ],
3293
+ };
3294
+ state.record_tool_progress(&mint_core::AgentProgress::PlanUpdated {
3295
+ plan: updated_plan.clone(),
3296
+ });
3297
+
3298
+ // Must update the checklist entry in-place rather than duplicating it
3299
+ assert_eq!(state.transcript.len(), 2);
3300
+ let updated_plain = state.plain_transcript_lines().join("\n");
3301
+ assert!(
3302
+ updated_plain.contains("Plan: Refactor Streaming · 1/2 (50%)"),
3303
+ "expected updated plan header with 1/2 (50%), got:\n{updated_plain}"
3304
+ );
3305
+ assert!(updated_plain.contains("[✓] Stream native direct text"));
3306
+ assert!(updated_plain.contains("[>] Render PlanUpdated in TUI"));
3307
+
3308
+ // Verify replay from saved activity also renders the checklist
3309
+ let mut replayed = ChatViewState::default();
3310
+ let saved = serde_json::to_value(vec![mint_core::AgentProgress::PlanUpdated {
3311
+ plan: updated_plan,
3312
+ }])
3313
+ .unwrap();
3314
+ replayed.push_saved_activity(Some(&saved), "completed");
3315
+ let replayed_plain = replayed.plain_transcript_lines().join("\n");
3316
+ assert!(replayed_plain.contains("Plan: Refactor Streaming · 1/2 (50%)"));
3317
+ assert!(replayed_plain.contains("[✓] Stream native direct text"));
3318
+ }
3319
+
3320
+ #[test]
3321
+ fn record_tool_progress_inserts_before_active_assistant_entry() {
3322
+ let mut state = ChatViewState::default();
3323
+ state.push_user("Check Cargo.toml".to_string());
3324
+ state.append_assistant_delta("Let me check:\nLine 2");
3325
+ assert_eq!(state.active_assistant_entry, Some(1));
3326
+
3327
+ state.record_tool_progress(&mint_core::AgentProgress::ToolStart {
3328
+ call_id: Some("call_1".to_string()),
3329
+ action: "read_file".to_string(),
3330
+ input: serde_json::json!({"path": "Cargo.toml"}),
3331
+ subagent: None,
3332
+ });
3333
+
3334
+ // Tool card should be inserted at index 1, shifting active_assistant_entry to 2
3335
+ assert_eq!(state.tools[0].entry, 1);
3336
+ assert_eq!(state.active_assistant_entry, Some(2));
3337
+ assert_eq!(state.transcript[2].role, TranscriptRole::Assistant);
3338
+ }
3339
+
3340
+ #[test]
3341
+ fn finish_thought_with_mismatched_or_repeated_id_does_not_duplicate_notice() {
3342
+ let state = Arc::new(Mutex::new(ChatViewState::default()));
3343
+ let handle = ChatTui::handle(state.clone());
3344
+
3345
+ // Live thought with id "stream-1" followed by finish_thought with mismatched id "stream-2"
3346
+ handle.append_thought_delta(
3347
+ "stream-1",
3348
+ "initial live reasoning",
3349
+ std::time::Duration::from_millis(900),
3350
+ );
3351
+ handle.finish_thought(
3352
+ Some("stream-2"),
3353
+ "final extended thinking",
3354
+ std::time::Duration::from_millis(1500),
3355
+ );
3356
+
3357
+ // Followed by another finish_thought for the same id "stream-2"
3358
+ handle.finish_thought(
3359
+ Some("stream-2"),
3360
+ "additional internal cot",
3361
+ std::time::Duration::from_millis(1800),
3362
+ );
3363
+
3364
+ let locked = state.lock().unwrap();
3365
+ let notices: Vec<_> = locked
3366
+ .transcript
3367
+ .iter()
3368
+ .filter(|e| e.text.starts_with("Thought · "))
3369
+ .collect();
3370
+ assert_eq!(
3371
+ notices.len(),
3372
+ 1,
3373
+ "expected exactly 1 Thought notice in transcript, got: {:?}",
3374
+ locked
3375
+ .transcript
3376
+ .iter()
3377
+ .map(|e| &e.text)
3378
+ .collect::<Vec<_>>()
3379
+ );
3380
+ let modal = locked.thought_record().expect("thought record");
3381
+ let joined = modal.lines.join("\n");
3382
+ assert!(joined.contains("initial live reasoning"));
3383
+ assert!(joined.contains("final extended thinking"));
3384
+ assert!(joined.contains("additional internal cot"));
3385
+ }
3386
+
3387
+ #[test]
3388
+ fn refresh_tool_card_preserves_full_subject_beyond_80_columns_in_plain_and_selected_text() {
3389
+ let mut state = ChatViewState::default();
3390
+ let long_path = "crates/mint-cli/src/interactive/chat_tui/very_long_directory_name/deeply_nested_module_file_name_beyond_eighty_chars.rs";
3391
+ assert!(long_path.len() > 100);
3392
+
3393
+ state.record_tool_progress(&mint_core::AgentProgress::ToolStart {
3394
+ call_id: Some("call_long".to_string()),
3395
+ action: "read_file".to_string(),
3396
+ input: serde_json::json!({ "path": long_path }),
3397
+ subagent: None,
3398
+ });
3399
+
3400
+ let plain = state.plain_transcript_lines().join("\n");
3401
+ assert!(
3402
+ plain.contains(long_path),
3403
+ "expected plain_transcript_lines to preserve full path > 80 cols, got:\n{plain}"
3404
+ );
3405
+
3406
+ state.selection_anchor = 0;
3407
+ state.selection_head = 2;
3408
+ let selected = state.selected_text();
3409
+ assert!(
3410
+ selected.contains(long_path),
3411
+ "expected selected_text to preserve full path > 80 cols, got:\n{selected}"
3412
+ );
3413
+ }
3414
+ }