@pheem49/mint 1.14.0 → 1.15.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 (585) hide show
  1. package/CLAUDE.md +1 -1
  2. package/Cargo.lock +291 -11
  3. package/Cargo.toml +1 -1
  4. package/README.md +71 -9
  5. package/ROADMAP.md +30 -0
  6. package/Release_Note.md +579 -465
  7. package/crates/mint-cli/Cargo.toml +3 -1
  8. package/crates/mint-cli/src/agent/approval_prompts.rs +16 -3
  9. package/crates/mint-cli/src/agent/live_status.rs +367 -118
  10. package/crates/mint-cli/src/agent/markdown_render.rs +601 -61
  11. package/crates/mint-cli/src/agent.rs +452 -215
  12. package/crates/mint-cli/src/commands/agent.rs +16 -21
  13. package/crates/mint-cli/src/commands/eval.rs +9 -5
  14. package/crates/mint-cli/src/commands/git.rs +204 -0
  15. package/crates/mint-cli/src/commands/integrations.rs +28 -0
  16. package/crates/mint-cli/src/commands/knowledge.rs +1 -0
  17. package/crates/mint-cli/src/commands/mod.rs +37 -2
  18. package/crates/mint-cli/src/commands/tailnet_web.rs +235 -0
  19. package/crates/mint-cli/src/image.rs +26 -3
  20. package/crates/mint-cli/src/interactive/chat_tui.rs +4742 -0
  21. package/crates/mint-cli/src/interactive/command_ui.rs +477 -0
  22. package/crates/mint-cli/src/interactive/format.rs +8 -0
  23. package/crates/mint-cli/src/interactive/input_box.rs +38 -2
  24. package/crates/mint-cli/src/interactive/picker.rs +180 -37
  25. package/crates/mint-cli/src/interactive/resume_picker.rs +686 -0
  26. package/crates/mint-cli/src/interactive/slash_commands.rs +868 -450
  27. package/crates/mint-cli/src/interactive/thought_viewer.rs +286 -0
  28. package/crates/mint-cli/src/interactive.rs +400 -136
  29. package/crates/mint-cli/src/main.rs +84 -23
  30. package/crates/mint-cli/src/markdown.rs +12 -5
  31. package/crates/mint-cli/src/onboard.rs +32 -30
  32. package/crates/mint-cli/src/plugins_cli.rs +8 -5
  33. package/crates/mint-cli/src/setup.rs +10 -8
  34. package/crates/mint-cli/src/terminal_theme.rs +187 -0
  35. package/crates/mint-cli/src/updater.rs +56 -21
  36. package/crates/mint-core/src/agent/agent_loop.rs +1 -0
  37. package/crates/mint-core/src/agent/chat.rs +843 -177
  38. package/crates/mint-core/src/agent/code_tools.rs +83 -2
  39. package/crates/mint-core/src/agent/memory.rs +658 -20
  40. package/crates/mint-core/src/agent/skills.rs +1 -1
  41. package/crates/mint-core/src/api_server/routes/chat.rs +31 -5
  42. package/crates/mint-core/src/api_server/routes/linked_folders.rs +118 -3
  43. package/crates/mint-core/src/api_server/routes/misc.rs +10 -14
  44. package/crates/mint-core/src/api_server/routes/sessions.rs +44 -0
  45. package/crates/mint-core/src/api_server.rs +22 -3
  46. package/crates/mint-core/src/avatar_bridge.rs +2 -0
  47. package/crates/mint-core/src/browser/cdp.rs +74 -83
  48. package/crates/mint-core/src/browser/evidence.rs +260 -0
  49. package/crates/mint-core/src/browser/input.rs +1 -1
  50. package/crates/mint-core/src/browser/interact.rs +98 -124
  51. package/crates/mint-core/src/browser/lifecycle.rs +75 -4
  52. package/crates/mint-core/src/browser/mod.rs +16 -2
  53. package/crates/mint-core/src/browser/navigate.rs +27 -19
  54. package/crates/mint-core/src/browser/observe.js +40 -0
  55. package/crates/mint-core/src/browser/session.rs +1558 -0
  56. package/crates/mint-core/src/git/branches.rs +348 -0
  57. package/crates/mint-core/src/git/checkpoint.rs +40 -0
  58. package/crates/mint-core/src/git/diff.rs +206 -0
  59. package/crates/mint-core/src/git/mod.rs +10 -2
  60. package/crates/mint-core/src/integrations/channels.rs +1 -0
  61. package/crates/mint-core/src/lib.rs +22 -14
  62. package/crates/mint-core/src/live_sync.rs +28 -9
  63. package/crates/mint-core/src/media/auto_shorts.rs +1 -0
  64. package/crates/mint-core/src/media/gemini_live.rs +12 -1
  65. package/crates/mint-core/src/media/mic_transcribe.rs +1 -0
  66. package/crates/mint-core/src/media/subtitle.rs +1 -0
  67. package/crates/mint-core/src/orchestration/context_render.rs +1 -0
  68. package/crates/mint-core/src/orchestration/memory_skill.rs +6 -1
  69. package/crates/mint-core/src/orchestration/mod.rs +1024 -271
  70. package/crates/mint-core/src/orchestration/tools/browser.rs +16 -139
  71. package/crates/mint-core/src/orchestration/tools/misc.rs +2 -1
  72. package/crates/mint-core/src/orchestration/tools/web.rs +1 -1
  73. package/crates/mint-core/src/prompts/agent.rs +58 -29
  74. package/crates/mint-core/src/prompts/chat.rs +1 -1
  75. package/crates/mint-core/src/prompts/persona.rs +1 -1
  76. package/crates/mint-core/src/prompts/tool_catalog.rs +93 -25
  77. package/crates/mint-core/src/search/linked_folders/index.rs +732 -0
  78. package/crates/mint-core/src/search/linked_folders.rs +391 -130
  79. package/crates/mint-core/src/slash/mod.rs +470 -22
  80. package/crates/mint-core/src/slash/model_fetcher.rs +12 -3
  81. package/crates/mint-core/src/slash/models.rs +66 -10
  82. package/crates/mint-core/src/system/config.rs +230 -0
  83. package/crates/mint-core/src/system/workspace.rs +228 -0
  84. package/crates/mint-core/src/system.rs +1 -0
  85. package/crates/mint-core/tests/memory_persistence.rs +39 -0
  86. package/mint-presentation-demo.mp4 +0 -0
  87. package/out/web/assets/CliSessionsView-BxUVWEEL.js +1 -0
  88. package/out/web/assets/CliSessionsView-CBDTXuQv.css +1 -0
  89. package/out/web/assets/CodeReviewPage-DkMgfcos.css +1 -0
  90. package/out/web/assets/CodeReviewPage-jKlmLJwT.js +3 -0
  91. package/out/web/assets/CommandPalette-DnwPA5hy.js +1 -0
  92. package/out/web/assets/ImageStudioPanel-BR1XFwhR.js +1 -0
  93. package/out/web/assets/ImageStudioPanel-D0lnhQV5.css +1 -0
  94. package/out/web/assets/LinkedFoldersView-bi-CWQ-I.js +1 -0
  95. package/out/web/assets/McpRegistryPicker-D5tEFnyq.js +1 -0
  96. package/out/web/assets/McpServersView-BGG1dhFM.js +1 -0
  97. package/out/web/assets/MintDashboard-BIglDEWX.js +20 -0
  98. package/out/web/assets/MintDashboard-DSMywSgs.css +1 -0
  99. package/out/web/assets/ModelPanel-CbJ1PFoZ.js +1 -0
  100. package/out/web/assets/PicturesLibrary-CQP0fLP1.css +1 -0
  101. package/out/web/assets/PicturesLibrary-DoYN7T1p.js +1 -0
  102. package/out/web/assets/PluginsView-CU1HWXGk.js +1 -0
  103. package/out/web/assets/ScheduledTasksView-Bnl36y2N.js +1 -0
  104. package/out/web/assets/SettingsWindow-DhWLAn9T.css +1 -0
  105. package/out/web/assets/SettingsWindow-Dw8L01pw.js +2 -0
  106. package/out/web/assets/SkillsView-cnl8aY5p.js +2 -0
  107. package/out/web/assets/ToolSurfacePage-CgsQw6l5.css +1 -0
  108. package/out/web/assets/ToolSurfacePage-Cz2VJAPZ.js +37 -0
  109. package/out/web/assets/VeoStudioPanel-D4JnERWR.js +4 -0
  110. package/out/web/assets/VeoStudioPanel-DOj7_WR3.css +1 -0
  111. package/out/web/assets/WorkspacePanel-CbJ1PFoZ.js +1 -0
  112. package/out/web/assets/{abnfDiagram-VCTEODGH-DAOBA7ZG.js → abnfDiagram-VCTEODGH-ByMyfanq.js} +1 -1
  113. package/out/web/assets/{architecture-7GRP2DOG-CgSwQOPQ.js → architecture-7GRP2DOG-CLq2kEOB.js} +1 -1
  114. package/out/web/assets/{architectureDiagram-5GKGNRK7-CYp3yupP.js → architectureDiagram-5GKGNRK7-6PoVSmNG.js} +1 -1
  115. package/out/web/assets/{blockDiagram-NRAW4CY4-CyWybDWr.js → blockDiagram-NRAW4CY4-cKw9mAKi.js} +1 -1
  116. package/out/web/assets/{c4Diagram-UCG6FXSJ-BvY3U709.js → c4Diagram-UCG6FXSJ-Cqy8N5Ge.js} +1 -1
  117. package/out/web/assets/{chunk-75Z2AOVW-TLXCoZYv.js → chunk-75Z2AOVW-3qybijF5.js} +1 -1
  118. package/out/web/assets/chunk-CKBBP62Z-cIrXq6jN.js +2 -0
  119. package/out/web/assets/{chunk-CLGD4ZFX-xjsKtNS7.js → chunk-CLGD4ZFX-CndDQkww.js} +1 -1
  120. package/out/web/assets/chunk-DSNCTWBM-Cc4NxvgD.js +2 -0
  121. package/out/web/assets/chunk-DU6HZSFF-drzh6GsU.js +128 -0
  122. package/out/web/assets/{chunk-F27PBJKO-BFpgF55A.js → chunk-F27PBJKO-FiY35l4v.js} +1 -1
  123. package/out/web/assets/{chunk-G27WJ6UU-DfF7qW-C.js → chunk-G27WJ6UU-Bjt9Il4A.js} +1 -1
  124. package/out/web/assets/{chunk-GMAD6QVW-C-558kh_.js → chunk-GMAD6QVW-Bppi3Ctd.js} +1 -1
  125. package/out/web/assets/{chunk-JQ64N6SF-wJmeGKAw.js → chunk-JQ64N6SF-DGg5kcEV.js} +1 -1
  126. package/out/web/assets/{chunk-LCL6LL3I-C1wHD8Ie.js → chunk-LCL6LL3I-CCYcjd7p.js} +1 -1
  127. package/out/web/assets/{chunk-OBVCFTLP-BsRTPBqs.js → chunk-OBVCFTLP-DJeNil5M.js} +1 -1
  128. package/out/web/assets/{chunk-P2QGCYS3-HZIiM7Ni.js → chunk-P2QGCYS3-lRzkRjBB.js} +1 -1
  129. package/out/web/assets/{chunk-POPQ4Y6H-Dk9wvVb0.js → chunk-POPQ4Y6H-DouASMBW.js} +1 -1
  130. package/out/web/assets/{chunk-PWAF6VOD-BmjYLNbq.js → chunk-PWAF6VOD-DMmD1vVf.js} +1 -1
  131. package/out/web/assets/{chunk-R7TYR2AO-CaHIf-lK.js → chunk-R7TYR2AO-CYLA5UC5.js} +1 -1
  132. package/out/web/assets/{chunk-RHFEMEQ7-_T561hOU.js → chunk-RHFEMEQ7-BiXwEcUT.js} +1 -1
  133. package/out/web/assets/{chunk-SVP7TREG-CM-GGYoY.js → chunk-SVP7TREG-BV-zPslF.js} +1 -1
  134. package/out/web/assets/{chunk-TEH6E4GO-K2A8Vg2C.js → chunk-TEH6E4GO-B2RGEohZ.js} +1 -1
  135. package/out/web/assets/classDiagram-DTDB5LWJ-Dq0smRWk.js +1 -0
  136. package/out/web/assets/classDiagram-v2-JRS7N3AN-Dq0smRWk.js +1 -0
  137. package/out/web/assets/config-C-ZFtipU.css +1 -0
  138. package/out/web/assets/config-eEDngJXO.js +1 -0
  139. package/out/web/assets/core-hQ2VA9Ti.js +1 -0
  140. package/out/web/assets/{cynefin-OW5HDTMX-ClzaB6cc.js → cynefin-OW5HDTMX-Cas4csHM.js} +1 -1
  141. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-D_JzZBzf.js → cynefinDiagram-5FMLGOSQ-Bbpj66Y3.js} +1 -1
  142. package/out/web/assets/{dagre-3AP2YEHR-BDuUeMHj.js → dagre-3AP2YEHR-B0-2RDEd.js} +1 -1
  143. package/out/web/assets/{diagram-S7CK7UJ4-DgtcfE8P.js → diagram-S7CK7UJ4-BdzEjUyH.js} +1 -1
  144. package/out/web/assets/{diagram-UQ7AKVKN-DTLoEYti.js → diagram-UQ7AKVKN-CJDwyPxF.js} +1 -1
  145. package/out/web/assets/{diagram-VSXAHHWV-ChGl9pXz.js → diagram-VSXAHHWV-BxaG1r_B.js} +1 -1
  146. package/out/web/assets/{diagram-VX7I27RA-Be8DWz0S.js → diagram-VX7I27RA-BMmUqm8f.js} +1 -1
  147. package/out/web/assets/{diagram-Z3DM3KII-BMXjRTOF.js → diagram-Z3DM3KII-CFtd03eE.js} +1 -1
  148. package/out/web/assets/dist-js-Bf2bhoku.js +1 -0
  149. package/out/web/assets/{ebnfDiagram-PWID7BFC-8Q9pl1CJ.js → ebnfDiagram-PWID7BFC-DwAen1hO.js} +1 -1
  150. package/out/web/assets/{erDiagram-SSCWMZ5O-DjH8Lnkn.js → erDiagram-SSCWMZ5O-BCZSHACj.js} +1 -1
  151. package/out/web/assets/event-ChBVpRy0.js +1 -0
  152. package/out/web/assets/{eventmodeling-NTZA5JFV-BKUH_DOp.js → eventmodeling-NTZA5JFV-XHStE4E_.js} +1 -1
  153. package/out/web/assets/fira-code-latin-600-normal-BmSSHshu.woff +0 -0
  154. package/out/web/assets/fira-code-latin-600-normal-CvYZpPHg.woff2 +0 -0
  155. package/out/web/assets/fira-code-symbols2-600-normal-Dvq6IQE5.woff +0 -0
  156. package/out/web/assets/flowDiagram-A5DVABFB-BLg7nEcd.js +1 -0
  157. package/out/web/assets/{ganttDiagram-EL5Y4UJY-D7fZKJNM.js → ganttDiagram-EL5Y4UJY-BAEu1Cql.js} +1 -1
  158. package/out/web/assets/{gitGraph-4MIJSDKK-BypWaaBU.js → gitGraph-4MIJSDKK-r32Vyper.js} +1 -1
  159. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-DWiw_3bq.js → gitGraphDiagram-WWUBYQGX-z9o9qt6V.js} +1 -1
  160. package/out/web/assets/index-Btsf4L8M.css +1 -0
  161. package/out/web/assets/index-D8D9MeRA.js +11 -0
  162. package/out/web/assets/{info-A6RAGUB7-KkAUxT4j.js → info-A6RAGUB7-BvLaC2cw.js} +1 -1
  163. package/out/web/assets/{infoDiagram-RXCK75RN-BNb6E48y.js → infoDiagram-RXCK75RN-Be6WAXXw.js} +1 -1
  164. package/out/web/assets/{ishikawaDiagram-5VMMS53U-Caa-JoAK.js → ishikawaDiagram-5VMMS53U-DZjoHJHC.js} +1 -1
  165. package/out/web/assets/{journeyDiagram-EYS64GPL-DOrmFGz6.js → journeyDiagram-EYS64GPL-CtVkjkGm.js} +1 -1
  166. package/out/web/assets/{kanban-definition-3QL26DDD-BOWCYcK0.js → kanban-definition-3QL26DDD-CW5FMv1w.js} +1 -1
  167. package/out/web/assets/lucide-react-Ip3L6Jtn.js +1 -0
  168. package/out/web/assets/markdown-BSeeUbJH.js +38 -0
  169. package/out/web/assets/{mermaid-parser.core-BQqjCkS_.js → mermaid-parser.core-aUgCqsZ5.js} +3 -3
  170. package/out/web/assets/mermaid.core-D57sEiSL.js +11 -0
  171. package/out/web/assets/{mindmap-definition-FBJOCRG2-jTceWpr0.js → mindmap-definition-FBJOCRG2-DAmSpM3a.js} +1 -1
  172. package/out/web/assets/modelManager-tMVmJVAW.js +2 -0
  173. package/out/web/assets/oauthManager-9jxicDWJ.js +1 -0
  174. package/out/web/assets/{packet-AYTQ26CC-CJ5RXgol.js → packet-AYTQ26CC-DJ27iIoJ.js} +1 -1
  175. package/out/web/assets/{pegDiagram-XKGWAZYB-y8wMtd0y.js → pegDiagram-XKGWAZYB-Bj3ii-gS.js} +1 -1
  176. package/out/web/assets/{pie-WAS4IAKB-72fTBbl4.js → pie-WAS4IAKB-DG5mCUbc.js} +1 -1
  177. package/out/web/assets/{pieDiagram-E7YTZNPT-e0O02qWq.js → pieDiagram-E7YTZNPT-BrJLPnDA.js} +1 -1
  178. package/out/web/assets/prompt-latin-700-normal-BfbftDSq.woff +0 -0
  179. package/out/web/assets/prompt-latin-700-normal-I2gc831J.woff2 +0 -0
  180. package/out/web/assets/prompt-thai-700-normal-Cb5ilR-c.woff +0 -0
  181. package/out/web/assets/prompt-thai-700-normal-Cg4aQ0Nn.woff2 +0 -0
  182. package/out/web/assets/{quadrantDiagram-AXDQQJYC-C9phYLom.js → quadrantDiagram-AXDQQJYC-3x_TmF3j.js} +1 -1
  183. package/out/web/assets/{radar-RG4KPBEZ-BAU2hzUT.js → radar-RG4KPBEZ-CrXaTTuV.js} +1 -1
  184. package/out/web/assets/{railroad-74A4TZTK-B0bCAVHC.js → railroad-74A4TZTK-BpqMH9BE.js} +1 -1
  185. package/out/web/assets/{railroad-abnf-HS5TGJTU-WhxO7ndE.js → railroad-abnf-HS5TGJTU-DHI0uLlV.js} +1 -1
  186. package/out/web/assets/{railroad-ebnf-LZEXJU2U-BkA0m7-F.js → railroad-ebnf-LZEXJU2U-CQCb316y.js} +1 -1
  187. package/out/web/assets/{railroad-peg-WCYAUIDC-BsVpHIaj.js → railroad-peg-WCYAUIDC-Ph1giKkH.js} +1 -1
  188. package/out/web/assets/{railroadDiagram-O6MQD6OU-CyOLN65Q.js → railroadDiagram-O6MQD6OU-BRq6wIxx.js} +1 -1
  189. package/out/web/assets/{requirementDiagram-EFPCY7ZU-DQXJ7Dud.js → requirementDiagram-EFPCY7ZU-DIJj6G1g.js} +1 -1
  190. package/out/web/assets/{sankeyDiagram-P5KCCOFB-B2XLyIIc.js → sankeyDiagram-P5KCCOFB-DZajSryq.js} +1 -1
  191. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-DGQY4wwm.js → sequenceDiagram-WJ2MYXX4-DxJCWyZS.js} +1 -1
  192. package/out/web/assets/{stateDiagram-HBIQ2CUA-DHZpDwNy.js → stateDiagram-HBIQ2CUA-k6voi8Vf.js} +1 -1
  193. package/out/web/assets/stateDiagram-v2-4QOOHH4V-DeEc0lGZ.js +1 -0
  194. package/out/web/assets/studio-B1mk40wD.js +1 -0
  195. package/out/web/assets/{swimlanes-XN3QIQJK-Bn_jcZz-.js → swimlanes-XN3QIQJK-igYhTjXB.js} +1 -1
  196. package/out/web/assets/swimlanesDiagram-VK2B7HYN-DpVik9v7.js +8 -0
  197. package/out/web/assets/{timeline-definition-24CTP7MA-CdTERuJZ.js → timeline-definition-24CTP7MA-Dke2p93p.js} +1 -1
  198. package/out/web/assets/{treeView-Q6P3EWNA-TKBKQG6R.js → treeView-Q6P3EWNA-hNELnXUs.js} +1 -1
  199. package/out/web/assets/{treemap-WGGIJYW6-BP7gQoiC.js → treemap-WGGIJYW6-D2B6wDsx.js} +1 -1
  200. package/out/web/assets/{vennDiagram-4TSXK5OY-BHUmxDuK.js → vennDiagram-4TSXK5OY-CDkW7rUP.js} +1 -1
  201. package/out/web/assets/{wardley-WFR3VGLG-9bct5AAN.js → wardley-WFR3VGLG-BECdXETi.js} +1 -1
  202. package/out/web/assets/{wardleyDiagram-VM6X3IG4-BfJwDDso.js → wardleyDiagram-VM6X3IG4-B5BycpQ4.js} +1 -1
  203. package/out/web/assets/window-5qYXKoW7.js +1 -0
  204. package/out/web/assets/{xychartDiagram-S5SC5T6Z-Dqd9Mvpo.js → xychartDiagram-S5SC5T6Z-CtJS3b--.js} +1 -1
  205. package/out/web/index-web.html +3 -4
  206. package/out/web/index.html +3 -4
  207. package/package.json +17 -6
  208. package/skills-lock.json +36 -0
  209. package/slash-commands.json +9 -0
  210. package/src/renderer/index-web.html +1 -0
  211. package/src/renderer/index.html +1 -0
  212. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  213. package/src/renderer/shared/agentProgress.ts +168 -3
  214. package/src/renderer/shared/components/AgentActivityDrawer.tsx +6 -6
  215. package/src/renderer/shared/components/AgentActivityTable.tsx +192 -132
  216. package/src/renderer/shared/components/ArtifactPreviewPanel.tsx +4 -4
  217. package/src/renderer/shared/components/AuthGate.tsx +4 -9
  218. package/src/renderer/shared/components/CalculationCard.tsx +41 -15
  219. package/src/renderer/shared/components/ChatMessageItem.tsx +20 -21
  220. package/src/renderer/shared/components/ChatPanel.tsx +432 -193
  221. package/src/renderer/shared/components/ChunkErrorBoundary.tsx +198 -49
  222. package/src/renderer/shared/components/CliSessionsView.tsx +495 -0
  223. package/src/renderer/shared/components/CodeReviewPage.tsx +565 -0
  224. package/src/renderer/shared/components/CommandPalette.tsx +8 -8
  225. package/src/renderer/shared/components/DashboardSidebar.tsx +862 -125
  226. package/src/renderer/shared/components/DesktopTitlebar.tsx +345 -0
  227. package/src/renderer/shared/components/DiffReviewModal.tsx +3 -3
  228. package/src/renderer/shared/components/GitBranchSelector.tsx +442 -0
  229. package/src/renderer/shared/components/ImageGenCard.tsx +31 -12
  230. package/src/renderer/shared/components/ImageSearchCard.tsx +59 -38
  231. package/src/renderer/shared/components/ImageStudioPanel.tsx +40 -41
  232. package/src/renderer/shared/components/InlineThoughtNote.tsx +27 -0
  233. package/src/renderer/shared/components/LinkedFoldersView.tsx +365 -46
  234. package/src/renderer/shared/components/McpRegistryPicker.tsx +1 -1
  235. package/src/renderer/shared/components/McpServersView.tsx +24 -22
  236. package/src/renderer/shared/components/MermaidCard.tsx +14 -8
  237. package/src/renderer/shared/components/MintDashboard.tsx +1141 -508
  238. package/src/renderer/shared/components/ModelSelectorPopover.tsx +535 -214
  239. package/src/renderer/shared/components/PlanChecklistWidget.tsx +1 -1
  240. package/src/renderer/shared/components/PluginsView.tsx +1 -1
  241. package/src/renderer/shared/components/RewindModal.tsx +6 -6
  242. package/src/renderer/shared/components/RunSummaryDashboard.tsx +3 -3
  243. package/src/renderer/shared/components/ScheduledTasksView.tsx +4 -4
  244. package/src/renderer/shared/components/ScreenCaptureDialog.css +221 -0
  245. package/src/renderer/shared/components/ScreenCaptureDialog.tsx +199 -0
  246. package/src/renderer/shared/components/Settings/AgentsTab.tsx +34 -39
  247. package/src/renderer/shared/components/Settings/AudioTab.tsx +8 -8
  248. package/src/renderer/shared/components/Settings/AutomationTab.tsx +8 -58
  249. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +368 -0
  250. package/src/renderer/shared/components/Settings/GeneralTab.tsx +335 -81
  251. package/src/renderer/shared/components/Settings/MemoryTab.tsx +3 -3
  252. package/src/renderer/shared/components/Settings/PluginsTab.tsx +20 -12
  253. package/src/renderer/shared/components/Settings/ProfileTab.tsx +29 -5
  254. package/src/renderer/shared/components/Settings/SearchableModelCombobox.tsx +411 -0
  255. package/src/renderer/shared/components/Settings/ThemeTab.tsx +401 -158
  256. package/src/renderer/shared/components/SkillsView.tsx +41 -16
  257. package/src/renderer/shared/components/SlashSuggestions.tsx +3 -2
  258. package/src/renderer/shared/components/SourcesBlock.tsx +44 -32
  259. package/src/renderer/shared/components/StockCard.tsx +58 -24
  260. package/src/renderer/shared/components/SubagentDagView.tsx +4 -4
  261. package/src/renderer/shared/components/TerminalDock.tsx +241 -0
  262. package/src/renderer/shared/components/ThinkingBlock.tsx +14 -8
  263. package/src/renderer/shared/components/ToolSurfacePage.tsx +520 -0
  264. package/src/renderer/shared/components/UiFeatureCard.tsx +267 -0
  265. package/src/renderer/shared/components/UiGridCard.tsx +188 -0
  266. package/src/renderer/shared/components/UiMockupWidget.tsx +149 -0
  267. package/src/renderer/shared/components/VeoStudioPanel.tsx +13 -29
  268. package/src/renderer/shared/components/WeatherCard.tsx +3 -3
  269. package/src/renderer/shared/components/WorkspaceContextPopover.tsx +133 -0
  270. package/src/renderer/shared/components/WorkspaceSelector.tsx +149 -0
  271. package/src/renderer/shared/constants/config.ts +28 -3
  272. package/src/renderer/shared/constants/models.ts +189 -2
  273. package/src/renderer/shared/conversation/coordinator.test.ts +27 -0
  274. package/src/renderer/shared/conversation/coordinator.ts +82 -0
  275. package/src/renderer/shared/conversation/syncView.test.ts +46 -0
  276. package/src/renderer/shared/conversation/syncView.ts +43 -0
  277. package/src/renderer/shared/conversation/useConversationCoordinator.ts +106 -0
  278. package/src/renderer/shared/css/agent-observability.css +35 -3
  279. package/src/renderer/shared/css/auth-gate.css +2 -2
  280. package/src/renderer/shared/css/chat-ui-cards.css +495 -0
  281. package/src/renderer/shared/css/code-review.css +823 -0
  282. package/src/renderer/shared/css/code-sessions.css +452 -0
  283. package/src/renderer/shared/css/file-changes.css +132 -0
  284. package/src/renderer/shared/css/image-studio.css +16 -16
  285. package/src/renderer/shared/css/management-views.css +277 -27
  286. package/src/renderer/shared/css/model-selector.css +442 -42
  287. package/src/renderer/shared/css/settings/agents.css +1 -1
  288. package/src/renderer/shared/css/settings/automation.css +13 -13
  289. package/src/renderer/shared/css/settings/general.css +526 -5
  290. package/src/renderer/shared/css/settings/plugins.css +5 -5
  291. package/src/renderer/shared/css/settings/profile.css +331 -1
  292. package/src/renderer/shared/css/settings/theme.css +651 -3
  293. package/src/renderer/shared/css/veo-studio.css +16 -15
  294. package/src/renderer/shared/fonts.ts +19 -37
  295. package/src/renderer/shared/platform.test.ts +34 -0
  296. package/src/renderer/shared/platform.ts +173 -6
  297. package/src/renderer/shared/theme/themeManager.ts +144 -0
  298. package/src/renderer/shared/theme/themes.ts +116 -0
  299. package/src/renderer/shared/theme/tokens.css +318 -0
  300. package/src/renderer/shared/types.ts +82 -1
  301. package/src/renderer/shared/utils/agentActivity.ts +126 -5
  302. package/src/renderer/shared/utils/cardJson.test.ts +35 -0
  303. package/src/renderer/shared/utils/cardJson.ts +30 -0
  304. package/src/renderer/shared/utils/fileIcons.ts +1 -1
  305. package/src/renderer/shared/utils/markdown.tsx +329 -80
  306. package/src/renderer/shared/utils/markdown.webImages.test.tsx +40 -0
  307. package/src/renderer/shared/utils/providers.ts +8 -3
  308. package/src/renderer/shared/utils/screenCapture.ts +43 -0
  309. package/src/renderer/shared/utils/slashCommandProcessor.ts +4 -1
  310. package/src/renderer/shared/utils/ui.ts +4 -64
  311. package/src/renderer/src/App.tsx +7 -0
  312. package/src/renderer/src/components/BrowserShell.tsx +108 -0
  313. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +91 -0
  314. package/src/renderer/src/components/Live2DStage.tsx +32 -9
  315. package/src/renderer/src/components/MintDashboard.tsx +4 -2
  316. package/src/renderer/src/components/ModelPanel.tsx +2 -1
  317. package/src/renderer/src/components/PicturesLibrary.tsx +3 -2
  318. package/src/renderer/src/components/ScreenPicker.css +190 -0
  319. package/src/renderer/src/components/ScreenPicker.tsx +185 -540
  320. package/src/renderer/src/components/SettingsWindow.tsx +40 -2
  321. package/src/renderer/src/components/SpotlightWindow.tsx +1 -0
  322. package/src/renderer/src/components/WidgetWindow.tsx +27 -3
  323. package/src/renderer/src/components/WorkspacePanel.tsx +61 -43
  324. package/src/renderer/src/components/browser-shell.css +185 -0
  325. package/src/renderer/src/components/browser-suggestions-popup.css +55 -0
  326. package/src/renderer/src/components/liveTranslateFrames.test.ts +23 -0
  327. package/src/renderer/src/components/liveTranslateFrames.ts +34 -0
  328. package/src/renderer/src/css/app-shell.css +394 -113
  329. package/src/renderer/src/css/chat.css +1926 -380
  330. package/src/renderer/src/css/dashboard.css +11 -0
  331. package/src/renderer/src/css/desktop-tools.css +730 -0
  332. package/src/renderer/src/css/misc.css +19 -22
  333. package/src/renderer/src/css/model.css +49 -48
  334. package/src/renderer/src/css/pictures.css +144 -136
  335. package/src/renderer/src/css/settings/base.css +357 -123
  336. package/src/renderer/src/css/sidebar-workspace.css +930 -291
  337. package/src/renderer/src/css/spotlight.css +15 -26
  338. package/src/renderer/src/css/tool-surfaces.css +728 -0
  339. package/src/renderer/src/css/widget.css +35 -7
  340. package/src/renderer/src/env.d.ts +3 -0
  341. package/src/renderer/src/hooks/useProviderModels.ts +2 -9
  342. package/src/renderer/src/index.css +53 -46
  343. package/src/renderer/src/main.tsx +11 -0
  344. package/src/renderer/src/tauri.ts +201 -30
  345. package/src/renderer/src-web/App.tsx +40 -0
  346. package/src/renderer/src-web/components/MintDashboard.tsx +4 -2
  347. package/src/renderer/src-web/components/PicturesLibrary.tsx +3 -2
  348. package/src/renderer/src-web/components/PullToRefresh.tsx +107 -0
  349. package/src/renderer/src-web/components/SettingsWindow.tsx +28 -2
  350. package/src/renderer/src-web/css/app-shell.css +174 -96
  351. package/src/renderer/src-web/css/chat.css +1519 -316
  352. package/src/renderer/src-web/css/dashboard.css +11 -0
  353. package/src/renderer/src-web/css/misc.css +17 -17
  354. package/src/renderer/src-web/css/mobile.css +678 -0
  355. package/src/renderer/src-web/css/model.css +46 -46
  356. package/src/renderer/src-web/css/pictures.css +81 -34
  357. package/src/renderer/src-web/css/pull-to-refresh.css +54 -0
  358. package/src/renderer/src-web/css/settings/base.css +316 -100
  359. package/src/renderer/src-web/css/settings/mobile.css +320 -0
  360. package/src/renderer/src-web/css/sidebar-workspace.css +545 -100
  361. package/src/renderer/src-web/css/spotlight.css +9 -26
  362. package/src/renderer/src-web/css/widget.css +0 -5
  363. package/src/renderer/src-web/env.d.ts +3 -0
  364. package/src/renderer/src-web/hooks/useProviderModels.ts +2 -9
  365. package/src/renderer/src-web/index.css +53 -96
  366. package/src/renderer/src-web/main.tsx +11 -0
  367. package/src/renderer/src-web/tauri.ts +200 -30
  368. package/vite.config.web.ts +1 -0
  369. package/out/web/assets/MintDashboard-DWSqucgb.js +0 -57
  370. package/out/web/assets/SettingsWindow-DFM2aj5B.js +0 -2
  371. package/out/web/assets/chunk-CKBBP62Z-nr1uKPj9.js +0 -2
  372. package/out/web/assets/chunk-DSNCTWBM-Dtj8Rqsd.js +0 -2
  373. package/out/web/assets/chunk-DU6HZSFF-2tv5K1wT.js +0 -128
  374. package/out/web/assets/classDiagram-DTDB5LWJ-BdSFArgw.js +0 -1
  375. package/out/web/assets/classDiagram-v2-JRS7N3AN-BdSFArgw.js +0 -1
  376. package/out/web/assets/core-DImejIUG.js +0 -1
  377. package/out/web/assets/dist-js-BxuKEVWM.js +0 -1
  378. package/out/web/assets/event-BNl8Zahw.js +0 -1
  379. package/out/web/assets/fira-code-cyrillic-400-normal-UC0NFL4U.woff2 +0 -0
  380. package/out/web/assets/fira-code-cyrillic-400-normal-dc1q3VD7.woff +0 -0
  381. package/out/web/assets/fira-code-cyrillic-500-normal-Bu7VCL72.woff +0 -0
  382. package/out/web/assets/fira-code-cyrillic-500-normal-CX35h3Mg.woff2 +0 -0
  383. package/out/web/assets/fira-code-cyrillic-ext-400-normal-Buh61xzB.woff +0 -0
  384. package/out/web/assets/fira-code-cyrillic-ext-400-normal-txZ9Fk_1.woff2 +0 -0
  385. package/out/web/assets/fira-code-cyrillic-ext-500-normal-CYRspHgm.woff2 +0 -0
  386. package/out/web/assets/fira-code-cyrillic-ext-500-normal-CwtLmSFD.woff +0 -0
  387. package/out/web/assets/fira-code-greek-400-normal-B2Gh_Y8s.woff2 +0 -0
  388. package/out/web/assets/fira-code-greek-400-normal-DobhaxvF.woff +0 -0
  389. package/out/web/assets/fira-code-greek-500-normal-CVO0IRNt.woff2 +0 -0
  390. package/out/web/assets/fira-code-greek-500-normal-DEXIWiey.woff +0 -0
  391. package/out/web/assets/fira-code-greek-ext-400-normal-DR7mBgIM.woff2 +0 -0
  392. package/out/web/assets/fira-code-greek-ext-400-normal-DSPUheWg.woff +0 -0
  393. package/out/web/assets/fira-code-greek-ext-500-normal-BO3iq16n.woff2 +0 -0
  394. package/out/web/assets/fira-code-greek-ext-500-normal-CMiNLXER.woff +0 -0
  395. package/out/web/assets/fira-code-latin-ext-400-normal-DG73JjOf.woff +0 -0
  396. package/out/web/assets/fira-code-latin-ext-400-normal-KSMg0QLl.woff2 +0 -0
  397. package/out/web/assets/fira-code-latin-ext-500-normal-CaANqXjI.woff +0 -0
  398. package/out/web/assets/fira-code-latin-ext-500-normal-Crd9e_oI.woff2 +0 -0
  399. package/out/web/assets/flowDiagram-A5DVABFB-BpnHUSU7.js +0 -1
  400. package/out/web/assets/index-CVRg270z.css +0 -1
  401. package/out/web/assets/index-DxZQr9yD.js +0 -10
  402. package/out/web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  403. package/out/web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  404. package/out/web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  405. package/out/web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  406. package/out/web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  407. package/out/web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  408. package/out/web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  409. package/out/web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  410. package/out/web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  411. package/out/web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  412. package/out/web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  413. package/out/web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  414. package/out/web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  415. package/out/web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  416. package/out/web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  417. package/out/web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  418. package/out/web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  419. package/out/web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  420. package/out/web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  421. package/out/web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  422. package/out/web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  423. package/out/web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  424. package/out/web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  425. package/out/web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  426. package/out/web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  427. package/out/web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  428. package/out/web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  429. package/out/web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  430. package/out/web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  431. package/out/web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  432. package/out/web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  433. package/out/web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  434. package/out/web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  435. package/out/web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  436. package/out/web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  437. package/out/web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  438. package/out/web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  439. package/out/web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  440. package/out/web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  441. package/out/web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  442. package/out/web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  443. package/out/web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  444. package/out/web/assets/kanit-latin-400-normal-BUlsQXH3.woff2 +0 -0
  445. package/out/web/assets/kanit-latin-400-normal-HVZWrfev.woff +0 -0
  446. package/out/web/assets/kanit-latin-500-normal-CX_4XcKi.woff2 +0 -0
  447. package/out/web/assets/kanit-latin-500-normal-qBha86O4.woff +0 -0
  448. package/out/web/assets/kanit-latin-600-normal-0lS78yAd.woff2 +0 -0
  449. package/out/web/assets/kanit-latin-600-normal-DlacpeTg.woff +0 -0
  450. package/out/web/assets/kanit-latin-ext-400-normal-B2xPf58u.woff2 +0 -0
  451. package/out/web/assets/kanit-latin-ext-400-normal-CHvNKHHD.woff +0 -0
  452. package/out/web/assets/kanit-latin-ext-500-normal-BFdWrG22.woff +0 -0
  453. package/out/web/assets/kanit-latin-ext-500-normal-EHs3suD6.woff2 +0 -0
  454. package/out/web/assets/kanit-latin-ext-600-normal-BBDzoLaj.woff +0 -0
  455. package/out/web/assets/kanit-latin-ext-600-normal-DAX3wtuq.woff2 +0 -0
  456. package/out/web/assets/kanit-thai-400-normal-D2nliXKa.woff +0 -0
  457. package/out/web/assets/kanit-thai-400-normal-D80Pglro.woff2 +0 -0
  458. package/out/web/assets/kanit-thai-500-normal-BB8gri04.woff2 +0 -0
  459. package/out/web/assets/kanit-thai-500-normal-Iy_w0a80.woff +0 -0
  460. package/out/web/assets/kanit-thai-600-normal-Bj6oiaDc.woff2 +0 -0
  461. package/out/web/assets/kanit-thai-600-normal-wbTlPO7I.woff +0 -0
  462. package/out/web/assets/kanit-vietnamese-400-normal-DRI8YWyb.woff2 +0 -0
  463. package/out/web/assets/kanit-vietnamese-400-normal-dow6Bnto.woff +0 -0
  464. package/out/web/assets/kanit-vietnamese-500-normal-BzTrpCM3.woff2 +0 -0
  465. package/out/web/assets/kanit-vietnamese-500-normal-T1ndT7MF.woff +0 -0
  466. package/out/web/assets/kanit-vietnamese-600-normal-Bi7fw3I4.woff2 +0 -0
  467. package/out/web/assets/kanit-vietnamese-600-normal-DXCRAa_8.woff +0 -0
  468. package/out/web/assets/mali-latin-400-normal-ByPcZ8Gx.woff +0 -0
  469. package/out/web/assets/mali-latin-400-normal-Cuc6Ykl8.woff2 +0 -0
  470. package/out/web/assets/mali-latin-500-normal-B01M-LP6.woff2 +0 -0
  471. package/out/web/assets/mali-latin-500-normal-Thgqw_9f.woff +0 -0
  472. package/out/web/assets/mali-latin-600-normal-B9TiUNpT.woff2 +0 -0
  473. package/out/web/assets/mali-latin-600-normal-CycpQCwU.woff +0 -0
  474. package/out/web/assets/mali-latin-ext-400-normal-DUVdJQO4.woff +0 -0
  475. package/out/web/assets/mali-latin-ext-400-normal-iCG1ALuJ.woff2 +0 -0
  476. package/out/web/assets/mali-latin-ext-500-normal-BfJE4UMZ.woff2 +0 -0
  477. package/out/web/assets/mali-latin-ext-500-normal-FoLEyOKS.woff +0 -0
  478. package/out/web/assets/mali-latin-ext-600-normal-BTvIfiCp.woff2 +0 -0
  479. package/out/web/assets/mali-latin-ext-600-normal-DAQcLVmD.woff +0 -0
  480. package/out/web/assets/mali-thai-400-normal-BO7A3bLZ.woff2 +0 -0
  481. package/out/web/assets/mali-thai-400-normal-CX9VrjTn.woff +0 -0
  482. package/out/web/assets/mali-thai-500-normal-Cph2qha9.woff +0 -0
  483. package/out/web/assets/mali-thai-500-normal-D89rMT77.woff2 +0 -0
  484. package/out/web/assets/mali-thai-600-normal-DPgXT6rZ.woff +0 -0
  485. package/out/web/assets/mali-thai-600-normal-DlTx3OsB.woff2 +0 -0
  486. package/out/web/assets/mali-vietnamese-400-normal-DUKxzXaz.woff2 +0 -0
  487. package/out/web/assets/mali-vietnamese-400-normal-gFDLvsOK.woff +0 -0
  488. package/out/web/assets/mali-vietnamese-500-normal--OY07q2c.woff +0 -0
  489. package/out/web/assets/mali-vietnamese-500-normal-KDrNRwfW.woff2 +0 -0
  490. package/out/web/assets/mali-vietnamese-600-normal-CtTMUU7T.woff +0 -0
  491. package/out/web/assets/mali-vietnamese-600-normal-DFlJr66u.woff2 +0 -0
  492. package/out/web/assets/mermaid.core-DYGGlqOf.js +0 -11
  493. package/out/web/assets/mitr-latin-400-normal-Cb18vPmZ.woff +0 -0
  494. package/out/web/assets/mitr-latin-400-normal-CwbkLQ4a.woff2 +0 -0
  495. package/out/web/assets/mitr-latin-500-normal-B4JOhzAi.woff +0 -0
  496. package/out/web/assets/mitr-latin-500-normal-BmClOow2.woff2 +0 -0
  497. package/out/web/assets/mitr-latin-ext-400-normal-B3CPG7Yo.woff2 +0 -0
  498. package/out/web/assets/mitr-latin-ext-400-normal-DTx65hiC.woff +0 -0
  499. package/out/web/assets/mitr-latin-ext-500-normal-B_Diajz1.woff2 +0 -0
  500. package/out/web/assets/mitr-latin-ext-500-normal-DYOsVaVT.woff +0 -0
  501. package/out/web/assets/mitr-thai-400-normal-CS4Do6aM.woff +0 -0
  502. package/out/web/assets/mitr-thai-400-normal-CXWaAdVj.woff2 +0 -0
  503. package/out/web/assets/mitr-thai-500-normal-CVdBrEVH.woff2 +0 -0
  504. package/out/web/assets/mitr-thai-500-normal-CW_B5D-i.woff +0 -0
  505. package/out/web/assets/mitr-vietnamese-400-normal-CPMGWyoC.woff2 +0 -0
  506. package/out/web/assets/mitr-vietnamese-400-normal-Z6XhIpdY.woff +0 -0
  507. package/out/web/assets/mitr-vietnamese-500-normal-CDrv0585.woff2 +0 -0
  508. package/out/web/assets/mitr-vietnamese-500-normal-CMQWyq-Z.woff +0 -0
  509. package/out/web/assets/noto-sans-thai-latin-400-normal-BaWBrqFP.woff2 +0 -0
  510. package/out/web/assets/noto-sans-thai-latin-400-normal-Dh_FJjOB.woff +0 -0
  511. package/out/web/assets/noto-sans-thai-latin-500-normal-BC04jsmm.woff +0 -0
  512. package/out/web/assets/noto-sans-thai-latin-500-normal-fyBKClF1.woff2 +0 -0
  513. package/out/web/assets/noto-sans-thai-latin-600-normal-CemcjnVR.woff2 +0 -0
  514. package/out/web/assets/noto-sans-thai-latin-600-normal-Dbc-dNO6.woff +0 -0
  515. package/out/web/assets/noto-sans-thai-latin-ext-400-normal-B-1H0YkH.woff +0 -0
  516. package/out/web/assets/noto-sans-thai-latin-ext-400-normal-nBP59pVw.woff2 +0 -0
  517. package/out/web/assets/noto-sans-thai-latin-ext-500-normal-B-ZWb8SZ.woff2 +0 -0
  518. package/out/web/assets/noto-sans-thai-latin-ext-500-normal-CrEHO7C8.woff +0 -0
  519. package/out/web/assets/noto-sans-thai-latin-ext-600-normal-PkMZo0xa.woff +0 -0
  520. package/out/web/assets/noto-sans-thai-latin-ext-600-normal-maHHBsez.woff2 +0 -0
  521. package/out/web/assets/noto-sans-thai-thai-400-normal-BEOmKAI1.woff2 +0 -0
  522. package/out/web/assets/noto-sans-thai-thai-400-normal-Dxb_unuU.woff +0 -0
  523. package/out/web/assets/noto-sans-thai-thai-500-normal-BOGXO2tw.woff +0 -0
  524. package/out/web/assets/noto-sans-thai-thai-500-normal-CU_X86GQ.woff2 +0 -0
  525. package/out/web/assets/noto-sans-thai-thai-600-normal-CmgB8_JK.woff +0 -0
  526. package/out/web/assets/noto-sans-thai-thai-600-normal-Cs73sVjl.woff2 +0 -0
  527. package/out/web/assets/oauthManager-DNiwM8Rn.js +0 -2
  528. package/out/web/assets/oauthManager-DuamS1Av.css +0 -1
  529. package/out/web/assets/outfit-latin-300-normal-AWLfuCom.woff +0 -0
  530. package/out/web/assets/outfit-latin-300-normal-CRYTwY6m.woff2 +0 -0
  531. package/out/web/assets/outfit-latin-400-normal-BGsTXAXT.woff2 +0 -0
  532. package/out/web/assets/outfit-latin-400-normal-DMwTpYkH.woff +0 -0
  533. package/out/web/assets/outfit-latin-500-normal-ClnHRwRh.woff +0 -0
  534. package/out/web/assets/outfit-latin-500-normal-DKnIMDSk.woff2 +0 -0
  535. package/out/web/assets/outfit-latin-600-normal-B7SfZ07L.woff2 +0 -0
  536. package/out/web/assets/outfit-latin-600-normal-BEfTtDA7.woff +0 -0
  537. package/out/web/assets/outfit-latin-ext-300-normal-BYS6Dpvq.woff2 +0 -0
  538. package/out/web/assets/outfit-latin-ext-300-normal-CMIMshEd.woff +0 -0
  539. package/out/web/assets/outfit-latin-ext-400-normal-5tcqmc2S.woff2 +0 -0
  540. package/out/web/assets/outfit-latin-ext-400-normal-DHm7mdGe.woff +0 -0
  541. package/out/web/assets/outfit-latin-ext-500-normal-DrCvqoFD.woff +0 -0
  542. package/out/web/assets/outfit-latin-ext-500-normal-zeox_O30.woff2 +0 -0
  543. package/out/web/assets/outfit-latin-ext-600-normal-B85nYjL1.woff2 +0 -0
  544. package/out/web/assets/outfit-latin-ext-600-normal-CWJcPgd7.woff +0 -0
  545. package/out/web/assets/preload-helper-D0uHx7FM.js +0 -1
  546. package/out/web/assets/prompt-latin-ext-400-normal-1pHbN9uy.woff +0 -0
  547. package/out/web/assets/prompt-latin-ext-400-normal-DdSafGZ9.woff2 +0 -0
  548. package/out/web/assets/prompt-latin-ext-500-normal--EZ1um7s.woff2 +0 -0
  549. package/out/web/assets/prompt-latin-ext-500-normal-D-lI1EVT.woff +0 -0
  550. package/out/web/assets/prompt-latin-ext-600-normal-C8b50x67.woff +0 -0
  551. package/out/web/assets/prompt-latin-ext-600-normal-Cg9L7iJU.woff2 +0 -0
  552. package/out/web/assets/prompt-vietnamese-400-normal-BCPzsgPT.woff2 +0 -0
  553. package/out/web/assets/prompt-vietnamese-400-normal-D4pLyeNK.woff +0 -0
  554. package/out/web/assets/prompt-vietnamese-500-normal-DmzxmPwa.woff2 +0 -0
  555. package/out/web/assets/prompt-vietnamese-500-normal-w_sS_j2F.woff +0 -0
  556. package/out/web/assets/prompt-vietnamese-600-normal-1dyohtNh.woff +0 -0
  557. package/out/web/assets/prompt-vietnamese-600-normal-7QWjJBsF.woff2 +0 -0
  558. package/out/web/assets/sarabun-latin-400-normal-DmguIS-7.woff +0 -0
  559. package/out/web/assets/sarabun-latin-400-normal-URPBxl-K.woff2 +0 -0
  560. package/out/web/assets/sarabun-latin-500-normal-BjUTcdxu.woff2 +0 -0
  561. package/out/web/assets/sarabun-latin-500-normal-CJexTAnl.woff +0 -0
  562. package/out/web/assets/sarabun-latin-600-normal-9FonlkKx.woff +0 -0
  563. package/out/web/assets/sarabun-latin-600-normal-DMD3TROr.woff2 +0 -0
  564. package/out/web/assets/sarabun-latin-ext-400-normal-BbP7GpLL.woff2 +0 -0
  565. package/out/web/assets/sarabun-latin-ext-400-normal-BsmTn0SS.woff +0 -0
  566. package/out/web/assets/sarabun-latin-ext-500-normal-Cot16Bd2.woff +0 -0
  567. package/out/web/assets/sarabun-latin-ext-500-normal-DE1LysqI.woff2 +0 -0
  568. package/out/web/assets/sarabun-latin-ext-600-normal-0tXu9x_i.woff +0 -0
  569. package/out/web/assets/sarabun-latin-ext-600-normal-BRxjKUQ6.woff2 +0 -0
  570. package/out/web/assets/sarabun-thai-400-normal-C2DaJlKK.woff2 +0 -0
  571. package/out/web/assets/sarabun-thai-400-normal-CH-UbWgg.woff +0 -0
  572. package/out/web/assets/sarabun-thai-500-normal-BVssjame.woff2 +0 -0
  573. package/out/web/assets/sarabun-thai-500-normal-C3iDLEzi.woff +0 -0
  574. package/out/web/assets/sarabun-thai-600-normal-3dIAQ-_s.woff2 +0 -0
  575. package/out/web/assets/sarabun-thai-600-normal-DWKeTe6a.woff +0 -0
  576. package/out/web/assets/sarabun-vietnamese-400-normal-DPfkSCMy.woff +0 -0
  577. package/out/web/assets/sarabun-vietnamese-400-normal-igdrzAGY.woff2 +0 -0
  578. package/out/web/assets/sarabun-vietnamese-500-normal-CpRVvGRU.woff +0 -0
  579. package/out/web/assets/sarabun-vietnamese-500-normal-Dpe7Br9m.woff2 +0 -0
  580. package/out/web/assets/sarabun-vietnamese-600-normal-BMbOgAW-.woff +0 -0
  581. package/out/web/assets/sarabun-vietnamese-600-normal-DkxrOlLB.woff2 +0 -0
  582. package/out/web/assets/stateDiagram-v2-4QOOHH4V-s6P9g2_b.js +0 -1
  583. package/out/web/assets/swimlanesDiagram-VK2B7HYN-CV9fOLrV.js +0 -8
  584. package/out/web/assets/tauri-CNrHQ2Gb.js +0 -3
  585. package/out/web/assets/window-DkIhgI7B.js +0 -1
@@ -0,0 +1,4742 @@
1
+ //! Full-screen adapter for interactive chat.
2
+ use super::{
3
+ InteractiveInput, InteractiveSession, active_model, format_provider_display_name,
4
+ format_workspace_with_branch,
5
+ };
6
+ use ansi_to_tui::IntoText;
7
+ use anyhow::{Result, bail};
8
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
9
+ use crossterm::{ExecutableCommand, event, terminal};
10
+ use ratatui::{
11
+ Terminal,
12
+ backend::CrosstermBackend,
13
+ buffer::Buffer,
14
+ layout::{Constraint, Direction, Layout, Position, Rect},
15
+ style::{Color, Modifier, Style},
16
+ text::{Line, Span, Text},
17
+ widgets::{
18
+ Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
19
+ Wrap,
20
+ },
21
+ };
22
+ use std::io::{self, Write};
23
+ use std::path::Path;
24
+ use std::sync::{
25
+ Arc, Mutex,
26
+ atomic::{AtomicBool, Ordering},
27
+ mpsc,
28
+ };
29
+ use unicode_width::UnicodeWidthStr;
30
+
31
+ fn format_thinking_status(config: &mint_core::MintConfig) -> String {
32
+ let model = active_model(&config.ai_provider, config);
33
+ if !config.resolved_thinking_enabled_for_model(&config.ai_provider, model) {
34
+ return "off".to_string();
35
+ }
36
+ match config.resolved_thinking_effort_for_model(model) {
37
+ "extra_high" => "extra high".to_string(),
38
+ effort => effort.to_string(),
39
+ }
40
+ }
41
+
42
+ const MIN_WIDTH: u16 = 60;
43
+ const MIN_HEIGHT: u16 = 12;
44
+ const COPY_LIMIT: usize = 100 * 1024;
45
+ const BACK_TO_BOTTOM_LABEL: &str = " ↓ Back to bottom · End ";
46
+ const WATERMARK_FRAME_COUNT: u8 = 50;
47
+ const WATERMARK_TICK: std::time::Duration = std::time::Duration::from_millis(30);
48
+ const WATERMARK_HOLD: std::time::Duration = std::time::Duration::from_secs(2);
49
+ const WATERMARK_WIDTH: u16 = 39;
50
+ const WATERMARK_HEIGHT: u16 = 17;
51
+
52
+ fn watermark_animation_at(
53
+ elapsed: std::time::Duration,
54
+ ) -> (Option<u8>, Option<std::time::Duration>) {
55
+ let half_turn_frames = WATERMARK_FRAME_COUNT / 2;
56
+ let half_turn = WATERMARK_TICK * half_turn_frames as u32;
57
+ let hold_end = half_turn + WATERMARK_HOLD;
58
+ let reverse_end = hold_end + half_turn;
59
+ if elapsed < half_turn {
60
+ let step = (elapsed.as_millis() / WATERMARK_TICK.as_millis()) as u8;
61
+ (Some(step), Some(WATERMARK_TICK * (step as u32 + 1)))
62
+ } else if elapsed < hold_end {
63
+ (Some(half_turn_frames), Some(hold_end))
64
+ } else if elapsed < reverse_end {
65
+ let step = ((elapsed - hold_end).as_millis() / WATERMARK_TICK.as_millis()) as u8;
66
+ (
67
+ Some(half_turn_frames - step - 1),
68
+ Some(hold_end + WATERMARK_TICK * (step as u32 + 1)),
69
+ )
70
+ } else {
71
+ (None, None)
72
+ }
73
+ }
74
+
75
+ fn mint_m_watermark(frame: Option<u8>, height: usize) -> Text<'static> {
76
+ const WIDTH: usize = WATERMARK_WIDTH as usize;
77
+ const DESIGN_HEIGHT: usize = WATERMARK_HEIGHT as usize;
78
+ #[derive(Clone, Copy)]
79
+ enum Part {
80
+ Void,
81
+ Rim,
82
+ Band,
83
+ Orbit,
84
+ Mark,
85
+ }
86
+
87
+ let height = height.clamp(1, DESIGN_HEIGHT);
88
+ let angle = frame.unwrap_or(0) as f32 * std::f32::consts::TAU / WATERMARK_FRAME_COUNT as f32;
89
+ let (sin, cos) = angle.sin_cos();
90
+ let mut pixels = vec![vec![None::<(f32, Part)>; WIDTH]; height];
91
+
92
+ // A dotted medallion with an M on its front and a leaf on its back. The
93
+ // two faces and the rim share one depth buffer as they turn in 3D.
94
+ for y in 0..DESIGN_HEIGHT {
95
+ let world_y = y as f32 - 8.0;
96
+ let projected_y = y * height.saturating_sub(1) / (DESIGN_HEIGHT - 1);
97
+ for source_x in 0..WIDTH {
98
+ let world_x = (source_x as f32 - 19.0) / 2.0;
99
+ let radius = world_x.hypot(world_y);
100
+ if radius > 8.15 {
101
+ continue;
102
+ }
103
+
104
+ let stem = (world_x.abs() - 3.7).abs() < 0.5 && world_y.abs() <= 4.2;
105
+ let diagonal_y = world_y + 4.2;
106
+ let diagonal = (-4.2..=2.2).contains(&world_y)
107
+ && [-3.7 + diagonal_y * 3.7 / 6.4, 3.7 - diagonal_y * 3.7 / 6.4]
108
+ .into_iter()
109
+ .any(|line_x| (world_x - line_x).abs() < 0.5);
110
+ let front_mark = stem || diagonal;
111
+
112
+ let leaf_x = world_x * 0.8 + world_y * 0.6;
113
+ let leaf_y = -world_x * 0.6 + world_y * 0.8;
114
+ let leaf_radius = (leaf_x / 4.2).powi(2) + (leaf_y / 2.5).powi(2);
115
+ let back_mark =
116
+ (leaf_radius - 1.0).abs() < 0.22 || (leaf_y.abs() < 0.32 && leaf_x.abs() < 3.8);
117
+
118
+ for (z, mark) in [(-1.2, back_mark), (0.0, false), (1.2, front_mark)] {
119
+ let part = if radius > 7.25 {
120
+ Part::Rim
121
+ } else if radius > 6.2 {
122
+ Part::Band
123
+ } else if mark {
124
+ Part::Mark
125
+ } else if radius > 5.75 && radius < 6.05 && (source_x + y) % 3 != 0 {
126
+ Part::Orbit
127
+ } else {
128
+ Part::Void
129
+ };
130
+ let projected_x = (19.0 + (world_x * cos + z * sin) * 2.0).round() as isize;
131
+ if !(0..WIDTH as isize).contains(&projected_x) {
132
+ continue;
133
+ }
134
+ let depth = -world_x * sin + z * cos;
135
+ let cell = &mut pixels[projected_y][projected_x as usize];
136
+ if cell.is_none_or(|(existing_depth, _)| depth > existing_depth) {
137
+ *cell = Some((depth, part));
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ let lines = pixels
144
+ .into_iter()
145
+ .map(|row| {
146
+ let spans = row
147
+ .into_iter()
148
+ .map(|pixel| match pixel {
149
+ Some((_, Part::Void)) | None => Span::raw(" "),
150
+ Some((depth, part)) => {
151
+ let lit = frame.is_some() && depth > 0.5;
152
+ let (glyph, color) = match part {
153
+ Part::Mark => (
154
+ "⣿",
155
+ if lit {
156
+ crate::terminal_theme::ACCENT
157
+ } else {
158
+ crate::terminal_theme::WATERMARK_MARK
159
+ },
160
+ ),
161
+ Part::Rim => (
162
+ "⠿",
163
+ if lit {
164
+ crate::terminal_theme::BLUE
165
+ } else {
166
+ crate::terminal_theme::WATERMARK_RIM
167
+ },
168
+ ),
169
+ Part::Band => ("⠿", crate::terminal_theme::WATERMARK_BAND),
170
+ Part::Orbit => ("⠂", crate::terminal_theme::WATERMARK_ORBIT),
171
+ Part::Void => unreachable!(),
172
+ };
173
+ Span::styled(glyph, Style::default().fg(color))
174
+ }
175
+ })
176
+ .collect::<Vec<_>>();
177
+ Line::from(spans)
178
+ })
179
+ .collect::<Vec<_>>();
180
+ Text::from(lines)
181
+ }
182
+
183
+ fn theme_picker_lines(dialog: &DialogState, height: u16) -> Vec<Line<'static>> {
184
+ let selected = crate::terminal_theme::TuiTheme::from_index(dialog.selected);
185
+ let colors = crate::terminal_theme::preview_colors(selected);
186
+ let mut lines = vec![
187
+ Line::styled(
188
+ " Theme",
189
+ Style::default()
190
+ .fg(crate::terminal_theme::BLUE)
191
+ .add_modifier(Modifier::BOLD),
192
+ ),
193
+ Line::raw(format!(" {}", dialog.body)),
194
+ Line::raw(""),
195
+ ];
196
+ for (index, label) in dialog.options.iter().enumerate() {
197
+ let focused = index == dialog.selected;
198
+ let active = dialog.theme_picker == Some(index);
199
+ let style = Style::default()
200
+ .fg(if focused {
201
+ crate::terminal_theme::ACCENT
202
+ } else {
203
+ crate::terminal_theme::TEXT
204
+ })
205
+ .add_modifier(if focused {
206
+ Modifier::BOLD
207
+ } else {
208
+ Modifier::empty()
209
+ });
210
+ lines.push(Line::from(vec![
211
+ Span::styled(if focused { " › " } else { " " }, style),
212
+ Span::styled(format!("{}. {label}", index + 1), style),
213
+ Span::styled(
214
+ if active { " ✓" } else { "" },
215
+ Style::default().fg(crate::terminal_theme::ACCENT),
216
+ ),
217
+ ]));
218
+ }
219
+ lines.push(Line::raw(""));
220
+ lines.push(Line::styled(
221
+ format!(" Preview · {}", selected.label()),
222
+ Style::default().fg(crate::terminal_theme::MUTED),
223
+ ));
224
+ let sample = Style::default().fg(colors.text).bg(colors.background);
225
+ lines.push(Line::from(vec![
226
+ Span::styled(" 1 ", sample.fg(colors.muted)),
227
+ Span::styled("fn", sample.fg(colors.keyword)),
228
+ Span::styled(" greet() {", sample),
229
+ ]));
230
+ lines.push(Line::from(vec![
231
+ Span::styled(
232
+ " 2 -",
233
+ sample.fg(colors.removed).bg(colors.removed_background),
234
+ ),
235
+ Span::styled(
236
+ " println!(\"Hello, World!\");",
237
+ sample.fg(colors.removed).bg(colors.removed_background),
238
+ ),
239
+ ]));
240
+ lines.push(Line::from(vec![
241
+ Span::styled(" 2 +", sample.fg(colors.added).bg(colors.added_background)),
242
+ Span::styled(
243
+ " println!(\"Hello, Mint!\");",
244
+ sample.fg(colors.added).bg(colors.added_background),
245
+ ),
246
+ ]));
247
+ lines.push(Line::from(vec![
248
+ Span::styled(" 3 ", sample.fg(colors.muted)),
249
+ Span::styled("}", sample),
250
+ ]));
251
+ lines.push(Line::raw(""));
252
+ lines.push(Line::styled(
253
+ " ↑/↓ preview · 1-3 select · Enter apply · Esc cancel",
254
+ Style::default().fg(crate::terminal_theme::MUTED),
255
+ ));
256
+ lines.truncate(height.saturating_sub(1) as usize);
257
+ lines
258
+ }
259
+
260
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
261
+ pub(crate) enum TranscriptRole {
262
+ User,
263
+ Assistant,
264
+ Notice,
265
+ Command,
266
+ #[allow(dead_code)]
267
+ System,
268
+ }
269
+ #[derive(Debug, Clone, PartialEq, Eq)]
270
+ pub(crate) struct TranscriptEntry {
271
+ pub role: TranscriptRole,
272
+ pub text: String,
273
+ pub rendered_lines: Vec<Line<'static>>,
274
+ pub plain_lines: Vec<String>,
275
+ }
276
+
277
+ impl TranscriptEntry {
278
+ pub fn new(role: TranscriptRole, text: impl Into<String>) -> Self {
279
+ let text = text.into();
280
+ let (rendered_lines, plain_lines) = Self::render_content(role, &text);
281
+ Self {
282
+ role,
283
+ text,
284
+ rendered_lines,
285
+ plain_lines,
286
+ }
287
+ }
288
+
289
+ fn render_content(role: TranscriptRole, text: &str) -> (Vec<Line<'static>>, Vec<String>) {
290
+ if text.is_empty() {
291
+ return (Vec::new(), Vec::new());
292
+ }
293
+ let processed = match role {
294
+ TranscriptRole::Assistant => {
295
+ let clean = crate::agent::sanitize_latex(text);
296
+ crate::agent::format_markdown_bold(&clean)
297
+ }
298
+ TranscriptRole::User
299
+ | TranscriptRole::Notice
300
+ | TranscriptRole::Command
301
+ | TranscriptRole::System => crate::agent::format_markdown_bold(text),
302
+ };
303
+
304
+ if let Ok(parsed) = processed.into_text() {
305
+ let rendered = parsed.lines;
306
+ let plain: Vec<String> = rendered
307
+ .iter()
308
+ .map(|line| {
309
+ line.spans
310
+ .iter()
311
+ .map(|span| span.content.as_ref())
312
+ .collect::<String>()
313
+ })
314
+ .collect();
315
+ (rendered, plain)
316
+ } else {
317
+ let rendered: Vec<Line<'static>> =
318
+ text.lines().map(|l| Line::raw(l.to_string())).collect();
319
+ let plain: Vec<String> = text.lines().map(str::to_owned).collect();
320
+ (rendered, plain)
321
+ }
322
+ }
323
+ }
324
+
325
+ #[derive(Debug, Clone)]
326
+ pub(crate) struct ThoughtModalState {
327
+ pub elapsed_str: String,
328
+ pub lines: Vec<String>,
329
+ pub scroll: usize,
330
+ }
331
+
332
+ #[derive(Debug, Default)]
333
+ pub(crate) struct ChatViewState {
334
+ pub(crate) transcript: Vec<TranscriptEntry>,
335
+ chat_id: String,
336
+ sync_cursor: i64,
337
+ last_interaction_id: i64,
338
+ composer: Vec<char>,
339
+ cursor: usize,
340
+ history: Vec<String>,
341
+ history_index: Option<usize>,
342
+ draft_before_history: Vec<char>,
343
+ slash_selected: usize,
344
+ status: Vec<String>,
345
+ model: String,
346
+ thinking_status: String,
347
+ provider: String,
348
+ workspace: String,
349
+ current_dir: std::path::PathBuf,
350
+ plan_mode: bool,
351
+ scroll_from_bottom: u16,
352
+ selection_mode: bool,
353
+ selection_anchor: usize,
354
+ selection_head: usize,
355
+ notice: Option<(String, std::time::Instant)>,
356
+ dialog: Option<DialogState>,
357
+ thought_modal: Option<ThoughtModalState>,
358
+ watermark_frame: u8,
359
+ watermark_animating: bool,
360
+ pub(crate) theme: crate::terminal_theme::TuiTheme,
361
+ }
362
+
363
+ #[derive(Debug)]
364
+ pub(crate) struct DialogState {
365
+ pub title: String,
366
+ pub body: String,
367
+ pub options: Vec<String>,
368
+ pub filter: Vec<char>,
369
+ pub selected: usize,
370
+ pub scroll_offset: usize,
371
+ pub checked: Option<Vec<bool>>,
372
+ pub input: Option<Vec<char>>,
373
+ pub resume_picker: Option<ResumePickerDialog>,
374
+ pub theme_picker: Option<usize>,
375
+ pub reply: mpsc::Sender<DialogAnswer>,
376
+ }
377
+
378
+ #[derive(Debug)]
379
+ pub(crate) struct ResumePickerDialog {
380
+ pub sessions: Vec<mint_core::ChatSession>,
381
+ pub current_workspace: String,
382
+ pub current_branch: Option<String>,
383
+ pub active_chat_id: String,
384
+ pub show_all_projects: bool,
385
+ pub only_current_branch: bool,
386
+ pub sort_by_created: bool,
387
+ }
388
+
389
+ #[derive(Debug, PartialEq, Eq)]
390
+ pub(crate) enum DialogAnswer {
391
+ Choice(usize),
392
+ Choices(Vec<usize>),
393
+ Text(String),
394
+ Cancel,
395
+ }
396
+
397
+ impl DialogState {
398
+ pub fn new_choice(
399
+ title: impl Into<String>,
400
+ body: impl Into<String>,
401
+ options: Vec<String>,
402
+ checked: Option<Vec<bool>>,
403
+ reply: mpsc::Sender<DialogAnswer>,
404
+ ) -> Self {
405
+ Self {
406
+ title: title.into(),
407
+ body: body.into(),
408
+ options,
409
+ filter: Vec::new(),
410
+ selected: 0,
411
+ scroll_offset: 0,
412
+ checked,
413
+ input: None,
414
+ resume_picker: None,
415
+ theme_picker: None,
416
+ reply,
417
+ }
418
+ }
419
+
420
+ pub fn new_text(
421
+ title: impl Into<String>,
422
+ body: impl Into<String>,
423
+ reply: mpsc::Sender<DialogAnswer>,
424
+ ) -> Self {
425
+ Self {
426
+ title: title.into(),
427
+ body: body.into(),
428
+ options: Vec::new(),
429
+ filter: Vec::new(),
430
+ selected: 0,
431
+ scroll_offset: 0,
432
+ checked: None,
433
+ input: Some(Vec::new()),
434
+ resume_picker: None,
435
+ theme_picker: None,
436
+ reply,
437
+ }
438
+ }
439
+
440
+ pub fn new_resume_picker(
441
+ sessions: Vec<mint_core::ChatSession>,
442
+ current_workspace: String,
443
+ current_branch: Option<String>,
444
+ active_chat_id: String,
445
+ reply: mpsc::Sender<DialogAnswer>,
446
+ ) -> Self {
447
+ let options = sessions
448
+ .iter()
449
+ .map(|session| session.title.clone())
450
+ .collect();
451
+ let show_all_projects = !sessions
452
+ .iter()
453
+ .any(|session| session.workspace_path.as_deref() == Some(&current_workspace));
454
+ Self {
455
+ title: "Resume Session".to_string(),
456
+ body: String::new(),
457
+ options,
458
+ filter: Vec::new(),
459
+ selected: 0,
460
+ scroll_offset: 0,
461
+ checked: None,
462
+ input: None,
463
+ resume_picker: Some(ResumePickerDialog {
464
+ sessions,
465
+ current_workspace,
466
+ current_branch,
467
+ active_chat_id,
468
+ show_all_projects,
469
+ only_current_branch: false,
470
+ sort_by_created: false,
471
+ }),
472
+ theme_picker: None,
473
+ reply,
474
+ }
475
+ }
476
+
477
+ pub fn new_theme_picker(active: usize, reply: mpsc::Sender<DialogAnswer>) -> Self {
478
+ Self {
479
+ title: "Theme".to_owned(),
480
+ body: "Choose the text style that looks best with your terminal".to_owned(),
481
+ options: ["Auto (match terminal)", "Dark mode", "Light mode"]
482
+ .into_iter()
483
+ .map(str::to_owned)
484
+ .collect(),
485
+ filter: Vec::new(),
486
+ selected: active.min(2),
487
+ scroll_offset: 0,
488
+ checked: None,
489
+ input: None,
490
+ resume_picker: None,
491
+ theme_picker: Some(active.min(2)),
492
+ reply,
493
+ }
494
+ }
495
+
496
+ pub fn filtered_indices(&self) -> Vec<usize> {
497
+ if let Some(picker) = &self.resume_picker {
498
+ let query: String = self.filter.iter().collect::<String>().to_lowercase();
499
+ let mut indices: Vec<usize> = picker
500
+ .sessions
501
+ .iter()
502
+ .enumerate()
503
+ .filter(|(_, session)| {
504
+ if !picker.show_all_projects
505
+ && session.workspace_path.as_deref() != Some(&picker.current_workspace)
506
+ {
507
+ return false;
508
+ }
509
+ if picker.only_current_branch {
510
+ match picker.current_branch.as_deref() {
511
+ Some(branch) if session.git_branch.as_deref() == Some(branch) => {}
512
+ _ => return false,
513
+ }
514
+ }
515
+ if !query.is_empty() {
516
+ let searchable = [
517
+ session.title.as_str(),
518
+ session.id.as_str(),
519
+ session.git_branch.as_deref().unwrap_or_default(),
520
+ session.main_language.as_deref().unwrap_or_default(),
521
+ ]
522
+ .join(" ")
523
+ .to_lowercase();
524
+ if !searchable.contains(&query) {
525
+ return false;
526
+ }
527
+ }
528
+ true
529
+ })
530
+ .map(|(index, _)| index)
531
+ .collect();
532
+ indices.sort_by(|left, right| {
533
+ let left = &picker.sessions[*left];
534
+ let right = &picker.sessions[*right];
535
+ let (left_time, right_time) = if picker.sort_by_created {
536
+ (&left.created_at, &right.created_at)
537
+ } else {
538
+ (&left.updated_at, &right.updated_at)
539
+ };
540
+ right_time.cmp(left_time)
541
+ });
542
+ return indices;
543
+ }
544
+ if self.filter.is_empty() {
545
+ (0..self.options.len()).collect()
546
+ } else {
547
+ let query: String = self.filter.iter().collect::<String>().to_lowercase();
548
+ self.options
549
+ .iter()
550
+ .enumerate()
551
+ .filter(|(_, opt)| opt.to_lowercase().contains(&query))
552
+ .map(|(i, _)| i)
553
+ .collect()
554
+ }
555
+ }
556
+
557
+ pub fn select_up(&mut self) {
558
+ let total = self.filtered_indices().len();
559
+ if total > 0 {
560
+ self.selected = self.selected.saturating_sub(1);
561
+ }
562
+ }
563
+
564
+ pub fn select_down(&mut self) {
565
+ let total = self.filtered_indices().len();
566
+ if total > 0 {
567
+ self.selected = (self.selected + 1).min(total.saturating_sub(1));
568
+ }
569
+ }
570
+
571
+ pub fn select_numeric(&mut self, digit: usize) -> bool {
572
+ let total = self.filtered_indices().len();
573
+ if digit >= 1 && digit <= total && digit <= 9 {
574
+ self.selected = digit - 1;
575
+ true
576
+ } else {
577
+ false
578
+ }
579
+ }
580
+
581
+ pub fn toggle_checked(&mut self) {
582
+ let filtered = self.filtered_indices();
583
+ if let Some(&original_idx) = filtered.get(self.selected) {
584
+ if let Some(checked) = self.checked.as_mut() {
585
+ if let Some(val) = checked.get_mut(original_idx) {
586
+ *val = !*val;
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ pub fn handle_key(&mut self, key: event::KeyEvent) -> Option<DialogAnswer> {
593
+ use event::{KeyCode, KeyModifiers};
594
+
595
+ // Text input mode
596
+ if let Some(input) = self.input.as_mut() {
597
+ match key.code {
598
+ KeyCode::Char(c)
599
+ if !key
600
+ .modifiers
601
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
602
+ {
603
+ input.push(c);
604
+ None
605
+ }
606
+ KeyCode::Backspace => {
607
+ input.pop();
608
+ None
609
+ }
610
+ KeyCode::Enter => {
611
+ let text = input.iter().collect();
612
+ Some(DialogAnswer::Text(text))
613
+ }
614
+ KeyCode::Esc => Some(DialogAnswer::Cancel),
615
+ KeyCode::Char('c') | KeyCode::Char('d')
616
+ if key.modifiers.contains(KeyModifiers::CONTROL) =>
617
+ {
618
+ Some(DialogAnswer::Cancel)
619
+ }
620
+ _ => None,
621
+ }
622
+ } else {
623
+ // Choice / multi-choice mode
624
+ if let Some(picker) = self.resume_picker.as_mut() {
625
+ if key.modifiers.contains(KeyModifiers::CONTROL) {
626
+ match key.code {
627
+ KeyCode::Char('a') => {
628
+ picker.show_all_projects = !picker.show_all_projects;
629
+ self.selected = 0;
630
+ self.scroll_offset = 0;
631
+ return None;
632
+ }
633
+ KeyCode::Char('b') => {
634
+ picker.only_current_branch = !picker.only_current_branch;
635
+ self.selected = 0;
636
+ self.scroll_offset = 0;
637
+ return None;
638
+ }
639
+ KeyCode::Char('c' | 'd') => return Some(DialogAnswer::Cancel),
640
+ _ => {}
641
+ }
642
+ }
643
+ if key.code == KeyCode::Tab {
644
+ picker.sort_by_created = !picker.sort_by_created;
645
+ self.selected = 0;
646
+ self.scroll_offset = 0;
647
+ return None;
648
+ }
649
+ }
650
+ match key.code {
651
+ KeyCode::Up => {
652
+ self.select_up();
653
+ None
654
+ }
655
+ KeyCode::Down => {
656
+ self.select_down();
657
+ None
658
+ }
659
+ KeyCode::PageUp => {
660
+ for _ in 0..5 {
661
+ self.select_up();
662
+ }
663
+ None
664
+ }
665
+ KeyCode::PageDown => {
666
+ for _ in 0..5 {
667
+ self.select_down();
668
+ }
669
+ None
670
+ }
671
+ KeyCode::Char(' ') if self.checked.is_some() => {
672
+ self.toggle_checked();
673
+ None
674
+ }
675
+ // Numeric shortcuts: 1-9 when filter is empty
676
+ KeyCode::Char(c @ '1'..='9') if self.filter.is_empty() => {
677
+ let digit = c.to_digit(10).unwrap() as usize;
678
+ if self.select_numeric(digit) && self.checked.is_none() {
679
+ let filtered = self.filtered_indices();
680
+ if let Some(&orig_idx) = filtered.get(self.selected) {
681
+ return Some(DialogAnswer::Choice(orig_idx));
682
+ }
683
+ }
684
+ None
685
+ }
686
+ // Type to filter: typing chars
687
+ KeyCode::Char(c)
688
+ if !key
689
+ .modifiers
690
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
691
+ {
692
+ if self.theme_picker.is_some() {
693
+ return None;
694
+ }
695
+ self.filter.push(c);
696
+ self.selected = 0;
697
+ self.scroll_offset = 0;
698
+ None
699
+ }
700
+ KeyCode::Backspace => {
701
+ if self.filter.pop().is_some() {
702
+ self.selected = 0;
703
+ self.scroll_offset = 0;
704
+ }
705
+ None
706
+ }
707
+ KeyCode::Enter => {
708
+ if let Some(checked) = &self.checked {
709
+ let choices = checked
710
+ .iter()
711
+ .enumerate()
712
+ .filter_map(|(idx, &c)| if c { Some(idx) } else { None })
713
+ .collect();
714
+ Some(DialogAnswer::Choices(choices))
715
+ } else {
716
+ let filtered = self.filtered_indices();
717
+ if let Some(&orig_idx) = filtered.get(self.selected) {
718
+ Some(DialogAnswer::Choice(orig_idx))
719
+ } else {
720
+ Some(DialogAnswer::Cancel)
721
+ }
722
+ }
723
+ }
724
+ KeyCode::Esc => {
725
+ if !self.filter.is_empty() {
726
+ self.filter.clear();
727
+ self.selected = 0;
728
+ self.scroll_offset = 0;
729
+ None
730
+ } else {
731
+ Some(DialogAnswer::Cancel)
732
+ }
733
+ }
734
+ KeyCode::Char('c') | KeyCode::Char('d')
735
+ if key.modifiers.contains(KeyModifiers::CONTROL) =>
736
+ {
737
+ Some(DialogAnswer::Cancel)
738
+ }
739
+ _ => None,
740
+ }
741
+ }
742
+ }
743
+ }
744
+
745
+ fn resume_picker_lines(dialog: &DialogState, width: u16, height: u16) -> Vec<Line<'static>> {
746
+ let Some(picker) = &dialog.resume_picker else {
747
+ return Vec::new();
748
+ };
749
+ let filtered = dialog.filtered_indices();
750
+ let count = filtered.len();
751
+ let total = picker.sessions.len();
752
+ let muted = Style::default().fg(crate::terminal_theme::MUTED);
753
+ let selected_chip = Style::default()
754
+ .fg(crate::terminal_theme::SELECTION_TEXT)
755
+ .bg(crate::terminal_theme::SELECTION_BACKGROUND)
756
+ .add_modifier(Modifier::BOLD);
757
+ let selected_position = if count == 0 {
758
+ 0
759
+ } else {
760
+ (dialog.selected + 1).min(count)
761
+ };
762
+
763
+ let mut lines = vec![Line::from(vec![
764
+ Span::styled(
765
+ "Resume Session",
766
+ Style::default()
767
+ .fg(crate::terminal_theme::TEXT)
768
+ .add_modifier(Modifier::BOLD),
769
+ ),
770
+ Span::styled(format!(" ({selected_position}/{count})"), muted),
771
+ ])];
772
+
773
+ let mut filters = vec![
774
+ Span::styled("Project: ", muted),
775
+ Span::styled(
776
+ " Cwd ",
777
+ if picker.show_all_projects {
778
+ muted
779
+ } else {
780
+ selected_chip
781
+ },
782
+ ),
783
+ Span::styled(
784
+ " All ",
785
+ if picker.show_all_projects {
786
+ selected_chip
787
+ } else {
788
+ muted
789
+ },
790
+ ),
791
+ Span::styled(" · Branch: ", muted),
792
+ Span::styled(
793
+ " All ",
794
+ if picker.only_current_branch {
795
+ muted
796
+ } else {
797
+ selected_chip
798
+ },
799
+ ),
800
+ Span::styled(
801
+ " Current ",
802
+ if picker.only_current_branch {
803
+ selected_chip
804
+ } else {
805
+ muted
806
+ },
807
+ ),
808
+ Span::styled(" · Sort: ", muted),
809
+ Span::styled(
810
+ " Updated ",
811
+ if picker.sort_by_created {
812
+ muted
813
+ } else {
814
+ selected_chip
815
+ },
816
+ ),
817
+ Span::styled(
818
+ " Created ",
819
+ if picker.sort_by_created {
820
+ selected_chip
821
+ } else {
822
+ muted
823
+ },
824
+ ),
825
+ ];
826
+ if picker.only_current_branch && picker.current_branch.is_none() {
827
+ filters.push(Span::styled(" (no branch)", muted));
828
+ }
829
+ lines.push(Line::from(filters));
830
+
831
+ let query: String = dialog.filter.iter().collect();
832
+ lines.push(Line::from(vec![
833
+ Span::styled("⌕ ", Style::default().fg(crate::terminal_theme::SUBTLE)),
834
+ Span::styled(
835
+ if query.is_empty() {
836
+ "Type to search".to_string()
837
+ } else {
838
+ query.clone()
839
+ },
840
+ if query.is_empty() {
841
+ muted
842
+ } else {
843
+ Style::default().fg(crate::terminal_theme::TEXT)
844
+ },
845
+ ),
846
+ if query.is_empty() {
847
+ Span::raw("")
848
+ } else {
849
+ Span::styled("█", Style::default().fg(crate::terminal_theme::TEXT))
850
+ },
851
+ ]));
852
+
853
+ let project_name = std::path::Path::new(&picker.current_workspace)
854
+ .file_name()
855
+ .map(|name| name.to_string_lossy().into_owned())
856
+ .unwrap_or_else(|| picker.current_workspace.clone());
857
+ let project_label = if picker.show_all_projects {
858
+ "All projects"
859
+ } else {
860
+ &project_name
861
+ };
862
+ let session_word = if count == 1 { "session" } else { "sessions" };
863
+ lines.push(Line::styled(
864
+ format!("{project_label} · {count} of {total} {session_word}"),
865
+ muted,
866
+ ));
867
+ lines.push(Line::styled(
868
+ "─".repeat(width as usize),
869
+ Style::default().fg(crate::terminal_theme::MUTED),
870
+ ));
871
+
872
+ let available_rows = (height as usize).saturating_sub(8).max(1);
873
+ let scroll_offset = if dialog.selected < dialog.scroll_offset {
874
+ dialog.selected
875
+ } else if dialog.selected >= dialog.scroll_offset + available_rows {
876
+ dialog.selected + 1 - available_rows
877
+ } else {
878
+ dialog.scroll_offset
879
+ }
880
+ .min(count.saturating_sub(available_rows));
881
+ let end = (scroll_offset + available_rows).min(count);
882
+
883
+ if count == 0 {
884
+ lines.push(Line::styled("No matching sessions", muted));
885
+ } else {
886
+ for position in scroll_offset..end {
887
+ let session = &picker.sessions[filtered[position]];
888
+ let time = super::resume_picker::truncate_to_width(
889
+ &super::resume_picker::format_relative_time(&session.updated_at),
890
+ 10,
891
+ );
892
+ let message_count = format!("{} msgs", session.message_count);
893
+ let current_tag = if session.id == picker.active_chat_id {
894
+ " (current)"
895
+ } else {
896
+ ""
897
+ };
898
+ let fixed_width = 2
899
+ + 10
900
+ + 2
901
+ + UnicodeWidthStr::width(message_count.as_str())
902
+ + 2
903
+ + UnicodeWidthStr::width(current_tag);
904
+ let title_width = (width as usize).saturating_sub(fixed_width);
905
+ let title = super::resume_picker::truncate_to_width(&session.title, title_width);
906
+ let is_selected = position == dialog.selected;
907
+ if is_selected {
908
+ let text = format!("› {time:>10} {title}{current_tag} {message_count}");
909
+ let text_width = UnicodeWidthStr::width(text.as_str());
910
+ let padded = format!(
911
+ "{text}{}",
912
+ " ".repeat((width as usize).saturating_sub(text_width))
913
+ );
914
+ lines.push(Line::styled(padded, selected_chip));
915
+ } else {
916
+ let row = format!(" {time:>10} {title}{current_tag} {message_count}");
917
+ let row_width = UnicodeWidthStr::width(row.as_str());
918
+ lines.push(Line::from(vec![
919
+ Span::styled(" ", Style::default().fg(crate::terminal_theme::SUBTLE)),
920
+ Span::styled(format!("{time:>10} "), muted),
921
+ Span::styled(
922
+ format!("{title}{current_tag}"),
923
+ Style::default().fg(crate::terminal_theme::TEXT),
924
+ ),
925
+ Span::styled(format!(" {message_count}"), muted),
926
+ Span::raw(" ".repeat((width as usize).saturating_sub(row_width))),
927
+ ]));
928
+ }
929
+ }
930
+ }
931
+
932
+ while lines.len() + 2 < height.saturating_sub(1) as usize {
933
+ lines.push(Line::raw(""));
934
+ }
935
+ lines.push(Line::styled(
936
+ "Enter resume · ↑/↓ navigate · Esc cancel",
937
+ muted,
938
+ ));
939
+ lines.push(Line::styled(
940
+ "Ctrl+A project · Ctrl+B branch · Tab sort · Type to search",
941
+ muted,
942
+ ));
943
+ lines
944
+ }
945
+
946
+ #[derive(Clone, Debug)]
947
+ pub(crate) struct TuiHandle {
948
+ pub(crate) state: Arc<Mutex<ChatViewState>>,
949
+ interrupted: Arc<AtomicBool>,
950
+ queued: Arc<Mutex<Vec<String>>>,
951
+ }
952
+
953
+ impl TuiHandle {
954
+ pub fn set_status(&self, lines: Vec<String>) {
955
+ if let Ok(mut state) = self.state.lock() {
956
+ state.status = lines;
957
+ }
958
+ }
959
+ pub fn clear_status(&self) {
960
+ self.set_status(Vec::new());
961
+ }
962
+ pub fn push_notice(&self, text: impl Into<String>) {
963
+ if let Ok(mut state) = self.state.lock() {
964
+ state.push_notice(text);
965
+ }
966
+ }
967
+ pub fn push_assistant(&self, text: impl Into<String>) {
968
+ if let Ok(mut state) = self.state.lock() {
969
+ state.push_assistant(text);
970
+ }
971
+ }
972
+ pub fn push_command(&self, text: impl Into<String>) {
973
+ if let Ok(mut state) = self.state.lock() {
974
+ state
975
+ .transcript
976
+ .push(TranscriptEntry::new(TranscriptRole::Command, text));
977
+ state.scroll_from_bottom = 0;
978
+ }
979
+ }
980
+ #[allow(dead_code)]
981
+ pub fn push_system(&self, text: impl Into<String>) {
982
+ if let Ok(mut state) = self.state.lock() {
983
+ state
984
+ .transcript
985
+ .push(TranscriptEntry::new(TranscriptRole::System, text));
986
+ state.scroll_from_bottom = 0;
987
+ }
988
+ }
989
+ pub fn set_notice(&self, text: impl Into<String>) {
990
+ if let Ok(mut state) = self.state.lock() {
991
+ state.set_notice(text);
992
+ }
993
+ }
994
+ pub fn take_interrupted(&self) -> bool {
995
+ self.interrupted.swap(false, Ordering::Relaxed)
996
+ }
997
+ pub fn take_queued(&self) -> Vec<String> {
998
+ self.queued
999
+ .lock()
1000
+ .map(|mut queued| std::mem::take(&mut *queued))
1001
+ .unwrap_or_default()
1002
+ }
1003
+ pub fn draft(&self) -> Option<String> {
1004
+ self.state
1005
+ .lock()
1006
+ .ok()
1007
+ .and_then(|state| (!state.composer.is_empty()).then(|| state.composer.iter().collect()))
1008
+ }
1009
+ pub fn choose(
1010
+ &self,
1011
+ title: impl Into<String>,
1012
+ body: impl Into<String>,
1013
+ options: Vec<String>,
1014
+ ) -> Option<usize> {
1015
+ let (reply, response) = mpsc::channel();
1016
+ if let Ok(mut state) = self.state.lock() {
1017
+ state.dialog = Some(DialogState::new_choice(title, body, options, None, reply));
1018
+ } else {
1019
+ return None;
1020
+ }
1021
+ match response.recv() {
1022
+ Ok(DialogAnswer::Choice(choice)) => Some(choice),
1023
+ _ => None,
1024
+ }
1025
+ }
1026
+ pub fn choose_many(
1027
+ &self,
1028
+ title: impl Into<String>,
1029
+ body: impl Into<String>,
1030
+ options: Vec<String>,
1031
+ ) -> Vec<usize> {
1032
+ let (reply, response) = mpsc::channel();
1033
+ let checked = vec![false; options.len()];
1034
+ if let Ok(mut state) = self.state.lock() {
1035
+ state.dialog = Some(DialogState::new_choice(
1036
+ title,
1037
+ body,
1038
+ options,
1039
+ Some(checked),
1040
+ reply,
1041
+ ));
1042
+ } else {
1043
+ return Vec::new();
1044
+ }
1045
+ match response.recv() {
1046
+ Ok(DialogAnswer::Choices(choices)) => choices,
1047
+ _ => Vec::new(),
1048
+ }
1049
+ }
1050
+ pub fn prompt_text(&self, title: impl Into<String>, body: impl Into<String>) -> Option<String> {
1051
+ let (reply, response) = mpsc::channel();
1052
+ if let Ok(mut state) = self.state.lock() {
1053
+ state.dialog = Some(DialogState::new_text(title, body, reply));
1054
+ } else {
1055
+ return None;
1056
+ }
1057
+ match response.recv() {
1058
+ Ok(DialogAnswer::Text(text)) if !text.trim().is_empty() => Some(text),
1059
+ _ => None,
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ impl ChatViewState {
1065
+ pub fn from_session(session: &InteractiveSession) -> Self {
1066
+ let mut state = Self {
1067
+ model: active_model(&session.config.ai_provider, &session.config).to_owned(),
1068
+ thinking_status: format_thinking_status(&session.config),
1069
+ provider: format_provider_display_name(&session.config.ai_provider, &session.config),
1070
+ workspace: format_workspace_with_branch(&session.current_dir),
1071
+ current_dir: session.current_dir.clone(),
1072
+ theme: crate::terminal_theme::TuiTheme::from_config(&session.config.tui_theme),
1073
+ plan_mode: session.plan_mode,
1074
+ history: session.history.clone(),
1075
+ ..Self::default()
1076
+ };
1077
+ state.reload_transcript(&session.chat_id, &session.current_dir);
1078
+ state
1079
+ }
1080
+ pub fn set_draft(&mut self, draft: String) {
1081
+ self.composer = draft.chars().collect();
1082
+ self.cursor = self.composer.len();
1083
+ }
1084
+ pub fn reload_transcript(&mut self, chat_id: &str, workspace: &Path) {
1085
+ if let Ok(memory) = mint_core::MemoryStore::open_default() {
1086
+ self.reload_transcript_with(&memory, chat_id, workspace);
1087
+ } else {
1088
+ self.chat_id = chat_id.to_owned();
1089
+ self.transcript.clear();
1090
+ self.last_interaction_id = 0;
1091
+ self.sync_cursor = 0;
1092
+ }
1093
+ }
1094
+
1095
+ pub fn reload_transcript_with(
1096
+ &mut self,
1097
+ memory: &mint_core::MemoryStore,
1098
+ chat_id: &str,
1099
+ workspace: &Path,
1100
+ ) {
1101
+ self.chat_id = chat_id.to_owned();
1102
+ self.transcript.clear();
1103
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
1104
+ self.sync_cursor = memory.latest_conversation_sequence(&scoped).unwrap_or(0);
1105
+ if let Ok(rows) = memory.interactions_for_chat(&scoped) {
1106
+ self.last_interaction_id = rows.last().map(|row| row.id).unwrap_or(0);
1107
+ for row in rows {
1108
+ self.transcript
1109
+ .push(TranscriptEntry::new(TranscriptRole::User, row.user_text));
1110
+ match row.status.as_str() {
1111
+ "completed" => self
1112
+ .transcript
1113
+ .push(TranscriptEntry::new(TranscriptRole::Assistant, row.ai_text)),
1114
+ "queued" => self.transcript.push(TranscriptEntry::new(
1115
+ TranscriptRole::Notice,
1116
+ "Queued for this session…",
1117
+ )),
1118
+ "running" => self.transcript.push(TranscriptEntry::new(
1119
+ TranscriptRole::Notice,
1120
+ "Mint is responding…",
1121
+ )),
1122
+ "failed" => self.transcript.push(TranscriptEntry::new(
1123
+ TranscriptRole::Notice,
1124
+ "This turn failed. Send it again to retry.",
1125
+ )),
1126
+ _ => self.transcript.push(TranscriptEntry::new(
1127
+ TranscriptRole::Notice,
1128
+ "This turn was interrupted. Send it again to retry.",
1129
+ )),
1130
+ }
1131
+ }
1132
+ } else {
1133
+ self.last_interaction_id = 0;
1134
+ }
1135
+ }
1136
+
1137
+ pub fn advance_sync_cursor(&mut self) {
1138
+ if let Ok(memory) = mint_core::MemoryStore::open_default() {
1139
+ self.advance_sync_cursor_with(&memory);
1140
+ }
1141
+ }
1142
+
1143
+ pub fn advance_sync_cursor_with(&mut self, memory: &mint_core::MemoryStore) {
1144
+ if self.chat_id.is_empty() {
1145
+ return;
1146
+ }
1147
+ let scoped =
1148
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1149
+ if let Ok(seq) = memory.latest_conversation_sequence(&scoped) {
1150
+ self.sync_cursor = seq;
1151
+ }
1152
+ if let Ok(rows) = memory.interactions_for_chat(&scoped) {
1153
+ if let Some(last) = rows.last() {
1154
+ self.last_interaction_id = last.id;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ fn refresh_shared_transcript(&mut self) -> bool {
1160
+ if self.chat_id.is_empty() {
1161
+ return false;
1162
+ }
1163
+ let Ok(memory) = mint_core::MemoryStore::open_default() else {
1164
+ return false;
1165
+ };
1166
+ self.refresh_shared_transcript_with(&memory)
1167
+ }
1168
+
1169
+ pub fn refresh_shared_transcript_with(&mut self, memory: &mint_core::MemoryStore) -> bool {
1170
+ if self.chat_id.is_empty() {
1171
+ return false;
1172
+ }
1173
+ let scoped =
1174
+ mint_core::scoped_chat_id(&self.chat_id, Some(&self.current_dir.to_string_lossy()));
1175
+ let Ok(changes) = memory.conversation_changes(&scoped, self.sync_cursor, 200) else {
1176
+ return false;
1177
+ };
1178
+ if changes.changes.is_empty() {
1179
+ return false;
1180
+ }
1181
+
1182
+ // If any interaction was deleted (e.g. /clear on Web or Desktop), do a full reload.
1183
+ if changes
1184
+ .changes
1185
+ .iter()
1186
+ .any(|change| change.interaction.is_none())
1187
+ {
1188
+ let chat_id = self.chat_id.clone();
1189
+ let workspace = self.current_dir.clone();
1190
+ self.reload_transcript_with(memory, &chat_id, &workspace);
1191
+ return true;
1192
+ }
1193
+
1194
+ let mut changed = false;
1195
+ for change in &changes.changes {
1196
+ let Some(row) = &change.interaction else {
1197
+ continue;
1198
+ };
1199
+ if row.id > self.last_interaction_id {
1200
+ // Incoming new interaction from an external client (Web/Desktop)
1201
+ self.transcript
1202
+ .push(TranscriptEntry::new(TranscriptRole::User, &row.user_text));
1203
+ match row.status.as_str() {
1204
+ "completed" => self.transcript.push(TranscriptEntry::new(
1205
+ TranscriptRole::Assistant,
1206
+ &row.ai_text,
1207
+ )),
1208
+ "queued" => self.transcript.push(TranscriptEntry::new(
1209
+ TranscriptRole::Notice,
1210
+ "Queued for this session…",
1211
+ )),
1212
+ "running" => self.transcript.push(TranscriptEntry::new(
1213
+ TranscriptRole::Notice,
1214
+ "Mint is responding…",
1215
+ )),
1216
+ "failed" => self.transcript.push(TranscriptEntry::new(
1217
+ TranscriptRole::Notice,
1218
+ "This turn failed. Send it again to retry.",
1219
+ )),
1220
+ _ => self.transcript.push(TranscriptEntry::new(
1221
+ TranscriptRole::Notice,
1222
+ "This turn was interrupted. Send it again to retry.",
1223
+ )),
1224
+ }
1225
+ self.last_interaction_id = row.id;
1226
+ changed = true;
1227
+ } else if row.id == self.last_interaction_id {
1228
+ // Status update for an external interaction that was previously queued/running
1229
+ if row.status == "completed" {
1230
+ if let Some(last) = self.transcript.last_mut() {
1231
+ if last.role == TranscriptRole::Notice
1232
+ && (last.text.starts_with("Mint is responding")
1233
+ || last.text.starts_with("Queued for this session"))
1234
+ {
1235
+ *last = TranscriptEntry::new(TranscriptRole::Assistant, &row.ai_text);
1236
+ changed = true;
1237
+ } else if last.role != TranscriptRole::Assistant {
1238
+ self.transcript.push(TranscriptEntry::new(
1239
+ TranscriptRole::Assistant,
1240
+ &row.ai_text,
1241
+ ));
1242
+ changed = true;
1243
+ }
1244
+ }
1245
+ } else if row.status == "running" {
1246
+ if let Some(last) = self.transcript.last_mut() {
1247
+ if last.role == TranscriptRole::Notice
1248
+ && last.text.starts_with("Queued for this session")
1249
+ {
1250
+ *last =
1251
+ TranscriptEntry::new(TranscriptRole::Notice, "Mint is responding…");
1252
+ changed = true;
1253
+ }
1254
+ }
1255
+ } else if matches!(row.status.as_str(), "failed" | "interrupted") {
1256
+ let msg = if row.status == "failed" {
1257
+ "This turn failed. Send it again to retry."
1258
+ } else {
1259
+ "This turn was interrupted. Send it again to retry."
1260
+ };
1261
+ if let Some(last) = self.transcript.last_mut() {
1262
+ if last.role == TranscriptRole::Notice
1263
+ && (last.text.starts_with("Mint is responding")
1264
+ || last.text.starts_with("Queued for this session"))
1265
+ {
1266
+ *last = TranscriptEntry::new(TranscriptRole::Notice, msg);
1267
+ changed = true;
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ self.sync_cursor = changes.cursor;
1275
+ changed
1276
+ }
1277
+ pub fn push_user(&mut self, text: String) {
1278
+ self.transcript
1279
+ .push(TranscriptEntry::new(TranscriptRole::User, text));
1280
+ self.scroll_from_bottom = 0;
1281
+ }
1282
+ pub fn push_notice(&mut self, text: impl Into<String>) {
1283
+ self.transcript
1284
+ .push(TranscriptEntry::new(TranscriptRole::Notice, text));
1285
+ self.scroll_from_bottom = 0;
1286
+ }
1287
+ pub fn push_assistant(&mut self, text: impl Into<String>) {
1288
+ self.transcript
1289
+ .push(TranscriptEntry::new(TranscriptRole::Assistant, text));
1290
+ self.scroll_from_bottom = 0;
1291
+ }
1292
+ pub fn clear_transcript(&mut self) {
1293
+ self.transcript.clear();
1294
+ self.scroll_from_bottom = 0;
1295
+ self.selection_mode = false;
1296
+ }
1297
+ pub fn sync_session(&mut self, session: &InteractiveSession) {
1298
+ self.model = active_model(&session.config.ai_provider, &session.config).to_owned();
1299
+ self.thinking_status = format_thinking_status(&session.config);
1300
+ self.provider = format_provider_display_name(&session.config.ai_provider, &session.config);
1301
+ self.workspace = format_workspace_with_branch(&session.current_dir);
1302
+ self.current_dir = session.current_dir.clone();
1303
+ self.plan_mode = session.plan_mode;
1304
+ self.history = session.history.clone();
1305
+ }
1306
+ fn history_previous(&mut self) {
1307
+ if self.history.is_empty() {
1308
+ return;
1309
+ }
1310
+ if self.history_index.is_none() {
1311
+ self.draft_before_history = self.composer.clone();
1312
+ }
1313
+ let index = self
1314
+ .history_index
1315
+ .map(|index| index.saturating_sub(1))
1316
+ .unwrap_or(self.history.len() - 1);
1317
+ self.history_index = Some(index);
1318
+ self.composer = self.history[index].chars().collect();
1319
+ self.cursor = self.composer.len();
1320
+ }
1321
+ fn history_next(&mut self) {
1322
+ let Some(index) = self.history_index else {
1323
+ return;
1324
+ };
1325
+ if index + 1 < self.history.len() {
1326
+ let next = index + 1;
1327
+ self.history_index = Some(next);
1328
+ self.composer = self.history[next].chars().collect();
1329
+ } else {
1330
+ self.history_index = None;
1331
+ self.composer = std::mem::take(&mut self.draft_before_history);
1332
+ }
1333
+ self.cursor = self.composer.len();
1334
+ }
1335
+ fn suggestions(&self) -> Vec<(String, String)> {
1336
+ let cursor = self.cursor.min(self.composer.len());
1337
+ let before: String = self.composer[..cursor].iter().collect();
1338
+ if before.starts_with('/') && !before.contains(char::is_whitespace) {
1339
+ return super::SLASH_COMMANDS
1340
+ .iter()
1341
+ .filter(|command| command.token.starts_with(&before))
1342
+ .map(|command| (command.token.clone(), command.description.clone()))
1343
+ .collect();
1344
+ }
1345
+ if before.starts_with('$') && !before.contains(char::is_whitespace) {
1346
+ let prefix = before[1..].to_lowercase();
1347
+ return super::load_all_available_skills(&self.current_dir)
1348
+ .into_iter()
1349
+ .filter(|skill| skill.name.to_lowercase().starts_with(&prefix))
1350
+ .map(|skill| {
1351
+ (
1352
+ format!("${}", skill.name),
1353
+ skill.description.unwrap_or_default(),
1354
+ )
1355
+ })
1356
+ .collect();
1357
+ }
1358
+ let start = self.composer[..cursor]
1359
+ .iter()
1360
+ .rposition(|c| c.is_whitespace())
1361
+ .map_or(0, |index| index + 1);
1362
+ if self.composer.get(start) == Some(&'@') {
1363
+ let query: String = self.composer[start..cursor].iter().collect();
1364
+ return super::collect_mention_candidates(&query, &self.current_dir)
1365
+ .into_iter()
1366
+ .map(|candidate| (candidate.label, candidate.description))
1367
+ .collect();
1368
+ }
1369
+ Vec::new()
1370
+ }
1371
+ fn apply_selected_suggestion(&mut self) -> bool {
1372
+ let suggestions = self.suggestions();
1373
+ let Some((token, _)) = suggestions.get(self.slash_selected % suggestions.len().max(1))
1374
+ else {
1375
+ return false;
1376
+ };
1377
+ let cursor = self.cursor.min(self.composer.len());
1378
+ let start = if token.starts_with('@') {
1379
+ self.composer[..cursor]
1380
+ .iter()
1381
+ .rposition(|c| c.is_whitespace())
1382
+ .map_or(0, |index| index + 1)
1383
+ } else {
1384
+ 0
1385
+ };
1386
+ let end = self.composer[cursor..]
1387
+ .iter()
1388
+ .position(|c| c.is_whitespace())
1389
+ .map_or(self.composer.len(), |offset| cursor + offset);
1390
+ let needs_space = end == self.composer.len();
1391
+ let replacement = if needs_space {
1392
+ format!("{token} ")
1393
+ } else {
1394
+ token.clone()
1395
+ };
1396
+ self.composer.splice(start..end, replacement.chars());
1397
+ self.cursor = start + token.chars().count() + 1;
1398
+ self.slash_selected = 0;
1399
+ true
1400
+ }
1401
+ pub fn set_notice(&mut self, text: impl Into<String>) {
1402
+ self.notice = Some((text.into(), std::time::Instant::now()));
1403
+ }
1404
+ pub fn clear_notice(&mut self) {
1405
+ self.notice = None;
1406
+ }
1407
+ pub fn active_notice(&self) -> Option<&str> {
1408
+ self.notice.as_ref().and_then(|(text, created)| {
1409
+ if text.starts_with("Terminal too small")
1410
+ || created.elapsed() < std::time::Duration::from_millis(2500)
1411
+ {
1412
+ Some(text.as_str())
1413
+ } else {
1414
+ None
1415
+ }
1416
+ })
1417
+ }
1418
+ fn notice_visibility_changed_since_draw(&self, painted_visible: bool) -> bool {
1419
+ painted_visible != self.active_notice().is_some()
1420
+ }
1421
+ fn transcript_text(&self) -> Text<'static> {
1422
+ let mut lines = Vec::new();
1423
+ let selected_start = self.selection_anchor.min(self.selection_head);
1424
+ let selected_end = self.selection_anchor.max(self.selection_head);
1425
+ let mut line_index = 0usize;
1426
+ for entry in &self.transcript {
1427
+ let role_header = match entry.role {
1428
+ TranscriptRole::User => Some(("You", crate::terminal_theme::BLUE)),
1429
+ TranscriptRole::Assistant => Some(("Mint", crate::terminal_theme::ACCENT)),
1430
+ TranscriptRole::Notice => None,
1431
+ TranscriptRole::Command => Some(("Command", crate::terminal_theme::ACCENT)),
1432
+ TranscriptRole::System => Some(("System", crate::terminal_theme::WARNING)),
1433
+ };
1434
+ if let Some((label, color)) = role_header {
1435
+ let selected =
1436
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1437
+ lines.push(Line::from(Span::styled(
1438
+ format!("{label} ›"),
1439
+ Style::default()
1440
+ .fg(color)
1441
+ .add_modifier(Modifier::BOLD)
1442
+ .add_modifier(if selected {
1443
+ Modifier::REVERSED
1444
+ } else {
1445
+ Modifier::empty()
1446
+ }),
1447
+ )));
1448
+ line_index += 1;
1449
+ }
1450
+ for content_line in &entry.rendered_lines {
1451
+ let selected =
1452
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1453
+ let mut sel_line = content_line.clone();
1454
+ if entry.role == TranscriptRole::Notice {
1455
+ for span in &mut sel_line.spans {
1456
+ if span.style.fg.is_none() {
1457
+ span.style = span.style.fg(crate::terminal_theme::TEXT);
1458
+ }
1459
+ }
1460
+ }
1461
+ // Short AI text replies have no explicit ANSI color — default
1462
+ // them to plain white so they don't appear in the terminal's
1463
+ // dim default foreground.
1464
+ if entry.role == TranscriptRole::Assistant {
1465
+ for span in &mut sel_line.spans {
1466
+ if span.style.fg.is_none() {
1467
+ span.style = span.style.fg(crate::terminal_theme::TEXT);
1468
+ }
1469
+ }
1470
+ }
1471
+ if selected {
1472
+ for span in &mut sel_line.spans {
1473
+ span.style = span.style.add_modifier(Modifier::REVERSED);
1474
+ }
1475
+ }
1476
+ lines.push(sel_line);
1477
+ line_index += 1;
1478
+ }
1479
+ let selected =
1480
+ self.selection_mode && (selected_start..=selected_end).contains(&line_index);
1481
+ lines.push(Line::styled(
1482
+ "",
1483
+ if selected {
1484
+ Style::default().add_modifier(Modifier::REVERSED)
1485
+ } else {
1486
+ Style::default()
1487
+ },
1488
+ ));
1489
+ line_index += 1;
1490
+ }
1491
+ Text::from(lines)
1492
+ }
1493
+ fn plain_transcript_lines(&self) -> Vec<String> {
1494
+ let mut lines = Vec::new();
1495
+ for entry in &self.transcript {
1496
+ if let Some(header) = match entry.role {
1497
+ TranscriptRole::User => Some("You ›"),
1498
+ TranscriptRole::Assistant => Some("Mint ›"),
1499
+ TranscriptRole::Notice => None,
1500
+ TranscriptRole::Command => Some("Command ›"),
1501
+ TranscriptRole::System => Some("System ›"),
1502
+ } {
1503
+ lines.push(header.to_owned());
1504
+ }
1505
+ lines.extend(entry.plain_lines.iter().cloned());
1506
+ lines.push(String::new());
1507
+ }
1508
+ lines
1509
+ }
1510
+ fn selected_text(&self) -> String {
1511
+ let lines = self.plain_transcript_lines();
1512
+ if lines.is_empty() {
1513
+ return String::new();
1514
+ }
1515
+ let start = self
1516
+ .selection_anchor
1517
+ .min(self.selection_head)
1518
+ .min(lines.len() - 1);
1519
+ let end = self
1520
+ .selection_anchor
1521
+ .max(self.selection_head)
1522
+ .min(lines.len() - 1);
1523
+ lines[start..=end].join("\n")
1524
+ }
1525
+ fn render(&self, frame: &mut ratatui::Frame<'_>) -> RenderAreas {
1526
+ let suggestions = self.suggestions();
1527
+ let suggestion_height = if suggestions.is_empty() {
1528
+ 0
1529
+ } else {
1530
+ (suggestions.len().min(5) as u16) + 1
1531
+ };
1532
+ let status_height = if self.status.is_empty() {
1533
+ 0
1534
+ } else {
1535
+ (self.status.len() as u16).min(8)
1536
+ };
1537
+ fn accent_logo_line(text: &str) -> Line<'static> {
1538
+ Line::styled(
1539
+ text.to_owned(),
1540
+ Style::default().fg(crate::terminal_theme::ACCENT),
1541
+ )
1542
+ }
1543
+
1544
+ /// Sweep a white glow across the thinking text, fading to dim gray at the edges.
1545
+ fn shimmer_thinking_line(line: &str, tick: usize) -> Line<'static> {
1546
+ // Split at the first " (" to isolate the verb from the timer suffix.
1547
+ let (verb, suffix) = if let Some(idx) = line.find(" (") {
1548
+ (&line[..idx], &line[idx..])
1549
+ } else {
1550
+ (line, "")
1551
+ };
1552
+
1553
+ let chars: Vec<char> = verb.chars().collect();
1554
+ let count = chars.len();
1555
+ let period = (count * 2).max(16);
1556
+ let spot = (tick % period) as f32 / period as f32 * count as f32;
1557
+
1558
+ let (dim_r, dim_g, dim_b) = crate::terminal_theme::THINKING_GLOW_DIM;
1559
+ let (bright_r, bright_g, bright_b) = crate::terminal_theme::THINKING_GLOW_BRIGHT;
1560
+ let mut spans: Vec<Span<'static>> = chars
1561
+ .iter()
1562
+ .enumerate()
1563
+ .map(|(i, &c)| {
1564
+ let dist = ((i as f32 - spot).abs() / (count as f32 * 0.35)).min(1.0);
1565
+ let brightness = ((1.0 - dist) * std::f32::consts::FRAC_PI_2).sin().powi(2);
1566
+ let r = (dim_r as f32 + (bright_r as f32 - dim_r as f32) * brightness).round()
1567
+ as u8;
1568
+ let g = (dim_g as f32 + (bright_g as f32 - dim_g as f32) * brightness).round()
1569
+ as u8;
1570
+ let b = (dim_b as f32 + (bright_b as f32 - dim_b as f32) * brightness).round()
1571
+ as u8;
1572
+ let style = Style::default()
1573
+ .fg(Color::Rgb(r, g, b))
1574
+ .add_modifier(Modifier::BOLD);
1575
+ Span::styled(c.to_string(), style)
1576
+ })
1577
+ .collect();
1578
+
1579
+ if !suffix.is_empty() {
1580
+ spans.push(Span::styled(
1581
+ suffix.to_string(),
1582
+ Style::default().fg(crate::terminal_theme::MUTED),
1583
+ ));
1584
+ }
1585
+
1586
+ Line::from(spans)
1587
+ }
1588
+
1589
+ fn format_tool_status_line(line: &str) -> Line<'static> {
1590
+ if line.trim_start().starts_with('│') {
1591
+ return Line::styled(
1592
+ line.to_string(),
1593
+ Style::default().fg(crate::terminal_theme::MUTED),
1594
+ );
1595
+ }
1596
+
1597
+ let (prefix, rest) = if let Some(branch_pos) = line.find('└') {
1598
+ let conn_end = branch_pos + '└'.len_utf8();
1599
+ (&line[..conn_end], line[conn_end..].trim_start())
1600
+ } else if line.starts_with(" ") {
1601
+ (" ", &line[6..])
1602
+ } else {
1603
+ ("", line)
1604
+ };
1605
+
1606
+ let mut spans = Vec::new();
1607
+ if !prefix.is_empty() {
1608
+ spans.push(Span::styled(
1609
+ if prefix.contains('└') {
1610
+ " └ ".to_string()
1611
+ } else {
1612
+ " ".to_string()
1613
+ },
1614
+ Style::default().fg(crate::terminal_theme::MUTED),
1615
+ ));
1616
+ }
1617
+
1618
+ const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1619
+ let first_char = rest.chars().next();
1620
+ let (icon_span, after_icon) = if first_char == Some('✓') {
1621
+ let span = Span::styled(
1622
+ "✓ ".to_string(),
1623
+ Style::default().fg(crate::terminal_theme::ACCENT),
1624
+ );
1625
+ (Some(span), rest['✓'.len_utf8()..].trim_start())
1626
+ } else if let Some(c) = first_char
1627
+ && SPINNER_CHARS.contains(&c)
1628
+ {
1629
+ let span = Span::styled(
1630
+ format!("{} ", c),
1631
+ Style::default()
1632
+ .fg(crate::terminal_theme::BLUE)
1633
+ .add_modifier(Modifier::BOLD),
1634
+ );
1635
+ (Some(span), rest[c.len_utf8()..].trim_start())
1636
+ } else {
1637
+ (None, rest)
1638
+ };
1639
+
1640
+ if let Some(s) = icon_span {
1641
+ spans.push(s);
1642
+ }
1643
+
1644
+ if let Some(timer_pos) = after_icon.rfind(" (") {
1645
+ let text = &after_icon[..timer_pos];
1646
+ let timer = &after_icon[timer_pos..];
1647
+ spans.push(Span::styled(
1648
+ text.to_string(),
1649
+ Style::default().fg(crate::terminal_theme::TEXT),
1650
+ ));
1651
+ spans.push(Span::styled(
1652
+ timer.to_string(),
1653
+ Style::default().fg(crate::terminal_theme::MUTED),
1654
+ ));
1655
+ } else {
1656
+ spans.push(Span::styled(
1657
+ after_icon.to_string(),
1658
+ Style::default().fg(crate::terminal_theme::TEXT),
1659
+ ));
1660
+ }
1661
+
1662
+ Line::from(spans)
1663
+ }
1664
+
1665
+ fn format_composer_row_spans(row: &str) -> Vec<Span<'static>> {
1666
+ let mut spans = Vec::new();
1667
+ let mut i = 0;
1668
+ let chars: Vec<char> = row.chars().collect();
1669
+ let mut last_end = 0;
1670
+
1671
+ while i < chars.len() {
1672
+ if chars[i] == '[' {
1673
+ let remaining: String = chars[i..].iter().collect();
1674
+ if remaining.starts_with("[Image") || remaining.starts_with("[Pasted text") {
1675
+ if let Some(end) = remaining.find(']') {
1676
+ let badge = &remaining[..=end];
1677
+ if i > last_end {
1678
+ let text: String = chars[last_end..i].iter().collect();
1679
+ spans.push(Span::raw(text));
1680
+ }
1681
+ spans.push(Span::styled(
1682
+ badge.to_string(),
1683
+ Style::default()
1684
+ .fg(crate::terminal_theme::BLUE)
1685
+ .add_modifier(Modifier::BOLD),
1686
+ ));
1687
+ i += end + 1;
1688
+ last_end = i;
1689
+ continue;
1690
+ }
1691
+ }
1692
+ }
1693
+ i += 1;
1694
+ }
1695
+ if last_end < chars.len() {
1696
+ let text: String = chars[last_end..].iter().collect();
1697
+ spans.push(Span::raw(text));
1698
+ }
1699
+ spans
1700
+ }
1701
+
1702
+ let horizontal_pad = if frame.area().width >= 80 {
1703
+ 2
1704
+ } else if frame.area().width >= 60 {
1705
+ 1
1706
+ } else {
1707
+ 0
1708
+ };
1709
+ let main_area = if horizontal_pad > 0 {
1710
+ Layout::default()
1711
+ .direction(Direction::Horizontal)
1712
+ .constraints([
1713
+ Constraint::Length(horizontal_pad),
1714
+ Constraint::Min(0),
1715
+ Constraint::Length(horizontal_pad),
1716
+ ])
1717
+ .split(frame.area())[1]
1718
+ } else {
1719
+ frame.area()
1720
+ };
1721
+
1722
+ let dialog_height = if let Some(dialog) = &self.dialog {
1723
+ let filtered_len = dialog.filtered_indices().len();
1724
+ let max_allowed = main_area.height.saturating_sub(7);
1725
+ if dialog.theme_picker.is_some() {
1726
+ 15.min(max_allowed)
1727
+ } else if dialog.resume_picker.is_some() {
1728
+ let visible_rows = filtered_len.clamp(1, 24);
1729
+ (8 + visible_rows as u16).min(max_allowed)
1730
+ } else {
1731
+ let body_lines = if dialog.body.is_empty() {
1732
+ 0
1733
+ } else {
1734
+ dialog
1735
+ .body
1736
+ .lines()
1737
+ .map(|line| {
1738
+ Paragraph::new(format!(" {line}"))
1739
+ .wrap(Wrap { trim: false })
1740
+ .line_count(main_area.width.max(1))
1741
+ })
1742
+ .sum()
1743
+ };
1744
+ let filter_lines = if dialog.filter.is_empty() { 0 } else { 1 };
1745
+ let content_lines = if dialog.input.is_some() {
1746
+ 3
1747
+ } else {
1748
+ filtered_len.max(1) + 3
1749
+ };
1750
+ // The top border consumes a row in addition to the title, body,
1751
+ // spacer, options, and footer counted above.
1752
+ let total = 1 + 1 + body_lines + filter_lines + 1 + content_lines;
1753
+ (total as u16).min(max_allowed)
1754
+ }
1755
+ } else {
1756
+ 0
1757
+ };
1758
+
1759
+ let thought_height = if self.thought_modal.is_some() {
1760
+ let max_allowed = main_area.height.saturating_sub(9).min(20).max(8);
1761
+ ((main_area.height * 4) / 10).clamp(8, max_allowed)
1762
+ } else {
1763
+ 0
1764
+ };
1765
+
1766
+ let composer_width = main_area.width.saturating_sub(4).max(1) as usize;
1767
+ let (composer_rows, cursor_row, cursor_col) =
1768
+ wrap_input_visual_into_rows(&self.composer, composer_width, self.cursor);
1769
+ let visible_composer_rows = composer_rows.len().clamp(1, 6);
1770
+ let composer_height = visible_composer_rows as u16 + 2;
1771
+ let composer_scroll = cursor_row
1772
+ .saturating_add(1)
1773
+ .saturating_sub(visible_composer_rows);
1774
+ let rows = if dialog_height > 0 {
1775
+ let dialog_status_height =
1776
+ status_height.min(main_area.height.saturating_sub(6 + 1 + dialog_height));
1777
+ Layout::default()
1778
+ .direction(Direction::Vertical)
1779
+ .constraints([
1780
+ Constraint::Length(6),
1781
+ Constraint::Min(1),
1782
+ Constraint::Length(dialog_status_height),
1783
+ Constraint::Length(dialog_height),
1784
+ ])
1785
+ .split(main_area)
1786
+ } else if thought_height > 0 {
1787
+ Layout::default()
1788
+ .direction(Direction::Vertical)
1789
+ .constraints([
1790
+ Constraint::Length(6),
1791
+ Constraint::Min(3),
1792
+ Constraint::Length(status_height),
1793
+ Constraint::Length(thought_height),
1794
+ ])
1795
+ .split(main_area)
1796
+ } else {
1797
+ Layout::default()
1798
+ .direction(Direction::Vertical)
1799
+ .constraints([
1800
+ Constraint::Length(6),
1801
+ Constraint::Min(3),
1802
+ Constraint::Length(status_height),
1803
+ Constraint::Length(suggestion_height),
1804
+ Constraint::Length(composer_height),
1805
+ Constraint::Length(1),
1806
+ ])
1807
+ .split(main_area)
1808
+ };
1809
+
1810
+ let line1_text = format!(
1811
+ "[Mint] v{} | Active AI: {}",
1812
+ env!("CARGO_PKG_VERSION"),
1813
+ self.provider
1814
+ );
1815
+ let now = chrono::Local::now();
1816
+ let buddhist_year = now.format("%Y").to_string().parse::<i32>().unwrap_or(2026) + 543;
1817
+ let line2_text = format!(
1818
+ "{}/{:02}/{} {:02}:{:02} • {}",
1819
+ now.format("%d"),
1820
+ now.format("%m"),
1821
+ buddhist_year,
1822
+ now.format("%H"),
1823
+ now.format("%M"),
1824
+ self.model
1825
+ );
1826
+ let content_width = line1_text.chars().count().max(line2_text.chars().count());
1827
+ let box_width = (content_width + 4) as u16;
1828
+
1829
+ let header_columns = Layout::default()
1830
+ .direction(Direction::Horizontal)
1831
+ .constraints([
1832
+ Constraint::Length(37),
1833
+ Constraint::Length(box_width),
1834
+ Constraint::Min(0),
1835
+ ])
1836
+ .split(rows[0]);
1837
+ let logo = Text::from(vec![
1838
+ accent_logo_line(" __ __ _ _ ___ _ ___"),
1839
+ accent_logo_line(r"| \/ (_)_ __ | |_ / __| | |_ _|"),
1840
+ accent_logo_line(r"| |\/| | | '_ \| _| (__| |__ | |"),
1841
+ accent_logo_line(r"|_| |_|_|_| |_|\___|\___|\___|___|"),
1842
+ ]);
1843
+ frame.render_widget(Paragraph::new(logo), header_columns[0]);
1844
+
1845
+ let details = Text::from(vec![
1846
+ Line::from(vec![
1847
+ Span::raw(" "),
1848
+ Span::styled(
1849
+ "[Mint] ",
1850
+ Style::default()
1851
+ .fg(crate::terminal_theme::ACCENT)
1852
+ .add_modifier(Modifier::BOLD),
1853
+ ),
1854
+ Span::styled(
1855
+ format!(
1856
+ "v{} | Active AI: {}",
1857
+ env!("CARGO_PKG_VERSION"),
1858
+ self.provider
1859
+ ),
1860
+ Style::default().add_modifier(Modifier::BOLD),
1861
+ ),
1862
+ ]),
1863
+ Line::from(vec![
1864
+ Span::raw(" "),
1865
+ Span::styled(
1866
+ line2_text,
1867
+ Style::default().fg(crate::terminal_theme::MUTED),
1868
+ ),
1869
+ ]),
1870
+ ]);
1871
+ let details_area = ratatui::layout::Rect {
1872
+ y: rows[0].y,
1873
+ height: 4,
1874
+ ..header_columns[1]
1875
+ };
1876
+ frame.render_widget(
1877
+ Paragraph::new(details).block(
1878
+ Block::default()
1879
+ .borders(Borders::ALL)
1880
+ .border_type(BorderType::Rounded)
1881
+ .border_style(Style::default().fg(crate::terminal_theme::MUTED)),
1882
+ ),
1883
+ details_area,
1884
+ );
1885
+ let help_area = ratatui::layout::Rect {
1886
+ y: rows[0].y + 4,
1887
+ height: 1,
1888
+ ..rows[0]
1889
+ };
1890
+ frame.render_widget(
1891
+ Paragraph::new("Type naturally or /help for commands. F6: Classic CLI. Ctrl+D exits."),
1892
+ help_area,
1893
+ );
1894
+
1895
+ let text = self.transcript_text();
1896
+ let total = Paragraph::new(text.clone())
1897
+ .wrap(Wrap { trim: false })
1898
+ .line_count(rows[1].width.max(1));
1899
+ let max_scroll = total.saturating_sub(rows[1].height as usize) as u16;
1900
+ let scroll = max_scroll.saturating_sub(self.scroll_from_bottom.min(max_scroll));
1901
+ frame.render_widget(
1902
+ Paragraph::new(text)
1903
+ .wrap(Wrap { trim: false })
1904
+ .scroll((scroll, 0)),
1905
+ rows[1],
1906
+ );
1907
+ if self.transcript.is_empty() && self.dialog.is_none() && self.thought_modal.is_none() {
1908
+ let watermark_height = WATERMARK_HEIGHT.min(rows[1].height);
1909
+ let watermark_width = WATERMARK_WIDTH.min(rows[1].width);
1910
+ let watermark_area = Rect::new(
1911
+ rows[1].x + rows[1].width.saturating_sub(watermark_width) / 2,
1912
+ rows[1].y + rows[1].height.saturating_sub(WATERMARK_HEIGHT) / 2,
1913
+ watermark_width,
1914
+ watermark_height,
1915
+ );
1916
+ let watermark = mint_m_watermark(
1917
+ self.watermark_animating.then_some(self.watermark_frame),
1918
+ watermark_height as usize,
1919
+ );
1920
+ frame.render_widget(
1921
+ Paragraph::new(watermark).alignment(ratatui::layout::Alignment::Center),
1922
+ watermark_area,
1923
+ );
1924
+ }
1925
+ if max_scroll > 0 {
1926
+ // Paragraph scroll ranges from 0 to content - viewport, while
1927
+ // Ratatui's scrollbar position ranges from 0 to content - 1.
1928
+ // Map between those ranges so both endpoints align with the track.
1929
+ let scrollbar_position = ((scroll as f64 / max_scroll as f64)
1930
+ * total.saturating_sub(1) as f64)
1931
+ .round() as usize;
1932
+ let mut bar = ScrollbarState::new(total)
1933
+ .viewport_content_length(rows[1].height as usize)
1934
+ .position(scrollbar_position);
1935
+ frame.render_stateful_widget(
1936
+ Scrollbar::new(ScrollbarOrientation::VerticalRight),
1937
+ rows[1],
1938
+ &mut bar,
1939
+ );
1940
+ }
1941
+ let back_to_bottom_area = if self.dialog.is_none()
1942
+ && self.thought_modal.is_none()
1943
+ && self.scroll_from_bottom.min(max_scroll) > 0
1944
+ && rows[1].height > 0
1945
+ && rows[1].width >= UnicodeWidthStr::width(BACK_TO_BOTTOM_LABEL) as u16 + 2
1946
+ {
1947
+ let width = UnicodeWidthStr::width(BACK_TO_BOTTOM_LABEL) as u16;
1948
+ let area = Rect::new(
1949
+ rows[1].x + (rows[1].width - width) / 2,
1950
+ rows[1].bottom() - 1,
1951
+ width,
1952
+ 1,
1953
+ );
1954
+ frame.render_widget(
1955
+ Paragraph::new(BACK_TO_BOTTOM_LABEL).style(
1956
+ Style::default()
1957
+ .fg(crate::terminal_theme::ACCENT)
1958
+ .bg(crate::terminal_theme::PANEL_BACKGROUND)
1959
+ .add_modifier(Modifier::BOLD),
1960
+ ),
1961
+ area,
1962
+ );
1963
+ Some(area)
1964
+ } else {
1965
+ None
1966
+ };
1967
+ if status_height > 0 {
1968
+ // Derive a tick counter from wall-clock time (ms / 80) so the
1969
+ // shimmer animation advances at ~12 fps regardless of the 30 ms
1970
+ // render loop frequency.
1971
+ let tick = std::time::SystemTime::now()
1972
+ .duration_since(std::time::SystemTime::UNIX_EPOCH)
1973
+ .unwrap_or_default()
1974
+ .as_millis() as usize
1975
+ / 80;
1976
+
1977
+ let status_lines: Vec<Line> = self
1978
+ .status
1979
+ .iter()
1980
+ .map(|line| {
1981
+ // Lines with a timing suffix: "Verb (elapsed · …)" —
1982
+ // shimmer animation from bright white (left) to dim (right).
1983
+ if line.find(" (").is_some()
1984
+ && !line.contains('└')
1985
+ && !line.starts_with(" ")
1986
+ && !line.contains('✓')
1987
+ && !line.chars().any(|c| {
1988
+ ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'].contains(&c)
1989
+ })
1990
+ {
1991
+ shimmer_thinking_line(line, tick)
1992
+ // Tool status lines (connected by └ or indented by 6 spaces)
1993
+ } else if line.contains('└') || line.starts_with(" ") {
1994
+ format_tool_status_line(line)
1995
+ } else if line.contains('●') || line.contains('○') {
1996
+ // Header line with bullet: bullet in Cyan bold, text in White bold
1997
+ let bullet_char = if line.contains('●') { '●' } else { '○' };
1998
+ if let Some(pos) = line.find(bullet_char) {
1999
+ let prefix = &line[..pos];
2000
+ let text = &line[pos + bullet_char.len_utf8()..];
2001
+ Line::from(vec![
2002
+ Span::raw(prefix.to_string()),
2003
+ Span::styled(
2004
+ bullet_char.to_string(),
2005
+ Style::default()
2006
+ .fg(crate::terminal_theme::BLUE)
2007
+ .add_modifier(Modifier::BOLD),
2008
+ ),
2009
+ Span::styled(
2010
+ text.to_string(),
2011
+ Style::default()
2012
+ .fg(crate::terminal_theme::TEXT)
2013
+ .add_modifier(Modifier::BOLD),
2014
+ ),
2015
+ ])
2016
+ } else {
2017
+ Line::styled(
2018
+ line.to_string(),
2019
+ Style::default()
2020
+ .fg(crate::terminal_theme::TEXT)
2021
+ .add_modifier(Modifier::BOLD),
2022
+ )
2023
+ }
2024
+ } else {
2025
+ // Activity/verb lines: "Reading 1 file…", "Running 2
2026
+ // commands…", etc. — bold white for clarity.
2027
+ Line::styled(
2028
+ line.to_string(),
2029
+ Style::default()
2030
+ .fg(crate::terminal_theme::TEXT)
2031
+ .add_modifier(Modifier::BOLD),
2032
+ )
2033
+ }
2034
+ })
2035
+ .collect();
2036
+ let skip = status_lines.len().saturating_sub(rows[2].height as usize);
2037
+ let visible_lines: Vec<Line> = status_lines.into_iter().skip(skip).collect();
2038
+ frame.render_widget(Paragraph::new(visible_lines), rows[2]);
2039
+ }
2040
+ if let Some(dialog) = &self.dialog {
2041
+ if dialog.theme_picker.is_some() {
2042
+ frame.render_widget(
2043
+ Paragraph::new(theme_picker_lines(dialog, rows[3].height)).block(
2044
+ Block::default()
2045
+ .borders(Borders::TOP)
2046
+ .border_style(Style::default().fg(crate::terminal_theme::MUTED)),
2047
+ ),
2048
+ rows[3],
2049
+ );
2050
+ } else if dialog.resume_picker.is_some() {
2051
+ let lines = resume_picker_lines(dialog, rows[3].width, rows[3].height);
2052
+ frame.render_widget(
2053
+ Paragraph::new(lines).block(
2054
+ Block::default()
2055
+ .borders(Borders::TOP)
2056
+ .border_style(Style::default().fg(crate::terminal_theme::MUTED)),
2057
+ ),
2058
+ rows[3],
2059
+ );
2060
+ } else {
2061
+ let mut lines = Vec::new();
2062
+ let filtered = dialog.filtered_indices();
2063
+ let total_options = filtered.len();
2064
+ let monochrome_picker = dialog.title == "Resume Session";
2065
+
2066
+ let title_suffix = if dialog.input.is_none() && !dialog.options.is_empty() {
2067
+ format!(
2068
+ " ({}/{})",
2069
+ (dialog.selected + 1).min(total_options),
2070
+ total_options
2071
+ )
2072
+ } else {
2073
+ String::new()
2074
+ };
2075
+
2076
+ lines.push(Line::from(vec![
2077
+ Span::raw(" "),
2078
+ Span::styled(
2079
+ &dialog.title,
2080
+ Style::default()
2081
+ .fg(if monochrome_picker {
2082
+ crate::terminal_theme::TEXT
2083
+ } else {
2084
+ crate::terminal_theme::BLUE
2085
+ })
2086
+ .add_modifier(Modifier::BOLD),
2087
+ ),
2088
+ Span::styled(
2089
+ title_suffix,
2090
+ Style::default().fg(crate::terminal_theme::MUTED),
2091
+ ),
2092
+ ]));
2093
+
2094
+ if !dialog.body.is_empty() {
2095
+ for line in dialog.body.lines() {
2096
+ lines.push(Line::from(vec![
2097
+ Span::raw(" "),
2098
+ Span::styled(
2099
+ line.to_owned(),
2100
+ Style::default().fg(crate::terminal_theme::MUTED),
2101
+ ),
2102
+ ]));
2103
+ }
2104
+ }
2105
+
2106
+ let mut filter_row_idx = None;
2107
+ if !dialog.filter.is_empty() {
2108
+ filter_row_idx = Some(lines.len());
2109
+ let filter_str: String = dialog.filter.iter().collect();
2110
+ lines.push(Line::from(vec![
2111
+ Span::raw(" "),
2112
+ Span::styled(
2113
+ "Filter: ",
2114
+ Style::default().fg(crate::terminal_theme::MUTED),
2115
+ ),
2116
+ Span::styled(
2117
+ filter_str,
2118
+ Style::default()
2119
+ .fg(if monochrome_picker {
2120
+ crate::terminal_theme::TEXT
2121
+ } else {
2122
+ crate::terminal_theme::WARNING
2123
+ })
2124
+ .add_modifier(Modifier::BOLD),
2125
+ ),
2126
+ Span::styled(
2127
+ format!(" ({total_options}/{})", dialog.options.len()),
2128
+ Style::default().fg(crate::terminal_theme::MUTED),
2129
+ ),
2130
+ ]));
2131
+ }
2132
+
2133
+ lines.push(Line::raw(""));
2134
+
2135
+ let mut input_cursor = None;
2136
+ if let Some(input) = &dialog.input {
2137
+ let input_text: String = input.iter().collect();
2138
+ let input_row_idx = lines.len();
2139
+ lines.push(Line::from(vec![
2140
+ Span::styled(
2141
+ " › ",
2142
+ Style::default()
2143
+ .fg(crate::terminal_theme::ACCENT)
2144
+ .add_modifier(Modifier::BOLD),
2145
+ ),
2146
+ Span::raw(input_text.clone()),
2147
+ Span::styled("█", Style::default().fg(crate::terminal_theme::ACCENT)),
2148
+ ]));
2149
+ lines.push(Line::raw(""));
2150
+ lines.push(Line::from(vec![
2151
+ Span::raw(" "),
2152
+ Span::styled(
2153
+ "Enter submit · Esc cancel",
2154
+ Style::default().fg(crate::terminal_theme::MUTED),
2155
+ ),
2156
+ ]));
2157
+ let visual_col = crate::markdown::unicode_width(&input_text);
2158
+ input_cursor = Some((visual_col, input_row_idx));
2159
+ } else {
2160
+ let header_height = Paragraph::new(lines.clone())
2161
+ .wrap(Wrap { trim: false })
2162
+ .line_count(rows[3].width.max(1));
2163
+ let footer_height = 3; // blank + hint + bottom padding
2164
+ let inner_height = (rows[3].height as usize).saturating_sub(1);
2165
+ let available_rows = inner_height.saturating_sub(header_height + footer_height);
2166
+ // Show every choice when it fits. Reserve space for both
2167
+ // scroll indicators only when the list exceeds the viewport.
2168
+ let visible_rows = if total_options > available_rows {
2169
+ available_rows.saturating_sub(2).max(1)
2170
+ } else {
2171
+ available_rows.max(1)
2172
+ };
2173
+
2174
+ let scroll_offset = if dialog.selected < dialog.scroll_offset {
2175
+ dialog.selected
2176
+ } else if dialog.selected >= dialog.scroll_offset + visible_rows {
2177
+ dialog.selected + 1 - visible_rows
2178
+ } else {
2179
+ dialog.scroll_offset
2180
+ };
2181
+ let max_scroll = total_options.saturating_sub(visible_rows);
2182
+ let scroll_offset = scroll_offset.min(max_scroll);
2183
+
2184
+ if total_options == 0 {
2185
+ lines.push(Line::from(vec![
2186
+ Span::raw(" "),
2187
+ Span::styled(
2188
+ "(No matching options)",
2189
+ Style::default().fg(crate::terminal_theme::MUTED),
2190
+ ),
2191
+ ]));
2192
+ } else {
2193
+ if scroll_offset > 0 {
2194
+ lines.push(Line::from(vec![
2195
+ Span::raw(" "),
2196
+ Span::styled(
2197
+ format!("▲ {scroll_offset} more above"),
2198
+ Style::default().fg(crate::terminal_theme::MUTED),
2199
+ ),
2200
+ ]));
2201
+ }
2202
+
2203
+ let end_idx = (scroll_offset + visible_rows).min(total_options);
2204
+ for idx in scroll_offset..end_idx {
2205
+ let original_idx = filtered[idx];
2206
+ let option_text = &dialog.options[original_idx];
2207
+ let is_selected = idx == dialog.selected;
2208
+
2209
+ let shortcut = if dialog.filter.is_empty() && idx < 9 {
2210
+ format!("{}. ", idx + 1)
2211
+ } else {
2212
+ " ".to_string()
2213
+ };
2214
+
2215
+ let marker = match &dialog.checked {
2216
+ Some(checked)
2217
+ if checked.get(original_idx).copied().unwrap_or(false) =>
2218
+ {
2219
+ "[✓] "
2220
+ }
2221
+ Some(_) => "[ ] ",
2222
+ None => "",
2223
+ };
2224
+
2225
+ let (label, desc) = if let Some((l, d)) = option_text.split_once(" — ")
2226
+ {
2227
+ (l, Some(d))
2228
+ } else if let Some((l, d)) = option_text.split_once('\t') {
2229
+ (l, Some(d))
2230
+ } else {
2231
+ (option_text.as_str(), None)
2232
+ };
2233
+
2234
+ if is_selected {
2235
+ let selected_color = if monochrome_picker {
2236
+ crate::terminal_theme::SELECTION_TEXT
2237
+ } else {
2238
+ crate::terminal_theme::ACCENT
2239
+ };
2240
+ let selected_style = if monochrome_picker {
2241
+ Style::default()
2242
+ .fg(crate::terminal_theme::SELECTION_TEXT)
2243
+ .bg(crate::terminal_theme::SELECTION_BACKGROUND)
2244
+ .add_modifier(Modifier::BOLD)
2245
+ } else {
2246
+ Style::default()
2247
+ .fg(selected_color)
2248
+ .add_modifier(Modifier::BOLD)
2249
+ };
2250
+ let mut spans = vec![
2251
+ Span::styled(" › ", selected_style),
2252
+ Span::styled(shortcut, selected_style),
2253
+ Span::styled(marker, selected_style),
2254
+ Span::styled(label, selected_style),
2255
+ ];
2256
+ if let Some(desc) = desc {
2257
+ spans.push(Span::styled(" ", selected_style));
2258
+ spans.push(Span::styled(desc, selected_style));
2259
+ }
2260
+ lines.push(Line::from(spans));
2261
+ } else {
2262
+ let mut spans = vec![
2263
+ Span::raw(" "),
2264
+ Span::styled(
2265
+ shortcut,
2266
+ Style::default().fg(crate::terminal_theme::MUTED),
2267
+ ),
2268
+ Span::styled(
2269
+ marker,
2270
+ Style::default().fg(crate::terminal_theme::MUTED),
2271
+ ),
2272
+ Span::styled(
2273
+ label,
2274
+ Style::default().fg(crate::terminal_theme::TEXT),
2275
+ ),
2276
+ ];
2277
+ if let Some(desc) = desc {
2278
+ spans.push(Span::styled(" ", Style::default()));
2279
+ spans.push(Span::styled(
2280
+ desc,
2281
+ Style::default().fg(crate::terminal_theme::MUTED),
2282
+ ));
2283
+ }
2284
+ lines.push(Line::from(spans));
2285
+ }
2286
+ }
2287
+
2288
+ let remaining = total_options.saturating_sub(end_idx);
2289
+ if remaining > 0 {
2290
+ lines.push(Line::from(vec![
2291
+ Span::raw(" "),
2292
+ Span::styled(
2293
+ format!("▼ {remaining} more below"),
2294
+ Style::default().fg(crate::terminal_theme::MUTED),
2295
+ ),
2296
+ ]));
2297
+ }
2298
+ }
2299
+
2300
+ lines.push(Line::raw(""));
2301
+ let hint = if dialog.checked.is_some() {
2302
+ " Space toggle · Enter confirm · 1-9 pick · Type to filter · Esc cancel"
2303
+ } else {
2304
+ " ↑/↓ navigate · 1-9 pick · Type to filter · Enter select · Esc cancel"
2305
+ };
2306
+ lines.push(Line::styled(
2307
+ hint,
2308
+ Style::default().fg(crate::terminal_theme::MUTED),
2309
+ ));
2310
+ lines.push(Line::raw(""));
2311
+ }
2312
+
2313
+ frame.render_widget(
2314
+ Paragraph::new(lines).wrap(Wrap { trim: false }).block(
2315
+ Block::default()
2316
+ .borders(Borders::TOP)
2317
+ .border_style(Style::default().fg(crate::terminal_theme::MUTED)),
2318
+ ),
2319
+ rows[3],
2320
+ );
2321
+
2322
+ if let Some((visual_col, row_idx)) = input_cursor {
2323
+ frame.set_cursor_position(Position::new(
2324
+ rows[3].x + 3 + visual_col as u16,
2325
+ rows[3].y + 1 + row_idx as u16,
2326
+ ));
2327
+ } else if let Some(row_idx) = filter_row_idx {
2328
+ let filter_str: String = dialog.filter.iter().collect();
2329
+ let visual_col = crate::markdown::unicode_width(&filter_str);
2330
+ frame.set_cursor_position(Position::new(
2331
+ rows[3].x + 1 + 8 + visual_col as u16,
2332
+ rows[3].y + 1 + row_idx as u16,
2333
+ ));
2334
+ }
2335
+ }
2336
+ } else if let Some(modal) = &self.thought_modal {
2337
+ let panel_area = rows[3];
2338
+ let content_width = (panel_area.width as usize).saturating_sub(4).max(20);
2339
+ let mut wrapped_lines: Vec<String> = Vec::new();
2340
+ for paragraph in &modal.lines {
2341
+ let p_trimmed = paragraph.trim_end();
2342
+ if p_trimmed.is_empty() {
2343
+ wrapped_lines.push(String::new());
2344
+ } else {
2345
+ let options = textwrap::Options::new(content_width).break_words(true);
2346
+ let filled = textwrap::fill(p_trimmed, &options);
2347
+ for l in filled.lines() {
2348
+ wrapped_lines.push(l.to_string());
2349
+ }
2350
+ }
2351
+ }
2352
+
2353
+ let inner_height = (panel_area.height as usize).saturating_sub(2);
2354
+ let max_scroll = wrapped_lines.len().saturating_sub(inner_height);
2355
+ let scroll = modal.scroll.min(max_scroll);
2356
+
2357
+ let scroll_hint = if max_scroll > 0 {
2358
+ format!(
2359
+ " ↑/↓ scroll {}/{} · Esc / Ctrl+T to close ",
2360
+ scroll + 1,
2361
+ max_scroll + 1
2362
+ )
2363
+ } else {
2364
+ " Esc / Ctrl+T to close ".to_string()
2365
+ };
2366
+
2367
+ let block = Block::default()
2368
+ .borders(Borders::TOP)
2369
+ .border_style(Style::default().fg(crate::terminal_theme::BLUE))
2370
+ .title(Span::styled(
2371
+ format!(" Thought Process ({}) ", modal.elapsed_str),
2372
+ Style::default()
2373
+ .fg(crate::terminal_theme::BLUE)
2374
+ .add_modifier(Modifier::BOLD),
2375
+ ))
2376
+ .title_bottom(Span::styled(
2377
+ scroll_hint,
2378
+ Style::default().fg(crate::terminal_theme::MUTED),
2379
+ ));
2380
+
2381
+ let visible = &wrapped_lines[scroll..(scroll + inner_height).min(wrapped_lines.len())];
2382
+ let mut p_lines: Vec<Line> = Vec::with_capacity(inner_height);
2383
+ for l in visible {
2384
+ p_lines.push(Line::from(vec![
2385
+ Span::raw(" "),
2386
+ Span::styled(l.clone(), Style::default().fg(crate::terminal_theme::TEXT)),
2387
+ ]));
2388
+ }
2389
+ for _ in visible.len()..inner_height {
2390
+ p_lines.push(Line::from(""));
2391
+ }
2392
+
2393
+ let paragraph = Paragraph::new(p_lines).block(block);
2394
+ frame.render_widget(paragraph, panel_area);
2395
+ } else {
2396
+ if suggestion_height > 0 {
2397
+ let selected = self.slash_selected.min(suggestions.len() - 1);
2398
+ let page_start = (selected / 5) * 5;
2399
+ let lines = std::iter::once(Line::styled(
2400
+ "Suggestions · ↑/↓ select · Tab complete · Enter send",
2401
+ Style::default().fg(crate::terminal_theme::MUTED),
2402
+ ))
2403
+ .chain(suggestions.iter().enumerate().skip(page_start).take(5).map(
2404
+ |(index, (token, description))| {
2405
+ Line::styled(
2406
+ format!(
2407
+ "{} {:<18} {description}",
2408
+ if index == selected { "›" } else { " " },
2409
+ token
2410
+ ),
2411
+ if index == selected {
2412
+ Style::default()
2413
+ .fg(crate::terminal_theme::BLUE)
2414
+ .add_modifier(Modifier::BOLD | Modifier::REVERSED)
2415
+ } else {
2416
+ Style::default().fg(crate::terminal_theme::MUTED)
2417
+ },
2418
+ )
2419
+ },
2420
+ ))
2421
+ .collect::<Vec<_>>();
2422
+ frame.render_widget(Paragraph::new(lines), rows[3]);
2423
+ }
2424
+ let input = if self.composer.is_empty() {
2425
+ Text::from(Line::from(vec![
2426
+ Span::styled(
2427
+ " › ",
2428
+ Style::default()
2429
+ .fg(crate::terminal_theme::ACCENT)
2430
+ .add_modifier(Modifier::BOLD),
2431
+ ),
2432
+ Span::styled(
2433
+ "Ask anything...",
2434
+ Style::default().fg(crate::terminal_theme::MUTED),
2435
+ ),
2436
+ ]))
2437
+ } else {
2438
+ Text::from(
2439
+ composer_rows
2440
+ .iter()
2441
+ .enumerate()
2442
+ .map(|(index, row)| {
2443
+ let mut spans = vec![Span::styled(
2444
+ if index == 0 { " › " } else { " " },
2445
+ Style::default()
2446
+ .fg(crate::terminal_theme::ACCENT)
2447
+ .add_modifier(Modifier::BOLD),
2448
+ )];
2449
+ spans.extend(format_composer_row_spans(row));
2450
+ Line::from(spans)
2451
+ })
2452
+ .collect::<Vec<_>>(),
2453
+ )
2454
+ };
2455
+ frame.render_widget(
2456
+ Paragraph::new(input)
2457
+ .wrap(Wrap { trim: false })
2458
+ .scroll((composer_scroll as u16, 0))
2459
+ .block(
2460
+ Block::default()
2461
+ .borders(Borders::TOP | Borders::BOTTOM)
2462
+ .border_style(Style::default().fg(crate::terminal_theme::MUTED)),
2463
+ ),
2464
+ rows[4],
2465
+ );
2466
+
2467
+ let mode_label = if self.plan_mode {
2468
+ " [Plan] "
2469
+ } else {
2470
+ " [Agent] "
2471
+ };
2472
+ let bg_running = mint_core::bg_shell::running_count();
2473
+ let jobs_prefix = if bg_running > 0 {
2474
+ format!(
2475
+ "{} bg shell{} · ",
2476
+ bg_running,
2477
+ if bg_running == 1 { "" } else { "s" }
2478
+ )
2479
+ } else {
2480
+ String::new()
2481
+ };
2482
+ let path_text = format!("path: {}", self.workspace);
2483
+ let thinking_text = format!("• {}", self.thinking_status);
2484
+ let left_len = mode_label.chars().count()
2485
+ + self.model.chars().count()
2486
+ + thinking_text.chars().count()
2487
+ + 1;
2488
+ let right_len = jobs_prefix.chars().count() + path_text.chars().count();
2489
+ let available = rows[5].width as usize;
2490
+ let gap = available.saturating_sub(left_len + right_len);
2491
+ let footer = if let Some(notice) = self.active_notice() {
2492
+ Line::styled(
2493
+ format!(" {notice}"),
2494
+ Style::default()
2495
+ .fg(crate::terminal_theme::WARNING)
2496
+ .add_modifier(Modifier::BOLD),
2497
+ )
2498
+ } else if self.selection_mode {
2499
+ Line::styled(
2500
+ " Selection mode · ↑/↓ scroll · Enter/y copy · Esc cancel",
2501
+ Style::default().fg(crate::terminal_theme::WARNING),
2502
+ )
2503
+ } else {
2504
+ Line::from(vec![
2505
+ Span::styled(
2506
+ mode_label,
2507
+ Style::default().fg(crate::terminal_theme::MUTED),
2508
+ ),
2509
+ Span::styled(
2510
+ &self.model,
2511
+ Style::default()
2512
+ .fg(crate::terminal_theme::ACCENT)
2513
+ .add_modifier(Modifier::BOLD),
2514
+ ),
2515
+ Span::raw(" "),
2516
+ Span::styled(
2517
+ thinking_text,
2518
+ Style::default().fg(crate::terminal_theme::ACCENT),
2519
+ ),
2520
+ Span::raw(" ".repeat(gap)),
2521
+ Span::styled(
2522
+ jobs_prefix,
2523
+ Style::default().fg(crate::terminal_theme::BLUE),
2524
+ ),
2525
+ Span::styled(path_text, Style::default().fg(crate::terminal_theme::MUTED)),
2526
+ ])
2527
+ };
2528
+ frame.render_widget(Paragraph::new(footer), rows[5]);
2529
+
2530
+ if !self.selection_mode {
2531
+ let row_start = self.cursor.saturating_sub(cursor_col);
2532
+ let visual_col = UnicodeWidthStr::width(
2533
+ self.composer[row_start..self.cursor]
2534
+ .iter()
2535
+ .collect::<String>()
2536
+ .as_str(),
2537
+ );
2538
+ frame.set_cursor_position(Position::new(
2539
+ rows[4].x + 3 + visual_col as u16,
2540
+ rows[4].y
2541
+ + 1
2542
+ + cursor_row
2543
+ .saturating_sub(composer_scroll)
2544
+ .min(visible_composer_rows.saturating_sub(1))
2545
+ as u16,
2546
+ ));
2547
+ }
2548
+ }
2549
+ RenderAreas {
2550
+ back_to_bottom: back_to_bottom_area,
2551
+ composer: rows.get(4).copied(),
2552
+ watermark: if self.transcript.is_empty()
2553
+ && self.dialog.is_none()
2554
+ && self.thought_modal.is_none()
2555
+ {
2556
+ Some(Rect::new(
2557
+ rows[1].x
2558
+ + rows[1]
2559
+ .width
2560
+ .saturating_sub(WATERMARK_WIDTH.min(rows[1].width))
2561
+ / 2,
2562
+ rows[1].y + rows[1].height.saturating_sub(WATERMARK_HEIGHT) / 2,
2563
+ WATERMARK_WIDTH.min(rows[1].width),
2564
+ WATERMARK_HEIGHT.min(rows[1].height),
2565
+ ))
2566
+ } else {
2567
+ None
2568
+ },
2569
+ }
2570
+ }
2571
+ }
2572
+
2573
+ #[derive(Default)]
2574
+ struct RenderAreas {
2575
+ back_to_bottom: Option<Rect>,
2576
+ composer: Option<Rect>,
2577
+ watermark: Option<Rect>,
2578
+ }
2579
+
2580
+ pub(crate) struct ChatTui {
2581
+ terminal: Terminal<CrosstermBackend<io::Stdout>>,
2582
+ active: bool,
2583
+ mouse_selection: MouseSelection,
2584
+ last_frame: Option<Buffer>,
2585
+ back_to_bottom_area: Option<Rect>,
2586
+ composer_area: Option<Rect>,
2587
+ watermark_area: Option<Rect>,
2588
+ clipboard: TextClipboard,
2589
+ }
2590
+
2591
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
2592
+ struct ScreenPoint {
2593
+ row: u16,
2594
+ column: u16,
2595
+ }
2596
+
2597
+ impl ScreenPoint {
2598
+ fn from_mouse(mouse: event::MouseEvent) -> Self {
2599
+ Self {
2600
+ row: mouse.row,
2601
+ column: mouse.column,
2602
+ }
2603
+ }
2604
+ }
2605
+
2606
+ #[derive(Debug, Clone, Copy, Default)]
2607
+ struct MouseSelection {
2608
+ anchor: Option<ScreenPoint>,
2609
+ head: Option<ScreenPoint>,
2610
+ dragging: bool,
2611
+ had_drag: bool,
2612
+ }
2613
+
2614
+ impl MouseSelection {
2615
+ fn start(&mut self, point: ScreenPoint) {
2616
+ self.anchor = Some(point);
2617
+ self.head = Some(point);
2618
+ self.dragging = true;
2619
+ self.had_drag = false;
2620
+ }
2621
+
2622
+ fn update(&mut self, point: ScreenPoint) {
2623
+ if self.dragging {
2624
+ self.head = Some(point);
2625
+ self.had_drag = true;
2626
+ }
2627
+ }
2628
+
2629
+ fn finish(&mut self, point: ScreenPoint) -> bool {
2630
+ if !self.dragging {
2631
+ return false;
2632
+ }
2633
+ self.head = Some(point);
2634
+ self.dragging = false;
2635
+ if self.had_drag {
2636
+ true
2637
+ } else {
2638
+ self.clear();
2639
+ false
2640
+ }
2641
+ }
2642
+
2643
+ fn clear(&mut self) {
2644
+ *self = Self::default();
2645
+ }
2646
+
2647
+ fn range(&self) -> Option<(ScreenPoint, ScreenPoint)> {
2648
+ let anchor = self.anchor?;
2649
+ let head = self.head?;
2650
+ Some(if anchor <= head {
2651
+ (anchor, head)
2652
+ } else {
2653
+ (head, anchor)
2654
+ })
2655
+ }
2656
+
2657
+ fn endpoints(&self) -> Option<(ScreenPoint, ScreenPoint)> {
2658
+ Some((self.anchor?, self.head?))
2659
+ }
2660
+ }
2661
+
2662
+ fn input_index_at(
2663
+ point: ScreenPoint,
2664
+ area: Rect,
2665
+ chars: &[char],
2666
+ row_width: usize,
2667
+ cursor_pos: usize,
2668
+ end_after_character: bool,
2669
+ ) -> Option<usize> {
2670
+ let first_text_row = area.y + 1;
2671
+ let visible_row = point.row.checked_sub(first_text_row)? as usize;
2672
+ let visible_rows = ((area.height as usize).saturating_sub(2)).min(6);
2673
+ if visible_row >= visible_rows {
2674
+ return None;
2675
+ }
2676
+
2677
+ let (rows, cursor_row, _) = wrap_input_visual_into_rows(chars, row_width, cursor_pos);
2678
+ let scroll = cursor_row.saturating_add(1).saturating_sub(visible_rows);
2679
+ let row_index = visible_row + scroll;
2680
+ let row = rows.get(row_index)?;
2681
+ let mut start = 0usize;
2682
+ for prior_row in rows.iter().take(row_index) {
2683
+ start += prior_row.chars().count();
2684
+ if chars.get(start) == Some(&'\n') {
2685
+ start += 1;
2686
+ }
2687
+ }
2688
+
2689
+ let text_x = area.x.saturating_add(3);
2690
+ let target_column = point.column.saturating_sub(text_x) as usize;
2691
+ let row_chars: Vec<char> = row.chars().collect();
2692
+ let mut visual_column = 0usize;
2693
+ for (index, character) in row_chars.iter().enumerate() {
2694
+ let width = UnicodeWidthStr::width(character.to_string().as_str()).max(1);
2695
+ if target_column < visual_column + width {
2696
+ return Some(start + index + usize::from(end_after_character));
2697
+ }
2698
+ visual_column += width;
2699
+ }
2700
+ Some(start + row_chars.len())
2701
+ }
2702
+
2703
+ fn wrap_input_visual_into_rows(
2704
+ input_chars: &[char],
2705
+ row_width: usize,
2706
+ cursor_pos: usize,
2707
+ ) -> (Vec<String>, usize, usize) {
2708
+ let row_width = row_width.max(1);
2709
+ let mut rows: Vec<String> = Vec::new();
2710
+ let mut row_starts = Vec::new();
2711
+ let mut row_start = 0usize;
2712
+ let mut visual_width = 0usize;
2713
+
2714
+ for (index, character) in input_chars.iter().copied().enumerate() {
2715
+ if character == '\n' {
2716
+ rows.push(input_chars[row_start..index].iter().collect());
2717
+ row_starts.push(row_start);
2718
+ row_start = index + 1;
2719
+ visual_width = 0;
2720
+ continue;
2721
+ }
2722
+
2723
+ let character_width = UnicodeWidthStr::width(character.to_string().as_str());
2724
+ if visual_width > 0 && visual_width + character_width > row_width {
2725
+ rows.push(input_chars[row_start..index].iter().collect());
2726
+ row_starts.push(row_start);
2727
+ row_start = index;
2728
+ visual_width = 0;
2729
+ }
2730
+ visual_width += character_width;
2731
+ }
2732
+
2733
+ if row_start < input_chars.len() || input_chars.last() == Some(&'\n') || rows.is_empty() {
2734
+ rows.push(input_chars[row_start..].iter().collect());
2735
+ row_starts.push(row_start);
2736
+ }
2737
+
2738
+ let cursor_pos = cursor_pos.min(input_chars.len());
2739
+ let mut cursor_row = rows.len().saturating_sub(1);
2740
+ for (index, start) in row_starts.iter().copied().enumerate() {
2741
+ let end = start + rows[index].chars().count();
2742
+ let next_starts_here = row_starts.get(index + 1) == Some(&cursor_pos);
2743
+ if cursor_pos < end || (cursor_pos == end && !next_starts_here) {
2744
+ cursor_row = index;
2745
+ break;
2746
+ }
2747
+ if cursor_pos == start {
2748
+ cursor_row = index;
2749
+ break;
2750
+ }
2751
+ }
2752
+ let cursor_col = cursor_pos.saturating_sub(row_starts[cursor_row]);
2753
+ (rows, cursor_row, cursor_col)
2754
+ }
2755
+
2756
+ #[derive(Default)]
2757
+ struct TextClipboard {
2758
+ native: Option<Box<dyn NativeClipboard>>,
2759
+ initialization_attempted: bool,
2760
+ }
2761
+
2762
+ trait NativeClipboard {
2763
+ fn set_text(&mut self, text: String) -> std::result::Result<(), String>;
2764
+ }
2765
+
2766
+ impl NativeClipboard for arboard::Clipboard {
2767
+ fn set_text(&mut self, text: String) -> std::result::Result<(), String> {
2768
+ arboard::Clipboard::set_text(self, text).map_err(|error| error.to_string())
2769
+ }
2770
+ }
2771
+
2772
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2773
+ enum CopyMethod {
2774
+ Native,
2775
+ Osc52,
2776
+ }
2777
+
2778
+ impl TextClipboard {
2779
+ fn copy<W: Write>(&mut self, text: &str, writer: &mut W) -> io::Result<CopyMethod> {
2780
+ if !self.initialization_attempted {
2781
+ self.initialization_attempted = true;
2782
+ self.native = arboard::Clipboard::new()
2783
+ .ok()
2784
+ .map(|clipboard| Box::new(clipboard) as Box<dyn NativeClipboard>);
2785
+ }
2786
+ if let Some(clipboard) = self.native.as_mut()
2787
+ && clipboard.set_text(text.to_owned()).is_ok()
2788
+ {
2789
+ return Ok(CopyMethod::Native);
2790
+ }
2791
+
2792
+ write!(writer, "\x1b]52;c;{}\x07", BASE64.encode(text.as_bytes()))?;
2793
+ writer.flush()?;
2794
+ Ok(CopyMethod::Osc52)
2795
+ }
2796
+ }
2797
+
2798
+ fn apply_mouse_selection(buffer: &mut Buffer, selection: MouseSelection) {
2799
+ for selected in selected_rows(buffer.area, selection) {
2800
+ for column in selected.left..=selected.right {
2801
+ if let Some(cell) = buffer.cell_mut(Position::new(column, selected.row)) {
2802
+ cell.modifier.insert(Modifier::REVERSED);
2803
+ }
2804
+ }
2805
+ }
2806
+ }
2807
+
2808
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2809
+ struct SelectedRow {
2810
+ row: u16,
2811
+ left: u16,
2812
+ right: u16,
2813
+ }
2814
+
2815
+ fn selected_rows(area: Rect, selection: MouseSelection) -> Vec<SelectedRow> {
2816
+ let Some((start, end)) = selection.range() else {
2817
+ return Vec::new();
2818
+ };
2819
+ if area.is_empty() {
2820
+ return Vec::new();
2821
+ }
2822
+ let top = start.row.max(area.top());
2823
+ let bottom = end.row.min(area.bottom().saturating_sub(1));
2824
+ if top > bottom {
2825
+ return Vec::new();
2826
+ }
2827
+
2828
+ (top..=bottom)
2829
+ .filter_map(|row| {
2830
+ let left = if row == start.row {
2831
+ start.column
2832
+ } else {
2833
+ area.left()
2834
+ }
2835
+ .max(area.left());
2836
+ let right = if row == end.row {
2837
+ end.column
2838
+ } else {
2839
+ area.right().saturating_sub(1)
2840
+ }
2841
+ .min(area.right().saturating_sub(1));
2842
+ (left <= right).then_some(SelectedRow { row, left, right })
2843
+ })
2844
+ .collect()
2845
+ }
2846
+
2847
+ fn selected_screen_text(buffer: &Buffer, selection: MouseSelection) -> String {
2848
+ let mut lines = Vec::new();
2849
+ for selected in selected_rows(buffer.area, selection) {
2850
+ let mut line = String::new();
2851
+ let mut column = buffer.area.left();
2852
+ while column < buffer.area.right() {
2853
+ let Some(cell) = buffer.cell(Position::new(column, selected.row)) else {
2854
+ break;
2855
+ };
2856
+ let symbol = cell.symbol();
2857
+ let width = UnicodeWidthStr::width(symbol).max(1) as u16;
2858
+ let symbol_right = column.saturating_add(width.saturating_sub(1));
2859
+ if symbol_right >= selected.left && column <= selected.right {
2860
+ line.push_str(symbol);
2861
+ }
2862
+ column = column.saturating_add(width);
2863
+ }
2864
+ lines.push(line.trim_end_matches(' ').to_owned());
2865
+ }
2866
+ lines.join("\n")
2867
+ }
2868
+
2869
+ fn selected_cells_match(previous: &Buffer, current: &Buffer, selection: MouseSelection) -> bool {
2870
+ if previous.area != current.area {
2871
+ return false;
2872
+ }
2873
+ let rows = selected_rows(current.area, selection);
2874
+ if selection.range().is_some() && rows.is_empty() {
2875
+ return false;
2876
+ }
2877
+ for selected in rows {
2878
+ for column in selected.left..=selected.right {
2879
+ let position = Position::new(column, selected.row);
2880
+ if previous.cell(position).map(|cell| cell.symbol())
2881
+ != current.cell(position).map(|cell| cell.symbol())
2882
+ {
2883
+ return false;
2884
+ }
2885
+ }
2886
+ }
2887
+ true
2888
+ }
2889
+
2890
+ fn route_mouse_wheel(state: &mut ChatViewState, kind: event::MouseEventKind) {
2891
+ match kind {
2892
+ event::MouseEventKind::ScrollUp => {
2893
+ if let Some(modal) = state.thought_modal.as_mut() {
2894
+ modal.scroll = modal.scroll.saturating_sub(3);
2895
+ } else {
2896
+ state.scroll_from_bottom = state.scroll_from_bottom.saturating_add(3);
2897
+ }
2898
+ }
2899
+ event::MouseEventKind::ScrollDown => {
2900
+ if let Some(modal) = state.thought_modal.as_mut() {
2901
+ modal.scroll = modal.scroll.saturating_add(3);
2902
+ } else {
2903
+ state.scroll_from_bottom = state.scroll_from_bottom.saturating_sub(3);
2904
+ }
2905
+ }
2906
+ _ => {}
2907
+ }
2908
+ }
2909
+
2910
+ fn activate_back_to_bottom(
2911
+ state: &mut ChatViewState,
2912
+ area: Option<Rect>,
2913
+ kind: event::MouseEventKind,
2914
+ point: ScreenPoint,
2915
+ ) -> bool {
2916
+ if kind == event::MouseEventKind::Down(event::MouseButton::Left)
2917
+ && area.is_some_and(|area| area.contains(Position::new(point.column, point.row)))
2918
+ {
2919
+ state.scroll_from_bottom = 0;
2920
+ true
2921
+ } else {
2922
+ false
2923
+ }
2924
+ }
2925
+
2926
+ fn setup_terminal_output<W: Write>(stdout: &mut W) -> io::Result<()> {
2927
+ crossterm::execute!(
2928
+ stdout,
2929
+ terminal::EnterAlternateScreen,
2930
+ // Mouse capture lets Mint distinguish wheel scrolling from physical
2931
+ // Up/Down keys and powers Mint's own drag-to-copy selection layer.
2932
+ event::EnableMouseCapture,
2933
+ event::EnableBracketedPaste
2934
+ )
2935
+ }
2936
+
2937
+ pub(crate) fn restore_terminal() {
2938
+ let mut stdout = io::stdout();
2939
+ let _ = stdout.execute(event::DisableBracketedPaste);
2940
+ let _ = stdout.execute(event::DisableMouseCapture);
2941
+ let _ = stdout.execute(terminal::LeaveAlternateScreen);
2942
+ let _ = terminal::disable_raw_mode();
2943
+ }
2944
+ impl ChatTui {
2945
+ pub fn enter(workspace: &Path) -> Result<Self> {
2946
+ use crossterm::tty::IsTty;
2947
+ if !io::stdin().is_tty() || !io::stdout().is_tty() {
2948
+ bail!("stdin/stdout is not a TTY");
2949
+ }
2950
+ let (w, h) = terminal::size()?;
2951
+ if w < MIN_WIDTH || h < MIN_HEIGHT {
2952
+ bail!("terminal is too small ({w}x{h}; need {MIN_WIDTH}x{MIN_HEIGHT})");
2953
+ }
2954
+ terminal::enable_raw_mode()?;
2955
+ let mut stdout = io::stdout();
2956
+ let workspace_name = workspace
2957
+ .file_name()
2958
+ .unwrap_or_else(|| workspace.as_os_str())
2959
+ .to_string_lossy();
2960
+ let workspace_name: String = workspace_name
2961
+ .chars()
2962
+ .map(|character| {
2963
+ if character.is_control() {
2964
+ ' '
2965
+ } else {
2966
+ character
2967
+ }
2968
+ })
2969
+ .collect();
2970
+ let title = if workspace_name.trim().is_empty() {
2971
+ "Mint Agent | workspace".to_owned()
2972
+ } else {
2973
+ format!("Mint Agent | {}", workspace_name.trim())
2974
+ };
2975
+ let setup = setup_terminal_output(&mut stdout);
2976
+ if let Err(error) = setup {
2977
+ restore_terminal();
2978
+ return Err(error.into());
2979
+ }
2980
+ match Terminal::new(CrosstermBackend::new(stdout)) {
2981
+ Ok(terminal) => {
2982
+ let _ = io::stdout().execute(terminal::SetTitle(title));
2983
+ Ok(Self {
2984
+ terminal,
2985
+ active: true,
2986
+ mouse_selection: MouseSelection::default(),
2987
+ last_frame: None,
2988
+ back_to_bottom_area: None,
2989
+ composer_area: None,
2990
+ watermark_area: None,
2991
+ clipboard: TextClipboard::default(),
2992
+ })
2993
+ }
2994
+ Err(error) => {
2995
+ restore_terminal();
2996
+ Err(error.into())
2997
+ }
2998
+ }
2999
+ }
3000
+ fn draw_state(&mut self, state: &ChatViewState) -> Result<()> {
3001
+ let selection = self.mouse_selection;
3002
+ let previous = self.last_frame.as_ref();
3003
+ let mut invalidated = false;
3004
+ let mut areas = RenderAreas::default();
3005
+ let completed = self.terminal.draw(|frame| {
3006
+ areas = state.render(frame);
3007
+ if selection.range().is_some() && state.active_notice().is_none() {
3008
+ let area = frame.area();
3009
+ let hint_area = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1);
3010
+ frame.render_widget(
3011
+ Paragraph::new(Line::styled(
3012
+ " Ctrl+C / right-click copy · Delete input · Esc clear",
3013
+ Style::default().fg(crate::terminal_theme::MUTED),
3014
+ )),
3015
+ hint_area,
3016
+ );
3017
+ }
3018
+ invalidated = !selection.dragging
3019
+ && selection.range().is_some()
3020
+ && previous
3021
+ .map(|buffer| !selected_cells_match(buffer, frame.buffer_mut(), selection))
3022
+ .unwrap_or(false);
3023
+ if !invalidated {
3024
+ apply_mouse_selection(frame.buffer_mut(), selection);
3025
+ }
3026
+ crate::terminal_theme::apply_tui_theme(frame.buffer_mut(), state.theme);
3027
+ })?;
3028
+ self.last_frame = Some(completed.buffer.clone());
3029
+ self.back_to_bottom_area = areas.back_to_bottom;
3030
+ self.composer_area = areas.composer;
3031
+ self.watermark_area = areas.watermark;
3032
+ if invalidated {
3033
+ self.mouse_selection.clear();
3034
+ }
3035
+ Ok(())
3036
+ }
3037
+
3038
+ fn handle_selection_mouse(
3039
+ &mut self,
3040
+ mouse: event::MouseEvent,
3041
+ state: &mut ChatViewState,
3042
+ ) -> Result<bool> {
3043
+ use event::{MouseButton, MouseEventKind};
3044
+
3045
+ let point = ScreenPoint::from_mouse(mouse);
3046
+ if activate_back_to_bottom(state, self.back_to_bottom_area, mouse.kind, point) {
3047
+ self.mouse_selection.clear();
3048
+ return Ok(true);
3049
+ }
3050
+ match mouse.kind {
3051
+ MouseEventKind::Down(MouseButton::Left) => {
3052
+ let inside_frame = self
3053
+ .last_frame
3054
+ .as_ref()
3055
+ .map(|buffer| buffer.area.contains(Position::new(point.column, point.row)))
3056
+ .unwrap_or(false);
3057
+ if inside_frame {
3058
+ self.mouse_selection.start(point);
3059
+ state.selection_mode = false;
3060
+ state.clear_notice();
3061
+ } else {
3062
+ self.mouse_selection.clear();
3063
+ }
3064
+ Ok(true)
3065
+ }
3066
+ MouseEventKind::Drag(MouseButton::Left) => {
3067
+ self.mouse_selection.update(point);
3068
+ Ok(self.mouse_selection.dragging)
3069
+ }
3070
+ MouseEventKind::Up(MouseButton::Left) => {
3071
+ if !self.mouse_selection.finish(point) {
3072
+ return Ok(true);
3073
+ }
3074
+ if self
3075
+ .last_frame
3076
+ .as_ref()
3077
+ .map(|buffer| {
3078
+ selected_screen_text(buffer, self.mouse_selection)
3079
+ .trim()
3080
+ .is_empty()
3081
+ })
3082
+ .unwrap_or(true)
3083
+ {
3084
+ self.mouse_selection.clear();
3085
+ }
3086
+ Ok(true)
3087
+ }
3088
+ MouseEventKind::Down(MouseButton::Right) => {
3089
+ let text = self
3090
+ .last_frame
3091
+ .as_ref()
3092
+ .map(|buffer| selected_screen_text(buffer, self.mouse_selection))
3093
+ .unwrap_or_default();
3094
+ if text.trim().is_empty() {
3095
+ return Ok(false);
3096
+ }
3097
+ if text.len() > COPY_LIMIT {
3098
+ state.set_notice("Selection exceeds the 100 KiB copy limit");
3099
+ return Ok(true);
3100
+ }
3101
+ let character_count = text.chars().count();
3102
+ match self.clipboard.copy(&text, &mut io::stdout()) {
3103
+ Ok(CopyMethod::Native) => state
3104
+ .set_notice(format!("Copied {character_count} chars to host clipboard")),
3105
+ Ok(CopyMethod::Osc52) => {
3106
+ state.set_notice(format!("Copied {character_count} chars with OSC52"))
3107
+ }
3108
+ Err(error) => state.set_notice(format!("Could not copy selection: {error}")),
3109
+ }
3110
+ self.mouse_selection.clear();
3111
+ Ok(true)
3112
+ }
3113
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
3114
+ self.mouse_selection.clear();
3115
+ Ok(false)
3116
+ }
3117
+ _ => Ok(false),
3118
+ }
3119
+ }
3120
+
3121
+ fn clear_mouse_selection(&mut self) {
3122
+ self.mouse_selection.clear();
3123
+ }
3124
+
3125
+ fn composer_selection_range(&self, state: &ChatViewState) -> Option<(usize, usize)> {
3126
+ let area = self.composer_area?;
3127
+ let (anchor, head) = self.mouse_selection.endpoints()?;
3128
+ let (start, end) = if anchor <= head {
3129
+ (anchor, head)
3130
+ } else {
3131
+ (head, anchor)
3132
+ };
3133
+ let chars = &state.composer;
3134
+ let row_width = (area.width as usize).saturating_sub(4).max(1);
3135
+ let start = input_index_at(start, area, chars, row_width, state.cursor, false)?;
3136
+ let end = input_index_at(end, area, chars, row_width, state.cursor, true)?;
3137
+ (start < end).then_some((start.min(chars.len()), end.min(chars.len())))
3138
+ }
3139
+
3140
+ fn copy_mouse_selection(&mut self, state: &mut ChatViewState) -> bool {
3141
+ if self.mouse_selection.range().is_none() {
3142
+ return false;
3143
+ }
3144
+ let text = if let Some((start, end)) = self.composer_selection_range(state) {
3145
+ state.composer[start..end].iter().collect::<String>()
3146
+ } else {
3147
+ self.last_frame
3148
+ .as_ref()
3149
+ .map(|buffer| selected_screen_text(buffer, self.mouse_selection))
3150
+ .unwrap_or_default()
3151
+ };
3152
+ if text.trim().is_empty() {
3153
+ self.clear_mouse_selection();
3154
+ state.set_notice("Nothing to copy from this selection");
3155
+ return true;
3156
+ }
3157
+ if text.len() > COPY_LIMIT {
3158
+ state.set_notice("Selection exceeds the 100 KiB copy limit");
3159
+ } else {
3160
+ let character_count = text.chars().count();
3161
+ match self.clipboard.copy(&text, &mut io::stdout()) {
3162
+ Ok(CopyMethod::Native | CopyMethod::Osc52) => {
3163
+ state.set_notice(format!("Copied {character_count} chars to clipboard"))
3164
+ }
3165
+ Err(error) => state.set_notice(format!("Could not copy selection: {error}")),
3166
+ }
3167
+ }
3168
+ true
3169
+ }
3170
+
3171
+ pub fn read_input(
3172
+ &mut self,
3173
+ shared_state: &Arc<Mutex<ChatViewState>>,
3174
+ ) -> Result<Option<InteractiveInput>> {
3175
+ let mut state = shared_state
3176
+ .lock()
3177
+ .map_err(|_| anyhow::anyhow!("TUI state lock poisoned"))?;
3178
+ let mut pasted_image: Option<String> = None;
3179
+ let mut last_ctrl_d: Option<std::time::Instant> = None;
3180
+ let mut last_sync = std::time::Instant::now();
3181
+ let mut redraw = true;
3182
+ let mut painted_notice_visible = false;
3183
+ let mut watermark_started: Option<std::time::Instant> = None;
3184
+ const DOUBLE_PRESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
3185
+
3186
+ loop {
3187
+ let watermark_visible = state.transcript.is_empty()
3188
+ && state.dialog.is_none()
3189
+ && state.thought_modal.is_none();
3190
+ if state.watermark_animating && watermark_visible {
3191
+ let started = *watermark_started.get_or_insert_with(std::time::Instant::now);
3192
+ match watermark_animation_at(started.elapsed()).0 {
3193
+ Some(frame) if frame != state.watermark_frame => {
3194
+ state.watermark_frame = frame;
3195
+ redraw = true;
3196
+ }
3197
+ None => {
3198
+ state.watermark_frame = 0;
3199
+ state.watermark_animating = false;
3200
+ watermark_started = None;
3201
+ redraw = true;
3202
+ }
3203
+ _ => {}
3204
+ }
3205
+ } else {
3206
+ watermark_started = None;
3207
+ }
3208
+
3209
+ if let Some(time) = last_ctrl_d {
3210
+ if time.elapsed() >= DOUBLE_PRESS_TIMEOUT {
3211
+ last_ctrl_d = None;
3212
+ if state.active_notice() == Some("Press Ctrl+D again to exit") {
3213
+ state.clear_notice();
3214
+ }
3215
+ }
3216
+ }
3217
+
3218
+ redraw |= state.notice_visibility_changed_since_draw(painted_notice_visible);
3219
+
3220
+ if redraw {
3221
+ let notice_visible = state.active_notice().is_some();
3222
+ self.draw_state(&state)?;
3223
+ painted_notice_visible = notice_visible;
3224
+ redraw = false;
3225
+ }
3226
+
3227
+ let watermark_animating = state.watermark_animating && watermark_visible;
3228
+ {
3229
+ let poll_interval = if watermark_animating {
3230
+ let elapsed = watermark_started
3231
+ .expect("active watermark has a start time")
3232
+ .elapsed();
3233
+ watermark_animation_at(elapsed)
3234
+ .1
3235
+ .unwrap_or(elapsed)
3236
+ .saturating_sub(elapsed)
3237
+ .min(std::time::Duration::from_millis(250))
3238
+ } else if last_ctrl_d.is_some() || state.active_notice().is_some() {
3239
+ std::time::Duration::from_millis(50)
3240
+ } else {
3241
+ std::time::Duration::from_millis(250)
3242
+ };
3243
+ if !event::poll(poll_interval)? {
3244
+ if last_sync.elapsed() >= std::time::Duration::from_millis(1500)
3245
+ && self.mouse_selection.range().is_none()
3246
+ && !state.selection_mode
3247
+ {
3248
+ redraw |= state.refresh_shared_transcript();
3249
+ last_sync = std::time::Instant::now();
3250
+ }
3251
+ continue;
3252
+ }
3253
+ }
3254
+
3255
+ let input_event = event::read()?;
3256
+ if matches!(
3257
+ &input_event,
3258
+ event::Event::Mouse(m) if m.kind == event::MouseEventKind::Moved
3259
+ ) {
3260
+ continue;
3261
+ }
3262
+ redraw = true;
3263
+ match input_event {
3264
+ event::Event::Resize(w, h) => {
3265
+ self.clear_mouse_selection();
3266
+ if w < MIN_WIDTH || h < MIN_HEIGHT {
3267
+ state.set_notice(format!("Terminal too small: {w}x{h}"));
3268
+ } else if state
3269
+ .active_notice()
3270
+ .map(|n| n.starts_with("Terminal too small"))
3271
+ .unwrap_or(false)
3272
+ {
3273
+ state.clear_notice();
3274
+ }
3275
+ }
3276
+ event::Event::Mouse(m) => {
3277
+ if m.kind == event::MouseEventKind::Down(event::MouseButton::Left)
3278
+ && self
3279
+ .watermark_area
3280
+ .is_some_and(|area| area.contains(Position::new(m.column, m.row)))
3281
+ {
3282
+ self.clear_mouse_selection();
3283
+ state.watermark_frame = 0;
3284
+ state.watermark_animating = true;
3285
+ watermark_started = Some(std::time::Instant::now());
3286
+ continue;
3287
+ }
3288
+ if self.handle_selection_mouse(m, &mut state)? {
3289
+ continue;
3290
+ }
3291
+ route_mouse_wheel(&mut state, m.kind);
3292
+ }
3293
+ event::Event::Paste(text) => {
3294
+ self.clear_mouse_selection();
3295
+ for c in text.trim_end_matches(['\r', '\n']).chars() {
3296
+ let cursor = state.cursor;
3297
+ state.composer.insert(cursor, c);
3298
+ state.cursor += 1;
3299
+ }
3300
+ }
3301
+ event::Event::Key(key) if key.kind == event::KeyEventKind::Press => {
3302
+ use event::{KeyCode, KeyModifiers};
3303
+
3304
+ let is_ctrl_c = key.code == KeyCode::Char('c')
3305
+ && key.modifiers.contains(KeyModifiers::CONTROL);
3306
+ if is_ctrl_c && self.copy_mouse_selection(&mut state) {
3307
+ continue;
3308
+ }
3309
+
3310
+ if self.mouse_selection.range().is_some() {
3311
+ if matches!(key.code, KeyCode::Delete | KeyCode::Backspace) {
3312
+ if let Some((start, end)) = self.composer_selection_range(&state) {
3313
+ state.composer.drain(start..end);
3314
+ state.cursor = start;
3315
+ state.slash_selected = 0;
3316
+ state.clear_notice();
3317
+ }
3318
+ self.clear_mouse_selection();
3319
+ continue;
3320
+ }
3321
+ }
3322
+
3323
+ self.clear_mouse_selection();
3324
+
3325
+ if key.code == KeyCode::F(6) {
3326
+ let text: String = state.composer.iter().collect();
3327
+ return Ok(Some(InteractiveInput {
3328
+ text,
3329
+ pasted_image,
3330
+ switch_mode: true,
3331
+ }));
3332
+ }
3333
+
3334
+ if let Some(modal) = state.thought_modal.as_mut() {
3335
+ match key.code {
3336
+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
3337
+ state.thought_modal = None;
3338
+ }
3339
+ KeyCode::Char('t') | KeyCode::Char('c') | KeyCode::Char('d')
3340
+ if key.modifiers.contains(KeyModifiers::CONTROL) =>
3341
+ {
3342
+ state.thought_modal = None;
3343
+ }
3344
+ KeyCode::Up | KeyCode::Char('k') => {
3345
+ modal.scroll = modal.scroll.saturating_sub(1);
3346
+ }
3347
+ KeyCode::Down | KeyCode::Char('j') => {
3348
+ modal.scroll = modal.scroll.saturating_add(1);
3349
+ }
3350
+ KeyCode::PageUp => {
3351
+ modal.scroll = modal.scroll.saturating_sub(10);
3352
+ }
3353
+ KeyCode::PageDown => {
3354
+ modal.scroll = modal.scroll.saturating_add(10);
3355
+ }
3356
+ KeyCode::Home | KeyCode::Char('g') => {
3357
+ modal.scroll = 0;
3358
+ }
3359
+ KeyCode::End | KeyCode::Char('G') => {
3360
+ modal.scroll = usize::MAX;
3361
+ }
3362
+ _ => {}
3363
+ }
3364
+ continue;
3365
+ }
3366
+
3367
+ let is_ctrl_d = key.code == KeyCode::Char('d')
3368
+ && key.modifiers.contains(KeyModifiers::CONTROL);
3369
+ if !is_ctrl_d && last_ctrl_d.is_some() {
3370
+ last_ctrl_d = None;
3371
+ if state.active_notice() == Some("Press Ctrl+D again to exit") {
3372
+ state.clear_notice();
3373
+ }
3374
+ }
3375
+
3376
+ if state.selection_mode {
3377
+ match key.code {
3378
+ KeyCode::Esc => state.selection_mode = false,
3379
+ KeyCode::Char('c') | KeyCode::Char('d')
3380
+ if key.modifiers.contains(KeyModifiers::CONTROL) =>
3381
+ {
3382
+ state.selection_mode = false;
3383
+ }
3384
+ KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3385
+ state.selection_mode = false;
3386
+ if let Some(record) = super::get_last_thought() {
3387
+ if !record.thought.trim().is_empty() {
3388
+ let lines: Vec<String> = record
3389
+ .thought
3390
+ .trim()
3391
+ .lines()
3392
+ .map(str::to_string)
3393
+ .collect();
3394
+ state.thought_modal = Some(ThoughtModalState {
3395
+ elapsed_str: record.elapsed_str,
3396
+ lines,
3397
+ scroll: 0,
3398
+ });
3399
+ state.clear_notice();
3400
+ }
3401
+ }
3402
+ }
3403
+ KeyCode::Enter | KeyCode::Char('y') => {
3404
+ let text = state.selected_text();
3405
+ if text.len() > COPY_LIMIT {
3406
+ state.set_notice("Transcript exceeds the 100 KiB copy limit");
3407
+ } else {
3408
+ match self.clipboard.copy(&text, &mut io::stdout()) {
3409
+ Ok(CopyMethod::Native) => {
3410
+ state.set_notice("Transcript copied to clipboard")
3411
+ }
3412
+ Ok(CopyMethod::Osc52) => {
3413
+ state.set_notice("Transcript copied with OSC52")
3414
+ }
3415
+ Err(error) => state.set_notice(format!(
3416
+ "Could not copy transcript: {error}"
3417
+ )),
3418
+ }
3419
+ state.selection_mode = false;
3420
+ }
3421
+ }
3422
+ KeyCode::PageUp | KeyCode::Up => {
3423
+ state.selection_head = state.selection_head.saturating_sub(1);
3424
+ state.scroll_from_bottom =
3425
+ state.scroll_from_bottom.saturating_add(1)
3426
+ }
3427
+ KeyCode::PageDown | KeyCode::Down => {
3428
+ let last = state.plain_transcript_lines().len().saturating_sub(1);
3429
+ state.selection_head = (state.selection_head + 1).min(last);
3430
+ state.scroll_from_bottom =
3431
+ state.scroll_from_bottom.saturating_sub(1)
3432
+ }
3433
+ _ => {}
3434
+ }
3435
+ continue;
3436
+ }
3437
+ match key.code {
3438
+ KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3439
+ if let Some(record) = super::get_last_thought() {
3440
+ if !record.thought.trim().is_empty() {
3441
+ let lines: Vec<String> =
3442
+ record.thought.trim().lines().map(str::to_string).collect();
3443
+ state.thought_modal = Some(ThoughtModalState {
3444
+ elapsed_str: record.elapsed_str,
3445
+ lines,
3446
+ scroll: 0,
3447
+ });
3448
+ state.clear_notice();
3449
+ } else {
3450
+ state.set_notice("No thought process recorded for this turn");
3451
+ }
3452
+ } else {
3453
+ state.set_notice("No thought process recorded for this turn");
3454
+ }
3455
+ }
3456
+ KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3457
+ if let Ok(Some(uri)) = crate::image::read_clipboard_image() {
3458
+ if let Some(current) = pasted_image.as_mut() {
3459
+ current.push(' ');
3460
+ current.push_str(&uri);
3461
+ } else {
3462
+ pasted_image = Some(uri);
3463
+ }
3464
+ let mut cursor = state.cursor;
3465
+ super::insert_image_placeholder(&mut state.composer, &mut cursor);
3466
+ state.cursor = cursor;
3467
+ state.set_notice("Image attached");
3468
+ } else {
3469
+ state.set_notice("No image found in clipboard");
3470
+ }
3471
+ }
3472
+ KeyCode::PageUp => {
3473
+ state.scroll_from_bottom = state.scroll_from_bottom.saturating_add(10)
3474
+ }
3475
+ KeyCode::PageDown => {
3476
+ state.scroll_from_bottom = state.scroll_from_bottom.saturating_sub(10)
3477
+ }
3478
+ KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
3479
+ state.scroll_from_bottom = u16::MAX
3480
+ }
3481
+ KeyCode::End => state.scroll_from_bottom = 0,
3482
+ KeyCode::Up if !state.suggestions().is_empty() => {
3483
+ state.slash_selected = state.slash_selected.saturating_sub(1)
3484
+ }
3485
+ KeyCode::Down if !state.suggestions().is_empty() => {
3486
+ let last = state.suggestions().len().saturating_sub(1);
3487
+ state.slash_selected = (state.slash_selected + 1).min(last);
3488
+ }
3489
+ KeyCode::Tab if !state.suggestions().is_empty() => {
3490
+ state.apply_selected_suggestion();
3491
+ }
3492
+ KeyCode::Up if !state.composer.contains(&'\n') => state.history_previous(),
3493
+ KeyCode::Down if !state.composer.contains(&'\n') => state.history_next(),
3494
+ KeyCode::Left => state.cursor = state.cursor.saturating_sub(1),
3495
+ KeyCode::Right => {
3496
+ state.cursor = (state.cursor + 1).min(state.composer.len())
3497
+ }
3498
+ KeyCode::Backspace if state.cursor > 0 => {
3499
+ state.cursor -= 1;
3500
+ let cursor = state.cursor;
3501
+ state.composer.remove(cursor);
3502
+ state.slash_selected = 0;
3503
+ }
3504
+ KeyCode::Delete if state.cursor < state.composer.len() => {
3505
+ let cursor = state.cursor;
3506
+ state.composer.remove(cursor);
3507
+ }
3508
+ KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => {
3509
+ let cursor = state.cursor;
3510
+ state.composer.insert(cursor, '\n');
3511
+ state.cursor += 1;
3512
+ }
3513
+ KeyCode::Enter => {
3514
+ let suggestions = state.suggestions();
3515
+ let input: String = state.composer.iter().collect();
3516
+ if input.starts_with('/')
3517
+ && !suggestions.is_empty()
3518
+ && !suggestions.iter().any(|(token, _)| token == input.trim())
3519
+ {
3520
+ state.apply_selected_suggestion();
3521
+ }
3522
+ let text: String = state.composer.drain(..).collect();
3523
+ state.cursor = 0;
3524
+ state.history_index = None;
3525
+ state.draft_before_history.clear();
3526
+ state.clear_notice();
3527
+ if !text.trim().is_empty() {
3528
+ return Ok(Some(InteractiveInput {
3529
+ text,
3530
+ pasted_image,
3531
+ switch_mode: false,
3532
+ }));
3533
+ }
3534
+ }
3535
+ KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3536
+ if let Some(time) = last_ctrl_d {
3537
+ if time.elapsed() < DOUBLE_PRESS_TIMEOUT {
3538
+ return Ok(None);
3539
+ }
3540
+ }
3541
+ last_ctrl_d = Some(std::time::Instant::now());
3542
+ state.set_notice("Press Ctrl+D again to exit");
3543
+ }
3544
+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3545
+ if !state.composer.is_empty() {
3546
+ state.composer.clear();
3547
+ state.cursor = 0;
3548
+ state.slash_selected = 0;
3549
+ state.clear_notice();
3550
+ } else {
3551
+ state.set_notice("Press Ctrl+D twice to exit");
3552
+ }
3553
+ }
3554
+ KeyCode::Char(c)
3555
+ if !key
3556
+ .modifiers
3557
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
3558
+ {
3559
+ if state
3560
+ .active_notice()
3561
+ .map(|n| !n.starts_with("Terminal too small"))
3562
+ .unwrap_or(false)
3563
+ {
3564
+ state.clear_notice();
3565
+ }
3566
+ let cursor = state.cursor;
3567
+ state.composer.insert(cursor, c);
3568
+ state.cursor += 1;
3569
+ state.slash_selected = 0;
3570
+ }
3571
+ _ => {}
3572
+ }
3573
+ }
3574
+ _ => {}
3575
+ }
3576
+ }
3577
+ }
3578
+ pub fn handle(state: Arc<Mutex<ChatViewState>>) -> TuiHandle {
3579
+ TuiHandle {
3580
+ state,
3581
+ interrupted: Arc::new(AtomicBool::new(false)),
3582
+ queued: Arc::new(Mutex::new(Vec::new())),
3583
+ }
3584
+ }
3585
+
3586
+ fn handle_dialog_event(
3587
+ &mut self,
3588
+ input_event: event::Event,
3589
+ state: &mut ChatViewState,
3590
+ ) -> Result<()> {
3591
+ match input_event {
3592
+ event::Event::Mouse(mouse) => {
3593
+ if !self.handle_selection_mouse(mouse, state)? {
3594
+ route_mouse_wheel(state, mouse.kind);
3595
+ }
3596
+ }
3597
+ event::Event::Resize(_, _) => self.clear_mouse_selection(),
3598
+ event::Event::Key(key) if key.kind == event::KeyEventKind::Press => {
3599
+ self.clear_mouse_selection();
3600
+ if let Some(dialog) = state.dialog.as_mut()
3601
+ && let Some(answer) = dialog.handle_key(key)
3602
+ && let Some(dialog) = state.dialog.take()
3603
+ {
3604
+ let _ = dialog.reply.send(answer);
3605
+ }
3606
+ }
3607
+ _ => {}
3608
+ }
3609
+ Ok(())
3610
+ }
3611
+
3612
+ pub fn prompt_choice(
3613
+ &mut self,
3614
+ handle: &TuiHandle,
3615
+ title: impl Into<String>,
3616
+ body: impl Into<String>,
3617
+ options: Vec<String>,
3618
+ ) -> Result<Option<usize>> {
3619
+ let (reply, response) = mpsc::channel();
3620
+ if let Ok(mut state) = handle.state.lock() {
3621
+ state.dialog = Some(DialogState::new_choice(title, body, options, None, reply));
3622
+ }
3623
+ loop {
3624
+ if let Ok(state) = handle.state.lock() {
3625
+ self.draw_state(&state)?;
3626
+ }
3627
+ if let Ok(answer) = response.try_recv() {
3628
+ return Ok(match answer {
3629
+ DialogAnswer::Choice(index) => Some(index),
3630
+ _ => None,
3631
+ });
3632
+ }
3633
+ if event::poll(std::time::Duration::from_millis(50))?
3634
+ && let Ok(mut state) = handle.state.lock()
3635
+ {
3636
+ self.handle_dialog_event(event::read()?, &mut state)?;
3637
+ }
3638
+ }
3639
+ }
3640
+
3641
+ pub fn prompt_theme(&mut self, handle: &TuiHandle, current: usize) -> Result<Option<usize>> {
3642
+ let (reply, response) = mpsc::channel();
3643
+ if let Ok(mut state) = handle.state.lock() {
3644
+ state.dialog = Some(DialogState::new_theme_picker(current, reply));
3645
+ }
3646
+ loop {
3647
+ if let Ok(state) = handle.state.lock() {
3648
+ self.draw_state(&state)?;
3649
+ }
3650
+ if let Ok(answer) = response.try_recv() {
3651
+ return Ok(match answer {
3652
+ DialogAnswer::Choice(index) => Some(index),
3653
+ _ => None,
3654
+ });
3655
+ }
3656
+ if event::poll(std::time::Duration::from_millis(50))?
3657
+ && let Ok(mut state) = handle.state.lock()
3658
+ {
3659
+ self.handle_dialog_event(event::read()?, &mut state)?;
3660
+ }
3661
+ }
3662
+ }
3663
+
3664
+ pub fn prompt_resume_picker(
3665
+ &mut self,
3666
+ handle: &TuiHandle,
3667
+ sessions: Vec<mint_core::ChatSession>,
3668
+ current_dir: &std::path::Path,
3669
+ current_branch: Option<String>,
3670
+ active_chat_id: &str,
3671
+ ) -> Result<Option<usize>> {
3672
+ let (reply, response) = mpsc::channel();
3673
+ if let Ok(mut state) = handle.state.lock() {
3674
+ state.dialog = Some(DialogState::new_resume_picker(
3675
+ sessions,
3676
+ current_dir.to_string_lossy().to_string(),
3677
+ current_branch,
3678
+ active_chat_id.to_string(),
3679
+ reply,
3680
+ ));
3681
+ }
3682
+ loop {
3683
+ if let Ok(state) = handle.state.lock() {
3684
+ self.draw_state(&state)?;
3685
+ }
3686
+ if let Ok(answer) = response.try_recv() {
3687
+ return Ok(match answer {
3688
+ DialogAnswer::Choice(index) => Some(index),
3689
+ _ => None,
3690
+ });
3691
+ }
3692
+ if event::poll(std::time::Duration::from_millis(50))?
3693
+ && let Ok(mut state) = handle.state.lock()
3694
+ {
3695
+ self.handle_dialog_event(event::read()?, &mut state)?;
3696
+ }
3697
+ }
3698
+ }
3699
+
3700
+ #[allow(dead_code)]
3701
+ pub fn prompt_multi_choice(
3702
+ &mut self,
3703
+ handle: &TuiHandle,
3704
+ title: impl Into<String>,
3705
+ body: impl Into<String>,
3706
+ options: Vec<String>,
3707
+ ) -> Result<Option<Vec<usize>>> {
3708
+ let (reply, response) = mpsc::channel();
3709
+ let checked = vec![false; options.len()];
3710
+ if let Ok(mut state) = handle.state.lock() {
3711
+ state.dialog = Some(DialogState::new_choice(
3712
+ title,
3713
+ body,
3714
+ options,
3715
+ Some(checked),
3716
+ reply,
3717
+ ));
3718
+ }
3719
+ loop {
3720
+ if let Ok(state) = handle.state.lock() {
3721
+ self.draw_state(&state)?;
3722
+ }
3723
+ if let Ok(answer) = response.try_recv() {
3724
+ return Ok(match answer {
3725
+ DialogAnswer::Choices(indices) => Some(indices),
3726
+ _ => None,
3727
+ });
3728
+ }
3729
+ if event::poll(std::time::Duration::from_millis(50))?
3730
+ && let Ok(mut state) = handle.state.lock()
3731
+ {
3732
+ self.handle_dialog_event(event::read()?, &mut state)?;
3733
+ }
3734
+ }
3735
+ }
3736
+ pub fn prompt_text(
3737
+ &mut self,
3738
+ handle: &TuiHandle,
3739
+ title: impl Into<String>,
3740
+ body: impl Into<String>,
3741
+ initial: Option<&str>,
3742
+ ) -> Result<Option<String>> {
3743
+ let (reply, response) = mpsc::channel();
3744
+ if let Ok(mut state) = handle.state.lock() {
3745
+ let mut dialog = DialogState::new_text(title, body, reply);
3746
+ if let Some(init) = initial {
3747
+ dialog.input = Some(init.chars().collect());
3748
+ }
3749
+ state.dialog = Some(dialog);
3750
+ }
3751
+ loop {
3752
+ if let Ok(state) = handle.state.lock() {
3753
+ self.draw_state(&state)?;
3754
+ }
3755
+ if let Ok(answer) = response.try_recv() {
3756
+ return Ok(match answer {
3757
+ DialogAnswer::Text(text) if !text.trim().is_empty() => Some(text),
3758
+ _ => None,
3759
+ });
3760
+ }
3761
+ if event::poll(std::time::Duration::from_millis(50))?
3762
+ && let Ok(mut state) = handle.state.lock()
3763
+ {
3764
+ self.handle_dialog_event(event::read()?, &mut state)?;
3765
+ }
3766
+ }
3767
+ }
3768
+ pub async fn drive_agent<T: Send + 'static>(
3769
+ &mut self,
3770
+ handle: &TuiHandle,
3771
+ task: tokio::task::JoinHandle<T>,
3772
+ ) -> Result<T> {
3773
+ use event::{KeyCode, KeyModifiers};
3774
+ loop {
3775
+ if let Ok(state) = handle.state.lock() {
3776
+ self.draw_state(&state)?;
3777
+ }
3778
+ if task.is_finished() {
3779
+ return Ok(task.await?);
3780
+ }
3781
+ if event::poll(std::time::Duration::from_millis(30))? {
3782
+ match event::read()? {
3783
+ event::Event::Resize(_, _) => self.clear_mouse_selection(),
3784
+ event::Event::Mouse(mouse) => {
3785
+ if let Ok(mut state) = handle.state.lock() {
3786
+ if self.handle_selection_mouse(mouse, &mut state)? {
3787
+ continue;
3788
+ }
3789
+ route_mouse_wheel(&mut state, mouse.kind);
3790
+ }
3791
+ }
3792
+ event::Event::Paste(text) => {
3793
+ self.clear_mouse_selection();
3794
+ if let Ok(mut state) = handle.state.lock() {
3795
+ let cursor = state.cursor;
3796
+ for (offset, character) in text.chars().enumerate() {
3797
+ state.composer.insert(cursor + offset, character);
3798
+ }
3799
+ state.cursor += text.chars().count();
3800
+ }
3801
+ }
3802
+ event::Event::Key(key) if key.kind == event::KeyEventKind::Press => {
3803
+ let mut selection_handled = false;
3804
+ if let Ok(mut state) = handle.state.lock() {
3805
+ if let Some(dialog) = state.dialog.as_mut() {
3806
+ if let Some(answer) = dialog.handle_key(key) {
3807
+ if let Some(dialog) = state.dialog.take() {
3808
+ let _ = dialog.reply.send(answer);
3809
+ }
3810
+ }
3811
+ selection_handled = true;
3812
+ } else if key.code == KeyCode::Char('c')
3813
+ && key.modifiers.contains(KeyModifiers::CONTROL)
3814
+ && self.copy_mouse_selection(&mut state)
3815
+ {
3816
+ selection_handled = true;
3817
+ } else if self.mouse_selection.range().is_some()
3818
+ && matches!(key.code, KeyCode::Delete | KeyCode::Backspace)
3819
+ {
3820
+ if let Some((start, end)) = self.composer_selection_range(&state) {
3821
+ state.composer.drain(start..end);
3822
+ state.cursor = start;
3823
+ state.slash_selected = 0;
3824
+ }
3825
+ self.clear_mouse_selection();
3826
+ selection_handled = true;
3827
+ }
3828
+ }
3829
+ if selection_handled {
3830
+ continue;
3831
+ }
3832
+ self.clear_mouse_selection();
3833
+ if key.code == KeyCode::Esc
3834
+ || (key.code == KeyCode::Char('c')
3835
+ && key.modifiers.contains(KeyModifiers::CONTROL))
3836
+ {
3837
+ handle.interrupted.store(true, Ordering::Relaxed);
3838
+ continue;
3839
+ }
3840
+ if let Ok(mut state) = handle.state.lock() {
3841
+ match key.code {
3842
+ KeyCode::End => state.scroll_from_bottom = 0,
3843
+ KeyCode::Enter => {
3844
+ if !state.composer.is_empty() {
3845
+ let text: String = state.composer.drain(..).collect();
3846
+ state.cursor = 0;
3847
+ if let Ok(mut queued) = handle.queued.lock() {
3848
+ queued.push(text);
3849
+ }
3850
+ }
3851
+ }
3852
+ KeyCode::Backspace if state.cursor > 0 => {
3853
+ state.cursor -= 1;
3854
+ let cursor = state.cursor;
3855
+ state.composer.remove(cursor);
3856
+ }
3857
+ KeyCode::Delete if state.cursor < state.composer.len() => {
3858
+ let cursor = state.cursor;
3859
+ state.composer.remove(cursor);
3860
+ }
3861
+ KeyCode::Left => state.cursor = state.cursor.saturating_sub(1),
3862
+ KeyCode::Right => {
3863
+ state.cursor = (state.cursor + 1).min(state.composer.len())
3864
+ }
3865
+ KeyCode::Char(character)
3866
+ if !key
3867
+ .modifiers
3868
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
3869
+ {
3870
+ let cursor = state.cursor;
3871
+ state.composer.insert(cursor, character);
3872
+ state.cursor += 1;
3873
+ }
3874
+ _ => {}
3875
+ }
3876
+ }
3877
+ }
3878
+ _ => {}
3879
+ }
3880
+ }
3881
+ tokio::task::yield_now().await;
3882
+ }
3883
+ }
3884
+ pub fn suspend(&mut self) {
3885
+ if !self.active {
3886
+ return;
3887
+ }
3888
+ let _ = self.terminal.show_cursor();
3889
+ restore_terminal();
3890
+ self.active = false;
3891
+ }
3892
+ pub fn resume(&mut self) -> Result<()> {
3893
+ if self.active {
3894
+ return Ok(());
3895
+ }
3896
+ terminal::enable_raw_mode()?;
3897
+ let mut stdout = io::stdout();
3898
+ let setup = setup_terminal_output(&mut stdout);
3899
+ if let Err(error) = setup {
3900
+ restore_terminal();
3901
+ return Err(error.into());
3902
+ }
3903
+ if let Err(error) = self.terminal.clear() {
3904
+ restore_terminal();
3905
+ return Err(error.into());
3906
+ }
3907
+ self.active = true;
3908
+ Ok(())
3909
+ }
3910
+ }
3911
+ impl Drop for ChatTui {
3912
+ fn drop(&mut self) {
3913
+ self.suspend();
3914
+ }
3915
+ }
3916
+
3917
+ #[cfg(test)]
3918
+ mod dialog_tests {
3919
+ use super::*;
3920
+ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
3921
+ use ratatui::backend::TestBackend;
3922
+ use ratatui::layout::Rect;
3923
+
3924
+ #[test]
3925
+ fn mint_watermark_frames_fit_available_terminal_height() {
3926
+ for height in [3, 7, WATERMARK_HEIGHT as usize] {
3927
+ for frame in 0..WATERMARK_FRAME_COUNT {
3928
+ let text = mint_m_watermark(Some(frame), height);
3929
+ assert_eq!(text.lines.len(), height);
3930
+ assert!(text.lines.iter().all(|line| {
3931
+ line.spans
3932
+ .iter()
3933
+ .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
3934
+ .sum::<usize>()
3935
+ == WATERMARK_WIDTH as usize
3936
+ }));
3937
+ }
3938
+ }
3939
+ assert_ne!(
3940
+ mint_m_watermark(Some(0), WATERMARK_HEIGHT as usize),
3941
+ mint_m_watermark(Some(6), WATERMARK_HEIGHT as usize)
3942
+ );
3943
+ let front = mint_m_watermark(None, WATERMARK_HEIGHT as usize);
3944
+ assert_eq!(front.lines[8].spans[19].content.as_ref(), " ");
3945
+ let back = mint_m_watermark(Some(WATERMARK_FRAME_COUNT / 2), WATERMARK_HEIGHT as usize);
3946
+ assert_eq!(back.lines[8].spans[19].content.as_ref(), "⣿");
3947
+ }
3948
+
3949
+ #[test]
3950
+ fn watermark_animation_turns_to_leaf_holds_and_returns_to_m() {
3951
+ let frames = (0..=350)
3952
+ .map(|event| watermark_animation_at(std::time::Duration::from_millis(event * 10)).0)
3953
+ .collect::<Vec<_>>();
3954
+ assert_eq!(frames[0], Some(0));
3955
+ assert_eq!(frames[3], Some(1));
3956
+ assert_eq!(frames[74], Some(24));
3957
+ assert_eq!(frames[75], Some(25));
3958
+ assert_eq!(frames[100], Some(25));
3959
+ assert_eq!(frames[274], Some(25));
3960
+ assert_eq!(frames[275], Some(24));
3961
+ assert_eq!(frames[347], Some(0));
3962
+ assert_eq!(frames[350], None);
3963
+ assert_eq!(
3964
+ watermark_animation_at(std::time::Duration::from_millis(2749)),
3965
+ (Some(25), Some(std::time::Duration::from_millis(2750)))
3966
+ );
3967
+ assert_eq!(
3968
+ watermark_animation_at(std::time::Duration::from_millis(3500)),
3969
+ (None, None)
3970
+ );
3971
+ }
3972
+
3973
+ fn make_key(code: KeyCode) -> KeyEvent {
3974
+ KeyEvent {
3975
+ code,
3976
+ modifiers: KeyModifiers::empty(),
3977
+ kind: KeyEventKind::Press,
3978
+ state: KeyEventState::empty(),
3979
+ }
3980
+ }
3981
+
3982
+ #[test]
3983
+ fn skill_suggestion_completes_name_before_task() {
3984
+ let workspace = std::env::temp_dir().join(format!(
3985
+ "mint-tui-skill-{}-{}",
3986
+ std::process::id(),
3987
+ std::time::SystemTime::now()
3988
+ .duration_since(std::time::UNIX_EPOCH)
3989
+ .unwrap()
3990
+ .as_nanos()
3991
+ ));
3992
+ let skill_dir = workspace.join("skills").join("tui-check-skill");
3993
+ std::fs::create_dir_all(&skill_dir).unwrap();
3994
+ std::fs::write(
3995
+ skill_dir.join("SKILL.md"),
3996
+ "---\ndescription: Test skill\n---\n",
3997
+ )
3998
+ .unwrap();
3999
+ let mut state = ChatViewState {
4000
+ current_dir: workspace.clone(),
4001
+ ..ChatViewState::default()
4002
+ };
4003
+ state.set_draft("$tui-check".to_owned());
4004
+ assert!(
4005
+ state
4006
+ .suggestions()
4007
+ .iter()
4008
+ .any(|(name, _)| name == "$tui-check-skill")
4009
+ );
4010
+ assert!(state.apply_selected_suggestion());
4011
+ assert_eq!(
4012
+ state.composer.iter().collect::<String>(),
4013
+ "$tui-check-skill "
4014
+ );
4015
+ std::fs::remove_dir_all(workspace).unwrap();
4016
+ }
4017
+
4018
+ #[test]
4019
+ fn mention_suggestion_replaces_only_the_word_at_cursor() {
4020
+ let mut state = ChatViewState {
4021
+ current_dir: std::env::current_dir().unwrap(),
4022
+ ..ChatViewState::default()
4023
+ };
4024
+ state.set_draft("Please check @work today".to_owned());
4025
+ state.cursor = "Please check @work".chars().count();
4026
+ assert!(
4027
+ state
4028
+ .suggestions()
4029
+ .iter()
4030
+ .any(|(name, _)| name == "@workspace")
4031
+ );
4032
+ assert!(state.apply_selected_suggestion());
4033
+ assert_eq!(
4034
+ state.composer.iter().collect::<String>(),
4035
+ "Please check @workspace today"
4036
+ );
4037
+ assert_eq!(state.cursor, "Please check @workspace ".chars().count());
4038
+ }
4039
+
4040
+ #[test]
4041
+ fn three_choice_dialog_shows_every_option_with_three_body_lines() {
4042
+ let (reply, _) = mpsc::channel();
4043
+ let mut state = ChatViewState::default();
4044
+ state.dialog = Some(DialogState::new_choice(
4045
+ "Local shell command",
4046
+ "Command: uname -r && uname -a\nMode: mutating\nBackground: no",
4047
+ vec![
4048
+ "Yes".to_owned(),
4049
+ "Yes, allow for this session".to_owned(),
4050
+ "No".to_owned(),
4051
+ ],
4052
+ None,
4053
+ reply,
4054
+ ));
4055
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
4056
+ terminal
4057
+ .draw(|frame| {
4058
+ state.render(frame);
4059
+ })
4060
+ .unwrap();
4061
+ let rendered: String = terminal
4062
+ .backend()
4063
+ .buffer()
4064
+ .content
4065
+ .iter()
4066
+ .map(|cell| cell.symbol())
4067
+ .collect();
4068
+ assert!(rendered.contains("3. No"));
4069
+ assert!(!rendered.contains("more below"));
4070
+ }
4071
+
4072
+ #[test]
4073
+ fn choice_dialog_uses_available_height_for_more_than_eight_options() {
4074
+ let (reply, _) = mpsc::channel();
4075
+ let mut state = ChatViewState::default();
4076
+ state.dialog = Some(DialogState::new_choice(
4077
+ "Choose one",
4078
+ "",
4079
+ (1..=9).map(|index| format!("Option {index}")).collect(),
4080
+ None,
4081
+ reply,
4082
+ ));
4083
+ let mut terminal = Terminal::new(TestBackend::new(80, 32)).unwrap();
4084
+ terminal
4085
+ .draw(|frame| {
4086
+ state.render(frame);
4087
+ })
4088
+ .unwrap();
4089
+ let rendered: String = terminal
4090
+ .backend()
4091
+ .buffer()
4092
+ .content
4093
+ .iter()
4094
+ .map(|cell| cell.symbol())
4095
+ .collect();
4096
+ assert!(rendered.contains("Option 9"));
4097
+ assert!(!rendered.contains("more below"));
4098
+ }
4099
+
4100
+ #[test]
4101
+ fn long_approval_command_keeps_choices_and_hint_visible_in_narrow_terminal() {
4102
+ let (reply, _) = mpsc::channel();
4103
+ let mut state = ChatViewState::default();
4104
+ state.status = vec!["Running shell command".to_owned(); 6];
4105
+ state.dialog = Some(DialogState::new_choice(
4106
+ "Local shell command",
4107
+ concat!(
4108
+ "Command: who -a; echo '--- LOGINCTL ---'; loginctl list-sessions 2>&1; ",
4109
+ "echo '--- SEATS/USERS ---'; loginctl list-users 2>&1; ",
4110
+ "echo '--- UPTIME ---'; uptime -p; uptime -s; ",
4111
+ "echo '--- SESSION ENV ---'; echo TYPE=$XDG_SESSION_TYPE ",
4112
+ "DESKTOP=$XDG_CURRENT_DESKTOP SESSION=$DESKTOP_SESSION ",
4113
+ "WAYLAND=$WAYLAND_DISPLAY DISPLAY=$DISPLAY; ",
4114
+ "echo '--- TTY/USER PROCS (top 25 by count) ---'; ",
4115
+ "ps -u $USER -o comm= | sort | uniq -c | sort -rn | head -25\n",
4116
+ "Mode: mutating\nBackground: no",
4117
+ ),
4118
+ vec![
4119
+ "Yes".to_owned(),
4120
+ "Yes, allow for this session".to_owned(),
4121
+ "No".to_owned(),
4122
+ ],
4123
+ None,
4124
+ reply,
4125
+ ));
4126
+ let mut terminal = Terminal::new(TestBackend::new(94, 30)).unwrap();
4127
+ terminal
4128
+ .draw(|frame| {
4129
+ state.render(frame);
4130
+ })
4131
+ .unwrap();
4132
+ let rendered: String = terminal
4133
+ .backend()
4134
+ .buffer()
4135
+ .content
4136
+ .iter()
4137
+ .map(|cell| cell.symbol())
4138
+ .collect();
4139
+ assert!(rendered.contains("3. No"));
4140
+ assert!(rendered.contains("Enter select"));
4141
+ assert!(!rendered.contains("more below"));
4142
+ }
4143
+
4144
+ #[test]
4145
+ fn live_shell_status_remains_visible_above_a_long_transcript() {
4146
+ let mut state = ChatViewState::default();
4147
+ state.transcript.push(TranscriptEntry::new(
4148
+ TranscriptRole::Assistant,
4149
+ "Earlier output\n".repeat(80),
4150
+ ));
4151
+ state.status = vec![
4152
+ " ● Running 1 shell command…".to_owned(),
4153
+ " └ ⠋ [run_shell] Running command: sleep 1".to_owned(),
4154
+ " Unpacking…".to_owned(),
4155
+ ];
4156
+ let mut terminal = Terminal::new(TestBackend::new(94, 30)).unwrap();
4157
+ terminal
4158
+ .draw(|frame| {
4159
+ state.render(frame);
4160
+ })
4161
+ .unwrap();
4162
+ let rendered: String = terminal
4163
+ .backend()
4164
+ .buffer()
4165
+ .content
4166
+ .iter()
4167
+ .map(|cell| cell.symbol())
4168
+ .collect();
4169
+ assert!(rendered.contains("Running 1 shell command"));
4170
+ assert!(rendered.contains("[run_shell] Running command"));
4171
+ }
4172
+
4173
+ fn point(column: u16, row: u16) -> ScreenPoint {
4174
+ ScreenPoint { row, column }
4175
+ }
4176
+
4177
+ struct FakeClipboard {
4178
+ fail: bool,
4179
+ }
4180
+
4181
+ impl NativeClipboard for FakeClipboard {
4182
+ fn set_text(&mut self, _text: String) -> std::result::Result<(), String> {
4183
+ if self.fail {
4184
+ Err("unavailable".to_owned())
4185
+ } else {
4186
+ Ok(())
4187
+ }
4188
+ }
4189
+ }
4190
+
4191
+ #[test]
4192
+ fn mouse_selection_requires_a_drag_and_normalizes_direction() {
4193
+ let mut selection = MouseSelection::default();
4194
+ selection.start(point(7, 4));
4195
+ assert!(!selection.finish(point(7, 4)));
4196
+ assert_eq!(selection.range(), None);
4197
+
4198
+ selection.start(point(7, 4));
4199
+ selection.update(point(2, 1));
4200
+ assert!(selection.finish(point(2, 1)));
4201
+ assert_eq!(selection.range(), Some((point(2, 1), point(7, 4))));
4202
+ }
4203
+
4204
+ #[test]
4205
+ fn screen_selection_copies_visual_rows_and_trims_only_the_end() {
4206
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 2));
4207
+ buffer.set_string(0, 0, " hello", Style::default());
4208
+ buffer.set_string(0, 1, "world", Style::default());
4209
+ let selection = MouseSelection {
4210
+ anchor: Some(point(1, 0)),
4211
+ head: Some(point(2, 1)),
4212
+ dragging: false,
4213
+ had_drag: true,
4214
+ };
4215
+
4216
+ assert_eq!(selected_screen_text(&buffer, selection), "hello\nwor");
4217
+ }
4218
+
4219
+ #[test]
4220
+ fn screen_selection_includes_a_wide_grapheme_from_its_continuation_cell() {
4221
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 1));
4222
+ buffer.set_string(0, 0, "A🙂B ก้", Style::default());
4223
+ let selection = MouseSelection {
4224
+ anchor: Some(point(2, 0)),
4225
+ head: Some(point(2, 0)),
4226
+ dragging: false,
4227
+ had_drag: true,
4228
+ };
4229
+
4230
+ assert_eq!(selected_screen_text(&buffer, selection), "🙂");
4231
+ }
4232
+
4233
+ #[test]
4234
+ fn selection_overlay_changes_style_without_changing_symbols() {
4235
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
4236
+ buffer.set_string(
4237
+ 0,
4238
+ 0,
4239
+ "Mint",
4240
+ Style::default().fg(crate::terminal_theme::ACCENT),
4241
+ );
4242
+ let before = buffer
4243
+ .content
4244
+ .iter()
4245
+ .map(|cell| cell.symbol().to_owned())
4246
+ .collect::<Vec<_>>();
4247
+ let selection = MouseSelection {
4248
+ anchor: Some(point(1, 0)),
4249
+ head: Some(point(2, 0)),
4250
+ dragging: false,
4251
+ had_drag: true,
4252
+ };
4253
+
4254
+ apply_mouse_selection(&mut buffer, selection);
4255
+
4256
+ let after = buffer
4257
+ .content
4258
+ .iter()
4259
+ .map(|cell| cell.symbol().to_owned())
4260
+ .collect::<Vec<_>>();
4261
+ assert_eq!(after, before);
4262
+ assert!(!buffer[(0, 0)].modifier.contains(Modifier::REVERSED));
4263
+ assert!(buffer[(1, 0)].modifier.contains(Modifier::REVERSED));
4264
+ assert!(buffer[(2, 0)].modifier.contains(Modifier::REVERSED));
4265
+ assert!(!buffer[(3, 0)].modifier.contains(Modifier::REVERSED));
4266
+ }
4267
+
4268
+ #[test]
4269
+ fn completed_selection_is_invalidated_when_selected_cells_change() {
4270
+ let mut previous = Buffer::empty(Rect::new(0, 0, 5, 1));
4271
+ previous.set_string(0, 0, "Mint", Style::default());
4272
+ let selection = MouseSelection {
4273
+ anchor: Some(point(1, 0)),
4274
+ head: Some(point(2, 0)),
4275
+ dragging: false,
4276
+ had_drag: true,
4277
+ };
4278
+ let unchanged = previous.clone();
4279
+ assert!(selected_cells_match(&previous, &unchanged, selection));
4280
+
4281
+ let mut changed_inside = previous.clone();
4282
+ changed_inside.set_string(1, 0, "XX", Style::default());
4283
+ assert!(!selected_cells_match(&previous, &changed_inside, selection));
4284
+
4285
+ let mut changed_outside = previous.clone();
4286
+ changed_outside.set_string(4, 0, "!", Style::default());
4287
+ assert!(selected_cells_match(&previous, &changed_outside, selection));
4288
+ }
4289
+
4290
+ #[test]
4291
+ fn clipboard_prefers_native_and_falls_back_to_osc52() {
4292
+ let mut native = TextClipboard {
4293
+ native: Some(Box::new(FakeClipboard { fail: false })),
4294
+ initialization_attempted: true,
4295
+ };
4296
+ let mut native_output = Vec::new();
4297
+ assert_eq!(
4298
+ native.copy("Mint", &mut native_output).unwrap(),
4299
+ CopyMethod::Native
4300
+ );
4301
+ assert!(native_output.is_empty());
4302
+
4303
+ let mut fallback = TextClipboard {
4304
+ native: Some(Box::new(FakeClipboard { fail: true })),
4305
+ initialization_attempted: true,
4306
+ };
4307
+ let mut osc52_output = Vec::new();
4308
+ assert_eq!(
4309
+ fallback.copy("Mint", &mut osc52_output).unwrap(),
4310
+ CopyMethod::Osc52
4311
+ );
4312
+ assert_eq!(
4313
+ String::from_utf8(osc52_output).unwrap(),
4314
+ "\u{1b}]52;c;TWludA==\u{7}"
4315
+ );
4316
+ }
4317
+
4318
+ #[test]
4319
+ fn terminal_setup_keeps_wheel_and_history_navigation_distinct() {
4320
+ let mut output = Vec::new();
4321
+ setup_terminal_output(&mut output).expect("terminal setup should render ANSI commands");
4322
+
4323
+ let output = String::from_utf8(output).expect("terminal commands should be UTF-8");
4324
+ assert!(output.contains("\u{1b}[?1049h"));
4325
+ assert!(
4326
+ output.contains("\u{1b}[?1000h")
4327
+ && output.contains("\u{1b}[?1002h")
4328
+ && output.contains("\u{1b}[?1003h")
4329
+ && output.contains("\u{1b}[?1006h"),
4330
+ "mouse reporting is required to distinguish wheel events from Up/Down keys"
4331
+ );
4332
+ assert!(output.contains("\u{1b}[?2004h"));
4333
+ }
4334
+
4335
+ #[test]
4336
+ fn mouse_wheel_routes_to_transcript_or_open_thought_panel() {
4337
+ let mut state = ChatViewState::default();
4338
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp);
4339
+ assert_eq!(state.scroll_from_bottom, 3);
4340
+
4341
+ state.thought_modal = Some(ThoughtModalState {
4342
+ elapsed_str: "1.0s".to_owned(),
4343
+ lines: vec!["thought".to_owned()],
4344
+ scroll: 4,
4345
+ });
4346
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollUp);
4347
+ assert_eq!(state.thought_modal.as_ref().unwrap().scroll, 1);
4348
+ route_mouse_wheel(&mut state, event::MouseEventKind::ScrollDown);
4349
+ assert_eq!(state.thought_modal.as_ref().unwrap().scroll, 4);
4350
+ }
4351
+
4352
+ #[test]
4353
+ fn back_to_bottom_appears_only_while_scrolled_up_and_click_returns_to_latest() {
4354
+ let mut state = ChatViewState::default();
4355
+ state.push_user(
4356
+ (0..60)
4357
+ .map(|n| format!("line {n}"))
4358
+ .collect::<Vec<_>>()
4359
+ .join("\n"),
4360
+ );
4361
+ let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
4362
+ let mut button = None;
4363
+ terminal
4364
+ .draw(|frame| button = state.render(frame).back_to_bottom)
4365
+ .unwrap();
4366
+ assert!(button.is_none());
4367
+
4368
+ state.scroll_from_bottom = 10;
4369
+ terminal
4370
+ .draw(|frame| button = state.render(frame).back_to_bottom)
4371
+ .unwrap();
4372
+ let area = button.expect("button should appear above the composer");
4373
+ let rendered: String = terminal
4374
+ .backend()
4375
+ .buffer()
4376
+ .content
4377
+ .iter()
4378
+ .map(|cell| cell.symbol())
4379
+ .collect();
4380
+ assert!(rendered.contains("Back to bottom"));
4381
+ assert!(!activate_back_to_bottom(
4382
+ &mut state,
4383
+ button,
4384
+ event::MouseEventKind::Down(event::MouseButton::Left),
4385
+ ScreenPoint {
4386
+ row: area.y,
4387
+ column: area.x.saturating_sub(1)
4388
+ },
4389
+ ));
4390
+ assert_eq!(state.scroll_from_bottom, 10);
4391
+ assert!(activate_back_to_bottom(
4392
+ &mut state,
4393
+ button,
4394
+ event::MouseEventKind::Down(event::MouseButton::Left),
4395
+ ScreenPoint {
4396
+ row: area.y,
4397
+ column: area.x
4398
+ },
4399
+ ));
4400
+ assert_eq!(state.scroll_from_bottom, 0);
4401
+ terminal
4402
+ .draw(|frame| button = state.render(frame).back_to_bottom)
4403
+ .unwrap();
4404
+ assert!(button.is_none());
4405
+ }
4406
+
4407
+ #[test]
4408
+ fn test_dialog_filtering() {
4409
+ let (tx, _rx) = mpsc::channel();
4410
+ let options = vec![
4411
+ "gpt-4o".to_string(),
4412
+ "claude-3-5-sonnet".to_string(),
4413
+ "gemini-2.0-flash".to_string(),
4414
+ "deepseek-r1".to_string(),
4415
+ ];
4416
+ let mut dialog = DialogState::new_choice("Pick model", "", options, None, tx);
4417
+
4418
+ assert_eq!(dialog.filtered_indices(), vec![0, 1, 2, 3]);
4419
+
4420
+ // Type 'g'
4421
+ dialog.handle_key(make_key(KeyCode::Char('g')));
4422
+ assert_eq!(dialog.filtered_indices(), vec![0, 2]);
4423
+
4424
+ // Type 'e' -> "ge"
4425
+ dialog.handle_key(make_key(KeyCode::Char('e')));
4426
+ assert_eq!(dialog.filtered_indices(), vec![2]);
4427
+
4428
+ // Backspace -> "g"
4429
+ dialog.handle_key(make_key(KeyCode::Backspace));
4430
+ assert_eq!(dialog.filtered_indices(), vec![0, 2]);
4431
+
4432
+ // Esc clears filter
4433
+ dialog.handle_key(make_key(KeyCode::Esc));
4434
+ assert_eq!(dialog.filtered_indices(), vec![0, 1, 2, 3]);
4435
+ }
4436
+
4437
+ #[test]
4438
+ fn test_dialog_navigation_and_numeric_shortcuts() {
4439
+ let (tx, _rx) = mpsc::channel();
4440
+ let options = vec![
4441
+ "Option 1".to_string(),
4442
+ "Option 2".to_string(),
4443
+ "Option 3".to_string(),
4444
+ ];
4445
+ let mut dialog = DialogState::new_choice("Pick one", "", options, None, tx);
4446
+
4447
+ assert_eq!(dialog.selected, 0);
4448
+
4449
+ dialog.handle_key(make_key(KeyCode::Down));
4450
+ assert_eq!(dialog.selected, 1);
4451
+
4452
+ dialog.handle_key(make_key(KeyCode::Down));
4453
+ assert_eq!(dialog.selected, 2);
4454
+
4455
+ // Clamped at bottom
4456
+ dialog.handle_key(make_key(KeyCode::Down));
4457
+ assert_eq!(dialog.selected, 2);
4458
+
4459
+ dialog.handle_key(make_key(KeyCode::Up));
4460
+ assert_eq!(dialog.selected, 1);
4461
+
4462
+ // Numeric shortcut: '1' selects index 0 and returns Choice(0)
4463
+ let answer = dialog.handle_key(make_key(KeyCode::Char('1')));
4464
+ assert_eq!(answer, Some(DialogAnswer::Choice(0)));
4465
+
4466
+ // Numeric shortcut: '3' selects index 2 and returns Choice(2)
4467
+ let answer = dialog.handle_key(make_key(KeyCode::Char('3')));
4468
+ assert_eq!(answer, Some(DialogAnswer::Choice(2)));
4469
+ }
4470
+
4471
+ #[test]
4472
+ fn test_dialog_multiselect() {
4473
+ let (tx, _rx) = mpsc::channel();
4474
+ let options = vec!["A".to_string(), "B".to_string(), "C".to_string()];
4475
+ let checked = vec![false, false, false];
4476
+ let mut dialog = DialogState::new_choice("Multi", "", options, Some(checked), tx);
4477
+
4478
+ // Toggle first option with Space
4479
+ dialog.handle_key(make_key(KeyCode::Char(' ')));
4480
+ assert_eq!(dialog.checked.as_ref().unwrap(), &vec![true, false, false]);
4481
+
4482
+ // Move to second option and toggle
4483
+ dialog.handle_key(make_key(KeyCode::Down));
4484
+ dialog.handle_key(make_key(KeyCode::Char(' ')));
4485
+ assert_eq!(dialog.checked.as_ref().unwrap(), &vec![true, true, false]);
4486
+
4487
+ // Enter submits selected choices
4488
+ let answer = dialog.handle_key(make_key(KeyCode::Enter));
4489
+ assert_eq!(answer, Some(DialogAnswer::Choices(vec![0, 1])));
4490
+ }
4491
+
4492
+ #[test]
4493
+ fn test_dialog_text_input() {
4494
+ let (tx, _rx) = mpsc::channel();
4495
+ let mut dialog = DialogState::new_text("Input", "Prompt", tx);
4496
+
4497
+ dialog.handle_key(make_key(KeyCode::Char('h')));
4498
+ dialog.handle_key(make_key(KeyCode::Char('i')));
4499
+ assert_eq!(dialog.input.as_ref().unwrap(), &vec!['h', 'i']);
4500
+
4501
+ dialog.handle_key(make_key(KeyCode::Backspace));
4502
+ assert_eq!(dialog.input.as_ref().unwrap(), &vec!['h']);
4503
+
4504
+ let answer = dialog.handle_key(make_key(KeyCode::Enter));
4505
+ assert_eq!(answer, Some(DialogAnswer::Text("h".to_string())));
4506
+ }
4507
+
4508
+ #[test]
4509
+ fn test_dialog_cancellation() {
4510
+ let (tx, _rx) = mpsc::channel();
4511
+ let mut dialog =
4512
+ DialogState::new_choice("Pick", "", vec!["A".into(), "B".into()], None, tx);
4513
+
4514
+ let answer = dialog.handle_key(make_key(KeyCode::Esc));
4515
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
4516
+
4517
+ let ctrl_c = KeyEvent {
4518
+ code: KeyCode::Char('c'),
4519
+ modifiers: KeyModifiers::CONTROL,
4520
+ kind: KeyEventKind::Press,
4521
+ state: KeyEventState::empty(),
4522
+ };
4523
+ let answer = dialog.handle_key(ctrl_c);
4524
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
4525
+
4526
+ let ctrl_d = KeyEvent {
4527
+ code: KeyCode::Char('d'),
4528
+ modifiers: KeyModifiers::CONTROL,
4529
+ kind: KeyEventKind::Press,
4530
+ state: KeyEventState::empty(),
4531
+ };
4532
+ let answer = dialog.handle_key(ctrl_d);
4533
+ assert_eq!(answer, Some(DialogAnswer::Cancel));
4534
+ }
4535
+
4536
+ #[test]
4537
+ fn test_transcript_entry_markdown_rendering() {
4538
+ let text = "**Bold Header**\n- Item 1\n---\n> [!TIP]\n> Pro tip here";
4539
+ let entry = TranscriptEntry::new(TranscriptRole::Assistant, text);
4540
+
4541
+ // Check line synchronization
4542
+ assert_eq!(entry.rendered_lines.len(), entry.plain_lines.len());
4543
+
4544
+ // Check bold rendering
4545
+ let first_rendered = &entry.rendered_lines[0];
4546
+ let has_bold = first_rendered
4547
+ .spans
4548
+ .iter()
4549
+ .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
4550
+ assert!(has_bold, "First line should contain bold modifier");
4551
+
4552
+ // Check divider line rendering (converted --- into sleek box drawing ─)
4553
+ let divider_line = &entry.plain_lines[2];
4554
+ assert!(
4555
+ divider_line.contains('─'),
4556
+ "--- should be converted to ─ divider: {divider_line}"
4557
+ );
4558
+
4559
+ // Check TIP alert formatting
4560
+ let alert_line = &entry.plain_lines[3];
4561
+ assert!(
4562
+ alert_line.contains("│ TIP:"),
4563
+ "Alert should format as │ TIP: : {alert_line}"
4564
+ );
4565
+
4566
+ // Check bullet formatting
4567
+ let bullet_line = &entry.plain_lines[1];
4568
+ assert!(
4569
+ bullet_line.contains('•'),
4570
+ "List item should format with bullet • : {bullet_line}"
4571
+ );
4572
+ }
4573
+
4574
+ #[test]
4575
+ fn test_thought_modal_state_and_scrolling() {
4576
+ let mut modal = ThoughtModalState {
4577
+ elapsed_str: "3.5s".to_string(),
4578
+ lines: vec!["line 1".into(), "line 2".into(), "line 3".into()],
4579
+ scroll: 0,
4580
+ };
4581
+
4582
+ assert_eq!(modal.scroll, 0);
4583
+ modal.scroll = modal.scroll.saturating_add(1);
4584
+ assert_eq!(modal.scroll, 1);
4585
+ modal.scroll = modal.scroll.saturating_sub(1);
4586
+ assert_eq!(modal.scroll, 0);
4587
+ }
4588
+
4589
+ #[test]
4590
+ fn test_notice_expiration_and_clear() {
4591
+ let mut state = ChatViewState::default();
4592
+ assert_eq!(state.active_notice(), None);
4593
+
4594
+ state.set_notice("Image attached");
4595
+ assert_eq!(state.active_notice(), Some("Image attached"));
4596
+
4597
+ // Terminal too small warning doesn't auto-expire
4598
+ state.set_notice("Terminal too small: 40x10");
4599
+ assert_eq!(state.active_notice(), Some("Terminal too small: 40x10"));
4600
+
4601
+ state.clear_notice();
4602
+ assert_eq!(state.active_notice(), None);
4603
+
4604
+ // Manually test expiration logic
4605
+ state.notice = Some((
4606
+ "Old notice".into(),
4607
+ std::time::Instant::now() - std::time::Duration::from_millis(3000),
4608
+ ));
4609
+ assert_eq!(state.active_notice(), None);
4610
+ assert!(state.notice_visibility_changed_since_draw(true));
4611
+ assert!(!state.notice_visibility_changed_since_draw(false));
4612
+ }
4613
+
4614
+ #[test]
4615
+ fn test_refresh_shared_transcript_preserves_tool_notice_order() {
4616
+ let temp_dir = std::env::temp_dir().join(format!(
4617
+ "mint-test-tui-sync-{}",
4618
+ std::time::SystemTime::now()
4619
+ .duration_since(std::time::UNIX_EPOCH)
4620
+ .unwrap()
4621
+ .as_nanos()
4622
+ ));
4623
+ let _ = std::fs::create_dir_all(&temp_dir);
4624
+ let db_path = temp_dir.join("test_memory.db");
4625
+ let memory = mint_core::MemoryStore::open(db_path);
4626
+ let workspace = temp_dir.clone();
4627
+ let chat_id = "test-session";
4628
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
4629
+
4630
+ let mut state = ChatViewState {
4631
+ chat_id: chat_id.to_string(),
4632
+ current_dir: workspace.clone(),
4633
+ ..ChatViewState::default()
4634
+ };
4635
+ state.reload_transcript_with(&memory, chat_id, &workspace);
4636
+
4637
+ // Turn 1 runs locally in CLI:
4638
+ state.push_user("List files please".into());
4639
+ state.push_notice("• Listing 2 directories...");
4640
+ state.push_notice("✓ Completed in 1.2s • 1 tool • 0 files changed");
4641
+ state.push_assistant("Found 2 files.");
4642
+ state.push_notice("─ Worked for 1s • test ──────────────────────────────");
4643
+
4644
+ // In DB, TurnLease records this interaction:
4645
+ let turn_id = memory.start_turn(&scoped, "List files please").unwrap();
4646
+ memory.claim_turn(&scoped, turn_id).unwrap();
4647
+ memory
4648
+ .finish_turn(turn_id, "Found 2 files.", "test", "model", None)
4649
+ .unwrap();
4650
+
4651
+ // Advance cursor as local turn completion would:
4652
+ state.advance_sync_cursor_with(&memory);
4653
+
4654
+ // Refresh shared transcript:
4655
+ let redrawn = state.refresh_shared_transcript_with(&memory);
4656
+ assert!(
4657
+ !redrawn,
4658
+ "Should not redraw since changes were from local turn"
4659
+ );
4660
+
4661
+ // Now simulate an external message arriving from Web/Desktop on the same session:
4662
+ let ext_id = memory.start_turn(&scoped, "Message from Web").unwrap();
4663
+ memory.claim_turn(&scoped, ext_id).unwrap();
4664
+ memory
4665
+ .finish_turn(ext_id, "Reply to Web", "test", "model", None)
4666
+ .unwrap();
4667
+
4668
+ let redrawn2 = state.refresh_shared_transcript_with(&memory);
4669
+ assert!(redrawn2, "Should redraw when external turn arrives");
4670
+
4671
+ // Verify transcript order:
4672
+ // Turn 1's tool notices must still be BEFORE Turn 1's assistant reply!
4673
+ // Not pushed to the very bottom!
4674
+ let texts: Vec<&str> = state.transcript.iter().map(|e| e.text.as_str()).collect();
4675
+ assert_eq!(texts.len(), 7);
4676
+ assert_eq!(texts[0], "List files please");
4677
+ assert_eq!(texts[1], "• Listing 2 directories...");
4678
+ assert_eq!(texts[2], "✓ Completed in 1.2s • 1 tool • 0 files changed");
4679
+ assert_eq!(texts[3], "Found 2 files.");
4680
+ assert_eq!(
4681
+ texts[4],
4682
+ "─ Worked for 1s • test ──────────────────────────────"
4683
+ );
4684
+ assert_eq!(texts[5], "Message from Web");
4685
+ assert_eq!(texts[6], "Reply to Web");
4686
+
4687
+ let _ = std::fs::remove_dir_all(&temp_dir);
4688
+ }
4689
+
4690
+ #[test]
4691
+ fn test_refresh_shared_transcript_external_turn_lifecycle() {
4692
+ let temp_dir = std::env::temp_dir().join(format!(
4693
+ "mint-test-tui-sync-lifecycle-{}",
4694
+ std::time::SystemTime::now()
4695
+ .duration_since(std::time::UNIX_EPOCH)
4696
+ .unwrap()
4697
+ .as_nanos()
4698
+ ));
4699
+ let _ = std::fs::create_dir_all(&temp_dir);
4700
+ let db_path = temp_dir.join("test_memory.db");
4701
+ let memory = mint_core::MemoryStore::open(db_path);
4702
+ let workspace = temp_dir.clone();
4703
+ let chat_id = "test-session-2";
4704
+ let scoped = mint_core::scoped_chat_id(chat_id, Some(&workspace.to_string_lossy()));
4705
+
4706
+ let mut state = ChatViewState {
4707
+ chat_id: chat_id.to_string(),
4708
+ current_dir: workspace.clone(),
4709
+ ..ChatViewState::default()
4710
+ };
4711
+ state.reload_transcript_with(&memory, chat_id, &workspace);
4712
+
4713
+ // Turn arrives from Web (status: queued):
4714
+ let ext_id = memory.start_turn(&scoped, "External prompt").unwrap();
4715
+ let redrawn = state.refresh_shared_transcript_with(&memory);
4716
+ assert!(redrawn);
4717
+ assert_eq!(state.transcript.len(), 2);
4718
+ assert_eq!(state.transcript[0].text, "External prompt");
4719
+ assert_eq!(state.transcript[1].text, "Queued for this session…");
4720
+
4721
+ // External turn updates to running:
4722
+ memory.claim_turn(&scoped, ext_id).unwrap();
4723
+ let redrawn = state.refresh_shared_transcript_with(&memory);
4724
+ assert!(redrawn);
4725
+ assert_eq!(state.transcript.len(), 2);
4726
+ assert_eq!(state.transcript[0].text, "External prompt");
4727
+ assert_eq!(state.transcript[1].text, "Mint is responding…");
4728
+
4729
+ // External turn completes:
4730
+ memory
4731
+ .finish_turn(ext_id, "External answer", "provider", "model", None)
4732
+ .unwrap();
4733
+ let redrawn = state.refresh_shared_transcript_with(&memory);
4734
+ assert!(redrawn);
4735
+ assert_eq!(state.transcript.len(), 2);
4736
+ assert_eq!(state.transcript[0].text, "External prompt");
4737
+ assert_eq!(state.transcript[1].text, "External answer");
4738
+ assert_eq!(state.transcript[1].role, TranscriptRole::Assistant);
4739
+
4740
+ let _ = std::fs::remove_dir_all(&temp_dir);
4741
+ }
4742
+ }