@build-astron-co/nimbus 0.2.0 → 0.4.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 (469) hide show
  1. package/bin/nimbus +26 -10
  2. package/bin/nimbus.cmd +41 -0
  3. package/bin/nimbus.mjs +70 -0
  4. package/completions/nimbus.bash +38 -0
  5. package/completions/nimbus.fish +48 -0
  6. package/completions/nimbus.zsh +81 -0
  7. package/dist/src/agent/compaction-agent.js +215 -0
  8. package/dist/src/agent/context-manager.js +385 -0
  9. package/dist/src/agent/context.js +322 -0
  10. package/dist/src/agent/deploy-preview.js +395 -0
  11. package/dist/src/agent/expand-files.js +95 -0
  12. package/dist/src/agent/index.js +18 -0
  13. package/dist/src/agent/loop.js +1535 -0
  14. package/dist/src/agent/modes.js +347 -0
  15. package/dist/src/agent/permissions.js +396 -0
  16. package/dist/src/agent/subagents/base.js +67 -0
  17. package/dist/src/agent/subagents/cost.js +45 -0
  18. package/dist/src/agent/subagents/explore.js +36 -0
  19. package/dist/src/agent/subagents/general.js +41 -0
  20. package/dist/src/agent/subagents/index.js +88 -0
  21. package/dist/src/agent/subagents/infra.js +52 -0
  22. package/dist/src/agent/subagents/security.js +60 -0
  23. package/dist/src/agent/system-prompt.js +860 -0
  24. package/dist/src/app.js +152 -0
  25. package/dist/src/audit/activity-log.js +209 -0
  26. package/dist/src/audit/compliance-checker.js +419 -0
  27. package/dist/src/audit/cost-tracker.js +231 -0
  28. package/dist/src/audit/index.js +10 -0
  29. package/dist/src/audit/security-scanner.js +490 -0
  30. package/dist/src/auth/guard.js +64 -0
  31. package/dist/src/auth/index.js +19 -0
  32. package/dist/src/auth/keychain.js +79 -0
  33. package/dist/src/auth/oauth.js +389 -0
  34. package/dist/src/auth/providers.js +415 -0
  35. package/dist/src/auth/sso.js +87 -0
  36. package/dist/src/auth/store.js +424 -0
  37. package/dist/src/auth/types.js +5 -0
  38. package/dist/src/cli/index.js +8 -0
  39. package/dist/src/cli/init.js +1048 -0
  40. package/dist/src/cli/openapi-spec.js +346 -0
  41. package/dist/src/cli/run.js +505 -0
  42. package/dist/src/cli/serve-auth.js +56 -0
  43. package/dist/src/cli/serve.js +432 -0
  44. package/dist/src/cli/web.js +50 -0
  45. package/dist/src/cli.js +1574 -0
  46. package/dist/src/clients/core-engine-client.js +156 -0
  47. package/dist/src/clients/enterprise-client.js +246 -0
  48. package/dist/src/clients/generator-client.js +219 -0
  49. package/dist/src/clients/git-client.js +367 -0
  50. package/dist/src/clients/github-client.js +229 -0
  51. package/dist/src/clients/helm-client.js +299 -0
  52. package/dist/src/clients/index.js +18 -0
  53. package/dist/src/clients/k8s-client.js +270 -0
  54. package/dist/src/clients/llm-client.js +119 -0
  55. package/dist/src/clients/rest-client.js +104 -0
  56. package/dist/src/clients/service-discovery.js +35 -0
  57. package/dist/src/clients/terraform-client.js +302 -0
  58. package/dist/src/clients/tools-client.js +1227 -0
  59. package/dist/src/clients/ws-client.js +93 -0
  60. package/dist/src/commands/alias.js +91 -0
  61. package/dist/src/commands/analyze/index.js +313 -0
  62. package/dist/src/commands/apply/helm.js +375 -0
  63. package/dist/src/commands/apply/index.js +176 -0
  64. package/dist/src/commands/apply/k8s.js +350 -0
  65. package/dist/src/commands/apply/terraform.js +465 -0
  66. package/dist/src/commands/ask.js +137 -0
  67. package/dist/src/commands/audit/index.js +322 -0
  68. package/dist/src/commands/auth-cloud.js +345 -0
  69. package/dist/src/commands/auth-list.js +112 -0
  70. package/dist/src/commands/auth-profile.js +104 -0
  71. package/dist/src/commands/auth-refresh.js +161 -0
  72. package/dist/src/commands/auth-status.js +122 -0
  73. package/dist/src/commands/aws/ec2.js +402 -0
  74. package/dist/src/commands/aws/iam.js +304 -0
  75. package/dist/src/commands/aws/index.js +108 -0
  76. package/dist/src/commands/aws/lambda.js +317 -0
  77. package/dist/src/commands/aws/rds.js +345 -0
  78. package/dist/src/commands/aws/s3.js +346 -0
  79. package/dist/src/commands/aws/vpc.js +302 -0
  80. package/dist/src/commands/aws-discover.js +413 -0
  81. package/dist/src/commands/aws-terraform.js +618 -0
  82. package/dist/src/commands/azure/aks.js +305 -0
  83. package/dist/src/commands/azure/functions.js +200 -0
  84. package/dist/src/commands/azure/index.js +93 -0
  85. package/dist/src/commands/azure/storage.js +378 -0
  86. package/dist/src/commands/azure/vm.js +291 -0
  87. package/dist/src/commands/billing/index.js +224 -0
  88. package/dist/src/commands/chat.js +259 -0
  89. package/dist/src/commands/completions.js +255 -0
  90. package/dist/src/commands/config.js +291 -0
  91. package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
  92. package/dist/src/commands/cost/estimator.js +73 -0
  93. package/dist/src/commands/cost/index.js +625 -0
  94. package/dist/src/commands/cost/parsers/terraform.js +234 -0
  95. package/dist/src/commands/cost/parsers/types.js +4 -0
  96. package/dist/src/commands/cost/pricing/aws.js +501 -0
  97. package/dist/src/commands/cost/pricing/azure.js +462 -0
  98. package/dist/src/commands/cost/pricing/gcp.js +359 -0
  99. package/dist/src/commands/cost/pricing/index.js +24 -0
  100. package/dist/src/commands/demo.js +196 -0
  101. package/dist/src/commands/deploy.js +215 -0
  102. package/dist/src/commands/doctor.js +1291 -0
  103. package/dist/src/commands/drift/index.js +674 -0
  104. package/dist/src/commands/explain.js +235 -0
  105. package/dist/src/commands/export.js +120 -0
  106. package/dist/src/commands/feedback.js +319 -0
  107. package/dist/src/commands/fix.js +263 -0
  108. package/dist/src/commands/fs/index.js +338 -0
  109. package/dist/src/commands/gcp/compute.js +266 -0
  110. package/dist/src/commands/gcp/functions.js +221 -0
  111. package/dist/src/commands/gcp/gke.js +357 -0
  112. package/dist/src/commands/gcp/iam.js +295 -0
  113. package/dist/src/commands/gcp/index.js +105 -0
  114. package/dist/src/commands/gcp/storage.js +232 -0
  115. package/dist/src/commands/generate-helm.js +1026 -0
  116. package/dist/src/commands/generate-k8s.js +1263 -0
  117. package/dist/src/commands/generate-terraform.js +1058 -0
  118. package/dist/src/commands/gh/index.js +663 -0
  119. package/dist/src/commands/git/index.js +1208 -0
  120. package/dist/src/commands/helm/index.js +985 -0
  121. package/dist/src/commands/help.js +639 -0
  122. package/dist/src/commands/history.js +120 -0
  123. package/dist/src/commands/import.js +782 -0
  124. package/dist/src/commands/incident.js +144 -0
  125. package/dist/src/commands/index.js +109 -0
  126. package/dist/src/commands/init.js +955 -0
  127. package/dist/src/commands/k8s/index.js +979 -0
  128. package/dist/src/commands/login.js +588 -0
  129. package/dist/src/commands/logout.js +61 -0
  130. package/dist/src/commands/logs.js +160 -0
  131. package/dist/src/commands/onboarding.js +382 -0
  132. package/dist/src/commands/pipeline.js +153 -0
  133. package/dist/src/commands/plan/display.js +216 -0
  134. package/dist/src/commands/plan/index.js +525 -0
  135. package/dist/src/commands/plugin.js +325 -0
  136. package/dist/src/commands/preview.js +356 -0
  137. package/dist/src/commands/profile.js +297 -0
  138. package/dist/src/commands/questionnaire.js +1021 -0
  139. package/dist/src/commands/resume.js +35 -0
  140. package/dist/src/commands/rollback.js +259 -0
  141. package/dist/src/commands/rollout.js +74 -0
  142. package/dist/src/commands/runbook.js +307 -0
  143. package/dist/src/commands/schedule.js +202 -0
  144. package/dist/src/commands/status.js +213 -0
  145. package/dist/src/commands/team/index.js +309 -0
  146. package/dist/src/commands/team-context.js +200 -0
  147. package/dist/src/commands/template.js +204 -0
  148. package/dist/src/commands/tf/index.js +989 -0
  149. package/dist/src/commands/upgrade.js +515 -0
  150. package/dist/src/commands/usage/index.js +118 -0
  151. package/dist/src/commands/version.js +145 -0
  152. package/dist/src/commands/watch.js +127 -0
  153. package/dist/src/compat/index.js +2 -0
  154. package/dist/src/compat/runtime.js +10 -0
  155. package/dist/src/compat/sqlite.js +144 -0
  156. package/dist/src/config/index.js +6 -0
  157. package/dist/src/config/manager.js +469 -0
  158. package/dist/src/config/mode-store.js +57 -0
  159. package/dist/src/config/profiles.js +66 -0
  160. package/dist/src/config/safety-policy.js +251 -0
  161. package/dist/src/config/schema.js +107 -0
  162. package/dist/src/config/types.js +311 -0
  163. package/dist/src/config/workspace-state.js +38 -0
  164. package/dist/src/context/context-db.js +138 -0
  165. package/dist/src/demo/index.js +295 -0
  166. package/dist/src/demo/scenarios/full-journey.js +226 -0
  167. package/dist/src/demo/scenarios/getting-started.js +124 -0
  168. package/dist/src/demo/scenarios/helm-release.js +334 -0
  169. package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
  170. package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
  171. package/dist/src/demo/types.js +6 -0
  172. package/dist/src/engine/cost-estimator.js +334 -0
  173. package/dist/src/engine/diagram-generator.js +192 -0
  174. package/dist/src/engine/drift-detector.js +688 -0
  175. package/dist/src/engine/executor.js +832 -0
  176. package/dist/src/engine/index.js +39 -0
  177. package/dist/src/engine/orchestrator.js +436 -0
  178. package/dist/src/engine/planner.js +616 -0
  179. package/dist/src/engine/safety.js +609 -0
  180. package/dist/src/engine/verifier.js +664 -0
  181. package/dist/src/enterprise/audit.js +241 -0
  182. package/dist/src/enterprise/auth.js +189 -0
  183. package/dist/src/enterprise/billing.js +512 -0
  184. package/dist/src/enterprise/index.js +16 -0
  185. package/dist/src/enterprise/teams.js +315 -0
  186. package/dist/src/generator/best-practices.js +1375 -0
  187. package/dist/src/generator/helm.js +495 -0
  188. package/dist/src/generator/index.js +11 -0
  189. package/dist/src/generator/intent-parser.js +420 -0
  190. package/dist/src/generator/kubernetes.js +773 -0
  191. package/dist/src/generator/terraform.js +1472 -0
  192. package/dist/src/history/index.js +6 -0
  193. package/dist/src/history/manager.js +199 -0
  194. package/dist/src/history/types.js +6 -0
  195. package/dist/src/hooks/config.js +318 -0
  196. package/dist/src/hooks/engine.js +317 -0
  197. package/dist/src/hooks/index.js +2 -0
  198. package/dist/src/llm/auth-bridge.js +157 -0
  199. package/dist/src/llm/circuit-breaker.js +116 -0
  200. package/dist/src/llm/config-loader.js +172 -0
  201. package/dist/src/llm/cost-calculator.js +137 -0
  202. package/dist/src/llm/index.js +7 -0
  203. package/dist/src/llm/model-aliases.js +99 -0
  204. package/dist/src/llm/provider-registry.js +57 -0
  205. package/dist/src/llm/providers/anthropic.js +430 -0
  206. package/dist/src/llm/providers/bedrock.js +409 -0
  207. package/dist/src/llm/providers/google.js +344 -0
  208. package/dist/src/llm/providers/ollama.js +661 -0
  209. package/dist/src/llm/providers/openai-compatible.js +289 -0
  210. package/dist/src/llm/providers/openai.js +284 -0
  211. package/dist/src/llm/providers/openrouter.js +293 -0
  212. package/dist/src/llm/router.js +844 -0
  213. package/dist/src/llm/types.js +69 -0
  214. package/dist/src/lsp/client.js +239 -0
  215. package/dist/src/lsp/languages.js +95 -0
  216. package/dist/src/lsp/manager.js +243 -0
  217. package/dist/src/mcp/client.js +289 -0
  218. package/dist/src/mcp/index.js +5 -0
  219. package/dist/src/mcp/manager.js +113 -0
  220. package/dist/src/nimbus.js +212 -0
  221. package/dist/src/plugins/index.js +13 -0
  222. package/dist/src/plugins/loader.js +280 -0
  223. package/dist/src/plugins/manager.js +282 -0
  224. package/dist/src/plugins/types.js +23 -0
  225. package/dist/src/scanners/cicd-scanner.js +230 -0
  226. package/dist/src/scanners/cloud-scanner.js +415 -0
  227. package/dist/src/scanners/framework-scanner.js +430 -0
  228. package/dist/src/scanners/iac-scanner.js +350 -0
  229. package/dist/src/scanners/index.js +454 -0
  230. package/dist/src/scanners/language-scanner.js +258 -0
  231. package/dist/src/scanners/package-manager-scanner.js +252 -0
  232. package/dist/src/scanners/types.js +6 -0
  233. package/dist/src/sessions/manager.js +395 -0
  234. package/dist/src/sessions/types.js +4 -0
  235. package/dist/src/sharing/sync.js +238 -0
  236. package/dist/src/sharing/viewer.js +131 -0
  237. package/dist/src/snapshots/index.js +1 -0
  238. package/dist/src/snapshots/manager.js +432 -0
  239. package/dist/src/state/artifacts.js +94 -0
  240. package/dist/src/state/audit.js +73 -0
  241. package/dist/src/state/billing.js +126 -0
  242. package/dist/src/state/checkpoints.js +81 -0
  243. package/dist/src/state/config.js +58 -0
  244. package/dist/src/state/conversations.js +7 -0
  245. package/dist/src/state/credentials.js +96 -0
  246. package/dist/src/state/db.js +53 -0
  247. package/dist/src/state/index.js +23 -0
  248. package/dist/src/state/messages.js +76 -0
  249. package/dist/src/state/projects.js +92 -0
  250. package/dist/src/state/schema.js +233 -0
  251. package/dist/src/state/sessions.js +79 -0
  252. package/dist/src/state/teams.js +131 -0
  253. package/dist/src/telemetry.js +91 -0
  254. package/dist/src/tools/aws-ops.js +747 -0
  255. package/dist/src/tools/azure-ops.js +491 -0
  256. package/dist/src/tools/file-ops.js +451 -0
  257. package/dist/src/tools/gcp-ops.js +559 -0
  258. package/dist/src/tools/git-ops.js +557 -0
  259. package/dist/src/tools/github-ops.js +460 -0
  260. package/dist/src/tools/helm-ops.js +634 -0
  261. package/dist/src/tools/index.js +16 -0
  262. package/dist/src/tools/k8s-ops.js +579 -0
  263. package/dist/src/tools/schemas/converter.js +129 -0
  264. package/dist/src/tools/schemas/devops.js +3319 -0
  265. package/dist/src/tools/schemas/index.js +19 -0
  266. package/dist/src/tools/schemas/standard.js +966 -0
  267. package/dist/src/tools/schemas/types.js +409 -0
  268. package/dist/src/tools/spawn-exec.js +109 -0
  269. package/dist/src/tools/terraform-ops.js +627 -0
  270. package/dist/src/types/config.js +1 -0
  271. package/dist/src/types/drift.js +4 -0
  272. package/dist/src/types/enterprise.js +5 -0
  273. package/dist/src/types/index.js +14 -0
  274. package/dist/src/types/plan.js +1 -0
  275. package/dist/src/types/request.js +1 -0
  276. package/dist/src/types/response.js +1 -0
  277. package/dist/src/types/service.js +1 -0
  278. package/dist/src/ui/App.js +1672 -0
  279. package/dist/src/ui/DeployPreview.js +60 -0
  280. package/dist/src/ui/FileDiffModal.js +108 -0
  281. package/dist/src/ui/Header.js +46 -0
  282. package/dist/src/ui/HelpModal.js +9 -0
  283. package/dist/src/ui/InputBox.js +408 -0
  284. package/dist/src/ui/MessageList.js +795 -0
  285. package/dist/src/ui/PermissionPrompt.js +72 -0
  286. package/dist/src/ui/StatusBar.js +109 -0
  287. package/dist/src/ui/TerminalPane.js +31 -0
  288. package/dist/src/ui/ToolCallDisplay.js +303 -0
  289. package/dist/src/ui/TreePane.js +83 -0
  290. package/dist/src/ui/chat-ui.js +721 -0
  291. package/dist/src/ui/index.js +11 -0
  292. package/dist/src/ui/ink/index.js +1325 -0
  293. package/dist/src/ui/streaming.js +137 -0
  294. package/dist/src/ui/theme.js +78 -0
  295. package/dist/src/ui/types.js +7 -0
  296. package/dist/src/utils/analytics.js +61 -0
  297. package/dist/src/utils/cost-warning.js +25 -0
  298. package/dist/src/utils/env.js +42 -0
  299. package/dist/src/utils/errors.js +54 -0
  300. package/dist/src/utils/event-bus.js +22 -0
  301. package/dist/src/utils/index.js +16 -0
  302. package/dist/src/utils/logger.js +150 -0
  303. package/dist/src/utils/rate-limiter.js +90 -0
  304. package/dist/src/utils/service-auth.js +36 -0
  305. package/dist/src/utils/validation.js +39 -0
  306. package/dist/src/version.js +3 -0
  307. package/dist/src/watcher/index.js +192 -0
  308. package/dist/src/wizard/approval.js +275 -0
  309. package/dist/src/wizard/index.js +13 -0
  310. package/dist/src/wizard/prompts.js +273 -0
  311. package/dist/src/wizard/types.js +4 -0
  312. package/dist/src/wizard/ui.js +453 -0
  313. package/dist/src/wizard/wizard.js +227 -0
  314. package/package.json +31 -23
  315. package/src/__tests__/alias.test.ts +133 -0
  316. package/src/__tests__/app.test.ts +1 -1
  317. package/src/__tests__/audit.test.ts +1 -1
  318. package/src/__tests__/circuit-breaker.test.ts +1 -1
  319. package/src/__tests__/cli-run.test.ts +237 -1
  320. package/src/__tests__/compat-sqlite.test.ts +68 -0
  321. package/src/__tests__/context-manager.test.ts +131 -1
  322. package/src/__tests__/context.test.ts +1 -1
  323. package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
  324. package/src/__tests__/doctor.test.ts +48 -0
  325. package/src/__tests__/enterprise.test.ts +1 -1
  326. package/src/__tests__/export.test.ts +236 -0
  327. package/src/__tests__/gap-11-18-20.test.ts +958 -0
  328. package/src/__tests__/generator.test.ts +1 -1
  329. package/src/__tests__/helm-streaming.test.ts +127 -0
  330. package/src/__tests__/hooks.test.ts +1 -1
  331. package/src/__tests__/incident.test.ts +179 -0
  332. package/src/__tests__/init.test.ts +55 -4
  333. package/src/__tests__/intent-parser.test.ts +1 -1
  334. package/src/__tests__/llm-router.test.ts +1 -1
  335. package/src/__tests__/logs.test.ts +107 -0
  336. package/src/__tests__/loop-errors.test.ts +244 -0
  337. package/src/__tests__/lsp.test.ts +1 -1
  338. package/src/__tests__/modes.test.ts +1 -1
  339. package/src/__tests__/perf-optimizations.test.ts +847 -0
  340. package/src/__tests__/permissions.test.ts +1 -1
  341. package/src/__tests__/pipeline.test.ts +50 -0
  342. package/src/__tests__/polish-phase3.test.ts +340 -0
  343. package/src/__tests__/profile.test.ts +237 -0
  344. package/src/__tests__/rollback.test.ts +83 -0
  345. package/src/__tests__/runbook.test.ts +219 -0
  346. package/src/__tests__/schedule.test.ts +206 -0
  347. package/src/__tests__/serve.test.ts +1 -1
  348. package/src/__tests__/sessions.test.ts +96 -1
  349. package/src/__tests__/sharing.test.ts +53 -1
  350. package/src/__tests__/snapshots.test.ts +1 -1
  351. package/src/__tests__/standalone-migration.test.ts +199 -0
  352. package/src/__tests__/state-db.test.ts +1 -1
  353. package/src/__tests__/status.test.ts +158 -0
  354. package/src/__tests__/stream-with-tools.test.ts +71 -25
  355. package/src/__tests__/subagents.test.ts +1 -1
  356. package/src/__tests__/system-prompt.test.ts +82 -3
  357. package/src/__tests__/terminal-gap-v2.test.ts +395 -0
  358. package/src/__tests__/terminal-parity.test.ts +393 -0
  359. package/src/__tests__/tf-apply.test.ts +187 -0
  360. package/src/__tests__/tool-converter.test.ts +1 -1
  361. package/src/__tests__/tool-schemas.test.ts +209 -4
  362. package/src/__tests__/tools.test.ts +4 -3
  363. package/src/__tests__/version-json.test.ts +184 -0
  364. package/src/__tests__/version.test.ts +1 -1
  365. package/src/__tests__/watch.test.ts +129 -0
  366. package/src/agent/compaction-agent.ts +40 -1
  367. package/src/agent/context-manager.ts +67 -3
  368. package/src/agent/deploy-preview.ts +62 -1
  369. package/src/agent/expand-files.ts +108 -0
  370. package/src/agent/loop.ts +1312 -31
  371. package/src/agent/permissions.ts +51 -4
  372. package/src/agent/system-prompt.ts +573 -19
  373. package/src/app.ts +58 -0
  374. package/src/audit/security-scanner.ts +45 -0
  375. package/src/auth/keychain.ts +82 -0
  376. package/src/auth/oauth.ts +15 -5
  377. package/src/cli/init.ts +378 -5
  378. package/src/cli/run.ts +407 -16
  379. package/src/cli/serve.ts +78 -1
  380. package/src/cli/web.ts +10 -6
  381. package/src/cli.ts +312 -1
  382. package/src/clients/service-discovery.ts +30 -25
  383. package/src/commands/alias.ts +100 -0
  384. package/src/commands/audit/index.ts +121 -2
  385. package/src/commands/auth-cloud.ts +113 -0
  386. package/src/commands/auth-refresh.ts +187 -0
  387. package/src/commands/aws-discover.ts +144 -251
  388. package/src/commands/aws-terraform.ts +68 -118
  389. package/src/commands/chat.ts +9 -3
  390. package/src/commands/completions.ts +268 -0
  391. package/src/commands/config.ts +26 -0
  392. package/src/commands/cost/index.ts +218 -2
  393. package/src/commands/deploy.ts +260 -0
  394. package/src/commands/doctor.ts +744 -152
  395. package/src/commands/drift/index.ts +371 -23
  396. package/src/commands/export.ts +146 -0
  397. package/src/commands/generate-k8s.ts +9 -61
  398. package/src/commands/generate-terraform.ts +191 -449
  399. package/src/commands/help.ts +212 -36
  400. package/src/commands/history.ts +8 -1
  401. package/src/commands/incident.ts +166 -0
  402. package/src/commands/init.ts +5 -0
  403. package/src/commands/login.ts +86 -1
  404. package/src/commands/logs.ts +167 -0
  405. package/src/commands/onboarding.ts +211 -34
  406. package/src/commands/pipeline.ts +186 -0
  407. package/src/commands/plugin.ts +398 -0
  408. package/src/commands/profile.ts +342 -0
  409. package/src/commands/questionnaire.ts +0 -98
  410. package/src/commands/resume.ts +26 -34
  411. package/src/commands/rollback.ts +315 -0
  412. package/src/commands/rollout.ts +88 -0
  413. package/src/commands/runbook.ts +346 -0
  414. package/src/commands/schedule.ts +236 -0
  415. package/src/commands/status.ts +252 -0
  416. package/src/commands/team-context.ts +220 -0
  417. package/src/commands/template.ts +58 -57
  418. package/src/commands/tf/index.ts +70 -11
  419. package/src/commands/upgrade.ts +57 -0
  420. package/src/commands/version.ts +54 -50
  421. package/src/commands/watch.ts +153 -0
  422. package/src/compat/runtime.ts +1 -1
  423. package/src/compat/sqlite.ts +75 -5
  424. package/src/config/mode-store.ts +62 -0
  425. package/src/config/profiles.ts +84 -0
  426. package/src/config/types.ts +83 -1
  427. package/src/config/workspace-state.ts +53 -0
  428. package/src/engine/cost-estimator.ts +52 -10
  429. package/src/engine/executor.ts +33 -2
  430. package/src/engine/planner.ts +68 -1
  431. package/src/generator/terraform.ts +8 -0
  432. package/src/history/manager.ts +2 -74
  433. package/src/hooks/engine.ts +5 -4
  434. package/src/llm/cost-calculator.ts +2 -2
  435. package/src/llm/providers/anthropic.ts +50 -21
  436. package/src/llm/router.ts +76 -7
  437. package/src/lsp/languages.ts +3 -0
  438. package/src/lsp/manager.ts +21 -5
  439. package/src/nimbus.ts +37 -18
  440. package/src/sessions/manager.ts +108 -1
  441. package/src/sharing/sync.ts +4 -0
  442. package/src/sharing/viewer.ts +66 -0
  443. package/src/tools/file-ops.ts +22 -0
  444. package/src/tools/schemas/devops.ts +3007 -117
  445. package/src/tools/schemas/standard.ts +5 -1
  446. package/src/tools/schemas/types.ts +31 -1
  447. package/src/tools/spawn-exec.ts +148 -0
  448. package/src/ui/App.tsx +1183 -66
  449. package/src/ui/DeployPreview.tsx +62 -57
  450. package/src/ui/FileDiffModal.tsx +162 -0
  451. package/src/ui/Header.tsx +87 -24
  452. package/src/ui/HelpModal.tsx +57 -0
  453. package/src/ui/InputBox.tsx +163 -10
  454. package/src/ui/MessageList.tsx +487 -40
  455. package/src/ui/PermissionPrompt.tsx +17 -5
  456. package/src/ui/StatusBar.tsx +122 -3
  457. package/src/ui/TerminalPane.tsx +84 -0
  458. package/src/ui/ToolCallDisplay.tsx +252 -18
  459. package/src/ui/TreePane.tsx +132 -0
  460. package/src/ui/chat-ui.ts +41 -44
  461. package/src/ui/ink/index.ts +771 -38
  462. package/src/ui/streaming.ts +1 -1
  463. package/src/ui/theme.ts +104 -0
  464. package/src/ui/types.ts +18 -0
  465. package/src/version.ts +1 -1
  466. package/src/watcher/index.ts +66 -15
  467. package/src/wizard/types.ts +1 -0
  468. package/src/wizard/ui.ts +1 -1
  469. package/tsconfig.json +2 -2
@@ -0,0 +1,1672 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * App Component
4
+ *
5
+ * Root Ink component that composes the entire Nimbus TUI. It manages the
6
+ * top-level application state and wires child components together:
7
+ *
8
+ * Header (top)
9
+ * MessageList (middle, flexGrow)
10
+ * ToolCallDisplay (inline when a tool is active)
11
+ * PermissionPrompt (modal overlay when permission is needed)
12
+ * DeployPreview (modal overlay when deploy confirmation is needed)
13
+ * InputBox (above status bar)
14
+ * StatusBar (bottom)
15
+ *
16
+ * Keyboard shortcuts (via useInput):
17
+ * Tab - cycle through modes (plan -> build -> deploy -> plan)
18
+ * Ctrl+C - interrupt current operation or exit
19
+ * Escape - cancel current operation
20
+ */
21
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
22
+ import { Box, Text, useInput, useApp } from 'ink';
23
+ import Spinner from 'ink-spinner';
24
+ import { readFileSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
+ import { Header } from './Header';
27
+ import { MessageList } from './MessageList';
28
+ import { ToolCallDisplay } from './ToolCallDisplay';
29
+ import { InputBox } from './InputBox';
30
+ import { StatusBar } from './StatusBar';
31
+ import { PermissionPrompt } from './PermissionPrompt';
32
+ import { DeployPreview } from './DeployPreview';
33
+ import { FileDiffModal } from './FileDiffModal';
34
+ import { HelpModal } from './HelpModal';
35
+ import { TerminalPane } from './TerminalPane';
36
+ import { TreePane } from './TreePane';
37
+ /* ---------------------------------------------------------------------------
38
+ * Mode rotation helper
39
+ * -------------------------------------------------------------------------*/
40
+ const MODES = ['plan', 'build', 'deploy'];
41
+ function nextMode(current) {
42
+ const idx = MODES.indexOf(current);
43
+ return MODES[(idx + 1) % MODES.length];
44
+ }
45
+ /* ---------------------------------------------------------------------------
46
+ * Production environment detection helper (G7)
47
+ * -------------------------------------------------------------------------*/
48
+ /**
49
+ * Returns true when the session's terraform workspace or kubectl context
50
+ * matches a production naming convention (prod, production, live).
51
+ */
52
+ function isProdEnvironment(session) {
53
+ const prodPattern = /prod|production|live/i;
54
+ if (session.terraformWorkspace && prodPattern.test(session.terraformWorkspace)) {
55
+ return true;
56
+ }
57
+ if (session.kubectlContext && prodPattern.test(session.kubectlContext)) {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /* ---------------------------------------------------------------------------
63
+ * Default session factory
64
+ * -------------------------------------------------------------------------*/
65
+ function createDefaultSession(overrides) {
66
+ return {
67
+ id: overrides?.id ?? crypto.randomUUID(),
68
+ model: overrides?.model ?? 'default',
69
+ mode: overrides?.mode ?? 'build',
70
+ tokenCount: overrides?.tokenCount ?? 0,
71
+ maxTokens: overrides?.maxTokens ?? 200_000,
72
+ costUSD: overrides?.costUSD ?? 0,
73
+ snapshotCount: overrides?.snapshotCount ?? 0,
74
+ };
75
+ }
76
+ /* ---------------------------------------------------------------------------
77
+ * App component
78
+ * -------------------------------------------------------------------------*/
79
+ /**
80
+ * App is the root Ink component. It maintains the full UI state and delegates
81
+ * rendering to focused child components. External orchestration logic can
82
+ * interact with the TUI by passing `onMessage` and `onAbort` callbacks, or
83
+ * by manipulating state through the imperative handles exposed on this
84
+ * component (see the exported hooks below).
85
+ */
86
+ export function App({ initialSession, onMessage, onAbort, onCompact, onContext, onUndo, onRedo, onSessions, onNewSession, onSwitchSession, onModels, onClear, onModelChange, onModeChange, onDiff, onCost, onInit, onExport, onRemember, onReady, initialMessages, initialMode, hasApiKey = true, onFetchCompletions, columns = 80, }) {
87
+ const { exit } = useApp();
88
+ /* -- State ------------------------------------------------------------- */
89
+ const [session, setSession] = useState(createDefaultSession({ ...initialSession, mode: initialMode ?? initialSession?.mode ?? 'build' }));
90
+ const [messages, setMessages] = useState((initialMessages ?? []));
91
+ const [activeToolCalls, setActiveToolCalls] = useState([]);
92
+ const [permissionRequest, setPermissionRequest] = useState(null);
93
+ const [deployPreview, setDeployPreview] = useState(null);
94
+ const [fileDiffRequest, setFileDiffRequest] = useState(null);
95
+ const [showHelp, setShowHelp] = useState(false);
96
+ const [showTerminalPane, setShowTerminalPane] = useState(false);
97
+ /** M3: Auto-show terminal pane when long-running DevOps tools start. */
98
+ const [terminalPaneAuto, setTerminalPaneAuto] = useState(false);
99
+ const [showTreePane, setShowTreePane] = useState(false);
100
+ const [isProcessing, setIsProcessing] = useState(false);
101
+ const [abortPending, setAbortPending] = useState(false);
102
+ const [processingStartTime, setProcessingStartTime] = useState(null);
103
+ const [inputLineCount, setInputLineCount] = useState(1);
104
+ /** GAP-7: pending context selection — holds available contexts while user picks */
105
+ const [pendingContextSelect, setPendingContextSelect] = useState(null);
106
+ /** GAP-8: pending workspace selection — holds available workspaces while user picks */
107
+ const [pendingWorkspaceSelect, setPendingWorkspaceSelect] = useState(null);
108
+ // Tracks whether the current agent turn has produced any visible output (text or tool calls).
109
+ // Reset to false when a new turn starts, set to true on first content/tool.
110
+ const [currentTurnHasOutput, setCurrentTurnHasOutput] = useState(false);
111
+ // Rolling buffer of all completed tool calls for TerminalPane (M1)
112
+ const [completedToolCalls, setCompletedToolCalls] = useState([]);
113
+ /** GAP-21: Pre-fill text for InputBox (injected by TreePane file selection). */
114
+ const [inputPrefill, setInputPrefill] = useState(undefined);
115
+ /** C3: Show API key setup banner when no API key is configured. */
116
+ const [showApiKeySetup, setShowApiKeySetup] = useState(!hasApiKey);
117
+ /** C1: Number of messages scrolled back from the bottom (0 = pinned to bottom). */
118
+ const [scrollOffset, setScrollOffset] = useState(0);
119
+ /** C1: When true, new messages auto-scroll to the bottom. */
120
+ const [scrollLocked, setScrollLocked] = useState(true);
121
+ /** C1: Ref to scrollLocked for use inside imperative callbacks (closures). */
122
+ const scrollLockedRef = useRef(true);
123
+ /** H1: Toast message shown after copying a code block to clipboard. */
124
+ const [copyToast, setCopyToast] = useState('');
125
+ /** H5: Toast shown briefly after Tab mode cycle. */
126
+ const [modeToast, setModeToast] = useState(null);
127
+ /** H3: When true, show deploy mode confirmation box before switching. */
128
+ const [pendingDeployConfirm, setPendingDeployConfirm] = useState(false);
129
+ /** M1: Current search query for conversation filtering. */
130
+ const [searchQuery, setSearchQuery] = useState('');
131
+ /** M1: Whether search mode is active. */
132
+ const [searchMode, setSearchMode] = useState(false);
133
+ /** M5: Watch mode active — shows watched pattern in StatusBar. */
134
+ const [watchPattern, setWatchPattern] = useState(null);
135
+ const watchAbortRef = useRef(null);
136
+ /* -- Expose imperative API to external orchestrator -------------------- */
137
+ const onReadyCalled = useRef(false);
138
+ useEffect(() => {
139
+ if (onReady && !onReadyCalled.current) {
140
+ onReadyCalled.current = true;
141
+ onReady({
142
+ addMessage: (msg) => {
143
+ setMessages(prev => [...prev, msg]);
144
+ // C1: Keep pinned to bottom when scroll is locked
145
+ if (scrollLockedRef.current)
146
+ setScrollOffset(0);
147
+ },
148
+ updateMessage: (id, content) => {
149
+ if (content)
150
+ setCurrentTurnHasOutput(true);
151
+ setMessages(prev => prev.map(m => (m.id === id ? { ...m, content } : m)));
152
+ },
153
+ updateSession: (patch) => setSession(prev => ({ ...prev, ...patch })),
154
+ setToolCalls: (toolCalls) => {
155
+ if (toolCalls.length > 0)
156
+ setCurrentTurnHasOutput(true);
157
+ setActiveToolCalls(toolCalls);
158
+ // M3: Auto-show terminal pane when long-running DevOps tools start
159
+ const LONG_RUNNING_TOOL_PATTERNS = [
160
+ 'terraform', 'helm', 'kubectl', 'docker', 'cicd', 'gitops', 'drift_detect', 'cfn',
161
+ ];
162
+ const hasRunning = toolCalls.some(tc => tc.status === 'running');
163
+ const hasLongRunning = toolCalls.some(tc => tc.status === 'running' &&
164
+ LONG_RUNNING_TOOL_PATTERNS.some(n => tc.name.toLowerCase().includes(n)));
165
+ if (hasLongRunning) {
166
+ setTerminalPaneAuto(true);
167
+ }
168
+ else if (!hasRunning &&
169
+ toolCalls.length > 0 &&
170
+ toolCalls.every(tc => tc.status === 'completed' || tc.status === 'failed')) {
171
+ // All tools done — auto-hide after 2 seconds
172
+ setTimeout(() => setTerminalPaneAuto(false), 2000);
173
+ }
174
+ // Accumulate completed/failed tool calls for TerminalPane (M1)
175
+ const done = toolCalls.filter(tc => tc.status === 'completed' || tc.status === 'failed');
176
+ if (done.length > 0) {
177
+ setCompletedToolCalls(prev => [...prev, ...done].slice(-100));
178
+ }
179
+ },
180
+ requestPermission: (req) => setPermissionRequest(req),
181
+ showDeployPreview: (preview) => setDeployPreview(preview),
182
+ requestDeployPreview: (preview, onDecide) => setDeployPreview({ ...preview, onDecide }),
183
+ requestFileDiff: (path, toolName, diff, onDecide, currentIndex) => setFileDiffRequest({ filePath: path, toolName, diff, onDecide, currentIndex }),
184
+ setProcessing: (v) => {
185
+ setIsProcessing(v);
186
+ setProcessingStartTime(v ? Date.now() : null);
187
+ },
188
+ setLLMHealth: (health) => {
189
+ setSession(prev => ({ ...prev, llmHealth: health }));
190
+ },
191
+ });
192
+ }
193
+ }, [onReady]);
194
+ /* -- C3: Auto-dismiss API key setup banner after 8 seconds ------------ */
195
+ useEffect(() => {
196
+ if (showApiKeySetup) {
197
+ const timer = setTimeout(() => setShowApiKeySetup(false), 8000);
198
+ return () => clearTimeout(timer);
199
+ }
200
+ }, [showApiKeySetup]);
201
+ /* -- C1: Keep scrollLockedRef in sync with scrollLocked state ---------- */
202
+ useEffect(() => {
203
+ scrollLockedRef.current = scrollLocked;
204
+ }, [scrollLocked]);
205
+ /* -- Callbacks --------------------------------------------------------- */
206
+ /** Handle user message submission from the InputBox. */
207
+ const handleSubmit = useCallback((text) => {
208
+ // C3: Dismiss the API key setup banner on first message submission
209
+ setShowApiKeySetup(false);
210
+ const trimmed = text.trim();
211
+ // -----------------------------------------------------------------
212
+ // GAP-7/GAP-8: Handle pending picker selections (kubectl context / tf workspace)
213
+ // -----------------------------------------------------------------
214
+ if (pendingContextSelect) {
215
+ setPendingContextSelect(null);
216
+ const idx = parseInt(trimmed, 10);
217
+ const chosen = (!isNaN(idx) && idx >= 1 && idx <= pendingContextSelect.length)
218
+ ? pendingContextSelect[idx - 1]
219
+ : pendingContextSelect.find(c => c === trimmed);
220
+ if (chosen) {
221
+ try {
222
+ const { execSync } = require('node:child_process');
223
+ execSync(`kubectl config use-context ${chosen}`, { encoding: 'utf-8', timeout: 5000 });
224
+ setSession(prev => ({ ...prev, kubectlContext: chosen }));
225
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `[OK] Switched kubectl context to: ${chosen}`, timestamp: new Date() }]);
226
+ }
227
+ catch (e) {
228
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Failed: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
229
+ }
230
+ }
231
+ else {
232
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Context not found: "${trimmed}". Type /k8s-ctx to try again.`, timestamp: new Date() }]);
233
+ }
234
+ return;
235
+ }
236
+ if (pendingWorkspaceSelect) {
237
+ setPendingWorkspaceSelect(null);
238
+ const idx = parseInt(trimmed, 10);
239
+ const chosen = (!isNaN(idx) && idx >= 1 && idx <= pendingWorkspaceSelect.length)
240
+ ? pendingWorkspaceSelect[idx - 1]
241
+ : pendingWorkspaceSelect.find(w => w === trimmed);
242
+ if (chosen) {
243
+ try {
244
+ const { execSync } = require('node:child_process');
245
+ execSync(`terraform workspace select ${chosen}`, { encoding: 'utf-8', timeout: 10000, cwd: process.cwd() });
246
+ setSession(prev => ({ ...prev, terraformWorkspace: chosen }));
247
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `[OK] Switched Terraform workspace to: ${chosen}`, timestamp: new Date() }]);
248
+ }
249
+ catch (e) {
250
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Failed: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
251
+ }
252
+ }
253
+ else {
254
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Workspace not found: "${trimmed}". Type /tf-ws to try again.`, timestamp: new Date() }]);
255
+ }
256
+ return;
257
+ }
258
+ // -----------------------------------------------------------------
259
+ // Slash command handling
260
+ // -----------------------------------------------------------------
261
+ // /compact [focus area] — manually trigger context compaction
262
+ if (trimmed === '/compact' || trimmed.startsWith('/compact ')) {
263
+ const focusArea = trimmed.length > '/compact'.length ? trimmed.slice('/compact '.length).trim() : undefined;
264
+ const systemMsg = {
265
+ id: crypto.randomUUID(),
266
+ role: 'system',
267
+ content: focusArea
268
+ ? `Compacting context (focus: ${focusArea})...`
269
+ : 'Compacting context...',
270
+ timestamp: new Date(),
271
+ };
272
+ setMessages(prev => [...prev, systemMsg]);
273
+ if (onCompact) {
274
+ setIsProcessing(true);
275
+ onCompact(focusArea)
276
+ .then(result => {
277
+ const resultMsg = {
278
+ id: crypto.randomUUID(),
279
+ role: 'system',
280
+ content: result
281
+ ? `Context compacted! Saved ${result.savedTokens.toLocaleString()} tokens (${result.originalTokens.toLocaleString()} → ${result.compactedTokens.toLocaleString()}).`
282
+ : 'Compaction skipped — not enough context to compact.',
283
+ timestamp: new Date(),
284
+ };
285
+ setMessages(prev => [...prev, resultMsg]);
286
+ setIsProcessing(false);
287
+ })
288
+ .catch(() => {
289
+ const errMsg = {
290
+ id: crypto.randomUUID(),
291
+ role: 'system',
292
+ content: 'Compaction failed. The conversation continues unchanged.',
293
+ timestamp: new Date(),
294
+ };
295
+ setMessages(prev => [...prev, errMsg]);
296
+ setIsProcessing(false);
297
+ });
298
+ }
299
+ else {
300
+ const noHandler = {
301
+ id: crypto.randomUUID(),
302
+ role: 'system',
303
+ content: 'Compaction is not available in this session.',
304
+ timestamp: new Date(),
305
+ };
306
+ setMessages(prev => [...prev, noHandler]);
307
+ }
308
+ return;
309
+ }
310
+ // /branch [name] — save conversation checkpoint (M3)
311
+ if (trimmed === '/branch' || trimmed.startsWith('/branch ')) {
312
+ const branchName = trimmed.length > '/branch'.length
313
+ ? trimmed.slice('/branch '.length).trim()
314
+ : `branch-${Date.now()}`;
315
+ void (async () => {
316
+ try {
317
+ const { join } = require('node:path');
318
+ const { homedir } = require('node:os');
319
+ const { mkdirSync, writeFileSync } = require('node:fs');
320
+ const branchDir = join(homedir(), '.nimbus', 'branches');
321
+ mkdirSync(branchDir, { recursive: true });
322
+ const branchPath = join(branchDir, `${branchName}.json`);
323
+ const snapshot = {
324
+ name: branchName,
325
+ savedAt: new Date().toISOString(),
326
+ messages: messages.map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp })),
327
+ session: { mode: session.mode, model: session.model },
328
+ };
329
+ writeFileSync(branchPath, JSON.stringify(snapshot, null, 2), 'utf-8');
330
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Conversation checkpoint saved: "${branchName}" (${messages.length} messages)`, timestamp: new Date() }]);
331
+ }
332
+ catch (e) {
333
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Branch save failed: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
334
+ }
335
+ })();
336
+ return;
337
+ }
338
+ // /undo — revert the last file-modifying tool call
339
+ if (trimmed === '/undo') {
340
+ if (onUndo) {
341
+ const pendingMsg = {
342
+ id: crypto.randomUUID(),
343
+ role: 'system',
344
+ content: 'Reverting last change...',
345
+ timestamp: new Date(),
346
+ };
347
+ setMessages(prev => [...prev, pendingMsg]);
348
+ setIsProcessing(true);
349
+ onUndo()
350
+ .then(result => {
351
+ const msg = {
352
+ id: crypto.randomUUID(),
353
+ role: 'system',
354
+ content: result.success
355
+ ? `Undo successful: ${result.description}`
356
+ : `Undo failed: ${result.description}`,
357
+ timestamp: new Date(),
358
+ };
359
+ setMessages(prev => [...prev, msg]);
360
+ setIsProcessing(false);
361
+ })
362
+ .catch(() => {
363
+ const msg = {
364
+ id: crypto.randomUUID(),
365
+ role: 'system',
366
+ content: 'Undo failed unexpectedly.',
367
+ timestamp: new Date(),
368
+ };
369
+ setMessages(prev => [...prev, msg]);
370
+ setIsProcessing(false);
371
+ });
372
+ }
373
+ else {
374
+ const msg = {
375
+ id: crypto.randomUUID(),
376
+ role: 'system',
377
+ content: 'Undo is not available in this session.',
378
+ timestamp: new Date(),
379
+ };
380
+ setMessages(prev => [...prev, msg]);
381
+ }
382
+ return;
383
+ }
384
+ // /redo — re-apply a previously undone change
385
+ if (trimmed === '/redo') {
386
+ if (onRedo) {
387
+ const pendingMsg = {
388
+ id: crypto.randomUUID(),
389
+ role: 'system',
390
+ content: 'Re-applying change...',
391
+ timestamp: new Date(),
392
+ };
393
+ setMessages(prev => [...prev, pendingMsg]);
394
+ setIsProcessing(true);
395
+ onRedo()
396
+ .then(result => {
397
+ const msg = {
398
+ id: crypto.randomUUID(),
399
+ role: 'system',
400
+ content: result.success
401
+ ? `Redo successful: ${result.description}`
402
+ : `Redo failed: ${result.description}`,
403
+ timestamp: new Date(),
404
+ };
405
+ setMessages(prev => [...prev, msg]);
406
+ setIsProcessing(false);
407
+ })
408
+ .catch(() => {
409
+ const msg = {
410
+ id: crypto.randomUUID(),
411
+ role: 'system',
412
+ content: 'Redo failed unexpectedly.',
413
+ timestamp: new Date(),
414
+ };
415
+ setMessages(prev => [...prev, msg]);
416
+ setIsProcessing(false);
417
+ });
418
+ }
419
+ else {
420
+ const msg = {
421
+ id: crypto.randomUUID(),
422
+ role: 'system',
423
+ content: 'Redo is not available in this session.',
424
+ timestamp: new Date(),
425
+ };
426
+ setMessages(prev => [...prev, msg]);
427
+ }
428
+ return;
429
+ }
430
+ // /help — show dismissable help modal overlay (does not pollute chat history)
431
+ if (trimmed === '/help') {
432
+ setShowHelp(true);
433
+ return;
434
+ }
435
+ // /clear — clear conversation history (both UI and LLM context)
436
+ if (trimmed === '/clear') {
437
+ setMessages([]);
438
+ if (onClear) {
439
+ onClear();
440
+ }
441
+ const msg = {
442
+ id: crypto.randomUUID(),
443
+ role: 'system',
444
+ content: 'Conversation cleared.',
445
+ timestamp: new Date(),
446
+ };
447
+ setMessages([msg]);
448
+ return;
449
+ }
450
+ // /model [name] — show or switch the active model
451
+ if (trimmed === '/model' || trimmed.startsWith('/model ')) {
452
+ const newModel = trimmed.length > '/model'.length ? trimmed.slice('/model '.length).trim() : undefined;
453
+ if (newModel) {
454
+ setSession(prev => ({ ...prev, model: newModel }));
455
+ // Propagate the model change to the agent loop
456
+ if (onModelChange) {
457
+ onModelChange(newModel);
458
+ }
459
+ const msg = {
460
+ id: crypto.randomUUID(),
461
+ role: 'system',
462
+ content: `Model switched to: ${newModel}`,
463
+ timestamp: new Date(),
464
+ };
465
+ setMessages(prev => [...prev, msg]);
466
+ }
467
+ else {
468
+ // Gap 6: show authenticated providers for discovery
469
+ let providerInfo = '';
470
+ try {
471
+ const { listAuthenticatedProviders } = require('../llm/router');
472
+ const providers = listAuthenticatedProviders();
473
+ if (providers.length > 0) {
474
+ providerInfo = `\nAuthenticated providers: ${providers.join(', ')}\nUsage: /model <provider>/<model> (e.g. /model anthropic/claude-sonnet-4-20250514)`;
475
+ }
476
+ }
477
+ catch { /* non-critical */ }
478
+ const msg = {
479
+ id: crypto.randomUUID(),
480
+ role: 'system',
481
+ content: `Current model: ${session.model}${providerInfo || '\n\nUsage: /model <name> (e.g. /model sonnet, /model gpt4o, /model gemini)'}`,
482
+ timestamp: new Date(),
483
+ };
484
+ setMessages(prev => [...prev, msg]);
485
+ }
486
+ return;
487
+ }
488
+ // /mode [plan|build|deploy] — show or switch agent mode
489
+ if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
490
+ const newMode = trimmed.length > '/mode'.length
491
+ ? trimmed.slice('/mode '.length).trim().toLowerCase()
492
+ : undefined;
493
+ if (newMode) {
494
+ const validModes = ['plan', 'build', 'deploy'];
495
+ if (validModes.includes(newMode)) {
496
+ // H3: Deploy mode requires confirmation before switching
497
+ if (newMode === 'deploy') {
498
+ setPendingDeployConfirm(true);
499
+ return;
500
+ }
501
+ setSession(prev => ({ ...prev, mode: newMode }));
502
+ if (onModeChange) {
503
+ onModeChange(newMode);
504
+ }
505
+ // H3: Persist the new mode for this working directory
506
+ try {
507
+ const { saveModeForCwd } = require('../config/mode-store');
508
+ saveModeForCwd(process.cwd(), newMode);
509
+ }
510
+ catch { /* non-critical */ }
511
+ const msg = {
512
+ id: crypto.randomUUID(),
513
+ role: 'system',
514
+ content: `Mode switched to: ${newMode}`,
515
+ timestamp: new Date(),
516
+ };
517
+ setMessages(prev => [...prev, msg]);
518
+ // G7: Warn when switching to deploy mode in a production environment
519
+ if (newMode === 'deploy' && isProdEnvironment(session)) {
520
+ const ctx = [
521
+ session.terraformWorkspace && `tf:${session.terraformWorkspace}`,
522
+ session.kubectlContext && `k8s:${session.kubectlContext}`,
523
+ ].filter(Boolean).join(', ');
524
+ const warnMsg = {
525
+ id: crypto.randomUUID(),
526
+ role: 'system',
527
+ content: `[!!] Production environment detected (${ctx}). Switched to DEPLOY mode — all operations will target production.`,
528
+ timestamp: new Date(),
529
+ };
530
+ setMessages(prev => [...prev, warnMsg]);
531
+ }
532
+ }
533
+ else {
534
+ const msg = {
535
+ id: crypto.randomUUID(),
536
+ role: 'system',
537
+ content: `Invalid mode: "${newMode}". Valid modes: plan, build, deploy`,
538
+ timestamp: new Date(),
539
+ };
540
+ setMessages(prev => [...prev, msg]);
541
+ }
542
+ }
543
+ else {
544
+ const msg = {
545
+ id: crypto.randomUUID(),
546
+ role: 'system',
547
+ content: `Current mode: ${session.mode}\n\nUsage: /mode <plan|build|deploy>`,
548
+ timestamp: new Date(),
549
+ };
550
+ setMessages(prev => [...prev, msg]);
551
+ }
552
+ return;
553
+ }
554
+ // /sessions — list active sessions
555
+ if (trimmed === '/sessions') {
556
+ if (onSessions) {
557
+ const sessions = onSessions();
558
+ const content = sessions.length > 0
559
+ ? [
560
+ 'Active sessions:',
561
+ ...sessions.map(s => ` ${s.id === session.id ? '* ' : ' '}${s.id.slice(0, 8)} ${s.name} (${s.model}, ${s.mode}) ${s.updatedAt}`),
562
+ ].join('\n')
563
+ : 'No sessions found.';
564
+ const msg = {
565
+ id: crypto.randomUUID(),
566
+ role: 'system',
567
+ content,
568
+ timestamp: new Date(),
569
+ };
570
+ setMessages(prev => [...prev, msg]);
571
+ }
572
+ else {
573
+ const msg = {
574
+ id: crypto.randomUUID(),
575
+ role: 'system',
576
+ content: 'Session management is not available.',
577
+ timestamp: new Date(),
578
+ };
579
+ setMessages(prev => [...prev, msg]);
580
+ }
581
+ return;
582
+ }
583
+ // /new [name] — create a new session
584
+ if (trimmed === '/new' || trimmed.startsWith('/new ')) {
585
+ const name = trimmed.length > '/new'.length ? trimmed.slice('/new '.length).trim() : undefined;
586
+ if (onNewSession) {
587
+ const newSession = onNewSession(name);
588
+ if (newSession) {
589
+ setMessages([]);
590
+ setSession(prev => ({
591
+ ...prev,
592
+ id: newSession.id,
593
+ model: newSession.model,
594
+ mode: newSession.mode,
595
+ }));
596
+ const msg = {
597
+ id: crypto.randomUUID(),
598
+ role: 'system',
599
+ content: `New session created: ${newSession.name}`,
600
+ timestamp: new Date(),
601
+ };
602
+ setMessages([msg]);
603
+ }
604
+ else {
605
+ const msg = {
606
+ id: crypto.randomUUID(),
607
+ role: 'system',
608
+ content: 'Failed to create new session.',
609
+ timestamp: new Date(),
610
+ };
611
+ setMessages(prev => [...prev, msg]);
612
+ }
613
+ }
614
+ else {
615
+ const msg = {
616
+ id: crypto.randomUUID(),
617
+ role: 'system',
618
+ content: 'Session management is not available.',
619
+ timestamp: new Date(),
620
+ };
621
+ setMessages(prev => [...prev, msg]);
622
+ }
623
+ return;
624
+ }
625
+ // /switch <id> — switch to a different session
626
+ if (trimmed.startsWith('/switch ')) {
627
+ const targetId = trimmed.slice('/switch '.length).trim();
628
+ if (onSwitchSession) {
629
+ const switched = onSwitchSession(targetId);
630
+ if (switched) {
631
+ setMessages([]);
632
+ setSession(prev => ({
633
+ ...prev,
634
+ id: switched.id,
635
+ model: switched.model,
636
+ mode: switched.mode,
637
+ }));
638
+ const msg = {
639
+ id: crypto.randomUUID(),
640
+ role: 'system',
641
+ content: `Switched to session: ${switched.name}`,
642
+ timestamp: new Date(),
643
+ };
644
+ setMessages([msg]);
645
+ }
646
+ else {
647
+ const msg = {
648
+ id: crypto.randomUUID(),
649
+ role: 'system',
650
+ content: `Session not found: ${targetId}`,
651
+ timestamp: new Date(),
652
+ };
653
+ setMessages(prev => [...prev, msg]);
654
+ }
655
+ }
656
+ else {
657
+ const msg = {
658
+ id: crypto.randomUUID(),
659
+ role: 'system',
660
+ content: 'Session management is not available.',
661
+ timestamp: new Date(),
662
+ };
663
+ setMessages(prev => [...prev, msg]);
664
+ }
665
+ return;
666
+ }
667
+ // /models — list available models from all providers
668
+ if (trimmed === '/models') {
669
+ if (onModels) {
670
+ setIsProcessing(true);
671
+ setProcessingStartTime(Date.now());
672
+ onModels()
673
+ .then(modelsMap => {
674
+ const lines = ['Available models:'];
675
+ for (const [provider, modelList] of Object.entries(modelsMap)) {
676
+ lines.push(`\n ${provider}:`);
677
+ for (const model of modelList) {
678
+ const isActive = model === session.model;
679
+ lines.push(` ${isActive ? '[OK]' : ' '} ${model}`);
680
+ }
681
+ }
682
+ if (lines.length === 1) {
683
+ lines.push(' (no providers configured)');
684
+ }
685
+ const msg = {
686
+ id: crypto.randomUUID(),
687
+ role: 'system',
688
+ content: lines.join('\n'),
689
+ timestamp: new Date(),
690
+ };
691
+ setMessages(prev => [...prev, msg]);
692
+ setIsProcessing(false);
693
+ setProcessingStartTime(null);
694
+ })
695
+ .catch(() => {
696
+ const msg = {
697
+ id: crypto.randomUUID(),
698
+ role: 'system',
699
+ content: 'Failed to list models.',
700
+ timestamp: new Date(),
701
+ };
702
+ setMessages(prev => [...prev, msg]);
703
+ setIsProcessing(false);
704
+ setProcessingStartTime(null);
705
+ });
706
+ }
707
+ else {
708
+ const msg = {
709
+ id: crypto.randomUUID(),
710
+ role: 'system',
711
+ content: 'Model listing is not available in this session.',
712
+ timestamp: new Date(),
713
+ };
714
+ setMessages(prev => [...prev, msg]);
715
+ }
716
+ return;
717
+ }
718
+ // /context — show context window usage breakdown
719
+ if (trimmed === '/context') {
720
+ if (onContext) {
721
+ const breakdown = onContext();
722
+ const content = breakdown
723
+ ? [
724
+ 'Context Snapshot:',
725
+ ` LLM Model: ${session.model ?? 'default'}`,
726
+ ` Mode: ${session.mode}`,
727
+ ` TF Workspace: ${session.terraformWorkspace ?? '(none)'}`,
728
+ ` K8s Context: ${session.kubectlContext ?? '(none)'}`,
729
+ '',
730
+ 'Context Budget:',
731
+ ` System prompt: ${breakdown.systemPrompt.toLocaleString()} tokens`,
732
+ ` NIMBUS.md: ${breakdown.nimbusInstructions.toLocaleString()} tokens`,
733
+ ` Messages: ${breakdown.messages.toLocaleString()} tokens`,
734
+ ` Tool definitions: ${breakdown.toolDefinitions.toLocaleString()} tokens`,
735
+ ` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`,
736
+ ` Total: ${breakdown.total.toLocaleString()} / ${breakdown.budget.toLocaleString()} (${breakdown.usagePercent}%)`,
737
+ ].join('\n')
738
+ : 'Context information is not available.';
739
+ const msg = {
740
+ id: crypto.randomUUID(),
741
+ role: 'system',
742
+ content,
743
+ timestamp: new Date(),
744
+ };
745
+ setMessages(prev => [...prev, msg]);
746
+ }
747
+ else {
748
+ const msg = {
749
+ id: crypto.randomUUID(),
750
+ role: 'system',
751
+ content: 'Context tracking is not available in this session.',
752
+ timestamp: new Date(),
753
+ };
754
+ setMessages(prev => [...prev, msg]);
755
+ }
756
+ return;
757
+ }
758
+ // /diff — show git diff of unstaged changes
759
+ if (trimmed === '/diff') {
760
+ if (onDiff) {
761
+ setIsProcessing(true);
762
+ setProcessingStartTime(Date.now());
763
+ onDiff()
764
+ .then(diff => {
765
+ const msg = {
766
+ id: crypto.randomUUID(),
767
+ role: 'system',
768
+ content: diff,
769
+ timestamp: new Date(),
770
+ };
771
+ setMessages(prev => [...prev, msg]);
772
+ setIsProcessing(false);
773
+ setProcessingStartTime(null);
774
+ })
775
+ .catch(() => {
776
+ const msg = {
777
+ id: crypto.randomUUID(),
778
+ role: 'system',
779
+ content: 'Failed to get git diff.',
780
+ timestamp: new Date(),
781
+ };
782
+ setMessages(prev => [...prev, msg]);
783
+ setIsProcessing(false);
784
+ setProcessingStartTime(null);
785
+ });
786
+ }
787
+ else {
788
+ const msg = {
789
+ id: crypto.randomUUID(),
790
+ role: 'system',
791
+ content: 'Diff is not available in this session.',
792
+ timestamp: new Date(),
793
+ };
794
+ setMessages(prev => [...prev, msg]);
795
+ }
796
+ return;
797
+ }
798
+ // /cost — show per-turn cost breakdown
799
+ if (trimmed === '/cost') {
800
+ const content = onCost ? onCost() : 'Cost tracking unavailable.';
801
+ const msg = {
802
+ id: crypto.randomUUID(),
803
+ role: 'system',
804
+ content,
805
+ timestamp: new Date(),
806
+ };
807
+ setMessages(prev => [...prev, msg]);
808
+ return;
809
+ }
810
+ // /init — regenerate NIMBUS.md from inside the TUI
811
+ if (trimmed === '/init') {
812
+ if (onInit) {
813
+ setIsProcessing(true);
814
+ setProcessingStartTime(Date.now());
815
+ onInit()
816
+ .then(result => {
817
+ const msg = {
818
+ id: crypto.randomUUID(),
819
+ role: 'system',
820
+ content: result,
821
+ timestamp: new Date(),
822
+ };
823
+ setMessages(prev => [...prev, msg]);
824
+ setIsProcessing(false);
825
+ setProcessingStartTime(null);
826
+ })
827
+ .catch((err) => {
828
+ const msg = {
829
+ id: crypto.randomUUID(),
830
+ role: 'system',
831
+ content: `Init failed: ${err.message}`,
832
+ timestamp: new Date(),
833
+ };
834
+ setMessages(prev => [...prev, msg]);
835
+ setIsProcessing(false);
836
+ setProcessingStartTime(null);
837
+ });
838
+ }
839
+ else {
840
+ const msg = {
841
+ id: crypto.randomUUID(),
842
+ role: 'system',
843
+ content: 'Init is not available in this session.',
844
+ timestamp: new Date(),
845
+ };
846
+ setMessages(prev => [...prev, msg]);
847
+ }
848
+ return;
849
+ }
850
+ // /export [filename] — serialize conversation to a runbook markdown file (G16)
851
+ if (trimmed.startsWith('/export')) {
852
+ const exportArg = trimmed.slice('/export'.length).trim() || undefined;
853
+ if (onExport) {
854
+ setIsProcessing(true);
855
+ setProcessingStartTime(Date.now());
856
+ onExport(exportArg)
857
+ .then(filePath => {
858
+ const msg = {
859
+ id: crypto.randomUUID(),
860
+ role: 'system',
861
+ content: `Session exported to: ${filePath}`,
862
+ timestamp: new Date(),
863
+ };
864
+ setMessages(prev => [...prev, msg]);
865
+ setIsProcessing(false);
866
+ setProcessingStartTime(null);
867
+ })
868
+ .catch((err) => {
869
+ const msg = {
870
+ id: crypto.randomUUID(),
871
+ role: 'system',
872
+ content: `Export failed: ${err.message}`,
873
+ timestamp: new Date(),
874
+ };
875
+ setMessages(prev => [...prev, msg]);
876
+ setIsProcessing(false);
877
+ setProcessingStartTime(null);
878
+ });
879
+ }
880
+ else {
881
+ setMessages(prev => [...prev, {
882
+ id: crypto.randomUUID(),
883
+ role: 'system',
884
+ content: 'Export is not available in this session.',
885
+ timestamp: new Date(),
886
+ }]);
887
+ }
888
+ return;
889
+ }
890
+ // /remember <fact> — append fact to NIMBUS.md Agent Memory (G17)
891
+ if (trimmed.startsWith('/remember ')) {
892
+ const fact = trimmed.slice('/remember '.length).trim();
893
+ if (fact && onRemember) {
894
+ onRemember(fact)
895
+ .then(() => {
896
+ setMessages(prev => [...prev, {
897
+ id: crypto.randomUUID(),
898
+ role: 'system',
899
+ content: `Remembered: "${fact}" — saved to NIMBUS.md Agent Memory.`,
900
+ timestamp: new Date(),
901
+ }]);
902
+ })
903
+ .catch((err) => {
904
+ setMessages(prev => [...prev, {
905
+ id: crypto.randomUUID(),
906
+ role: 'system',
907
+ content: `Remember failed: ${err.message}`,
908
+ timestamp: new Date(),
909
+ }]);
910
+ });
911
+ }
912
+ else if (!fact) {
913
+ setMessages(prev => [...prev, {
914
+ id: crypto.randomUUID(),
915
+ role: 'system',
916
+ content: 'Usage: /remember <fact to remember>',
917
+ timestamp: new Date(),
918
+ }]);
919
+ }
920
+ return;
921
+ }
922
+ // /search [query] — filter conversation messages (M1)
923
+ if (trimmed === '/search' || trimmed.startsWith('/search ')) {
924
+ const query = trimmed.length > '/search'.length ? trimmed.slice('/search '.length).trim() : '';
925
+ if (query) {
926
+ setSearchQuery(query);
927
+ setSearchMode(true);
928
+ const count = messages.filter(m => m.content.toLowerCase().includes(query.toLowerCase())).length;
929
+ setMessages(prev => [...prev, {
930
+ id: crypto.randomUUID(),
931
+ role: 'system',
932
+ content: `Search: "${query}" — ${count} match${count !== 1 ? 'es' : ''}`,
933
+ timestamp: new Date(),
934
+ }]);
935
+ }
936
+ else {
937
+ setSearchQuery('');
938
+ setSearchMode(false);
939
+ setMessages(prev => [...prev, {
940
+ id: crypto.randomUUID(),
941
+ role: 'system',
942
+ content: 'Search cleared. Showing all messages.',
943
+ timestamp: new Date(),
944
+ }]);
945
+ }
946
+ return;
947
+ }
948
+ // /watch [pattern] — watch files and run agent on change (M5)
949
+ if (trimmed === '/watch' || trimmed.startsWith('/watch ')) {
950
+ const pattern = trimmed.length > '/watch'.length ? trimmed.slice('/watch '.length).trim() : '';
951
+ const sysMsg = (content) => setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content, timestamp: new Date() }]);
952
+ if (!pattern) {
953
+ // Stop watch if active
954
+ if (watchPattern) {
955
+ watchAbortRef.current?.abort();
956
+ watchAbortRef.current = null;
957
+ setWatchPattern(null);
958
+ sysMsg('Watch stopped.');
959
+ }
960
+ else {
961
+ sysMsg('Usage: /watch <glob> (e.g. /watch **/*.tf)');
962
+ }
963
+ return;
964
+ }
965
+ // Start watching
966
+ watchAbortRef.current?.abort();
967
+ const ac = new AbortController();
968
+ watchAbortRef.current = ac;
969
+ setWatchPattern(pattern);
970
+ sysMsg(`Watching: ${pattern} — changes will trigger agent analysis.`);
971
+ setShowTerminalPane(true);
972
+ void (async () => {
973
+ try {
974
+ const { FileWatcher } = require('../watcher');
975
+ const watcher = new FileWatcher(process.cwd());
976
+ watcher.start();
977
+ watcher.on('change', (filePath) => {
978
+ if (ac.signal.aborted)
979
+ return;
980
+ const ext = pattern.replace('**/', '').replace(/\*/g, '');
981
+ if (ext && !filePath.includes(ext))
982
+ return;
983
+ const prompt = `File changed: ${filePath}. Analyze the change and report any issues or drift.`;
984
+ sysMsg(`[watch] Change detected: ${filePath}`);
985
+ if (!isProcessing)
986
+ handleSubmit(prompt);
987
+ });
988
+ ac.signal.addEventListener('abort', () => watcher.stop());
989
+ }
990
+ catch {
991
+ sysMsg('Watch: could not start file watcher.');
992
+ }
993
+ })();
994
+ return;
995
+ }
996
+ // /plan — show a terraform plan via the agent
997
+ if (trimmed === '/plan') {
998
+ const userMsg = {
999
+ id: crypto.randomUUID(),
1000
+ role: 'user',
1001
+ content: '/plan',
1002
+ timestamp: new Date(),
1003
+ };
1004
+ setMessages(prev => [...prev, userMsg]);
1005
+ setIsProcessing(true);
1006
+ setCurrentTurnHasOutput(false);
1007
+ setProcessingStartTime(Date.now());
1008
+ if (onMessage) {
1009
+ onMessage('Show a terraform plan for the current directory. Use plan mode — read-only analysis only.');
1010
+ }
1011
+ return;
1012
+ }
1013
+ // /apply — apply infrastructure changes via the agent
1014
+ if (trimmed === '/apply') {
1015
+ const userMsg = {
1016
+ id: crypto.randomUUID(),
1017
+ role: 'user',
1018
+ content: '/apply',
1019
+ timestamp: new Date(),
1020
+ };
1021
+ setMessages(prev => [...prev, userMsg]);
1022
+ setIsProcessing(true);
1023
+ setCurrentTurnHasOutput(false);
1024
+ setProcessingStartTime(Date.now());
1025
+ if (onMessage) {
1026
+ onMessage('Apply the infrastructure changes. Show a deploy preview first, then apply after confirmation.');
1027
+ }
1028
+ return;
1029
+ }
1030
+ // /k8s-ctx — interactive kubectl context picker (GAP-7)
1031
+ if (trimmed === '/k8s-ctx' || trimmed.startsWith('/k8s-ctx ')) {
1032
+ const arg = trimmed.length > '/k8s-ctx'.length ? trimmed.slice('/k8s-ctx '.length).trim() : '';
1033
+ if (arg) {
1034
+ // Direct switch with name provided
1035
+ try {
1036
+ const { execSync } = require('node:child_process');
1037
+ execSync(`kubectl config use-context ${arg}`, { encoding: 'utf-8', timeout: 5000 });
1038
+ setSession(prev => ({ ...prev, kubectlContext: arg }));
1039
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `[OK] Switched kubectl context to: ${arg}`, timestamp: new Date() }]);
1040
+ }
1041
+ catch (e) {
1042
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Failed to switch context: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
1043
+ }
1044
+ return;
1045
+ }
1046
+ // No arg — show numbered picker
1047
+ try {
1048
+ const { execSync } = require('node:child_process');
1049
+ const ctxOutput = execSync('kubectl config get-contexts -o name 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
1050
+ const contexts = ctxOutput.trim().split('\n').filter(Boolean);
1051
+ if (contexts.length === 0) {
1052
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: 'No kubectl contexts found. Check your kubeconfig.', timestamp: new Date() }]);
1053
+ return;
1054
+ }
1055
+ setPendingContextSelect(contexts);
1056
+ const lines = ['Available kubectl contexts:', ...contexts.map((c, i) => ` ${i + 1}. ${c}`), '', 'Type a number or context name to switch:'];
1057
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: lines.join('\n'), timestamp: new Date() }]);
1058
+ }
1059
+ catch {
1060
+ // Fallback to agent
1061
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'user', content: '/k8s-ctx', timestamp: new Date() }]);
1062
+ setIsProcessing(true);
1063
+ setCurrentTurnHasOutput(false);
1064
+ setProcessingStartTime(Date.now());
1065
+ if (onMessage)
1066
+ onMessage('List all available Kubernetes contexts and show the current one.');
1067
+ }
1068
+ return;
1069
+ }
1070
+ // M3: /profile <name> — switch credential profile in the TUI
1071
+ if (trimmed.startsWith('/profile ')) {
1072
+ const profileName = trimmed.slice('/profile '.length).trim();
1073
+ if (profileName) {
1074
+ void (async () => {
1075
+ try {
1076
+ const { profileCommand } = require('../commands/profile');
1077
+ await profileCommand('set', [profileName]);
1078
+ // Update session with new infra context after profile switch
1079
+ const { discoverInfraContext } = require('../cli/init');
1080
+ const ctx = await discoverInfraContext(process.cwd()).catch(() => undefined);
1081
+ if (ctx) {
1082
+ setSession(prev => ({
1083
+ ...prev,
1084
+ terraformWorkspace: ctx.terraformWorkspace ?? prev.terraformWorkspace,
1085
+ kubectlContext: ctx.kubectlContext ?? prev.kubectlContext,
1086
+ }));
1087
+ }
1088
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Profile "${profileName}" activated.`, timestamp: new Date() }]);
1089
+ }
1090
+ catch (e) {
1091
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Failed to activate profile "${profileName}": ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
1092
+ }
1093
+ })();
1094
+ }
1095
+ else {
1096
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: 'Usage: /profile <name>', timestamp: new Date() }]);
1097
+ }
1098
+ return;
1099
+ }
1100
+ // /tf-ws — interactive Terraform workspace picker (GAP-8)
1101
+ if (trimmed === '/tf-ws' || trimmed.startsWith('/tf-ws ')) {
1102
+ const arg = trimmed.length > '/tf-ws'.length ? trimmed.slice('/tf-ws '.length).trim() : '';
1103
+ if (arg) {
1104
+ // Direct switch with name provided
1105
+ try {
1106
+ const { execSync } = require('node:child_process');
1107
+ execSync(`terraform workspace select ${arg}`, { encoding: 'utf-8', timeout: 10000, cwd: process.cwd() });
1108
+ setSession(prev => ({ ...prev, terraformWorkspace: arg }));
1109
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `[OK] Switched Terraform workspace to: ${arg}`, timestamp: new Date() }]);
1110
+ }
1111
+ catch (e) {
1112
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: `Failed to switch workspace: ${e instanceof Error ? e.message : String(e)}`, timestamp: new Date() }]);
1113
+ }
1114
+ return;
1115
+ }
1116
+ // No arg — show numbered picker
1117
+ try {
1118
+ const { execSync } = require('node:child_process');
1119
+ const wsOutput = execSync('terraform workspace list 2>/dev/null', { encoding: 'utf-8', timeout: 10000, cwd: process.cwd() });
1120
+ const workspaces = wsOutput.trim().split('\n').map((w) => w.replace(/^\*\s*/, '').trim()).filter(Boolean);
1121
+ if (workspaces.length === 0) {
1122
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: 'No Terraform workspaces found. Run terraform workspace list manually.', timestamp: new Date() }]);
1123
+ return;
1124
+ }
1125
+ setPendingWorkspaceSelect(workspaces);
1126
+ const lines = ['Available Terraform workspaces:', ...workspaces.map((w, i) => ` ${i + 1}. ${w}`), '', 'Type a number or workspace name to switch:'];
1127
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: lines.join('\n'), timestamp: new Date() }]);
1128
+ }
1129
+ catch {
1130
+ // Fallback to agent
1131
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'user', content: '/tf-ws', timestamp: new Date() }]);
1132
+ setIsProcessing(true);
1133
+ setCurrentTurnHasOutput(false);
1134
+ setProcessingStartTime(Date.now());
1135
+ if (onMessage)
1136
+ onMessage('List all Terraform workspaces and show the current one.');
1137
+ }
1138
+ return;
1139
+ }
1140
+ // /workspace <name> — select terraform workspace (M2)
1141
+ if (trimmed.startsWith('/workspace ')) {
1142
+ const wsName = trimmed.slice('/workspace '.length).trim();
1143
+ if (!wsName) {
1144
+ const sysMsg = {
1145
+ id: crypto.randomUUID(),
1146
+ role: 'system',
1147
+ content: 'Usage: /workspace <name>',
1148
+ timestamp: new Date(),
1149
+ };
1150
+ setMessages(prev => [...prev, sysMsg]);
1151
+ return;
1152
+ }
1153
+ const userMsg = {
1154
+ id: crypto.randomUUID(),
1155
+ role: 'user',
1156
+ content: `/workspace ${wsName}`,
1157
+ timestamp: new Date(),
1158
+ };
1159
+ setMessages(prev => [...prev, userMsg]);
1160
+ setIsProcessing(true);
1161
+ setCurrentTurnHasOutput(false);
1162
+ setProcessingStartTime(Date.now());
1163
+ if (onMessage) {
1164
+ onMessage(`Switch to Terraform workspace "${wsName}" using the terraform workspace-select action, then confirm the switch was successful.`);
1165
+ }
1166
+ return;
1167
+ }
1168
+ // /profile <name> — set AWS_PROFILE (M2)
1169
+ if (trimmed.startsWith('/profile ')) {
1170
+ const profileName = trimmed.slice('/profile '.length).trim();
1171
+ if (!profileName) {
1172
+ const sysMsg = {
1173
+ id: crypto.randomUUID(),
1174
+ role: 'system',
1175
+ content: 'Usage: /profile <name>',
1176
+ timestamp: new Date(),
1177
+ };
1178
+ setMessages(prev => [...prev, sysMsg]);
1179
+ return;
1180
+ }
1181
+ process.env.AWS_PROFILE = profileName;
1182
+ const sysMsg = {
1183
+ id: crypto.randomUUID(),
1184
+ role: 'system',
1185
+ content: `AWS_PROFILE set to "${profileName}". Subsequent AWS operations will use this profile.`,
1186
+ timestamp: new Date(),
1187
+ };
1188
+ setMessages(prev => [...prev, sysMsg]);
1189
+ return;
1190
+ }
1191
+ // /terminal — toggle the terminal output pane (M1)
1192
+ if (trimmed === '/terminal') {
1193
+ setShowTerminalPane(prev => !prev);
1194
+ return;
1195
+ }
1196
+ // /tree — toggle the file tree sidebar (L1)
1197
+ if (trimmed === '/tree') {
1198
+ setShowTreePane(prev => !prev);
1199
+ return;
1200
+ }
1201
+ // /theme [dark|light] — switch the TUI color theme (Gap 2)
1202
+ if (trimmed === '/theme' || trimmed.startsWith('/theme ')) {
1203
+ const themeName = trimmed.length > '/theme'.length ? trimmed.slice('/theme '.length).trim() : undefined;
1204
+ if (themeName) {
1205
+ try {
1206
+ const { setTheme, listThemes } = require('./theme');
1207
+ const available = listThemes();
1208
+ if (available.includes(themeName)) {
1209
+ setTheme(themeName);
1210
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Theme switched to: ${themeName}`, timestamp: new Date() };
1211
+ setMessages(prev => [...prev, msg]);
1212
+ }
1213
+ else {
1214
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Unknown theme "${themeName}". Available: ${available.join(', ')}`, timestamp: new Date() };
1215
+ setMessages(prev => [...prev, msg]);
1216
+ }
1217
+ }
1218
+ catch {
1219
+ const msg = { id: crypto.randomUUID(), role: 'system', content: 'Theme switching unavailable.', timestamp: new Date() };
1220
+ setMessages(prev => [...prev, msg]);
1221
+ }
1222
+ }
1223
+ else {
1224
+ const msg = { id: crypto.randomUUID(), role: 'system', content: 'Usage: /theme <dark|light>', timestamp: new Date() };
1225
+ setMessages(prev => [...prev, msg]);
1226
+ }
1227
+ return;
1228
+ }
1229
+ // /tools [name] — list tool schemas or show a specific tool (Gap 15)
1230
+ if (trimmed === '/tools' || trimmed.startsWith('/tools ')) {
1231
+ const toolName = trimmed.length > '/tools'.length ? trimmed.slice('/tools '.length).trim() : undefined;
1232
+ try {
1233
+ const { defaultToolRegistry } = require('../tools/schemas/types');
1234
+ if (toolName) {
1235
+ const tool = defaultToolRegistry.get(toolName);
1236
+ if (tool) {
1237
+ const schema = JSON.stringify(tool.inputSchema._def ?? { type: 'object' }, null, 2);
1238
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `**${tool.name}** (${tool.permissionTier}): ${tool.description}\n\`\`\`json\n${schema.slice(0, 2000)}\n\`\`\``, timestamp: new Date() };
1239
+ setMessages(prev => [...prev, msg]);
1240
+ }
1241
+ else {
1242
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Tool not found: ${toolName}`, timestamp: new Date() };
1243
+ setMessages(prev => [...prev, msg]);
1244
+ }
1245
+ }
1246
+ else {
1247
+ const list = defaultToolRegistry.getAll()
1248
+ .map((t) => `- **${t.name}** (${t.permissionTier}): ${t.description.slice(0, 60)}`)
1249
+ .join('\n');
1250
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Available tools:\n${list}`, timestamp: new Date() };
1251
+ setMessages(prev => [...prev, msg]);
1252
+ }
1253
+ }
1254
+ catch {
1255
+ const msg = { id: crypto.randomUUID(), role: 'system', content: 'Tool registry unavailable.', timestamp: new Date() };
1256
+ setMessages(prev => [...prev, msg]);
1257
+ }
1258
+ return;
1259
+ }
1260
+ // /rollback [resource] — inject a rollback prompt (Gap 14)
1261
+ if (trimmed === '/rollback' || trimmed.startsWith('/rollback ')) {
1262
+ const resource = trimmed.length > '/rollback'.length ? trimmed.slice('/rollback '.length).trim() : 'last-deployment';
1263
+ const userMsg = { id: crypto.randomUUID(), role: 'user', content: trimmed, timestamp: new Date() };
1264
+ setMessages(prev => [...prev, userMsg]);
1265
+ setIsProcessing(true);
1266
+ setCurrentTurnHasOutput(false);
1267
+ setProcessingStartTime(Date.now());
1268
+ if (onMessage) {
1269
+ onMessage(`Please safely rollback ${resource}. Detect the infra type (terraform/kubectl/helm) from context and use the safest rollback method. Show what you're doing before executing.`);
1270
+ }
1271
+ return;
1272
+ }
1273
+ // /drift — scan all terraform workspaces for drift (Gap 17)
1274
+ if (trimmed === '/drift') {
1275
+ const userMsg = { id: crypto.randomUUID(), role: 'user', content: '/drift', timestamp: new Date() };
1276
+ setMessages(prev => [...prev, userMsg]);
1277
+ setIsProcessing(true);
1278
+ setCurrentTurnHasOutput(false);
1279
+ setProcessingStartTime(Date.now());
1280
+ if (onMessage) {
1281
+ onMessage('Run drift_detect for all terraform workspaces in this project and summarize findings in a table with columns: Workspace, Status, Drifted Resources.');
1282
+ }
1283
+ return;
1284
+ }
1285
+ // /auth-refresh — refresh cloud credentials (Gap 16)
1286
+ if (trimmed === '/auth-refresh') {
1287
+ const userMsg = { id: crypto.randomUUID(), role: 'user', content: '/auth-refresh', timestamp: new Date() };
1288
+ setMessages(prev => [...prev, userMsg]);
1289
+ setIsProcessing(true);
1290
+ setCurrentTurnHasOutput(false);
1291
+ setProcessingStartTime(Date.now());
1292
+ if (onMessage) {
1293
+ onMessage('Check and refresh cloud credentials for AWS, GCP, and Azure. Show the current auth status for each provider and guide me through renewing any expired credentials.');
1294
+ }
1295
+ return;
1296
+ }
1297
+ // /export [filename] — export session as Markdown runbook (Gap 4)
1298
+ if (trimmed === '/export' || trimmed.startsWith('/export ')) {
1299
+ const filename = trimmed.length > '/export'.length
1300
+ ? trimmed.slice('/export '.length).trim()
1301
+ : `nimbus-runbook-${Date.now()}.md`;
1302
+ try {
1303
+ const { formatSessionAsRunbook } = require('../sharing/viewer');
1304
+ const fs = require('node:fs');
1305
+ const runbookMessages = messages
1306
+ .filter(m => m.role === 'user' || m.role === 'assistant')
1307
+ .map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp }));
1308
+ const content = formatSessionAsRunbook(runbookMessages, { model: session.model, mode: session.mode, costUSD: session.costUSD, tokenCount: session.tokenCount });
1309
+ fs.writeFileSync(filename, content, 'utf-8');
1310
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Session exported to ${filename}`, timestamp: new Date() };
1311
+ setMessages(prev => [...prev, msg]);
1312
+ }
1313
+ catch (err) {
1314
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `Export failed: ${err instanceof Error ? err.message : String(err)}`, timestamp: new Date() };
1315
+ setMessages(prev => [...prev, msg]);
1316
+ }
1317
+ return;
1318
+ }
1319
+ // /alias [list|create|remove] — manage command aliases from TUI (G23)
1320
+ if (trimmed === '/alias' || trimmed.startsWith('/alias ')) {
1321
+ const subArgs = trimmed.length > '/alias'.length
1322
+ ? trimmed.slice('/alias '.length).trim().split(/\s+/).filter(Boolean)
1323
+ : ['list'];
1324
+ setIsProcessing(true);
1325
+ import('../commands/alias').then(({ aliasCommand }) => {
1326
+ return aliasCommand(subArgs[0] ?? 'list', subArgs.slice(1));
1327
+ }).then(output => {
1328
+ const msg = { id: crypto.randomUUID(), role: 'system', content: String(output ?? '(no output)'), timestamp: new Date() };
1329
+ setMessages(prev => [...prev, msg]);
1330
+ setIsProcessing(false);
1331
+ }).catch(err => {
1332
+ const msg = { id: crypto.randomUUID(), role: 'system', content: `alias error: ${err instanceof Error ? err.message : String(err)}`, timestamp: new Date() };
1333
+ setMessages(prev => [...prev, msg]);
1334
+ setIsProcessing(false);
1335
+ });
1336
+ return;
1337
+ }
1338
+ // M7: /explain [topic] — explain a DevOps resource or concept via agent
1339
+ if (trimmed.startsWith('/explain ') || trimmed === '/explain') {
1340
+ const topic = trimmed.length > '/explain '.length
1341
+ ? trimmed.slice('/explain '.length).trim()
1342
+ : 'the current infrastructure context';
1343
+ const explainPrompt = `Please explain ${topic} in the context of DevOps/infrastructure. Include: what it does, common use cases, and relevant commands or patterns.`;
1344
+ const userMsg = {
1345
+ id: crypto.randomUUID(),
1346
+ role: 'user',
1347
+ content: trimmed,
1348
+ timestamp: new Date(),
1349
+ };
1350
+ setMessages(prev => [...prev, userMsg]);
1351
+ setIsProcessing(true);
1352
+ setCurrentTurnHasOutput(false);
1353
+ setProcessingStartTime(Date.now());
1354
+ if (onMessage) {
1355
+ onMessage(explainPrompt);
1356
+ }
1357
+ return;
1358
+ }
1359
+ // -----------------------------------------------------------------
1360
+ // Normal message — expand @file references, then send to agent
1361
+ // -----------------------------------------------------------------
1362
+ // Expand @path/to/file references: replace with file contents inline
1363
+ let expandedText = trimmed;
1364
+ const fileRefs = trimmed.match(/@"([^"]+)"|@([\w./_~-]+)/g);
1365
+ if (fileRefs) {
1366
+ for (const ref of fileRefs) {
1367
+ // Handle both @"path with spaces" and @simple/path
1368
+ const filePath = ref.startsWith('@"') ? ref.slice(2, -1) : ref.slice(1);
1369
+ try {
1370
+ const resolved = resolve(process.cwd(), filePath);
1371
+ const content = readFileSync(resolved, 'utf-8');
1372
+ // GAP-6: 100KB cap (up from 10KB)
1373
+ const truncated = content.length > 100_000
1374
+ ? `${content.slice(0, 100_000)}\n... (truncated — showing 100,000 of ${content.length.toLocaleString()} chars)`
1375
+ : content;
1376
+ const ext = filePath.split('.').pop() ?? '';
1377
+ expandedText = expandedText.replace(ref, `\n\`\`\`${ext}\n// File: ${filePath}\n${truncated}\n\`\`\``);
1378
+ }
1379
+ catch {
1380
+ // File not found — leave the @reference as-is
1381
+ }
1382
+ }
1383
+ }
1384
+ // Append user message to the conversation
1385
+ const userMsg = {
1386
+ id: crypto.randomUUID(),
1387
+ role: 'user',
1388
+ content: trimmed, // Show original text in the UI
1389
+ timestamp: new Date(),
1390
+ };
1391
+ setMessages(prev => [...prev, userMsg]);
1392
+ setInputPrefill(undefined); // GAP-21: clear prefill after submit
1393
+ setIsProcessing(true);
1394
+ setCurrentTurnHasOutput(false);
1395
+ setProcessingStartTime(Date.now());
1396
+ if (onMessage) {
1397
+ onMessage(expandedText); // Send expanded text to the agent
1398
+ }
1399
+ }, [
1400
+ onMessage,
1401
+ onCompact,
1402
+ onContext,
1403
+ onUndo,
1404
+ onRedo,
1405
+ onSessions,
1406
+ onNewSession,
1407
+ onSwitchSession,
1408
+ onModels,
1409
+ onClear,
1410
+ onModelChange,
1411
+ onModeChange,
1412
+ onDiff,
1413
+ onCost,
1414
+ onInit,
1415
+ session.id,
1416
+ session.model,
1417
+ session.mode,
1418
+ pendingContextSelect,
1419
+ pendingWorkspaceSelect,
1420
+ messages,
1421
+ ]);
1422
+ /** Handle abort from InputBox (Escape key). */
1423
+ const handleAbort = useCallback(() => {
1424
+ setIsProcessing(false);
1425
+ setProcessingStartTime(null);
1426
+ if (onAbort) {
1427
+ onAbort();
1428
+ }
1429
+ }, [onAbort]);
1430
+ /** Handle permission prompt decisions. */
1431
+ const handlePermission = useCallback((decision) => {
1432
+ if (permissionRequest) {
1433
+ permissionRequest.onDecide(decision);
1434
+ }
1435
+ setPermissionRequest(null);
1436
+ }, [permissionRequest]);
1437
+ /** Handle deploy preview decisions. */
1438
+ const handleDeployDecision = useCallback((decision) => {
1439
+ if (deployPreview?.onDecide) {
1440
+ deployPreview.onDecide(decision);
1441
+ }
1442
+ setDeployPreview(null);
1443
+ }, [deployPreview]);
1444
+ /** Handle file diff modal decisions. */
1445
+ const handleFileDiffDecision = useCallback((decision) => {
1446
+ if (fileDiffRequest) {
1447
+ fileDiffRequest.onDecide(decision);
1448
+ }
1449
+ setFileDiffRequest(null);
1450
+ }, [fileDiffRequest]);
1451
+ /* -- Global keyboard shortcuts ----------------------------------------- */
1452
+ useInput((input, key) => {
1453
+ // Tab: cycle modes (only when not in a modal and not typing a slash command)
1454
+ // When input starts with '/', Tab is handled by InputBox for autocomplete
1455
+ if (key.tab && !permissionRequest && !deployPreview && !fileDiffRequest) {
1456
+ // G7: Compute newMode from current session state (available in closure)
1457
+ // so we can inject a warning message when switching to deploy on prod.
1458
+ const newMode = nextMode(session.mode);
1459
+ // H3: Deploy mode requires confirmation before switching
1460
+ if (newMode === 'deploy') {
1461
+ setPendingDeployConfirm(true);
1462
+ return;
1463
+ }
1464
+ setSession(prev => {
1465
+ // Propagate mode change to the agent loop so it actually takes effect
1466
+ if (onModeChange) {
1467
+ onModeChange(newMode);
1468
+ }
1469
+ return { ...prev, mode: newMode };
1470
+ });
1471
+ // H5: Show 2-second mode toast
1472
+ setModeToast(`→ ${newMode.toUpperCase()} mode`);
1473
+ setTimeout(() => setModeToast(null), 2000);
1474
+ // H3: Persist the Tab-cycled mode for this working directory
1475
+ try {
1476
+ const { saveModeForCwd } = require('../config/mode-store');
1477
+ saveModeForCwd(process.cwd(), newMode);
1478
+ }
1479
+ catch { /* non-critical */ }
1480
+ return;
1481
+ }
1482
+ // Ctrl+C: interrupt or exit
1483
+ if (input === 'c' && key.ctrl) {
1484
+ if (isProcessing) {
1485
+ handleAbort();
1486
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: '[!!] Cancelling current operation... (Ctrl+C again to force exit)', timestamp: new Date() }]);
1487
+ setAbortPending(true);
1488
+ setTimeout(() => setAbortPending(false), 3000);
1489
+ }
1490
+ else if (abortPending) {
1491
+ exit();
1492
+ }
1493
+ else {
1494
+ exit();
1495
+ }
1496
+ return;
1497
+ }
1498
+ // Escape: cancel current operation
1499
+ if (key.escape) {
1500
+ if (permissionRequest) {
1501
+ handlePermission('reject');
1502
+ }
1503
+ else if (deployPreview) {
1504
+ handleDeployDecision('reject');
1505
+ }
1506
+ else if (fileDiffRequest) {
1507
+ handleFileDiffDecision('reject');
1508
+ }
1509
+ else if (isProcessing) {
1510
+ handleAbort();
1511
+ }
1512
+ }
1513
+ },
1514
+ // Disable the global handler when modals are active so their own
1515
+ // useInput handlers take priority.
1516
+ { isActive: !permissionRequest && !deployPreview && !fileDiffRequest });
1517
+ /* -- C1: Scroll input handler ------------------------------------------ */
1518
+ useInput((input, key) => {
1519
+ // Arrow up / k — scroll back one message
1520
+ if (key.upArrow || input === 'k') {
1521
+ setScrollOffset(prev => prev + 1);
1522
+ setScrollLocked(false);
1523
+ return;
1524
+ }
1525
+ // Arrow down / j — scroll forward one message
1526
+ if (key.downArrow || input === 'j') {
1527
+ setScrollOffset(prev => {
1528
+ const next = Math.max(0, prev - 1);
1529
+ if (next === 0)
1530
+ setScrollLocked(true);
1531
+ return next;
1532
+ });
1533
+ return;
1534
+ }
1535
+ // Page up / b — scroll back 10 messages
1536
+ if (key.pageUp || input === 'b') {
1537
+ setScrollOffset(prev => prev + 10);
1538
+ setScrollLocked(false);
1539
+ return;
1540
+ }
1541
+ // Page down / f / space — scroll forward 10
1542
+ if (key.pageDown || input === 'f' || input === ' ') {
1543
+ setScrollOffset(prev => {
1544
+ const next = Math.max(0, prev - 10);
1545
+ if (next === 0)
1546
+ setScrollLocked(true);
1547
+ return next;
1548
+ });
1549
+ return;
1550
+ }
1551
+ // G / End — jump to bottom
1552
+ if (input === 'G') {
1553
+ setScrollOffset(0);
1554
+ setScrollLocked(true);
1555
+ return;
1556
+ }
1557
+ // L2: Ctrl+Z — undo last file-modifying operation (same as /undo command)
1558
+ if (input === 'z' && key.ctrl) {
1559
+ if (onUndo) {
1560
+ setIsProcessing(true);
1561
+ onUndo()
1562
+ .then(result => {
1563
+ setMessages(prev => [...prev, {
1564
+ id: crypto.randomUUID(),
1565
+ role: 'system',
1566
+ content: result.success
1567
+ ? `Undo: ${result.description ?? 'snapshot restored'}`
1568
+ : 'Nothing to undo.',
1569
+ timestamp: new Date(),
1570
+ }]);
1571
+ setIsProcessing(false);
1572
+ })
1573
+ .catch(() => {
1574
+ setMessages(prev => [...prev, {
1575
+ id: crypto.randomUUID(),
1576
+ role: 'system',
1577
+ content: 'Nothing to undo.',
1578
+ timestamp: new Date(),
1579
+ }]);
1580
+ setIsProcessing(false);
1581
+ });
1582
+ }
1583
+ else {
1584
+ setMessages(prev => [...prev, {
1585
+ id: crypto.randomUUID(),
1586
+ role: 'system',
1587
+ content: 'Nothing to undo.',
1588
+ timestamp: new Date(),
1589
+ }]);
1590
+ }
1591
+ return;
1592
+ }
1593
+ }, { isActive: !isProcessing && !permissionRequest && !deployPreview && !fileDiffRequest && !showHelp });
1594
+ /* -- H3: Deploy mode confirmation input handler ----------------------- */
1595
+ useInput((input, key) => {
1596
+ if (!pendingDeployConfirm)
1597
+ return;
1598
+ if (input === 'y' || input === 'Y') {
1599
+ setPendingDeployConfirm(false);
1600
+ setSession(prev => ({ ...prev, mode: 'deploy' }));
1601
+ if (onModeChange)
1602
+ onModeChange('deploy');
1603
+ try {
1604
+ const { saveModeForCwd } = require('../config/mode-store');
1605
+ saveModeForCwd(process.cwd(), 'deploy');
1606
+ }
1607
+ catch { /* non-critical */ }
1608
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: 'Mode switched to: deploy', timestamp: new Date() }]);
1609
+ setModeToast('→ DEPLOY mode');
1610
+ setTimeout(() => setModeToast(null), 2000);
1611
+ }
1612
+ else if (input === 'n' || input === 'N' || key.escape) {
1613
+ setPendingDeployConfirm(false);
1614
+ setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'system', content: 'Deploy mode cancelled.', timestamp: new Date() }]);
1615
+ }
1616
+ }, { isActive: pendingDeployConfirm });
1617
+ /* -- H5: ? key opens HelpModal ---------------------------------------- */
1618
+ useInput((input) => {
1619
+ if (input === '?' && !isProcessing && !showHelp) {
1620
+ setShowHelp(true);
1621
+ }
1622
+ }, { isActive: !permissionRequest && !deployPreview && !fileDiffRequest && !showHelp });
1623
+ /* -- Derived state ----------------------------------------------------- */
1624
+ // M1: Compute search result count for the StatusBar
1625
+ const searchResultCount = useMemo(() => searchQuery ? messages.filter(m => m.content.toLowerCase().includes(searchQuery.toLowerCase())).length : 0, [messages, searchQuery]);
1626
+ // Collect tool calls from the last assistant message (if any) plus any
1627
+ // currently active tool calls being streamed in.
1628
+ // useMemo avoids the O(n) backwards scan on every React render.
1629
+ const visibleToolCalls = useMemo(() => {
1630
+ if (activeToolCalls.length > 0) {
1631
+ return activeToolCalls;
1632
+ }
1633
+ // Fall back to the tool calls from the most recent assistant message
1634
+ for (let i = messages.length - 1; i >= 0; i--) {
1635
+ const msg = messages[i];
1636
+ if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
1637
+ return msg.toolCalls;
1638
+ }
1639
+ }
1640
+ return [];
1641
+ }, [activeToolCalls, messages]);
1642
+ /* -- Render ------------------------------------------------------------ */
1643
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [showApiKeySetup && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", padding: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Welcome to Nimbus! No API key configured." }), _jsx(Text, { dimColor: true, children: "Set ANTHROPIC_API_KEY environment variable, or run: nimbus login" }), _jsx(Text, { dimColor: true, children: "Press Enter to continue without API key (limited functionality)" }), _jsx(Text, { dimColor: true, children: "This banner will dismiss in 8 seconds or on your first message." })] })), _jsx(Header, { session: session }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(MessageList, { messages: messages, mode: session.mode, scrollOffset: scrollOffset, searchQuery: searchQuery || undefined, columns: columns }) }), (showTerminalPane || terminalPaneAuto) && (_jsx(TerminalPane, { toolCalls: completedToolCalls, maxLines: 20 })), showTreePane && (_jsx(TreePane, { cwd: process.cwd(), onSelectFile: fp => {
1644
+ // GAP-21: inject @filepath directly into InputBox via prefill state
1645
+ const cwd = process.cwd();
1646
+ const rel = fp.startsWith(cwd + '/') ? fp.slice(cwd.length + 1) : fp;
1647
+ setInputPrefill(`@${rel} `);
1648
+ } }))] }), isProcessing && !currentTurnHasOutput && (_jsxs(Box, { paddingX: 1, paddingY: 0, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { color: "cyan", dimColor: true, children: [' ', "Thinking..."] })] })), visibleToolCalls.length > 0 && (_jsx(ToolCallDisplay, { toolCalls: visibleToolCalls, expanded: isProcessing })), permissionRequest && (_jsx(PermissionPrompt, { toolName: permissionRequest.tool, toolInput: permissionRequest.input, riskLevel: permissionRequest.riskLevel, onDecide: handlePermission })), pendingDeployConfirm && (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "red", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "!! Switch to DEPLOY mode?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: "DEPLOY mode enables destructive operations:" }), _jsx(Text, { dimColor: true, children: " terraform apply/destroy, kubectl delete, helm uninstall" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Press ", _jsx(Text, { bold: true, color: "green", children: "y" }), " to confirm | ", _jsx(Text, { bold: true, color: "red", children: "n" }), " or Esc to cancel"] })] })), deployPreview && _jsx(DeployPreview, { preview: deployPreview, onDecide: handleDeployDecision }), fileDiffRequest && (_jsx(FileDiffModal, { request: {
1649
+ ...fileDiffRequest,
1650
+ onDecide: handleFileDiffDecision,
1651
+ } })), showHelp && _jsx(HelpModal, { onClose: () => setShowHelp(false) }), _jsx(InputBox, { onSubmit: handleSubmit, onAbort: handleAbort, disabled: isProcessing || !!permissionRequest || !!deployPreview || !!fileDiffRequest || showHelp, placeholder: isProcessing ? 'Agent is thinking...' : undefined, mode: session.mode, onLineCountChange: setInputLineCount, prefill: inputPrefill, onFetchCompletions: onFetchCompletions }), _jsx(StatusBar, { session: session, isProcessing: isProcessing, processingStartTime: processingStartTime, inputLineCount: inputLineCount, showScrollHint: !scrollLocked, copyToast: copyToast, modeToast: modeToast ?? undefined, searchQuery: searchQuery || undefined, searchResultCount: searchQuery ? searchResultCount : undefined })] }));
1652
+ }
1653
+ /**
1654
+ * Catches uncaught React render errors and displays a recovery message
1655
+ * instead of crashing the entire TUI.
1656
+ */
1657
+ export class AppErrorBoundary extends React.Component {
1658
+ constructor(props) {
1659
+ super(props);
1660
+ this.state = { hasError: false, error: null };
1661
+ }
1662
+ static getDerivedStateFromError(error) {
1663
+ return { hasError: true, error };
1664
+ }
1665
+ render() {
1666
+ if (this.state.hasError) {
1667
+ const msg = this.state.error?.message || 'Unknown error';
1668
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Nimbus TUI encountered an error:" }), _jsx(Text, { color: "red", children: msg }), _jsxs(Text, { dimColor: true, children: ['\n', "The interactive UI has crashed. You can:", '\n', " 1. Restart nimbus", '\n', " 2. Use readline mode: nimbus chat --ui=readline"] })] }));
1669
+ }
1670
+ return this.props.children;
1671
+ }
1672
+ }