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