codeply-cli 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (429) hide show
  1. package/LICENSE +21 -21
  2. package/THIRD_PARTY_NOTICES.md +65 -65
  3. package/bin/codeply.js +8 -3
  4. package/lib/agent.mjs +2596 -2268
  5. package/lib/ai.js +135 -27
  6. package/lib/ai.mjs +6 -6
  7. package/lib/apply-patch.js +217 -217
  8. package/lib/arity-table.json +138 -138
  9. package/lib/arity.js +70 -70
  10. package/lib/auth.mjs +6 -6
  11. package/lib/autopilot.js +84 -0
  12. package/lib/bot-mail.js +46 -0
  13. package/lib/bots.js +142 -15
  14. package/lib/brain.js +914 -0
  15. package/lib/call-assist.js +120 -0
  16. package/lib/chatgpt.js +282 -282
  17. package/lib/cloud-browser.mjs +143 -143
  18. package/lib/cloud.mjs +1396 -1312
  19. package/lib/commands.js +103 -103
  20. package/lib/computer.js +136 -0
  21. package/lib/design-library/assemble-artifact.mjs +21 -21
  22. package/lib/design-library/cleanup.mjs +45 -45
  23. package/lib/design-library/index.json +22379 -22379
  24. package/lib/design-library/make-table-data.mjs +39 -39
  25. package/lib/design-tools.mjs +173 -0
  26. package/lib/diagnostics.js +192 -192
  27. package/lib/dsh-acp.mjs +444 -0
  28. package/lib/edit-engine.js +364 -364
  29. package/lib/github-agent.mjs +326 -326
  30. package/lib/handoff.mjs +245 -0
  31. package/lib/history.js +78 -75
  32. package/lib/life-sources.js +319 -0
  33. package/lib/life-store.js +356 -0
  34. package/lib/life-triggers.js +286 -0
  35. package/lib/life.js +487 -0
  36. package/lib/mail-watch.js +3 -3
  37. package/lib/mcp.js +258 -258
  38. package/lib/native-tools.mjs +17 -5
  39. package/lib/oauth-connectors.js +76 -21
  40. package/lib/permissions.js +109 -109
  41. package/lib/plugins.js +356 -353
  42. package/lib/reach.js +67 -67
  43. package/lib/render-md.mjs +647 -617
  44. package/lib/screen.js +77 -0
  45. package/lib/server.mjs +458 -457
  46. package/lib/session-db.js +220 -220
  47. package/lib/share.js +208 -168
  48. package/lib/skills.js +4 -1
  49. package/lib/snapshot.js +146 -146
  50. package/lib/subagents.js +4 -2
  51. package/lib/telemetry.js +105 -105
  52. package/lib/terminal-hints.js +154 -154
  53. package/lib/textarea.mjs +264 -258
  54. package/lib/tools.mjs +326 -40
  55. package/lib/tui.mjs +1459 -1086
  56. package/lib/typecheck-worker.js +147 -147
  57. package/lib/web-tools.js +209 -209
  58. package/package.json +1 -1
  59. package/skills/SOURCE.md +58 -58
  60. package/skills/accessibility/SKILL.md +147 -147
  61. package/skills/agent-architecture-audit/SKILL.md +257 -257
  62. package/skills/agent-eval/SKILL.md +147 -147
  63. package/skills/agent-harness-construction/SKILL.md +74 -74
  64. package/skills/agent-introspection-debugging/SKILL.md +154 -154
  65. package/skills/agent-payment-x402/SKILL.md +225 -225
  66. package/skills/agent-self-evaluation/SKILL.md +182 -182
  67. package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
  68. package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
  69. package/skills/agent-sort/SKILL.md +216 -216
  70. package/skills/agentic-engineering/SKILL.md +64 -64
  71. package/skills/agentic-os/SKILL.md +388 -388
  72. package/skills/ai-first-engineering/SKILL.md +52 -52
  73. package/skills/ai-regression-testing/SKILL.md +386 -386
  74. package/skills/android-clean-architecture/SKILL.md +340 -340
  75. package/skills/angular-developer/SKILL.md +155 -155
  76. package/skills/angular-developer/references/angular-animations.md +160 -160
  77. package/skills/angular-developer/references/angular-aria.md +410 -410
  78. package/skills/angular-developer/references/cli.md +86 -86
  79. package/skills/angular-developer/references/component-harnesses.md +59 -59
  80. package/skills/angular-developer/references/component-styling.md +91 -91
  81. package/skills/angular-developer/references/components.md +117 -117
  82. package/skills/angular-developer/references/creating-services.md +97 -97
  83. package/skills/angular-developer/references/data-resolvers.md +69 -69
  84. package/skills/angular-developer/references/define-routes.md +67 -67
  85. package/skills/angular-developer/references/defining-providers.md +72 -72
  86. package/skills/angular-developer/references/di-fundamentals.md +120 -120
  87. package/skills/angular-developer/references/e2e-testing.md +56 -56
  88. package/skills/angular-developer/references/effects.md +83 -83
  89. package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
  90. package/skills/angular-developer/references/host-elements.md +80 -80
  91. package/skills/angular-developer/references/injection-context.md +63 -63
  92. package/skills/angular-developer/references/inputs.md +101 -101
  93. package/skills/angular-developer/references/linked-signal.md +59 -59
  94. package/skills/angular-developer/references/loading-strategies.md +61 -61
  95. package/skills/angular-developer/references/mcp.md +108 -108
  96. package/skills/angular-developer/references/navigate-to-routes.md +69 -69
  97. package/skills/angular-developer/references/outputs.md +86 -86
  98. package/skills/angular-developer/references/reactive-forms.md +122 -122
  99. package/skills/angular-developer/references/rendering-strategies.md +44 -44
  100. package/skills/angular-developer/references/resource.md +77 -77
  101. package/skills/angular-developer/references/route-animations.md +56 -56
  102. package/skills/angular-developer/references/route-guards.md +52 -52
  103. package/skills/angular-developer/references/router-lifecycle.md +45 -45
  104. package/skills/angular-developer/references/router-testing.md +87 -87
  105. package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
  106. package/skills/angular-developer/references/signal-forms.md +795 -795
  107. package/skills/angular-developer/references/signals-overview.md +94 -94
  108. package/skills/angular-developer/references/tailwind-css.md +69 -69
  109. package/skills/angular-developer/references/template-driven-forms.md +114 -114
  110. package/skills/angular-developer/references/testing-fundamentals.md +65 -65
  111. package/skills/api-connector-builder/SKILL.md +121 -121
  112. package/skills/api-design/SKILL.md +524 -524
  113. package/skills/architecture-decision-records/SKILL.md +180 -180
  114. package/skills/article-writing/SKILL.md +80 -80
  115. package/skills/automation-audit-ops/SKILL.md +143 -143
  116. package/skills/autonomous-agent-harness/SKILL.md +274 -274
  117. package/skills/autonomous-loops/SKILL.md +611 -611
  118. package/skills/backend-patterns/SKILL.md +562 -562
  119. package/skills/benchmark/SKILL.md +95 -95
  120. package/skills/benchmark-methodology/SKILL.md +191 -191
  121. package/skills/benchmark-optimization-loop/SKILL.md +71 -71
  122. package/skills/blender-motion-state-inspection/SKILL.md +165 -165
  123. package/skills/blueprint/SKILL.md +106 -106
  124. package/skills/brand-discovery/SKILL.md +145 -145
  125. package/skills/brand-discovery/references/10_purpose-why.md +40 -40
  126. package/skills/brand-discovery/references/20_positioning.md +44 -44
  127. package/skills/brand-discovery/references/30_audience-niche.md +52 -52
  128. package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
  129. package/skills/brand-discovery/references/50_voice-tone.md +59 -59
  130. package/skills/brand-discovery/references/60_narrative-story.md +50 -50
  131. package/skills/brand-discovery/references/70_founder-tension.md +49 -49
  132. package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
  133. package/skills/brand-voice/SKILL.md +98 -98
  134. package/skills/brand-voice/references/voice-profile-schema.md +55 -55
  135. package/skills/browser-qa/SKILL.md +105 -105
  136. package/skills/bun-runtime/SKILL.md +85 -85
  137. package/skills/canary-watch/SKILL.md +108 -108
  138. package/skills/carrier-relationship-management/SKILL.md +212 -212
  139. package/skills/cisco-ios-patterns/SKILL.md +164 -164
  140. package/skills/ck/SKILL.md +148 -148
  141. package/skills/claude-devfleet/SKILL.md +112 -112
  142. package/skills/click-path-audit/SKILL.md +245 -245
  143. package/skills/clickhouse-io/SKILL.md +445 -445
  144. package/skills/code-tour/SKILL.md +254 -254
  145. package/skills/codebase-onboarding/SKILL.md +234 -234
  146. package/skills/codehealth-mcp/SKILL.md +167 -167
  147. package/skills/codeply-taste/LICENSE +21 -0
  148. package/skills/codeply-taste/SKILL.md +100 -0
  149. package/skills/codeply-taste/SOURCE.md +10 -0
  150. package/skills/codeply-taste/assets/base.css +100 -0
  151. package/skills/codeply-taste/assets/base.js +37 -0
  152. package/skills/codeply-taste/library/directions.json +1078 -0
  153. package/skills/codeply-taste/library/heroes.json +894 -0
  154. package/skills/codeply-taste/library/motions.json +57 -0
  155. package/skills/codeply-taste/library/palettes.json +1485 -0
  156. package/skills/codeply-taste/library/photos.json +7240 -0
  157. package/skills/codeply-taste/library/sections.json +4136 -0
  158. package/skills/codeply-taste/library/sites.json +7274 -0
  159. package/skills/codeply-taste/library/type.json +2024 -0
  160. package/skills/codeply-taste/references/colour.md +141 -0
  161. package/skills/codeply-taste/references/copy.md +177 -0
  162. package/skills/codeply-taste/references/critique.md +171 -0
  163. package/skills/codeply-taste/references/imagery.md +377 -0
  164. package/skills/codeply-taste/references/layout.md +178 -0
  165. package/skills/codeply-taste/references/motion.md +292 -0
  166. package/skills/codeply-taste/references/research.md +306 -0
  167. package/skills/codeply-taste/references/tells.md +115 -0
  168. package/skills/codeply-taste/references/type.md +170 -0
  169. package/skills/codeply-taste/scripts/assets.mjs +865 -0
  170. package/skills/codeply-taste/scripts/draw.mjs +525 -0
  171. package/skills/codeply-taste/scripts/inspect.mjs +205 -0
  172. package/skills/codeply-taste/scripts/lib/cdp.mjs +205 -0
  173. package/skills/codeply-taste/scripts/lib/probe.mjs +569 -0
  174. package/skills/codeply-taste/scripts/lib/rules.mjs +137 -0
  175. package/skills/codeply-taste/scripts/lint.mjs +108 -0
  176. package/skills/coding-standards/SKILL.md +551 -551
  177. package/skills/competitive-platform-analysis/SKILL.md +214 -214
  178. package/skills/competitive-report-structure/SKILL.md +162 -162
  179. package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
  180. package/skills/config-gc/SKILL.md +120 -120
  181. package/skills/configure-ecc/SKILL.md +385 -385
  182. package/skills/connections-optimizer/SKILL.md +190 -190
  183. package/skills/content-engine/SKILL.md +132 -132
  184. package/skills/content-hash-cache-pattern/SKILL.md +162 -162
  185. package/skills/context-budget/SKILL.md +136 -136
  186. package/skills/continuous-agent-loop/SKILL.md +46 -46
  187. package/skills/continuous-learning/SKILL.md +132 -132
  188. package/skills/continuous-learning-v2/SKILL.md +361 -361
  189. package/skills/contract-first/SKILL.md +287 -287
  190. package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
  191. package/skills/cost-tracking/SKILL.md +97 -97
  192. package/skills/council/SKILL.md +204 -204
  193. package/skills/cpp-coding-standards/SKILL.md +724 -724
  194. package/skills/cpp-testing/SKILL.md +325 -325
  195. package/skills/crosspost/SKILL.md +112 -112
  196. package/skills/csharp-testing/SKILL.md +322 -322
  197. package/skills/customer-billing-ops/SKILL.md +141 -141
  198. package/skills/customs-trade-compliance/SKILL.md +263 -263
  199. package/skills/dart-flutter-patterns/SKILL.md +564 -564
  200. package/skills/dashboard-builder/SKILL.md +109 -109
  201. package/skills/data-scraper-agent/SKILL.md +765 -765
  202. package/skills/data-throughput-accelerator/SKILL.md +74 -74
  203. package/skills/database-migrations/SKILL.md +430 -430
  204. package/skills/deep-research/SKILL.md +160 -160
  205. package/skills/defi-amm-security/SKILL.md +167 -167
  206. package/skills/delivery-gate/SKILL.md +126 -126
  207. package/skills/deployment-patterns/SKILL.md +428 -428
  208. package/skills/design-system/SKILL.md +83 -83
  209. package/skills/django-celery/SKILL.md +458 -458
  210. package/skills/django-patterns/SKILL.md +735 -735
  211. package/skills/django-security/SKILL.md +644 -644
  212. package/skills/django-tdd/SKILL.md +730 -730
  213. package/skills/django-verification/SKILL.md +470 -470
  214. package/skills/dmux-workflows/SKILL.md +192 -192
  215. package/skills/docker-patterns/SKILL.md +365 -365
  216. package/skills/documentation-lookup/SKILL.md +91 -91
  217. package/skills/dotnet-patterns/SKILL.md +322 -322
  218. package/skills/dynamic-workflow-mode/SKILL.md +124 -124
  219. package/skills/e2e-testing/SKILL.md +327 -327
  220. package/skills/ecc-guide/SKILL.md +190 -190
  221. package/skills/ecc-recipes/SKILL.md +149 -149
  222. package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
  223. package/skills/email-ops/SKILL.md +122 -122
  224. package/skills/energy-procurement/SKILL.md +228 -228
  225. package/skills/enterprise-agent-ops/SKILL.md +51 -51
  226. package/skills/error-handling/SKILL.md +377 -377
  227. package/skills/eval-harness/SKILL.md +271 -271
  228. package/skills/evm-token-decimals/SKILL.md +131 -131
  229. package/skills/exa-search/SKILL.md +108 -108
  230. package/skills/fal-ai-media/SKILL.md +289 -289
  231. package/skills/fastapi-patterns/SKILL.md +514 -514
  232. package/skills/finance-billing-ops/SKILL.md +128 -128
  233. package/skills/flox-environments/SKILL.md +497 -497
  234. package/skills/flutter-dart-code-review/SKILL.md +436 -436
  235. package/skills/foundation-models-on-device/SKILL.md +243 -243
  236. package/skills/frontend-a11y/SKILL.md +446 -446
  237. package/skills/frontend-design-direction/SKILL.md +93 -93
  238. package/skills/frontend-patterns/SKILL.md +657 -657
  239. package/skills/frontend-slides/SKILL.md +185 -185
  240. package/skills/fsharp-testing/SKILL.md +281 -281
  241. package/skills/gan-style-harness/SKILL.md +279 -279
  242. package/skills/gateguard/SKILL.md +133 -133
  243. package/skills/generating-python-installer/SKILL.md +820 -820
  244. package/skills/git-workflow/SKILL.md +716 -716
  245. package/skills/github-ops/SKILL.md +145 -145
  246. package/skills/golang-patterns/SKILL.md +676 -676
  247. package/skills/golang-testing/SKILL.md +721 -721
  248. package/skills/google-workspace-ops/SKILL.md +96 -96
  249. package/skills/growth-log/SKILL.md +128 -128
  250. package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
  251. package/skills/healthcare-emr-patterns/SKILL.md +160 -160
  252. package/skills/healthcare-eval-harness/SKILL.md +208 -208
  253. package/skills/healthcare-phi-compliance/SKILL.md +146 -146
  254. package/skills/hermes-imports/SKILL.md +89 -89
  255. package/skills/hexagonal-architecture/SKILL.md +277 -277
  256. package/skills/hipaa-compliance/SKILL.md +79 -79
  257. package/skills/homelab-network-readiness/SKILL.md +170 -170
  258. package/skills/homelab-network-setup/SKILL.md +130 -130
  259. package/skills/homelab-pihole-dns/SKILL.md +275 -275
  260. package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
  261. package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
  262. package/skills/hookify-rules/SKILL.md +128 -128
  263. package/skills/inherit-legacy-style/SKILL.md +157 -157
  264. package/skills/intent-driven-development/SKILL.md +360 -360
  265. package/skills/inventory-demand-planning/SKILL.md +247 -247
  266. package/skills/investor-materials/SKILL.md +97 -97
  267. package/skills/investor-outreach/SKILL.md +92 -92
  268. package/skills/ios-icon-gen/SKILL.md +158 -158
  269. package/skills/iterative-retrieval/SKILL.md +212 -212
  270. package/skills/ito-basket-compare/SKILL.md +64 -64
  271. package/skills/ito-compute/SKILL.md +129 -129
  272. package/skills/ito-data-atlas-agent/SKILL.md +64 -64
  273. package/skills/ito-market-intelligence/SKILL.md +61 -61
  274. package/skills/ito-trade-planner/SKILL.md +68 -68
  275. package/skills/java-coding-standards/SKILL.md +384 -384
  276. package/skills/jira-integration/SKILL.md +303 -303
  277. package/skills/jpa-patterns/SKILL.md +152 -152
  278. package/skills/knowledge-ops/SKILL.md +155 -155
  279. package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
  280. package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
  281. package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
  282. package/skills/kotlin-patterns/SKILL.md +712 -712
  283. package/skills/kotlin-testing/SKILL.md +825 -825
  284. package/skills/kubernetes-patterns/SKILL.md +756 -756
  285. package/skills/laravel-patterns/SKILL.md +416 -416
  286. package/skills/laravel-plugin-discovery/SKILL.md +230 -230
  287. package/skills/laravel-security/SKILL.md +948 -948
  288. package/skills/laravel-tdd/SKILL.md +675 -675
  289. package/skills/laravel-verification/SKILL.md +180 -180
  290. package/skills/latency-critical-systems/SKILL.md +75 -75
  291. package/skills/lead-intelligence/SKILL.md +322 -322
  292. package/skills/liquid-glass-design/SKILL.md +279 -279
  293. package/skills/llm-trading-agent-security/SKILL.md +147 -147
  294. package/skills/logistics-exception-management/SKILL.md +222 -222
  295. package/skills/loop-design-check/SKILL.md +143 -143
  296. package/skills/mailtrap-email-integration/SKILL.md +77 -77
  297. package/skills/make-interfaces-feel-better/SKILL.md +152 -152
  298. package/skills/manim-video/SKILL.md +90 -90
  299. package/skills/market-research/SKILL.md +76 -76
  300. package/skills/marketing-campaign/SKILL.md +114 -114
  301. package/skills/mcp-server-patterns/SKILL.md +70 -70
  302. package/skills/messages-ops/SKILL.md +105 -105
  303. package/skills/ml-adoption-playbook/SKILL.md +57 -57
  304. package/skills/mle-workflow/SKILL.md +348 -348
  305. package/skills/motion-advanced/SKILL.md +596 -596
  306. package/skills/motion-foundations/SKILL.md +299 -299
  307. package/skills/motion-patterns/SKILL.md +434 -434
  308. package/skills/motion-ui/SKILL.md +576 -576
  309. package/skills/mysql-patterns/SKILL.md +413 -413
  310. package/skills/nanoclaw-repl/SKILL.md +34 -34
  311. package/skills/nestjs-patterns/SKILL.md +231 -231
  312. package/skills/netmiko-ssh-automation/SKILL.md +174 -174
  313. package/skills/network-bgp-diagnostics/SKILL.md +168 -168
  314. package/skills/network-config-validation/SKILL.md +211 -211
  315. package/skills/network-interface-health/SKILL.md +153 -153
  316. package/skills/nextjs-turbopack/SKILL.md +58 -58
  317. package/skills/nodejs-keccak256/SKILL.md +103 -103
  318. package/skills/nutrient-document-processing/SKILL.md +168 -168
  319. package/skills/nuxt4-patterns/SKILL.md +101 -101
  320. package/skills/openclaw-persona-forge/SKILL.md +289 -289
  321. package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
  322. package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
  323. package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
  324. package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
  325. package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
  326. package/skills/openclaw-persona-forge/references/output-template.md +166 -166
  327. package/skills/opensource-pipeline/SKILL.md +256 -256
  328. package/skills/orch-add-feature/SKILL.md +45 -45
  329. package/skills/orch-build-mvp/SKILL.md +49 -49
  330. package/skills/orch-change-feature/SKILL.md +43 -43
  331. package/skills/orch-fix-defect/SKILL.md +43 -43
  332. package/skills/orch-pipeline/SKILL.md +121 -121
  333. package/skills/orch-refine-code/SKILL.md +44 -44
  334. package/skills/parallel-execution-optimizer/SKILL.md +74 -74
  335. package/skills/perl-patterns/SKILL.md +505 -505
  336. package/skills/perl-security/SKILL.md +504 -504
  337. package/skills/perl-testing/SKILL.md +476 -476
  338. package/skills/plan-canvas/SKILL.md +153 -153
  339. package/skills/plan-orchestrate/SKILL.md +263 -263
  340. package/skills/plankton-code-quality/SKILL.md +237 -237
  341. package/skills/postgres-patterns/SKILL.md +148 -148
  342. package/skills/prediction-market-oracle-research/SKILL.md +64 -64
  343. package/skills/prediction-market-risk-review/SKILL.md +61 -61
  344. package/skills/premium-web-design/SKILL.md +154 -154
  345. package/skills/prisma-patterns/SKILL.md +401 -401
  346. package/skills/product-capability/SKILL.md +142 -142
  347. package/skills/product-lens/SKILL.md +93 -93
  348. package/skills/production-audit/SKILL.md +207 -207
  349. package/skills/production-scheduling/SKILL.md +238 -238
  350. package/skills/project-flow-ops/SKILL.md +112 -112
  351. package/skills/prompt-optimizer/SKILL.md +398 -398
  352. package/skills/python-patterns/SKILL.md +751 -751
  353. package/skills/python-testing/SKILL.md +817 -817
  354. package/skills/pytorch-patterns/SKILL.md +397 -397
  355. package/skills/quality-nonconformance/SKILL.md +260 -260
  356. package/skills/quarkus-patterns/SKILL.md +723 -723
  357. package/skills/quarkus-security/SKILL.md +468 -468
  358. package/skills/quarkus-tdd/SKILL.md +812 -812
  359. package/skills/quarkus-verification/SKILL.md +480 -480
  360. package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
  361. package/skills/react-native-patterns/SKILL.md +326 -326
  362. package/skills/react-patterns/SKILL.md +342 -342
  363. package/skills/react-performance/SKILL.md +575 -575
  364. package/skills/react-testing/SKILL.md +424 -424
  365. package/skills/recsys-pipeline-architect/SKILL.md +115 -115
  366. package/skills/recursive-decision-ledger/SKILL.md +81 -81
  367. package/skills/redis-patterns/SKILL.md +404 -404
  368. package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
  369. package/skills/remotion-video-creation/SKILL.md +43 -43
  370. package/skills/repo-scan/SKILL.md +79 -79
  371. package/skills/research-ops/SKILL.md +113 -113
  372. package/skills/returns-reverse-logistics/SKILL.md +240 -240
  373. package/skills/rules-distill/SKILL.md +265 -265
  374. package/skills/rust-patterns/SKILL.md +500 -500
  375. package/skills/rust-testing/SKILL.md +501 -501
  376. package/skills/safety-guard/SKILL.md +76 -76
  377. package/skills/santa-method/SKILL.md +307 -307
  378. package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
  379. package/skills/scientific-db-uspto-database/SKILL.md +178 -178
  380. package/skills/scientific-pkg-gget/SKILL.md +167 -167
  381. package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
  382. package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
  383. package/skills/search-first/SKILL.md +183 -183
  384. package/skills/security-bounty-hunter/SKILL.md +100 -100
  385. package/skills/security-review/SKILL.md +504 -504
  386. package/skills/security-scan/SKILL.md +166 -166
  387. package/skills/seo/SKILL.md +155 -155
  388. package/skills/skill-comply/SKILL.md +59 -59
  389. package/skills/skill-scout/SKILL.md +141 -141
  390. package/skills/skill-stocktake/SKILL.md +195 -195
  391. package/skills/social-graph-ranker/SKILL.md +155 -155
  392. package/skills/social-publisher/SKILL.md +130 -130
  393. package/skills/springboot-patterns/SKILL.md +315 -315
  394. package/skills/springboot-security/SKILL.md +273 -273
  395. package/skills/springboot-tdd/SKILL.md +159 -159
  396. package/skills/springboot-verification/SKILL.md +232 -232
  397. package/skills/strategic-compact/SKILL.md +142 -142
  398. package/skills/swift-actor-persistence/SKILL.md +144 -144
  399. package/skills/swift-concurrency-6-2/SKILL.md +216 -216
  400. package/skills/swift-protocol-di-testing/SKILL.md +191 -191
  401. package/skills/swiftui-patterns/SKILL.md +259 -259
  402. package/skills/taste/SKILL.md +264 -264
  403. package/skills/taste/references/genre-taxonomy.md +87 -87
  404. package/skills/tdd-workflow/SKILL.md +583 -583
  405. package/skills/team-agent-orchestration/SKILL.md +111 -111
  406. package/skills/team-builder/SKILL.md +169 -169
  407. package/skills/terminal-ops/SKILL.md +110 -110
  408. package/skills/tinystruct-patterns/SKILL.md +279 -279
  409. package/skills/tinystruct-patterns/references/architecture.md +90 -90
  410. package/skills/tinystruct-patterns/references/data-handling.md +60 -60
  411. package/skills/tinystruct-patterns/references/database.md +99 -99
  412. package/skills/tinystruct-patterns/references/routing.md +64 -64
  413. package/skills/tinystruct-patterns/references/system-usage.md +97 -97
  414. package/skills/tinystruct-patterns/references/testing.md +72 -72
  415. package/skills/token-budget-advisor/SKILL.md +134 -134
  416. package/skills/ui-demo/SKILL.md +466 -466
  417. package/skills/ui-to-vue/SKILL.md +135 -135
  418. package/skills/uncloud/SKILL.md +344 -344
  419. package/skills/unified-memory/SKILL.md +170 -170
  420. package/skills/unified-notifications-ops/SKILL.md +188 -188
  421. package/skills/verification-loop/SKILL.md +129 -129
  422. package/skills/video-editing/SKILL.md +311 -311
  423. package/skills/videodb/SKILL.md +375 -375
  424. package/skills/visa-doc-translate/SKILL.md +117 -117
  425. package/skills/vite-patterns/SKILL.md +450 -450
  426. package/skills/vue-patterns/SKILL.md +471 -471
  427. package/skills/windows-desktop-e2e/SKILL.md +888 -888
  428. package/skills/workspace-surface-audit/SKILL.md +126 -126
  429. package/skills/x-api/SKILL.md +235 -235
package/lib/tui.mjs CHANGED
@@ -1,1086 +1,1459 @@
1
- /**
2
- * Codeply TUI - Google-brand interactive shell.
3
- *
4
- * Rendering model (important):
5
- * Everything that is *finished* (banner + past messages + tool activity) is
6
- * emitted through Ink's <Static>, which writes each item to stdout exactly
7
- * once and never touches it again. Only the live area - spinner, approval
8
- * prompt, command palette, composer, hints - lives in the re-rendered frame.
9
- *
10
- * This is what kills the flicker/duplicate-screen bug: Ink redraws a frame by
11
- * erasing the previous one, but it can only erase lines still on screen. Once
12
- * the frame grows taller than the terminal, the erase is incomplete and the
13
- * frame gets appended instead of replaced - you see the conversation twice.
14
- * Keeping the live frame a handful of lines tall makes the redraw exact.
15
- *
16
- * Rule for future edits: nothing tall goes outside <Static>.
17
- *
18
- * ┌ scrollback (banner + conversation, via <Static>) ──┐
19
- * ├ live frame ────────────────────────────────────────┤
20
- * │ spinner · approval · palette · composer · hints │
21
- * └────────────────────────────────────────────────────┘
22
- */
23
- import React from 'react';
24
- import { Box, Text, Static, useInput, useApp, useStdout } from 'ink';
25
- import Spinner from 'ink-spinner';
26
- import { createRequire } from 'module';
27
- import path from 'path';
28
- import os from 'os';
29
- import fs from 'fs';
30
-
31
- const require = createRequire(import.meta.url);
32
- const auth = require('../lib/auth.js');
33
- const appConfig = require('../lib/config.js');
34
- const skills = require('../lib/skills.js');
35
- const VERSION = require('../package.json').version;
36
- import {
37
- renderMarkdown, Panel, hexToAnsi, hexToAnsiBg, fillRow, SURFACE,
38
- ANSI_RESET, ANSI_BOLD, ANSI_DIM,
39
- } from '../lib/render-md.mjs';
40
- import { runAgent } from '../lib/agent.mjs';
41
- import { TOOL_DISPLAY } from '../lib/tools.mjs';
42
- import TextArea, { enableModifiedEnter, isTerminalReport } from '../lib/textarea.mjs';
43
-
44
- // ─── Theme (Google brand) ───────────────────────────────────────────────────
45
- const G = {
46
- blue: '#4285F4',
47
- red: '#EA4335',
48
- yellow: '#FBBC05',
49
- green: '#34A853',
50
- };
51
-
52
- const T = {
53
- accent: G.blue,
54
- text: '#e8eaed',
55
- secondary: '#9aa0a6',
56
- dim: '#80868b',
57
- muted: '#5f6368',
58
- faint: '#3c4043',
59
- border: '#3c4043',
60
- cycle: [G.blue, G.red, G.yellow, G.green],
61
- };
62
-
63
- // The mascot's own colour, from mascot.png - the home screen's box border and
64
- // headings pick this up too, so the splash reads as one themed piece instead
65
- // of the mascot looking like a sticker dropped on top of an unrelated blue box.
66
- const MASCOT_ORANGE = '#E8821E';
67
-
68
- const MODES = ['Build', 'Plan', 'Ask'];
69
- const MODE_COLOR = { Build: G.blue, Plan: G.yellow, Ask: G.green };
70
- const MODE_HINT = {
71
- Build: 'reads, edits and verifies files',
72
- Plan: 'investigates, then writes a plan',
73
- Ask: 'answers from the codebase, read-only',
74
- };
75
-
76
- // CODEPLY block letters - fixed per-letter widths, one Google color each.
77
- // C=blue O=red D=yellow E=green P=blue L=red Y=yellow
78
- const LOGO_SEGMENTS = [
79
- [
80
- { t: ' ██████╗ ', c: G.blue }, { t: ' ██████╗ ', c: G.red },
81
- { t: '██████╗ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
82
- { t: '██████╗ ', c: G.blue }, { t: '██╗ ', c: G.red },
83
- { t: '██╗ ██╗', c: G.yellow },
84
- ],
85
- [
86
- { t: '██╔════╝ ', c: G.blue }, { t: '██╔═══██╗', c: G.red },
87
- { t: '██╔══██╗ ', c: G.yellow }, { t: '██╔════╝ ', c: G.green },
88
- { t: '██╔══██╗ ', c: G.blue }, { t: '██║ ', c: G.red },
89
- { t: '╚██╗ ██╔╝', c: G.yellow },
90
- ],
91
- [
92
- { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
93
- { t: '██║ ██║ ', c: G.yellow }, { t: '█████╗ ', c: G.green },
94
- { t: '██████╔╝ ', c: G.blue }, { t: '██║ ', c: G.red },
95
- { t: ' ╚████╔╝ ', c: G.yellow },
96
- ],
97
- [
98
- { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
99
- { t: '██║ ██║ ', c: G.yellow }, { t: '██╔══╝ ', c: G.green },
100
- { t: '██╔═══╝ ', c: G.blue }, { t: '██║ ', c: G.red },
101
- { t: ' ╚██╔╝ ', c: G.yellow },
102
- ],
103
- [
104
- { t: '╚██████╗ ', c: G.blue }, { t: '╚██████╔╝', c: G.red },
105
- { t: '██████╔╝ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
106
- { t: '██║ ', c: G.blue }, { t: '███████╗ ', c: G.red },
107
- { t: ' ██║ ', c: G.yellow },
108
- ],
109
- [
110
- { t: ' ╚═════╝ ', c: G.blue }, { t: ' ╚═════╝ ', c: G.red },
111
- { t: '╚═════╝ ', c: G.yellow }, { t: '╚══════╝ ', c: G.green },
112
- { t: '╚═╝ ', c: G.blue }, { t: '╚══════╝ ', c: G.red },
113
- { t: ' ╚═╝ ', c: G.yellow },
114
- ],
115
- ];
116
-
117
- // Pre-flattened to one ANSI string per row: 6 <Text> nodes instead of 42.
118
- const LOGO_ROWS = LOGO_SEGMENTS.map((row) =>
119
- row.map((s) => hexToAnsi(s.c) + ANSI_BOLD + s.t + ANSI_RESET).join('')
120
- );
121
- const LOGO_WIDTH = LOGO_SEGMENTS[0].reduce((n, s) => n + s.t.length, 0);
122
-
123
- const WORDMARK = hexToAnsi(G.yellow) + ANSI_BOLD + 'codeply' + ANSI_RESET;
124
-
125
- const COMMANDS = [
126
- { name: '/login', description: 'Sign in with your Codeply account' },
127
- { name: '/logout', description: 'Sign out of current session' },
128
- { name: '/whoami', description: 'Show currently signed-in account' },
129
- { name: '/help', description: 'Show available commands' },
130
- { name: '/clear', description: 'Clear conversation and context' },
131
- { name: '/mode', description: 'Cycle Build → Plan → Ask' },
132
- { name: '/provider', description: 'Show or switch model provider (BYOK: openrouter, groq, anthropic, openai)' },
133
- { name: '/skill', description: 'List, install, or remove agent skills' },
134
- { name: '/keys', description: 'Show what your terminal sends for a key' },
135
- { name: '/quit', description: 'Exit Codeply' },
136
- ];
137
-
138
- const TIPS = [
139
- ['ctrl+j', 'inserts a new line - or end a line with \\ and press enter'],
140
- ['Press tab', 'to cycle Build → Plan → Ask without leaving the composer'],
141
- ['Press esc', 'while it is working to interrupt the run'],
142
- ['Ask it to fix a failing test', 'it can run commands and iterate on the output'],
143
- ['Run /keys', 'to see exactly what your terminal sends for a key'],
144
- ];
145
-
146
- const WHATS_NEW = [
147
- 'Verification gate - the agent now has to check its changes (and can no longer claim tests or deploys it never ran) before it finishes',
148
- 'Added shift+tab to toggle bypass permissions without leaving the composer',
149
- '/skill install now accepts a bare GitHub owner/repo, not just a full URL',
150
- ];
151
-
152
- // A tiny pixel mascot echoing mascot.png's blocky orange creature - pointy
153
- // ear notches, a solid head, two eyes, comb-like legs. The terminal's own
154
- // background already stands in for every "black" pixel in the source art, so
155
- // only the orange pixels need marking; everything else is a plain space.
156
- const MASCOT_ROWS = [
157
- ' █ █ ',
158
- '████████',
159
- '██ █ ███',
160
- '████████',
161
- '█ █ █ █',
162
- ];
163
-
164
- function homePath(cwd) {
165
- const home = os.homedir();
166
- if (cwd.startsWith(home)) return '~' + cwd.slice(home.length);
167
- return cwd;
168
- }
169
-
170
- /** Shorten a path from the left so it never blows out the footer. */
171
- function fitPath(p, max) {
172
- if (p.length <= max) return p;
173
- return '…' + p.slice(p.length - max + 1);
174
- }
175
-
176
- // ─── Home screen ────────────────────────────────────────────────────────────
177
- //
178
- // A single bordered box - title baked into the top border, a left column
179
- // (identity: mascot, account, mode, cwd) and a right column (tips / what's
180
- // new) split by one interior divider that runs the full height. Everything
181
- // is pre-flattened to one ANSI string per row, same discipline as the rest of
182
- // this file: a handful of <Text> nodes, not one per glyph.
183
-
184
- const BOX_MIN_COLS = 74; // below this, two columns can't breathe - fall back to the compact mark
185
-
186
- /** Pad/truncate plain text to exactly `width` visible columns, then colour it. */
187
- function cell(text, width, color, opts = {}) {
188
- const t = String(text ?? '');
189
- const fit = t.length > width ? (width > 1 ? t.slice(0, width - 1) + '…' : t.slice(0, width)) : t;
190
- const pad = ' '.repeat(Math.max(0, width - fit.length));
191
- const styled = (color ? hexToAnsi(color) : '') + (opts.bold ? ANSI_BOLD : '') + (opts.dim ? ANSI_DIM : '') + fit + ANSI_RESET;
192
- return styled + pad;
193
- }
194
-
195
- /** Centre plain text within `width` columns before it goes through cell(). */
196
- function centered(text, width) {
197
- const t = String(text ?? '');
198
- if (t.length >= width) return t;
199
- const left = Math.floor((width - t.length) / 2);
200
- return ' '.repeat(left) + t;
201
- }
202
-
203
- /** Shared by BoxHome and its caller - the caller needs leftInner/rightInner
204
- * up front to centre text at the right width before the rows are built. */
205
- function boxHomeMetrics(cols) {
206
- const boxWidth = Math.max(60, Math.min(cols - 2, 96));
207
- const leftColW = Math.round(boxWidth * 0.44);
208
- const rightColW = boxWidth - leftColW - 3; // 3 = two side borders + one divider
209
- return { boxWidth, leftColW, rightColW, leftInner: leftColW - 2, rightInner: rightColW - 2 };
210
- }
211
-
212
- function BoxHome({ cols, title, left, right, borderColor }) {
213
- const { boxWidth, leftInner, rightInner } = boxHomeMetrics(cols);
214
- const rowCount = Math.max(left.length, right.length);
215
- const border = hexToAnsi(borderColor);
216
-
217
- const topPrefix = `── ${title} `;
218
- const topDashes = Math.max(0, boxWidth - topPrefix.length - 2);
219
- const top = border + '┌' + topPrefix + '─'.repeat(topDashes) + '┐' + ANSI_RESET;
220
- const bottom = border + '└' + '─'.repeat(boxWidth - 2) + '┘' + ANSI_RESET;
221
-
222
- const rows = [];
223
- for (let i = 0; i < rowCount; i++) {
224
- const l = left[i] || { text: '' };
225
- const r = right[i] || { text: '' };
226
- rows.push(
227
- border + '│' + ANSI_RESET + ' ' +
228
- cell(l.text, leftInner, l.color, l) +
229
- ' ' + border + '│' + ANSI_RESET + ' ' +
230
- cell(r.text, rightInner, r.color, r) +
231
- ' ' + border + '│' + ANSI_RESET
232
- );
233
- }
234
-
235
- return React.createElement(
236
- Box,
237
- { flexDirection: 'column' },
238
- React.createElement(Text, { key: 'top' }, top),
239
- ...rows.map((row, i) => React.createElement(Text, { key: `r${i}` }, row)),
240
- React.createElement(Text, { key: 'bot' }, bottom)
241
- );
242
- }
243
-
244
- function CompactMark({ cols }) {
245
- const wide = cols >= LOGO_WIDTH + 4;
246
- return React.createElement(
247
- Box,
248
- { flexDirection: 'column', width: cols, alignItems: 'center' },
249
- wide
250
- ? LOGO_ROWS.map((row, i) => React.createElement(Text, { key: `lg-${i}` }, row))
251
- : React.createElement(Text, { key: 'lg' }, WORDMARK),
252
- React.createElement(
253
- Box,
254
- { key: 'tag', marginTop: 1, flexDirection: 'column', alignItems: 'center' },
255
- React.createElement(
256
- Text,
257
- null,
258
- hexToAnsi(T.secondary) + 'An agent that reads your code, edits it, and checks its work.' + ANSI_RESET
259
- ),
260
- React.createElement(
261
- Text,
262
- null,
263
- hexToAnsi(T.muted) + 'interactive shell' + ANSI_RESET +
264
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
265
- hexToAnsi(T.muted) + 'v' + VERSION + ANSI_RESET
266
- )
267
- )
268
- );
269
- }
270
-
271
- // ─── Message blocks ─────────────────────────────────────────────────────────
272
-
273
- /** One line of tool activity in the transcript. */
274
- function ToolLine({ msg }) {
275
- const display = TOOL_DISPLAY[msg.name] || { verb: msg.name, icon: '▸' };
276
- const failed = !msg.ok;
277
- const wrote = msg.name === 'write_file' || msg.name === 'edit_file';
278
- const ran = msg.name === 'run';
279
-
280
- const iconColor = failed ? G.red : wrote ? G.yellow : ran ? G.green : G.blue;
281
- const detail = [msg.label, msg.detail].filter(Boolean).join(' · ');
282
-
283
- return React.createElement(
284
- Text,
285
- { wrap: 'truncate-end' },
286
- hexToAnsi(iconColor) + display.icon + ' ' + ANSI_RESET +
287
- hexToAnsi(failed ? G.red : T.secondary) + display.verb.padEnd(6) + ANSI_RESET +
288
- hexToAnsi(T.dim) + ' ' + detail + ANSI_RESET
289
- );
290
- }
291
-
292
- function MessageBlock({ msg, cols }) {
293
- if (msg.role === 'user') {
294
- return React.createElement(
295
- Box,
296
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
297
- React.createElement(Text, { color: G.blue }, '▌'),
298
- React.createElement(
299
- Box,
300
- { flexDirection: 'column', flexGrow: 1 },
301
- React.createElement(Text, null, hexToAnsi(T.dim) + ANSI_DIM + 'you' + ANSI_RESET),
302
- React.createElement(Text, { color: T.text, wrap: 'wrap' }, msg.content)
303
- )
304
- );
305
- }
306
-
307
- if (msg.role === 'assistant') {
308
- // A plan is a deliverable, not chatter - give it its own panel so it stands
309
- // apart from the running commentary above and below it.
310
- if (msg.plan) {
311
- return React.createElement(
312
- Box,
313
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
314
- React.createElement(Text, { color: G.yellow }, '▌'),
315
- React.createElement(
316
- Box,
317
- { flexDirection: 'column', flexGrow: 1 },
318
- React.createElement(Panel, {
319
- title: 'plan',
320
- lines: msg.content.split('\n'),
321
- width: cols - 4,
322
- accent: G.yellow,
323
- })
324
- )
325
- );
326
- }
327
- return React.createElement(
328
- Box,
329
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
330
- React.createElement(Text, { color: G.green }, '▌'),
331
- React.createElement(
332
- Box,
333
- { flexDirection: 'column', flexGrow: 1 },
334
- React.createElement(Text, null, WORDMARK),
335
- ...renderMarkdown(msg.content, cols - 4)
336
- )
337
- );
338
- }
339
-
340
- if (msg.role === 'tool') {
341
- return React.createElement(
342
- Box,
343
- { flexDirection: 'row', marginBottom: 0, paddingLeft: 2 },
344
- React.createElement(ToolLine, { msg })
345
- );
346
- }
347
-
348
- if (msg.role === 'system') {
349
- return React.createElement(
350
- Box,
351
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
352
- React.createElement(Text, { color: G.yellow }, '◆'),
353
- React.createElement(Text, { color: T.secondary, wrap: 'wrap' }, msg.content)
354
- );
355
- }
356
-
357
- if (msg.role === 'error') {
358
- return React.createElement(
359
- Box,
360
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
361
- React.createElement(Text, { color: G.red }, '✗'),
362
- React.createElement(Text, { color: G.red, wrap: 'wrap' }, msg.content)
363
- );
364
- }
365
-
366
- return null;
367
- }
368
-
369
- // ─── Command palette ────────────────────────────────────────────────────────
370
-
371
- function CommandsMenu({ commands, selectedIndex }) {
372
- return React.createElement(
373
- Box,
374
- {
375
- flexDirection: 'column',
376
- borderStyle: 'round',
377
- borderColor: T.border,
378
- paddingX: 1,
379
- marginBottom: 1,
380
- flexShrink: 0,
381
- },
382
- React.createElement(
383
- Text,
384
- null,
385
- hexToAnsi(T.muted) + '↑↓' + ANSI_RESET + hexToAnsi(T.faint) + ' select ' + ANSI_RESET +
386
- hexToAnsi(T.muted) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' complete ' + ANSI_RESET +
387
- hexToAnsi(T.muted) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' dismiss' + ANSI_RESET
388
- ),
389
- ...commands.map((cmd, i) => {
390
- const selected = i === selectedIndex;
391
- const nameColor = selected ? T.cycle[i % T.cycle.length] : T.secondary;
392
- return React.createElement(
393
- Text,
394
- { key: cmd.name, wrap: 'truncate-end' },
395
- (selected ? hexToAnsi(G.blue) + '› ' + ANSI_RESET : ' ') +
396
- hexToAnsi(nameColor) + (selected ? ANSI_BOLD : '') + cmd.name.padEnd(10) + ANSI_RESET +
397
- hexToAnsi(selected ? T.dim : T.faint) + ' ' + cmd.description + ANSI_RESET
398
- );
399
- })
400
- );
401
- }
402
-
403
- // ─── Approval prompt ────────────────────────────────────────────────────────
404
-
405
- function ApprovalPrompt({ request }) {
406
- const accent = request.danger ? G.red : G.yellow;
407
- const lines = [];
408
-
409
- if (request.diff) {
410
- const removed = request.diff.search.split('\n').slice(0, 6);
411
- const added = request.diff.replace.split('\n').slice(0, 6);
412
- for (const l of removed) lines.push(hexToAnsi(G.red) + '- ' + l + ANSI_RESET);
413
- for (const l of added) lines.push(hexToAnsi(G.green) + '+ ' + l + ANSI_RESET);
414
- }
415
-
416
- return React.createElement(
417
- Box,
418
- {
419
- flexDirection: 'column',
420
- borderStyle: 'round',
421
- borderColor: accent,
422
- paddingX: 1,
423
- marginBottom: 1,
424
- flexShrink: 0,
425
- },
426
- React.createElement(
427
- Text,
428
- { wrap: 'truncate-end' },
429
- hexToAnsi(accent) + ANSI_BOLD + request.title + ANSI_RESET +
430
- (request.danger ? hexToAnsi(G.red) + ' ⚠ looks destructive' + ANSI_RESET : '')
431
- ),
432
- request.detail && React.createElement(
433
- Text,
434
- { wrap: 'truncate-end' },
435
- hexToAnsi(T.secondary) + request.detail + ANSI_RESET
436
- ),
437
- ...lines.map((l, i) => React.createElement(Text, { key: i, wrap: 'truncate-end' }, l)),
438
- React.createElement(
439
- Text,
440
- null,
441
- hexToAnsi(G.green) + 'y' + ANSI_RESET + hexToAnsi(T.faint) + ' approve ' + ANSI_RESET +
442
- hexToAnsi(G.blue) + 'a' + ANSI_RESET + hexToAnsi(T.faint) + ' approve all this session ' + ANSI_RESET +
443
- hexToAnsi(G.red) + 'n' + ANSI_RESET + hexToAnsi(T.faint) + ' decline' + ANSI_RESET
444
- )
445
- );
446
- }
447
-
448
- // ─── Status bar ─────────────────────────────────────────────────────────────
449
-
450
- /** Permission-bypass state and the running-agent count, pinned bottom-left. */
451
- function StatusBar({ bypassMode, agentCount }) {
452
- const glyphColor = bypassMode ? G.yellow : T.muted;
453
- return React.createElement(
454
- Text,
455
- { wrap: 'truncate-end' },
456
- hexToAnsi(glyphColor) + ANSI_BOLD + '⏵⏵ ' + ANSI_RESET +
457
- hexToAnsi(bypassMode ? G.yellow : T.secondary) + `bypass permissions ${bypassMode ? 'on' : 'off'}` + ANSI_RESET +
458
- hexToAnsi(T.faint) + ' (shift+tab to cycle)' + ANSI_RESET +
459
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
460
- hexToAnsi(T.muted) + '← ' + agentCount + (agentCount === 1 ? ' agent' : ' agents') + ANSI_RESET
461
- );
462
- }
463
-
464
- // ─── App ────────────────────────────────────────────────────────────────────
465
-
466
- const App = () => {
467
- const { exit } = useApp();
468
- const { stdout } = useStdout();
469
- const [cols, setCols] = React.useState(stdout?.columns || 80);
470
- const [rows, setRows] = React.useState(stdout?.rows || 24);
471
-
472
- const [input, setInput] = React.useState('');
473
- const [modeIndex, setModeIndex] = React.useState(0);
474
- const [session, setSession] = React.useState(null);
475
- const [running, setRunning] = React.useState(false);
476
- const [statusLine, setStatusLine] = React.useState('');
477
- const [elapsed, setElapsed] = React.useState(0);
478
- const [steps, setSteps] = React.useState(0);
479
- const [chatHistory, setChatHistory] = React.useState([]);
480
- const [staticKey, setStaticKey] = React.useState(0);
481
- const [showCommands, setShowCommands] = React.useState(false);
482
- const [selectedCommand, setSelectedCommand] = React.useState(0);
483
- const [filteredCommands, setFilteredCommands] = React.useState(COMMANDS);
484
- const [escArmed, setEscArmed] = React.useState(false);
485
- const [approval, setApproval] = React.useState(null);
486
- const [bypassMode, setBypassMode] = React.useState(false);
487
- const [activeAgents, setActiveAgents] = React.useState([]); // [{id,label}] - running subagents, this session's own run excluded
488
- // Only true once the terminal answers our capability query. Windows Terminal
489
- // never does, so there we advertise ctrl+j instead of lying about shift+enter.
490
- const [modifiedEnterWorks, setModifiedEnterWorks] = React.useState(false);
491
- const [keyProbe, setKeyProbe] = React.useState(false);
492
-
493
- const nextId = React.useRef(1);
494
- // A real AbortController, not a plain {aborted} object: its .signal ends up
495
- // handed straight to fetch() (see ai.js), which requires an actual
496
- // AbortSignal instance - a look-alike object throws a TypeError there
497
- // ("Expected signal ... to be an instance of AbortSignal"), and that
498
- // message contains the word "AbortSignal", which the fetch error classifier
499
- // then mistakes for a real abort. Net effect: every single turn "aborted"
500
- // immediately, before the model was ever called.
501
- const abortRef = React.useRef(new AbortController());
502
- const alwaysAllow = React.useRef(new Set());
503
- const convo = React.useRef([]); // {role,content} pairs handed to the agent
504
-
505
- const cwd = process.cwd();
506
- const mode = MODES[modeIndex];
507
- const modeColor = MODE_COLOR[mode];
508
- const tip = React.useMemo(() => TIPS[Math.floor(Math.random() * TIPS.length)], []);
509
-
510
- const push = React.useCallback((msg) => {
511
- setChatHistory((prev) => [...prev, { id: `m-${nextId.current++}`, ...msg }]);
512
- }, []);
513
-
514
- React.useEffect(() => {
515
- auth.getSession().then((s) => setSession(s)).catch(() => {});
516
- }, []);
517
-
518
- // Ask the terminal to disambiguate shift+enter from enter (see textarea.mjs).
519
- React.useEffect(() => enableModifiedEnter(stdout), [stdout]);
520
-
521
- React.useEffect(() => {
522
- if (!stdout) return;
523
- const onResize = () => { setCols(stdout.columns || 80); setRows(stdout.rows || 24); };
524
- stdout.on('resize', onResize);
525
- return () => stdout.off('resize', onResize);
526
- }, [stdout]);
527
-
528
- React.useEffect(() => {
529
- if (input.startsWith('/') && !input.includes('\n')) {
530
- const query = input.toLowerCase().split(/\s/)[0];
531
- const filtered = COMMANDS.filter((cmd) => cmd.name.startsWith(query));
532
- setFilteredCommands(filtered);
533
- setShowCommands(filtered.length > 0);
534
- setSelectedCommand(0);
535
- } else {
536
- setShowCommands(false);
537
- }
538
- }, [input]);
539
-
540
- React.useEffect(() => {
541
- if (!escArmed) return;
542
- const t = setTimeout(() => setEscArmed(false), 1500);
543
- return () => clearTimeout(t);
544
- }, [escArmed]);
545
-
546
- // Elapsed counter while the agent works. The spinner already repaints the
547
- // live frame several times a second, so this ticker costs nothing extra.
548
- React.useEffect(() => {
549
- if (!running) { setElapsed(0); return; }
550
- const startedAt = Date.now();
551
- const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
552
- return () => clearInterval(t);
553
- }, [running]);
554
-
555
- // ── approval bridge: tools await this promise, the keypress handler settles it ──
556
- const requestApproval = React.useCallback((request) => {
557
- if (bypassModeRef.current) return Promise.resolve('always');
558
- if (alwaysAllow.current.has(request.tool)) return Promise.resolve('always');
559
- return new Promise((resolve) => {
560
- setApproval({ request, resolve });
561
- });
562
- }, []);
563
-
564
- // Read inside the approval callback without re-creating it on every toggle -
565
- // requestApproval is captured once per run and handed down through several
566
- // layers of nested subagent calls, so it must see the current value, not a
567
- // stale one closed over when the run started.
568
- const bypassModeRef = React.useRef(false);
569
- React.useEffect(() => { bypassModeRef.current = bypassMode; }, [bypassMode]);
570
-
571
- // ── subagent bridge: nested runs report in here so the status bar can show them ──
572
- const onSubagentEvent = React.useCallback((evt) => {
573
- if (evt.type === 'start') {
574
- setActiveAgents((prev) => [...prev, { id: evt.id, label: evt.label }]);
575
- } else if (evt.type === 'end') {
576
- setActiveAgents((prev) => prev.filter((a) => a.id !== evt.id));
577
- }
578
- }, []);
579
-
580
- const settleApproval = React.useCallback((verdict) => {
581
- setApproval((current) => {
582
- if (!current) return null;
583
- if (verdict === 'always') alwaysAllow.current.add(current.request.tool);
584
- current.resolve(verdict);
585
- return null;
586
- });
587
- }, []);
588
-
589
- useInput((char, key) => {
590
- // The terminal's answer to our kitty query - proof that modified enter is
591
- // really distinguishable here. Swallow it so it never reaches the draft.
592
- if (isTerminalReport(char)) { setModifiedEnterWorks(true); return; }
593
-
594
- // /keys diagnostic: report exactly what the terminal sent for one keypress.
595
- if (keyProbe) {
596
- setKeyProbe(false);
597
- const seq = JSON.stringify(char);
598
- const flags = ['ctrl', 'shift', 'meta', 'return', 'tab', 'escape', 'backspace', 'delete']
599
- .filter((f) => key[f]).join(', ') || 'none';
600
- push({ role: 'system', content: `Key sent: ${seq}\nFlags: ${flags}` });
601
- return;
602
- }
603
-
604
- // The approval prompt owns the keyboard while it is up.
605
- if (approval) {
606
- if (char === 'y' || key.return) { settleApproval('once'); return; }
607
- if (char === 'a') { settleApproval('always'); return; }
608
- if (char === 'n' || key.escape) { settleApproval('reject'); return; }
609
- return;
610
- }
611
-
612
- if (key.escape) {
613
- if (running) { abortRef.current.abort(); setStatusLine('interrupting…'); return; }
614
- if (showCommands) { setShowCommands(false); return; }
615
- if (input.length > 0) {
616
- if (escArmed) { setInput(''); setEscArmed(false); }
617
- else { setEscArmed(true); }
618
- return;
619
- }
620
- if (escArmed) { exit(); } else { setEscArmed(true); }
621
- return;
622
- }
623
-
624
- if (key.ctrl && char === 'c') {
625
- if (running) { abortRef.current.abort(); return; }
626
- if (input.length > 0) { setInput(''); return; }
627
- exit();
628
- return;
629
- }
630
-
631
- if (key.tab && key.shift) {
632
- // Deliberately no chat message here - this is a status-bar toggle, not
633
- // conversation content, and pushing one would knock a bare "/"-free home
634
- // screen straight into the compact conversation layout for no reason.
635
- setBypassMode((b) => !b);
636
- return;
637
- }
638
-
639
- if (key.tab) {
640
- if (showCommands && filteredCommands.length > 0) {
641
- const sel = filteredCommands[selectedCommand];
642
- if (sel) { setInput(sel.name + ' '); setShowCommands(false); }
643
- return;
644
- }
645
- setModeIndex((i) => (i + 1) % MODES.length);
646
- return;
647
- }
648
-
649
- if (showCommands) {
650
- if (key.upArrow) { setSelectedCommand((p) => (p > 0 ? p - 1 : filteredCommands.length - 1)); return; }
651
- if (key.downArrow) { setSelectedCommand((p) => (p < filteredCommands.length - 1 ? p + 1 : 0)); return; }
652
- }
653
- });
654
-
655
- // ── slash commands ──
656
- const runCommand = async (cmd, args = '') => {
657
- if (cmd === '/login') {
658
- push({ role: 'system', content: 'Opening sign-in…' });
659
- try {
660
- await auth.login();
661
- const s = await auth.getSession();
662
- setSession(s);
663
- push({ role: 'system', content: s ? `Signed in as ${s.user.email}` : 'Login cancelled or failed' });
664
- } catch (e) {
665
- push({ role: 'error', content: e.message || 'Login failed' });
666
- }
667
- } else if (cmd === '/logout') {
668
- await auth.logout();
669
- setSession(null);
670
- push({ role: 'system', content: 'Signed out' });
671
- } else if (cmd === '/whoami') {
672
- const s = await auth.getSession();
673
- push({ role: 'system', content: s ? s.user.email : 'Not signed in' });
674
- } else if (cmd === '/help') {
675
- push({
676
- role: 'system',
677
- content: 'Commands\n' + COMMANDS.map((c) => `${c.name.padEnd(10)} ${c.description}`).join('\n') +
678
- '\n\nKeys\nshift+enter / ctrl+j new line\ntab switch mode\nesc interrupt a run\n' +
679
- `\nModel\n${appConfig.describeProvider()} - change with \`codeply provider\`\n`,
680
- });
681
- } else if (cmd === '/clear') {
682
- // <Static> never re-renders what it already printed, so a clear means
683
- // wiping the terminal and remounting it with a fresh key. Empty history
684
- // also drops us back to the home splash.
685
- convo.current = [];
686
- if (stdout) stdout.write('\x1b[2J\x1b[3J\x1b[H');
687
- setChatHistory([]);
688
- setStaticKey((k) => k + 1);
689
- } else if (cmd === '/mode') {
690
- const next = (modeIndex + 1) % MODES.length;
691
- setModeIndex(next);
692
- push({ role: 'system', content: `Mode → ${MODES[next]} - ${MODE_HINT[MODES[next]]}` });
693
- } else if (cmd === '/provider') {
694
- const parts = args.trim().split(/\s+/).filter(Boolean);
695
- const name = parts[0];
696
-
697
- if (!name) {
698
- const cfg = appConfig.getConfig();
699
- let msg = `Provider: ${appConfig.describeProvider(cfg)}\n` +
700
- `Providers: ${appConfig.PROVIDERS.join(', ')}\n` +
701
- 'Set with /provider <name> [key] - e.g. /provider openrouter sk-or-v1-...';
702
- if (cfg.provider === 'codeply') msg += `\n\n${appConfig.byokHint()}`;
703
- push({ role: 'system', content: msg });
704
- return;
705
- }
706
-
707
- if (!appConfig.PROVIDERS.includes(name)) {
708
- push({ role: 'error', content: `Unknown provider "${name}". Use one of: ${appConfig.PROVIDERS.join(', ')}.` });
709
- return;
710
- }
711
-
712
- const key = parts[1];
713
- const patch = { provider: name };
714
- if (key && (appConfig.BYOK_PROVIDERS.includes(name) || name === 'ollama')) {
715
- patch[name] = { apiKey: key };
716
- }
717
-
718
- const saved = appConfig.saveConfig(patch);
719
- if (!saved.ok) {
720
- push({ role: 'error', content: `Could not save config: ${saved.error}` });
721
- return;
722
- }
723
- push({ role: 'system', content: `Provider → ${appConfig.describeProvider(appConfig.getConfig())}` });
724
- } else if (cmd === '/keys') {
725
- push({ role: 'system', content: 'Press any key (or combination)…' });
726
- setKeyProbe(true);
727
- } else if (cmd === '/skill') {
728
- const parts = args.trim().split(/\s+/).filter(Boolean);
729
- const sub = parts[0];
730
-
731
- if (!sub || sub === 'list') {
732
- const list = skills.listSkills();
733
- if (list.length === 0) {
734
- push({ role: 'system', content: 'No skills available.' });
735
- } else {
736
- const lines = list.map((s) => `${s.name.padEnd(28)} [${s.source}] ${s.description}`);
737
- push({ role: 'system', content: `Skills (${list.length})\n` + lines.join('\n') +
738
- '\n\n/skill install <path-or-github-url> add one\n/skill remove <name> remove a user-installed one' });
739
- }
740
- return;
741
- }
742
-
743
- if (sub === 'install') {
744
- const source = parts.slice(1).join(' ');
745
- if (!source) { push({ role: 'error', content: 'Usage: /skill install <local-path-or-github-url>' }); return; }
746
- push({ role: 'system', content: `Installing from ${source}…` });
747
- try {
748
- const isLocal = fs.existsSync(source);
749
- const result = isLocal
750
- ? skills.installFromLocalDir(path.resolve(cwd, source))
751
- : await skills.installFromGitHub(source);
752
- if (isLocal) {
753
- if (!result.ok) push({ role: 'error', content: result.error });
754
- else push({ role: 'system', content: `Installed "${result.name}" → ${result.dest}` });
755
- } else if (!result.ok) {
756
- push({ role: 'error', content: result.error || `No skills installed from ${source}.` });
757
- } else {
758
- const names = result.installed.map((i) => i.name).join(', ');
759
- push({ role: 'system', content: `Installed ${result.installed.length} skill(s) from ${result.source}: ${names}` });
760
- if (result.failed.length) {
761
- push({ role: 'error', content: `${result.failed.length} failed: ${result.failed.map((f) => f.dir).join(', ')}` });
762
- }
763
- }
764
- } catch (e) {
765
- push({ role: 'error', content: `Install failed: ${e.message}` });
766
- }
767
- return;
768
- }
769
-
770
- if (sub === 'remove') {
771
- const name = parts[1];
772
- if (!name) { push({ role: 'error', content: 'Usage: /skill remove <name>' }); return; }
773
- const result = skills.removeSkill(name);
774
- push(result.ok
775
- ? { role: 'system', content: `Removed "${name}".` }
776
- : { role: 'error', content: result.error });
777
- return;
778
- }
779
-
780
- push({ role: 'error', content: `Unknown /skill subcommand "${sub}". Use list, install, or remove.` });
781
- } else if (cmd === '/quit' || cmd === '/exit') {
782
- exit();
783
- } else {
784
- push({ role: 'error', content: `Unknown command: ${cmd}. Type /help for available commands.` });
785
- }
786
- };
787
-
788
- // ── main turn ──
789
- const handleSubmit = async (value) => {
790
- const text = value.trim();
791
- if (!text || running) return;
792
-
793
- let submitValue = text;
794
- if (showCommands && filteredCommands[selectedCommand]) {
795
- const raw = text.split(/\s+/)[0].toLowerCase();
796
- if (!COMMANDS.some((c) => c.name === raw)) {
797
- submitValue = filteredCommands[selectedCommand].name;
798
- }
799
- }
800
- setShowCommands(false);
801
- setInput('');
802
-
803
- if (submitValue.startsWith('/')) {
804
- const [first, ...rest] = submitValue.split(/\s+/);
805
- await runCommand(first.toLowerCase(), rest.join(' '));
806
- return;
807
- }
808
-
809
- push({ role: 'user', content: submitValue });
810
- setRunning(true);
811
- setStatusLine('thinking…');
812
- setSteps(0);
813
- abortRef.current = new AbortController();
814
-
815
- const turn = [];
816
- try {
817
- const stream = runAgent({
818
- userMessage: submitValue,
819
- history: convo.current.slice(-20),
820
- mode,
821
- cwd,
822
- approve: requestApproval,
823
- signal: abortRef.current.signal,
824
- onSubagentEvent,
825
- });
826
-
827
- for await (const event of stream) {
828
- if (event.type === 'text') {
829
- // In Plan mode the substantial reply is the plan itself; short
830
- // interstitial notes ("let me check X") stay as ordinary prose.
831
- const isPlan = mode === 'Plan' && event.text.split('\n').length >= 3;
832
- push({ role: 'assistant', content: event.text, plan: isPlan });
833
- turn.push({ role: 'assistant', content: event.text });
834
- setStatusLine('working…');
835
- } else if (event.type === 'tool_start') {
836
- const display = TOOL_DISPLAY[event.name] || { verb: event.name };
837
- setSteps((n) => n + 1);
838
- setStatusLine(`${display.verb} ${event.args.path || event.args.command || event.args.pattern || ''}`.trim());
839
- } else if (event.type === 'tool_end') {
840
- const meta = event.meta || {};
841
- const unit = event.name === 'search' ? 'matches'
842
- : event.name === 'list_dir' ? 'entries'
843
- : 'lines';
844
- let detail = '';
845
- if (meta.count != null) detail = `${meta.count} ${unit}`;
846
- if (meta.added != null) detail = `-${meta.removed || 0} +${meta.added}`;
847
- if (meta.exitCode != null) detail = `exit ${meta.exitCode}`;
848
- if (meta.noop) detail = 'no change';
849
- if (meta.rejected) detail = 'declined';
850
- push({
851
- role: 'tool',
852
- name: event.name,
853
- ok: event.ok && !meta.rejected && (meta.exitCode == null || meta.exitCode === 0),
854
- label: event.summary || '',
855
- detail,
856
- });
857
- setStatusLine('thinking…');
858
- } else if (event.type === 'aborted') {
859
- push({ role: 'system', content: 'Interrupted.' });
860
- } else if (event.type === 'error') {
861
- push({ role: 'error', content: event.error });
862
- }
863
- }
864
- } catch (e) {
865
- push({ role: 'error', content: e.message });
866
- } finally {
867
- convo.current = [...convo.current, { role: 'user', content: submitValue }, ...turn];
868
- setRunning(false);
869
- setStatusLine('');
870
- // A run that ended while a prompt was up must not leave a dangling promise.
871
- setApproval((current) => { if (current) current.resolve('reject'); return null; });
872
- }
873
- };
874
-
875
- // Ollama needs no Codeply sign-in, so showing "not signed in" there would be
876
- // a false alarm - surface the model that is actually answering instead.
877
- const provider = React.useMemo(() => appConfig.getConfig(), []);
878
- const usingOllama = provider.provider === 'ollama';
879
- const account = usingOllama
880
- ? appConfig.describeProviderShort(provider)
881
- : session ? session.user.email : 'not signed in';
882
-
883
- // Advertise the key that actually inserts a newline in THIS terminal.
884
- const newlineKey = modifiedEnterWorks ? 'shift+enter' : 'ctrl+j';
885
-
886
- const isHome = chatHistory.length === 0;
887
- // The splash needs real estate; in a short or narrow window fall straight
888
- // through to the compact layout rather than rendering a cramped one.
889
- const showSplash = isHome && rows >= 22 && cols >= 44;
890
- // 1 for this session's own run, plus whatever subagents it has spawned and
891
- // are still going - matches what the status bar's "← N agent" is counting.
892
- const agentCount = 1 + activeAgents.length;
893
-
894
- // On home the composer is a centred column like an app window; in
895
- // conversation it spans the width so long messages have room.
896
- const shellWidth = showSplash ? Math.min(cols - 4, 92) : cols - 2;
897
- const inner = Math.max(20, shellWidth);
898
-
899
- // Two-column rows are laid out by hand: Ink truncates each child against the
900
- // width it was given, so without explicit widths the columns run into each
901
- // other on narrow terminals instead of truncating cleanly.
902
- const rightHint = escArmed
903
- ? `esc again to ${input.length > 0 ? 'clear' : 'quit'}`
904
- : running ? 'esc interrupts' : 'enter to send';
905
- const footRight = fitPath(homePath(cwd), Math.max(12, Math.floor(inner * 0.45))) + ' codeply ' + VERSION;
906
- const hintsWidth = Math.max(10, inner - rightHint.length - 2);
907
- const tipWidth = inner - footRight.length - 2;
908
- const showTip = tipWidth >= 24;
909
-
910
- const composer = React.createElement(
911
- Box,
912
- {
913
- key: 'composer',
914
- flexDirection: 'column',
915
- borderStyle: 'bold',
916
- borderTop: false,
917
- borderRight: false,
918
- borderBottom: false,
919
- borderLeftColor: running ? T.muted : modeColor,
920
- paddingLeft: 1,
921
- width: shellWidth,
922
- marginTop: showSplash ? 2 : 0,
923
- flexShrink: 0,
924
- },
925
- React.createElement(TextArea, {
926
- value: input,
927
- onChange: setInput,
928
- onSubmit: handleSubmit,
929
- width: inner - 3,
930
- focus: !running && !approval,
931
- allowVertical: !showCommands,
932
- placeholder: running
933
- ? 'working - esc to interrupt'
934
- : 'Ask for a change, a fix, or an explanation… (/ for commands)',
935
- }),
936
- React.createElement(
937
- Text,
938
- { wrap: 'truncate-end' },
939
- hexToAnsi(modeColor) + ANSI_BOLD + mode + ANSI_RESET +
940
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
941
- hexToAnsi(T.muted) + MODE_HINT[mode] + ANSI_RESET +
942
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
943
- hexToAnsi(usingOllama || session ? T.dim : G.yellow) + account + ANSI_RESET
944
- )
945
- );
946
-
947
- const hints = React.createElement(
948
- Box,
949
- { key: 'hints', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, marginTop: 1, flexShrink: 0 },
950
- React.createElement(
951
- Box,
952
- { width: hintsWidth, flexShrink: 0 },
953
- React.createElement(
954
- Text,
955
- { wrap: 'truncate-end' },
956
- hexToAnsi(G.blue) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' mode ' + ANSI_RESET +
957
- hexToAnsi(G.red) + newlineKey + ANSI_RESET + hexToAnsi(T.faint) + ' newline ' + ANSI_RESET +
958
- hexToAnsi(G.yellow) + '/' + ANSI_RESET + hexToAnsi(T.faint) + ' commands ' + ANSI_RESET +
959
- hexToAnsi(G.green) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' interrupt' + ANSI_RESET
960
- )
961
- ),
962
- React.createElement(
963
- Text,
964
- null,
965
- (escArmed ? hexToAnsi(G.yellow) : hexToAnsi(T.faint)) + rightHint + ANSI_RESET
966
- )
967
- );
968
-
969
- const tipLine = React.createElement(
970
- Box,
971
- { key: 'tip', width: shellWidth, flexShrink: 0 },
972
- showTip && React.createElement(
973
- Text,
974
- { wrap: 'truncate-end' },
975
- hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
976
- hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
977
- hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
978
- )
979
- );
980
-
981
- const footer = React.createElement(
982
- Box,
983
- { key: 'footer', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, flexShrink: 0 },
984
- React.createElement(
985
- Box,
986
- { width: Math.max(0, tipWidth), flexShrink: 0 },
987
- showTip && React.createElement(
988
- Text,
989
- { wrap: 'truncate-end' },
990
- hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
991
- hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
992
- hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
993
- )
994
- ),
995
- React.createElement(Text, null, hexToAnsi(T.faint) + footRight + ANSI_RESET)
996
- );
997
-
998
- const statusBar = React.createElement(
999
- Box,
1000
- { key: 'statusbar', width: shellWidth, marginTop: 1, flexShrink: 0 },
1001
- React.createElement(StatusBar, { bypassMode, agentCount })
1002
- );
1003
-
1004
- // ── Home: one full-viewport screen, centred like an app ──
1005
- if (showSplash) {
1006
- const avail = rows - 1;
1007
- const canBox = cols >= BOX_MIN_COLS;
1008
-
1009
- const { leftInner, rightInner } = boxHomeMetrics(cols);
1010
- const welcomeName = (session?.user?.email || '').split('@')[0] || null;
1011
- const leftRows = [
1012
- { text: '' },
1013
- { text: centered(welcomeName ? `Welcome back, ${welcomeName}!` : 'Welcome to Codeply', leftInner), bold: true, color: T.text },
1014
- { text: '' },
1015
- ...MASCOT_ROWS.map((row) => ({ text: centered(row, leftInner), color: T.accent })),
1016
- { text: '' },
1017
- { text: centered(`${mode} mode · v${VERSION}`, leftInner), color: T.muted },
1018
- { text: centered(usingOllama || session ? account : 'not signed in - /login', leftInner), color: usingOllama || session ? T.secondary : G.yellow },
1019
- { text: centered(fitPath(homePath(cwd), Math.max(10, leftInner - 2)), leftInner), color: T.faint },
1020
- ];
1021
- const rightRows = [
1022
- { text: 'Tips for getting started', bold: true, color: T.accent },
1023
- { text: `${tip[0]} ${tip[1]}` },
1024
- { text: '' },
1025
- { text: 'What\'s new', bold: true, color: T.accent },
1026
- ...WHATS_NEW.map((line) => ({ text: line, color: T.secondary })),
1027
- ].map((r) => ({ color: T.secondary, ...r }));
1028
-
1029
- return React.createElement(
1030
- Box,
1031
- { flexDirection: 'column', width: cols, height: avail, alignItems: 'center', justifyContent: 'center' },
1032
- canBox
1033
- ? React.createElement(BoxHome, {
1034
- key: 'home', cols, title: `Codeply CLI v${VERSION}`,
1035
- left: leftRows, right: rightRows, borderColor: T.accent,
1036
- })
1037
- : React.createElement(CompactMark, { key: 'home', cols }),
1038
- composer,
1039
- hints,
1040
- tipLine,
1041
- statusBar
1042
- );
1043
- }
1044
-
1045
- // ── Conversation: scrollback + a deliberately short live frame ──
1046
- return React.createElement(
1047
- Box,
1048
- { flexDirection: 'column', width: cols, paddingX: 1 },
1049
-
1050
- // ── Scrollback: printed once, never redrawn. Keeps the live frame short. ──
1051
- React.createElement(
1052
- Static,
1053
- { key: staticKey, items: chatHistory },
1054
- (msg) => React.createElement(MessageBlock, { key: msg.id, msg, cols: cols - 2 })
1055
- ),
1056
-
1057
- // ── Live frame below ──
1058
-
1059
- running && React.createElement(
1060
- Box,
1061
- { flexDirection: 'row', gap: 1, marginBottom: 1, flexShrink: 0 },
1062
- React.createElement(Text, { color: T.accent }, React.createElement(Spinner, { type: 'dots' })),
1063
- React.createElement(
1064
- Text,
1065
- { wrap: 'truncate-end' },
1066
- hexToAnsi(T.dim) + (statusLine || 'working…') + ANSI_RESET +
1067
- hexToAnsi(T.faint) + ' ' + elapsed + 's' + ANSI_RESET +
1068
- (steps > 0 ? hexToAnsi(T.faint) + ' · step ' + steps + ANSI_RESET : '')
1069
- )
1070
- ),
1071
-
1072
- approval && React.createElement(ApprovalPrompt, { request: approval.request }),
1073
-
1074
- showCommands && React.createElement(CommandsMenu, {
1075
- commands: filteredCommands,
1076
- selectedIndex: selectedCommand,
1077
- }),
1078
-
1079
- composer,
1080
- hints,
1081
- footer,
1082
- statusBar
1083
- );
1084
- };
1085
-
1086
- export default App;
1
+ /**
2
+ * Codeply TUI - interactive shell.
3
+ *
4
+ * Layout follows the shape every good coding CLI has settled on (Claude Code,
5
+ * Codex, Gemini CLI, OpenCode, Grok Build): a scrolling transcript, one quiet
6
+ * input box at the bottom, one footer line under it.
7
+ *
8
+ * ● Read src/config.js a step: bullet, tool, target
9
+ * ⎿ Read 84 lines its result, indented under it
10
+ * ● Update src/config.js
11
+ * ⎿ Updated src/config.js with 2 additions and 1 removal
12
+ * 12 - const port = Number(raw); inline diff, line numbers from the file
13
+ * 12 + const port = parseInt(raw, 10);
14
+ * ╭──────────────────────────────────╮
15
+ * │ › _ │
16
+ * ╰──────────────────────────────────╯
17
+ * Build mode (tab to switch) Haze 2
18
+ *
19
+ * Rendering model (important):
20
+ * Everything that is *finished* (banner + past messages + tool activity) is
21
+ * emitted through Ink's <Static>, which writes each item to stdout exactly
22
+ * once and never touches it again. Only the live area - spinner, approval
23
+ * prompt, command palette, composer, footer - lives in the re-rendered frame.
24
+ *
25
+ * Ink redraws a frame by erasing the previous one, but it can only erase
26
+ * lines still on screen. Once the frame grows taller than the terminal, the
27
+ * erase is incomplete and the frame gets appended instead of replaced - you
28
+ * see the conversation twice. Keeping the live frame short makes the redraw
29
+ * exact. Rule for future edits: nothing tall goes outside <Static>.
30
+ *
31
+ * Resizing changes where every line wraps, and <Static> cannot reflow what it
32
+ * already printed, so a width change clears the screen and reprints the
33
+ * transcript at the new width (the same thing Claude Code does).
34
+ */
35
+ import React from 'react';
36
+ import { Box, Text, Static, useInput, useApp, useStdout } from 'ink';
37
+ import { createRequire } from 'module';
38
+ import path from 'path';
39
+ import os from 'os';
40
+ import fs from 'fs';
41
+
42
+ const require = createRequire(import.meta.url);
43
+ const auth = require('../lib/auth.js');
44
+ const appConfig = require('../lib/config.js');
45
+ const skills = require('../lib/skills.js');
46
+ const VERSION = require('../package.json').version;
47
+ import {
48
+ renderMarkdown, Panel, hexToAnsi, hexToAnsiBg, visibleLength, SURFACE,
49
+ ANSI_RESET, ANSI_BOLD, ANSI_ITALIC, ANSI_STRIKE,
50
+ } from '../lib/render-md.mjs';
51
+ import { runAgent } from '../lib/agent.mjs';
52
+ import { TOOL_DISPLAY } from '../lib/tools.mjs';
53
+ import TextArea, { enableModifiedEnter, isTerminalReport } from '../lib/textarea.mjs';
54
+ import { DshEngine, summarizeArgs } from '../lib/dsh-acp.mjs';
55
+
56
+ // ─── Glyphs ─────────────────────────────────────────────────────────────────
57
+ //
58
+ // Windows Terminal, VS Code and every macOS/Linux terminal have fonts with
59
+ // these symbols. The old console host (powershell.exe opened on its own) uses
60
+ // Consolas with no fallback, where most of them turn into boxes, so it gets a
61
+ // plain set. CODEPLY_ASCII=1 forces the plain set anywhere.
62
+
63
+ const FANCY = !process.env.CODEPLY_ASCII && (
64
+ process.platform !== 'win32' ||
65
+ !!process.env.WT_SESSION ||
66
+ !!process.env.TERM_PROGRAM ||
67
+ /xterm|kitty|alacritty|wezterm|ghostty/i.test(process.env.TERM || '')
68
+ );
69
+
70
+ const GL = FANCY
71
+ ? {
72
+ dot: '●', branch: '⎿', prompt: '›', pointer: '❯', star: '✻', warn: '⚠', cross: '✗',
73
+ open: '☐', done: '☒', bypass: '⏵⏵', rule: '─', border: 'round',
74
+ corners: ['╭', '╮', '╰', '╯'], spin: ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'],
75
+ }
76
+ : {
77
+ dot: '•', branch: '└', prompt: '>', pointer: '>', star: '*', warn: '!', cross: 'x',
78
+ open: '[ ]', done: '[x]', bypass: '>>', rule: '─', border: 'single',
79
+ corners: ['┌', '┐', '└', '┘'], spin: ['-', '\\', '|', '/'],
80
+ };
81
+
82
+ // ─── Theme ──────────────────────────────────────────────────────────────────
83
+ //
84
+ // Codeply blue (the app icon's #1f6bff) for the mascot and the welcome box, a
85
+ // lighter step of it for text so it stays readable on a dark background, greys
86
+ // for everything structural, and red/green/yellow kept for what they mean
87
+ // (failed, succeeded, needs attention).
88
+
89
+ const T = {
90
+ brand: '#1f6bff',
91
+ accent: '#5b9bff',
92
+ text: '#e3e3e3',
93
+ secondary: '#a8adb3',
94
+ dim: '#80868b',
95
+ faint: '#5f6368',
96
+ border: '#4a4f55',
97
+ ok: '#5bb974',
98
+ err: '#f28b82',
99
+ warn: '#fdd663',
100
+ };
101
+
102
+ const paint = (hex, s, extra = '') => hexToAnsi(hex) + extra + s + ANSI_RESET;
103
+ const bold = (hex, s) => paint(hex, s, ANSI_BOLD);
104
+ const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
105
+ const stripAnsi = (s) => String(s || '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
106
+
107
+ function fmtSecs(s) {
108
+ if (s < 60) return `${s}s`;
109
+ return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
110
+ }
111
+
112
+ const MODES = ['Build', 'Plan', 'Ask'];
113
+ const MODE_COLOR = { Build: T.accent, Plan: T.warn, Ask: '#81c995' };
114
+ const MODE_HINT = {
115
+ Build: 'reads, edits and verifies files',
116
+ Plan: 'investigates, then writes a plan',
117
+ Ask: 'answers from the codebase, read-only',
118
+ };
119
+
120
+ // The Codeply agent: a solid blue ball with two eyes, in the spirit of Kimi
121
+ // CLI's blue square. Drawn as pixels, two per character cell with half blocks
122
+ // (▀: top pixel is the foreground colour, bottom pixel the background), which
123
+ // comes out square in a normal terminal font. The eyes are holes, so they take
124
+ // the terminal's own background colour. A slight top-to-bottom shade keeps it
125
+ // reading as a ball rather than a flat disc. Mirror-symmetric by construction.
126
+ const BALL_PIXELS = [
127
+ '...XXXX...',
128
+ '.XXXXXXXX.',
129
+ '.XXXXXXXX.',
130
+ 'XXX.XX.XXX',
131
+ 'XXX.XX.XXX',
132
+ 'XXXXXXXXXX',
133
+ 'XXXXXXXXXX',
134
+ '.XXXXXXXX.',
135
+ '.XXXXXXXX.',
136
+ '...XXXX...',
137
+ ];
138
+ const BALL_TOP = '#6fa3ff';
139
+ const BALL_BOTTOM = '#1f56e0';
140
+
141
+ function shadeAt(y, h) {
142
+ const t = h > 1 ? y / (h - 1) : 0;
143
+ const a = [1, 3, 5].map((i) => parseInt(BALL_TOP.slice(i, i + 2), 16));
144
+ const b = [1, 3, 5].map((i) => parseInt(BALL_BOTTOM.slice(i, i + 2), 16));
145
+ return '#' + a.map((v, i) => Math.round(v + (b[i] - v) * t).toString(16).padStart(2, '0')).join('');
146
+ }
147
+
148
+ function drawBall(pixels) {
149
+ const h = pixels.length;
150
+ const rows = [];
151
+ for (let y = 0; y < h; y += 2) {
152
+ let line = '';
153
+ for (let x = 0; x < pixels[y].length; x++) {
154
+ const top = pixels[y][x] === 'X' ? shadeAt(y, h) : null;
155
+ const bottom = pixels[y + 1] && pixels[y + 1][x] === 'X' ? shadeAt(y + 1, h) : null;
156
+ if (!top && !bottom) line += ANSI_RESET + ' ';
157
+ else if (top && !bottom) line += ANSI_RESET + hexToAnsi(top) + '▀';
158
+ else if (!top) line += ANSI_RESET + hexToAnsi(bottom) + '▄';
159
+ else line += hexToAnsi(top) + hexToAnsiBg(bottom) + '▀';
160
+ }
161
+ rows.push(line + ANSI_RESET);
162
+ }
163
+ return { rows, width: pixels[0].length };
164
+ }
165
+
166
+ const MASCOT = drawBall(BALL_PIXELS);
167
+
168
+ const COMMANDS = [
169
+ { name: '/help', description: 'Show commands and keyboard shortcuts' },
170
+ { name: '/clear', description: 'Clear the conversation and start fresh' },
171
+ { name: '/mode', description: 'Cycle Build → Plan → Ask' },
172
+ { name: '/provider', description: 'Show or switch the model provider (Haze 2 or your own key)' },
173
+ { name: '/skill', description: 'List, install, or remove agent skills' },
174
+ { name: '/login', description: 'Sign in with your Codeply account' },
175
+ { name: '/logout', description: 'Sign out of this computer' },
176
+ { name: '/whoami', description: 'Show the signed-in account' },
177
+ { name: '/engine', description: 'Show or switch the engine: classic or dsh' },
178
+ { name: '/keys', description: 'Show what your terminal sends for a key' },
179
+ { name: '/quit', description: 'Exit Codeply' },
180
+ ];
181
+
182
+ const WHATS_NEW = [
183
+ 'New terminal UI with inline diffs and step results',
184
+ '↑ ↓ brings back earlier prompts',
185
+ 'Type while it works to queue a follow-up',
186
+ ];
187
+
188
+ // ─── Tool presentation ──────────────────────────────────────────────────────
189
+
190
+ const TOOL_TITLE = {
191
+ read_file: ['Read', 'Reading'],
192
+ write_file: ['Write', 'Writing'],
193
+ edit_file: ['Update', 'Editing'],
194
+ apply_patch: ['Patch', 'Patching'],
195
+ search: ['Search', 'Searching'],
196
+ list_dir: ['List', 'Listing'],
197
+ run: ['Run', 'Running'],
198
+ job: ['Job', 'Checking job'],
199
+ web_fetch: ['Fetch', 'Fetching'],
200
+ web_search: ['Web search', 'Searching the web'],
201
+ image_search: ['Image search', 'Searching images'],
202
+ todo: ['Update todos', 'Updating todos'],
203
+ use_skill: ['Skill', 'Loading skill'],
204
+ list_skills: ['Skills', 'Listing skills'],
205
+ browser_check: ['Browser check', 'Checking in the browser'],
206
+ remember: ['Remember', 'Remembering'],
207
+ recall: ['Recall', 'Recalling'],
208
+ lsp: ['Code check', 'Checking code'],
209
+ // The DSH engine's own tool names.
210
+ read: ['Read', 'Reading'],
211
+ edit: ['Update', 'Editing'],
212
+ write: ['Write', 'Writing'],
213
+ glob: ['Search', 'Searching'],
214
+ grep: ['Search', 'Searching'],
215
+ pwsh: ['Run', 'Running'],
216
+ bash: ['Run', 'Running'],
217
+ shell: ['Run', 'Running'],
218
+ };
219
+
220
+ function toolTitle(name) {
221
+ if (TOOL_TITLE[name]) return TOOL_TITLE[name];
222
+ const verb = (TOOL_DISPLAY[name] && TOOL_DISPLAY[name].verb) || String(name || 'tool').replace(/_/g, ' ');
223
+ const cap = verb[0].toUpperCase() + verb.slice(1);
224
+ return [cap, cap];
225
+ }
226
+
227
+ const argTarget = (args = {}) =>
228
+ args.path || args.file_path || args.command || args.pattern || args.glob || args.url || args.query || args.name || '';
229
+
230
+ /** Line-level diff (LCS), so unchanged lines show as context, not -/+ pairs. */
231
+ function lineDiff(before, after) {
232
+ const A = String(before ?? '').split('\n');
233
+ const B = String(after ?? '').split('\n');
234
+ const n = A.length;
235
+ const m = B.length;
236
+ if (n * m > 90000) return [...A.map((s) => ({ t: '-', s })), ...B.map((s) => ({ t: '+', s }))];
237
+ const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
238
+ for (let i = n - 1; i >= 0; i--) {
239
+ for (let j = m - 1; j >= 0; j--) dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
240
+ }
241
+ const out = [];
242
+ let i = 0;
243
+ let j = 0;
244
+ while (i < n && j < m) {
245
+ if (A[i] === B[j]) { out.push({ t: ' ', s: A[i] }); i++; j++; }
246
+ else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ t: '-', s: A[i++] });
247
+ else out.push({ t: '+', s: B[j++] });
248
+ }
249
+ while (i < n) out.push({ t: '-', s: A[i++] });
250
+ while (j < m) out.push({ t: '+', s: B[j++] });
251
+ return out;
252
+ }
253
+
254
+ /** 1-based line where `needle` starts in the file, or null. */
255
+ function lineOf(cwd, rel, needle) {
256
+ if (!rel || !needle) return null;
257
+ try {
258
+ const content = fs.readFileSync(path.resolve(cwd, rel), 'utf8');
259
+ const idx = content.indexOf(needle);
260
+ return idx < 0 ? null : content.slice(0, idx).split('\n').length;
261
+ } catch { return null; }
262
+ }
263
+
264
+ /** Diff rows as finished ANSI strings, filled to `width` like Claude Code's. */
265
+ function diffRows(diff, start, width, max = 24) {
266
+ const w = Math.max(20, width);
267
+ const numW = String((start || 1) + diff.length).length;
268
+ let oldN = start || 1;
269
+ let newN = start || 1;
270
+ const rows = [];
271
+ for (const d of diff.slice(0, max)) {
272
+ const num = start ? String(d.t === '+' ? newN : oldN).padStart(numW) : ' '.repeat(numW);
273
+ if (d.t !== '+') oldN++;
274
+ if (d.t !== '-') newN++;
275
+ let body = `${num} ${d.t} ${d.s.replace(/\t/g, ' ')}`;
276
+ if (body.length > w) body = body.slice(0, w - 1) + '…';
277
+ if (d.t === ' ') { rows.push(paint(T.dim, body)); continue; }
278
+ const bg = d.t === '+' ? SURFACE.diffAdd : SURFACE.diffDel;
279
+ const fg = d.t === '+' ? '#c8f2d2' : '#ffd4d4';
280
+ rows.push(hexToAnsiBg(bg) + hexToAnsi(fg) + body + ' '.repeat(Math.max(0, w - body.length)) + ANSI_RESET);
281
+ }
282
+ if (diff.length > max) rows.push(paint(T.faint, `… +${diff.length - max} more lines`));
283
+ return rows;
284
+ }
285
+
286
+ /** A finished tool call → a transcript entry with its result lines worked out. */
287
+ function toolMessage(e, cwd) {
288
+ const meta = e.meta || {};
289
+ const args = e.args || {};
290
+ const [title] = toolTitle(e.name);
291
+ const rejected = !!meta.rejected;
292
+ const exitBad = meta.exitCode != null && meta.exitCode !== 0;
293
+ const failed = !e.ok && !rejected;
294
+ const msg = {
295
+ role: 'tool',
296
+ name: e.name,
297
+ title,
298
+ label: e.name === 'todo' ? '' : (meta.label || argTarget(args) || (e.ok ? e.summary : '') || ''),
299
+ status: rejected ? 'declined' : failed || exitBad ? 'error' : 'ok',
300
+ result: [],
301
+ };
302
+ const r = (s, color = T.dim) => msg.result.push(paint(color, s));
303
+
304
+ if (rejected) { r('Declined', T.warn); return msg; }
305
+ // A command that ran and failed still has output worth seeing: let it fall
306
+ // through to the run/job rendering, which leads with the exit code.
307
+ const ranAndFailed = (e.name === 'run' || e.name === 'job') && (meta.output != null || meta.exitCode != null);
308
+ if (failed && !ranAndFailed) {
309
+ msg.wrapResult = true;
310
+ const why = String(e.error || e.summary || 'Failed').trim().split('\n').filter(Boolean).slice(0, 4);
311
+ for (const line of why) r(stripAnsi(line), T.err);
312
+ return msg;
313
+ }
314
+
315
+ switch (e.name) {
316
+ case 'read_file':
317
+ if (meta.linesShown && meta.count && meta.linesShown < meta.count) {
318
+ const from = (meta.offset || 0) + 1;
319
+ r(`Read lines ${from}-${from + meta.linesShown - 1} of ${meta.count}`);
320
+ } else if (meta.count != null) r(`Read ${plural(meta.count, 'line')}`);
321
+ break;
322
+ case 'search':
323
+ r(meta.count ? `Found ${plural(meta.count, 'match', 'matches')}` : 'No matches');
324
+ break;
325
+ case 'glob':
326
+ r(meta.count ? `Found ${plural(meta.count, 'file')}` : 'No files found');
327
+ break;
328
+ case 'grep':
329
+ r(meta.count ? `Found ${plural(meta.count, 'match', 'matches')}` : 'No matches');
330
+ break;
331
+ case 'list_dir':
332
+ if (meta.count != null) r(plural(meta.count, 'entry', 'entries'));
333
+ break;
334
+ case 'edit_file': {
335
+ if (meta.noop) { r('No changes needed'); break; }
336
+ const search = args.search ?? args.old_string ?? args.old;
337
+ const replace = args.replace ?? args.new_string ?? args.new;
338
+ if (search != null && replace != null) {
339
+ msg.diff = lineDiff(search, replace);
340
+ msg.start = lineOf(cwd, args.path, replace);
341
+ const adds = msg.diff.filter((d) => d.t === '+').length;
342
+ const dels = msg.diff.filter((d) => d.t === '-').length;
343
+ r(`Updated ${msg.label} with ${plural(adds, 'addition')} and ${plural(dels, 'removal')}`);
344
+ } else {
345
+ r(`Updated ${msg.label} (-${meta.removed || 0} +${meta.added || 0} lines)`);
346
+ }
347
+ break;
348
+ }
349
+ case 'write_file':
350
+ if (meta.noop) { r('Already up to date, nothing written'); break; }
351
+ if (!meta.removed) {
352
+ r(`Wrote ${plural(meta.added || 0, 'line')} to ${msg.label}`);
353
+ if (typeof args.content === 'string') {
354
+ msg.diff = args.content.replace(/\n$/, '').split('\n').map((s) => ({ t: '+', s }));
355
+ msg.start = 1;
356
+ msg.maxDiff = 10;
357
+ }
358
+ } else {
359
+ r(`Rewrote ${msg.label} (${meta.removed} → ${plural(meta.added || 0, 'line')})`);
360
+ }
361
+ break;
362
+ case 'run':
363
+ case 'job': {
364
+ if (meta.background) { r(`Running in the background as job ${meta.jobId}`); break; }
365
+ if (exitBad) r(`Exit code ${meta.exitCode}`, T.err);
366
+ const out = stripAnsi(meta.output || '').replace(/\s+$/, '');
367
+ const lines = out ? out.split('\n') : [];
368
+ if (!lines.length && !exitBad) r('(no output)', T.faint);
369
+ const SHOW = 5;
370
+ if (lines.length > SHOW) r(`… ${plural(lines.length - SHOW, 'earlier line')}`, T.faint);
371
+ for (const line of lines.slice(-SHOW)) r(line, T.secondary);
372
+ break;
373
+ }
374
+ case 'todo':
375
+ for (const t of meta.todos || []) {
376
+ if (t.status === 'done') msg.result.push(paint(T.faint, `${GL.done} `) + paint(T.faint, t.text, ANSI_STRIKE));
377
+ else if (t.status === 'dropped') msg.result.push(paint(T.faint, `${GL.open} ${t.text}`, ANSI_STRIKE));
378
+ else if (t.status === 'in_progress') msg.result.push(paint(T.accent, `${GL.open} `) + bold(T.text, t.text));
379
+ else msg.result.push(paint(T.secondary, `${GL.open} ${t.text}`));
380
+ }
381
+ break;
382
+ default:
383
+ if (meta.deduped) r('Unchanged, reused the earlier result');
384
+ else if (meta.count != null) r(plural(meta.count, 'result'));
385
+ else if (e.summary && e.summary !== msg.label) r(stripAnsi(e.summary));
386
+ }
387
+
388
+ if (Array.isArray(meta.problems) && meta.problems.length) {
389
+ r(`${GL.warn} ${plural(meta.problems.length, 'problem')}: ${stripAnsi(meta.problems[0])}`, T.warn);
390
+ }
391
+ return msg;
392
+ }
393
+
394
+ /**
395
+ * A DSH tool result (name, args, raw output text) in the shape the Classic
396
+ * engine reports, so both engines get the same step rows and diffs.
397
+ */
398
+ function fromDsh(e) {
399
+ const a = e.args || {};
400
+ const out = stripAnsi(e.output || '');
401
+ const file = a.file_path || a.path || a.filePath;
402
+ const lines = out.split('\n').filter((l) => l.trim());
403
+ switch (e.name) {
404
+ case 'read':
405
+ return { ...e, name: 'read_file', args: { path: file }, meta: { label: file, count: (out.match(/^\d+: /gm) || []).length || undefined } };
406
+ case 'edit':
407
+ return { ...e, name: 'edit_file', args: { path: file, search: a.old_string, replace: a.new_string }, meta: { label: file } };
408
+ case 'write':
409
+ return { ...e, name: 'write_file', args: { path: file, content: a.content }, meta: { label: file, added: String(a.content || '').split('\n').length, removed: 0 } };
410
+ case 'glob':
411
+ case 'grep':
412
+ return { ...e, meta: { label: a.pattern || a.query || '', count: e.ok ? lines.length : 0 } };
413
+ case 'pwsh':
414
+ case 'bash':
415
+ case 'shell': {
416
+ const code = /exit(?:\s+code)?:?\s*(-?\d+)/i.exec(out);
417
+ return {
418
+ ...e, name: 'run', args: { command: a.command },
419
+ meta: { label: a.command, output: out.replace(/\n?\[stderr\]\n?/g, '\n'), exitCode: code ? Number(code[1]) : e.ok ? 0 : 1 },
420
+ };
421
+ }
422
+ default:
423
+ return { ...e, summary: e.ok ? lines[0] : undefined, meta: { label: summarizeArgs(a) } };
424
+ }
425
+ }
426
+
427
+ function homePath(cwd) {
428
+ const home = os.homedir();
429
+ if (cwd.startsWith(home)) return '~' + cwd.slice(home.length);
430
+ return cwd;
431
+ }
432
+
433
+ /** Shorten a path from the left so it never blows out a line. */
434
+ function fitPath(p, max) {
435
+ if (p.length <= max) return p;
436
+ return '…' + p.slice(p.length - max + 1);
437
+ }
438
+
439
+ const fit = (s, max) => (s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s);
440
+
441
+ // ─── Banner (printed once into the scrollback) ──────────────────────────────
442
+ //
443
+ // Laid out like Kimi CLI's: one blue box sized to its content, the mascot on
444
+ // the left with the welcome text beside it, then where you are and what is
445
+ // answering. Everything is pre-flattened to one ANSI string per row.
446
+
447
+ // Picked once per launch, so a resize reprints the same line.
448
+ const GREETINGS = [
449
+ (n) => `Hey ${n}! What are we building?`,
450
+ (n) => `Welcome back, ${n}!`,
451
+ (n) => `Good to see you, ${n}!`,
452
+ (n) => `Hi ${n}, ready when you are`,
453
+ ];
454
+ const GREETING_PICK = Math.floor(Math.random() * GREETINGS.length);
455
+ const GREETING = (name) => (name ? GREETINGS[GREETING_PICK](name) : 'Welcome to Codeply CLI!');
456
+
457
+ /** Styled pieces → { ansi, len } so rows can be padded by visible width. */
458
+ function piece(parts, max) {
459
+ let ansi = '';
460
+ let len = 0;
461
+ for (const [text, color, isBold] of parts) {
462
+ if (len >= max) break;
463
+ const t = fit(String(text), max - len);
464
+ ansi += paint(color, t, isBold ? ANSI_BOLD : '');
465
+ len += t.length;
466
+ }
467
+ return { ansi, len };
468
+ }
469
+
470
+ function Banner({ cols, account, needsLogin, model, cwd, name, mode }) {
471
+ const edge = (s) => paint(T.brand, s);
472
+ const [tl, tr, bl, br] = GL.corners;
473
+ const PAD = 3; // spaces between the border and the content, each side
474
+ const GAP = 3; // between the mascot and the text beside it
475
+
476
+ const side = [
477
+ null,
478
+ [[GREETING(name), T.text, true]],
479
+ [['Send ', T.secondary], ['/help', T.text], [' for help, ', T.secondary], ['?', T.text], [' for shortcuts', T.secondary]],
480
+ [[`${mode}`, T.accent], [mode === 'DSH engine' ? ' · /engine to switch' : ' · tab to switch', T.dim]],
481
+ null,
482
+ ];
483
+ const info = [
484
+ ['Directory:', [[homePath(cwd), T.secondary]], true],
485
+ ['Account:', needsLogin ? [['not signed in, send /login to sign in', T.warn]] : [[account, T.secondary]]],
486
+ ['Model:', [[model, T.secondary]]],
487
+ ];
488
+ const labelW = Math.max(...info.map(([l]) => l.length)) + 1;
489
+
490
+ const plainLen = (parts) => parts.reduce((n, [t]) => n + String(t).length, 0);
491
+ const wanted = Math.max(
492
+ MASCOT.width + GAP + Math.max(...side.filter(Boolean).map(plainLen)),
493
+ // A long path is shortened from the left rather than stretching the box.
494
+ ...info.map(([, parts, isPath]) => labelW + (isPath ? Math.min(plainLen(parts), 48) : plainLen(parts)))
495
+ );
496
+ const inner = Math.max(20, Math.min(cols - 2 - 2 - PAD * 2, wanted));
497
+ const width = inner + PAD * 2 + 2;
498
+
499
+ const line = (p) => edge('│') + ' '.repeat(PAD) + p.ansi + ' '.repeat(Math.max(0, inner - p.len)) + ' '.repeat(PAD) + edge('│');
500
+ const blank = line({ ansi: '', len: 0 });
501
+
502
+ const rows = [edge(tl + GL.rule.repeat(width - 2) + tr), blank];
503
+ const textRoom = inner - MASCOT.width - GAP;
504
+ MASCOT.rows.forEach((ball, i) => {
505
+ const text = side[i] && textRoom > 4 ? piece(side[i], textRoom) : { ansi: '', len: 0 };
506
+ rows.push(line({ ansi: ball + ' '.repeat(GAP) + text.ansi, len: MASCOT.width + GAP + text.len }));
507
+ });
508
+ rows.push(blank);
509
+ for (const [label, parts, isPath] of info) {
510
+ const room = inner - labelW;
511
+ const value = isPath ? [[fitPath(parts[0][0], room), parts[0][1]]] : parts;
512
+ const p = piece(value, room);
513
+ rows.push(line({ ansi: paint(T.dim, label.padEnd(labelW)) + p.ansi, len: labelW + p.len }));
514
+ }
515
+ rows.push(blank, edge(bl + GL.rule.repeat(width - 2) + br));
516
+
517
+ return React.createElement(
518
+ Box,
519
+ { flexDirection: 'column', marginBottom: 1 },
520
+ ...rows.map((row, i) => React.createElement(Text, { key: i }, row))
521
+ );
522
+ }
523
+
524
+ // ─── Transcript entries ─────────────────────────────────────────────────────
525
+
526
+ /** Bullet in a 2-column gutter, content beside it, one blank line after. */
527
+ function Bulleted({ bullet, children, gap = 1 }) {
528
+ return React.createElement(
529
+ Box,
530
+ { flexDirection: 'row', marginBottom: gap },
531
+ React.createElement(Box, { width: 2, flexShrink: 0 }, React.createElement(Text, null, bullet)),
532
+ React.createElement(Box, { flexDirection: 'column', flexGrow: 1, flexShrink: 1 }, ...[].concat(children).filter(Boolean))
533
+ );
534
+ }
535
+
536
+ /** The ⎿ result block under a step. */
537
+ function Result({ lines, wrap = 'truncate-end', extra = [] }) {
538
+ if (!lines.length && !extra.length) return null;
539
+ return React.createElement(
540
+ Box,
541
+ { flexDirection: 'row' },
542
+ React.createElement(Box, { width: 3, flexShrink: 0 }, React.createElement(Text, null, paint(T.faint, GL.branch))),
543
+ React.createElement(
544
+ Box,
545
+ { flexDirection: 'column', flexGrow: 1, flexShrink: 1 },
546
+ ...lines.map((l, i) => React.createElement(Text, { key: `l${i}`, wrap }, l)),
547
+ ...extra.map((l, i) => React.createElement(Text, { key: `x${i}` }, l))
548
+ )
549
+ );
550
+ }
551
+
552
+ function MessageBlock({ msg, cols, rows, banner }) {
553
+ const width = cols - 2;
554
+
555
+ if (msg.role === 'banner') return React.createElement(Banner, { cols, rows, ...banner });
556
+
557
+ if (msg.role === 'user') {
558
+ return React.createElement(
559
+ Bulleted,
560
+ { bullet: paint(T.dim, GL.prompt) },
561
+ React.createElement(Text, { wrap: 'wrap' }, paint(T.secondary, msg.content))
562
+ );
563
+ }
564
+
565
+ if (msg.role === 'assistant') {
566
+ if (msg.plan) {
567
+ return React.createElement(
568
+ Bulleted,
569
+ { bullet: paint(T.warn, GL.dot) },
570
+ React.createElement(Panel, { title: 'Plan', lines: msg.content.split('\n'), width: width - 2, accent: T.warn })
571
+ );
572
+ }
573
+ return React.createElement(Bulleted, { bullet: paint(T.text, GL.dot) }, ...renderMarkdown(msg.content, width - 2));
574
+ }
575
+
576
+ if (msg.role === 'tool') {
577
+ const color = msg.status === 'error' ? T.err : msg.status === 'declined' ? T.warn : T.ok;
578
+ const extra = msg.diff ? diffRows(msg.diff, msg.start, width - 5, msg.maxDiff || 24) : [];
579
+ return React.createElement(
580
+ Bulleted,
581
+ { bullet: paint(color, GL.dot) },
582
+ React.createElement(
583
+ Text,
584
+ { wrap: 'truncate-end' },
585
+ bold(T.text, msg.title) + (msg.label ? ' ' + paint(T.secondary, msg.label.replace(/\s*\n\s*/g, ' ')) : '')
586
+ ),
587
+ React.createElement(Result, { lines: msg.result, wrap: msg.wrapResult ? 'wrap' : 'truncate-end', extra })
588
+ );
589
+ }
590
+
591
+ if (msg.role === 'reasoning') {
592
+ const preview = String(msg.text || '').replace(/\s+/g, ' ').trim();
593
+ return React.createElement(
594
+ Bulleted,
595
+ { bullet: paint(T.faint, GL.star) },
596
+ React.createElement(Text, null, paint(T.dim, `Thought for ${fmtSecs(Math.max(1, Math.round((msg.ms || 0) / 1000)))}`, ANSI_ITALIC)),
597
+ preview && React.createElement(Text, { wrap: 'truncate-end' }, paint(T.faint, fit(preview, width * 2), ANSI_ITALIC))
598
+ );
599
+ }
600
+
601
+ if (msg.role === 'notice') {
602
+ const loud = msg.level === 'warn' || msg.level === 'limit';
603
+ return React.createElement(
604
+ Bulleted,
605
+ { bullet: paint(loud ? T.warn : T.dim, loud ? GL.warn : GL.dot) },
606
+ React.createElement(Text, { wrap: 'wrap' }, paint(loud ? T.warn : T.dim, msg.content))
607
+ );
608
+ }
609
+
610
+ if (msg.role === 'system') {
611
+ return React.createElement(
612
+ Box,
613
+ { flexDirection: 'row', marginBottom: 1, paddingLeft: 2 },
614
+ React.createElement(Result, { lines: [paint(T.secondary, msg.content)], wrap: 'wrap' })
615
+ );
616
+ }
617
+
618
+ if (msg.role === 'error') {
619
+ return React.createElement(
620
+ Bulleted,
621
+ { bullet: paint(T.err, GL.dot) },
622
+ React.createElement(Text, { wrap: 'wrap' }, paint(T.err, msg.content))
623
+ );
624
+ }
625
+
626
+ if (msg.role === 'done') {
627
+ const parts = [`Worked for ${fmtSecs(msg.secs)}`];
628
+ if (msg.steps) parts.push(plural(msg.steps, 'step'));
629
+ if (msg.files && msg.files.length) {
630
+ parts.push(msg.files.length <= 2 ? `changed ${msg.files.join(', ')}` : `changed ${plural(msg.files.length, 'file')}`);
631
+ }
632
+ const label = ` ${parts.join(' · ')} `;
633
+ const right = Math.max(0, width - label.length - 2);
634
+ return React.createElement(
635
+ Box,
636
+ { marginBottom: 1 },
637
+ React.createElement(Text, { wrap: 'truncate-end' }, paint(T.faint, GL.rule.repeat(2) + label + GL.rule.repeat(right)))
638
+ );
639
+ }
640
+
641
+ return null;
642
+ }
643
+
644
+ // ─── Live area ──────────────────────────────────────────────────────────────
645
+
646
+ // While the model is thinking (no tool running), the spinner word rotates
647
+ // every few seconds instead of sitting on one word for a minute.
648
+ const THINKING_WORDS = ['Thinking', 'Pondering', 'Tinkering', 'Brewing', 'Noodling', 'Cooking', 'Scheming', 'Untangling', 'Doodling'];
649
+
650
+ /** Spinner line with a soft shimmer running across the label. */
651
+ function Working({ label, startedAt, steps, queued, width }) {
652
+ const wordOffset = React.useMemo(() => Math.floor(Math.random() * THINKING_WORDS.length), []);
653
+ const [tick, setTick] = React.useState(0);
654
+ React.useEffect(() => {
655
+ const t = setInterval(() => setTick((n) => n + 1), 120);
656
+ return () => clearInterval(t);
657
+ }, []);
658
+ const secs = Math.floor((Date.now() - startedAt) / 1000);
659
+ const frame = GL.spin[tick % GL.spin.length];
660
+ const word = label === 'Thinking' ? THINKING_WORDS[(wordOffset + Math.floor(secs / 4)) % THINKING_WORDS.length] : label;
661
+ const text = `${word}…`;
662
+ const head = tick % (text.length + 12);
663
+ let shimmer = '';
664
+ for (let i = 0; i < text.length; i++) {
665
+ const d = Math.abs(i - head);
666
+ shimmer += hexToAnsi(d === 0 ? '#ffffff' : d <= 2 ? T.text : T.secondary) + text[i];
667
+ }
668
+ const meta = [fmtSecs(secs), steps > 0 ? `step ${steps}` : null, 'esc to interrupt'].filter(Boolean).join(' · ');
669
+ return React.createElement(
670
+ Box,
671
+ { flexDirection: 'column', marginBottom: 1, width },
672
+ React.createElement(Text, { wrap: 'truncate-end' }, paint(T.accent, frame + ' ') + shimmer + ANSI_RESET + paint(T.faint, ` (${meta})`)),
673
+ queued && React.createElement(
674
+ Text,
675
+ { wrap: 'truncate-end' },
676
+ paint(T.faint, ` ${GL.branch} queued: `) + paint(T.secondary, queued.replace(/\s*\n\s*/g, ' '))
677
+ )
678
+ );
679
+ }
680
+
681
+ function approvalOptions(req) {
682
+ const edits = ['edit_file', 'write_file', 'apply_patch'].includes(req.tool);
683
+ const what = req.tool === 'run' ? 'commands' : edits ? 'file edits' : toolTitle(req.tool)[0].toLowerCase();
684
+ return [
685
+ { verdict: 'once', label: 'Yes' },
686
+ { verdict: 'always', label: `Yes, and don't ask again for ${what} this session` },
687
+ { verdict: 'reject', label: 'No (esc)' },
688
+ ];
689
+ }
690
+
691
+ function approvalQuestion(req) {
692
+ const base = req.path ? path.basename(req.path) : '';
693
+ if (req.tool === 'edit_file') return `Do you want to make this edit to ${base}?`;
694
+ if (req.tool === 'write_file') return /^Create/.test(req.title || '') ? `Do you want to create ${base}?` : `Do you want to overwrite ${base}?`;
695
+ if (req.tool === 'run') return 'Do you want to run this command?';
696
+ return 'Do you want to allow this?';
697
+ }
698
+
699
+ function ApprovalPrompt({ request, selected, cols, rows, cwd }) {
700
+ const accent = request.danger ? T.err : T.accent;
701
+ const width = cols - 2;
702
+ const body = [];
703
+
704
+ if (request.tool === 'run') {
705
+ body.push(React.createElement(Text, { key: 'cmd', wrap: 'wrap' }, paint(T.dim, '$ ') + paint(T.text, request.detail || '')));
706
+ } else if (request.detail && !request.diff) {
707
+ body.push(React.createElement(Text, { key: 'det', wrap: 'wrap' }, paint(T.secondary, request.detail)));
708
+ }
709
+ if (request.diff) {
710
+ const diff = lineDiff(request.diff.search, request.diff.replace);
711
+ const start = lineOf(cwd, request.path, request.diff.search);
712
+ const max = Math.max(3, rows - 16);
713
+ diffRows(diff, start, width - 4, max).forEach((l, i) => body.push(React.createElement(Text, { key: `d${i}` }, l)));
714
+ }
715
+
716
+ const options = approvalOptions(request);
717
+ return React.createElement(
718
+ Box,
719
+ { flexDirection: 'column', borderStyle: GL.border, borderColor: accent, paddingX: 1, marginBottom: 1, width },
720
+ React.createElement(
721
+ Text,
722
+ { wrap: 'truncate-end' },
723
+ bold(accent, request.title || 'Permission needed') +
724
+ (request.path && !String(request.title || '').includes(request.path) ? paint(T.dim, ' ' + request.path) : '') +
725
+ (request.danger ? paint(T.err, ` ${GL.warn} looks destructive`) : '')
726
+ ),
727
+ ...body,
728
+ React.createElement(Text, { key: 'q' }, ' '),
729
+ React.createElement(Text, { key: 'qq', wrap: 'wrap' }, paint(T.text, approvalQuestion(request))),
730
+ ...options.map((o, i) => React.createElement(
731
+ Text,
732
+ { key: `o${i}`, wrap: 'truncate-end' },
733
+ i === selected
734
+ ? paint(accent, `${GL.pointer} ${i + 1}. ${o.label}`)
735
+ : paint(T.secondary, ` ${i + 1}. ${o.label}`)
736
+ ))
737
+ );
738
+ }
739
+
740
+ const PALETTE_ROWS = 8;
741
+
742
+ function CommandsMenu({ commands, selectedIndex, width }) {
743
+ const start = Math.max(0, Math.min(selectedIndex - PALETTE_ROWS + 1, commands.length - PALETTE_ROWS));
744
+ const shown = commands.slice(start, start + PALETTE_ROWS);
745
+ const nameW = Math.max(...COMMANDS.map((c) => c.name.length)) + 3;
746
+ return React.createElement(
747
+ Box,
748
+ { flexDirection: 'column', paddingLeft: 2, width },
749
+ ...shown.map((cmd, k) => {
750
+ const selected = start + k === selectedIndex;
751
+ return React.createElement(
752
+ Text,
753
+ { key: cmd.name, wrap: 'truncate-end' },
754
+ selected
755
+ ? bold(T.accent, cmd.name.padEnd(nameW)) + paint(T.text, cmd.description)
756
+ : paint(T.secondary, cmd.name.padEnd(nameW)) + paint(T.dim, cmd.description)
757
+ );
758
+ }),
759
+ commands.length > PALETTE_ROWS && React.createElement(
760
+ Text,
761
+ { key: 'more' },
762
+ paint(T.faint, `${commands.length - PALETTE_ROWS} more, keep typing to filter`)
763
+ )
764
+ );
765
+ }
766
+
767
+ function Shortcuts({ width, newlineKey }) {
768
+ const items = [
769
+ ['/', 'for commands'], ['tab', 'to switch mode'], ['shift+tab', 'to toggle bypass'],
770
+ [newlineKey, 'for a new line'], ['\\ enter', 'for a new line'], ['↑ ↓', 'for prompt history'],
771
+ ['esc', 'to interrupt'], ['esc esc', 'to clear input'], ['ctrl+c', 'twice to quit'],
772
+ ];
773
+ const per = width >= 96 ? 3 : width >= 60 ? 2 : 1;
774
+ const colW = Math.floor((width - 2) / per);
775
+ const lines = [];
776
+ for (let i = 0; i < items.length; i += per) {
777
+ lines.push(items.slice(i, i + per).map(([k, d]) => {
778
+ const cell = paint(T.text, k) + ' ' + paint(T.dim, d);
779
+ return cell + ' '.repeat(Math.max(1, colW - k.length - d.length - 1));
780
+ }).join(''));
781
+ }
782
+ return React.createElement(
783
+ Box,
784
+ { flexDirection: 'column', paddingLeft: 2, width },
785
+ ...lines.map((l, i) => React.createElement(Text, { key: i, wrap: 'truncate-end' }, l))
786
+ );
787
+ }
788
+
789
+ // ─── App ────────────────────────────────────────────────────────────────────
790
+
791
+ const BANNER_ITEM = { id: 'banner', role: 'banner' };
792
+
793
+ const App = () => {
794
+ const { exit } = useApp();
795
+ const { stdout } = useStdout();
796
+ const [cols, setCols] = React.useState(stdout?.columns || 80);
797
+ const [rows, setRows] = React.useState(stdout?.rows || 24);
798
+
799
+ const [input, setInput] = React.useState('');
800
+ const [modeIndex, setModeIndex] = React.useState(0);
801
+ const [session, setSession] = React.useState(null);
802
+ const [booted, setBooted] = React.useState(false);
803
+ const [running, setRunning] = React.useState(false);
804
+ const [runStarted, setRunStarted] = React.useState(0);
805
+ const [statusLine, setStatusLine] = React.useState('');
806
+ const [steps, setSteps] = React.useState(0);
807
+ const [chatHistory, setChatHistory] = React.useState([]);
808
+ const [staticKey, setStaticKey] = React.useState(0);
809
+ const [showCommands, setShowCommands] = React.useState(false);
810
+ const [selectedCommand, setSelectedCommand] = React.useState(0);
811
+ const [filteredCommands, setFilteredCommands] = React.useState(COMMANDS);
812
+ const [showShortcuts, setShowShortcuts] = React.useState(false);
813
+ const [escArmed, setEscArmed] = React.useState(false);
814
+ const [ctrlCArmed, setCtrlCArmed] = React.useState(false);
815
+ const [approval, setApproval] = React.useState(null);
816
+ const [approvalIndex, setApprovalIndex] = React.useState(0);
817
+ const [bypassMode, setBypassMode] = React.useState(false);
818
+ const [activeAgents, setActiveAgents] = React.useState([]); // [{id,label}] - running subagents, this session's own run excluded
819
+ const [queued, setQueued] = React.useState(null);
820
+ const [exiting, setExiting] = React.useState(false);
821
+ // Only true once the terminal answers our capability query. Windows Terminal
822
+ // never does, so there we advertise ctrl+j instead of lying about shift+enter.
823
+ const [modifiedEnterWorks, setModifiedEnterWorks] = React.useState(false);
824
+ const [keyProbe, setKeyProbe] = React.useState(false);
825
+ // 'classic' is codeply-cli's own agent; 'dsh' is Craft's DeepSeek Harness
826
+ // fork, driven over ACP (see dsh-acp.mjs). `codeply dsh` starts in DSH.
827
+ const [engine, setEngine] = React.useState(process.env.CODEPLY_ENGINE === 'dsh' ? 'dsh' : 'classic');
828
+ const dshRef = React.useRef(null);
829
+ const getDsh = React.useCallback(() => {
830
+ if (!dshRef.current) dshRef.current = new DshEngine({ cwd: process.cwd() });
831
+ return dshRef.current;
832
+ }, []);
833
+ const closeDsh = React.useCallback(() => {
834
+ if (dshRef.current) { dshRef.current.close(); dshRef.current = null; }
835
+ }, []);
836
+ React.useEffect(() => {
837
+ process.once('exit', closeDsh);
838
+ return () => { process.off('exit', closeDsh); closeDsh(); };
839
+ }, [closeDsh]);
840
+ // Boot the engine in the background as soon as DSH is picked: its first
841
+ // start takes a few seconds, and this hides most of it behind typing.
842
+ React.useEffect(() => {
843
+ if (engine !== 'dsh') return;
844
+ getDsh().start().catch((e) => push({ role: 'error', content: e.message }));
845
+ }, [engine]);
846
+
847
+ const nextId = React.useRef(1);
848
+ // A real AbortController, not a plain {aborted} object: its .signal ends up
849
+ // handed straight to fetch() (see ai.js), which requires an actual
850
+ // AbortSignal instance - a look-alike object throws a TypeError there
851
+ // ("Expected signal ... to be an instance of AbortSignal"), and that
852
+ // message contains the word "AbortSignal", which the fetch error classifier
853
+ // then mistakes for a real abort. Net effect: every single turn "aborted"
854
+ // immediately, before the model was ever called.
855
+ const abortRef = React.useRef(new AbortController());
856
+ const alwaysAllow = React.useRef(new Set());
857
+ const convo = React.useRef([]); // {role,content} pairs handed to the agent
858
+ const runningRef = React.useRef(false);
859
+ const queuedRef = React.useRef(null);
860
+ const submitRef = React.useRef(null);
861
+ // Prompt history for up/down: past prompts, where we are in them, and the
862
+ // unsent draft to come back to.
863
+ const promptHistory = React.useRef([]);
864
+ const historyPos = React.useRef(-1);
865
+ const draft = React.useRef('');
866
+
867
+ const cwd = process.cwd();
868
+ const mode = MODES[modeIndex];
869
+ const modeColor = MODE_COLOR[mode];
870
+
871
+ const push = React.useCallback((msg) => {
872
+ setChatHistory((prev) => [...prev, { id: `m-${nextId.current++}`, ...msg }]);
873
+ }, []);
874
+
875
+ // The banner names the account, so hold it back until the session lookup
876
+ // settles (or briefly times out) instead of printing "not signed in" first.
877
+ React.useEffect(() => {
878
+ let done = false;
879
+ const finish = () => { if (!done) { done = true; setBooted(true); } };
880
+ auth.getSession().then((s) => setSession(s)).catch(() => {}).finally(finish);
881
+ const t = setTimeout(finish, 1200);
882
+ return () => clearTimeout(t);
883
+ }, []);
884
+
885
+ // Ask the terminal to disambiguate shift+enter from enter (see textarea.mjs).
886
+ React.useEffect(() => enableModifiedEnter(stdout), [stdout]);
887
+
888
+ React.useEffect(() => {
889
+ if (!stdout) return;
890
+ const onResize = () => { setCols(stdout.columns || 80); setRows(stdout.rows || 24); };
891
+ stdout.on('resize', onResize);
892
+ return () => stdout.off('resize', onResize);
893
+ }, [stdout]);
894
+
895
+ // A width change reflows every line, and <Static> cannot reprint what it
896
+ // already wrote: clear and print the whole transcript again at the new width.
897
+ const firstWidth = React.useRef(true);
898
+ React.useEffect(() => {
899
+ if (firstWidth.current) { firstWidth.current = false; return; }
900
+ const t = setTimeout(() => {
901
+ if (stdout) stdout.write('\x1b[2J\x1b[3J\x1b[H');
902
+ setStaticKey((k) => k + 1);
903
+ }, 150);
904
+ return () => clearTimeout(t);
905
+ }, [cols]);
906
+
907
+ React.useEffect(() => {
908
+ if (input.startsWith('/') && !input.includes('\n')) {
909
+ const query = input.toLowerCase().split(/\s/)[0];
910
+ const filtered = COMMANDS.filter((cmd) => cmd.name.startsWith(query));
911
+ setFilteredCommands(filtered);
912
+ setShowCommands(filtered.length > 0 && !input.includes(' '));
913
+ setSelectedCommand(0);
914
+ } else {
915
+ setShowCommands(false);
916
+ }
917
+ }, [input]);
918
+
919
+ React.useEffect(() => {
920
+ if (!escArmed) return;
921
+ const t = setTimeout(() => setEscArmed(false), 1500);
922
+ return () => clearTimeout(t);
923
+ }, [escArmed]);
924
+
925
+ React.useEffect(() => {
926
+ if (!ctrlCArmed) return;
927
+ const t = setTimeout(() => setCtrlCArmed(false), 1500);
928
+ return () => clearTimeout(t);
929
+ }, [ctrlCArmed]);
930
+
931
+ // Leave nothing behind: blank the live frame first, then exit, so the shell
932
+ // prompt lands right under the transcript instead of under a dead input box.
933
+ const quit = React.useCallback(() => {
934
+ closeDsh();
935
+ setExiting(true);
936
+ setTimeout(() => exit(), 30);
937
+ }, [exit]);
938
+
939
+ // ── approval bridge: tools await this promise, the keypress handler settles it ──
940
+ const requestApproval = React.useCallback((request) => {
941
+ if (bypassModeRef.current) return Promise.resolve('always');
942
+ if (alwaysAllow.current.has(request.tool)) return Promise.resolve('always');
943
+ return new Promise((resolve) => {
944
+ setApprovalIndex(0);
945
+ setApproval({ request, resolve });
946
+ });
947
+ }, []);
948
+
949
+ // Read inside the approval callback without re-creating it on every toggle -
950
+ // requestApproval is captured once per run and handed down through several
951
+ // layers of nested subagent calls, so it must see the current value, not a
952
+ // stale one closed over when the run started.
953
+ const bypassModeRef = React.useRef(false);
954
+ React.useEffect(() => { bypassModeRef.current = bypassMode; }, [bypassMode]);
955
+
956
+ // ── subagent bridge: nested runs report in here so the footer can show them ──
957
+ const onSubagentEvent = React.useCallback((evt) => {
958
+ if (evt.type === 'start') {
959
+ setActiveAgents((prev) => [...prev, { id: evt.id, label: evt.label }]);
960
+ } else if (evt.type === 'end') {
961
+ setActiveAgents((prev) => prev.filter((a) => a.id !== evt.id));
962
+ }
963
+ }, []);
964
+
965
+ const settleApproval = React.useCallback((verdict) => {
966
+ setApproval((current) => {
967
+ if (!current) return null;
968
+ if (verdict === 'always') alwaysAllow.current.add(current.request.tool);
969
+ current.resolve(verdict);
970
+ return null;
971
+ });
972
+ }, []);
973
+
974
+ // Up/down on the first/last line walks through earlier prompts.
975
+ const onHistory = React.useCallback((dir) => {
976
+ const list = promptHistory.current;
977
+ if (!list.length) return false;
978
+ let pos = historyPos.current;
979
+ if (pos === -1) {
980
+ if (dir > 0) return false;
981
+ draft.current = input;
982
+ pos = list.length - 1;
983
+ } else {
984
+ pos += dir;
985
+ }
986
+ if (pos < 0) pos = 0;
987
+ if (pos >= list.length) {
988
+ historyPos.current = -1;
989
+ setInput(draft.current);
990
+ return true;
991
+ }
992
+ historyPos.current = pos;
993
+ setInput(list[pos]);
994
+ return true;
995
+ }, [input]);
996
+
997
+ // "?" on an empty input opens the shortcuts list instead of typing a "?".
998
+ const onChange = React.useCallback((next) => {
999
+ if (input === '' && next === '?') { setShowShortcuts((s) => !s); return; }
1000
+ if (showShortcuts) setShowShortcuts(false);
1001
+ setInput(next);
1002
+ }, [input, showShortcuts]);
1003
+
1004
+ useInput((char, key) => {
1005
+ // The terminal's answer to our kitty query - proof that modified enter is
1006
+ // really distinguishable here. Swallow it so it never reaches the draft.
1007
+ if (isTerminalReport(char)) { setModifiedEnterWorks(true); return; }
1008
+
1009
+ // /keys diagnostic: report exactly what the terminal sent for one keypress.
1010
+ if (keyProbe) {
1011
+ setKeyProbe(false);
1012
+ const seq = JSON.stringify(char);
1013
+ const flags = ['ctrl', 'shift', 'meta', 'return', 'tab', 'escape', 'backspace', 'delete']
1014
+ .filter((f) => key[f]).join(', ') || 'none';
1015
+ push({ role: 'system', content: `Key sent: ${seq}\nFlags: ${flags}` });
1016
+ return;
1017
+ }
1018
+
1019
+ // The approval prompt owns the keyboard while it is up.
1020
+ if (approval) {
1021
+ const options = approvalOptions(approval.request);
1022
+ if (key.upArrow) { setApprovalIndex((i) => (i > 0 ? i - 1 : options.length - 1)); return; }
1023
+ if (key.downArrow || key.tab) { setApprovalIndex((i) => (i + 1) % options.length); return; }
1024
+ if (key.return) { settleApproval(options[approvalIndex].verdict); return; }
1025
+ if (char === '1' || char === 'y') { settleApproval('once'); return; }
1026
+ if (char === '2' || char === 'a') { settleApproval('always'); return; }
1027
+ if (char === '3' || char === 'n' || key.escape) { settleApproval('reject'); return; }
1028
+ return;
1029
+ }
1030
+
1031
+ if (key.escape) {
1032
+ if (running) { abortRef.current.abort(); setStatusLine('Interrupting'); return; }
1033
+ if (showCommands) { setShowCommands(false); return; }
1034
+ if (showShortcuts) { setShowShortcuts(false); return; }
1035
+ if (input.length > 0) {
1036
+ if (escArmed) { setInput(''); setEscArmed(false); }
1037
+ else { setEscArmed(true); }
1038
+ return;
1039
+ }
1040
+ return;
1041
+ }
1042
+
1043
+ if (key.ctrl && char === 'c') {
1044
+ if (running) { abortRef.current.abort(); return; }
1045
+ if (input.length > 0) { setInput(''); return; }
1046
+ if (ctrlCArmed) { quit(); } else { setCtrlCArmed(true); }
1047
+ return;
1048
+ }
1049
+
1050
+ if (key.tab && key.shift) {
1051
+ setBypassMode((b) => !b);
1052
+ return;
1053
+ }
1054
+
1055
+ if (key.tab) {
1056
+ if (showCommands && filteredCommands.length > 0) {
1057
+ const sel = filteredCommands[selectedCommand];
1058
+ if (sel) { setInput(sel.name + ' '); setShowCommands(false); }
1059
+ return;
1060
+ }
1061
+ if (engine === 'dsh') return; // modes belong to the Classic engine
1062
+ setModeIndex((i) => (i + 1) % MODES.length);
1063
+ return;
1064
+ }
1065
+
1066
+ if (showCommands) {
1067
+ if (key.upArrow) { setSelectedCommand((p) => (p > 0 ? p - 1 : filteredCommands.length - 1)); return; }
1068
+ if (key.downArrow) { setSelectedCommand((p) => (p < filteredCommands.length - 1 ? p + 1 : 0)); return; }
1069
+ }
1070
+ });
1071
+
1072
+ // ── slash commands ──
1073
+ const runCommand = async (cmd, args = '') => {
1074
+ if (cmd === '/login') {
1075
+ push({ role: 'system', content: 'Opening sign-in…' });
1076
+ try {
1077
+ await auth.login();
1078
+ const s = await auth.getSession();
1079
+ setSession(s);
1080
+ push({ role: 'system', content: s ? `Signed in as ${s.user.email}` : 'Login cancelled or failed' });
1081
+ } catch (e) {
1082
+ push({ role: 'error', content: e.message || 'Login failed' });
1083
+ }
1084
+ } else if (cmd === '/logout') {
1085
+ await auth.logout();
1086
+ setSession(null);
1087
+ push({ role: 'system', content: 'Signed out' });
1088
+ } else if (cmd === '/whoami') {
1089
+ const s = await auth.getSession();
1090
+ push({ role: 'system', content: s ? s.user.email : 'Not signed in' });
1091
+ } else if (cmd === '/help') {
1092
+ const w = Math.max(...COMMANDS.map((c) => c.name.length)) + 3;
1093
+ push({
1094
+ role: 'system',
1095
+ content: 'Commands\n' + COMMANDS.map((c) => ` ${c.name.padEnd(w)}${c.description}`).join('\n') +
1096
+ '\n\nKeys\n' +
1097
+ ` ${(newlineKey + ' or \\ enter').padEnd(22)}new line\n` +
1098
+ ` ${'tab'.padEnd(22)}switch mode (Build, Plan, Ask)\n` +
1099
+ ` ${'shift+tab'.padEnd(22)}toggle bypass permissions\n` +
1100
+ ` ${'↑ ↓'.padEnd(22)}prompt history\n` +
1101
+ ` ${'esc'.padEnd(22)}interrupt a run, or twice to clear the input\n` +
1102
+ ` ${'ctrl+c'.padEnd(22)}twice to quit\n` +
1103
+ `\nModel\n ${appConfig.describeProvider()} (change with /provider)\n` +
1104
+ `\nWhat's new\n${WHATS_NEW.map((l) => ` ${l}`).join('\n')}`,
1105
+ });
1106
+ } else if (cmd === '/clear') {
1107
+ // <Static> never re-renders what it already printed, so a clear means
1108
+ // wiping the terminal and remounting it with a fresh key. The banner
1109
+ // comes back with it.
1110
+ convo.current = [];
1111
+ // A fresh DSH session too: the engine keeps its own history per session.
1112
+ if (engine === 'dsh') closeDsh();
1113
+ if (stdout) stdout.write('\x1b[2J\x1b[3J\x1b[H');
1114
+ setChatHistory([]);
1115
+ setStaticKey((k) => k + 1);
1116
+ } else if (cmd === '/mode') {
1117
+ const next = (modeIndex + 1) % MODES.length;
1118
+ setModeIndex(next);
1119
+ push({ role: 'system', content: `${MODES[next]} mode: ${MODE_HINT[MODES[next]]}` });
1120
+ } else if (cmd === '/provider') {
1121
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1122
+ const name = parts[0];
1123
+
1124
+ if (!name) {
1125
+ const cfg = appConfig.getConfig();
1126
+ let msg = `Provider: ${appConfig.describeProvider(cfg)}\n` +
1127
+ `Providers: ${appConfig.PROVIDERS.join(', ')}\n` +
1128
+ 'Set with /provider <name> [key], for example /provider openrouter sk-or-v1-...';
1129
+ if (cfg.provider === 'codeply') msg += `\n\n${appConfig.byokHint()}`;
1130
+ push({ role: 'system', content: msg });
1131
+ return;
1132
+ }
1133
+
1134
+ if (!appConfig.PROVIDERS.includes(name)) {
1135
+ push({ role: 'error', content: `Unknown provider "${name}". Use one of: ${appConfig.PROVIDERS.join(', ')}.` });
1136
+ return;
1137
+ }
1138
+
1139
+ const key = parts[1];
1140
+ const patch = { provider: name };
1141
+ if (key && (appConfig.BYOK_PROVIDERS.includes(name) || name === 'ollama')) {
1142
+ patch[name] = { apiKey: key };
1143
+ }
1144
+
1145
+ const saved = appConfig.saveConfig(patch);
1146
+ if (!saved.ok) {
1147
+ push({ role: 'error', content: `Could not save config: ${saved.error}` });
1148
+ return;
1149
+ }
1150
+ setProvider(appConfig.getConfig());
1151
+ push({ role: 'system', content: `Provider set to ${appConfig.describeProvider(appConfig.getConfig())}` });
1152
+ } else if (cmd === '/engine') {
1153
+ const want = args.trim().toLowerCase();
1154
+ if (!want) {
1155
+ push({ role: 'system', content: `Engine: ${engine === 'dsh' ? "DSH (Craft's DeepSeek Harness fork, over ACP)" : 'Classic (codeply-cli)'}\nSwitch with /engine classic or /engine dsh` });
1156
+ return;
1157
+ }
1158
+ if (want !== 'dsh' && want !== 'classic') {
1159
+ push({ role: 'error', content: `Unknown engine "${want}". Use classic or dsh.` });
1160
+ return;
1161
+ }
1162
+ if (want === engine) { push({ role: 'system', content: `Already on the ${want === 'dsh' ? 'DSH' : 'Classic'} engine.` }); return; }
1163
+ if (want === 'classic') closeDsh();
1164
+ setEngine(want);
1165
+ push({ role: 'system', content: want === 'dsh' ? 'Switched to the DSH engine. Starting it…' : 'Switched to the Classic engine.' });
1166
+ } else if (cmd === '/keys') {
1167
+ push({ role: 'system', content: 'Press any key (or combination)…' });
1168
+ setKeyProbe(true);
1169
+ } else if (cmd === '/skill') {
1170
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1171
+ const sub = parts[0];
1172
+
1173
+ if (!sub || sub === 'list') {
1174
+ const list = skills.listSkills();
1175
+ if (list.length === 0) {
1176
+ push({ role: 'system', content: 'No skills available.' });
1177
+ } else {
1178
+ const lines = list.map((s) => `${s.name.padEnd(28)} [${s.source}] ${s.description}`);
1179
+ push({ role: 'system', content: `Skills (${list.length})\n` + lines.join('\n') +
1180
+ '\n\n/skill install <path-or-github-url> add one\n/skill remove <name> remove a user-installed one' });
1181
+ }
1182
+ return;
1183
+ }
1184
+
1185
+ if (sub === 'install') {
1186
+ const source = parts.slice(1).join(' ');
1187
+ if (!source) { push({ role: 'error', content: 'Usage: /skill install <local-path-or-github-url>' }); return; }
1188
+ push({ role: 'system', content: `Installing from ${source}…` });
1189
+ try {
1190
+ const isLocal = fs.existsSync(source);
1191
+ const result = isLocal
1192
+ ? skills.installFromLocalDir(path.resolve(cwd, source))
1193
+ : await skills.installFromGitHub(source);
1194
+ if (isLocal) {
1195
+ if (!result.ok) push({ role: 'error', content: result.error });
1196
+ else push({ role: 'system', content: `Installed "${result.name}" to ${result.dest}` });
1197
+ } else if (!result.ok) {
1198
+ push({ role: 'error', content: result.error || `No skills installed from ${source}.` });
1199
+ } else {
1200
+ const names = result.installed.map((i) => i.name).join(', ');
1201
+ push({ role: 'system', content: `Installed ${result.installed.length} skill(s) from ${result.source}: ${names}` });
1202
+ if (result.failed.length) {
1203
+ push({ role: 'error', content: `${result.failed.length} failed: ${result.failed.map((f) => f.dir).join(', ')}` });
1204
+ }
1205
+ }
1206
+ } catch (e) {
1207
+ push({ role: 'error', content: `Install failed: ${e.message}` });
1208
+ }
1209
+ return;
1210
+ }
1211
+
1212
+ if (sub === 'remove') {
1213
+ const name = parts[1];
1214
+ if (!name) { push({ role: 'error', content: 'Usage: /skill remove <name>' }); return; }
1215
+ const result = skills.removeSkill(name);
1216
+ push(result.ok
1217
+ ? { role: 'system', content: `Removed "${name}".` }
1218
+ : { role: 'error', content: result.error });
1219
+ return;
1220
+ }
1221
+
1222
+ push({ role: 'error', content: `Unknown /skill subcommand "${sub}". Use list, install, or remove.` });
1223
+ } else if (cmd === '/quit' || cmd === '/exit') {
1224
+ quit();
1225
+ } else {
1226
+ push({ role: 'error', content: `Unknown command: ${cmd}. Type /help for available commands.` });
1227
+ }
1228
+ };
1229
+
1230
+ // ── main turn ──
1231
+ const handleSubmit = async (value) => {
1232
+ const text = value.trim();
1233
+ if (!text) return;
1234
+
1235
+ let submitValue = text;
1236
+ if (showCommands && filteredCommands[selectedCommand]) {
1237
+ const raw = text.split(/\s+/)[0].toLowerCase();
1238
+ if (!COMMANDS.some((c) => c.name === raw)) {
1239
+ submitValue = filteredCommands[selectedCommand].name;
1240
+ }
1241
+ }
1242
+ setShowCommands(false);
1243
+ setShowShortcuts(false);
1244
+ setInput('');
1245
+ historyPos.current = -1;
1246
+ draft.current = '';
1247
+ const list = promptHistory.current;
1248
+ if (list[list.length - 1] !== submitValue) list.push(submitValue);
1249
+
1250
+ // Typed while a run is going: hold it and send it when the run ends.
1251
+ if (runningRef.current) {
1252
+ queuedRef.current = submitValue;
1253
+ setQueued(submitValue);
1254
+ return;
1255
+ }
1256
+
1257
+ if (submitValue.startsWith('/')) {
1258
+ const [first, ...rest] = submitValue.split(/\s+/);
1259
+ const cmd = first.toLowerCase();
1260
+ if (cmd !== '/clear' && cmd !== '/quit' && cmd !== '/exit') push({ role: 'user', content: submitValue });
1261
+ await runCommand(cmd, rest.join(' '));
1262
+ return;
1263
+ }
1264
+
1265
+ push({ role: 'user', content: submitValue });
1266
+ runningRef.current = true;
1267
+ setRunning(true);
1268
+ const startedAt = Date.now();
1269
+ setRunStarted(startedAt);
1270
+ setStatusLine('Thinking');
1271
+ setSteps(0);
1272
+ abortRef.current = new AbortController();
1273
+ let runMode = mode;
1274
+
1275
+ const turn = [];
1276
+ try {
1277
+ if (engine === 'dsh' && !(dshRef.current && dshRef.current.sessionId)) setStatusLine('Starting the DSH engine');
1278
+ const stream = engine === 'dsh'
1279
+ ? getDsh().prompt(submitValue, { approve: requestApproval, signal: abortRef.current.signal })
1280
+ : runAgent({
1281
+ userMessage: submitValue,
1282
+ history: convo.current.slice(-20),
1283
+ mode,
1284
+ cwd,
1285
+ approve: requestApproval,
1286
+ signal: abortRef.current.signal,
1287
+ onSubagentEvent,
1288
+ });
1289
+
1290
+ for await (const event of stream) {
1291
+ if (event.type === 'text') {
1292
+ // In Plan mode the substantial reply is the plan itself; short
1293
+ // interstitial notes ("let me check X") stay as ordinary prose.
1294
+ const isPlan = runMode === 'Plan' && !event.interim && event.text.split('\n').length >= 3;
1295
+ push({ role: 'assistant', content: event.text, plan: isPlan });
1296
+ turn.push({ role: 'assistant', content: event.text });
1297
+ setStatusLine('Thinking');
1298
+ } else if (event.type === 'reasoning') {
1299
+ push({ role: 'reasoning', text: event.text, ms: event.ms });
1300
+ } else if (event.type === 'notice') {
1301
+ push({ role: 'notice', level: event.level, content: event.text });
1302
+ } else if (event.type === 'tool_start') {
1303
+ const [, verb] = toolTitle(event.name);
1304
+ const target = argTarget(event.args).split('\n')[0];
1305
+ setSteps((n) => n + 1);
1306
+ setStatusLine(target ? `${verb} ${target}` : verb);
1307
+ } else if (event.type === 'tool_end') {
1308
+ push(toolMessage(event.engine === 'dsh' ? fromDsh(event) : event, cwd));
1309
+ setStatusLine('Thinking');
1310
+ } else if (event.type === 'verifying') {
1311
+ setStatusLine(`Verifying ${plural((event.files || []).length, 'file')}`);
1312
+ } else if (event.type === 'mode_switch') {
1313
+ const idx = MODES.indexOf(event.mode);
1314
+ if (idx >= 0) { setModeIndex(idx); runMode = event.mode; }
1315
+ push({ role: 'system', content: `Switched to ${event.mode} mode${event.planPath ? `. Plan saved to ${event.planPath}` : ''}` });
1316
+ } else if (event.type === 'done') {
1317
+ push({
1318
+ role: 'done',
1319
+ secs: Math.max(1, Math.round((Date.now() - startedAt) / 1000)),
1320
+ steps: event.steps,
1321
+ files: event.writtenFiles || [],
1322
+ });
1323
+ } else if (event.type === 'aborted') {
1324
+ push({ role: 'notice', level: 'info', content: 'Interrupted. Tell Codeply what to do instead, or press ↑ to edit your last message.' });
1325
+ } else if (event.type === 'error') {
1326
+ let content = event.error;
1327
+ if (event.limited && event.resetsAt) {
1328
+ const at = new Date(event.resetsAt);
1329
+ if (!Number.isNaN(at.getTime())) content += `\nResets at ${at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`;
1330
+ }
1331
+ if (event.limited) content += `\n${appConfig.byokHint()}`;
1332
+ push({ role: 'error', content });
1333
+ }
1334
+ }
1335
+ } catch (e) {
1336
+ push({ role: 'error', content: e.message });
1337
+ } finally {
1338
+ convo.current = [...convo.current, { role: 'user', content: submitValue }, ...turn];
1339
+ runningRef.current = false;
1340
+ setRunning(false);
1341
+ setStatusLine('');
1342
+ // A run that ended while a prompt was up must not leave a dangling promise.
1343
+ setApproval((current) => { if (current) current.resolve('reject'); return null; });
1344
+ const next = queuedRef.current;
1345
+ if (next) {
1346
+ queuedRef.current = null;
1347
+ setQueued(null);
1348
+ setTimeout(() => submitRef.current && submitRef.current(next), 0);
1349
+ }
1350
+ }
1351
+ };
1352
+ submitRef.current = handleSubmit;
1353
+
1354
+ // Ollama needs no Codeply sign-in, so showing "not signed in" there would be
1355
+ // a false alarm - surface the model that is actually answering instead.
1356
+ const [provider, setProvider] = React.useState(() => appConfig.getConfig());
1357
+ // The DSH engine always answers through Haze 2, whatever /provider says.
1358
+ const usingHaze = engine === 'dsh' || provider.provider === 'codeply';
1359
+ const needsLogin = usingHaze && !session;
1360
+ const modelLabel = engine === 'dsh' ? 'Haze 2 (auto) on the DSH engine' : usingHaze ? 'Haze 2 (auto)' : appConfig.describeProvider(provider);
1361
+ const modelShort = engine === 'dsh' ? 'DSH · Haze 2' : usingHaze ? 'Haze 2' : appConfig.describeProviderShort(provider);
1362
+ const account = session ? session.user.email : usingHaze ? 'not signed in' : 'using your own key';
1363
+
1364
+ // Advertise the key that actually inserts a newline in THIS terminal.
1365
+ const newlineKey = modifiedEnterWorks ? 'shift+enter' : 'ctrl+j';
1366
+
1367
+ if (exiting) return null;
1368
+
1369
+ const width = Math.max(20, cols - 2);
1370
+ const items = booted ? [BANNER_ITEM, ...chatHistory] : [];
1371
+ const bannerProps = {
1372
+ account, needsLogin, model: modelLabel, cwd, mode: engine === 'dsh' ? 'DSH engine' : `${mode} mode`,
1373
+ name: (session?.user?.email || '').split('@')[0] || null,
1374
+ };
1375
+
1376
+ // ── footer: one line, left = state, right = model ──
1377
+ let left;
1378
+ if (escArmed) left = paint(T.warn, `Press esc again to clear`);
1379
+ else if (ctrlCArmed) left = paint(T.warn, 'Press ctrl+c again to exit');
1380
+ else {
1381
+ left = (bypassMode ? paint(T.warn, `${GL.bypass} bypass permissions on`) + paint(T.faint, ' · ') : '') +
1382
+ (engine === 'dsh'
1383
+ ? paint(T.accent, 'DSH engine') + paint(T.faint, ' (/engine to switch)')
1384
+ : paint(modeColor, `${mode} mode`) + paint(T.faint, ' (tab to switch)'));
1385
+ if (!bypassMode && !running && !input) left += paint(T.faint, ' · ? for shortcuts');
1386
+ }
1387
+ const subagents = activeAgents.length;
1388
+ const rightPlain = (subagents ? `${plural(subagents, 'subagent')} · ` : '') + (needsLogin ? 'not signed in · /login' : modelShort);
1389
+ const right = (subagents ? paint(T.accent, `${plural(subagents, 'subagent')}`) + paint(T.faint, ' · ') : '') +
1390
+ (needsLogin ? paint(T.warn, 'not signed in · /login') : paint(T.dim, modelShort));
1391
+ const showRight = width - 4 - rightPlain.length >= 24;
1392
+
1393
+ const footer = React.createElement(
1394
+ Box,
1395
+ { key: 'footer', flexDirection: 'row', width, paddingX: 2, flexShrink: 0 },
1396
+ React.createElement(
1397
+ Box,
1398
+ { flexGrow: 1, flexShrink: 1 },
1399
+ React.createElement(Text, { wrap: 'truncate-end' }, left)
1400
+ ),
1401
+ showRight && React.createElement(Box, { flexShrink: 0, marginLeft: 2 }, React.createElement(Text, null, right))
1402
+ );
1403
+
1404
+ const borderColor = approval ? T.faint : bypassMode ? T.warn : mode === 'Build' ? T.border : modeColor;
1405
+ const composer = React.createElement(
1406
+ Box,
1407
+ { key: 'composer', flexDirection: 'row', borderStyle: GL.border, borderColor, paddingX: 1, width, flexShrink: 0 },
1408
+ React.createElement(Text, null, paint(running ? T.faint : T.accent, GL.prompt + ' ')),
1409
+ React.createElement(TextArea, {
1410
+ value: input,
1411
+ onChange,
1412
+ onSubmit: handleSubmit,
1413
+ onHistory,
1414
+ width: width - 6,
1415
+ focus: !approval && !keyProbe,
1416
+ allowVertical: !showCommands,
1417
+ placeholder: running
1418
+ ? 'Type to queue a follow-up'
1419
+ : 'Ask for a change, a fix, or an explanation',
1420
+ })
1421
+ );
1422
+
1423
+ return React.createElement(
1424
+ Box,
1425
+ { flexDirection: 'column', width: cols },
1426
+
1427
+ // ── Scrollback: printed once, never redrawn. Keeps the live frame short. ──
1428
+ // <Static> is absolutely positioned, so a parent's right padding does not
1429
+ // narrow it: each item carries its own width instead, or every wrapped
1430
+ // line comes out one column too wide and spills onto the next row.
1431
+ React.createElement(
1432
+ Static,
1433
+ { key: staticKey, items },
1434
+ (msg) => React.createElement(
1435
+ Box,
1436
+ { key: msg.id, width: cols - 1, paddingLeft: 1 },
1437
+ React.createElement(MessageBlock, { msg, cols, rows, banner: bannerProps })
1438
+ )
1439
+ ),
1440
+
1441
+ // ── Live frame below ──
1442
+ React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
1443
+ running && !approval && React.createElement(Working, {
1444
+ label: statusLine || 'Thinking', startedAt: runStarted, steps, queued, width,
1445
+ }),
1446
+
1447
+ approval && React.createElement(ApprovalPrompt, {
1448
+ request: approval.request, selected: approvalIndex, cols, rows, cwd,
1449
+ }),
1450
+
1451
+ booted && composer,
1452
+ booted && showCommands && React.createElement(CommandsMenu, { commands: filteredCommands, selectedIndex: selectedCommand, width }),
1453
+ booted && showShortcuts && !showCommands && React.createElement(Shortcuts, { width, newlineKey }),
1454
+ booted && !showCommands && footer
1455
+ )
1456
+ );
1457
+ };
1458
+
1459
+ export default App;