@build-astron-co/nimbus 0.4.2 → 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 (430) hide show
  1. package/dist/src/agent/compaction-agent.js +24 -12
  2. package/dist/src/agent/context-manager.js +2 -1
  3. package/dist/src/agent/expand-files.js +2 -1
  4. package/dist/src/agent/loop.js +71 -33
  5. package/dist/src/agent/permissions.js +4 -2
  6. package/dist/src/agent/system-prompt.js +34 -17
  7. package/dist/src/app.js +1 -1
  8. package/dist/src/auth/keychain.js +8 -4
  9. package/dist/src/auth/store.js +70 -107
  10. package/dist/src/cli/init.js +35 -19
  11. package/dist/src/cli/run.js +18 -10
  12. package/dist/src/cli/serve.js +4 -2
  13. package/dist/src/cli.js +52 -11
  14. package/dist/src/commands/alias.js +5 -3
  15. package/dist/src/commands/audit/index.js +2 -1
  16. package/dist/src/commands/aws-terraform.js +36 -18
  17. package/dist/src/commands/completions.js +1 -1
  18. package/dist/src/commands/config.js +3 -2
  19. package/dist/src/commands/connect-github.js +92 -0
  20. package/dist/src/commands/cost/index.js +3 -2
  21. package/dist/src/commands/deploy.js +15 -10
  22. package/dist/src/commands/doctor.js +6 -3
  23. package/dist/src/commands/drift/index.js +2 -1
  24. package/dist/src/commands/export.js +5 -3
  25. package/dist/src/commands/generate-terraform.js +110 -2
  26. package/dist/src/commands/import.js +3 -3
  27. package/dist/src/commands/incident.js +10 -5
  28. package/dist/src/commands/login.js +8 -93
  29. package/dist/src/commands/logs.js +16 -8
  30. package/dist/src/commands/onboarding.js +6 -4
  31. package/dist/src/commands/pipeline.js +6 -3
  32. package/dist/src/commands/plugin.js +3 -2
  33. package/dist/src/commands/profile.js +27 -14
  34. package/dist/src/commands/questionnaire.js +1 -1
  35. package/dist/src/commands/rollback.js +3 -2
  36. package/dist/src/commands/rollout.js +5 -3
  37. package/dist/src/commands/runbook.js +17 -10
  38. package/dist/src/commands/schedule.js +10 -5
  39. package/dist/src/commands/status.js +2 -1
  40. package/dist/src/commands/team-context.js +12 -7
  41. package/dist/src/commands/template.js +1 -1
  42. package/dist/src/commands/tf/index.js +6 -3
  43. package/dist/src/commands/version.js +6 -3
  44. package/dist/src/commands/watch.js +6 -3
  45. package/dist/src/compat/sqlite.js +5 -3
  46. package/dist/src/config/mode-store.js +2 -1
  47. package/dist/src/config/profiles.js +4 -2
  48. package/dist/src/config/types.js +2 -1
  49. package/dist/src/engine/executor.js +8 -4
  50. package/dist/src/engine/planner.js +9 -5
  51. package/dist/src/llm/providers/anthropic.js +6 -3
  52. package/dist/src/llm/providers/ollama.js +1 -1
  53. package/dist/src/llm/router.js +22 -7
  54. package/dist/src/sessions/manager.js +6 -3
  55. package/dist/src/sharing/viewer.js +2 -1
  56. package/dist/src/tools/file-ops.js +1 -2
  57. package/dist/src/tools/schemas/devops.js +197 -108
  58. package/dist/src/tools/schemas/standard.js +1 -1
  59. package/dist/src/ui/App.js +25 -13
  60. package/dist/src/ui/FileDiffModal.js +22 -11
  61. package/dist/src/ui/HelpModal.js +2 -1
  62. package/dist/src/ui/InputBox.js +6 -3
  63. package/dist/src/ui/MessageList.js +40 -20
  64. package/dist/src/ui/TerminalPane.js +2 -1
  65. package/dist/src/ui/ToolCallDisplay.js +12 -6
  66. package/dist/src/ui/TreePane.js +2 -1
  67. package/dist/src/ui/ink/index.js +37 -21
  68. package/dist/src/watcher/index.js +8 -4
  69. package/package.json +3 -5
  70. package/src/__tests__/alias.test.ts +0 -133
  71. package/src/__tests__/app.test.ts +0 -76
  72. package/src/__tests__/audit.test.ts +0 -877
  73. package/src/__tests__/circuit-breaker.test.ts +0 -116
  74. package/src/__tests__/cli-run.test.ts +0 -351
  75. package/src/__tests__/compat-sqlite.test.ts +0 -68
  76. package/src/__tests__/context-manager.test.ts +0 -632
  77. package/src/__tests__/context.test.ts +0 -242
  78. package/src/__tests__/devops-terminal-gaps.test.ts +0 -718
  79. package/src/__tests__/doctor.test.ts +0 -48
  80. package/src/__tests__/enterprise.test.ts +0 -401
  81. package/src/__tests__/export.test.ts +0 -236
  82. package/src/__tests__/gap-11-18-20.test.ts +0 -958
  83. package/src/__tests__/generator.test.ts +0 -433
  84. package/src/__tests__/helm-streaming.test.ts +0 -127
  85. package/src/__tests__/hooks.test.ts +0 -582
  86. package/src/__tests__/incident.test.ts +0 -179
  87. package/src/__tests__/init.test.ts +0 -487
  88. package/src/__tests__/intent-parser.test.ts +0 -229
  89. package/src/__tests__/llm-router.test.ts +0 -209
  90. package/src/__tests__/logs.test.ts +0 -107
  91. package/src/__tests__/loop-errors.test.ts +0 -244
  92. package/src/__tests__/lsp.test.ts +0 -293
  93. package/src/__tests__/modes.test.ts +0 -336
  94. package/src/__tests__/perf-optimizations.test.ts +0 -847
  95. package/src/__tests__/permissions.test.ts +0 -338
  96. package/src/__tests__/pipeline.test.ts +0 -50
  97. package/src/__tests__/polish-phase3.test.ts +0 -340
  98. package/src/__tests__/profile.test.ts +0 -237
  99. package/src/__tests__/rollback.test.ts +0 -83
  100. package/src/__tests__/runbook.test.ts +0 -219
  101. package/src/__tests__/schedule.test.ts +0 -206
  102. package/src/__tests__/serve.test.ts +0 -275
  103. package/src/__tests__/sessions.test.ts +0 -322
  104. package/src/__tests__/sharing.test.ts +0 -340
  105. package/src/__tests__/snapshots.test.ts +0 -581
  106. package/src/__tests__/standalone-migration.test.ts +0 -199
  107. package/src/__tests__/state-db.test.ts +0 -334
  108. package/src/__tests__/status.test.ts +0 -158
  109. package/src/__tests__/stream-with-tools.test.ts +0 -778
  110. package/src/__tests__/subagents.test.ts +0 -176
  111. package/src/__tests__/system-prompt.test.ts +0 -248
  112. package/src/__tests__/terminal-gap-v2.test.ts +0 -395
  113. package/src/__tests__/terminal-parity.test.ts +0 -393
  114. package/src/__tests__/tf-apply.test.ts +0 -187
  115. package/src/__tests__/tool-converter.test.ts +0 -256
  116. package/src/__tests__/tool-schemas.test.ts +0 -602
  117. package/src/__tests__/tools.test.ts +0 -144
  118. package/src/__tests__/version-json.test.ts +0 -184
  119. package/src/__tests__/version.test.ts +0 -49
  120. package/src/__tests__/watch.test.ts +0 -129
  121. package/src/agent/compaction-agent.ts +0 -266
  122. package/src/agent/context-manager.ts +0 -499
  123. package/src/agent/context.ts +0 -427
  124. package/src/agent/deploy-preview.ts +0 -487
  125. package/src/agent/expand-files.ts +0 -108
  126. package/src/agent/index.ts +0 -68
  127. package/src/agent/loop.ts +0 -1998
  128. package/src/agent/modes.ts +0 -429
  129. package/src/agent/permissions.ts +0 -513
  130. package/src/agent/subagents/base.ts +0 -116
  131. package/src/agent/subagents/cost.ts +0 -51
  132. package/src/agent/subagents/explore.ts +0 -42
  133. package/src/agent/subagents/general.ts +0 -54
  134. package/src/agent/subagents/index.ts +0 -102
  135. package/src/agent/subagents/infra.ts +0 -59
  136. package/src/agent/subagents/security.ts +0 -69
  137. package/src/agent/system-prompt.ts +0 -990
  138. package/src/app.ts +0 -180
  139. package/src/audit/activity-log.ts +0 -290
  140. package/src/audit/compliance-checker.ts +0 -540
  141. package/src/audit/cost-tracker.ts +0 -318
  142. package/src/audit/index.ts +0 -23
  143. package/src/audit/security-scanner.ts +0 -641
  144. package/src/auth/guard.ts +0 -75
  145. package/src/auth/index.ts +0 -56
  146. package/src/auth/keychain.ts +0 -82
  147. package/src/auth/oauth.ts +0 -465
  148. package/src/auth/providers.ts +0 -470
  149. package/src/auth/sso.ts +0 -113
  150. package/src/auth/store.ts +0 -505
  151. package/src/auth/types.ts +0 -187
  152. package/src/build.ts +0 -141
  153. package/src/cli/index.ts +0 -16
  154. package/src/cli/init.ts +0 -1227
  155. package/src/cli/openapi-spec.ts +0 -356
  156. package/src/cli/run.ts +0 -628
  157. package/src/cli/serve-auth.ts +0 -80
  158. package/src/cli/serve.ts +0 -539
  159. package/src/cli/web.ts +0 -71
  160. package/src/cli.ts +0 -1728
  161. package/src/clients/core-engine-client.ts +0 -227
  162. package/src/clients/enterprise-client.ts +0 -334
  163. package/src/clients/generator-client.ts +0 -351
  164. package/src/clients/git-client.ts +0 -627
  165. package/src/clients/github-client.ts +0 -410
  166. package/src/clients/helm-client.ts +0 -504
  167. package/src/clients/index.ts +0 -80
  168. package/src/clients/k8s-client.ts +0 -497
  169. package/src/clients/llm-client.ts +0 -161
  170. package/src/clients/rest-client.ts +0 -130
  171. package/src/clients/service-discovery.ts +0 -38
  172. package/src/clients/terraform-client.ts +0 -482
  173. package/src/clients/tools-client.ts +0 -1843
  174. package/src/clients/ws-client.ts +0 -115
  175. package/src/commands/alias.ts +0 -100
  176. package/src/commands/analyze/index.ts +0 -352
  177. package/src/commands/apply/helm.ts +0 -473
  178. package/src/commands/apply/index.ts +0 -213
  179. package/src/commands/apply/k8s.ts +0 -454
  180. package/src/commands/apply/terraform.ts +0 -582
  181. package/src/commands/ask.ts +0 -167
  182. package/src/commands/audit/index.ts +0 -357
  183. package/src/commands/auth-cloud.ts +0 -407
  184. package/src/commands/auth-list.ts +0 -134
  185. package/src/commands/auth-profile.ts +0 -121
  186. package/src/commands/auth-refresh.ts +0 -187
  187. package/src/commands/auth-status.ts +0 -141
  188. package/src/commands/aws/ec2.ts +0 -501
  189. package/src/commands/aws/iam.ts +0 -397
  190. package/src/commands/aws/index.ts +0 -133
  191. package/src/commands/aws/lambda.ts +0 -396
  192. package/src/commands/aws/rds.ts +0 -439
  193. package/src/commands/aws/s3.ts +0 -439
  194. package/src/commands/aws/vpc.ts +0 -393
  195. package/src/commands/aws-discover.ts +0 -542
  196. package/src/commands/aws-terraform.ts +0 -755
  197. package/src/commands/azure/aks.ts +0 -376
  198. package/src/commands/azure/functions.ts +0 -253
  199. package/src/commands/azure/index.ts +0 -116
  200. package/src/commands/azure/storage.ts +0 -478
  201. package/src/commands/azure/vm.ts +0 -355
  202. package/src/commands/billing/index.ts +0 -256
  203. package/src/commands/chat.ts +0 -320
  204. package/src/commands/completions.ts +0 -268
  205. package/src/commands/config.ts +0 -372
  206. package/src/commands/cost/cloud-cost-estimator.ts +0 -266
  207. package/src/commands/cost/estimator.ts +0 -79
  208. package/src/commands/cost/index.ts +0 -810
  209. package/src/commands/cost/parsers/terraform.ts +0 -273
  210. package/src/commands/cost/parsers/types.ts +0 -25
  211. package/src/commands/cost/pricing/aws.ts +0 -544
  212. package/src/commands/cost/pricing/azure.ts +0 -499
  213. package/src/commands/cost/pricing/gcp.ts +0 -396
  214. package/src/commands/cost/pricing/index.ts +0 -40
  215. package/src/commands/demo.ts +0 -250
  216. package/src/commands/deploy.ts +0 -260
  217. package/src/commands/doctor.ts +0 -1386
  218. package/src/commands/drift/index.ts +0 -787
  219. package/src/commands/explain.ts +0 -277
  220. package/src/commands/export.ts +0 -146
  221. package/src/commands/feedback.ts +0 -389
  222. package/src/commands/fix.ts +0 -324
  223. package/src/commands/fs/index.ts +0 -402
  224. package/src/commands/gcp/compute.ts +0 -325
  225. package/src/commands/gcp/functions.ts +0 -271
  226. package/src/commands/gcp/gke.ts +0 -438
  227. package/src/commands/gcp/iam.ts +0 -344
  228. package/src/commands/gcp/index.ts +0 -129
  229. package/src/commands/gcp/storage.ts +0 -284
  230. package/src/commands/generate-helm.ts +0 -1249
  231. package/src/commands/generate-k8s.ts +0 -1508
  232. package/src/commands/generate-terraform.ts +0 -1202
  233. package/src/commands/gh/index.ts +0 -863
  234. package/src/commands/git/index.ts +0 -1343
  235. package/src/commands/helm/index.ts +0 -1126
  236. package/src/commands/help.ts +0 -715
  237. package/src/commands/history.ts +0 -149
  238. package/src/commands/import.ts +0 -868
  239. package/src/commands/incident.ts +0 -166
  240. package/src/commands/index.ts +0 -367
  241. package/src/commands/init.ts +0 -1051
  242. package/src/commands/k8s/index.ts +0 -1137
  243. package/src/commands/login.ts +0 -716
  244. package/src/commands/logout.ts +0 -83
  245. package/src/commands/logs.ts +0 -167
  246. package/src/commands/onboarding.ts +0 -405
  247. package/src/commands/pipeline.ts +0 -186
  248. package/src/commands/plan/display.ts +0 -279
  249. package/src/commands/plan/index.ts +0 -599
  250. package/src/commands/plugin.ts +0 -398
  251. package/src/commands/preview.ts +0 -452
  252. package/src/commands/profile.ts +0 -342
  253. package/src/commands/questionnaire.ts +0 -1172
  254. package/src/commands/resume.ts +0 -47
  255. package/src/commands/rollback.ts +0 -315
  256. package/src/commands/rollout.ts +0 -88
  257. package/src/commands/runbook.ts +0 -346
  258. package/src/commands/schedule.ts +0 -236
  259. package/src/commands/status.ts +0 -252
  260. package/src/commands/team/index.ts +0 -346
  261. package/src/commands/team-context.ts +0 -220
  262. package/src/commands/template.ts +0 -233
  263. package/src/commands/tf/index.ts +0 -1093
  264. package/src/commands/upgrade.ts +0 -609
  265. package/src/commands/usage/index.ts +0 -134
  266. package/src/commands/version.ts +0 -174
  267. package/src/commands/watch.ts +0 -153
  268. package/src/compat/index.ts +0 -2
  269. package/src/compat/runtime.ts +0 -12
  270. package/src/compat/sqlite.ts +0 -177
  271. package/src/config/index.ts +0 -17
  272. package/src/config/manager.ts +0 -530
  273. package/src/config/mode-store.ts +0 -62
  274. package/src/config/profiles.ts +0 -84
  275. package/src/config/safety-policy.ts +0 -358
  276. package/src/config/schema.ts +0 -125
  277. package/src/config/types.ts +0 -609
  278. package/src/config/workspace-state.ts +0 -53
  279. package/src/context/context-db.ts +0 -199
  280. package/src/demo/index.ts +0 -349
  281. package/src/demo/scenarios/full-journey.ts +0 -229
  282. package/src/demo/scenarios/getting-started.ts +0 -127
  283. package/src/demo/scenarios/helm-release.ts +0 -341
  284. package/src/demo/scenarios/k8s-deployment.ts +0 -194
  285. package/src/demo/scenarios/terraform-vpc.ts +0 -170
  286. package/src/demo/types.ts +0 -92
  287. package/src/engine/cost-estimator.ts +0 -480
  288. package/src/engine/diagram-generator.ts +0 -256
  289. package/src/engine/drift-detector.ts +0 -902
  290. package/src/engine/executor.ts +0 -1066
  291. package/src/engine/index.ts +0 -76
  292. package/src/engine/orchestrator.ts +0 -636
  293. package/src/engine/planner.ts +0 -787
  294. package/src/engine/safety.ts +0 -743
  295. package/src/engine/verifier.ts +0 -770
  296. package/src/enterprise/audit.ts +0 -348
  297. package/src/enterprise/auth.ts +0 -270
  298. package/src/enterprise/billing.ts +0 -822
  299. package/src/enterprise/index.ts +0 -17
  300. package/src/enterprise/teams.ts +0 -443
  301. package/src/generator/best-practices.ts +0 -1608
  302. package/src/generator/helm.ts +0 -630
  303. package/src/generator/index.ts +0 -37
  304. package/src/generator/intent-parser.ts +0 -514
  305. package/src/generator/kubernetes.ts +0 -976
  306. package/src/generator/terraform.ts +0 -1875
  307. package/src/history/index.ts +0 -8
  308. package/src/history/manager.ts +0 -250
  309. package/src/history/types.ts +0 -34
  310. package/src/hooks/config.ts +0 -432
  311. package/src/hooks/engine.ts +0 -392
  312. package/src/hooks/index.ts +0 -4
  313. package/src/llm/auth-bridge.ts +0 -198
  314. package/src/llm/circuit-breaker.ts +0 -140
  315. package/src/llm/config-loader.ts +0 -201
  316. package/src/llm/cost-calculator.ts +0 -171
  317. package/src/llm/index.ts +0 -8
  318. package/src/llm/model-aliases.ts +0 -115
  319. package/src/llm/provider-registry.ts +0 -63
  320. package/src/llm/providers/anthropic.ts +0 -462
  321. package/src/llm/providers/bedrock.ts +0 -477
  322. package/src/llm/providers/google.ts +0 -405
  323. package/src/llm/providers/ollama.ts +0 -767
  324. package/src/llm/providers/openai-compatible.ts +0 -340
  325. package/src/llm/providers/openai.ts +0 -328
  326. package/src/llm/providers/openrouter.ts +0 -338
  327. package/src/llm/router.ts +0 -1104
  328. package/src/llm/types.ts +0 -232
  329. package/src/lsp/client.ts +0 -298
  330. package/src/lsp/languages.ts +0 -119
  331. package/src/lsp/manager.ts +0 -294
  332. package/src/mcp/client.ts +0 -402
  333. package/src/mcp/index.ts +0 -5
  334. package/src/mcp/manager.ts +0 -133
  335. package/src/nimbus.ts +0 -234
  336. package/src/plugins/index.ts +0 -27
  337. package/src/plugins/loader.ts +0 -334
  338. package/src/plugins/manager.ts +0 -376
  339. package/src/plugins/types.ts +0 -284
  340. package/src/scanners/cicd-scanner.ts +0 -258
  341. package/src/scanners/cloud-scanner.ts +0 -466
  342. package/src/scanners/framework-scanner.ts +0 -469
  343. package/src/scanners/iac-scanner.ts +0 -388
  344. package/src/scanners/index.ts +0 -539
  345. package/src/scanners/language-scanner.ts +0 -276
  346. package/src/scanners/package-manager-scanner.ts +0 -277
  347. package/src/scanners/types.ts +0 -172
  348. package/src/sessions/manager.ts +0 -472
  349. package/src/sessions/types.ts +0 -44
  350. package/src/sharing/sync.ts +0 -300
  351. package/src/sharing/viewer.ts +0 -163
  352. package/src/snapshots/index.ts +0 -2
  353. package/src/snapshots/manager.ts +0 -530
  354. package/src/state/artifacts.ts +0 -147
  355. package/src/state/audit.ts +0 -137
  356. package/src/state/billing.ts +0 -240
  357. package/src/state/checkpoints.ts +0 -117
  358. package/src/state/config.ts +0 -67
  359. package/src/state/conversations.ts +0 -14
  360. package/src/state/credentials.ts +0 -154
  361. package/src/state/db.ts +0 -58
  362. package/src/state/index.ts +0 -26
  363. package/src/state/messages.ts +0 -115
  364. package/src/state/projects.ts +0 -123
  365. package/src/state/schema.ts +0 -236
  366. package/src/state/sessions.ts +0 -147
  367. package/src/state/teams.ts +0 -200
  368. package/src/telemetry.ts +0 -108
  369. package/src/tools/aws-ops.ts +0 -952
  370. package/src/tools/azure-ops.ts +0 -579
  371. package/src/tools/file-ops.ts +0 -615
  372. package/src/tools/gcp-ops.ts +0 -625
  373. package/src/tools/git-ops.ts +0 -773
  374. package/src/tools/github-ops.ts +0 -799
  375. package/src/tools/helm-ops.ts +0 -943
  376. package/src/tools/index.ts +0 -17
  377. package/src/tools/k8s-ops.ts +0 -819
  378. package/src/tools/schemas/converter.ts +0 -184
  379. package/src/tools/schemas/devops.ts +0 -3502
  380. package/src/tools/schemas/index.ts +0 -73
  381. package/src/tools/schemas/standard.ts +0 -1148
  382. package/src/tools/schemas/types.ts +0 -735
  383. package/src/tools/spawn-exec.ts +0 -148
  384. package/src/tools/terraform-ops.ts +0 -862
  385. package/src/types/ambient.d.ts +0 -193
  386. package/src/types/config.ts +0 -83
  387. package/src/types/drift.ts +0 -116
  388. package/src/types/enterprise.ts +0 -335
  389. package/src/types/index.ts +0 -20
  390. package/src/types/plan.ts +0 -44
  391. package/src/types/request.ts +0 -65
  392. package/src/types/response.ts +0 -54
  393. package/src/types/service.ts +0 -51
  394. package/src/ui/App.tsx +0 -2114
  395. package/src/ui/DeployPreview.tsx +0 -174
  396. package/src/ui/FileDiffModal.tsx +0 -162
  397. package/src/ui/Header.tsx +0 -131
  398. package/src/ui/HelpModal.tsx +0 -57
  399. package/src/ui/InputBox.tsx +0 -503
  400. package/src/ui/MessageList.tsx +0 -1032
  401. package/src/ui/PermissionPrompt.tsx +0 -163
  402. package/src/ui/StatusBar.tsx +0 -277
  403. package/src/ui/TerminalPane.tsx +0 -84
  404. package/src/ui/ToolCallDisplay.tsx +0 -643
  405. package/src/ui/TreePane.tsx +0 -132
  406. package/src/ui/chat-ui.ts +0 -850
  407. package/src/ui/index.ts +0 -33
  408. package/src/ui/ink/index.ts +0 -1444
  409. package/src/ui/streaming.ts +0 -176
  410. package/src/ui/theme.ts +0 -104
  411. package/src/ui/types.ts +0 -75
  412. package/src/utils/analytics.ts +0 -72
  413. package/src/utils/cost-warning.ts +0 -27
  414. package/src/utils/env.ts +0 -46
  415. package/src/utils/errors.ts +0 -69
  416. package/src/utils/event-bus.ts +0 -38
  417. package/src/utils/index.ts +0 -24
  418. package/src/utils/logger.ts +0 -171
  419. package/src/utils/rate-limiter.ts +0 -121
  420. package/src/utils/service-auth.ts +0 -49
  421. package/src/utils/validation.ts +0 -53
  422. package/src/version.ts +0 -4
  423. package/src/watcher/index.ts +0 -214
  424. package/src/wizard/approval.ts +0 -383
  425. package/src/wizard/index.ts +0 -25
  426. package/src/wizard/prompts.ts +0 -338
  427. package/src/wizard/types.ts +0 -172
  428. package/src/wizard/ui.ts +0 -556
  429. package/src/wizard/wizard.ts +0 -304
  430. package/tsconfig.json +0 -24
@@ -1,1444 +0,0 @@
1
- /**
2
- * Ink TUI Launcher
3
- *
4
- * Bridges the Ink-based App component with the core agent loop.
5
- * This is the entry point for `nimbus chat --ui=ink` (and the default).
6
- */
7
-
8
- import React from 'react';
9
- import { render } from 'ink';
10
- import {
11
- App,
12
- AppErrorBoundary,
13
- type AppProps,
14
- type AppImperativeAPI,
15
- type CompactCommandResult,
16
- type ContextCommandResult,
17
- type UndoRedoResult,
18
- type OnModelsCallback,
19
- type OnModelChangeCallback,
20
- type OnModeChangeCallback,
21
- type SessionSummary,
22
- type OnDiffCallback,
23
- type OnCostCallback,
24
- type OnInitCallback,
25
- } from '../App';
26
- import type { FileDiffDecision } from '../FileDiffModal';
27
- import type { UIMessage, UIToolCall, DeployPreviewData } from '../types';
28
- import { getAppContext } from '../../app';
29
- import { runAgentLoop, type AgentLoopResult } from '../../agent/loop';
30
- import { buildSystemPrompt, type AgentMode } from '../../agent/system-prompt';
31
- import { ContextManager } from '../../agent/context-manager';
32
- import { SnapshotManager } from '../../snapshots/manager';
33
- import { defaultToolRegistry, type ToolDefinition } from '../../tools/schemas/types';
34
- import { getTextContent, type LLMMessage } from '../../llm/types';
35
- import { SessionManager } from '../../sessions/manager';
36
- import {
37
- createPermissionState,
38
- checkPermission,
39
- approveForSession,
40
- approveActionForSession,
41
- type PermissionSessionState,
42
- } from '../../agent/permissions';
43
- import { FileWatcher } from '../../watcher';
44
- import { HookEngine } from '../../hooks/engine';
45
- import { getLSPManager } from '../../lsp/manager';
46
- import { DEVOPS_LANGUAGE_IDS } from '../../lsp/languages';
47
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
48
- import { join } from 'node:path';
49
- import { homedir } from 'node:os';
50
- import type { InfraContext } from '../../cli/init';
51
- import { setTheme } from '../theme';
52
-
53
- export interface InkChatOptions {
54
- /** LLM model to use. */
55
- model?: string;
56
- /** Custom system prompt. */
57
- systemPrompt?: string;
58
- /** Show token count in status bar. */
59
- showTokenCount?: boolean;
60
- /** Initial agent mode. */
61
- mode?: AgentMode;
62
- /** Resume a previous session by ID. */
63
- resumeSessionId?: string;
64
- /** Pre-loaded initial prompt (sent as first user message automatically). */
65
- initialPrompt?: string;
66
- }
67
-
68
- /**
69
- * Launch the Ink-based interactive chat TUI.
70
- *
71
- * Renders the React/Ink `App` component and wires it to the core agent
72
- * loop so that each user message triggers an agentic conversation turn.
73
- */
74
- export async function startInkChat(options: InkChatOptions = {}): Promise<void> {
75
- const ctx = getAppContext();
76
- if (!ctx) {
77
- throw new Error('App not initialised. Call initApp() before startInkChat().');
78
- }
79
-
80
- // Gap 19: collect any startup warnings so they can be shown as system messages
81
- let _startupWarnings: string[] = [];
82
- try {
83
- const { startupWarnings } = await import('../../app');
84
- _startupWarnings = startupWarnings;
85
- } catch { /* non-critical */ }
86
-
87
- // Gap 2: load theme from ~/.nimbus/config.yaml if present
88
- try {
89
- const configPath = join(homedir(), '.nimbus', 'config.yaml');
90
- if (existsSync(configPath)) {
91
- const configContent = readFileSync(configPath, 'utf-8');
92
- const themeMatch = configContent.match(/^theme:\s*(\S+)/m);
93
- if (themeMatch) {
94
- setTheme(themeMatch[1]);
95
- }
96
- }
97
- } catch { /* non-critical */ }
98
-
99
- // Use mutable refs so /model, /mode, and Tab changes propagate to the agent loop
100
- let currentMode: AgentMode = options.mode ?? 'build';
101
- let currentModel: string | undefined = options.model;
102
- // Gap 7 & 10: live infra context discovered at startup
103
- let currentInfraContext: InfraContext | undefined;
104
-
105
- // C1: Load prior infra state from ~/.nimbus/infra-state.json before discovery
106
- const infraStatePath = join(homedir(), '.nimbus', 'infra-state.json');
107
- let priorInfraState: InfraContext | undefined;
108
- try {
109
- if (existsSync(infraStatePath)) {
110
- const raw = readFileSync(infraStatePath, 'utf-8');
111
- priorInfraState = JSON.parse(raw) as InfraContext;
112
- }
113
- } catch { /* non-critical */ }
114
-
115
- // H6: Load persisted workspace state as baseline (fresh discovery will override below)
116
- try {
117
- const { loadWorkspaceState } = await import('../../config/workspace-state');
118
- const storedWorkspace = loadWorkspaceState(process.cwd());
119
- if (!currentInfraContext && Object.keys(storedWorkspace).length > 0) {
120
- currentInfraContext = storedWorkspace as InfraContext;
121
- }
122
- } catch { /* non-critical */ }
123
-
124
- const contextManager = new ContextManager({ model: currentModel });
125
- const snapshotManager = new SnapshotManager({ projectDir: process.cwd() });
126
- const lspManager = getLSPManager(process.cwd(), { enabledLanguages: DEVOPS_LANGUAGE_IDS });
127
-
128
- // Concurrent message guard: prevent overlapping agent loop runs
129
- let isRunning = false;
130
-
131
- // Context window warning: warn once per session at 70% usage
132
- let contextWarningShown = false;
133
-
134
- // Eagerly load NIMBUS.md for explicit pass-through to the agent loop.
135
- // On the first run (no NIMBUS.md found), auto-run `nimbus init --quiet`
136
- // to generate one with detected project context.
137
- let nimbusInstructions: string | undefined;
138
- const nimbusMdPaths = [
139
- join(process.cwd(), 'NIMBUS.md'),
140
- join(process.cwd(), '.nimbus', 'NIMBUS.md'),
141
- ];
142
-
143
- const foundNimbusMd = nimbusMdPaths.find(p => existsSync(p));
144
- if (foundNimbusMd) {
145
- try {
146
- nimbusInstructions = readFileSync(foundNimbusMd, 'utf-8');
147
- } catch {
148
- /* skip */
149
- }
150
- } else if (!options.resumeSessionId) {
151
- // Fresh session with no NIMBUS.md — silently auto-generate one
152
- try {
153
- const { runInit } = await import('../../cli/init');
154
- const result = await runInit({ cwd: process.cwd(), quiet: true });
155
- // Load the freshly generated NIMBUS.md
156
- if (result.nimbusmdPath && existsSync(result.nimbusmdPath)) {
157
- nimbusInstructions = readFileSync(result.nimbusmdPath, 'utf-8');
158
- }
159
- } catch {
160
- /* init failure is non-critical — proceed without project context */
161
- }
162
- }
163
-
164
- // G4: If NIMBUS.md is still missing after auto-init attempt, show a prominent banner
165
- const isNewSessionEarly = !options.resumeSessionId;
166
- const nimbusMdMissing = !nimbusInstructions;
167
- // initialMessages array will be populated later; we track the banner flag here
168
- const showNimbusMdBanner = nimbusMdMissing && isNewSessionEarly;
169
-
170
- // Initialize hook engine with project dir (loads .nimbus/hooks.yaml if present)
171
- const hookEngine = new HookEngine(process.cwd());
172
-
173
- // Start filesystem watcher for external change awareness
174
- const watcher = new FileWatcher(process.cwd());
175
- watcher.start();
176
-
177
- // NIMBUS.md live reload (M10): watch for changes to NIMBUS.md mid-session
178
- // M5: Also notify on DevOps file changes (debounced 30s per file)
179
- const devopsChangeDebounce = new Map<string, ReturnType<typeof setTimeout>>();
180
-
181
- watcher.on('change', (changedPath: string) => {
182
- if (changedPath.endsWith('NIMBUS.md')) {
183
- try {
184
- nimbusInstructions = readFileSync(changedPath, 'utf-8');
185
- addMessage({
186
- id: crypto.randomUUID(),
187
- role: 'system',
188
- content: '[md] NIMBUS.md reloaded — new instructions active for next turn.',
189
- timestamp: new Date(),
190
- });
191
- } catch {
192
- /* ignore read errors */
193
- }
194
- }
195
-
196
- // M5: Notify on DevOps file changes (debounced 30s per file)
197
- const filePath = typeof changedPath === 'string' ? changedPath : (changedPath as any)?.path ?? '';
198
- const isDevOps = /\.(tf|yaml|yml)$|Dockerfile|docker-compose/i.test(filePath);
199
- if (isDevOps) {
200
- const existing = devopsChangeDebounce.get(filePath);
201
- if (existing) clearTimeout(existing);
202
- const timer = setTimeout(() => {
203
- devopsChangeDebounce.delete(filePath);
204
- const relPath = filePath.replace(process.cwd() + '/', '');
205
- const hint = relPath.endsWith('.tf') ? '/plan' : relPath.includes('yaml') ? '/plan' : '/init';
206
- addMessage({
207
- id: crypto.randomUUID(),
208
- role: 'system',
209
- content: `[~] File changed: ${relPath} — type ${hint} to review drift impact`,
210
- timestamp: new Date(),
211
- });
212
- }, 30000);
213
- devopsChangeDebounce.set(filePath, timer);
214
- }
215
- });
216
-
217
- // C4: Surface LSP unavailability as system messages so the user knows diagnostics are disabled
218
- lspManager.on('lsp-unavailable', (lang: string, cmd: string) => {
219
- addMessage({
220
- id: crypto.randomUUID(),
221
- role: 'system',
222
- content: `[LSP] ${lang} server (${cmd}) not found — diagnostics disabled.`,
223
- timestamp: new Date(),
224
- });
225
- });
226
-
227
- // Create or resume a session for conversation persistence
228
- let sessionManager: SessionManager | null = null;
229
- let sessionId: string | null = null;
230
- try {
231
- sessionManager = SessionManager.getInstance();
232
-
233
- if (options.resumeSessionId) {
234
- // Resume an existing session
235
- const existing = sessionManager.get(options.resumeSessionId);
236
- if (existing) {
237
- sessionId = existing.id;
238
- sessionManager.resume(existing.id);
239
- }
240
- }
241
-
242
- if (!sessionId) {
243
- // Create a new session
244
- const session = sessionManager.create({
245
- name: `chat-${new Date().toISOString().slice(0, 16)}`,
246
- mode: currentMode,
247
- model: currentModel,
248
- cwd: process.cwd(),
249
- });
250
- sessionId = session.id;
251
- }
252
- } catch (sessionErr) {
253
- // C5: Surface SQLite failure prominently in the TUI (not just stderr)
254
- const errMsg = sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
255
- const tuiWarning = `Session persistence unavailable: ${errMsg}. Chat history will NOT be saved this session. Fix: npm install better-sqlite3`;
256
- _startupWarnings.push(tuiWarning);
257
- process.stderr.write(`\x1b[33m Warning: ${tuiWarning}\x1b[0m\n`);
258
- }
259
-
260
- // Gap 7 & 10: discover live infra context at startup (best-effort, non-blocking)
261
- try {
262
- const { discoverInfraContext } = await import('../../cli/init');
263
- currentInfraContext = await discoverInfraContext(process.cwd());
264
-
265
- // C1: Merge with prior state (fresh discovery wins per-field)
266
- if (priorInfraState) {
267
- currentInfraContext = { ...priorInfraState, ...currentInfraContext };
268
- }
269
-
270
- // C1: Persist discovered infra state to ~/.nimbus/infra-state.json
271
- if (currentInfraContext) {
272
- try {
273
- mkdirSync(join(homedir(), '.nimbus'), { recursive: true });
274
- writeFileSync(infraStatePath, JSON.stringify(currentInfraContext, null, 2), 'utf-8');
275
- } catch { /* non-critical */ }
276
-
277
- // H6: Also persist workspace state (terraform workspace + kubectl context) per cwd
278
- try {
279
- const { mergeWorkspaceState } = await import('../../config/workspace-state');
280
- mergeWorkspaceState(process.cwd(), currentInfraContext ?? {});
281
- } catch { /* non-critical */ }
282
- }
283
-
284
- if (sessionManager && sessionId && currentInfraContext) {
285
- try {
286
- sessionManager.setInfraContext(sessionId, currentInfraContext);
287
- } catch { /* non-critical */ }
288
- }
289
-
290
- // C4: Set terminal window title with infra context
291
- try {
292
- const ctxLabel = [
293
- currentInfraContext?.terraformWorkspace && `tf:${currentInfraContext.terraformWorkspace}`,
294
- currentInfraContext?.kubectlContext && `k8s:${currentInfraContext.kubectlContext}`,
295
- ].filter(Boolean).join(' | ') || 'nimbus';
296
- process.stdout.write(`\x1b]0;nimbus -- ${ctxLabel}\x07`);
297
- process.on('exit', () => process.stdout.write('\x1b]0;Terminal\x07'));
298
- } catch { /* non-critical */ }
299
- } catch { /* non-critical — infra discovery failure must never block startup */ }
300
-
301
- // C3: Auto-generate NIMBUS.md if infra is detected but no NIMBUS.md exists
302
- try {
303
- const nimbusmdPath = join(process.cwd(), 'NIMBUS.md');
304
- if (currentInfraContext && !existsSync(nimbusmdPath)) {
305
- const hasTerraform = (currentInfraContext as { terraformWorkspace?: string }).terraformWorkspace !== undefined
306
- || existsSync(join(process.cwd(), 'main.tf'))
307
- || existsSync(join(process.cwd(), 'terraform'));
308
- const hasK8s = (currentInfraContext as { kubectlContext?: string }).kubectlContext !== undefined;
309
- const hasHelm = ((currentInfraContext as { helmReleases?: string[] }).helmReleases?.length ?? 0) > 0;
310
-
311
- if (hasTerraform || hasK8s || hasHelm) {
312
- const { generateNimbusMd, detectProject } = await import('../../cli/init');
313
- const detection = detectProject(process.cwd());
314
- const mdContent = generateNimbusMd(detection, process.cwd(), currentInfraContext);
315
- writeFileSync(nimbusmdPath, mdContent, 'utf-8');
316
- process.stderr.write('\x1b[32m [nimbus] Auto-generated NIMBUS.md from detected infra\x1b[0m\n');
317
- }
318
- }
319
- } catch { /* non-critical */ }
320
-
321
- // Conversation history shared between turns.
322
- // When resuming, restore saved conversation from the session.
323
- let history: LLMMessage[] = [];
324
- if (options.resumeSessionId && sessionManager && sessionId) {
325
- try {
326
- const restored = sessionManager.loadConversation(sessionId);
327
- if (restored.length > 0) {
328
- history = restored;
329
- }
330
- } catch {
331
- // Restore is non-critical
332
- }
333
-
334
- // Gap 10: On resume, merge stored infra context with freshly discovered live context
335
- try {
336
- const storedInfra = sessionManager.getInfraContext(sessionId);
337
- // Live context (already discovered above) takes precedence for mutable fields
338
- currentInfraContext = { ...storedInfra, ...currentInfraContext };
339
- } catch { /* non-critical */ }
340
- }
341
-
342
- // G2 / C1: Build resume context summary message when resuming with infra context
343
- // Also show when prior state was loaded (even on a new session) to confirm context continuity
344
- const hasResumeContext = currentInfraContext && (
345
- currentInfraContext.kubectlContext || currentInfraContext.terraformWorkspace || currentInfraContext.awsAccount
346
- );
347
- const showResumeBanner = (options.resumeSessionId || !!priorInfraState) && hasResumeContext;
348
- const resumeContextMessage: UIMessage | null = showResumeBanner ? {
349
- id: crypto.randomUUID(),
350
- role: 'system' as const,
351
- content: [
352
- options.resumeSessionId ? 'Resuming session -' : 'Resuming with:',
353
- currentInfraContext!.terraformWorkspace ? `tf:${currentInfraContext!.terraformWorkspace}` : null,
354
- currentInfraContext!.kubectlContext ? `k8s:${currentInfraContext!.kubectlContext}` : null,
355
- currentInfraContext!.awsAccount ? `aws:${currentInfraContext!.awsAccount}` : null,
356
- ].filter(Boolean).join(' | '),
357
- timestamp: new Date(),
358
- } : null;
359
-
360
- // AbortController for cancellation (Ctrl+C / Escape)
361
- let abortController = new AbortController();
362
-
363
- // Permission session state (tracks ask-once approvals)
364
- const permissionState: PermissionSessionState = createPermissionState();
365
-
366
- // Imperative API populated by the App component's onReady callback.
367
- let api: AppImperativeAPI | undefined;
368
-
369
- // Convenience accessors (safe to call before onReady fires).
370
- const addMessage = (msg: UIMessage) => api?.addMessage(msg);
371
- const updateMessage = (id: string, content: string) => api?.updateMessage(id, content);
372
- const setProcessing = (v: boolean) => api?.setProcessing(v);
373
- const updateSession = (patch: Record<string, unknown>) =>
374
- api?.updateSession(patch as Record<string, unknown>);
375
- const setToolCalls = (calls: UIToolCall[]) => api?.setToolCalls(calls);
376
-
377
- // Track active tool calls for UI updates
378
- const activeToolCalls: Map<string, UIToolCall> = new Map();
379
-
380
- // Track the in-flight streaming message so we can update it incrementally
381
- let streamingMessageId: string | null = null;
382
- let streamingContent = '';
383
-
384
- /**
385
- * Derive a risk level from the tool's permission tier.
386
- */
387
- function getRiskLevel(tool: ToolDefinition): 'low' | 'medium' | 'high' | 'critical' {
388
- switch (tool.permissionTier) {
389
- case 'auto_allow':
390
- return 'low';
391
- case 'ask_once':
392
- return 'medium';
393
- case 'always_ask':
394
- return 'high';
395
- case 'blocked':
396
- return 'critical';
397
- default:
398
- return 'medium';
399
- }
400
- }
401
-
402
- /**
403
- * Prompt the user for permission via the Ink PermissionPrompt component.
404
- * Uses the imperative API to render the prompt inside the TUI.
405
- */
406
- function promptPermission(
407
- tool: ToolDefinition,
408
- input: unknown
409
- ): Promise<'allow' | 'deny' | 'block'> {
410
- const toolInput = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
411
-
412
- return new Promise(resolve => {
413
- if (!api) {
414
- // Imperative API not yet wired — deny by default
415
- resolve('deny');
416
- return;
417
- }
418
-
419
- api.requestPermission({
420
- tool: tool.name,
421
- input: toolInput,
422
- riskLevel: getRiskLevel(tool),
423
- onDecide: decision => {
424
- // Map PermissionPrompt decisions to agent loop decisions
425
- switch (decision) {
426
- case 'approve':
427
- resolve('allow');
428
- break;
429
- case 'session': {
430
- approveForSession(tool, permissionState);
431
- const action = toolInput.action;
432
- if (typeof action === 'string') {
433
- approveActionForSession(tool.name, action, permissionState);
434
- }
435
- resolve('allow');
436
- break;
437
- }
438
- case 'approve_all':
439
- approveForSession(tool, permissionState);
440
- resolve('allow');
441
- break;
442
- case 'reject':
443
- default:
444
- resolve('deny');
445
- break;
446
- }
447
- },
448
- });
449
- });
450
- }
451
-
452
- /**
453
- * Determines whether a tool call requires a deploy preview confirmation in deploy mode.
454
- * Covers terraform/kubectl/helm plus destructive bash cloud CLI commands.
455
- */
456
- function requiresDeployPreview(toolName: string, toolInput: Record<string, unknown>): boolean {
457
- if (['terraform', 'kubectl', 'helm'].includes(toolName)) return true;
458
- if (toolName === 'docker') {
459
- const action = String(toolInput.action ?? '');
460
- return ['build', 'push', 'stop', 'compose-up', 'compose-down', 'rm', 'prune'].includes(action);
461
- }
462
- if (toolName === 'cloud_action') {
463
- const action = String(toolInput.action ?? '');
464
- return ['create', 'delete', 'stop'].includes(action);
465
- }
466
- if (toolName === 'cfn') {
467
- const action = String(toolInput.action ?? '');
468
- return ['create', 'update', 'delete', 'deploy'].includes(action);
469
- }
470
- if (toolName === 'bash') {
471
- const cmd = String(toolInput.command ?? '');
472
- return /\b(aws\s+\S+\s+delete|aws\s+ec2\s+terminate|gcloud\s+\S+\s+delete|az\s+\S+\s+delete|kubectl\s+delete)\b/.test(cmd);
473
- }
474
- return false;
475
- }
476
-
477
- /**
478
- * Show the deploy preview modal and wait for user confirmation.
479
- * Returns true if the user approves, false if they cancel.
480
- */
481
- function promptDeployPreview(tool: string, input: Record<string, unknown>): Promise<boolean> {
482
- return new Promise(resolve => {
483
- if (!api) {
484
- resolve(true); // API not ready — allow by default
485
- return;
486
- }
487
-
488
- const action = typeof input.action === 'string' ? input.action : 'apply';
489
- const changeAction: 'create' | 'modify' | 'destroy' | 'replace' =
490
- action.includes('destroy') || action.includes('delete') ? 'destroy' : 'modify';
491
-
492
- const preview: DeployPreviewData = {
493
- tool,
494
- changes: [
495
- {
496
- action: changeAction,
497
- resourceType: tool,
498
- resourceName: typeof input.command === 'string' ? input.command : action,
499
- details: typeof input.args === 'string' ? input.args : undefined,
500
- },
501
- ],
502
- };
503
-
504
- api.requestDeployPreview(preview, decision => {
505
- resolve(decision === 'approve');
506
- });
507
- });
508
- }
509
-
510
- /**
511
- * Handle a user message: run the agent loop and stream results back
512
- * into the TUI.
513
- */
514
- // Track the timestamp of each turn so watcher can report changes since last turn
515
- let lastTurnTimestamp = Date.now();
516
- // M2: Track user message count for first-message session rename
517
- let userMessageCount = 0;
518
-
519
- /**
520
- * GAP-20: Parse the ## Tool Timeouts section from NIMBUS.md.
521
- * Each line has the format: tool_name: milliseconds
522
- * Returns a Record<string, number> for passing to runAgentLoop as toolTimeouts.
523
- */
524
- function parseToolTimeouts(nimbusMd: string): Record<string, number> {
525
- const result: Record<string, number> = {};
526
- const match = nimbusMd.match(/##\s+Tool Timeouts\s*\n([\s\S]*?)(?=##|$)/);
527
- if (!match) return result;
528
- for (const line of match[1].split('\n')) {
529
- const m = line.match(/^\s*([a-z_]+)\s*:\s*(\d+)\s*$/);
530
- if (m) result[m[1]] = parseInt(m[2], 10);
531
- }
532
- return result;
533
- }
534
-
535
- const onMessage = async (text: string) => {
536
- // Gap 1: Prevent concurrent agent loop runs (would corrupt history)
537
- if (isRunning) {
538
- addMessage({
539
- id: crypto.randomUUID(),
540
- role: 'system',
541
- content: 'Please wait — the agent is still processing the previous message.',
542
- timestamp: new Date(),
543
- });
544
- return;
545
- }
546
- isRunning = true;
547
- abortController = new AbortController();
548
- // Track diff request index within this turn for progress display
549
- let diffRequestIndex = 0;
550
-
551
- // M2: Auto-rename session from first user message (semantic name)
552
- userMessageCount++;
553
- if (userMessageCount === 1 && sessionManager && sessionId) {
554
- try {
555
- const semanticName = text.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').toLowerCase().replace(/^-+|-+$/g, '');
556
- if (semanticName) sessionManager.rename(sessionId, semanticName);
557
- } catch { /* non-critical */ }
558
- }
559
-
560
- // Prepend external file change summary if any files changed since last turn
561
- const changeSummary = watcher.getSummary(lastTurnTimestamp);
562
- const enrichedText = changeSummary ? `[System: ${changeSummary}]\n\n${text}` : text;
563
- watcher.clearChanges();
564
- lastTurnTimestamp = Date.now();
565
-
566
- try {
567
- const result: AgentLoopResult = await runAgentLoop(enrichedText, history, {
568
- router: ctx.router,
569
- toolRegistry: defaultToolRegistry,
570
- mode: currentMode,
571
- model: currentModel,
572
- cwd: process.cwd(),
573
- nimbusInstructions,
574
- infraContext: currentInfraContext,
575
- signal: abortController.signal,
576
- contextManager,
577
- snapshotManager,
578
- lspManager,
579
- hookEngine,
580
- onText: chunk => {
581
- // Stream text incrementally into the TUI
582
- if (!streamingMessageId) {
583
- streamingMessageId = crypto.randomUUID();
584
- streamingContent = chunk;
585
- addMessage({
586
- id: streamingMessageId,
587
- role: 'assistant',
588
- content: streamingContent,
589
- timestamp: new Date(),
590
- });
591
- } else {
592
- streamingContent += chunk;
593
- updateMessage(streamingMessageId, streamingContent);
594
- }
595
- },
596
- onToolCallStart: info => {
597
- const toolCall: UIToolCall = {
598
- id: info.id,
599
- name: info.name,
600
- input:
601
- info.input && typeof info.input === 'object'
602
- ? (info.input as Record<string, unknown>)
603
- : {},
604
- status: 'running',
605
- startTime: info.startTime ?? Date.now(),
606
- };
607
- activeToolCalls.set(info.id, toolCall);
608
- setToolCalls([...activeToolCalls.values()]);
609
- },
610
- onToolCallEnd: (info, toolResult) => {
611
- const existing = activeToolCalls.get(info.id);
612
- if (existing) {
613
- existing.status = toolResult.isError ? 'failed' : 'completed';
614
- existing.result = {
615
- output: toolResult.isError ? (toolResult.error ?? '') : toolResult.output,
616
- isError: toolResult.isError,
617
- };
618
- }
619
- setToolCalls([...activeToolCalls.values()]);
620
-
621
- // G6: Surface LSP diagnostics as visible TUI system messages
622
- if (!toolResult.isError && typeof toolResult.output === 'string'
623
- && toolResult.output.includes('LSP Diagnostics:')) {
624
- const diagMatch = toolResult.output.match(/LSP Diagnostics:([\s\S]+?)(?:\n\n|$)/);
625
- if (diagMatch) {
626
- addMessage({
627
- id: crypto.randomUUID(),
628
- role: 'system',
629
- content: `⚠ LSP: ${diagMatch[1].trim()}`,
630
- timestamp: new Date(),
631
- });
632
- }
633
- }
634
- },
635
- onToolOutputChunk: (toolId: string, chunk: string) => {
636
- // Gap 1: stream live output into the running tool call's streamingOutput field
637
- const existing = activeToolCalls.get(toolId);
638
- if (existing) {
639
- existing.streamingOutput = (existing.streamingOutput ?? '') + chunk;
640
- setToolCalls([...activeToolCalls.values()]);
641
- }
642
- },
643
- onUsage: (usage, costUSD) => {
644
- // Update the TUI in real-time after each LLM turn
645
- updateSession({
646
- tokenCount: usage.totalTokens,
647
- costUSD,
648
- });
649
-
650
- // Context window warning at 70% (H5)
651
- // Use 200k as a reasonable default context window size
652
- const CTX_MAX = 200_000;
653
- if (!contextWarningShown && usage.totalTokens > 0) {
654
- const ratio = usage.totalTokens / CTX_MAX;
655
- if (ratio >= 0.70) {
656
- contextWarningShown = true;
657
- addMessage({
658
- id: crypto.randomUUID(),
659
- role: 'system',
660
- content: `⚠ Context window at ${Math.round(ratio * 100)}% — consider /compact [focus] to preserve the most important context before it auto-compacts at 85%.`,
661
- timestamp: new Date(),
662
- });
663
- }
664
- }
665
-
666
- // Track per-turn cost delta for /cost command
667
- const turnCost = costUSD - previousTotalCost;
668
- if (turnCost > 0) {
669
- currentTurn++;
670
- turnCostLog.push({
671
- turn: currentTurn,
672
- costUSD: turnCost,
673
- tokens: usage.totalTokens,
674
- });
675
- previousTotalCost = costUSD;
676
- }
677
- },
678
- onCompact: compactResult => {
679
- addMessage({
680
- id: crypto.randomUUID(),
681
- role: 'system',
682
- content: `Context auto-compacted: saved ${compactResult.savedTokens.toLocaleString()} tokens.`,
683
- timestamp: new Date(),
684
- });
685
- },
686
- checkPermission: async (tool, input) => {
687
- const toolInput =
688
- input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
689
- // In deploy mode, show a preview confirmation before infra-mutating tools
690
- if (currentMode === 'deploy' && requiresDeployPreview(tool.name, toolInput)) {
691
- const approved = await promptDeployPreview(tool.name, toolInput);
692
- if (!approved) {
693
- return 'deny';
694
- }
695
- }
696
-
697
- const decision = checkPermission(tool, input, permissionState);
698
- if (decision === 'allow') {
699
- return 'allow';
700
- }
701
- if (decision === 'block') {
702
- return 'block';
703
- }
704
- // decision === 'ask': prompt the user
705
- return promptPermission(tool, input);
706
- },
707
- requestFileDiff: (path: string, toolName: string, diff: string): Promise<FileDiffDecision> =>
708
- new Promise(resolve => {
709
- if (!api) {
710
- resolve('apply');
711
- return;
712
- }
713
- diffRequestIndex++;
714
- api.requestFileDiff(path, toolName, diff, resolve, diffRequestIndex);
715
- }),
716
- // GAP-20: Pass per-tool timeouts parsed from NIMBUS.md
717
- toolTimeouts: nimbusInstructions ? parseToolTimeouts(nimbusInstructions) : undefined,
718
- });
719
-
720
- // Clear active tool calls now that the turn is complete
721
- activeToolCalls.clear();
722
- setToolCalls([]);
723
-
724
- // Update history with the full conversation from this turn
725
- history = result.messages;
726
-
727
- // Persist conversation + stats to SQLite atomically
728
- if (sessionManager && sessionId) {
729
- try {
730
- sessionManager.saveConversationAndStats(sessionId, history, {
731
- tokenCount: result.usage.totalTokens,
732
- costUSD: result.totalCost,
733
- });
734
- } catch {
735
- /* persistence is non-critical */
736
- }
737
- }
738
-
739
- // Finalize the streamed assistant message with the complete content.
740
- // If onText was never called (e.g., the response was only tool calls),
741
- // add the final assistant message now.
742
- const lastAssistantMsg = [...result.messages].reverse().find(m => m.role === 'assistant');
743
-
744
- if (lastAssistantMsg) {
745
- const finalContent = getTextContent(lastAssistantMsg.content);
746
- if (streamingMessageId) {
747
- // Update the streamed message with the final complete content
748
- updateMessage(streamingMessageId, finalContent);
749
- } else {
750
- // No streaming happened — add the message now
751
- addMessage({
752
- id: crypto.randomUUID(),
753
- role: 'assistant',
754
- content: finalContent,
755
- timestamp: new Date(),
756
- });
757
- }
758
- }
759
-
760
- // Reset streaming state for the next turn
761
- streamingMessageId = null;
762
- streamingContent = '';
763
-
764
- // Update session stats
765
- updateSession({
766
- tokenCount: result.usage.totalTokens,
767
- costUSD: result.totalCost,
768
- });
769
-
770
- // (Session stats already persisted atomically above with saveConversationAndStats)
771
- } catch (err: unknown) {
772
- const msg = err instanceof Error ? err.message : String(err);
773
- addMessage({
774
- id: crypto.randomUUID(),
775
- role: 'system',
776
- content: `Error: ${msg}`,
777
- timestamp: new Date(),
778
- });
779
- // Reset streaming state on error too
780
- streamingMessageId = null;
781
- streamingContent = '';
782
- } finally {
783
- isRunning = false;
784
- setProcessing(false);
785
- }
786
- };
787
-
788
- /**
789
- * Handle abort (Ctrl+C / Escape while processing).
790
- */
791
- const onAbort = () => {
792
- abortController.abort();
793
- };
794
-
795
- /**
796
- * Handle /compact command.
797
- */
798
- const onCompact = async (focusArea?: string): Promise<CompactCommandResult | null> => {
799
- const systemPrompt = buildSystemPrompt({
800
- mode: currentMode,
801
- tools: defaultToolRegistry.getAll(),
802
- cwd: process.cwd(),
803
- });
804
-
805
- const toolTokens = defaultToolRegistry
806
- .getAll()
807
- .reduce((sum, t) => sum + Math.ceil(JSON.stringify(t).length / 4), 0);
808
-
809
- if (!contextManager.shouldCompact(systemPrompt, history, toolTokens)) {
810
- return null;
811
- }
812
-
813
- const { runCompaction } = await import('../../agent/compaction-agent');
814
- const result = await runCompaction(history, contextManager, {
815
- router: ctx.router,
816
- focusArea,
817
- });
818
- history = result.messages;
819
-
820
- return {
821
- originalTokens: result.result.originalTokens,
822
- compactedTokens: result.result.compactedTokens,
823
- savedTokens: result.result.savedTokens,
824
- };
825
- };
826
-
827
- /**
828
- * Estimate token count using gpt-tokenizer (falls back to char/4).
829
- */
830
- let _encode: ((text: string) => unknown[]) | null = null;
831
- let _encodeLoaded = false;
832
- function estimateTokens(text: string): number {
833
- if (!_encodeLoaded) {
834
- _encodeLoaded = true;
835
- try {
836
- // eslint-disable-next-line @typescript-eslint/no-var-requires
837
- _encode = require('gpt-tokenizer').encode;
838
- } catch {
839
- /* fallback */
840
- }
841
- }
842
- if (_encode) {
843
- try {
844
- return _encode(text).length;
845
- } catch {
846
- /* fallback */
847
- }
848
- }
849
- return Math.ceil(text.length / 4);
850
- }
851
-
852
- /**
853
- * Handle /context command.
854
- */
855
- const onContext = (): ContextCommandResult | null => {
856
- const systemPrompt = buildSystemPrompt({
857
- mode: currentMode,
858
- tools: defaultToolRegistry.getAll(),
859
- cwd: process.cwd(),
860
- });
861
-
862
- const systemTokens = estimateTokens(systemPrompt);
863
- const messageTokens = history.reduce(
864
- (sum, m) => sum + estimateTokens(getTextContent(m.content)),
865
- 0
866
- );
867
- const toolTokens = defaultToolRegistry
868
- .getAll()
869
- .reduce((sum, t) => sum + estimateTokens(JSON.stringify(t)), 0);
870
- const total = systemTokens + messageTokens + toolTokens;
871
- // Use the context manager's actual budget (model-aware, not hardcoded)
872
- const budget = contextManager.getConfig().maxContextTokens;
873
-
874
- return {
875
- systemPrompt: systemTokens,
876
- nimbusInstructions: 0,
877
- messages: messageTokens,
878
- toolDefinitions: toolTokens,
879
- total,
880
- budget,
881
- usagePercent: Math.round((total / budget) * 100),
882
- };
883
- };
884
-
885
- /**
886
- * Handle /undo command.
887
- */
888
- const onUndo = async (): Promise<UndoRedoResult> => {
889
- return snapshotManager.undo();
890
- };
891
-
892
- /**
893
- * Handle /redo command.
894
- */
895
- const onRedo = async (): Promise<UndoRedoResult> => {
896
- return snapshotManager.redo();
897
- };
898
-
899
- /**
900
- * Handle /models command — list all available models across providers.
901
- */
902
- const onModels: OnModelsCallback = async () => {
903
- return ctx.router.getAvailableModels();
904
- };
905
-
906
- /**
907
- * Handle /clear command — reset the LLM conversation history.
908
- */
909
- const onClear = () => {
910
- history = [];
911
- };
912
-
913
- /**
914
- * Handle /model change — update the model used by the agent loop.
915
- */
916
- const onModelChange: OnModelChangeCallback = (model: string) => {
917
- currentModel = model;
918
- // Update context manager's budget for the new model
919
- contextManager.setModel(model);
920
- };
921
-
922
- /**
923
- * Handle mode change (Tab or /mode) — update the mode used by the agent loop.
924
- * Resets permission state to prevent privilege escalation.
925
- */
926
- const onModeChange: OnModeChangeCallback = (newMode: AgentMode) => {
927
- currentMode = newMode;
928
- // Reset permission state when switching modes (security)
929
- Object.assign(permissionState, createPermissionState());
930
- };
931
-
932
- // -------------------------------------------------------------------------
933
- // A5: Per-turn cost log for /cost command
934
- // -------------------------------------------------------------------------
935
- const turnCostLog: Array<{ turn: number; costUSD: number; tokens: number }> = [];
936
- let previousTotalCost = 0;
937
- let currentTurn = 0;
938
-
939
- /**
940
- * Handle /diff command — show unstaged git diff.
941
- */
942
- const onDiff: OnDiffCallback = async (): Promise<string> => {
943
- const { spawnSync } = await import('node:child_process');
944
- const stat = spawnSync('git', ['diff', '--stat'], { encoding: 'utf-8', cwd: process.cwd() });
945
- const full = spawnSync('git', ['diff'], { encoding: 'utf-8', cwd: process.cwd() });
946
- const statOut = stat.stdout?.trim() ?? '';
947
- const fullOut = full.stdout?.trim() ?? '';
948
- if (!statOut && !fullOut) return 'No unstaged changes.';
949
- return [statOut, fullOut].filter(Boolean).join('\n\n');
950
- };
951
-
952
- /**
953
- * Handle /cost command — show per-turn cost breakdown.
954
- */
955
- const onCost: OnCostCallback = (): string => {
956
- if (turnCostLog.length === 0) return 'No turns yet.';
957
- const rows = turnCostLog.map(
958
- t => ` Turn ${t.turn} ${t.tokens.toLocaleString()} tokens $${t.costUSD.toFixed(4)}`
959
- );
960
- const total = turnCostLog.reduce((s, t) => s + t.costUSD, 0);
961
- const totalTok = turnCostLog.reduce((s, t) => s + t.tokens, 0);
962
- return [
963
- 'Cost breakdown:',
964
- ...rows,
965
- ` ${'─'.repeat(40)}`,
966
- ` Total ${totalTok.toLocaleString()} tokens $${total.toFixed(4)}`,
967
- ].join('\n');
968
- };
969
-
970
- /**
971
- * Handle /init command — regenerate NIMBUS.md from inside the TUI.
972
- */
973
- const onInit: OnInitCallback = async (): Promise<string> => {
974
- const { runInit } = await import('../../cli/init');
975
- const result = await runInit({ cwd: process.cwd(), quiet: false });
976
- if (result.nimbusmdPath && existsSync(result.nimbusmdPath)) {
977
- nimbusInstructions = readFileSync(result.nimbusmdPath, 'utf-8');
978
- return `NIMBUS.md generated at ${result.nimbusmdPath}. Context updated.`;
979
- }
980
- return 'Init complete (no NIMBUS.md generated).';
981
- };
982
-
983
- /**
984
- * Handle /export [filename] — serialize conversation to a runbook markdown file. G16
985
- */
986
- const onExport: import('../App').OnExportCallback = async (filename?: string): Promise<string> => {
987
- const { join } = await import('node:path');
988
- const { writeFileSync } = await import('node:fs');
989
-
990
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
991
- const targetFile = filename ?? join(process.cwd(), `nimbus-session-${timestamp}.md`);
992
-
993
- const lines: string[] = [
994
- `# Nimbus Session Export`,
995
- `Session: ${sessionId ?? 'unknown'} | Mode: ${currentMode} | Date: ${new Date().toISOString()}`,
996
- '',
997
- '## Conversation',
998
- '',
999
- ];
1000
-
1001
- for (const msg of history) {
1002
- const role = msg.role === 'user' ? '**User**' : '**Agent**';
1003
- const contentStr = Array.isArray(msg.content)
1004
- ? msg.content.map((b: unknown) => (typeof b === 'object' && b !== null && 'text' in b ? (b as {text: string}).text : '')).join('')
1005
- : String(msg.content ?? '');
1006
- lines.push(`${role}: ${contentStr}`);
1007
- lines.push('');
1008
- }
1009
-
1010
- writeFileSync(targetFile, lines.join('\n'), 'utf-8');
1011
- return targetFile;
1012
- };
1013
-
1014
- /**
1015
- * Handle /remember <fact> — append fact to NIMBUS.md Agent Memory. G17
1016
- */
1017
- const onRemember: import('../App').OnRememberCallback = async (fact: string): Promise<void> => {
1018
- // Find the NIMBUS.md path in use
1019
- const nimbusMdPath = nimbusMdPaths.find(p => {
1020
- try { return existsSync(p); } catch { return false; }
1021
- }) ?? nimbusMdPaths[0];
1022
-
1023
- let content = '';
1024
- try {
1025
- if (existsSync(nimbusMdPath)) {
1026
- content = readFileSync(nimbusMdPath, 'utf-8');
1027
- }
1028
- } catch { /* will create new */ }
1029
-
1030
- const MEMORY_SECTION = '## Agent Memory';
1031
- if (content.includes(MEMORY_SECTION)) {
1032
- // Append to existing section
1033
- content = content.replace(
1034
- new RegExp(`(${MEMORY_SECTION}[\\s\\S]*?)(?=\\n##|$)`),
1035
- `$1\n- ${fact}`
1036
- );
1037
- } else {
1038
- content += `\n${MEMORY_SECTION}\n\n- ${fact}\n`;
1039
- }
1040
-
1041
- const { writeFileSync, mkdirSync } = await import('node:fs');
1042
- const { dirname } = await import('node:path');
1043
- mkdirSync(dirname(nimbusMdPath), { recursive: true });
1044
- writeFileSync(nimbusMdPath, content, 'utf-8');
1045
- // Reload instructions
1046
- nimbusInstructions = content;
1047
- };
1048
-
1049
- /**
1050
- * Handle /sessions command — list active sessions.
1051
- */
1052
- const onSessions = (): SessionSummary[] => {
1053
- if (!sessionManager) {
1054
- return [];
1055
- }
1056
- try {
1057
- const sessions = sessionManager.list();
1058
- // L9: include token and cost summary
1059
- let totalTokens = 0;
1060
- let totalCost = 0;
1061
- const mapped: SessionSummary[] = sessions.map(s => {
1062
- const tokens = (s as unknown as Record<string, unknown>).tokenCount as number | undefined ?? 0;
1063
- const cost = (s as unknown as Record<string, unknown>).costUSD as number | undefined ?? 0;
1064
- totalTokens += tokens;
1065
- totalCost += cost;
1066
- return {
1067
- id: s.id,
1068
- name: s.name ?? `session-${s.id.slice(0, 8)}`,
1069
- model: s.model ?? 'default',
1070
- mode: (s.mode ?? 'build') as string,
1071
- updatedAt: s.updatedAt ?? new Date().toISOString(),
1072
- tokenCount: tokens,
1073
- costUSD: cost,
1074
- };
1075
- });
1076
- // Append a total row as a synthetic session entry
1077
- if (mapped.length > 0) {
1078
- mapped.push({
1079
- id: '__total__',
1080
- name: `Total (${mapped.length} sessions)`,
1081
- model: '',
1082
- mode: '',
1083
- updatedAt: '',
1084
- tokenCount: totalTokens,
1085
- costUSD: totalCost,
1086
- });
1087
- }
1088
- return mapped;
1089
- } catch {
1090
- return [];
1091
- }
1092
- };
1093
-
1094
- /**
1095
- * Handle /new command — create a new session, reset history.
1096
- */
1097
- const onNewSession = (name?: string): SessionSummary | null => {
1098
- if (!sessionManager) {
1099
- return null;
1100
- }
1101
- try {
1102
- const newSession = sessionManager.create({
1103
- name: name ?? `chat-${new Date().toISOString().slice(0, 16)}`,
1104
- mode: currentMode,
1105
- model: currentModel,
1106
- cwd: process.cwd(),
1107
- });
1108
- // Reset conversation history for the new session
1109
- history = [];
1110
- sessionId = newSession.id;
1111
- return {
1112
- id: newSession.id,
1113
- name: newSession.name ?? name ?? 'new session',
1114
- model: currentModel ?? 'default',
1115
- mode: currentMode,
1116
- updatedAt: new Date().toISOString(),
1117
- };
1118
- } catch {
1119
- return null;
1120
- }
1121
- };
1122
-
1123
- /**
1124
- * Handle /switch command — switch to a different session.
1125
- */
1126
- const onSwitchSession = (targetId: string): SessionSummary | null => {
1127
- if (!sessionManager) {
1128
- return null;
1129
- }
1130
- try {
1131
- // Find session by ID prefix match
1132
- const sessions = sessionManager.list();
1133
- const target = sessions.find(s => s.id === targetId || s.id.startsWith(targetId));
1134
- if (!target) {
1135
- return null;
1136
- }
1137
-
1138
- // Save current conversation before switching
1139
- if (sessionId) {
1140
- try {
1141
- sessionManager.saveConversation(sessionId, history);
1142
- } catch {
1143
- /* non-critical */
1144
- }
1145
- }
1146
-
1147
- // Load the target session's conversation
1148
- sessionId = target.id;
1149
- sessionManager.resume(target.id);
1150
- try {
1151
- const restored = sessionManager.loadConversation(target.id);
1152
- history = restored;
1153
- } catch {
1154
- history = [];
1155
- }
1156
-
1157
- return {
1158
- id: target.id,
1159
- name: target.name ?? `session-${target.id.slice(0, 8)}`,
1160
- model: target.model ?? 'default',
1161
- mode: target.mode ?? 'build',
1162
- updatedAt: target.updatedAt ?? new Date().toISOString(),
1163
- };
1164
- } catch {
1165
- return null;
1166
- }
1167
- };
1168
-
1169
- // Convert restored LLM history into UIMessages for the TUI
1170
- const restoredMessages: UIMessage[] = history
1171
- .filter(m => m.role === 'user' || m.role === 'assistant')
1172
- .map(m => ({
1173
- id: crypto.randomUUID(),
1174
- role: m.role as 'user' | 'assistant',
1175
- content: getTextContent(m.content),
1176
- timestamp: new Date(),
1177
- }));
1178
-
1179
- // Show a welcome message on fresh sessions (no prior history)
1180
- const isNewSession = restoredMessages.length === 0;
1181
- // L4: Check for prior sessions to show resume hint
1182
- let priorSessionCount = 0;
1183
- try {
1184
- if (sessionManager) {
1185
- const allSessions = sessionManager.list();
1186
- priorSessionCount = allSessions.filter(s => s.id !== sessionId).length;
1187
- }
1188
- } catch { /* non-critical */ }
1189
- const welcomeMessage: UIMessage | null = isNewSession
1190
- ? (() => {
1191
- // G10: DevOps-context-aware welcome message
1192
- const infraLines: string[] = [];
1193
- if (currentInfraContext?.kubectlContext) {
1194
- infraLines.push(` Kubernetes: ${currentInfraContext.kubectlContext}`);
1195
- }
1196
- if (currentInfraContext?.terraformWorkspace) {
1197
- infraLines.push(` Terraform: workspace=${currentInfraContext.terraformWorkspace}`);
1198
- }
1199
- if (currentInfraContext?.awsAccount) {
1200
- infraLines.push(` AWS: ${currentInfraContext.awsAccount}${currentInfraContext.awsRegion ? ` / ${currentInfraContext.awsRegion}` : ''}`);
1201
- }
1202
- if (currentInfraContext?.gcpProject) {
1203
- infraLines.push(` GCP: ${currentInfraContext.gcpProject}`);
1204
- }
1205
-
1206
- // GAP-17: context-aware suggestions based on detected infrastructure
1207
- const suggestions: string[] = [];
1208
- if (currentInfraContext?.terraformWorkspace) suggestions.push(`"check for drift in workspace ${currentInfraContext.terraformWorkspace}"`);
1209
- if (currentInfraContext?.kubectlContext) suggestions.push(`"show all pods in ${currentInfraContext.kubectlContext}"`);
1210
- if (currentInfraContext?.awsAccount) suggestions.push(`"show AWS costs for this month"`);
1211
- if ((currentInfraContext?.helmReleases?.length ?? 0) > 0) suggestions.push(`"show helm release history for ${currentInfraContext!.helmReleases![0]}"`);
1212
-
1213
- // H5: Build one-line infra hint for cold start
1214
- const infraHintParts: string[] = [];
1215
- if (currentInfraContext?.terraformWorkspace) infraHintParts.push(`tf:${currentInfraContext.terraformWorkspace}`);
1216
- if (currentInfraContext?.kubectlContext) infraHintParts.push(`k8s:${currentInfraContext.kubectlContext}`);
1217
- if (currentInfraContext?.awsAccount) infraHintParts.push(`aws:${currentInfraContext.awsAccount}`);
1218
- if (currentInfraContext?.gcpProject) infraHintParts.push(`gcp:${currentInfraContext.gcpProject}`);
1219
- if ((currentInfraContext?.helmReleases?.length ?? 0) > 0) infraHintParts.push(`${currentInfraContext!.helmReleases!.length} helm release${currentInfraContext!.helmReleases!.length > 1 ? 's' : ''}`);
1220
- const infraHintLine = infraHintParts.length > 0 ? `Infra detected: ${infraHintParts.join(' | ')}` : '';
1221
-
1222
- // G24: DevOps-specific quick-start examples
1223
- // M3: When no NIMBUS.md, show concrete DevOps prompt examples to reduce blank-prompt friction
1224
- const noNimbusHints = !nimbusInstructions ? [
1225
- '',
1226
- 'Try asking:',
1227
- ' "list my kubernetes pods in the staging namespace"',
1228
- ' "run terraform plan in ./infrastructure"',
1229
- ' "show me the helm releases and their status"',
1230
- ' "check for infrastructure drift"',
1231
- ] : [];
1232
- const content = [
1233
- 'Welcome to Nimbus — Your AI DevOps Operator.',
1234
- ...(infraHintLine ? ['', infraHintLine] : []),
1235
- '',
1236
- ...(infraLines.length > 0 ? ['Detected infrastructure:', ...infraLines, ''] : []),
1237
- ...(suggestions.length > 0 ? ['', 'Suggested:', ...suggestions.map(s => ` • ${s}`)] : []),
1238
- ...noNimbusHints,
1239
- '',
1240
- 'Mode: PLAN (read-only). Tab → build → deploy to escalate.',
1241
- '',
1242
- 'Quick-start examples:',
1243
- ' "Show me all failing pods across all namespaces"',
1244
- ' "What terraform changes are pending in the staging workspace?"',
1245
- ' "Check for infrastructure drift between actual and desired state"',
1246
- ' "Summarize last 24 hours of production incidents in PagerDuty"',
1247
- '',
1248
- '/k8s-ctx — switch cluster /tf-ws — switch workspace',
1249
- '/help — all commands Tab — cycle modes',
1250
- '',
1251
- nimbusInstructions
1252
- ? 'NIMBUS.md loaded — project context active.'
1253
- : 'Tip: run `nimbus init` to generate a NIMBUS.md with your infra context.',
1254
- // L4: Session resume hint
1255
- ...(priorSessionCount > 0
1256
- ? ['', 'Previous session available — type /sessions to resume or /new to start fresh.']
1257
- : []),
1258
- ].join('\n');
1259
-
1260
- return {
1261
- id: crypto.randomUUID(),
1262
- role: 'system' as const,
1263
- content,
1264
- timestamp: new Date(),
1265
- };
1266
- })()
1267
- : null;
1268
-
1269
- // Gap 19: append any startup warnings as a system message
1270
- const startupWarningMessages: UIMessage[] = _startupWarnings.length > 0
1271
- ? [{
1272
- id: crypto.randomUUID(),
1273
- role: 'system' as const,
1274
- content: `Startup warnings:\n${_startupWarnings.map(w => ` ⚠ ${w}`).join('\n')}`,
1275
- timestamp: new Date(),
1276
- }]
1277
- : [];
1278
-
1279
- // G4: Proactive NIMBUS.md banner when auto-init failed to create one
1280
- const nimbusMdBannerMessage: UIMessage | null = showNimbusMdBanner ? {
1281
- id: crypto.randomUUID(),
1282
- role: 'system' as const,
1283
- content: [
1284
- '**No NIMBUS.md found in this directory.**',
1285
- '',
1286
- 'Type `/init` to auto-generate project context — I\'ll detect your Terraform workspaces,',
1287
- 'Kubernetes clusters, AWS accounts, and more.',
1288
- '',
1289
- 'Or ask me anything directly. I work best with project context loaded.',
1290
- ].join('\n'),
1291
- timestamp: new Date(),
1292
- } : null;
1293
-
1294
- const initialMessages: UIMessage[] = [
1295
- ...(welcomeMessage ? [welcomeMessage] : []),
1296
- ...(nimbusMdBannerMessage ? [nimbusMdBannerMessage] : []),
1297
- ...(resumeContextMessage ? [resumeContextMessage] : []),
1298
- ...startupWarningMessages,
1299
- ...restoredMessages,
1300
- ];
1301
-
1302
- // Build props for the App component
1303
- const appProps: AppProps = {
1304
- initialSession: {
1305
- model: options.model ?? 'default',
1306
- mode: currentMode,
1307
- kubectlContext: currentInfraContext?.kubectlContext,
1308
- terraformWorkspace: currentInfraContext?.terraformWorkspace,
1309
- },
1310
- initialMessages: initialMessages.length > 0 ? initialMessages : undefined,
1311
- onMessage,
1312
- onAbort,
1313
- onCompact,
1314
- onContext,
1315
- onUndo,
1316
- onRedo,
1317
- onModels,
1318
- onClear,
1319
- onModelChange,
1320
- onModeChange,
1321
- onDiff,
1322
- onCost,
1323
- onInit,
1324
- onExport,
1325
- onRemember,
1326
- onSessions,
1327
- onNewSession,
1328
- onSwitchSession,
1329
- onFetchCompletions: async (prefix: string): Promise<string[]> => {
1330
- // H3: Fetch dynamic completions for slash command arguments (cached 30s in InputBox)
1331
- try {
1332
- const { execFile } = await import('node:child_process');
1333
- const { promisify } = await import('node:util');
1334
- const execFileAsync = promisify(execFile);
1335
-
1336
- if (prefix.startsWith('/k8s-ctx ')) {
1337
- const { stdout } = await execFileAsync('kubectl', ['config', 'get-contexts', '-o', 'name'], { timeout: 5000 });
1338
- return stdout.trim().split('\n').filter(Boolean);
1339
- }
1340
- if (prefix.startsWith('/tf-ws ')) {
1341
- const { stdout } = await execFileAsync('terraform', ['workspace', 'list'], { timeout: 10000, cwd: process.cwd() });
1342
- return stdout.trim().split('\n').map(l => l.replace(/^\*?\s+/, '')).filter(Boolean);
1343
- }
1344
- if (prefix.startsWith('/model ')) {
1345
- const modelsMap = await ctx.router.getAvailableModels();
1346
- return Object.values(modelsMap).flat();
1347
- }
1348
- if (prefix.startsWith('/profile ')) {
1349
- const { listProfiles } = await import('../../config/profiles');
1350
- return listProfiles();
1351
- }
1352
- } catch { /* non-critical */ }
1353
- return [];
1354
- },
1355
- onReady: imperativeApi => {
1356
- api = imperativeApi;
1357
- // GAP-2: Fire background LLM connectivity check after API is ready
1358
- api.setLLMHealth('checking');
1359
- (async () => {
1360
- try {
1361
- const providers = await ctx.router.getAvailableProviders();
1362
- if (providers.length > 0) {
1363
- api!.setLLMHealth('ok');
1364
- } else {
1365
- api!.setLLMHealth('error');
1366
- }
1367
- } catch {
1368
- api!.setLLMHealth('error');
1369
- }
1370
- })();
1371
- },
1372
- };
1373
-
1374
- // Render the Ink application wrapped in an error boundary
1375
- const inkInstance = render(
1376
- React.createElement(AppErrorBoundary, null, React.createElement(App, { ...appProps, columns: process.stdout.columns ?? 80 }))
1377
- );
1378
- const { waitUntilExit } = inkInstance;
1379
-
1380
- // C1: Re-render on terminal resize so Ink layout reflows correctly
1381
- const handleResize = () => {
1382
- try {
1383
- inkInstance.rerender(
1384
- React.createElement(AppErrorBoundary, null, React.createElement(App, { ...appProps, columns: process.stdout.columns ?? 80 }))
1385
- );
1386
- } catch { /* non-critical */ }
1387
- };
1388
- process.stdout.on('resize', handleResize);
1389
- process.on('SIGWINCH', handleResize);
1390
-
1391
- // Gap 16: Periodic cloud auth status check every 15 minutes
1392
- const authCheckInterval = setInterval(async () => {
1393
- try {
1394
- const { execFile } = await import('node:child_process');
1395
- const { promisify } = await import('node:util');
1396
- const execFileAsync = promisify(execFile);
1397
- const expired: string[] = [];
1398
-
1399
- // Check AWS
1400
- try {
1401
- await execFileAsync('aws', ['sts', 'get-caller-identity'], { timeout: 5000 });
1402
- } catch {
1403
- expired.push('AWS');
1404
- }
1405
-
1406
- if (expired.length > 0) {
1407
- addMessage({
1408
- id: crypto.randomUUID(),
1409
- role: 'system',
1410
- content: `Cloud credentials may have expired: ${expired.join(', ')}. Run /auth-refresh to renew.`,
1411
- timestamp: new Date(),
1412
- });
1413
- }
1414
- } catch { /* non-critical */ }
1415
- }, 15 * 60 * 1000);
1416
-
1417
- // When the TUI exits, clean up watcher, LSP servers, and mark session as completed
1418
- process.on('exit', () => {
1419
- clearInterval(authCheckInterval);
1420
- watcher.stop();
1421
- lspManager.stopAll();
1422
- if (sessionManager && sessionId) {
1423
- try {
1424
- sessionManager.complete(sessionId);
1425
- } catch {
1426
- /* ignore */
1427
- }
1428
- }
1429
- // H1: Persist final infra context on exit so next session starts with it
1430
- if (currentInfraContext) {
1431
- try {
1432
- writeFileSync(infraStatePath, JSON.stringify(currentInfraContext, null, 2), 'utf-8');
1433
- } catch { /* non-critical */ }
1434
- }
1435
- });
1436
-
1437
- // Keep the process alive until the user exits (Ctrl+C twice, or exit())
1438
- await waitUntilExit();
1439
-
1440
- // A7: Session saved hint on exit
1441
- if (sessionId && process.stderr.isTTY) {
1442
- process.stderr.write('\n\x1b[2mSession saved. Resume with: nimbus chat --continue\x1b[0m\n');
1443
- }
1444
- }