pythinker-code 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (731) hide show
  1. pythinker_code/CHANGELOG.md +16 -0
  2. pythinker_code/__init__.py +0 -0
  3. pythinker_code/__main__.py +92 -0
  4. pythinker_code/acp/AGENTS.md +93 -0
  5. pythinker_code/acp/__init__.py +13 -0
  6. pythinker_code/acp/convert.py +128 -0
  7. pythinker_code/acp/host.py +298 -0
  8. pythinker_code/acp/mcp.py +46 -0
  9. pythinker_code/acp/server.py +497 -0
  10. pythinker_code/acp/session.py +496 -0
  11. pythinker_code/acp/tools.py +167 -0
  12. pythinker_code/acp/types.py +13 -0
  13. pythinker_code/acp/version.py +45 -0
  14. pythinker_code/agents/default/agent.yaml +36 -0
  15. pythinker_code/agents/default/coder.yaml +25 -0
  16. pythinker_code/agents/default/explore.yaml +46 -0
  17. pythinker_code/agents/default/plan.yaml +30 -0
  18. pythinker_code/agents/default/system.md +164 -0
  19. pythinker_code/agents/okabe/agent.yaml +22 -0
  20. pythinker_code/agentspec.py +163 -0
  21. pythinker_code/app.py +820 -0
  22. pythinker_code/approval_runtime/__init__.py +29 -0
  23. pythinker_code/approval_runtime/models.py +42 -0
  24. pythinker_code/approval_runtime/runtime.py +235 -0
  25. pythinker_code/auth/__init__.py +25 -0
  26. pythinker_code/auth/anthropic_direct.py +207 -0
  27. pythinker_code/auth/deepseek.py +192 -0
  28. pythinker_code/auth/lm_studio.py +418 -0
  29. pythinker_code/auth/minimax.py +203 -0
  30. pythinker_code/auth/oauth.py +1122 -0
  31. pythinker_code/auth/ollama.py +293 -0
  32. pythinker_code/auth/openai.py +771 -0
  33. pythinker_code/auth/opencode_go.py +203 -0
  34. pythinker_code/auth/openrouter.py +225 -0
  35. pythinker_code/auth/platforms.py +466 -0
  36. pythinker_code/background/__init__.py +36 -0
  37. pythinker_code/background/agent_runner.py +231 -0
  38. pythinker_code/background/ids.py +19 -0
  39. pythinker_code/background/manager.py +650 -0
  40. pythinker_code/background/models.py +105 -0
  41. pythinker_code/background/store.py +237 -0
  42. pythinker_code/background/summary.py +66 -0
  43. pythinker_code/background/worker.py +209 -0
  44. pythinker_code/cli/__init__.py +1326 -0
  45. pythinker_code/cli/__main__.py +19 -0
  46. pythinker_code/cli/_lazy_group.py +238 -0
  47. pythinker_code/cli/export.py +322 -0
  48. pythinker_code/cli/info.py +62 -0
  49. pythinker_code/cli/mcp.py +349 -0
  50. pythinker_code/cli/plugin.py +351 -0
  51. pythinker_code/cli/toad.py +74 -0
  52. pythinker_code/cli/vis.py +38 -0
  53. pythinker_code/cli/web.py +80 -0
  54. pythinker_code/config.py +453 -0
  55. pythinker_code/constant.py +33 -0
  56. pythinker_code/exception.py +43 -0
  57. pythinker_code/hooks/__init__.py +4 -0
  58. pythinker_code/hooks/config.py +34 -0
  59. pythinker_code/hooks/engine.py +371 -0
  60. pythinker_code/hooks/events.py +190 -0
  61. pythinker_code/hooks/runner.py +89 -0
  62. pythinker_code/llm.py +412 -0
  63. pythinker_code/metadata.py +79 -0
  64. pythinker_code/notifications/__init__.py +33 -0
  65. pythinker_code/notifications/llm.py +77 -0
  66. pythinker_code/notifications/manager.py +145 -0
  67. pythinker_code/notifications/models.py +50 -0
  68. pythinker_code/notifications/notifier.py +41 -0
  69. pythinker_code/notifications/store.py +118 -0
  70. pythinker_code/notifications/wire.py +21 -0
  71. pythinker_code/plugin/__init__.py +124 -0
  72. pythinker_code/plugin/manager.py +153 -0
  73. pythinker_code/plugin/tool.py +173 -0
  74. pythinker_code/prompts/__init__.py +6 -0
  75. pythinker_code/prompts/compact.md +73 -0
  76. pythinker_code/prompts/init.md +21 -0
  77. pythinker_code/py.typed +0 -0
  78. pythinker_code/session.py +319 -0
  79. pythinker_code/session_fork.py +325 -0
  80. pythinker_code/session_state.py +132 -0
  81. pythinker_code/share.py +14 -0
  82. pythinker_code/skill/__init__.py +727 -0
  83. pythinker_code/skill/flow/__init__.py +99 -0
  84. pythinker_code/skill/flow/d2.py +482 -0
  85. pythinker_code/skill/flow/mermaid.py +266 -0
  86. pythinker_code/skills/pythinker-code-help/SKILL.md +54 -0
  87. pythinker_code/skills/skill-creator/SKILL.md +367 -0
  88. pythinker_code/soul/__init__.py +304 -0
  89. pythinker_code/soul/agent.py +520 -0
  90. pythinker_code/soul/approval.py +267 -0
  91. pythinker_code/soul/btw.py +214 -0
  92. pythinker_code/soul/compaction.py +189 -0
  93. pythinker_code/soul/context.py +339 -0
  94. pythinker_code/soul/denwarenji.py +39 -0
  95. pythinker_code/soul/dynamic_injection.py +84 -0
  96. pythinker_code/soul/dynamic_injections/__init__.py +0 -0
  97. pythinker_code/soul/dynamic_injections/auto_mode.py +72 -0
  98. pythinker_code/soul/dynamic_injections/plan_mode.py +239 -0
  99. pythinker_code/soul/message.py +92 -0
  100. pythinker_code/soul/pythinkersoul.py +1613 -0
  101. pythinker_code/soul/slash.py +340 -0
  102. pythinker_code/soul/toolset.py +788 -0
  103. pythinker_code/subagents/__init__.py +21 -0
  104. pythinker_code/subagents/builder.py +42 -0
  105. pythinker_code/subagents/core.py +86 -0
  106. pythinker_code/subagents/git_context.py +172 -0
  107. pythinker_code/subagents/models.py +54 -0
  108. pythinker_code/subagents/output.py +71 -0
  109. pythinker_code/subagents/registry.py +28 -0
  110. pythinker_code/subagents/runner.py +428 -0
  111. pythinker_code/subagents/store.py +196 -0
  112. pythinker_code/telemetry/__init__.py +211 -0
  113. pythinker_code/telemetry/config.py +54 -0
  114. pythinker_code/telemetry/crash.py +157 -0
  115. pythinker_code/telemetry/metrics.py +208 -0
  116. pythinker_code/telemetry/otel.py +240 -0
  117. pythinker_code/telemetry/sentry.py +167 -0
  118. pythinker_code/telemetry/sink.py +189 -0
  119. pythinker_code/tools/AGENTS.md +6 -0
  120. pythinker_code/tools/__init__.py +105 -0
  121. pythinker_code/tools/agent/__init__.py +277 -0
  122. pythinker_code/tools/agent/description.md +41 -0
  123. pythinker_code/tools/ask_user/__init__.py +159 -0
  124. pythinker_code/tools/ask_user/description.md +19 -0
  125. pythinker_code/tools/background/__init__.py +318 -0
  126. pythinker_code/tools/background/list.md +10 -0
  127. pythinker_code/tools/background/output.md +11 -0
  128. pythinker_code/tools/background/stop.md +8 -0
  129. pythinker_code/tools/display.py +46 -0
  130. pythinker_code/tools/dmail/__init__.py +38 -0
  131. pythinker_code/tools/dmail/dmail.md +17 -0
  132. pythinker_code/tools/file/__init__.py +30 -0
  133. pythinker_code/tools/file/glob.md +17 -0
  134. pythinker_code/tools/file/glob.py +160 -0
  135. pythinker_code/tools/file/grep.md +6 -0
  136. pythinker_code/tools/file/grep_local.py +589 -0
  137. pythinker_code/tools/file/plan_mode.py +45 -0
  138. pythinker_code/tools/file/read.md +16 -0
  139. pythinker_code/tools/file/read.py +300 -0
  140. pythinker_code/tools/file/read_media.md +24 -0
  141. pythinker_code/tools/file/read_media.py +217 -0
  142. pythinker_code/tools/file/replace.md +7 -0
  143. pythinker_code/tools/file/replace.py +195 -0
  144. pythinker_code/tools/file/utils.py +257 -0
  145. pythinker_code/tools/file/write.md +5 -0
  146. pythinker_code/tools/file/write.py +177 -0
  147. pythinker_code/tools/plan/__init__.py +327 -0
  148. pythinker_code/tools/plan/description.md +29 -0
  149. pythinker_code/tools/plan/enter.py +190 -0
  150. pythinker_code/tools/plan/enter_description.md +35 -0
  151. pythinker_code/tools/plan/heroes.py +277 -0
  152. pythinker_code/tools/shell/__init__.py +253 -0
  153. pythinker_code/tools/shell/bash.md +35 -0
  154. pythinker_code/tools/shell/powershell.md +30 -0
  155. pythinker_code/tools/test.py +55 -0
  156. pythinker_code/tools/think/__init__.py +21 -0
  157. pythinker_code/tools/think/think.md +1 -0
  158. pythinker_code/tools/todo/__init__.py +168 -0
  159. pythinker_code/tools/todo/set_todo_list.md +23 -0
  160. pythinker_code/tools/utils.py +199 -0
  161. pythinker_code/tools/web/__init__.py +4 -0
  162. pythinker_code/tools/web/fetch.md +1 -0
  163. pythinker_code/tools/web/fetch.py +189 -0
  164. pythinker_code/tools/web/search.md +1 -0
  165. pythinker_code/tools/web/search.py +163 -0
  166. pythinker_code/ui/__init__.py +0 -0
  167. pythinker_code/ui/acp/__init__.py +99 -0
  168. pythinker_code/ui/print/__init__.py +474 -0
  169. pythinker_code/ui/print/visualize.py +185 -0
  170. pythinker_code/ui/shell/__init__.py +1696 -0
  171. pythinker_code/ui/shell/console.py +109 -0
  172. pythinker_code/ui/shell/debug.py +190 -0
  173. pythinker_code/ui/shell/echo.py +17 -0
  174. pythinker_code/ui/shell/export_import.py +117 -0
  175. pythinker_code/ui/shell/keyboard.py +300 -0
  176. pythinker_code/ui/shell/mcp_status.py +113 -0
  177. pythinker_code/ui/shell/model_picker.py +318 -0
  178. pythinker_code/ui/shell/oauth.py +272 -0
  179. pythinker_code/ui/shell/placeholders.py +531 -0
  180. pythinker_code/ui/shell/prompt.py +2278 -0
  181. pythinker_code/ui/shell/replay.py +215 -0
  182. pythinker_code/ui/shell/session_picker.py +227 -0
  183. pythinker_code/ui/shell/setup.py +212 -0
  184. pythinker_code/ui/shell/slash.py +898 -0
  185. pythinker_code/ui/shell/startup.py +32 -0
  186. pythinker_code/ui/shell/task_browser.py +486 -0
  187. pythinker_code/ui/shell/update.py +350 -0
  188. pythinker_code/ui/shell/usage.py +291 -0
  189. pythinker_code/ui/shell/usage_adapters/__init__.py +50 -0
  190. pythinker_code/ui/shell/usage_adapters/anthropic_admin.py +233 -0
  191. pythinker_code/ui/shell/usage_adapters/base.py +72 -0
  192. pythinker_code/ui/shell/usage_adapters/deepseek.py +137 -0
  193. pythinker_code/ui/shell/usage_adapters/minimax.py +236 -0
  194. pythinker_code/ui/shell/usage_adapters/openai_admin.py +225 -0
  195. pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py +241 -0
  196. pythinker_code/ui/shell/usage_adapters/opencode_go.py +232 -0
  197. pythinker_code/ui/shell/usage_adapters/openrouter.py +105 -0
  198. pythinker_code/ui/shell/usage_adapters/pythinker.py +189 -0
  199. pythinker_code/ui/shell/usage_adapters/pythinker_ai.py +50 -0
  200. pythinker_code/ui/shell/usage_render.py +150 -0
  201. pythinker_code/ui/shell/visualize/__init__.py +165 -0
  202. pythinker_code/ui/shell/visualize/_approval_panel.py +505 -0
  203. pythinker_code/ui/shell/visualize/_blocks.py +629 -0
  204. pythinker_code/ui/shell/visualize/_btw_panel.py +224 -0
  205. pythinker_code/ui/shell/visualize/_input_router.py +48 -0
  206. pythinker_code/ui/shell/visualize/_interactive.py +523 -0
  207. pythinker_code/ui/shell/visualize/_live_view.py +826 -0
  208. pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
  209. pythinker_code/ui/theme.py +241 -0
  210. pythinker_code/usage_ratelimit_cache.py +175 -0
  211. pythinker_code/utils/__init__.py +0 -0
  212. pythinker_code/utils/aiohttp.py +24 -0
  213. pythinker_code/utils/aioqueue.py +72 -0
  214. pythinker_code/utils/broadcast.py +37 -0
  215. pythinker_code/utils/changelog.py +108 -0
  216. pythinker_code/utils/clipboard.py +246 -0
  217. pythinker_code/utils/datetime.py +64 -0
  218. pythinker_code/utils/diff.py +135 -0
  219. pythinker_code/utils/editor.py +91 -0
  220. pythinker_code/utils/environment.py +73 -0
  221. pythinker_code/utils/envvar.py +22 -0
  222. pythinker_code/utils/export.py +696 -0
  223. pythinker_code/utils/file_filter.py +375 -0
  224. pythinker_code/utils/frontmatter.py +70 -0
  225. pythinker_code/utils/io.py +27 -0
  226. pythinker_code/utils/logging.py +146 -0
  227. pythinker_code/utils/media_tags.py +29 -0
  228. pythinker_code/utils/message.py +24 -0
  229. pythinker_code/utils/path.py +199 -0
  230. pythinker_code/utils/proctitle.py +33 -0
  231. pythinker_code/utils/proxy.py +31 -0
  232. pythinker_code/utils/pyinstaller.py +45 -0
  233. pythinker_code/utils/rich/__init__.py +33 -0
  234. pythinker_code/utils/rich/columns.py +99 -0
  235. pythinker_code/utils/rich/diff_render.py +481 -0
  236. pythinker_code/utils/rich/markdown.py +900 -0
  237. pythinker_code/utils/rich/markdown_sample.md +108 -0
  238. pythinker_code/utils/rich/markdown_sample_short.md +2 -0
  239. pythinker_code/utils/rich/syntax.py +114 -0
  240. pythinker_code/utils/sensitive.py +54 -0
  241. pythinker_code/utils/server.py +121 -0
  242. pythinker_code/utils/signals.py +43 -0
  243. pythinker_code/utils/slashcmd.py +124 -0
  244. pythinker_code/utils/string.py +41 -0
  245. pythinker_code/utils/subprocess_env.py +73 -0
  246. pythinker_code/utils/term.py +168 -0
  247. pythinker_code/utils/typing.py +20 -0
  248. pythinker_code/vis/__init__.py +0 -0
  249. pythinker_code/vis/api/__init__.py +5 -0
  250. pythinker_code/vis/api/sessions.py +687 -0
  251. pythinker_code/vis/api/statistics.py +209 -0
  252. pythinker_code/vis/api/system.py +19 -0
  253. pythinker_code/vis/app.py +175 -0
  254. pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-D2MTYyJz.js +1 -0
  255. pythinker_code/vis/static/assets/index-CezafTt_.js +185 -0
  256. pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -0
  257. pythinker_code/vis/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  258. pythinker_code/vis/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  259. pythinker_code/vis/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  260. pythinker_code/vis/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  261. pythinker_code/vis/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  262. pythinker_code/vis/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  263. pythinker_code/vis/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  264. pythinker_code/vis/static/index.html +17 -0
  265. pythinker_code/web/__init__.py +5 -0
  266. pythinker_code/web/api/__init__.py +15 -0
  267. pythinker_code/web/api/config.py +208 -0
  268. pythinker_code/web/api/open_in.py +199 -0
  269. pythinker_code/web/api/sessions.py +1225 -0
  270. pythinker_code/web/app.py +451 -0
  271. pythinker_code/web/auth.py +191 -0
  272. pythinker_code/web/models.py +98 -0
  273. pythinker_code/web/runner/__init__.py +5 -0
  274. pythinker_code/web/runner/messages.py +57 -0
  275. pythinker_code/web/runner/process.py +754 -0
  276. pythinker_code/web/runner/worker.py +97 -0
  277. pythinker_code/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  278. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  279. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  280. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  281. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  282. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  283. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  284. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  285. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  286. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  287. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  288. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  289. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  290. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  291. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  292. pythinker_code/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  293. pythinker_code/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  294. pythinker_code/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  295. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  296. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  297. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  298. pythinker_code/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  299. pythinker_code/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  300. pythinker_code/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  301. pythinker_code/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  302. pythinker_code/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  303. pythinker_code/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  304. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  305. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  306. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  307. pythinker_code/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  308. pythinker_code/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  309. pythinker_code/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  310. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  311. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  312. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  313. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  314. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  315. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  316. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  317. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  318. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  319. pythinker_code/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  320. pythinker_code/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  321. pythinker_code/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  322. pythinker_code/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  323. pythinker_code/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  324. pythinker_code/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  325. pythinker_code/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  326. pythinker_code/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  327. pythinker_code/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  328. pythinker_code/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  329. pythinker_code/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  330. pythinker_code/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  331. pythinker_code/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  332. pythinker_code/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  333. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  334. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  335. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  336. pythinker_code/web/static/assets/_baseUniq--dyU3g5v.js +1 -0
  337. pythinker_code/web/static/assets/abap-BdImnpbu.js +1 -0
  338. pythinker_code/web/static/assets/actionscript-3-CfeIJUat.js +1 -0
  339. pythinker_code/web/static/assets/ada-bCR0ucgS.js +1 -0
  340. pythinker_code/web/static/assets/andromeeda-C-Jbm3Hp.js +1 -0
  341. pythinker_code/web/static/assets/angular-html-CU67Zn6k.js +1 -0
  342. pythinker_code/web/static/assets/angular-ts-BwZT4LLn.js +1 -0
  343. pythinker_code/web/static/assets/apache-Pmp26Uib.js +1 -0
  344. pythinker_code/web/static/assets/apex-D8_7TLub.js +1 -0
  345. pythinker_code/web/static/assets/apl-dKokRX4l.js +1 -0
  346. pythinker_code/web/static/assets/applescript-Co6uUVPk.js +1 -0
  347. pythinker_code/web/static/assets/ara-BRHolxvo.js +1 -0
  348. pythinker_code/web/static/assets/arc-DkMjLpYa.js +1 -0
  349. pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-CHWVaGo9.js +36 -0
  350. pythinker_code/web/static/assets/asciidoc-Dv7Oe6Be.js +1 -0
  351. pythinker_code/web/static/assets/asm-D_Q5rh1f.js +1 -0
  352. pythinker_code/web/static/assets/astro-CbQHKStN.js +1 -0
  353. pythinker_code/web/static/assets/aurora-x-D-2ljcwZ.js +1 -0
  354. pythinker_code/web/static/assets/awk-DMzUqQB5.js +1 -0
  355. pythinker_code/web/static/assets/ayu-dark-CmMr59Fi.js +1 -0
  356. pythinker_code/web/static/assets/ballerina-BFfxhgS-.js +1 -0
  357. pythinker_code/web/static/assets/bat-BkioyH1T.js +1 -0
  358. pythinker_code/web/static/assets/beancount-k_qm7-4y.js +1 -0
  359. pythinker_code/web/static/assets/berry-uYugtg8r.js +1 -0
  360. pythinker_code/web/static/assets/bibtex-CHM0blh-.js +1 -0
  361. pythinker_code/web/static/assets/bicep-Bmn6On1c.js +1 -0
  362. pythinker_code/web/static/assets/blade-D4QpJJKB.js +1 -0
  363. pythinker_code/web/static/assets/blockDiagram-VD42YOAC-DzdKe497.js +122 -0
  364. pythinker_code/web/static/assets/bsl-BO_Y6i37.js +1 -0
  365. pythinker_code/web/static/assets/c-BIGW1oBm.js +1 -0
  366. pythinker_code/web/static/assets/c3-VCDPK7BO.js +1 -0
  367. pythinker_code/web/static/assets/c4Diagram-YG6GDRKO-D84Blotg.js +10 -0
  368. pythinker_code/web/static/assets/cadence-Bv_4Rxtq.js +1 -0
  369. pythinker_code/web/static/assets/cairo-KRGpt6FW.js +1 -0
  370. pythinker_code/web/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  371. pythinker_code/web/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  372. pythinker_code/web/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  373. pythinker_code/web/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  374. pythinker_code/web/static/assets/channel-CllSjjdl.js +1 -0
  375. pythinker_code/web/static/assets/chunk-4BX2VUAB-C9w8wleE.js +1 -0
  376. pythinker_code/web/static/assets/chunk-55IACEB6-YlYJ8HnF.js +1 -0
  377. pythinker_code/web/static/assets/chunk-B4BG7PRW-Bwtz_AHU.js +165 -0
  378. pythinker_code/web/static/assets/chunk-DI55MBZ5-BbEHkl8h.js +220 -0
  379. pythinker_code/web/static/assets/chunk-FMBD7UC4-BKPbvjLC.js +15 -0
  380. pythinker_code/web/static/assets/chunk-QN33PNHL-D73dQvKf.js +1 -0
  381. pythinker_code/web/static/assets/chunk-QZHKN3VN-zGiLKes_.js +1 -0
  382. pythinker_code/web/static/assets/chunk-TZMSLE5B-LHJCi2fy.js +1 -0
  383. pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
  384. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-vX27iZwa.js +1 -0
  385. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-vX27iZwa.js +1 -0
  386. pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
  387. pythinker_code/web/static/assets/clone-DYBkaPm2.js +1 -0
  388. pythinker_code/web/static/assets/cmake-D1j8_8rp.js +1 -0
  389. pythinker_code/web/static/assets/cobol-nwyudZeR.js +1 -0
  390. pythinker_code/web/static/assets/code-block-IT6T5CEO-NtKViZGl.js +2 -0
  391. pythinker_code/web/static/assets/codeowners-Bp6g37R7.js +1 -0
  392. pythinker_code/web/static/assets/codeql-DsOJ9woJ.js +1 -0
  393. pythinker_code/web/static/assets/coffee-Ch7k5sss.js +1 -0
  394. pythinker_code/web/static/assets/common-lisp-Cg-RD9OK.js +1 -0
  395. pythinker_code/web/static/assets/coq-DkFqJrB1.js +1 -0
  396. pythinker_code/web/static/assets/cose-bilkent-S5V4N54A-DialjZpd.js +1 -0
  397. pythinker_code/web/static/assets/cpp-CofmeUqb.js +1 -0
  398. pythinker_code/web/static/assets/crystal-tKQVLTB8.js +1 -0
  399. pythinker_code/web/static/assets/csharp-K5feNrxe.js +1 -0
  400. pythinker_code/web/static/assets/css-DPfMkruS.js +1 -0
  401. pythinker_code/web/static/assets/csv-fuZLfV_i.js +1 -0
  402. pythinker_code/web/static/assets/cue-D82EKSYY.js +1 -0
  403. pythinker_code/web/static/assets/cypher-COkxafJQ.js +1 -0
  404. pythinker_code/web/static/assets/cytoscape.esm-C_Fzpdck.js +321 -0
  405. pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
  406. pythinker_code/web/static/assets/dagre-6UL2VRFP-DfuvkZZ7.js +4 -0
  407. pythinker_code/web/static/assets/dark-plus-C3mMm8J8.js +1 -0
  408. pythinker_code/web/static/assets/dart-CF10PKvl.js +1 -0
  409. pythinker_code/web/static/assets/dax-CEL-wOlO.js +1 -0
  410. pythinker_code/web/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  411. pythinker_code/web/static/assets/desktop-BmXAJ9_W.js +1 -0
  412. pythinker_code/web/static/assets/diagram-PSM6KHXK-DLGctX3v.js +24 -0
  413. pythinker_code/web/static/assets/diagram-QEK2KX5R-DnxN6S0F.js +43 -0
  414. pythinker_code/web/static/assets/diagram-S2PKOQOG-Caq_Set9.js +24 -0
  415. pythinker_code/web/static/assets/diff-D97Zzqfu.js +1 -0
  416. pythinker_code/web/static/assets/docker-BcOcwvcX.js +1 -0
  417. pythinker_code/web/static/assets/dotenv-Da5cRb03.js +1 -0
  418. pythinker_code/web/static/assets/dracula-BzJJZx-M.js +1 -0
  419. pythinker_code/web/static/assets/dracula-soft-BXkSAIEj.js +1 -0
  420. pythinker_code/web/static/assets/dream-maker-BtqSS_iP.js +1 -0
  421. pythinker_code/web/static/assets/edge-BkV0erSs.js +1 -0
  422. pythinker_code/web/static/assets/elixir-CDX3lj18.js +1 -0
  423. pythinker_code/web/static/assets/elm-DbKCFpqz.js +1 -0
  424. pythinker_code/web/static/assets/emacs-lisp-C9XAeP06.js +1 -0
  425. pythinker_code/web/static/assets/erDiagram-Q2GNP2WA-BgTfALoK.js +60 -0
  426. pythinker_code/web/static/assets/erb-BOJIQeun.js +1 -0
  427. pythinker_code/web/static/assets/erlang-DsQrWhSR.js +1 -0
  428. pythinker_code/web/static/assets/everforest-dark-BgDCqdQA.js +1 -0
  429. pythinker_code/web/static/assets/everforest-light-C8M2exoo.js +1 -0
  430. pythinker_code/web/static/assets/fennel-BYunw83y.js +1 -0
  431. pythinker_code/web/static/assets/fish-BvzEVeQv.js +1 -0
  432. pythinker_code/web/static/assets/flowDiagram-NV44I4VS-QjW_fnGi.js +162 -0
  433. pythinker_code/web/static/assets/fluent-C4IJs8-o.js +1 -0
  434. pythinker_code/web/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  435. pythinker_code/web/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
  436. pythinker_code/web/static/assets/fsharp-CXgrBDvD.js +1 -0
  437. pythinker_code/web/static/assets/ganttDiagram-JELNMOA3-fqi8JFof.js +267 -0
  438. pythinker_code/web/static/assets/gdresource-B7Tvp0Sc.js +1 -0
  439. pythinker_code/web/static/assets/gdscript-DTMYz4Jt.js +1 -0
  440. pythinker_code/web/static/assets/gdshader-DkwncUOv.js +1 -0
  441. pythinker_code/web/static/assets/genie-D0YGMca9.js +1 -0
  442. pythinker_code/web/static/assets/gherkin-DyxjwDmM.js +1 -0
  443. pythinker_code/web/static/assets/git-commit-F4YmCXRG.js +1 -0
  444. pythinker_code/web/static/assets/git-rebase-r7XF79zn.js +1 -0
  445. pythinker_code/web/static/assets/gitGraphDiagram-NY62KEGX-i7o6VQ8x.js +65 -0
  446. pythinker_code/web/static/assets/github-dark-DHJKELXO.js +1 -0
  447. pythinker_code/web/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
  448. pythinker_code/web/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  449. pythinker_code/web/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  450. pythinker_code/web/static/assets/github-light-DAi9KRSo.js +1 -0
  451. pythinker_code/web/static/assets/github-light-default-D7oLnXFd.js +1 -0
  452. pythinker_code/web/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  453. pythinker_code/web/static/assets/gleam-BspZqrRM.js +1 -0
  454. pythinker_code/web/static/assets/glimmer-js-Rg0-pVw9.js +1 -0
  455. pythinker_code/web/static/assets/glimmer-ts-U6CK756n.js +1 -0
  456. pythinker_code/web/static/assets/glsl-DplSGwfg.js +1 -0
  457. pythinker_code/web/static/assets/gn-n2N0HUVH.js +1 -0
  458. pythinker_code/web/static/assets/gnuplot-DdkO51Og.js +1 -0
  459. pythinker_code/web/static/assets/go-Dn2_MT6a.js +1 -0
  460. pythinker_code/web/static/assets/graph-C0vZK2pT.js +1 -0
  461. pythinker_code/web/static/assets/graphql-ChdNCCLP.js +1 -0
  462. pythinker_code/web/static/assets/groovy-gcz8RCvz.js +1 -0
  463. pythinker_code/web/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  464. pythinker_code/web/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  465. pythinker_code/web/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  466. pythinker_code/web/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  467. pythinker_code/web/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  468. pythinker_code/web/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  469. pythinker_code/web/static/assets/hack-CaT9iCJl.js +1 -0
  470. pythinker_code/web/static/assets/haml-B8DHNrY2.js +1 -0
  471. pythinker_code/web/static/assets/handlebars-BL8al0AC.js +1 -0
  472. pythinker_code/web/static/assets/haskell-Df6bDoY_.js +1 -0
  473. pythinker_code/web/static/assets/haxe-CzTSHFRz.js +1 -0
  474. pythinker_code/web/static/assets/hcl-BWvSN4gD.js +1 -0
  475. pythinker_code/web/static/assets/hjson-D5-asLiD.js +1 -0
  476. pythinker_code/web/static/assets/hlsl-D3lLCCz7.js +1 -0
  477. pythinker_code/web/static/assets/houston-DnULxvSX.js +1 -0
  478. pythinker_code/web/static/assets/html-GMplVEZG.js +1 -0
  479. pythinker_code/web/static/assets/html-derivative-BFtXZ54Q.js +1 -0
  480. pythinker_code/web/static/assets/http-jrhK8wxY.js +1 -0
  481. pythinker_code/web/static/assets/hurl-irOxFIW8.js +1 -0
  482. pythinker_code/web/static/assets/hxml-Bvhsp5Yf.js +1 -0
  483. pythinker_code/web/static/assets/hy-DFXneXwc.js +1 -0
  484. pythinker_code/web/static/assets/imba-DGztddWO.js +1 -0
  485. pythinker_code/web/static/assets/index-BYCCk6-K.js +153 -0
  486. pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
  487. pythinker_code/web/static/assets/index-Cpy4G3uJ.js +2 -0
  488. pythinker_code/web/static/assets/index-CzV_vCfu.css +1 -0
  489. pythinker_code/web/static/assets/index-DI2oedCt.js +19 -0
  490. pythinker_code/web/static/assets/index-DdIkp80K.js +5 -0
  491. pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-BMPpeZSM.js +2 -0
  492. pythinker_code/web/static/assets/ini-BEwlwnbL.js +1 -0
  493. pythinker_code/web/static/assets/init-Gi6I4Gst.js +1 -0
  494. pythinker_code/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  495. pythinker_code/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  496. pythinker_code/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  497. pythinker_code/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  498. pythinker_code/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  499. pythinker_code/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  500. pythinker_code/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  501. pythinker_code/web/static/assets/java-CylS5w8V.js +1 -0
  502. pythinker_code/web/static/assets/javascript-wDzz0qaB.js +1 -0
  503. pythinker_code/web/static/assets/jinja-4LBKfQ-Z.js +1 -0
  504. pythinker_code/web/static/assets/jison-wvAkD_A8.js +1 -0
  505. pythinker_code/web/static/assets/journeyDiagram-XKPGCS4Q-DAM7gngo.js +139 -0
  506. pythinker_code/web/static/assets/json-Cp-IABpG.js +1 -0
  507. pythinker_code/web/static/assets/json5-C9tS-k6U.js +1 -0
  508. pythinker_code/web/static/assets/jsonc-Des-eS-w.js +1 -0
  509. pythinker_code/web/static/assets/jsonl-DcaNXYhu.js +1 -0
  510. pythinker_code/web/static/assets/jsonnet-DFQXde-d.js +1 -0
  511. pythinker_code/web/static/assets/jssm-C2t-YnRu.js +1 -0
  512. pythinker_code/web/static/assets/jsx-g9-lgVsj.js +1 -0
  513. pythinker_code/web/static/assets/julia-CxzCAyBv.js +1 -0
  514. pythinker_code/web/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  515. pythinker_code/web/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  516. pythinker_code/web/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
  517. pythinker_code/web/static/assets/kanban-definition-3W4ZIXB7-ChpBpV0k.js +89 -0
  518. pythinker_code/web/static/assets/katex-D2lIc1rk.css +1 -0
  519. pythinker_code/web/static/assets/kdl-DV7GczEv.js +1 -0
  520. pythinker_code/web/static/assets/kotlin-BdnUsdx6.js +1 -0
  521. pythinker_code/web/static/assets/kusto-DZf3V79B.js +1 -0
  522. pythinker_code/web/static/assets/laserwave-DUszq2jm.js +1 -0
  523. pythinker_code/web/static/assets/latex-B4uzh10-.js +1 -0
  524. pythinker_code/web/static/assets/layout-C3Jp1gKO.js +1 -0
  525. pythinker_code/web/static/assets/lean-BZvkOJ9d.js +1 -0
  526. pythinker_code/web/static/assets/less-B1dDrJ26.js +1 -0
  527. pythinker_code/web/static/assets/light-plus-B7mTdjB0.js +1 -0
  528. pythinker_code/web/static/assets/linear-BGHtL1N4.js +1 -0
  529. pythinker_code/web/static/assets/liquid-DYVedYrR.js +1 -0
  530. pythinker_code/web/static/assets/llvm-BtvRca6l.js +1 -0
  531. pythinker_code/web/static/assets/log-2UxHyX5q.js +1 -0
  532. pythinker_code/web/static/assets/logo-BtOb2qkB.js +1 -0
  533. pythinker_code/web/static/assets/lua-BbnMAYS6.js +1 -0
  534. pythinker_code/web/static/assets/luau-C-HG3fhB.js +1 -0
  535. pythinker_code/web/static/assets/make-CHLpvVh8.js +1 -0
  536. pythinker_code/web/static/assets/markdown-Cvjx9yec.js +1 -0
  537. pythinker_code/web/static/assets/marko-DZsq8hO1.js +1 -0
  538. pythinker_code/web/static/assets/material-theme-D5KoaKCx.js +1 -0
  539. pythinker_code/web/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
  540. pythinker_code/web/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  541. pythinker_code/web/static/assets/material-theme-ocean-CyktbL80.js +1 -0
  542. pythinker_code/web/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  543. pythinker_code/web/static/assets/matlab-D7o27uSR.js +1 -0
  544. pythinker_code/web/static/assets/mdc-DUICxH0z.js +1 -0
  545. pythinker_code/web/static/assets/mdx-Cmh6b_Ma.js +1 -0
  546. pythinker_code/web/static/assets/mermaid-VLURNSYL-B2P5VJ9v.css +1 -0
  547. pythinker_code/web/static/assets/mermaid-VLURNSYL-C_HW6koB.js +465 -0
  548. pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
  549. pythinker_code/web/static/assets/mermaid.core-CnT4VrPC.js +191 -0
  550. pythinker_code/web/static/assets/min-Dn5VRVmX.js +1 -0
  551. pythinker_code/web/static/assets/min-dark-CafNBF8u.js +1 -0
  552. pythinker_code/web/static/assets/min-light-CTRr51gU.js +1 -0
  553. pythinker_code/web/static/assets/mindmap-definition-VGOIOE7T-x8EwhfIt.js +68 -0
  554. pythinker_code/web/static/assets/mipsasm-CKIfxQSi.js +1 -0
  555. pythinker_code/web/static/assets/mojo-B93PlW-d.js +1 -0
  556. pythinker_code/web/static/assets/monokai-D4h5O-jR.js +1 -0
  557. pythinker_code/web/static/assets/moonbit-Ba13S78F.js +1 -0
  558. pythinker_code/web/static/assets/move-Bu9oaDYs.js +1 -0
  559. pythinker_code/web/static/assets/narrat-DRg8JJMk.js +1 -0
  560. pythinker_code/web/static/assets/nextflow-BrzmwbiE.js +1 -0
  561. pythinker_code/web/static/assets/nginx-DknmC5AR.js +1 -0
  562. pythinker_code/web/static/assets/night-owl-C39BiMTA.js +1 -0
  563. pythinker_code/web/static/assets/nim-CVrawwO9.js +1 -0
  564. pythinker_code/web/static/assets/nix-CwoSXNpI.js +1 -0
  565. pythinker_code/web/static/assets/nord-Ddv68eIx.js +1 -0
  566. pythinker_code/web/static/assets/nushell-C-sUppwS.js +1 -0
  567. pythinker_code/web/static/assets/objective-c-DXmwc3jG.js +1 -0
  568. pythinker_code/web/static/assets/objective-cpp-CLxacb5B.js +1 -0
  569. pythinker_code/web/static/assets/ocaml-C0hk2d4L.js +1 -0
  570. pythinker_code/web/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  571. pythinker_code/web/static/assets/one-light-PoHY5YXO.js +1 -0
  572. pythinker_code/web/static/assets/openscad-C4EeE6gA.js +1 -0
  573. pythinker_code/web/static/assets/ordinal-Cboi1Yqb.js +1 -0
  574. pythinker_code/web/static/assets/pascal-D93ZcfNL.js +1 -0
  575. pythinker_code/web/static/assets/perl-C0TMdlhV.js +1 -0
  576. pythinker_code/web/static/assets/php-CDn_0X-4.js +1 -0
  577. pythinker_code/web/static/assets/pieDiagram-ADFJNKIX-DgxBKGz2.js +30 -0
  578. pythinker_code/web/static/assets/pkl-u5AG7uiY.js +1 -0
  579. pythinker_code/web/static/assets/plastic-3e1v2bzS.js +1 -0
  580. pythinker_code/web/static/assets/plsql-ChMvpjG-.js +1 -0
  581. pythinker_code/web/static/assets/po-BTJTHyun.js +1 -0
  582. pythinker_code/web/static/assets/poimandres-CS3Unz2-.js +1 -0
  583. pythinker_code/web/static/assets/polar-C0HS_06l.js +1 -0
  584. pythinker_code/web/static/assets/postcss-CXtECtnM.js +1 -0
  585. pythinker_code/web/static/assets/powerquery-CEu0bR-o.js +1 -0
  586. pythinker_code/web/static/assets/powershell-Dpen1YoG.js +1 -0
  587. pythinker_code/web/static/assets/prisma-Dd19v3D-.js +1 -0
  588. pythinker_code/web/static/assets/prolog-CbFg5uaA.js +1 -0
  589. pythinker_code/web/static/assets/proto-C7zT0LnQ.js +1 -0
  590. pythinker_code/web/static/assets/pug-CGlum2m_.js +1 -0
  591. pythinker_code/web/static/assets/puppet-BMWR74SV.js +1 -0
  592. pythinker_code/web/static/assets/purescript-CklMAg4u.js +1 -0
  593. pythinker_code/web/static/assets/python-B6aJPvgy.js +1 -0
  594. pythinker_code/web/static/assets/qml-3beO22l8.js +1 -0
  595. pythinker_code/web/static/assets/qmldir-C8lEn-DE.js +1 -0
  596. pythinker_code/web/static/assets/qss-IeuSbFQv.js +1 -0
  597. pythinker_code/web/static/assets/quadrantDiagram-AYHSOK5B-DSpe_dqk.js +7 -0
  598. pythinker_code/web/static/assets/r-Dspwwk_N.js +1 -0
  599. pythinker_code/web/static/assets/racket-BqYA7rlc.js +1 -0
  600. pythinker_code/web/static/assets/raku-DXvB9xmW.js +1 -0
  601. pythinker_code/web/static/assets/razor-C1TweQQi.js +1 -0
  602. pythinker_code/web/static/assets/red-bN70gL4F.js +1 -0
  603. pythinker_code/web/static/assets/reg-C-SQnVFl.js +1 -0
  604. pythinker_code/web/static/assets/regexp-CDVJQ6XC.js +1 -0
  605. pythinker_code/web/static/assets/rel-C3B-1QV4.js +1 -0
  606. pythinker_code/web/static/assets/requirementDiagram-UZGBJVZJ-8o9hozL-.js +64 -0
  607. pythinker_code/web/static/assets/riscv-BM1_JUlF.js +1 -0
  608. pythinker_code/web/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  609. pythinker_code/web/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  610. pythinker_code/web/static/assets/rose-pine-qdsjHGoJ.js +1 -0
  611. pythinker_code/web/static/assets/rosmsg-BJDFO7_C.js +1 -0
  612. pythinker_code/web/static/assets/rst-B0xPkSld.js +1 -0
  613. pythinker_code/web/static/assets/ruby-BvKwtOVI.js +1 -0
  614. pythinker_code/web/static/assets/rust-B1yitclQ.js +1 -0
  615. pythinker_code/web/static/assets/sankeyDiagram-TZEHDZUN-BLOSUixH.js +10 -0
  616. pythinker_code/web/static/assets/sas-cz2c8ADy.js +1 -0
  617. pythinker_code/web/static/assets/sass-Cj5Yp3dK.js +1 -0
  618. pythinker_code/web/static/assets/scala-C151Ov-r.js +1 -0
  619. pythinker_code/web/static/assets/scheme-C98Dy4si.js +1 -0
  620. pythinker_code/web/static/assets/scss-OYdSNvt2.js +1 -0
  621. pythinker_code/web/static/assets/sdbl-DVxCFoDh.js +1 -0
  622. pythinker_code/web/static/assets/sequenceDiagram-WL72ISMW-6F2G8JTU.js +145 -0
  623. pythinker_code/web/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
  624. pythinker_code/web/static/assets/shellscript-Yzrsuije.js +1 -0
  625. pythinker_code/web/static/assets/shellsession-BADoaaVG.js +1 -0
  626. pythinker_code/web/static/assets/slack-dark-BthQWCQV.js +1 -0
  627. pythinker_code/web/static/assets/slack-ochin-DqwNpetd.js +1 -0
  628. pythinker_code/web/static/assets/smalltalk-BERRCDM3.js +1 -0
  629. pythinker_code/web/static/assets/snazzy-light-Bw305WKR.js +1 -0
  630. pythinker_code/web/static/assets/solarized-dark-DXbdFlpD.js +1 -0
  631. pythinker_code/web/static/assets/solarized-light-L9t79GZl.js +1 -0
  632. pythinker_code/web/static/assets/solidity-rGO070M0.js +1 -0
  633. pythinker_code/web/static/assets/soy-Brmx7dQM.js +1 -0
  634. pythinker_code/web/static/assets/sparql-rVzFXLq3.js +1 -0
  635. pythinker_code/web/static/assets/splunk-BtCnVYZw.js +1 -0
  636. pythinker_code/web/static/assets/sql-BLtJtn59.js +1 -0
  637. pythinker_code/web/static/assets/ssh-config-_ykCGR6B.js +1 -0
  638. pythinker_code/web/static/assets/stata-BH5u7GGu.js +1 -0
  639. pythinker_code/web/static/assets/stateDiagram-FKZM4ZOC-DP8xP0iJ.js +1 -0
  640. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-1l6-EZNX.js +1 -0
  641. pythinker_code/web/static/assets/stylus-BEDo0Tqx.js +1 -0
  642. pythinker_code/web/static/assets/svelte-zxCyuUbr.js +1 -0
  643. pythinker_code/web/static/assets/swift-Dg5xB15N.js +1 -0
  644. pythinker_code/web/static/assets/synthwave-84-CbfX1IO0.js +1 -0
  645. pythinker_code/web/static/assets/system-verilog-CnnmHF94.js +1 -0
  646. pythinker_code/web/static/assets/systemd-4A_iFExJ.js +1 -0
  647. pythinker_code/web/static/assets/talonscript-CkByrt1z.js +1 -0
  648. pythinker_code/web/static/assets/tasl-QIJgUcNo.js +1 -0
  649. pythinker_code/web/static/assets/tcl-dwOrl1Do.js +1 -0
  650. pythinker_code/web/static/assets/templ-W15q3VgB.js +1 -0
  651. pythinker_code/web/static/assets/terraform-BETggiCN.js +1 -0
  652. pythinker_code/web/static/assets/tex-CvyZ59Mk.js +1 -0
  653. pythinker_code/web/static/assets/timeline-definition-IT6M3QCI-DMgruDfK.js +61 -0
  654. pythinker_code/web/static/assets/tokyo-night-hegEt444.js +1 -0
  655. pythinker_code/web/static/assets/toml-vGWfd6FD.js +1 -0
  656. pythinker_code/web/static/assets/treemap-KMMF4GRG-Bo9ZkrAK.js +128 -0
  657. pythinker_code/web/static/assets/ts-tags-zn1MmPIZ.js +1 -0
  658. pythinker_code/web/static/assets/tsv-B_m7g4N7.js +1 -0
  659. pythinker_code/web/static/assets/tsx-COt5Ahok.js +1 -0
  660. pythinker_code/web/static/assets/turtle-BsS91CYL.js +1 -0
  661. pythinker_code/web/static/assets/twig-CO9l9SDP.js +1 -0
  662. pythinker_code/web/static/assets/typescript-BPQ3VLAy.js +1 -0
  663. pythinker_code/web/static/assets/typespec-BGHnOYBU.js +1 -0
  664. pythinker_code/web/static/assets/typst-DHCkPAjA.js +1 -0
  665. pythinker_code/web/static/assets/v-BcVCzyr7.js +1 -0
  666. pythinker_code/web/static/assets/vala-CsfeWuGM.js +1 -0
  667. pythinker_code/web/static/assets/vb-D17OF-Vu.js +1 -0
  668. pythinker_code/web/static/assets/verilog-BQ8w6xss.js +1 -0
  669. pythinker_code/web/static/assets/vesper-DU1UobuO.js +1 -0
  670. pythinker_code/web/static/assets/vhdl-CeAyd5Ju.js +1 -0
  671. pythinker_code/web/static/assets/viml-CJc9bBzg.js +1 -0
  672. pythinker_code/web/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
  673. pythinker_code/web/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
  674. pythinker_code/web/static/assets/vitesse-light-CVO1_9PV.js +1 -0
  675. pythinker_code/web/static/assets/vue-DN_0RTcg.js +1 -0
  676. pythinker_code/web/static/assets/vue-html-AaS7Mt5G.js +1 -0
  677. pythinker_code/web/static/assets/vue-vine-CQOfvN7w.js +1 -0
  678. pythinker_code/web/static/assets/vyper-CDx5xZoG.js +1 -0
  679. pythinker_code/web/static/assets/wasm-CG6Dc4jp.js +1 -0
  680. pythinker_code/web/static/assets/wasm-MzD3tlZU.js +1 -0
  681. pythinker_code/web/static/assets/wenyan-BV7otONQ.js +1 -0
  682. pythinker_code/web/static/assets/wgsl-Dx-B1_4e.js +1 -0
  683. pythinker_code/web/static/assets/wikitext-BhOHFoWU.js +1 -0
  684. pythinker_code/web/static/assets/wit-5i3qLPDT.js +1 -0
  685. pythinker_code/web/static/assets/wolfram-lXgVvXCa.js +1 -0
  686. pythinker_code/web/static/assets/xml-sdJ4AIDG.js +1 -0
  687. pythinker_code/web/static/assets/xsl-CtQFsRM5.js +1 -0
  688. pythinker_code/web/static/assets/xychartDiagram-PRI3JC2R-GeF2johi.js +7 -0
  689. pythinker_code/web/static/assets/yaml-Buea-lGh.js +1 -0
  690. pythinker_code/web/static/assets/zenscript-DVFEvuxE.js +1 -0
  691. pythinker_code/web/static/assets/zig-VOosw3JB.js +1 -0
  692. pythinker_code/web/static/brand/apple-touch-icon.png +0 -0
  693. pythinker_code/web/static/brand/arctecture.webp +0 -0
  694. pythinker_code/web/static/brand/bimi-logo.svg +46 -0
  695. pythinker_code/web/static/brand/favicon.ico +0 -0
  696. pythinker_code/web/static/brand/fonts/dm-sans-latin-ext.woff2 +0 -0
  697. pythinker_code/web/static/brand/fonts/dm-sans-latin.woff2 +0 -0
  698. pythinker_code/web/static/brand/fonts/instrument-sans-latin-ext.woff2 +0 -0
  699. pythinker_code/web/static/brand/fonts/instrument-sans-latin.woff2 +0 -0
  700. pythinker_code/web/static/brand/fonts/instrument-serif-latin-ext.woff2 +0 -0
  701. pythinker_code/web/static/brand/fonts/instrument-serif-latin.woff2 +0 -0
  702. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin-ext.woff2 +0 -0
  703. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin.woff2 +0 -0
  704. pythinker_code/web/static/brand/fonts/libre-baskerville-latin-ext.woff2 +0 -0
  705. pythinker_code/web/static/brand/fonts/libre-baskerville-latin.woff2 +0 -0
  706. pythinker_code/web/static/brand/fonts/roboto-latin-ext.woff2 +0 -0
  707. pythinker_code/web/static/brand/fonts/roboto-latin.woff2 +0 -0
  708. pythinker_code/web/static/brand/icon-192.png +0 -0
  709. pythinker_code/web/static/brand/icon-512.png +0 -0
  710. pythinker_code/web/static/brand/icon.svg +1 -0
  711. pythinker_code/web/static/brand/logo.png +0 -0
  712. pythinker_code/web/static/brand/pythinker_animated.svg +79 -0
  713. pythinker_code/web/static/brand/robots.txt +4 -0
  714. pythinker_code/web/static/index.html +15 -0
  715. pythinker_code/web/static/logo.png +0 -0
  716. pythinker_code/web/store/__init__.py +1 -0
  717. pythinker_code/web/store/sessions.py +432 -0
  718. pythinker_code/wire/__init__.py +148 -0
  719. pythinker_code/wire/file.py +151 -0
  720. pythinker_code/wire/jsonrpc.py +263 -0
  721. pythinker_code/wire/protocol.py +2 -0
  722. pythinker_code/wire/root_hub.py +27 -0
  723. pythinker_code/wire/serde.py +26 -0
  724. pythinker_code/wire/server.py +1069 -0
  725. pythinker_code/wire/types.py +698 -0
  726. pythinker_code-2.0.0.dist-info/METADATA +660 -0
  727. pythinker_code-2.0.0.dist-info/RECORD +731 -0
  728. pythinker_code-2.0.0.dist-info/WHEEL +4 -0
  729. pythinker_code-2.0.0.dist-info/entry_points.txt +4 -0
  730. pythinker_code-2.0.0.dist-info/licenses/LICENSE +202 -0
  731. pythinker_code-2.0.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,2278 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from hashlib import md5
17
+ from pathlib import Path
18
+ from typing import Any, Literal, Protocol, cast, override, runtime_checkable
19
+
20
+ from prompt_toolkit import PromptSession
21
+ from prompt_toolkit.application.current import get_app_or_none
22
+ from prompt_toolkit.buffer import Buffer
23
+ from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
24
+ from prompt_toolkit.completion import (
25
+ CompleteEvent,
26
+ Completer,
27
+ Completion,
28
+ FuzzyCompleter,
29
+ WordCompleter,
30
+ merge_completers,
31
+ )
32
+ from prompt_toolkit.data_structures import Point
33
+ from prompt_toolkit.document import Document
34
+ from prompt_toolkit.filters import Condition, has_completions, has_focus, is_done
35
+ from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text
36
+ from prompt_toolkit.history import InMemoryHistory
37
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
38
+ from prompt_toolkit.keys import Keys
39
+ from prompt_toolkit.layout.containers import (
40
+ ConditionalContainer,
41
+ DynamicContainer,
42
+ Float,
43
+ FloatContainer,
44
+ HSplit,
45
+ Window,
46
+ )
47
+ from prompt_toolkit.layout.controls import BufferControl, UIContent, UIControl
48
+ from prompt_toolkit.layout.dimension import Dimension
49
+ from prompt_toolkit.layout.menus import CompletionsMenu
50
+ from prompt_toolkit.patch_stdout import patch_stdout
51
+ from prompt_toolkit.utils import get_cwidth
52
+ from pydantic import BaseModel, ValidationError
53
+ from pythinker_host.path import HostPath
54
+
55
+ from pythinker_code.llm import ModelCapability
56
+ from pythinker_code.share import get_share_dir
57
+ from pythinker_code.soul import StatusSnapshot, format_context_status
58
+ from pythinker_code.ui.shell import placeholders as prompt_placeholders
59
+ from pythinker_code.ui.shell.console import console
60
+ from pythinker_code.ui.shell.placeholders import (
61
+ PromptPlaceholderManager,
62
+ normalize_pasted_text,
63
+ sanitize_surrogates,
64
+ )
65
+ from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors
66
+ from pythinker_code.utils.clipboard import (
67
+ grab_media_from_clipboard,
68
+ is_clipboard_available,
69
+ is_media_clipboard_available,
70
+ )
71
+ from pythinker_code.utils.logging import logger
72
+ from pythinker_code.utils.slashcmd import SlashCommand
73
+ from pythinker_code.wire.types import ContentPart
74
+
75
+ AttachmentCache = prompt_placeholders.AttachmentCache
76
+ CachedAttachment = prompt_placeholders.CachedAttachment
77
+ _parse_attachment_kind = prompt_placeholders.parse_attachment_kind
78
+
79
+ PROMPT_SYMBOL = "✨"
80
+ PROMPT_SYMBOL_SHELL = "$"
81
+ PROMPT_SYMBOL_THINKING = "💫"
82
+ PROMPT_SYMBOL_PLAN = "📋"
83
+
84
+
85
+ class CwdLostError(OSError):
86
+ """Raised when the working directory no longer exists (e.g. external drive unplugged)."""
87
+
88
+
89
+ class SlashCommandCompleter(Completer):
90
+ """
91
+ A completer that:
92
+ - Shows one line per slash command using the canonical "/name"
93
+ - Fuzzy-matches by primary name or any alias while inserting the canonical "/name"
94
+ - Only activates when the current token starts with '/'
95
+ """
96
+
97
+ def __init__(self, available_commands: Sequence[SlashCommand[Any]]) -> None:
98
+ super().__init__()
99
+ self._available_commands = list(available_commands)
100
+ self._command_lookup: dict[str, list[SlashCommand[Any]]] = {}
101
+ words: list[str] = []
102
+
103
+ for cmd in sorted(self._available_commands, key=lambda c: c.name):
104
+ if cmd.name not in self._command_lookup:
105
+ self._command_lookup[cmd.name] = []
106
+ words.append(cmd.name)
107
+ self._command_lookup[cmd.name].append(cmd)
108
+ for alias in cmd.aliases:
109
+ if alias in self._command_lookup:
110
+ self._command_lookup[alias].append(cmd)
111
+ else:
112
+ self._command_lookup[alias] = [cmd]
113
+ words.append(alias)
114
+
115
+ self._word_pattern = re.compile(r"[^\s]+")
116
+ self._fuzzy_pattern = r"^[^\s]*"
117
+ self._word_completer = WordCompleter(words, WORD=False, pattern=self._word_pattern)
118
+ self._fuzzy = FuzzyCompleter(self._word_completer, WORD=False, pattern=self._fuzzy_pattern)
119
+
120
+ @staticmethod
121
+ def should_complete(document: Document) -> bool:
122
+ """Return whether slash command completion should be active for the current buffer."""
123
+ text = document.text_before_cursor
124
+
125
+ if document.text_after_cursor.strip():
126
+ return False
127
+
128
+ last_space = text.rfind(" ")
129
+ token = text[last_space + 1 :]
130
+ prefix = text[: last_space + 1] if last_space != -1 else ""
131
+
132
+ return not prefix.strip() and token.startswith("/")
133
+
134
+ @override
135
+ def get_completions(
136
+ self, document: Document, complete_event: CompleteEvent
137
+ ) -> Iterable[Completion]:
138
+ if not self.should_complete(document):
139
+ return
140
+ text = document.text_before_cursor
141
+ last_space = text.rfind(" ")
142
+ token = text[last_space + 1 :]
143
+
144
+ typed = token[1:]
145
+ if typed and typed in self._command_lookup:
146
+ return
147
+ mention_doc = Document(text=typed, cursor_position=len(typed))
148
+ candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
149
+
150
+ seen: set[str] = set()
151
+
152
+ for candidate in candidates:
153
+ commands = self._command_lookup.get(candidate.text)
154
+ if not commands:
155
+ continue
156
+ for cmd in commands:
157
+ if cmd.name in seen:
158
+ continue
159
+ seen.add(cmd.name)
160
+ yield Completion(
161
+ text=f"/{cmd.name}",
162
+ start_position=-len(token),
163
+ display=f"/{cmd.name}",
164
+ display_meta=cmd.description,
165
+ )
166
+
167
+
168
+ def _truncate_to_width(text: str, width: int) -> str:
169
+ if width <= 0:
170
+ return ""
171
+
172
+ total = 0
173
+ chars: list[str] = []
174
+ for ch in text:
175
+ ch_width = get_cwidth(ch)
176
+ if total + ch_width > width:
177
+ break
178
+ chars.append(ch)
179
+ total += ch_width
180
+
181
+ if total == get_cwidth(text):
182
+ return text + (" " * max(0, width - total))
183
+
184
+ ellipsis = "..."
185
+ ellipsis_width = get_cwidth(ellipsis)
186
+ if width <= ellipsis_width:
187
+ return "." * width
188
+
189
+ available = width - ellipsis_width
190
+ total = 0
191
+ chars = []
192
+ for ch in text:
193
+ ch_width = get_cwidth(ch)
194
+ if total + ch_width > available:
195
+ break
196
+ chars.append(ch)
197
+ total += ch_width
198
+ return "".join(chars) + ellipsis + (" " * max(0, width - total - ellipsis_width))
199
+
200
+
201
+ def _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]:
202
+ if width <= 0:
203
+ return []
204
+
205
+ words = text.split()
206
+ if not words:
207
+ return [""]
208
+
209
+ lines: list[str] = []
210
+ current_words: list[str] = []
211
+ current_width = 0
212
+ index = 0
213
+
214
+ while index < len(words):
215
+ word = words[index]
216
+ word_width = get_cwidth(word)
217
+ separator_width = 1 if current_words else 0
218
+
219
+ if current_words and current_width + separator_width + word_width <= width:
220
+ current_words.append(word)
221
+ current_width += separator_width + word_width
222
+ index += 1
223
+ continue
224
+
225
+ if not current_words and word_width <= width:
226
+ current_words.append(word)
227
+ current_width = word_width
228
+ index += 1
229
+ continue
230
+
231
+ if not current_words and word_width > width:
232
+ current_words.append(_truncate_to_width(word, width).rstrip())
233
+ current_width = get_cwidth(current_words[0])
234
+ index += 1
235
+
236
+ lines.append(" ".join(current_words))
237
+ current_words = []
238
+ current_width = 0
239
+
240
+ if max_lines is not None and len(lines) == max_lines:
241
+ remaining = " ".join(words[index:])
242
+ if remaining:
243
+ prefix = f"{lines[-1]} " if lines[-1] else ""
244
+ lines[-1] = _truncate_to_width(prefix + remaining, width).rstrip()
245
+ return lines
246
+
247
+ if current_words:
248
+ line = " ".join(current_words)
249
+ if max_lines is not None and len(lines) + 1 > max_lines:
250
+ if lines:
251
+ lines[-1] = _truncate_to_width(f"{lines[-1]} {line}", width).rstrip()
252
+ else:
253
+ lines.append(_truncate_to_width(line, width).rstrip())
254
+ else:
255
+ lines.append(line)
256
+
257
+ return lines
258
+
259
+
260
+ def _find_prompt_float_container(layout_container: object) -> FloatContainer | None:
261
+ if not isinstance(layout_container, HSplit):
262
+ return None
263
+
264
+ for child in cast(Sequence[object], layout_container.children):
265
+ float_container = _extract_float_container(child)
266
+ if float_container is not None:
267
+ return float_container
268
+ return None
269
+
270
+
271
+ def _extract_float_container(container: object) -> FloatContainer | None:
272
+ if isinstance(container, FloatContainer):
273
+ return container
274
+ if isinstance(container, ConditionalContainer):
275
+ if isinstance(container.content, FloatContainer):
276
+ return container.content
277
+ if isinstance(container.alternative_content, FloatContainer):
278
+ return container.alternative_content
279
+ return None
280
+
281
+
282
+ def _find_default_buffer_container(
283
+ layout_container: object,
284
+ target_buffer: Buffer,
285
+ ) -> ConditionalContainer | None:
286
+ seen: set[int] = set()
287
+
288
+ def _walk(node: object) -> ConditionalContainer | None:
289
+ if id(node) in seen:
290
+ return None
291
+ seen.add(id(node))
292
+
293
+ if isinstance(node, ConditionalContainer):
294
+ content = getattr(node, "content", None)
295
+ if isinstance(content, Window):
296
+ control = content.content
297
+ if isinstance(control, BufferControl) and control.buffer is target_buffer:
298
+ return node
299
+
300
+ if isinstance(node, DynamicContainer):
301
+ with contextlib.suppress(Exception):
302
+ found = _walk(node.get_container())
303
+ if found is not None:
304
+ return found
305
+
306
+ for attr in ("children", "content", "floats", "container"):
307
+ if not hasattr(node, attr):
308
+ continue
309
+ value = getattr(node, attr)
310
+ if attr == "children" and isinstance(value, Sequence):
311
+ for child in value: # pyright: ignore[reportUnknownVariableType]
312
+ found = _walk(child) # pyright: ignore[reportUnknownArgumentType]
313
+ if found is not None:
314
+ return found
315
+ elif attr == "floats" and isinstance(value, Sequence):
316
+ for float_ in value: # pyright: ignore[reportUnknownVariableType]
317
+ content = getattr(float_, "content", None) # pyright: ignore[reportUnknownArgumentType]
318
+ if content is None:
319
+ continue
320
+ found = _walk(content)
321
+ if found is not None:
322
+ return found
323
+ elif (
324
+ attr in {"content", "container"}
325
+ and value is not None
326
+ and type(value).__module__.startswith("prompt_toolkit")
327
+ ):
328
+ found = _walk(value)
329
+ if found is not None:
330
+ return found
331
+ return None
332
+
333
+ return _walk(layout_container)
334
+
335
+
336
+ class SlashCommandMenuControl(UIControl):
337
+ """Render slash command completions as a full-width menu that matches the shell UI."""
338
+
339
+ _MAX_EXPANDED_META_LINES = 3
340
+
341
+ def __init__(
342
+ self,
343
+ *,
344
+ left_padding: Callable[[], int],
345
+ scroll_offset: int = 1,
346
+ ) -> None:
347
+ self._left_padding = left_padding
348
+ self._scroll_offset = scroll_offset
349
+
350
+ def has_focus(self) -> bool:
351
+ return False
352
+
353
+ def preferred_width(self, max_available_width: int) -> int | None:
354
+ return max_available_width
355
+
356
+ def preferred_height(
357
+ self,
358
+ width: int,
359
+ max_available_height: int,
360
+ wrap_lines: bool,
361
+ get_line_prefix: Callable[..., AnyFormattedText] | None,
362
+ ) -> int | None:
363
+ app = get_app_or_none()
364
+ complete_state = (
365
+ getattr(app.current_buffer, "complete_state", None) if app is not None else None
366
+ )
367
+ if complete_state is None:
368
+ return 0
369
+ completions = complete_state.completions
370
+ selected_index = complete_state.complete_index
371
+ if selected_index is None:
372
+ return min(max_available_height, len(completions) + 1)
373
+ menu_width = max(0, width - self._left_padding())
374
+ marker_width = 2
375
+ command_width = self._command_column_width(completions, menu_width, marker_width)
376
+ gap_width = 3 if menu_width > command_width + 6 else 1
377
+ meta_width = max(0, menu_width - marker_width - command_width - gap_width)
378
+ selected_meta_lines = self._selected_meta_lines(
379
+ completions[selected_index].display_meta_text,
380
+ meta_width,
381
+ )
382
+ return min(max_available_height, len(completions) + len(selected_meta_lines))
383
+
384
+ def create_content(self, width: int, height: int) -> UIContent:
385
+ app = get_app_or_none()
386
+ complete_state = (
387
+ getattr(app.current_buffer, "complete_state", None) if app is not None else None
388
+ )
389
+ if complete_state is None or not complete_state.completions:
390
+ return UIContent()
391
+
392
+ completions = complete_state.completions
393
+ selected_index = complete_state.complete_index
394
+ available_rows = max(1, height - 1)
395
+
396
+ menu_width = max(0, width - self._left_padding())
397
+ marker_width = 2
398
+ command_width = self._command_column_width(completions, menu_width, marker_width)
399
+ gap_width = 3 if menu_width > command_width + 6 else 1
400
+ meta_width = max(0, menu_width - marker_width - command_width - gap_width)
401
+
402
+ rendered_lines: list[FormattedText] = [
403
+ FormattedText([("class:slash-completion-menu.separator", "─" * max(0, width))])
404
+ ]
405
+ selected_line_index = 0
406
+
407
+ if selected_index is None:
408
+ # Pre-highlight index 0 even before the user navigates: pressing
409
+ # Enter accepts the first completion, so the visual state should
410
+ # match that behavior. Without this the menu looks ambiguous (no
411
+ # row highlighted) but Enter still commits the top row.
412
+ end = min(len(completions) - 1, available_rows - 1)
413
+ for index in range(0, end + 1):
414
+ rendered_lines.append(
415
+ self._render_single_line_item(
416
+ width=width,
417
+ completion=completions[index],
418
+ marker_width=marker_width,
419
+ command_width=command_width,
420
+ meta_width=meta_width,
421
+ gap_width=gap_width,
422
+ is_current=index == 0,
423
+ )
424
+ )
425
+
426
+ return UIContent(
427
+ get_line=lambda i: rendered_lines[i],
428
+ line_count=len(rendered_lines),
429
+ cursor_position=Point(x=0, y=1 if completions else 0),
430
+ )
431
+
432
+ selected_meta_lines = self._selected_meta_lines(
433
+ completions[selected_index].display_meta_text,
434
+ meta_width,
435
+ )
436
+ start, end = self._visible_window_bounds(
437
+ completion_count=len(completions),
438
+ selected_index=selected_index,
439
+ available_rows=available_rows,
440
+ selected_item_height=len(selected_meta_lines),
441
+ )
442
+ selected_line_index = 1
443
+
444
+ for index in range(start, end + 1):
445
+ completion = completions[index]
446
+ if index == selected_index:
447
+ selected_line_index = len(rendered_lines)
448
+ rendered_lines.extend(
449
+ self._render_selected_item_lines(
450
+ width=width,
451
+ completion=completion,
452
+ marker_width=marker_width,
453
+ command_width=command_width,
454
+ meta_width=meta_width,
455
+ gap_width=gap_width,
456
+ meta_lines=selected_meta_lines,
457
+ )
458
+ )
459
+ continue
460
+
461
+ rendered_lines.append(
462
+ self._render_single_line_item(
463
+ width=width,
464
+ completion=completion,
465
+ marker_width=marker_width,
466
+ command_width=command_width,
467
+ meta_width=meta_width,
468
+ gap_width=gap_width,
469
+ is_current=False,
470
+ )
471
+ )
472
+
473
+ return UIContent(
474
+ get_line=lambda i: rendered_lines[i],
475
+ line_count=len(rendered_lines),
476
+ cursor_position=Point(x=0, y=selected_line_index),
477
+ )
478
+
479
+ def _selected_meta_lines(self, text: str, meta_width: int) -> list[str]:
480
+ lines = _wrap_to_width(
481
+ text,
482
+ meta_width,
483
+ max_lines=self._MAX_EXPANDED_META_LINES,
484
+ )
485
+ return lines or [""]
486
+
487
+ def _visible_window_bounds(
488
+ self,
489
+ *,
490
+ completion_count: int,
491
+ selected_index: int,
492
+ available_rows: int,
493
+ selected_item_height: int,
494
+ ) -> tuple[int, int]:
495
+ selected_item_height = min(selected_item_height, available_rows)
496
+ remaining_rows = max(0, available_rows - selected_item_height)
497
+
498
+ before = min(self._scroll_offset, selected_index, remaining_rows)
499
+ remaining_rows -= before
500
+ after = min(completion_count - selected_index - 1, remaining_rows)
501
+ remaining_rows -= after
502
+
503
+ extra_before = min(selected_index - before, remaining_rows)
504
+ before += extra_before
505
+ remaining_rows -= extra_before
506
+
507
+ extra_after = min(completion_count - selected_index - 1 - after, remaining_rows)
508
+ after += extra_after
509
+
510
+ return selected_index - before, selected_index + after
511
+
512
+ def _command_column_width(
513
+ self,
514
+ completions: Sequence[Completion],
515
+ menu_width: int,
516
+ marker_width: int,
517
+ ) -> int:
518
+ if menu_width <= 0:
519
+ return 0
520
+ longest = max((get_cwidth(c.display_text) for c in completions), default=0)
521
+ preferred = longest + 2
522
+ usable_width = max(0, menu_width - marker_width)
523
+ minimum = min(usable_width, 18)
524
+ maximum = max(minimum, min(28, usable_width // 2))
525
+ return max(minimum, min(preferred, maximum))
526
+
527
+ def _render_single_line_item(
528
+ self,
529
+ *,
530
+ width: int,
531
+ completion: Completion,
532
+ marker_width: int,
533
+ command_width: int,
534
+ meta_width: int,
535
+ gap_width: int,
536
+ is_current: bool,
537
+ ) -> FormattedText:
538
+ padding_width = max(0, width - marker_width - command_width - meta_width - gap_width)
539
+ left_padding = min(self._left_padding(), padding_width)
540
+ trailing_width = max(
541
+ 0,
542
+ width - left_padding - marker_width - command_width - gap_width - meta_width,
543
+ )
544
+
545
+ command_style = (
546
+ "class:slash-completion-menu.command.current"
547
+ if is_current
548
+ else "class:slash-completion-menu.command"
549
+ )
550
+ meta_style = (
551
+ "class:slash-completion-menu.meta.current"
552
+ if is_current
553
+ else "class:slash-completion-menu.meta"
554
+ )
555
+ marker_style = (
556
+ "class:slash-completion-menu.marker.current"
557
+ if is_current
558
+ else "class:slash-completion-menu.marker"
559
+ )
560
+ marker = "› " if is_current else " "
561
+
562
+ fragments: FormattedText = FormattedText()
563
+ fragments.append(("class:slash-completion-menu", " " * left_padding))
564
+ fragments.append((marker_style, marker.ljust(marker_width)))
565
+ fragments.append(
566
+ (command_style, _truncate_to_width(completion.display_text, command_width))
567
+ )
568
+ fragments.append(("class:slash-completion-menu", " " * gap_width))
569
+ fragments.append((meta_style, _truncate_to_width(completion.display_meta_text, meta_width)))
570
+ fragments.append(("class:slash-completion-menu", " " * trailing_width))
571
+ return fragments
572
+
573
+ def _render_selected_item_lines(
574
+ self,
575
+ *,
576
+ width: int,
577
+ completion: Completion,
578
+ marker_width: int,
579
+ command_width: int,
580
+ meta_width: int,
581
+ gap_width: int,
582
+ meta_lines: Sequence[str],
583
+ ) -> list[FormattedText]:
584
+ lines = [
585
+ self._render_single_line_item(
586
+ width=width,
587
+ completion=Completion(
588
+ text=completion.text,
589
+ start_position=completion.start_position,
590
+ display=completion.display,
591
+ display_meta=meta_lines[0],
592
+ ),
593
+ marker_width=marker_width,
594
+ command_width=command_width,
595
+ meta_width=meta_width,
596
+ gap_width=gap_width,
597
+ is_current=True,
598
+ )
599
+ ]
600
+
601
+ continuation_prefix = (
602
+ " " * self._left_padding() + " " * marker_width + " " * command_width + " " * gap_width
603
+ )
604
+ continuation_trailing = max(
605
+ 0,
606
+ width - get_cwidth(continuation_prefix) - meta_width,
607
+ )
608
+ for meta_line in meta_lines[1:]:
609
+ fragments: FormattedText = FormattedText()
610
+ fragments.append(("class:slash-completion-menu", continuation_prefix))
611
+ fragments.append(
612
+ (
613
+ "class:slash-completion-menu.meta.current",
614
+ _truncate_to_width(meta_line, meta_width),
615
+ )
616
+ )
617
+ fragments.append(("class:slash-completion-menu", " " * continuation_trailing))
618
+ lines.append(fragments)
619
+
620
+ return lines
621
+
622
+
623
+ class LocalFileMentionCompleter(Completer):
624
+ """Offer fuzzy `@` path completion by indexing workspace files.
625
+
626
+ File discovery and ignore rules are delegated to
627
+ :mod:`pythinker_code.utils.file_filter` so that the web backend can reuse
628
+ them.
629
+ """
630
+
631
+ _FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
632
+ _TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
633
+
634
+ def __init__(
635
+ self,
636
+ root: Path,
637
+ *,
638
+ refresh_interval: float = 2.0,
639
+ limit: int = 1000,
640
+ ) -> None:
641
+ self._root = root
642
+ self._refresh_interval = refresh_interval
643
+ self._limit = limit
644
+ self._cache_time: float = 0.0
645
+ self._cached_paths: list[str] = []
646
+ self._cache_scope: str | None = None
647
+ self._top_cache_time: float = 0.0
648
+ self._top_cached_paths: list[str] = []
649
+ self._fragment_hint: str | None = None
650
+ self._is_git: bool | None = None # lazily detected
651
+ self._git_index_mtime: float | None = None
652
+
653
+ self._word_completer = WordCompleter(
654
+ self._get_paths,
655
+ WORD=False,
656
+ pattern=self._FRAGMENT_PATTERN,
657
+ )
658
+
659
+ self._fuzzy = FuzzyCompleter(
660
+ self._word_completer,
661
+ WORD=False,
662
+ pattern=r"^[^\s@]*",
663
+ )
664
+
665
+ def _get_paths(self) -> list[str]:
666
+ fragment = self._fragment_hint or ""
667
+ if "/" not in fragment and len(fragment) < 3:
668
+ return self._get_top_level_paths()
669
+ return self._get_deep_paths()
670
+
671
+ def _get_top_level_paths(self) -> list[str]:
672
+ from pythinker_code.utils.file_filter import is_ignored
673
+
674
+ now = time.monotonic()
675
+ if now - self._top_cache_time <= self._refresh_interval:
676
+ return self._top_cached_paths
677
+
678
+ entries: list[str] = []
679
+ try:
680
+ for entry in sorted(self._root.iterdir(), key=lambda p: p.name):
681
+ name = entry.name
682
+ if is_ignored(name):
683
+ continue
684
+ entries.append(f"{name}/" if entry.is_dir() else name)
685
+ if len(entries) >= self._limit:
686
+ break
687
+ except OSError:
688
+ return self._top_cached_paths
689
+
690
+ self._top_cached_paths = entries
691
+ self._top_cache_time = now
692
+ return self._top_cached_paths
693
+
694
+ def _get_deep_paths(self) -> list[str]:
695
+ from pythinker_code.utils.file_filter import (
696
+ detect_git,
697
+ git_index_mtime,
698
+ list_files_git,
699
+ list_files_walk,
700
+ )
701
+
702
+ fragment = self._fragment_hint or ""
703
+
704
+ scope: str | None = None
705
+ if "/" in fragment:
706
+ scope = fragment.rsplit("/", 1)[0]
707
+
708
+ now = time.monotonic()
709
+ cache_valid = (
710
+ now - self._cache_time <= self._refresh_interval and self._cache_scope == scope
711
+ )
712
+
713
+ # Invalidate on .git/index mtime change (like Claude Code).
714
+ if cache_valid and self._is_git:
715
+ mtime = git_index_mtime(self._root)
716
+ if mtime != self._git_index_mtime:
717
+ cache_valid = False
718
+
719
+ if cache_valid:
720
+ return self._cached_paths
721
+
722
+ if self._is_git is None:
723
+ self._is_git = detect_git(self._root)
724
+
725
+ paths: list[str] | None = None
726
+ if self._is_git:
727
+ paths = list_files_git(self._root, scope)
728
+ self._git_index_mtime = git_index_mtime(self._root)
729
+ if paths is None:
730
+ paths = list_files_walk(self._root, scope, limit=self._limit)
731
+
732
+ self._cached_paths = paths
733
+ self._cache_scope = scope
734
+ self._cache_time = now
735
+ return self._cached_paths
736
+
737
+ @staticmethod
738
+ def _extract_fragment(text: str) -> str | None:
739
+ index = text.rfind("@")
740
+ if index == -1:
741
+ return None
742
+
743
+ if index > 0:
744
+ prev = text[index - 1]
745
+ if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS:
746
+ return None
747
+
748
+ fragment = text[index + 1 :]
749
+ if not fragment:
750
+ return ""
751
+
752
+ if any(ch.isspace() for ch in fragment):
753
+ return None
754
+
755
+ return fragment
756
+
757
+ def _is_completed_file(self, fragment: str) -> bool:
758
+ candidate = fragment.rstrip("/")
759
+ if not candidate:
760
+ return False
761
+ try:
762
+ return (self._root / candidate).is_file()
763
+ except OSError:
764
+ return False
765
+
766
+ @override
767
+ def get_completions(
768
+ self, document: Document, complete_event: CompleteEvent
769
+ ) -> Iterable[Completion]:
770
+ fragment = self._extract_fragment(document.text_before_cursor)
771
+ if fragment is None:
772
+ return
773
+ if self._is_completed_file(fragment):
774
+ return
775
+
776
+ mention_doc = Document(text=fragment, cursor_position=len(fragment))
777
+ self._fragment_hint = fragment
778
+ try:
779
+ # First, ask the fuzzy completer for candidates.
780
+ candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
781
+
782
+ # re-rank: prefer basename matches
783
+ frag_lower = fragment.lower()
784
+
785
+ def _rank(c: Completion) -> tuple[int, ...]:
786
+ path = c.text
787
+ base = path.rstrip("/").split("/")[-1].lower()
788
+ if base.startswith(frag_lower):
789
+ cat = 0
790
+ elif frag_lower in base:
791
+ cat = 1
792
+ else:
793
+ cat = 2
794
+ # preserve original FuzzyCompleter's order in the same category
795
+ return (cat,)
796
+
797
+ candidates.sort(key=_rank)
798
+ yield from candidates
799
+ finally:
800
+ self._fragment_hint = None
801
+
802
+
803
+ class _HistoryEntry(BaseModel):
804
+ content: str
805
+
806
+
807
+ def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
808
+ entries: list[_HistoryEntry] = []
809
+ if not history_file.exists():
810
+ return entries
811
+
812
+ try:
813
+ with history_file.open(encoding="utf-8") as f:
814
+ for raw_line in f:
815
+ line = raw_line.strip()
816
+ if not line:
817
+ continue
818
+ try:
819
+ record = json.loads(line)
820
+ except json.JSONDecodeError:
821
+ logger.warning(
822
+ "Failed to parse user history line; skipping: {line}",
823
+ line=line,
824
+ )
825
+ continue
826
+ try:
827
+ entry = _HistoryEntry.model_validate(record)
828
+ entries.append(entry)
829
+ except ValidationError:
830
+ logger.warning(
831
+ "Failed to validate user history entry; skipping: {line}",
832
+ line=line,
833
+ )
834
+ continue
835
+ except OSError as exc:
836
+ logger.warning(
837
+ "Failed to load user history file: {file} ({error})",
838
+ file=history_file,
839
+ error=exc,
840
+ )
841
+
842
+ return entries
843
+
844
+
845
+ class PromptMode(Enum):
846
+ AGENT = "agent"
847
+ SHELL = "shell"
848
+
849
+ def toggle(self) -> PromptMode:
850
+ return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
851
+
852
+ def __str__(self) -> str:
853
+ return self.value
854
+
855
+
856
+ class PromptUIState(Enum):
857
+ NORMAL_INPUT = "normal_input"
858
+ MODAL_HIDDEN_INPUT = "modal_hidden_input"
859
+ MODAL_TEXT_INPUT = "modal_text_input"
860
+
861
+
862
+ class UserInput(BaseModel):
863
+ mode: PromptMode
864
+ command: str
865
+ """The plain text representation of the user input."""
866
+ resolved_command: str
867
+ """The text command after UI-only placeholders are expanded."""
868
+ content: list[ContentPart]
869
+ """The rich content parts."""
870
+
871
+ def __str__(self) -> str:
872
+ return self.command
873
+
874
+ def __bool__(self) -> bool:
875
+ return bool(self.command)
876
+
877
+
878
+ _IDLE_REFRESH_INTERVAL = 1.0
879
+ _RUNNING_REFRESH_INTERVAL = 0.1
880
+
881
+ _GIT_BRANCH_TTL = 5.0
882
+ _GIT_STATUS_TTL = 15.0
883
+ _TIP_ROTATE_INTERVAL = 30.0
884
+ _MAX_CWD_COLS = 30
885
+ _MAX_BRANCH_COLS = 22
886
+
887
+
888
+ @dataclass
889
+ class _GitBranchState:
890
+ timestamp: float = 0.0
891
+ branch: str | None = None
892
+ proc: subprocess.Popen[str] | None = None
893
+
894
+
895
+ @dataclass
896
+ class _GitStatusState:
897
+ timestamp: float = 0.0
898
+ dirty: bool = False
899
+ ahead: int = 0
900
+ behind: int = 0
901
+ proc: subprocess.Popen[str] | None = None
902
+
903
+
904
+ _git_branch_state = _GitBranchState()
905
+ _git_status_state = _GitStatusState()
906
+
907
+ _GIT_STATUS_AB_RE = re.compile(r"\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]")
908
+
909
+
910
+ def _get_git_branch() -> str | None:
911
+ """Return the current git branch name via a non-blocking cached subprocess."""
912
+ state = _git_branch_state
913
+ now = time.monotonic()
914
+
915
+ # Collect result if a previously launched process has finished
916
+ if state.proc is not None:
917
+ returncode = state.proc.poll()
918
+ if returncode is not None:
919
+ try:
920
+ stdout, _ = state.proc.communicate()
921
+ new_branch = stdout.strip() or None
922
+ # Branch changed — discard any in-flight status subprocess so it cannot
923
+ # write stale results for the old branch, then force an immediate refresh.
924
+ if new_branch != state.branch:
925
+ if _git_status_state.proc is not None:
926
+ with contextlib.suppress(Exception):
927
+ _git_status_state.proc.terminate()
928
+ _git_status_state.proc = None
929
+ _git_status_state.timestamp = 0.0
930
+ state.branch = new_branch
931
+ except Exception:
932
+ state.branch = None
933
+ state.proc = None
934
+
935
+ # Launch a new process when the TTL has expired and nothing is running
936
+ if state.timestamp + _GIT_BRANCH_TTL <= now and state.proc is None:
937
+ state.timestamp = now
938
+ try:
939
+ state.proc = subprocess.Popen(
940
+ ["git", "branch", "--show-current"],
941
+ stdout=subprocess.PIPE,
942
+ stderr=subprocess.DEVNULL,
943
+ text=True,
944
+ encoding="utf-8",
945
+ errors="replace",
946
+ )
947
+ except Exception:
948
+ state.branch = None
949
+
950
+ return state.branch
951
+
952
+
953
+ def _get_git_status() -> tuple[bool, int, int]:
954
+ """Return (dirty, ahead, behind) via a non-blocking cached subprocess.
955
+
956
+ Runs ``git status --porcelain -b`` (includes untracked files so newly created
957
+ files show as dirty). TTL is longer than the branch check because file-tree
958
+ scanning is expensive.
959
+ """
960
+ state = _git_status_state
961
+ now = time.monotonic()
962
+
963
+ if state.proc is not None:
964
+ returncode = state.proc.poll()
965
+ if returncode is not None:
966
+ try:
967
+ stdout, _ = state.proc.communicate()
968
+ dirty = False
969
+ ahead = 0
970
+ behind = 0
971
+ for line in stdout.splitlines():
972
+ if line.startswith("## "):
973
+ m = _GIT_STATUS_AB_RE.search(line)
974
+ if m:
975
+ ahead = int(m.group(1) or 0)
976
+ behind = int(m.group(2) or 0)
977
+ elif line.strip():
978
+ dirty = True
979
+ state.dirty = dirty
980
+ state.ahead = ahead
981
+ state.behind = behind
982
+ except Exception:
983
+ pass
984
+ state.proc = None
985
+ elif now - state.timestamp > _GIT_STATUS_TTL:
986
+ # Subprocess is stuck (e.g. OS pipe buffer full from many untracked files).
987
+ # Terminate it so the toolbar is not permanently frozen; retry after next TTL.
988
+ with contextlib.suppress(Exception):
989
+ state.proc.terminate()
990
+ state.proc = None
991
+ state.timestamp = now # delay next spawn by one full TTL
992
+
993
+ if state.timestamp + _GIT_STATUS_TTL <= now and state.proc is None:
994
+ state.timestamp = now
995
+ with contextlib.suppress(Exception):
996
+ state.proc = subprocess.Popen(
997
+ ["git", "status", "--porcelain", "-b"],
998
+ stdout=subprocess.PIPE,
999
+ stderr=subprocess.DEVNULL,
1000
+ text=True,
1001
+ encoding="utf-8",
1002
+ errors="replace",
1003
+ )
1004
+
1005
+ return state.dirty, state.ahead, state.behind
1006
+
1007
+
1008
+ def _format_git_badge(branch: str, dirty: bool, ahead: int, behind: int) -> str:
1009
+ """Format branch name with an optional status badge: ``main [± ↑3↓1]``."""
1010
+ parts: list[str] = []
1011
+ if dirty:
1012
+ parts.append("±")
1013
+ sync = ""
1014
+ if ahead:
1015
+ sync += f"↑{ahead}"
1016
+ if behind:
1017
+ sync += f"↓{behind}"
1018
+ if sync:
1019
+ parts.append(sync)
1020
+ if not parts:
1021
+ return branch
1022
+ return f"{branch} [{' '.join(parts)}]"
1023
+
1024
+
1025
+ def _shorten_cwd(path: str) -> str:
1026
+ """Replace the home directory prefix in *path* with ``~``."""
1027
+ home = str(Path.home())
1028
+ if path == home:
1029
+ return "~"
1030
+ if path.startswith(home + os.sep):
1031
+ return "~" + path[len(home) :]
1032
+ return path
1033
+
1034
+
1035
+ def _display_width(text: str) -> int:
1036
+ """Return the terminal column width of *text*, handling wide Unicode characters."""
1037
+ return sum(get_cwidth(c) for c in text)
1038
+
1039
+
1040
+ def _truncate_left(text: str, max_cols: int) -> str:
1041
+ """Truncate *text* from the left, prepending '…' if it exceeds *max_cols*."""
1042
+ if max_cols <= 0:
1043
+ return ""
1044
+ if _display_width(text) <= max_cols:
1045
+ return text
1046
+ ellipsis = "…"
1047
+ budget = max_cols - _display_width(ellipsis)
1048
+ chars: list[str] = []
1049
+ width = 0
1050
+ for ch in reversed(text):
1051
+ w = get_cwidth(ch)
1052
+ if width + w > budget:
1053
+ break
1054
+ chars.append(ch)
1055
+ width += w
1056
+ return ellipsis + "".join(reversed(chars))
1057
+
1058
+
1059
+ def _truncate_right(text: str, max_cols: int) -> str:
1060
+ """Truncate *text* from the right, appending '…' if it exceeds *max_cols*."""
1061
+ if max_cols <= 0:
1062
+ return ""
1063
+ if _display_width(text) <= max_cols:
1064
+ return text
1065
+ ellipsis = "…"
1066
+ budget = max_cols - _display_width(ellipsis)
1067
+ chars: list[str] = []
1068
+ width = 0
1069
+ for ch in text:
1070
+ w = get_cwidth(ch)
1071
+ if width + w > budget:
1072
+ break
1073
+ chars.append(ch)
1074
+ width += w
1075
+ return "".join(chars) + ellipsis
1076
+
1077
+
1078
+ @dataclass(slots=True)
1079
+ class _ToastEntry:
1080
+ topic: str | None
1081
+ """There can be only one toast of each non-None topic in the queue."""
1082
+ message: str
1083
+ expires_at: float
1084
+
1085
+
1086
+ class RunningPromptDelegate(Protocol):
1087
+ """Protocol for components that can take over the bottom prompt area."""
1088
+
1089
+ modal_priority: int
1090
+
1091
+ def render_running_prompt_body(self, columns: int) -> AnyFormattedText: ...
1092
+
1093
+ def running_prompt_placeholder(self) -> AnyFormattedText | None: ...
1094
+
1095
+ def running_prompt_allows_text_input(self) -> bool: ...
1096
+
1097
+ def running_prompt_hides_input_buffer(self) -> bool: ...
1098
+
1099
+ def running_prompt_accepts_submission(self) -> bool: ...
1100
+
1101
+ def should_handle_running_prompt_key(self, key: str) -> bool: ...
1102
+
1103
+ def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ...
1104
+
1105
+
1106
+ @dataclass(frozen=True, slots=True)
1107
+ class BgTaskCounts:
1108
+ bash: int = 0
1109
+ agent: int = 0
1110
+
1111
+
1112
+ @runtime_checkable
1113
+ class AgentStatusProvider(Protocol):
1114
+ """Optional protocol for delegates that render always-visible agent status.
1115
+
1116
+ When the running prompt delegate implements this, ``_render_agent_status``
1117
+ will call ``render_agent_status`` instead of the fallback status block.
1118
+ This ensures spinners, content blocks, and tool calls remain visible
1119
+ even when a modal (approval/question/btw) is active.
1120
+ """
1121
+
1122
+ def render_agent_status(self, columns: int) -> AnyFormattedText: ...
1123
+
1124
+
1125
+ _toast_queues: dict[Literal["left", "right"], deque[_ToastEntry]] = {
1126
+ "left": deque(),
1127
+ "right": deque(),
1128
+ }
1129
+ """The queue of toasts to show, including the one currently being shown (the first one)."""
1130
+
1131
+
1132
+ def toast(
1133
+ message: str,
1134
+ duration: float = 5.0,
1135
+ topic: str | None = None,
1136
+ immediate: bool = False,
1137
+ position: Literal["left", "right"] = "left",
1138
+ ) -> None:
1139
+ queue = _toast_queues[position]
1140
+ duration = max(duration, _IDLE_REFRESH_INTERVAL)
1141
+ entry = _ToastEntry(topic=topic, message=message, expires_at=time.monotonic() + duration)
1142
+ if topic is not None:
1143
+ # Remove existing toasts with the same topic
1144
+ for existing in list(queue):
1145
+ if existing.topic == topic:
1146
+ queue.remove(existing)
1147
+ if immediate:
1148
+ queue.appendleft(entry)
1149
+ else:
1150
+ queue.append(entry)
1151
+
1152
+
1153
+ def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | None:
1154
+ queue = _toast_queues[position]
1155
+ now = time.monotonic()
1156
+ while queue and queue[0].expires_at <= now:
1157
+ queue.popleft()
1158
+ if not queue:
1159
+ return None
1160
+ return queue[0]
1161
+
1162
+
1163
+ def _build_toolbar_tips(clipboard_available: bool) -> list[str]:
1164
+ tips = [
1165
+ "ctrl-x: toggle mode",
1166
+ "shift-tab: plan mode",
1167
+ "ctrl-o: editor",
1168
+ "ctrl-j: newline",
1169
+ "/feedback: send feedback",
1170
+ "/theme: switch dark/light",
1171
+ ]
1172
+ if clipboard_available:
1173
+ tips.append("ctrl-v: paste clipboard")
1174
+ tips.append("@: mention files")
1175
+ return tips
1176
+
1177
+
1178
+ _TIP_SEPARATOR = " | "
1179
+
1180
+
1181
+ class CustomPromptSession:
1182
+ def __init__(
1183
+ self,
1184
+ *,
1185
+ status_provider: Callable[[], StatusSnapshot],
1186
+ status_block_provider: Callable[[int], AnyFormattedText | None] | None = None,
1187
+ fast_refresh_provider: Callable[[], bool] | None = None,
1188
+ background_task_count_provider: Callable[[], BgTaskCounts] | None = None,
1189
+ model_capabilities: set[ModelCapability],
1190
+ model_name: str | None,
1191
+ thinking: bool,
1192
+ agent_mode_slash_commands: Sequence[SlashCommand[Any]],
1193
+ shell_mode_slash_commands: Sequence[SlashCommand[Any]],
1194
+ editor_command_provider: Callable[[], str] = lambda: "",
1195
+ plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None,
1196
+ ) -> None:
1197
+ history_dir = get_share_dir() / "user-history"
1198
+ history_dir.mkdir(parents=True, exist_ok=True)
1199
+ work_dir_id = md5(str(HostPath.cwd()).encode(encoding="utf-8")).hexdigest()
1200
+ self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
1201
+ self._status_provider = status_provider
1202
+ self._status_block_provider = status_block_provider
1203
+ self._fast_refresh_provider = fast_refresh_provider
1204
+ self._background_task_count_provider = background_task_count_provider
1205
+ self._editor_command_provider = editor_command_provider
1206
+ self._plan_mode_toggle_callback = plan_mode_toggle_callback
1207
+ self._model_capabilities = model_capabilities
1208
+ self._model_name = model_name
1209
+ self._last_history_content: str | None = None
1210
+ self._mode: PromptMode = PromptMode.AGENT
1211
+ self._thinking = thinking
1212
+ self._placeholder_manager = PromptPlaceholderManager()
1213
+ # Keep the old attribute for test compatibility and for any external imports.
1214
+ self._attachment_cache = self._placeholder_manager.attachment_cache
1215
+ self._last_tip_rotate_time: float = time.monotonic()
1216
+ self._last_submission_was_running = False
1217
+ self._last_input_activity_time: float = 0.0
1218
+ self._suppress_auto_completion: bool = False
1219
+ self._input_activity_event: asyncio.Event = asyncio.Event()
1220
+ self._running_prompt_previous_mode: PromptMode | None = None
1221
+ self._running_prompt_delegate: RunningPromptDelegate | None = None
1222
+ self._modal_delegates: list[RunningPromptDelegate] = []
1223
+ self._prompt_buffer_container: ConditionalContainer | None = None
1224
+ self._last_ui_state: PromptUIState = PromptUIState.NORMAL_INPUT
1225
+ self._suspended_buffer_document: Document | None = None
1226
+ clipboard_available = is_clipboard_available()
1227
+ media_clipboard_available = is_media_clipboard_available()
1228
+ self._tips = _build_toolbar_tips(clipboard_available or media_clipboard_available)
1229
+ self._tip_rotation_index: int = random.randrange(len(self._tips)) if self._tips else 0
1230
+
1231
+ history_entries = _load_history_entries(self._history_file)
1232
+ history = InMemoryHistory()
1233
+ for entry in history_entries:
1234
+ history.append_string(entry.content)
1235
+
1236
+ if history_entries:
1237
+ # for consecutive deduplication
1238
+ self._last_history_content = history_entries[-1].content
1239
+
1240
+ # Build completers
1241
+ self._agent_mode_completer = merge_completers(
1242
+ [
1243
+ SlashCommandCompleter(agent_mode_slash_commands),
1244
+ # TODO(host): we need an async HostFileMentionCompleter
1245
+ LocalFileMentionCompleter(HostPath.cwd().unsafe_to_local_path()),
1246
+ ],
1247
+ deduplicate=True,
1248
+ )
1249
+ self._shell_mode_completer = SlashCommandCompleter(shell_mode_slash_commands)
1250
+
1251
+ # Build key bindings
1252
+ _kb = KeyBindings()
1253
+
1254
+ def _accept_completion(buff: Buffer) -> None:
1255
+ """Accept the current or first completion, suppressing re-completion."""
1256
+ completion = buff.complete_state.current_completion # type: ignore[union-attr]
1257
+ if not completion:
1258
+ completion = buff.complete_state.completions[0] # type: ignore[union-attr]
1259
+ self._suppress_auto_completion = True
1260
+ try:
1261
+ buff.apply_completion(completion)
1262
+ finally:
1263
+ self._suppress_auto_completion = False
1264
+
1265
+ def _is_slash_completion() -> bool:
1266
+ """True when the active completion menu is for a slash command."""
1267
+ buff = self._session.default_buffer
1268
+ return bool(
1269
+ buff.complete_state
1270
+ and buff.complete_state.completions
1271
+ and SlashCommandCompleter.should_complete(buff.document)
1272
+ )
1273
+
1274
+ _slash_completion_filter = has_completions & Condition(_is_slash_completion)
1275
+ _non_slash_completion_filter = has_completions & ~Condition(_is_slash_completion)
1276
+
1277
+ @_kb.add("enter", filter=_slash_completion_filter)
1278
+ def _(event: KeyPressEvent) -> None:
1279
+ """Slash command completion: accept and submit in one step."""
1280
+ _accept_completion(event.current_buffer)
1281
+ event.current_buffer.validate_and_handle()
1282
+
1283
+ @_kb.add("enter", filter=_non_slash_completion_filter)
1284
+ def _(event: KeyPressEvent) -> None:
1285
+ """Non-slash completion (file mentions, etc.): accept only."""
1286
+ _accept_completion(event.current_buffer)
1287
+
1288
+ @_kb.add("c-x", eager=True)
1289
+ def _(event: KeyPressEvent) -> None:
1290
+ if self._active_prompt_delegate() is not None:
1291
+ return
1292
+ self._mode = self._mode.toggle()
1293
+ from pythinker_code.telemetry import track
1294
+
1295
+ track("shortcut_mode_switch", to_mode=self._mode.value)
1296
+ # Apply mode-specific settings
1297
+ self._apply_mode(event)
1298
+ # Redraw UI
1299
+ event.app.invalidate()
1300
+
1301
+ @_kb.add("s-tab", eager=True)
1302
+ def _(event: KeyPressEvent) -> None:
1303
+ """Toggle plan mode with Shift+Tab."""
1304
+ if self._active_prompt_delegate() is not None:
1305
+ return
1306
+ if self._plan_mode_toggle_callback is not None:
1307
+
1308
+ async def _toggle() -> None:
1309
+ assert self._plan_mode_toggle_callback is not None
1310
+ new_state = await self._plan_mode_toggle_callback()
1311
+ from pythinker_code.telemetry import track
1312
+
1313
+ track("shortcut_plan_toggle", enabled=new_state)
1314
+ if new_state:
1315
+ toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True)
1316
+ else:
1317
+ toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True)
1318
+ event.app.invalidate()
1319
+
1320
+ event.app.create_background_task(_toggle())
1321
+ event.app.invalidate()
1322
+
1323
+ @_kb.add("escape", "enter", eager=True)
1324
+ @_kb.add("c-j", eager=True)
1325
+ def _(event: KeyPressEvent) -> None:
1326
+ """Insert a newline when Alt-Enter or Ctrl-J is pressed."""
1327
+ from pythinker_code.telemetry import track
1328
+
1329
+ track("shortcut_newline")
1330
+ event.current_buffer.insert_text("\n")
1331
+
1332
+ @_kb.add("c-o", eager=True)
1333
+ def _(event: KeyPressEvent) -> None:
1334
+ """Open current buffer in external editor."""
1335
+ from pythinker_code.telemetry import track
1336
+
1337
+ track("shortcut_editor")
1338
+ self._open_in_external_editor(event)
1339
+
1340
+ @_kb.add(
1341
+ "up",
1342
+ eager=True,
1343
+ filter=Condition(lambda: self._should_handle_running_prompt_key("up")),
1344
+ )
1345
+ def _(event: KeyPressEvent) -> None:
1346
+ self._handle_running_prompt_key("up", event)
1347
+
1348
+ @_kb.add(
1349
+ "down",
1350
+ eager=True,
1351
+ filter=Condition(lambda: self._should_handle_running_prompt_key("down")),
1352
+ )
1353
+ def _(event: KeyPressEvent) -> None:
1354
+ self._handle_running_prompt_key("down", event)
1355
+
1356
+ @_kb.add(
1357
+ "left",
1358
+ eager=True,
1359
+ filter=Condition(lambda: self._should_handle_running_prompt_key("left")),
1360
+ )
1361
+ def _(event: KeyPressEvent) -> None:
1362
+ self._handle_running_prompt_key("left", event)
1363
+
1364
+ @_kb.add(
1365
+ "right",
1366
+ eager=True,
1367
+ filter=Condition(lambda: self._should_handle_running_prompt_key("right")),
1368
+ )
1369
+ def _(event: KeyPressEvent) -> None:
1370
+ self._handle_running_prompt_key("right", event)
1371
+
1372
+ @_kb.add(
1373
+ "tab",
1374
+ eager=True,
1375
+ filter=Condition(lambda: self._should_handle_running_prompt_key("tab")),
1376
+ )
1377
+ def _(event: KeyPressEvent) -> None:
1378
+ self._handle_running_prompt_key("tab", event)
1379
+
1380
+ @_kb.add(
1381
+ "enter",
1382
+ eager=True,
1383
+ filter=Condition(lambda: self._should_handle_running_prompt_key("enter")),
1384
+ )
1385
+ def _(event: KeyPressEvent) -> None:
1386
+ self._handle_running_prompt_key("enter", event)
1387
+
1388
+ @_kb.add(
1389
+ "space",
1390
+ eager=True,
1391
+ filter=Condition(lambda: self._should_handle_running_prompt_key("space")),
1392
+ )
1393
+ def _(event: KeyPressEvent) -> None:
1394
+ self._handle_running_prompt_key("space", event)
1395
+
1396
+ @_kb.add(
1397
+ "c-s",
1398
+ eager=True,
1399
+ filter=Condition(lambda: self._should_handle_running_prompt_key("c-s")),
1400
+ )
1401
+ def _(event: KeyPressEvent) -> None:
1402
+ self._handle_running_prompt_key("c-s", event)
1403
+
1404
+ @_kb.add(
1405
+ "c-e",
1406
+ eager=True,
1407
+ filter=Condition(lambda: self._should_handle_running_prompt_key("c-e")),
1408
+ )
1409
+ def _(event: KeyPressEvent) -> None:
1410
+ self._handle_running_prompt_key("c-e", event)
1411
+
1412
+ @_kb.add(
1413
+ "c-c",
1414
+ eager=True,
1415
+ filter=Condition(lambda: self._should_handle_running_prompt_key("c-c")),
1416
+ )
1417
+ def _(event: KeyPressEvent) -> None:
1418
+ self._handle_running_prompt_key("c-c", event)
1419
+
1420
+ @_kb.add(
1421
+ "c-d",
1422
+ eager=True,
1423
+ filter=Condition(lambda: self._should_handle_running_prompt_key("c-d")),
1424
+ )
1425
+ def _(event: KeyPressEvent) -> None:
1426
+ self._handle_running_prompt_key("c-d", event)
1427
+
1428
+ @_kb.add(
1429
+ "escape",
1430
+ eager=True,
1431
+ filter=Condition(lambda: self._should_handle_running_prompt_key("escape")),
1432
+ )
1433
+ def _(event: KeyPressEvent) -> None:
1434
+ self._handle_running_prompt_key("escape", event)
1435
+
1436
+ @_kb.add(
1437
+ "1",
1438
+ eager=True,
1439
+ filter=Condition(lambda: self._should_handle_running_prompt_key("1")),
1440
+ )
1441
+ def _(event: KeyPressEvent) -> None:
1442
+ self._handle_running_prompt_key("1", event)
1443
+
1444
+ @_kb.add(
1445
+ "2",
1446
+ eager=True,
1447
+ filter=Condition(lambda: self._should_handle_running_prompt_key("2")),
1448
+ )
1449
+ def _(event: KeyPressEvent) -> None:
1450
+ self._handle_running_prompt_key("2", event)
1451
+
1452
+ @_kb.add(
1453
+ "3",
1454
+ eager=True,
1455
+ filter=Condition(lambda: self._should_handle_running_prompt_key("3")),
1456
+ )
1457
+ def _(event: KeyPressEvent) -> None:
1458
+ self._handle_running_prompt_key("3", event)
1459
+
1460
+ @_kb.add(
1461
+ "4",
1462
+ eager=True,
1463
+ filter=Condition(lambda: self._should_handle_running_prompt_key("4")),
1464
+ )
1465
+ def _(event: KeyPressEvent) -> None:
1466
+ self._handle_running_prompt_key("4", event)
1467
+
1468
+ @_kb.add(
1469
+ "5",
1470
+ eager=True,
1471
+ filter=Condition(lambda: self._should_handle_running_prompt_key("5")),
1472
+ )
1473
+ def _(event: KeyPressEvent) -> None:
1474
+ self._handle_running_prompt_key("5", event)
1475
+
1476
+ @_kb.add(
1477
+ "6",
1478
+ eager=True,
1479
+ filter=Condition(lambda: self._should_handle_running_prompt_key("6")),
1480
+ )
1481
+ def _(event: KeyPressEvent) -> None:
1482
+ self._handle_running_prompt_key("6", event)
1483
+
1484
+ @_kb.add(Keys.BracketedPaste, eager=True)
1485
+ def _(event: KeyPressEvent) -> None:
1486
+ self._handle_bracketed_paste(event)
1487
+
1488
+ if clipboard_available or media_clipboard_available:
1489
+
1490
+ @_kb.add("c-v", eager=True)
1491
+ def _(event: KeyPressEvent) -> None:
1492
+ from pythinker_code.telemetry import track
1493
+
1494
+ track("shortcut_paste")
1495
+ if self._try_paste_media(event):
1496
+ return
1497
+ if clipboard_available:
1498
+ try:
1499
+ clipboard_data = event.app.clipboard.get_data()
1500
+ except Exception:
1501
+ return
1502
+ if clipboard_data is None: # type: ignore[reportUnnecessaryComparison]
1503
+ return
1504
+ self._insert_pasted_text(event.current_buffer, clipboard_data.text)
1505
+ event.app.invalidate()
1506
+
1507
+ # Only use PyperclipClipboard when pyperclip actually works.
1508
+ # PromptSession built-in keybindings (ctrl-k, ctrl-w, ctrl-y)
1509
+ # use clipboard without error handling, so a broken clipboard
1510
+ # object would crash the UI.
1511
+ clipboard = PyperclipClipboard() if clipboard_available else None
1512
+
1513
+ self._session = PromptSession[str](
1514
+ message=self._render_message,
1515
+ # prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
1516
+ completer=self._agent_mode_completer,
1517
+ complete_while_typing=True,
1518
+ reserve_space_for_menu=6,
1519
+ key_bindings=_kb,
1520
+ clipboard=clipboard,
1521
+ history=history,
1522
+ bottom_toolbar=self._render_bottom_toolbar,
1523
+ style=get_prompt_style(),
1524
+ )
1525
+ self._session.default_buffer.read_only = Condition(
1526
+ lambda: (
1527
+ (delegate := self._active_prompt_delegate()) is not None
1528
+ and not delegate.running_prompt_allows_text_input()
1529
+ )
1530
+ )
1531
+ self._install_slash_completion_menu()
1532
+ self._install_prompt_buffer_visibility()
1533
+ self._apply_mode()
1534
+
1535
+ # Allow completion to be triggered when the text is changed,
1536
+ # such as when backspace is used to delete text.
1537
+ @self._session.default_buffer.on_text_changed.add_handler
1538
+ def _(buffer: Buffer) -> None:
1539
+ self._last_input_activity_time = time.monotonic()
1540
+ self._input_activity_event.set()
1541
+ if buffer.complete_while_typing() and not self._suppress_auto_completion:
1542
+ buffer.start_completion()
1543
+
1544
+ # Pre-select the first slash-command completion as soon as the menu
1545
+ # appears. The visual hack in SlashCommandMenuControl.create_content
1546
+ # already paints index 0 as highlighted when complete_index is None,
1547
+ # but the underlying complete_state was still un-positioned, so the
1548
+ # first arrow-down moved None→0 (no visible change) and required a
1549
+ # second press to reach row 2. Setting complete_index=0 here makes
1550
+ # the visual and behavioral states agree from the start.
1551
+ @self._session.default_buffer.on_completions_changed.add_handler
1552
+ def _(buffer: Buffer) -> None:
1553
+ state = buffer.complete_state
1554
+ if state is None or not state.completions:
1555
+ return
1556
+ if state.complete_index is not None:
1557
+ return
1558
+ if not SlashCommandCompleter.should_complete(buffer.document):
1559
+ return
1560
+ state.complete_index = 0
1561
+
1562
+ self._status_refresh_task: asyncio.Task[None] | None = None
1563
+
1564
+ def _install_slash_completion_menu(self) -> None:
1565
+ float_container = _find_prompt_float_container(self._session.layout.container)
1566
+ if not isinstance(float_container, FloatContainer):
1567
+ return
1568
+
1569
+ slash_menu_filter = (
1570
+ has_focus(self._session.default_buffer)
1571
+ & has_completions
1572
+ & ~is_done
1573
+ & Condition(self._should_show_slash_completion_menu)
1574
+ )
1575
+ slash_menu = ConditionalContainer(
1576
+ Window(
1577
+ content=SlashCommandMenuControl(left_padding=self._slash_menu_left_padding),
1578
+ dont_extend_height=True,
1579
+ height=Dimension(max=10),
1580
+ style="class:slash-completion-menu",
1581
+ ),
1582
+ filter=slash_menu_filter,
1583
+ )
1584
+ float_container.floats.insert(
1585
+ 0,
1586
+ Float(
1587
+ left=0,
1588
+ right=0,
1589
+ ycursor=True,
1590
+ content=slash_menu,
1591
+ z_index=10**8,
1592
+ ),
1593
+ )
1594
+
1595
+ original_float = next(
1596
+ (
1597
+ float_
1598
+ for float_ in float_container.floats[1:]
1599
+ if isinstance(float_.content, CompletionsMenu)
1600
+ ),
1601
+ None,
1602
+ )
1603
+ if original_float is None:
1604
+ return
1605
+ original_float.content = ConditionalContainer(
1606
+ original_float.content,
1607
+ filter=~Condition(self._should_show_slash_completion_menu),
1608
+ )
1609
+
1610
+ def _install_prompt_buffer_visibility(self) -> None:
1611
+ buffer_container = _find_default_buffer_container(
1612
+ self._session.layout.container,
1613
+ self._session.default_buffer,
1614
+ )
1615
+ if buffer_container is None:
1616
+ return
1617
+ buffer_container.filter = buffer_container.filter & Condition(
1618
+ self._should_render_input_buffer
1619
+ )
1620
+ self._prompt_buffer_container = buffer_container
1621
+
1622
+ def _should_show_slash_completion_menu(self) -> bool:
1623
+ document = self._session.default_buffer.document
1624
+ return SlashCommandCompleter.should_complete(document)
1625
+
1626
+ def _slash_menu_left_padding(self) -> int:
1627
+ if self._mode == PromptMode.SHELL:
1628
+ return max(1, get_cwidth(f"{PROMPT_SYMBOL_SHELL} ") - 2)
1629
+ # Agent mode: prompt prefix is "│ " (3 chars inside input panel)
1630
+ return 1
1631
+
1632
+ def _render_message(self) -> FormattedText:
1633
+ if self._mode == PromptMode.SHELL:
1634
+ return self._render_shell_prompt_message()
1635
+ return self._render_agent_prompt_message()
1636
+
1637
+ def _render_shell_prompt_message(self) -> FormattedText:
1638
+ app = get_app_or_none()
1639
+ columns = app.output.get_size().columns if app is not None else 80
1640
+ fragments: FormattedText = FormattedText()
1641
+
1642
+ # Agent status (always visible)
1643
+ agent_status = self._render_agent_status(columns)
1644
+ if agent_status:
1645
+ fragments.extend(agent_status)
1646
+ if not agent_status[-1][1].endswith("\n"):
1647
+ fragments.append(("", "\n"))
1648
+
1649
+ # Interactive body
1650
+ body = self._render_interactive_body(columns)
1651
+ if body:
1652
+ fragments.extend(body)
1653
+ if not body[-1][1].endswith("\n"):
1654
+ fragments.append(("", "\n"))
1655
+
1656
+ if self._active_modal_delegate() is not None:
1657
+ return fragments
1658
+ has_content = bool(agent_status or body)
1659
+ if has_content:
1660
+ fragments.append(("", "\n"))
1661
+ # Shell mode: simple separator + $ prefix (no panel border)
1662
+ fragments.append(("class:running-prompt-separator", "─" * max(0, columns)))
1663
+ fragments.append(("", "\n"))
1664
+ fragments.append(("bold", f"{PROMPT_SYMBOL_SHELL} "))
1665
+ return fragments
1666
+
1667
+ def _open_in_external_editor(self, event: KeyPressEvent) -> None:
1668
+ """Open the current buffer content in an external editor."""
1669
+ from prompt_toolkit.application.run_in_terminal import run_in_terminal
1670
+
1671
+ from pythinker_code.utils.editor import edit_text_in_editor, get_editor_command
1672
+
1673
+ configured = self._editor_command_provider()
1674
+
1675
+ if get_editor_command(configured) is None:
1676
+ toast("No editor found. Set $VISUAL/$EDITOR or run /editor.")
1677
+ return
1678
+
1679
+ buff = event.current_buffer
1680
+ original_text = buff.text
1681
+ editor_text = self._get_placeholder_manager().expand_for_editor(original_text)
1682
+
1683
+ async def _run_editor() -> None:
1684
+ result = await run_in_terminal(
1685
+ lambda: edit_text_in_editor(editor_text, configured), in_executor=True
1686
+ )
1687
+ if result is not None:
1688
+ refolded = self._get_placeholder_manager().refold_after_editor(
1689
+ result, original_text
1690
+ )
1691
+ buff.document = Document(text=refolded, cursor_position=len(refolded))
1692
+
1693
+ event.app.create_background_task(_run_editor())
1694
+
1695
+ def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
1696
+ # Apply mode to the active buffer (not the PromptSession itself)
1697
+ try:
1698
+ buff = event.current_buffer if event is not None else self._session.default_buffer
1699
+ except Exception:
1700
+ buff = None
1701
+
1702
+ if self._mode == PromptMode.SHELL:
1703
+ if buff is not None:
1704
+ buff.completer = self._shell_mode_completer
1705
+ else:
1706
+ if buff is not None:
1707
+ buff.completer = self._agent_mode_completer
1708
+ self._sync_erase_when_done()
1709
+
1710
+ def _sync_erase_when_done(self) -> None:
1711
+ app = getattr(self._session, "app", None)
1712
+ if app is not None:
1713
+ app.erase_when_done = self._mode == PromptMode.AGENT
1714
+
1715
+ def _active_modal_delegate(self) -> RunningPromptDelegate | None:
1716
+ modal_delegates = getattr(self, "_modal_delegates", [])
1717
+ if not modal_delegates:
1718
+ return None
1719
+ _, delegate = max(
1720
+ enumerate(modal_delegates),
1721
+ key=lambda item: (item[1].modal_priority, item[0]),
1722
+ )
1723
+ return delegate
1724
+
1725
+ def _active_prompt_delegate(self) -> RunningPromptDelegate | None:
1726
+ if delegate := self._active_modal_delegate():
1727
+ return delegate
1728
+ return getattr(self, "_running_prompt_delegate", None)
1729
+
1730
+ def _active_ui_state(self) -> PromptUIState:
1731
+ delegate = self._active_modal_delegate()
1732
+ if delegate is None:
1733
+ return PromptUIState.NORMAL_INPUT
1734
+ if delegate.running_prompt_hides_input_buffer():
1735
+ return PromptUIState.MODAL_HIDDEN_INPUT
1736
+ if delegate.running_prompt_allows_text_input():
1737
+ return PromptUIState.MODAL_TEXT_INPUT
1738
+ return PromptUIState.NORMAL_INPUT
1739
+
1740
+ def _should_render_input_buffer(self) -> bool:
1741
+ return self._active_ui_state() != PromptUIState.MODAL_HIDDEN_INPUT
1742
+
1743
+ def _should_handle_running_prompt_key(self, key: str) -> bool:
1744
+ delegate = self._active_prompt_delegate()
1745
+ return delegate is not None and delegate.should_handle_running_prompt_key(key)
1746
+
1747
+ def _handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None:
1748
+ delegate = self._active_prompt_delegate()
1749
+ if delegate is None:
1750
+ return
1751
+ delegate.handle_running_prompt_key(key, event)
1752
+ event.app.invalidate()
1753
+
1754
+ def invalidate(self) -> None:
1755
+ self._sync_prompt_ui_state()
1756
+ app = get_app_or_none()
1757
+ if app is not None:
1758
+ app.invalidate()
1759
+
1760
+ def _sync_prompt_ui_state(self) -> None:
1761
+ new_state = self._active_ui_state()
1762
+ old_state = getattr(self, "_last_ui_state", PromptUIState.NORMAL_INPUT)
1763
+ buffer = self._session.default_buffer
1764
+
1765
+ if (
1766
+ old_state != PromptUIState.MODAL_HIDDEN_INPUT
1767
+ and new_state == PromptUIState.MODAL_HIDDEN_INPUT
1768
+ ):
1769
+ if self._suspended_buffer_document is None and buffer.text:
1770
+ self._suspended_buffer_document = buffer.document
1771
+ buffer.set_document(Document(), bypass_readonly=True)
1772
+ elif (
1773
+ old_state == PromptUIState.MODAL_HIDDEN_INPUT
1774
+ and new_state != PromptUIState.MODAL_HIDDEN_INPUT
1775
+ and self._suspended_buffer_document is not None
1776
+ ):
1777
+ if not buffer.text:
1778
+ buffer.set_document(self._suspended_buffer_document, bypass_readonly=True)
1779
+ else:
1780
+ # Buffer was externally modified (e.g. approval inline feedback).
1781
+ # Don't overwrite the new content, but log that the old input is lost.
1782
+ logger.debug(
1783
+ "Dropping suspended buffer document because buffer was modified externally"
1784
+ )
1785
+ self._suspended_buffer_document = None
1786
+
1787
+ self._last_ui_state = new_state
1788
+
1789
+ def _render_agent_prompt_message(self) -> FormattedText:
1790
+ app = get_app_or_none()
1791
+ columns = app.output.get_size().columns if app is not None else 80
1792
+ fragments: FormattedText = FormattedText()
1793
+
1794
+ # 1. Agent status — ALWAYS rendered from running prompt delegate.
1795
+ # This ensures spinners, content blocks, tool calls etc. stay
1796
+ # visible even when a modal (btw/approval/question) is active.
1797
+ agent_status = self._render_agent_status(columns)
1798
+ if agent_status:
1799
+ fragments.extend(agent_status)
1800
+ if not agent_status[-1][1].endswith("\n"):
1801
+ fragments.append(("", "\n"))
1802
+
1803
+ # 2. Interactive area — from the active delegate (modal overrides).
1804
+ body = self._render_interactive_body(columns)
1805
+ if body:
1806
+ fragments.extend(body)
1807
+ if not body[-1][1].endswith("\n"):
1808
+ fragments.append(("", "\n"))
1809
+
1810
+ # 3. When a modal is active, skip input panel border.
1811
+ if self._active_modal_delegate() is not None:
1812
+ return fragments
1813
+
1814
+ # 4. Input section header — style varies by mode:
1815
+ # normal: ── input ───────────────── (grey, solid)
1816
+ # plan: ╌╌ input · plan ╌╌╌╌╌╌╌╌╌ (blue, dashed)
1817
+ status = self._status_provider()
1818
+ # Build title parts
1819
+ title_parts = ["input"]
1820
+ if status.plan_mode:
1821
+ title_parts.append("plan")
1822
+ # Queue count from running prompt delegate
1823
+ running = self._running_prompt_delegate
1824
+ queue_count = len(getattr(running, "_queued_messages", []))
1825
+ if queue_count > 0:
1826
+ title_parts.append(f"{queue_count} queued")
1827
+ title = f" {' · '.join(title_parts)} "
1828
+ if status.plan_mode:
1829
+ dash = "╌"
1830
+ style = "fg:#60a5fa" # blue
1831
+ else:
1832
+ dash = "─"
1833
+ style = "class:running-prompt-separator"
1834
+ border_fill = max(0, columns - len(title) - 2)
1835
+ top_border = f"{dash}{dash}{title}{dash * border_fill}"
1836
+ fragments.append(("", "\n"))
1837
+ fragments.append((style, top_border))
1838
+ fragments.append(("", "\n"))
1839
+ fragments.append(("", " "))
1840
+ return fragments
1841
+
1842
+ def _render_agent_status(self, columns: int) -> FormattedText:
1843
+ """Render agent streaming output (always visible, independent of modals)."""
1844
+ running = self._running_prompt_delegate
1845
+ if running is not None and isinstance(running, AgentStatusProvider):
1846
+ return to_formatted_text(running.render_agent_status(columns))
1847
+ return self._render_status_block(columns)
1848
+
1849
+ def _render_interactive_body(self, columns: int) -> FormattedText:
1850
+ """Render the interactive area from the active delegate (modal or running prompt)."""
1851
+ delegate = self._active_prompt_delegate()
1852
+ if delegate is None:
1853
+ return FormattedText([])
1854
+ return to_formatted_text(delegate.render_running_prompt_body(columns))
1855
+
1856
+ def _render_status_block(self, columns: int) -> FormattedText:
1857
+ status_block_provider = getattr(self, "_status_block_provider", None)
1858
+ if status_block_provider is None:
1859
+ return FormattedText([])
1860
+ block = status_block_provider(columns)
1861
+ if block is None:
1862
+ return FormattedText([])
1863
+ return to_formatted_text(block)
1864
+
1865
+ def _render_agent_prompt_label(self) -> FormattedText:
1866
+ """Render the prompt label (empty — cursor starts at column 0)."""
1867
+ return FormattedText([("", " ")])
1868
+
1869
+ def __enter__(self) -> CustomPromptSession:
1870
+ if self._status_refresh_task is not None and not self._status_refresh_task.done():
1871
+ return self
1872
+
1873
+ async def _refresh() -> None:
1874
+ try:
1875
+ while True:
1876
+ app = get_app_or_none()
1877
+ if app is not None:
1878
+ app.invalidate()
1879
+
1880
+ try:
1881
+ asyncio.get_running_loop()
1882
+ except RuntimeError:
1883
+ logger.warning("No running loop found, exiting status refresh task")
1884
+ self._status_refresh_task = None
1885
+ break
1886
+
1887
+ interval = (
1888
+ _RUNNING_REFRESH_INTERVAL
1889
+ if self._active_prompt_delegate() is not None
1890
+ or (
1891
+ self._fast_refresh_provider is not None
1892
+ and self._fast_refresh_provider()
1893
+ )
1894
+ else _IDLE_REFRESH_INTERVAL
1895
+ )
1896
+ await asyncio.sleep(interval)
1897
+ except asyncio.CancelledError:
1898
+ # graceful exit
1899
+ pass
1900
+
1901
+ self._status_refresh_task = asyncio.create_task(_refresh())
1902
+ return self
1903
+
1904
+ def __exit__(self, *_) -> None:
1905
+ if self._status_refresh_task is not None and not self._status_refresh_task.done():
1906
+ self._status_refresh_task.cancel()
1907
+ self._status_refresh_task = None
1908
+
1909
+ def _get_placeholder_manager(self) -> PromptPlaceholderManager:
1910
+ manager = getattr(self, "_placeholder_manager", None)
1911
+ if manager is None:
1912
+ attachment_cache = getattr(self, "_attachment_cache", None)
1913
+ manager = PromptPlaceholderManager(attachment_cache=attachment_cache)
1914
+ self._placeholder_manager = manager
1915
+ self._attachment_cache = manager.attachment_cache
1916
+ return manager
1917
+
1918
+ def _insert_pasted_text(self, buffer: Buffer, text: str) -> None:
1919
+ normalized = normalize_pasted_text(text)
1920
+ if self._mode != PromptMode.AGENT:
1921
+ buffer.insert_text(normalized)
1922
+ return
1923
+ token_or_text = self._get_placeholder_manager().maybe_placeholderize_pasted_text(normalized)
1924
+ buffer.insert_text(token_or_text)
1925
+
1926
+ def _handle_bracketed_paste(self, event: KeyPressEvent) -> None:
1927
+ self._insert_pasted_text(event.current_buffer, event.data)
1928
+ event.app.invalidate()
1929
+
1930
+ def _try_paste_media(self, event: KeyPressEvent) -> bool:
1931
+ """Try to paste media from the clipboard.
1932
+
1933
+ Reads the clipboard once and handles all detected content:
1934
+ non-image files (videos, PDFs, etc.) are inserted as paths,
1935
+ image files are cached and inserted as placeholders.
1936
+ Returns True if any media content was inserted.
1937
+ """
1938
+ try:
1939
+ result = grab_media_from_clipboard()
1940
+ except Exception:
1941
+ # ImageGrab.grabclipboard() may fail on headless Linux if the
1942
+ # real xclip cannot connect to an X server. Silently ignore so
1943
+ # that the text-paste fallback can still be attempted.
1944
+ return False
1945
+ if result is None:
1946
+ return False
1947
+
1948
+ parts: list[str] = []
1949
+
1950
+ # 1. Insert file paths (videos, PDFs, etc.)
1951
+ if result.file_paths:
1952
+ logger.debug("Pasted {count} file path(s) from clipboard", count=len(result.file_paths))
1953
+ for p in result.file_paths:
1954
+ text = str(p)
1955
+ if self._mode == PromptMode.SHELL:
1956
+ text = shlex.quote(text)
1957
+ parts.append(text)
1958
+
1959
+ # 2. Insert images via cache.
1960
+ if result.images:
1961
+ if "image_in" not in self._model_capabilities:
1962
+ console.print(
1963
+ "[yellow]Image input is not supported by the selected LLM model[/yellow]"
1964
+ )
1965
+ else:
1966
+ for image in result.images:
1967
+ token = self._get_placeholder_manager().create_image_placeholder(image)
1968
+ if token is None:
1969
+ continue
1970
+ logger.debug(
1971
+ "Pasted image from clipboard placeholder: {token}, {image_size}",
1972
+ token=token,
1973
+ image_size=image.size,
1974
+ )
1975
+ parts.append(token)
1976
+
1977
+ if parts:
1978
+ event.current_buffer.insert_text(" ".join(parts))
1979
+ event.app.invalidate()
1980
+ return bool(parts)
1981
+
1982
+ def set_prefill_text(self, text: str) -> None:
1983
+ """Pre-fill the input buffer with the given text.
1984
+
1985
+ Must be called after the prompt session is created but before the
1986
+ first prompt_async call. The text will appear as editable default
1987
+ input in the next prompt.
1988
+ """
1989
+ self._prefill_text = text
1990
+
1991
+ async def prompt_next(self) -> UserInput:
1992
+ return await self._prompt_once(append_history=None)
1993
+
1994
+ @property
1995
+ def last_submission_was_running(self) -> bool:
1996
+ return getattr(self, "_last_submission_was_running", False)
1997
+
1998
+ def has_pending_input(self) -> bool:
1999
+ return bool(self._session.default_buffer.text)
2000
+
2001
+ def had_recent_input_activity(self, *, within_s: float) -> bool:
2002
+ if self._last_input_activity_time <= 0:
2003
+ return False
2004
+ return (time.monotonic() - self._last_input_activity_time) <= within_s
2005
+
2006
+ def recent_input_activity_remaining(self, *, within_s: float) -> float:
2007
+ if self._last_input_activity_time <= 0:
2008
+ return 0.0
2009
+ elapsed = time.monotonic() - self._last_input_activity_time
2010
+ return max(0.0, within_s - elapsed)
2011
+
2012
+ async def wait_for_input_activity(self) -> None:
2013
+ await self._input_activity_event.wait()
2014
+ self._input_activity_event.clear()
2015
+
2016
+ def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
2017
+ current = getattr(self, "_running_prompt_delegate", None)
2018
+ if current is delegate:
2019
+ return
2020
+ if current is None:
2021
+ self._running_prompt_previous_mode = self._mode
2022
+ self._running_prompt_delegate = delegate
2023
+ self._mode = PromptMode.AGENT
2024
+ self._apply_mode()
2025
+ self.invalidate()
2026
+
2027
+ def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
2028
+ if getattr(self, "_running_prompt_delegate", None) is not delegate:
2029
+ return
2030
+ previous_mode = getattr(self, "_running_prompt_previous_mode", None)
2031
+ self._running_prompt_delegate = None
2032
+ self._running_prompt_previous_mode = None
2033
+ if previous_mode is not None:
2034
+ self._mode = previous_mode
2035
+ self._apply_mode()
2036
+ self.invalidate()
2037
+
2038
+ def attach_modal(self, delegate: RunningPromptDelegate) -> None:
2039
+ modal_delegates: list[RunningPromptDelegate] | None = getattr(
2040
+ self, "_modal_delegates", None
2041
+ )
2042
+ if modal_delegates is None:
2043
+ modal_delegates = []
2044
+ self._modal_delegates = modal_delegates
2045
+ if delegate in modal_delegates:
2046
+ return
2047
+ modal_delegates.append(delegate)
2048
+ self.invalidate()
2049
+
2050
+ def detach_modal(self, delegate: RunningPromptDelegate) -> None:
2051
+ modal_delegates = getattr(self, "_modal_delegates", None)
2052
+ if not modal_delegates or delegate not in modal_delegates:
2053
+ return
2054
+ modal_delegates.remove(delegate)
2055
+ self.invalidate()
2056
+
2057
+ def running_prompt_accepts_submission(self) -> bool:
2058
+ delegate = self._active_prompt_delegate()
2059
+ if delegate is None:
2060
+ return False
2061
+ return delegate.running_prompt_accepts_submission()
2062
+
2063
+ async def _prompt_once(self, *, append_history: bool | None) -> UserInput:
2064
+ placeholder = None
2065
+ if (delegate := self._active_prompt_delegate()) is not None:
2066
+ placeholder = delegate.running_prompt_placeholder()
2067
+ # Consume one-shot prefill text if set
2068
+ default = getattr(self, "_prefill_text", None) or ""
2069
+ self._prefill_text = None
2070
+ with patch_stdout(raw=True):
2071
+ command = str(
2072
+ await self._session.prompt_async(placeholder=placeholder, default=default)
2073
+ ).strip()
2074
+ command = command.replace("\x00", "") # just in case null bytes are somehow inserted
2075
+ # Sanitize UTF-16 surrogates that may come from Windows clipboard
2076
+ command = sanitize_surrogates(command)
2077
+ was_running = self.running_prompt_accepts_submission()
2078
+ self._last_submission_was_running = was_running
2079
+ if append_history is None:
2080
+ append_history = not was_running
2081
+ if append_history:
2082
+ self._append_history_entry(command)
2083
+ self._tip_rotation_index += 1
2084
+ return self._build_user_input(command)
2085
+
2086
+ def _build_user_input(self, command: str) -> UserInput:
2087
+ resolved = self._get_placeholder_manager().resolve_command(command)
2088
+
2089
+ return UserInput(
2090
+ mode=self._mode,
2091
+ command=resolved.display_command,
2092
+ resolved_command=resolved.resolved_text,
2093
+ content=resolved.content,
2094
+ )
2095
+
2096
+ def _append_history_entry(self, text: str) -> None:
2097
+ safe_history_text = self._get_placeholder_manager().serialize_for_history(text).strip()
2098
+ entry = _HistoryEntry(content=safe_history_text)
2099
+ if not entry.content:
2100
+ return
2101
+
2102
+ # skip if same as last entry
2103
+ if entry.content == self._last_history_content:
2104
+ return
2105
+
2106
+ try:
2107
+ self._history_file.parent.mkdir(parents=True, exist_ok=True)
2108
+ with self._history_file.open("a", encoding="utf-8") as f:
2109
+ f.write(entry.model_dump_json(ensure_ascii=False) + "\n")
2110
+ self._last_history_content = entry.content
2111
+ except OSError as exc:
2112
+ logger.warning(
2113
+ "Failed to append user history entry: {file} ({error})",
2114
+ file=self._history_file,
2115
+ error=exc,
2116
+ )
2117
+
2118
+ def _render_bottom_toolbar(self) -> FormattedText:
2119
+ if (
2120
+ hasattr(self, "_session")
2121
+ and self._should_show_slash_completion_menu()
2122
+ and self._session.default_buffer.complete_state is not None
2123
+ ):
2124
+ return FormattedText([])
2125
+ app = get_app_or_none()
2126
+ assert app is not None
2127
+ columns = app.output.get_size().columns
2128
+
2129
+ fragments: list[tuple[str, str]] = []
2130
+ tc = get_toolbar_colors()
2131
+
2132
+ fragments.append((tc.separator, "─" * columns))
2133
+ fragments.append(("", "\n"))
2134
+
2135
+ remaining = columns
2136
+
2137
+ # Time-based tip rotation (every 30 s, independent of user submissions)
2138
+ now = time.monotonic()
2139
+ if now - self._last_tip_rotate_time >= _TIP_ROTATE_INTERVAL:
2140
+ self._tip_rotation_index += 1
2141
+ self._last_tip_rotate_time = now
2142
+
2143
+ # Status flags: yolo / auto / plan
2144
+ status = self._status_provider()
2145
+ if status.yolo_enabled:
2146
+ fragments.extend([(tc.yolo_label, "yolo"), ("", " ")])
2147
+ remaining -= 6 # "yolo" = 4, " " = 2
2148
+ if status.auto_enabled:
2149
+ fragments.extend([(tc.auto_label, "auto"), ("", " ")])
2150
+ remaining -= 6 # "auto" = 4, " " = 2
2151
+ if status.plan_mode:
2152
+ fragments.extend([(tc.plan_label, "plan"), ("", " ")])
2153
+ remaining -= 6
2154
+
2155
+ # Mode indicator (agent / shell) + model name + thinking indicator.
2156
+ # Degrade gracefully on narrow terminals:
2157
+ # full: "agent (model-name ○)" → mid: "agent ○" → bare: "agent"
2158
+ mode = str(self._mode)
2159
+ if self._mode == PromptMode.AGENT and self._model_name:
2160
+ thinking_dot = "●" if self._thinking else "○"
2161
+ mode_full = f"{mode} ({self._model_name} {thinking_dot})"
2162
+ mode_mid = f"{mode} {thinking_dot}"
2163
+ if _display_width(mode_full) <= remaining - 2:
2164
+ mode = mode_full
2165
+ elif _display_width(mode_mid) <= remaining - 2:
2166
+ mode = mode_mid
2167
+ # else: keep bare mode name — model_name and dot are both dropped
2168
+ fragments.extend([("", mode), ("", " ")])
2169
+ remaining -= _display_width(mode) + 2
2170
+
2171
+ # CWD (truncated from left) + git branch with status badge
2172
+ # Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip
2173
+ try:
2174
+ cwd = _truncate_left(_shorten_cwd(str(HostPath.cwd())), _MAX_CWD_COLS)
2175
+ except OSError:
2176
+ # CWD no longer exists (e.g. external drive unplugged). Ask
2177
+ # prompt_toolkit to exit; the raised exception will propagate out
2178
+ # of prompt_async() into the Shell's event router which prints a
2179
+ # crash report with session info and exits cleanly.
2180
+ app.exit(exception=CwdLostError())
2181
+ return FormattedText([])
2182
+ branch = _get_git_branch()
2183
+ if branch:
2184
+ dirty, ahead, behind = _get_git_status()
2185
+ branch = _truncate_right(branch, _MAX_BRANCH_COLS)
2186
+ badge = _format_git_badge(branch, dirty, ahead, behind)
2187
+ cwd_text = f"{cwd} {badge}"
2188
+ else:
2189
+ cwd_text = cwd
2190
+ cwd_w = _display_width(cwd_text)
2191
+ if cwd_w > remaining - 2:
2192
+ cwd_text = cwd # drop badge
2193
+ cwd_w = _display_width(cwd_text)
2194
+ if cwd_w > remaining - 2:
2195
+ cwd_text = _truncate_right(cwd, max(0, remaining - 2))
2196
+ cwd_w = _display_width(cwd_text)
2197
+ if cwd_text and remaining >= cwd_w + 2:
2198
+ fragments.extend([(tc.cwd, cwd_text), ("", " ")])
2199
+ remaining -= cwd_w + 2
2200
+
2201
+ # Active background task counts (bash + agent, each rendered as its own
2202
+ # badge). Order matters: bash renders first; if there isn't room for the
2203
+ # agent badge too, drop agent and keep bash.
2204
+ bg_counts = (
2205
+ self._background_task_count_provider()
2206
+ if self._background_task_count_provider
2207
+ else BgTaskCounts()
2208
+ )
2209
+ for kind_label, kind_count in (("bash", bg_counts.bash), ("agent", bg_counts.agent)):
2210
+ if kind_count <= 0:
2211
+ continue
2212
+ bg_text = f"⚙ {kind_label}: {kind_count}"
2213
+ bg_width = _display_width(bg_text)
2214
+ if remaining < bg_width + 2:
2215
+ break
2216
+ fragments.extend([(tc.bg_tasks, bg_text), ("", " ")])
2217
+ remaining -= bg_width + 2
2218
+
2219
+ # Tips fill remaining space on line 1
2220
+ tip_text = self._get_two_rotating_tips()
2221
+ if tip_text and _display_width(tip_text) > remaining:
2222
+ tip_text = self._get_one_rotating_tip()
2223
+ if tip_text and _display_width(tip_text) <= remaining:
2224
+ fragments.append((tc.tip, tip_text))
2225
+
2226
+ # ── line 2: toast (left) + context (right) — always rendered ──────
2227
+ fragments.append(("", "\n"))
2228
+
2229
+ right_text = self._render_right_span(status)
2230
+ right_width = _display_width(right_text)
2231
+
2232
+ left_toast = _current_toast("left")
2233
+ if left_toast is not None:
2234
+ max_left = max(0, columns - right_width - 2)
2235
+ if max_left > 0:
2236
+ left_text = left_toast.message
2237
+ if _display_width(left_text) > max_left:
2238
+ left_text = _truncate_right(left_text, max_left)
2239
+ left_width = _display_width(left_text)
2240
+ fragments.append(("", left_text))
2241
+ else:
2242
+ left_width = 0
2243
+ else:
2244
+ left_width = 0
2245
+
2246
+ fragments.append(("", " " * max(0, columns - left_width - right_width)))
2247
+ fragments.append(("", right_text))
2248
+
2249
+ return FormattedText(fragments)
2250
+
2251
+ def _get_two_rotating_tips(self) -> str | None:
2252
+ """Return a string with exactly 2 tips from the rotation, or fewer if not enough."""
2253
+ n = len(self._tips)
2254
+ if n == 0:
2255
+ return None
2256
+ if n == 1:
2257
+ return self._tips[0]
2258
+ offset = self._tip_rotation_index % n
2259
+ tip1 = self._tips[offset]
2260
+ tip2 = self._tips[(offset + 1) % n]
2261
+ return f"{tip1}{_TIP_SEPARATOR}{tip2}"
2262
+
2263
+ def _get_one_rotating_tip(self) -> str | None:
2264
+ """Return the single leading tip for the current rotation."""
2265
+ if not self._tips:
2266
+ return None
2267
+ return self._tips[self._tip_rotation_index % len(self._tips)]
2268
+
2269
+ @staticmethod
2270
+ def _render_right_span(status: StatusSnapshot) -> str:
2271
+ current_toast = _current_toast("right")
2272
+ if current_toast is None:
2273
+ return format_context_status(
2274
+ status.context_usage,
2275
+ status.context_tokens,
2276
+ status.max_context_tokens,
2277
+ )
2278
+ return current_toast.message