@build-astron-co/nimbus 0.4.1 → 0.4.3

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