codeply-cli 0.1.1 → 0.3.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 (384) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +562 -259
  3. package/lib/agent.mjs +1721 -116
  4. package/lib/ai.js +1107 -184
  5. package/lib/apply-limit.js +33 -41
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -98
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +290 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +416 -0
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1709 -73
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -899
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +5 -3
  43. package/skills/SOURCE.md +58 -0
  44. package/skills/accessibility/SKILL.md +147 -0
  45. package/skills/agent-architecture-audit/SKILL.md +257 -0
  46. package/skills/agent-eval/SKILL.md +147 -0
  47. package/skills/agent-harness-construction/SKILL.md +74 -0
  48. package/skills/agent-introspection-debugging/SKILL.md +154 -0
  49. package/skills/agent-payment-x402/SKILL.md +225 -0
  50. package/skills/agent-self-evaluation/SKILL.md +182 -0
  51. package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -0
  52. package/skills/agent-self-evaluation/references/hook-integration.md +64 -0
  53. package/skills/agent-sort/SKILL.md +216 -0
  54. package/skills/agentic-engineering/SKILL.md +64 -0
  55. package/skills/agentic-os/SKILL.md +388 -0
  56. package/skills/ai-first-engineering/SKILL.md +52 -0
  57. package/skills/ai-regression-testing/SKILL.md +386 -0
  58. package/skills/android-clean-architecture/SKILL.md +340 -0
  59. package/skills/angular-developer/SKILL.md +155 -0
  60. package/skills/angular-developer/references/angular-animations.md +160 -0
  61. package/skills/angular-developer/references/angular-aria.md +410 -0
  62. package/skills/angular-developer/references/cli.md +86 -0
  63. package/skills/angular-developer/references/component-harnesses.md +59 -0
  64. package/skills/angular-developer/references/component-styling.md +91 -0
  65. package/skills/angular-developer/references/components.md +117 -0
  66. package/skills/angular-developer/references/creating-services.md +97 -0
  67. package/skills/angular-developer/references/data-resolvers.md +69 -0
  68. package/skills/angular-developer/references/define-routes.md +67 -0
  69. package/skills/angular-developer/references/defining-providers.md +72 -0
  70. package/skills/angular-developer/references/di-fundamentals.md +120 -0
  71. package/skills/angular-developer/references/e2e-testing.md +56 -0
  72. package/skills/angular-developer/references/effects.md +83 -0
  73. package/skills/angular-developer/references/hierarchical-injectors.md +43 -0
  74. package/skills/angular-developer/references/host-elements.md +80 -0
  75. package/skills/angular-developer/references/injection-context.md +63 -0
  76. package/skills/angular-developer/references/inputs.md +101 -0
  77. package/skills/angular-developer/references/linked-signal.md +59 -0
  78. package/skills/angular-developer/references/loading-strategies.md +61 -0
  79. package/skills/angular-developer/references/mcp.md +108 -0
  80. package/skills/angular-developer/references/navigate-to-routes.md +69 -0
  81. package/skills/angular-developer/references/outputs.md +86 -0
  82. package/skills/angular-developer/references/reactive-forms.md +122 -0
  83. package/skills/angular-developer/references/rendering-strategies.md +44 -0
  84. package/skills/angular-developer/references/resource.md +77 -0
  85. package/skills/angular-developer/references/route-animations.md +56 -0
  86. package/skills/angular-developer/references/route-guards.md +52 -0
  87. package/skills/angular-developer/references/router-lifecycle.md +45 -0
  88. package/skills/angular-developer/references/router-testing.md +87 -0
  89. package/skills/angular-developer/references/show-routes-with-outlets.md +68 -0
  90. package/skills/angular-developer/references/signal-forms.md +795 -0
  91. package/skills/angular-developer/references/signals-overview.md +94 -0
  92. package/skills/angular-developer/references/tailwind-css.md +69 -0
  93. package/skills/angular-developer/references/template-driven-forms.md +114 -0
  94. package/skills/angular-developer/references/testing-fundamentals.md +65 -0
  95. package/skills/api-connector-builder/SKILL.md +121 -0
  96. package/skills/api-design/SKILL.md +524 -0
  97. package/skills/architecture-decision-records/SKILL.md +180 -0
  98. package/skills/article-writing/SKILL.md +80 -0
  99. package/skills/automation-audit-ops/SKILL.md +143 -0
  100. package/skills/autonomous-agent-harness/SKILL.md +274 -0
  101. package/skills/autonomous-loops/SKILL.md +611 -0
  102. package/skills/backend-patterns/SKILL.md +562 -0
  103. package/skills/benchmark/SKILL.md +95 -0
  104. package/skills/benchmark-methodology/SKILL.md +191 -0
  105. package/skills/benchmark-optimization-loop/SKILL.md +71 -0
  106. package/skills/blender-motion-state-inspection/SKILL.md +165 -0
  107. package/skills/blueprint/SKILL.md +106 -0
  108. package/skills/brand-discovery/SKILL.md +145 -0
  109. package/skills/brand-discovery/references/10_purpose-why.md +40 -0
  110. package/skills/brand-discovery/references/20_positioning.md +44 -0
  111. package/skills/brand-discovery/references/30_audience-niche.md +52 -0
  112. package/skills/brand-discovery/references/40_personality-archetype.md +57 -0
  113. package/skills/brand-discovery/references/50_voice-tone.md +59 -0
  114. package/skills/brand-discovery/references/60_narrative-story.md +50 -0
  115. package/skills/brand-discovery/references/70_founder-tension.md +49 -0
  116. package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -0
  117. package/skills/brand-voice/SKILL.md +98 -0
  118. package/skills/brand-voice/references/voice-profile-schema.md +55 -0
  119. package/skills/browser-qa/SKILL.md +105 -0
  120. package/skills/bun-runtime/SKILL.md +85 -0
  121. package/skills/canary-watch/SKILL.md +108 -0
  122. package/skills/carrier-relationship-management/SKILL.md +212 -0
  123. package/skills/cisco-ios-patterns/SKILL.md +164 -0
  124. package/skills/ck/SKILL.md +148 -0
  125. package/skills/claude-devfleet/SKILL.md +112 -0
  126. package/skills/click-path-audit/SKILL.md +245 -0
  127. package/skills/clickhouse-io/SKILL.md +445 -0
  128. package/skills/code-tour/SKILL.md +254 -0
  129. package/skills/codebase-onboarding/SKILL.md +234 -0
  130. package/skills/codehealth-mcp/SKILL.md +167 -0
  131. package/skills/coding-standards/SKILL.md +551 -0
  132. package/skills/competitive-platform-analysis/SKILL.md +214 -0
  133. package/skills/competitive-report-structure/SKILL.md +162 -0
  134. package/skills/compose-multiplatform-patterns/SKILL.md +300 -0
  135. package/skills/config-gc/SKILL.md +120 -0
  136. package/skills/configure-ecc/SKILL.md +385 -0
  137. package/skills/connections-optimizer/SKILL.md +190 -0
  138. package/skills/content-engine/SKILL.md +132 -0
  139. package/skills/content-hash-cache-pattern/SKILL.md +162 -0
  140. package/skills/context-budget/SKILL.md +136 -0
  141. package/skills/continuous-agent-loop/SKILL.md +46 -0
  142. package/skills/continuous-learning/SKILL.md +132 -0
  143. package/skills/continuous-learning-v2/SKILL.md +361 -0
  144. package/skills/contract-first/SKILL.md +287 -0
  145. package/skills/cost-aware-llm-pipeline/SKILL.md +184 -0
  146. package/skills/cost-tracking/SKILL.md +97 -0
  147. package/skills/council/SKILL.md +204 -0
  148. package/skills/cpp-coding-standards/SKILL.md +724 -0
  149. package/skills/cpp-testing/SKILL.md +325 -0
  150. package/skills/crosspost/SKILL.md +112 -0
  151. package/skills/csharp-testing/SKILL.md +322 -0
  152. package/skills/customer-billing-ops/SKILL.md +141 -0
  153. package/skills/customs-trade-compliance/SKILL.md +263 -0
  154. package/skills/dart-flutter-patterns/SKILL.md +564 -0
  155. package/skills/dashboard-builder/SKILL.md +109 -0
  156. package/skills/data-scraper-agent/SKILL.md +765 -0
  157. package/skills/data-throughput-accelerator/SKILL.md +74 -0
  158. package/skills/database-migrations/SKILL.md +430 -0
  159. package/skills/deep-research/SKILL.md +160 -0
  160. package/skills/defi-amm-security/SKILL.md +167 -0
  161. package/skills/delivery-gate/SKILL.md +126 -0
  162. package/skills/deployment-patterns/SKILL.md +428 -0
  163. package/skills/design-system/SKILL.md +83 -0
  164. package/skills/django-celery/SKILL.md +458 -0
  165. package/skills/django-patterns/SKILL.md +735 -0
  166. package/skills/django-security/SKILL.md +644 -0
  167. package/skills/django-tdd/SKILL.md +730 -0
  168. package/skills/django-verification/SKILL.md +470 -0
  169. package/skills/dmux-workflows/SKILL.md +192 -0
  170. package/skills/docker-patterns/SKILL.md +365 -0
  171. package/skills/documentation-lookup/SKILL.md +91 -0
  172. package/skills/dotnet-patterns/SKILL.md +322 -0
  173. package/skills/dynamic-workflow-mode/SKILL.md +124 -0
  174. package/skills/e2e-testing/SKILL.md +327 -0
  175. package/skills/ecc-guide/SKILL.md +190 -0
  176. package/skills/ecc-recipes/SKILL.md +149 -0
  177. package/skills/ecc-tools-cost-audit/SKILL.md +161 -0
  178. package/skills/email-ops/SKILL.md +122 -0
  179. package/skills/energy-procurement/SKILL.md +228 -0
  180. package/skills/enterprise-agent-ops/SKILL.md +51 -0
  181. package/skills/error-handling/SKILL.md +377 -0
  182. package/skills/eval-harness/SKILL.md +271 -0
  183. package/skills/evm-token-decimals/SKILL.md +131 -0
  184. package/skills/exa-search/SKILL.md +108 -0
  185. package/skills/fal-ai-media/SKILL.md +289 -0
  186. package/skills/fastapi-patterns/SKILL.md +514 -0
  187. package/skills/finance-billing-ops/SKILL.md +128 -0
  188. package/skills/flox-environments/SKILL.md +497 -0
  189. package/skills/flutter-dart-code-review/SKILL.md +436 -0
  190. package/skills/foundation-models-on-device/SKILL.md +243 -0
  191. package/skills/frontend-a11y/SKILL.md +446 -0
  192. package/skills/frontend-design-direction/SKILL.md +93 -0
  193. package/skills/frontend-patterns/SKILL.md +657 -0
  194. package/skills/frontend-slides/SKILL.md +185 -0
  195. package/skills/fsharp-testing/SKILL.md +281 -0
  196. package/skills/gan-style-harness/SKILL.md +279 -0
  197. package/skills/gateguard/SKILL.md +133 -0
  198. package/skills/generating-python-installer/SKILL.md +820 -0
  199. package/skills/git-workflow/SKILL.md +716 -0
  200. package/skills/github-ops/SKILL.md +145 -0
  201. package/skills/golang-patterns/SKILL.md +676 -0
  202. package/skills/golang-testing/SKILL.md +721 -0
  203. package/skills/google-workspace-ops/SKILL.md +96 -0
  204. package/skills/growth-log/SKILL.md +128 -0
  205. package/skills/healthcare-cdss-patterns/SKILL.md +246 -0
  206. package/skills/healthcare-emr-patterns/SKILL.md +160 -0
  207. package/skills/healthcare-eval-harness/SKILL.md +208 -0
  208. package/skills/healthcare-phi-compliance/SKILL.md +146 -0
  209. package/skills/hermes-imports/SKILL.md +89 -0
  210. package/skills/hexagonal-architecture/SKILL.md +277 -0
  211. package/skills/hipaa-compliance/SKILL.md +79 -0
  212. package/skills/homelab-network-readiness/SKILL.md +170 -0
  213. package/skills/homelab-network-setup/SKILL.md +130 -0
  214. package/skills/homelab-pihole-dns/SKILL.md +275 -0
  215. package/skills/homelab-vlan-segmentation/SKILL.md +312 -0
  216. package/skills/homelab-wireguard-vpn/SKILL.md +306 -0
  217. package/skills/hookify-rules/SKILL.md +128 -0
  218. package/skills/inherit-legacy-style/SKILL.md +157 -0
  219. package/skills/intent-driven-development/SKILL.md +360 -0
  220. package/skills/inventory-demand-planning/SKILL.md +247 -0
  221. package/skills/investor-materials/SKILL.md +97 -0
  222. package/skills/investor-outreach/SKILL.md +92 -0
  223. package/skills/ios-icon-gen/SKILL.md +158 -0
  224. package/skills/iterative-retrieval/SKILL.md +212 -0
  225. package/skills/ito-basket-compare/SKILL.md +64 -0
  226. package/skills/ito-compute/SKILL.md +129 -0
  227. package/skills/ito-data-atlas-agent/SKILL.md +64 -0
  228. package/skills/ito-market-intelligence/SKILL.md +61 -0
  229. package/skills/ito-trade-planner/SKILL.md +68 -0
  230. package/skills/java-coding-standards/SKILL.md +384 -0
  231. package/skills/jira-integration/SKILL.md +303 -0
  232. package/skills/jpa-patterns/SKILL.md +152 -0
  233. package/skills/knowledge-ops/SKILL.md +155 -0
  234. package/skills/kotlin-coroutines-flows/SKILL.md +285 -0
  235. package/skills/kotlin-exposed-patterns/SKILL.md +720 -0
  236. package/skills/kotlin-ktor-patterns/SKILL.md +690 -0
  237. package/skills/kotlin-patterns/SKILL.md +712 -0
  238. package/skills/kotlin-testing/SKILL.md +825 -0
  239. package/skills/kubernetes-patterns/SKILL.md +756 -0
  240. package/skills/laravel-patterns/SKILL.md +416 -0
  241. package/skills/laravel-plugin-discovery/SKILL.md +230 -0
  242. package/skills/laravel-security/SKILL.md +948 -0
  243. package/skills/laravel-tdd/SKILL.md +675 -0
  244. package/skills/laravel-verification/SKILL.md +180 -0
  245. package/skills/latency-critical-systems/SKILL.md +75 -0
  246. package/skills/lead-intelligence/SKILL.md +322 -0
  247. package/skills/liquid-glass-design/SKILL.md +279 -0
  248. package/skills/llm-trading-agent-security/SKILL.md +147 -0
  249. package/skills/logistics-exception-management/SKILL.md +222 -0
  250. package/skills/loop-design-check/SKILL.md +143 -0
  251. package/skills/mailtrap-email-integration/SKILL.md +77 -0
  252. package/skills/make-interfaces-feel-better/SKILL.md +152 -0
  253. package/skills/manim-video/SKILL.md +90 -0
  254. package/skills/market-research/SKILL.md +76 -0
  255. package/skills/marketing-campaign/SKILL.md +114 -0
  256. package/skills/mcp-server-patterns/SKILL.md +70 -0
  257. package/skills/messages-ops/SKILL.md +105 -0
  258. package/skills/ml-adoption-playbook/SKILL.md +57 -0
  259. package/skills/mle-workflow/SKILL.md +348 -0
  260. package/skills/motion-advanced/SKILL.md +596 -0
  261. package/skills/motion-foundations/SKILL.md +299 -0
  262. package/skills/motion-patterns/SKILL.md +434 -0
  263. package/skills/motion-ui/SKILL.md +576 -0
  264. package/skills/mysql-patterns/SKILL.md +413 -0
  265. package/skills/nanoclaw-repl/SKILL.md +34 -0
  266. package/skills/nestjs-patterns/SKILL.md +231 -0
  267. package/skills/netmiko-ssh-automation/SKILL.md +174 -0
  268. package/skills/network-bgp-diagnostics/SKILL.md +168 -0
  269. package/skills/network-config-validation/SKILL.md +211 -0
  270. package/skills/network-interface-health/SKILL.md +153 -0
  271. package/skills/nextjs-turbopack/SKILL.md +58 -0
  272. package/skills/nodejs-keccak256/SKILL.md +103 -0
  273. package/skills/nutrient-document-processing/SKILL.md +168 -0
  274. package/skills/nuxt4-patterns/SKILL.md +101 -0
  275. package/skills/openclaw-persona-forge/SKILL.md +289 -0
  276. package/skills/openclaw-persona-forge/references/avatar-style.md +124 -0
  277. package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -0
  278. package/skills/openclaw-persona-forge/references/error-handling.md +53 -0
  279. package/skills/openclaw-persona-forge/references/identity-tension.md +48 -0
  280. package/skills/openclaw-persona-forge/references/naming-system.md +39 -0
  281. package/skills/openclaw-persona-forge/references/output-template.md +166 -0
  282. package/skills/opensource-pipeline/SKILL.md +256 -0
  283. package/skills/orch-add-feature/SKILL.md +45 -0
  284. package/skills/orch-build-mvp/SKILL.md +49 -0
  285. package/skills/orch-change-feature/SKILL.md +43 -0
  286. package/skills/orch-fix-defect/SKILL.md +43 -0
  287. package/skills/orch-pipeline/SKILL.md +121 -0
  288. package/skills/orch-refine-code/SKILL.md +44 -0
  289. package/skills/parallel-execution-optimizer/SKILL.md +74 -0
  290. package/skills/perl-patterns/SKILL.md +505 -0
  291. package/skills/perl-security/SKILL.md +504 -0
  292. package/skills/perl-testing/SKILL.md +476 -0
  293. package/skills/plan-canvas/SKILL.md +153 -0
  294. package/skills/plan-orchestrate/SKILL.md +263 -0
  295. package/skills/plankton-code-quality/SKILL.md +237 -0
  296. package/skills/postgres-patterns/SKILL.md +148 -0
  297. package/skills/prediction-market-oracle-research/SKILL.md +64 -0
  298. package/skills/prediction-market-risk-review/SKILL.md +61 -0
  299. package/skills/premium-web-design/SKILL.md +154 -0
  300. package/skills/prisma-patterns/SKILL.md +401 -0
  301. package/skills/product-capability/SKILL.md +142 -0
  302. package/skills/product-lens/SKILL.md +93 -0
  303. package/skills/production-audit/SKILL.md +207 -0
  304. package/skills/production-scheduling/SKILL.md +238 -0
  305. package/skills/project-flow-ops/SKILL.md +112 -0
  306. package/skills/prompt-optimizer/SKILL.md +398 -0
  307. package/skills/python-patterns/SKILL.md +751 -0
  308. package/skills/python-testing/SKILL.md +817 -0
  309. package/skills/pytorch-patterns/SKILL.md +397 -0
  310. package/skills/quality-nonconformance/SKILL.md +260 -0
  311. package/skills/quarkus-patterns/SKILL.md +723 -0
  312. package/skills/quarkus-security/SKILL.md +468 -0
  313. package/skills/quarkus-tdd/SKILL.md +812 -0
  314. package/skills/quarkus-verification/SKILL.md +480 -0
  315. package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -0
  316. package/skills/react-native-patterns/SKILL.md +326 -0
  317. package/skills/react-patterns/SKILL.md +342 -0
  318. package/skills/react-performance/SKILL.md +575 -0
  319. package/skills/react-testing/SKILL.md +424 -0
  320. package/skills/recsys-pipeline-architect/SKILL.md +115 -0
  321. package/skills/recursive-decision-ledger/SKILL.md +81 -0
  322. package/skills/redis-patterns/SKILL.md +404 -0
  323. package/skills/regex-vs-llm-structured-text/SKILL.md +221 -0
  324. package/skills/remotion-video-creation/SKILL.md +43 -0
  325. package/skills/repo-scan/SKILL.md +79 -0
  326. package/skills/research-ops/SKILL.md +113 -0
  327. package/skills/returns-reverse-logistics/SKILL.md +240 -0
  328. package/skills/rules-distill/SKILL.md +265 -0
  329. package/skills/rust-patterns/SKILL.md +500 -0
  330. package/skills/rust-testing/SKILL.md +501 -0
  331. package/skills/safety-guard/SKILL.md +76 -0
  332. package/skills/santa-method/SKILL.md +307 -0
  333. package/skills/scientific-db-pubmed-database/SKILL.md +176 -0
  334. package/skills/scientific-db-uspto-database/SKILL.md +178 -0
  335. package/skills/scientific-pkg-gget/SKILL.md +167 -0
  336. package/skills/scientific-thinking-literature-review/SKILL.md +193 -0
  337. package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -0
  338. package/skills/search-first/SKILL.md +183 -0
  339. package/skills/security-bounty-hunter/SKILL.md +100 -0
  340. package/skills/security-review/SKILL.md +504 -0
  341. package/skills/security-scan/SKILL.md +166 -0
  342. package/skills/seo/SKILL.md +155 -0
  343. package/skills/skill-comply/SKILL.md +59 -0
  344. package/skills/skill-scout/SKILL.md +141 -0
  345. package/skills/skill-stocktake/SKILL.md +195 -0
  346. package/skills/social-graph-ranker/SKILL.md +155 -0
  347. package/skills/social-publisher/SKILL.md +130 -0
  348. package/skills/springboot-patterns/SKILL.md +315 -0
  349. package/skills/springboot-security/SKILL.md +273 -0
  350. package/skills/springboot-tdd/SKILL.md +159 -0
  351. package/skills/springboot-verification/SKILL.md +232 -0
  352. package/skills/strategic-compact/SKILL.md +142 -0
  353. package/skills/swift-actor-persistence/SKILL.md +144 -0
  354. package/skills/swift-concurrency-6-2/SKILL.md +216 -0
  355. package/skills/swift-protocol-di-testing/SKILL.md +191 -0
  356. package/skills/swiftui-patterns/SKILL.md +259 -0
  357. package/skills/taste/SKILL.md +264 -0
  358. package/skills/taste/references/genre-taxonomy.md +87 -0
  359. package/skills/tdd-workflow/SKILL.md +583 -0
  360. package/skills/team-agent-orchestration/SKILL.md +111 -0
  361. package/skills/team-builder/SKILL.md +169 -0
  362. package/skills/terminal-ops/SKILL.md +110 -0
  363. package/skills/tinystruct-patterns/SKILL.md +279 -0
  364. package/skills/tinystruct-patterns/references/architecture.md +90 -0
  365. package/skills/tinystruct-patterns/references/data-handling.md +60 -0
  366. package/skills/tinystruct-patterns/references/database.md +99 -0
  367. package/skills/tinystruct-patterns/references/routing.md +64 -0
  368. package/skills/tinystruct-patterns/references/system-usage.md +97 -0
  369. package/skills/tinystruct-patterns/references/testing.md +72 -0
  370. package/skills/token-budget-advisor/SKILL.md +134 -0
  371. package/skills/ui-demo/SKILL.md +466 -0
  372. package/skills/ui-to-vue/SKILL.md +135 -0
  373. package/skills/uncloud/SKILL.md +344 -0
  374. package/skills/unified-memory/SKILL.md +170 -0
  375. package/skills/unified-notifications-ops/SKILL.md +188 -0
  376. package/skills/verification-loop/SKILL.md +129 -0
  377. package/skills/video-editing/SKILL.md +311 -0
  378. package/skills/videodb/SKILL.md +375 -0
  379. package/skills/visa-doc-translate/SKILL.md +117 -0
  380. package/skills/vite-patterns/SKILL.md +450 -0
  381. package/skills/vue-patterns/SKILL.md +471 -0
  382. package/skills/windows-desktop-e2e/SKILL.md +888 -0
  383. package/skills/workspace-surface-audit/SKILL.md +126 -0
  384. package/skills/x-api/SKILL.md +235 -0
package/lib/tui.mjs CHANGED
@@ -1,899 +1,1086 @@
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
-
30
- const require = createRequire(import.meta.url);
31
- const auth = require('../lib/auth.js');
32
- const appConfig = require('../lib/config.js');
33
- const VERSION = require('../package.json').version;
34
- import {
35
- renderMarkdown, Panel, hexToAnsi, hexToAnsiBg, fillRow, SURFACE,
36
- ANSI_RESET, ANSI_BOLD, ANSI_DIM,
37
- } from '../lib/render-md.mjs';
38
- import { runAgent } from '../lib/agent.mjs';
39
- import { TOOL_DISPLAY } from '../lib/tools.mjs';
40
- import TextArea, { enableModifiedEnter, isTerminalReport } from '../lib/textarea.mjs';
41
-
42
- // ─── Theme (Google brand) ───────────────────────────────────────────────────
43
- const G = {
44
- blue: '#4285F4',
45
- red: '#EA4335',
46
- yellow: '#FBBC05',
47
- green: '#34A853',
48
- };
49
-
50
- const T = {
51
- accent: G.blue,
52
- text: '#e8eaed',
53
- secondary: '#9aa0a6',
54
- dim: '#80868b',
55
- muted: '#5f6368',
56
- faint: '#3c4043',
57
- border: '#3c4043',
58
- cycle: [G.blue, G.red, G.yellow, G.green],
59
- };
60
-
61
- const MODES = ['Build', 'Plan', 'Ask'];
62
- const MODE_COLOR = { Build: G.blue, Plan: G.yellow, Ask: G.green };
63
- const MODE_HINT = {
64
- Build: 'reads, edits and verifies files',
65
- Plan: 'investigates, then writes a plan',
66
- Ask: 'answers from the codebase, read-only',
67
- };
68
-
69
- // CODEPLY block letters — fixed per-letter widths, one Google color each.
70
- // C=blue O=red D=yellow E=green P=blue L=red Y=yellow
71
- const LOGO_SEGMENTS = [
72
- [
73
- { t: ' ██████╗ ', c: G.blue }, { t: ' ██████╗ ', c: G.red },
74
- { t: '██████╗ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
75
- { t: '██████╗ ', c: G.blue }, { t: '██╗ ', c: G.red },
76
- { t: '██╗ ██╗', c: G.yellow },
77
- ],
78
- [
79
- { t: '██╔════╝ ', c: G.blue }, { t: '██╔═══██╗', c: G.red },
80
- { t: '██╔══██╗ ', c: G.yellow }, { t: '██╔════╝ ', c: G.green },
81
- { t: '██╔══██╗ ', c: G.blue }, { t: '██║ ', c: G.red },
82
- { t: '╚██╗ ██╔╝', c: G.yellow },
83
- ],
84
- [
85
- { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
86
- { t: '██║ ██║ ', c: G.yellow }, { t: '█████╗ ', c: G.green },
87
- { t: '██████╔╝ ', c: G.blue }, { t: '██║ ', c: G.red },
88
- { t: ' ╚████╔╝ ', c: G.yellow },
89
- ],
90
- [
91
- { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
92
- { t: '██║ ██║ ', c: G.yellow }, { t: '██╔══╝ ', c: G.green },
93
- { t: '██╔═══╝ ', c: G.blue }, { t: '██║ ', c: G.red },
94
- { t: ' ╚██╔╝ ', c: G.yellow },
95
- ],
96
- [
97
- { t: '╚██████╗ ', c: G.blue }, { t: '╚██████╔╝', c: G.red },
98
- { t: '██████╔╝ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
99
- { t: '██║ ', c: G.blue }, { t: '███████╗ ', c: G.red },
100
- { t: ' ██║ ', c: G.yellow },
101
- ],
102
- [
103
- { t: ' ╚═════╝ ', c: G.blue }, { t: ' ╚═════╝ ', c: G.red },
104
- { t: '╚═════╝ ', c: G.yellow }, { t: '╚══════╝ ', c: G.green },
105
- { t: '╚═╝ ', c: G.blue }, { t: '╚══════╝ ', c: G.red },
106
- { t: ' ╚═╝ ', c: G.yellow },
107
- ],
108
- ];
109
-
110
- // Pre-flattened to one ANSI string per row: 6 <Text> nodes instead of 42.
111
- const LOGO_ROWS = LOGO_SEGMENTS.map((row) =>
112
- row.map((s) => hexToAnsi(s.c) + ANSI_BOLD + s.t + ANSI_RESET).join('')
113
- );
114
- const LOGO_WIDTH = LOGO_SEGMENTS[0].reduce((n, s) => n + s.t.length, 0);
115
-
116
- const WORDMARK = hexToAnsi(G.yellow) + ANSI_BOLD + 'codeply' + ANSI_RESET;
117
-
118
- const COMMANDS = [
119
- { name: '/login', description: 'Sign in with your Codeply account' },
120
- { name: '/logout', description: 'Sign out of current session' },
121
- { name: '/whoami', description: 'Show currently signed-in account' },
122
- { name: '/help', description: 'Show available commands' },
123
- { name: '/clear', description: 'Clear conversation and context' },
124
- { name: '/mode', description: 'Cycle Build → Plan → Ask' },
125
- { name: '/keys', description: 'Show what your terminal sends for a key' },
126
- { name: '/quit', description: 'Exit Codeply' },
127
- ];
128
-
129
- const TIPS = [
130
- ['ctrl+j', 'inserts a new line — or end a line with \\ and press enter'],
131
- ['Press tab', 'to cycle Build → Plan → Ask without leaving the composer'],
132
- ['Press esc', 'while it is working to interrupt the run'],
133
- ['Ask it to fix a failing test', 'it can run commands and iterate on the output'],
134
- ['Run /keys', 'to see exactly what your terminal sends for a key'],
135
- ];
136
-
137
- function homePath(cwd) {
138
- const home = os.homedir();
139
- if (cwd.startsWith(home)) return '~' + cwd.slice(home.length);
140
- return cwd;
141
- }
142
-
143
- /** Shorten a path from the left so it never blows out the footer. */
144
- function fitPath(p, max) {
145
- if (p.length <= max) return p;
146
- return '…' + p.slice(p.length - max + 1);
147
- }
148
-
149
- // ─── Decorative starfield ───────────────────────────────────────────────────
150
-
151
- function mulberry32(seed) {
152
- let a = seed >>> 0;
153
- return function () {
154
- a = (a + 0x6d2b79f5) | 0;
155
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
156
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
157
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
158
- };
159
- }
160
-
161
- const SPARKS = ['✦', '✧', '·', '✦'];
162
- const SPARK_COLORS = [T.faint, T.faint, T.muted, '#4a3b1a'];
163
-
164
- /** One row of scattered sparkles as a single ANSI string. */
165
- function sparkleRow(width, count, rnd) {
166
- const cells = new Array(width).fill(null);
167
- for (let i = 0; i < count; i++) {
168
- const col = Math.floor(rnd() * width);
169
- if (cells[col]) continue;
170
- cells[col] = {
171
- ch: SPARKS[Math.floor(rnd() * SPARKS.length)],
172
- c: SPARK_COLORS[Math.floor(rnd() * SPARK_COLORS.length)],
173
- };
174
- }
175
- let out = '';
176
- for (const cell of cells) {
177
- out += cell ? hexToAnsi(cell.c) + cell.ch + ANSI_RESET : ' ';
178
- }
179
- return out.replace(/\s+$/, '');
180
- }
181
-
182
- // ─── Home screen ────────────────────────────────────────────────────────────
183
-
184
- const LOGO_BLOCK_HEIGHT = LOGO_ROWS.length + 3; // logo + gap + two tagline lines
185
- const COMPOSER_BLOCK_HEIGHT = 7; // gap + input + meta + gap + hints + tip
186
-
187
- /**
188
- * The full-viewport splash.
189
- *
190
- * Deliberately sized to the terminal so the app owns the screen instead of
191
- * looking like output that happened to land in a shell. This is the ONE place a
192
- * tall live frame is allowed: it only renders while the scrollback is empty, so
193
- * the frame is still the entire output and Ink's erase stays exact. The moment
194
- * a message exists we drop to the compact layout — see the note at the top of
195
- * this file for why that matters.
196
- */
197
- /**
198
- * `n` rows of starfield.
199
- *
200
- * Blank rows carry a single space rather than an empty string: Ink measures
201
- * '' as zero height, so empty sky silently collapses and the screen never
202
- * fills.
203
- */
204
- function SkyField({ cols, n, seed, keyPrefix }) {
205
- const rnd = mulberry32(seed);
206
- return Array.from({ length: Math.max(0, n) }, (_, i) =>
207
- React.createElement(
208
- Text,
209
- { key: `${keyPrefix}-${i}` },
210
- rnd() > 0.55 ? sparkleRow(cols, rnd() > 0.75 ? 2 : 1, rnd) : ' '
211
- )
212
- );
213
- }
214
-
215
- function HomeScreen({ cols, skyRows }) {
216
- const wide = cols >= LOGO_WIDTH + 4;
217
-
218
- return React.createElement(
219
- Box,
220
- { flexDirection: 'column', width: cols, alignItems: 'center' },
221
-
222
- ...SkyField({ cols, n: skyRows, seed: 0xc0de91, keyPrefix: 'sky-top' }),
223
-
224
- wide
225
- ? LOGO_ROWS.map((row, i) => React.createElement(Text, { key: `lg-${i}` }, row))
226
- : React.createElement(Text, { key: 'lg' }, WORDMARK),
227
-
228
- React.createElement(
229
- Box,
230
- { key: 'tag', marginTop: 1, flexDirection: 'column', alignItems: 'center' },
231
- React.createElement(
232
- Text,
233
- null,
234
- hexToAnsi(T.secondary) + 'An agent that reads your code, edits it, and checks its work.' + ANSI_RESET
235
- ),
236
- React.createElement(
237
- Text,
238
- null,
239
- hexToAnsi(T.muted) + 'interactive shell' + ANSI_RESET +
240
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
241
- hexToAnsi(T.muted) + 'v' + VERSION + ANSI_RESET
242
- )
243
- )
244
- );
245
- }
246
-
247
- // ─── Message blocks ─────────────────────────────────────────────────────────
248
-
249
- /** One line of tool activity in the transcript. */
250
- function ToolLine({ msg }) {
251
- const display = TOOL_DISPLAY[msg.name] || { verb: msg.name, icon: '▸' };
252
- const failed = !msg.ok;
253
- const wrote = msg.name === 'write_file' || msg.name === 'edit_file';
254
- const ran = msg.name === 'run';
255
-
256
- const iconColor = failed ? G.red : wrote ? G.yellow : ran ? G.green : G.blue;
257
- const detail = [msg.label, msg.detail].filter(Boolean).join(' · ');
258
-
259
- return React.createElement(
260
- Text,
261
- { wrap: 'truncate-end' },
262
- hexToAnsi(iconColor) + display.icon + ' ' + ANSI_RESET +
263
- hexToAnsi(failed ? G.red : T.secondary) + display.verb.padEnd(6) + ANSI_RESET +
264
- hexToAnsi(T.dim) + ' ' + detail + ANSI_RESET
265
- );
266
- }
267
-
268
- function MessageBlock({ msg, cols }) {
269
- if (msg.role === 'user') {
270
- return React.createElement(
271
- Box,
272
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
273
- React.createElement(Text, { color: G.blue }, '▌'),
274
- React.createElement(
275
- Box,
276
- { flexDirection: 'column', flexGrow: 1 },
277
- React.createElement(Text, null, hexToAnsi(T.dim) + ANSI_DIM + 'you' + ANSI_RESET),
278
- React.createElement(Text, { color: T.text, wrap: 'wrap' }, msg.content)
279
- )
280
- );
281
- }
282
-
283
- if (msg.role === 'assistant') {
284
- // A plan is a deliverable, not chatter — give it its own panel so it stands
285
- // apart from the running commentary above and below it.
286
- if (msg.plan) {
287
- return React.createElement(
288
- Box,
289
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
290
- React.createElement(Text, { color: G.yellow }, '▌'),
291
- React.createElement(
292
- Box,
293
- { flexDirection: 'column', flexGrow: 1 },
294
- React.createElement(Panel, {
295
- title: 'plan',
296
- lines: msg.content.split('\n'),
297
- width: cols - 4,
298
- accent: G.yellow,
299
- })
300
- )
301
- );
302
- }
303
- return React.createElement(
304
- Box,
305
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
306
- React.createElement(Text, { color: G.green }, '▌'),
307
- React.createElement(
308
- Box,
309
- { flexDirection: 'column', flexGrow: 1 },
310
- React.createElement(Text, null, WORDMARK),
311
- ...renderMarkdown(msg.content, cols - 4)
312
- )
313
- );
314
- }
315
-
316
- if (msg.role === 'tool') {
317
- return React.createElement(
318
- Box,
319
- { flexDirection: 'row', marginBottom: 0, paddingLeft: 2 },
320
- React.createElement(ToolLine, { msg })
321
- );
322
- }
323
-
324
- if (msg.role === 'system') {
325
- return React.createElement(
326
- Box,
327
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
328
- React.createElement(Text, { color: G.yellow }, '◆'),
329
- React.createElement(Text, { color: T.secondary, wrap: 'wrap' }, msg.content)
330
- );
331
- }
332
-
333
- if (msg.role === 'error') {
334
- return React.createElement(
335
- Box,
336
- { flexDirection: 'row', marginBottom: 1, gap: 1 },
337
- React.createElement(Text, { color: G.red }, '✗'),
338
- React.createElement(Text, { color: G.red, wrap: 'wrap' }, msg.content)
339
- );
340
- }
341
-
342
- return null;
343
- }
344
-
345
- // ─── Command palette ────────────────────────────────────────────────────────
346
-
347
- function CommandsMenu({ commands, selectedIndex }) {
348
- return React.createElement(
349
- Box,
350
- {
351
- flexDirection: 'column',
352
- borderStyle: 'round',
353
- borderColor: T.border,
354
- paddingX: 1,
355
- marginBottom: 1,
356
- flexShrink: 0,
357
- },
358
- React.createElement(
359
- Text,
360
- null,
361
- hexToAnsi(T.muted) + '↑↓' + ANSI_RESET + hexToAnsi(T.faint) + ' select ' + ANSI_RESET +
362
- hexToAnsi(T.muted) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' complete ' + ANSI_RESET +
363
- hexToAnsi(T.muted) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' dismiss' + ANSI_RESET
364
- ),
365
- ...commands.map((cmd, i) => {
366
- const selected = i === selectedIndex;
367
- const nameColor = selected ? T.cycle[i % T.cycle.length] : T.secondary;
368
- return React.createElement(
369
- Text,
370
- { key: cmd.name, wrap: 'truncate-end' },
371
- (selected ? hexToAnsi(G.blue) + '› ' + ANSI_RESET : ' ') +
372
- hexToAnsi(nameColor) + (selected ? ANSI_BOLD : '') + cmd.name.padEnd(10) + ANSI_RESET +
373
- hexToAnsi(selected ? T.dim : T.faint) + ' ' + cmd.description + ANSI_RESET
374
- );
375
- })
376
- );
377
- }
378
-
379
- // ─── Approval prompt ────────────────────────────────────────────────────────
380
-
381
- function ApprovalPrompt({ request }) {
382
- const accent = request.danger ? G.red : G.yellow;
383
- const lines = [];
384
-
385
- if (request.diff) {
386
- const removed = request.diff.search.split('\n').slice(0, 6);
387
- const added = request.diff.replace.split('\n').slice(0, 6);
388
- for (const l of removed) lines.push(hexToAnsi(G.red) + '- ' + l + ANSI_RESET);
389
- for (const l of added) lines.push(hexToAnsi(G.green) + '+ ' + l + ANSI_RESET);
390
- }
391
-
392
- return React.createElement(
393
- Box,
394
- {
395
- flexDirection: 'column',
396
- borderStyle: 'round',
397
- borderColor: accent,
398
- paddingX: 1,
399
- marginBottom: 1,
400
- flexShrink: 0,
401
- },
402
- React.createElement(
403
- Text,
404
- { wrap: 'truncate-end' },
405
- hexToAnsi(accent) + ANSI_BOLD + request.title + ANSI_RESET +
406
- (request.danger ? hexToAnsi(G.red) + ' ⚠ looks destructive' + ANSI_RESET : '')
407
- ),
408
- request.detail && React.createElement(
409
- Text,
410
- { wrap: 'truncate-end' },
411
- hexToAnsi(T.secondary) + request.detail + ANSI_RESET
412
- ),
413
- ...lines.map((l, i) => React.createElement(Text, { key: i, wrap: 'truncate-end' }, l)),
414
- React.createElement(
415
- Text,
416
- null,
417
- hexToAnsi(G.green) + 'y' + ANSI_RESET + hexToAnsi(T.faint) + ' approve ' + ANSI_RESET +
418
- hexToAnsi(G.blue) + 'a' + ANSI_RESET + hexToAnsi(T.faint) + ' approve all this session ' + ANSI_RESET +
419
- hexToAnsi(G.red) + 'n' + ANSI_RESET + hexToAnsi(T.faint) + ' decline' + ANSI_RESET
420
- )
421
- );
422
- }
423
-
424
- // ─── App ────────────────────────────────────────────────────────────────────
425
-
426
- const App = () => {
427
- const { exit } = useApp();
428
- const { stdout } = useStdout();
429
- const [cols, setCols] = React.useState(stdout?.columns || 80);
430
- const [rows, setRows] = React.useState(stdout?.rows || 24);
431
-
432
- const [input, setInput] = React.useState('');
433
- const [modeIndex, setModeIndex] = React.useState(0);
434
- const [session, setSession] = React.useState(null);
435
- const [running, setRunning] = React.useState(false);
436
- const [statusLine, setStatusLine] = React.useState('');
437
- const [elapsed, setElapsed] = React.useState(0);
438
- const [steps, setSteps] = React.useState(0);
439
- const [chatHistory, setChatHistory] = React.useState([]);
440
- const [staticKey, setStaticKey] = React.useState(0);
441
- const [showCommands, setShowCommands] = React.useState(false);
442
- const [selectedCommand, setSelectedCommand] = React.useState(0);
443
- const [filteredCommands, setFilteredCommands] = React.useState(COMMANDS);
444
- const [escArmed, setEscArmed] = React.useState(false);
445
- const [approval, setApproval] = React.useState(null);
446
- // Only true once the terminal answers our capability query. Windows Terminal
447
- // never does, so there we advertise ctrl+j instead of lying about shift+enter.
448
- const [modifiedEnterWorks, setModifiedEnterWorks] = React.useState(false);
449
- const [keyProbe, setKeyProbe] = React.useState(false);
450
-
451
- const nextId = React.useRef(1);
452
- const abortRef = React.useRef({ aborted: false });
453
- const alwaysAllow = React.useRef(new Set());
454
- const convo = React.useRef([]); // {role,content} pairs handed to the agent
455
-
456
- const cwd = process.cwd();
457
- const mode = MODES[modeIndex];
458
- const modeColor = MODE_COLOR[mode];
459
- const tip = React.useMemo(() => TIPS[Math.floor(Math.random() * TIPS.length)], []);
460
-
461
- const push = React.useCallback((msg) => {
462
- setChatHistory((prev) => [...prev, { id: `m-${nextId.current++}`, ...msg }]);
463
- }, []);
464
-
465
- React.useEffect(() => {
466
- auth.getSession().then((s) => setSession(s)).catch(() => {});
467
- }, []);
468
-
469
- // Ask the terminal to disambiguate shift+enter from enter (see textarea.mjs).
470
- React.useEffect(() => enableModifiedEnter(stdout), [stdout]);
471
-
472
- React.useEffect(() => {
473
- if (!stdout) return;
474
- const onResize = () => { setCols(stdout.columns || 80); setRows(stdout.rows || 24); };
475
- stdout.on('resize', onResize);
476
- return () => stdout.off('resize', onResize);
477
- }, [stdout]);
478
-
479
- React.useEffect(() => {
480
- if (input.startsWith('/') && !input.includes('\n')) {
481
- const query = input.toLowerCase().split(/\s/)[0];
482
- const filtered = COMMANDS.filter((cmd) => cmd.name.startsWith(query));
483
- setFilteredCommands(filtered);
484
- setShowCommands(filtered.length > 0);
485
- setSelectedCommand(0);
486
- } else {
487
- setShowCommands(false);
488
- }
489
- }, [input]);
490
-
491
- React.useEffect(() => {
492
- if (!escArmed) return;
493
- const t = setTimeout(() => setEscArmed(false), 1500);
494
- return () => clearTimeout(t);
495
- }, [escArmed]);
496
-
497
- // Elapsed counter while the agent works. The spinner already repaints the
498
- // live frame several times a second, so this ticker costs nothing extra.
499
- React.useEffect(() => {
500
- if (!running) { setElapsed(0); return; }
501
- const startedAt = Date.now();
502
- const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
503
- return () => clearInterval(t);
504
- }, [running]);
505
-
506
- // ── approval bridge: tools await this promise, the keypress handler settles it ──
507
- const requestApproval = React.useCallback((request) => {
508
- if (alwaysAllow.current.has(request.tool)) return Promise.resolve('always');
509
- return new Promise((resolve) => {
510
- setApproval({ request, resolve });
511
- });
512
- }, []);
513
-
514
- const settleApproval = React.useCallback((verdict) => {
515
- setApproval((current) => {
516
- if (!current) return null;
517
- if (verdict === 'always') alwaysAllow.current.add(current.request.tool);
518
- current.resolve(verdict);
519
- return null;
520
- });
521
- }, []);
522
-
523
- useInput((char, key) => {
524
- // The terminal's answer to our kitty query — proof that modified enter is
525
- // really distinguishable here. Swallow it so it never reaches the draft.
526
- if (isTerminalReport(char)) { setModifiedEnterWorks(true); return; }
527
-
528
- // /keys diagnostic: report exactly what the terminal sent for one keypress.
529
- if (keyProbe) {
530
- setKeyProbe(false);
531
- const seq = JSON.stringify(char);
532
- const flags = ['ctrl', 'shift', 'meta', 'return', 'tab', 'escape', 'backspace', 'delete']
533
- .filter((f) => key[f]).join(', ') || 'none';
534
- push({ role: 'system', content: `Key sent: ${seq}\nFlags: ${flags}` });
535
- return;
536
- }
537
-
538
- // The approval prompt owns the keyboard while it is up.
539
- if (approval) {
540
- if (char === 'y' || key.return) { settleApproval('once'); return; }
541
- if (char === 'a') { settleApproval('always'); return; }
542
- if (char === 'n' || key.escape) { settleApproval('reject'); return; }
543
- return;
544
- }
545
-
546
- if (key.escape) {
547
- if (running) { abortRef.current.aborted = true; setStatusLine('interrupting…'); return; }
548
- if (showCommands) { setShowCommands(false); return; }
549
- if (input.length > 0) {
550
- if (escArmed) { setInput(''); setEscArmed(false); }
551
- else { setEscArmed(true); }
552
- return;
553
- }
554
- if (escArmed) { exit(); } else { setEscArmed(true); }
555
- return;
556
- }
557
-
558
- if (key.ctrl && char === 'c') {
559
- if (running) { abortRef.current.aborted = true; return; }
560
- if (input.length > 0) { setInput(''); return; }
561
- exit();
562
- return;
563
- }
564
-
565
- if (key.tab) {
566
- if (showCommands && filteredCommands.length > 0) {
567
- const sel = filteredCommands[selectedCommand];
568
- if (sel) { setInput(sel.name + ' '); setShowCommands(false); }
569
- return;
570
- }
571
- setModeIndex((i) => (i + 1) % MODES.length);
572
- return;
573
- }
574
-
575
- if (showCommands) {
576
- if (key.upArrow) { setSelectedCommand((p) => (p > 0 ? p - 1 : filteredCommands.length - 1)); return; }
577
- if (key.downArrow) { setSelectedCommand((p) => (p < filteredCommands.length - 1 ? p + 1 : 0)); return; }
578
- }
579
- });
580
-
581
- // ── slash commands ──
582
- const runCommand = async (cmd) => {
583
- if (cmd === '/login') {
584
- push({ role: 'system', content: 'Opening sign-in…' });
585
- try {
586
- await auth.login();
587
- const s = await auth.getSession();
588
- setSession(s);
589
- push({ role: 'system', content: s ? `Signed in as ${s.user.email}` : 'Login cancelled or failed' });
590
- } catch (e) {
591
- push({ role: 'error', content: e.message || 'Login failed' });
592
- }
593
- } else if (cmd === '/logout') {
594
- await auth.logout();
595
- setSession(null);
596
- push({ role: 'system', content: 'Signed out' });
597
- } else if (cmd === '/whoami') {
598
- const s = await auth.getSession();
599
- push({ role: 'system', content: s ? s.user.email : 'Not signed in' });
600
- } else if (cmd === '/help') {
601
- push({
602
- role: 'system',
603
- content: 'Commands\n' + COMMANDS.map((c) => `${c.name.padEnd(10)} ${c.description}`).join('\n') +
604
- '\n\nKeys\nshift+enter / ctrl+j new line\ntab switch mode\nesc interrupt a run\n' +
605
- `\nModel\n${appConfig.describeProvider()} — change with \`codeply provider\`\n`,
606
- });
607
- } else if (cmd === '/clear') {
608
- // <Static> never re-renders what it already printed, so a clear means
609
- // wiping the terminal and remounting it with a fresh key. Empty history
610
- // also drops us back to the home splash.
611
- convo.current = [];
612
- if (stdout) stdout.write('\x1b[2J\x1b[3J\x1b[H');
613
- setChatHistory([]);
614
- setStaticKey((k) => k + 1);
615
- } else if (cmd === '/mode') {
616
- const next = (modeIndex + 1) % MODES.length;
617
- setModeIndex(next);
618
- push({ role: 'system', content: `Mode → ${MODES[next]} — ${MODE_HINT[MODES[next]]}` });
619
- } else if (cmd === '/quit' || cmd === '/exit') {
620
- exit();
621
- } else {
622
- push({ role: 'error', content: `Unknown command: ${cmd}. Type /help for available commands.` });
623
- }
624
- };
625
-
626
- // ── main turn ──
627
- const handleSubmit = async (value) => {
628
- const text = value.trim();
629
- if (!text || running) return;
630
-
631
- let submitValue = text;
632
- if (showCommands && filteredCommands[selectedCommand]) {
633
- const raw = text.split(/\s+/)[0].toLowerCase();
634
- if (!COMMANDS.some((c) => c.name === raw)) {
635
- submitValue = filteredCommands[selectedCommand].name;
636
- }
637
- }
638
- setShowCommands(false);
639
- setInput('');
640
-
641
- if (submitValue.startsWith('/')) {
642
- await runCommand(submitValue.split(/\s+/)[0].toLowerCase());
643
- return;
644
- }
645
-
646
- push({ role: 'user', content: submitValue });
647
- setRunning(true);
648
- setStatusLine('thinking…');
649
- setSteps(0);
650
- abortRef.current = { aborted: false };
651
-
652
- const turn = [];
653
- try {
654
- const stream = runAgent({
655
- userMessage: submitValue,
656
- history: convo.current.slice(-20),
657
- mode,
658
- cwd,
659
- approve: requestApproval,
660
- signal: abortRef.current,
661
- });
662
-
663
- for await (const event of stream) {
664
- if (event.type === 'text') {
665
- // In Plan mode the substantial reply is the plan itself; short
666
- // interstitial notes ("let me check X") stay as ordinary prose.
667
- const isPlan = mode === 'Plan' && event.text.split('\n').length >= 3;
668
- push({ role: 'assistant', content: event.text, plan: isPlan });
669
- turn.push({ role: 'assistant', content: event.text });
670
- setStatusLine('working…');
671
- } else if (event.type === 'tool_start') {
672
- const display = TOOL_DISPLAY[event.name] || { verb: event.name };
673
- setSteps((n) => n + 1);
674
- setStatusLine(`${display.verb} ${event.args.path || event.args.command || event.args.pattern || ''}`.trim());
675
- } else if (event.type === 'tool_end') {
676
- const meta = event.meta || {};
677
- const unit = event.name === 'search' ? 'matches'
678
- : event.name === 'list_dir' ? 'entries'
679
- : 'lines';
680
- let detail = '';
681
- if (meta.count != null) detail = `${meta.count} ${unit}`;
682
- if (meta.added != null) detail = `-${meta.removed || 0} +${meta.added}`;
683
- if (meta.exitCode != null) detail = `exit ${meta.exitCode}`;
684
- if (meta.noop) detail = 'no change';
685
- if (meta.rejected) detail = 'declined';
686
- push({
687
- role: 'tool',
688
- name: event.name,
689
- ok: event.ok && !meta.rejected && (meta.exitCode == null || meta.exitCode === 0),
690
- label: event.summary || '',
691
- detail,
692
- });
693
- setStatusLine('thinking…');
694
- } else if (event.type === 'aborted') {
695
- push({ role: 'system', content: 'Interrupted.' });
696
- } else if (event.type === 'error') {
697
- push({ role: 'error', content: event.error });
698
- }
699
- }
700
- } catch (e) {
701
- push({ role: 'error', content: e.message });
702
- } finally {
703
- convo.current = [...convo.current, { role: 'user', content: submitValue }, ...turn];
704
- setRunning(false);
705
- setStatusLine('');
706
- // A run that ended while a prompt was up must not leave a dangling promise.
707
- setApproval((current) => { if (current) current.resolve('reject'); return null; });
708
- }
709
- };
710
-
711
- // Ollama needs no Codeply sign-in, so showing "not signed in" there would be
712
- // a false alarm — surface the model that is actually answering instead.
713
- const provider = React.useMemo(() => appConfig.getConfig(), []);
714
- const usingOllama = provider.provider === 'ollama';
715
- const account = usingOllama
716
- ? appConfig.describeProviderShort(provider)
717
- : session ? session.user.email : 'not signed in';
718
-
719
- // Advertise the key that actually inserts a newline in THIS terminal.
720
- const newlineKey = modifiedEnterWorks ? 'shift+enter' : 'ctrl+j';
721
-
722
- const isHome = chatHistory.length === 0;
723
- // The splash needs real estate; in a short or narrow window fall straight
724
- // through to the compact layout rather than rendering a cramped one.
725
- const showSplash = isHome && rows >= 22 && cols >= 44;
726
-
727
- // On home the composer is a centred column like an app window; in
728
- // conversation it spans the width so long messages have room.
729
- const shellWidth = showSplash ? Math.min(cols - 4, 92) : cols - 2;
730
- const inner = Math.max(20, shellWidth);
731
-
732
- // Two-column rows are laid out by hand: Ink truncates each child against the
733
- // width it was given, so without explicit widths the columns run into each
734
- // other on narrow terminals instead of truncating cleanly.
735
- const rightHint = escArmed
736
- ? `esc again to ${input.length > 0 ? 'clear' : 'quit'}`
737
- : running ? 'esc interrupts' : 'enter to send';
738
- const footRight = fitPath(homePath(cwd), Math.max(12, Math.floor(inner * 0.45))) + ' codeply ' + VERSION;
739
- const hintsWidth = Math.max(10, inner - rightHint.length - 2);
740
- const tipWidth = inner - footRight.length - 2;
741
- const showTip = tipWidth >= 24;
742
-
743
- const composer = React.createElement(
744
- Box,
745
- {
746
- key: 'composer',
747
- flexDirection: 'column',
748
- borderStyle: 'bold',
749
- borderTop: false,
750
- borderRight: false,
751
- borderBottom: false,
752
- borderLeftColor: running ? T.muted : modeColor,
753
- paddingLeft: 1,
754
- width: shellWidth,
755
- marginTop: showSplash ? 2 : 0,
756
- flexShrink: 0,
757
- },
758
- React.createElement(TextArea, {
759
- value: input,
760
- onChange: setInput,
761
- onSubmit: handleSubmit,
762
- width: inner - 3,
763
- focus: !running && !approval,
764
- allowVertical: !showCommands,
765
- placeholder: running
766
- ? 'working — esc to interrupt'
767
- : 'Ask for a change, a fix, or an explanation… (/ for commands)',
768
- }),
769
- React.createElement(
770
- Text,
771
- { wrap: 'truncate-end' },
772
- hexToAnsi(modeColor) + ANSI_BOLD + mode + ANSI_RESET +
773
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
774
- hexToAnsi(T.muted) + MODE_HINT[mode] + ANSI_RESET +
775
- hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
776
- hexToAnsi(usingOllama || session ? T.dim : G.yellow) + account + ANSI_RESET
777
- )
778
- );
779
-
780
- const hints = React.createElement(
781
- Box,
782
- { key: 'hints', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, marginTop: 1, flexShrink: 0 },
783
- React.createElement(
784
- Box,
785
- { width: hintsWidth, flexShrink: 0 },
786
- React.createElement(
787
- Text,
788
- { wrap: 'truncate-end' },
789
- hexToAnsi(G.blue) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' mode ' + ANSI_RESET +
790
- hexToAnsi(G.red) + newlineKey + ANSI_RESET + hexToAnsi(T.faint) + ' newline ' + ANSI_RESET +
791
- hexToAnsi(G.yellow) + '/' + ANSI_RESET + hexToAnsi(T.faint) + ' commands ' + ANSI_RESET +
792
- hexToAnsi(G.green) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' interrupt' + ANSI_RESET
793
- )
794
- ),
795
- React.createElement(
796
- Text,
797
- null,
798
- (escArmed ? hexToAnsi(G.yellow) : hexToAnsi(T.faint)) + rightHint + ANSI_RESET
799
- )
800
- );
801
-
802
- const tipLine = React.createElement(
803
- Box,
804
- { key: 'tip', width: shellWidth, flexShrink: 0 },
805
- showTip && React.createElement(
806
- Text,
807
- { wrap: 'truncate-end' },
808
- hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
809
- hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
810
- hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
811
- )
812
- );
813
-
814
- const footer = React.createElement(
815
- Box,
816
- { key: 'footer', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, flexShrink: 0 },
817
- React.createElement(
818
- Box,
819
- { width: Math.max(0, tipWidth), flexShrink: 0 },
820
- showTip && React.createElement(
821
- Text,
822
- { wrap: 'truncate-end' },
823
- hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
824
- hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
825
- hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
826
- )
827
- ),
828
- React.createElement(Text, null, hexToAnsi(T.faint) + footRight + ANSI_RESET)
829
- );
830
-
831
- // ── Home: one full-viewport screen, centred like an app ──
832
- if (showSplash) {
833
- const avail = rows - 1;
834
- // Sky is whatever the logo, composer and the pinned bottom bar don't use,
835
- // split so the logo sits a little above centre — the same weighting the
836
- // reference app uses.
837
- const free = Math.max(0, avail - LOGO_BLOCK_HEIGHT - COMPOSER_BLOCK_HEIGHT - 1);
838
- const skyTop = Math.round(free * 0.42);
839
- const skyBottom = free - skyTop;
840
-
841
- return React.createElement(
842
- Box,
843
- { flexDirection: 'column', width: cols, height: avail, alignItems: 'center' },
844
- React.createElement(HomeScreen, { key: 'home', cols, skyRows: skyTop }),
845
- composer,
846
- hints,
847
- tipLine,
848
- ...SkyField({ cols, n: skyBottom, seed: 0x5eed17, keyPrefix: 'sky-bot' }),
849
- // Pinned to the last row, like a status bar.
850
- React.createElement(
851
- Box,
852
- { key: 'bar', flexDirection: 'row', justifyContent: 'space-between', width: cols - 2 },
853
- React.createElement(Text, null, hexToAnsi(T.faint) + fitPath(homePath(cwd), Math.max(12, cols - 24)) + ANSI_RESET),
854
- React.createElement(Text, null, hexToAnsi(T.faint) + 'codeply ' + VERSION + ANSI_RESET)
855
- )
856
- );
857
- }
858
-
859
- // ── Conversation: scrollback + a deliberately short live frame ──
860
- return React.createElement(
861
- Box,
862
- { flexDirection: 'column', width: cols, paddingX: 1 },
863
-
864
- // ── Scrollback: printed once, never redrawn. Keeps the live frame short. ──
865
- React.createElement(
866
- Static,
867
- { key: staticKey, items: chatHistory },
868
- (msg) => React.createElement(MessageBlock, { key: msg.id, msg, cols: cols - 2 })
869
- ),
870
-
871
- // ── Live frame below ──
872
-
873
- running && React.createElement(
874
- Box,
875
- { flexDirection: 'row', gap: 1, marginBottom: 1, flexShrink: 0 },
876
- React.createElement(Text, { color: T.accent }, React.createElement(Spinner, { type: 'dots' })),
877
- React.createElement(
878
- Text,
879
- { wrap: 'truncate-end' },
880
- hexToAnsi(T.dim) + (statusLine || 'working…') + ANSI_RESET +
881
- hexToAnsi(T.faint) + ' ' + elapsed + 's' + ANSI_RESET +
882
- (steps > 0 ? hexToAnsi(T.faint) + ' · step ' + steps + ANSI_RESET : '')
883
- )
884
- ),
885
-
886
- approval && React.createElement(ApprovalPrompt, { request: approval.request }),
887
-
888
- showCommands && React.createElement(CommandsMenu, {
889
- commands: filteredCommands,
890
- selectedIndex: selectedCommand,
891
- }),
892
-
893
- composer,
894
- hints,
895
- footer
896
- );
897
- };
898
-
899
- export default App;
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;