@build-astron-co/nimbus 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (469) hide show
  1. package/bin/nimbus +26 -10
  2. package/bin/nimbus.cmd +41 -0
  3. package/bin/nimbus.mjs +70 -0
  4. package/completions/nimbus.bash +38 -0
  5. package/completions/nimbus.fish +48 -0
  6. package/completions/nimbus.zsh +81 -0
  7. package/dist/src/agent/compaction-agent.js +215 -0
  8. package/dist/src/agent/context-manager.js +385 -0
  9. package/dist/src/agent/context.js +322 -0
  10. package/dist/src/agent/deploy-preview.js +395 -0
  11. package/dist/src/agent/expand-files.js +95 -0
  12. package/dist/src/agent/index.js +18 -0
  13. package/dist/src/agent/loop.js +1535 -0
  14. package/dist/src/agent/modes.js +347 -0
  15. package/dist/src/agent/permissions.js +396 -0
  16. package/dist/src/agent/subagents/base.js +67 -0
  17. package/dist/src/agent/subagents/cost.js +45 -0
  18. package/dist/src/agent/subagents/explore.js +36 -0
  19. package/dist/src/agent/subagents/general.js +41 -0
  20. package/dist/src/agent/subagents/index.js +88 -0
  21. package/dist/src/agent/subagents/infra.js +52 -0
  22. package/dist/src/agent/subagents/security.js +60 -0
  23. package/dist/src/agent/system-prompt.js +860 -0
  24. package/dist/src/app.js +152 -0
  25. package/dist/src/audit/activity-log.js +209 -0
  26. package/dist/src/audit/compliance-checker.js +419 -0
  27. package/dist/src/audit/cost-tracker.js +231 -0
  28. package/dist/src/audit/index.js +10 -0
  29. package/dist/src/audit/security-scanner.js +490 -0
  30. package/dist/src/auth/guard.js +64 -0
  31. package/dist/src/auth/index.js +19 -0
  32. package/dist/src/auth/keychain.js +79 -0
  33. package/dist/src/auth/oauth.js +389 -0
  34. package/dist/src/auth/providers.js +415 -0
  35. package/dist/src/auth/sso.js +87 -0
  36. package/dist/src/auth/store.js +424 -0
  37. package/dist/src/auth/types.js +5 -0
  38. package/dist/src/cli/index.js +8 -0
  39. package/dist/src/cli/init.js +1048 -0
  40. package/dist/src/cli/openapi-spec.js +346 -0
  41. package/dist/src/cli/run.js +505 -0
  42. package/dist/src/cli/serve-auth.js +56 -0
  43. package/dist/src/cli/serve.js +432 -0
  44. package/dist/src/cli/web.js +50 -0
  45. package/dist/src/cli.js +1574 -0
  46. package/dist/src/clients/core-engine-client.js +156 -0
  47. package/dist/src/clients/enterprise-client.js +246 -0
  48. package/dist/src/clients/generator-client.js +219 -0
  49. package/dist/src/clients/git-client.js +367 -0
  50. package/dist/src/clients/github-client.js +229 -0
  51. package/dist/src/clients/helm-client.js +299 -0
  52. package/dist/src/clients/index.js +18 -0
  53. package/dist/src/clients/k8s-client.js +270 -0
  54. package/dist/src/clients/llm-client.js +119 -0
  55. package/dist/src/clients/rest-client.js +104 -0
  56. package/dist/src/clients/service-discovery.js +35 -0
  57. package/dist/src/clients/terraform-client.js +302 -0
  58. package/dist/src/clients/tools-client.js +1227 -0
  59. package/dist/src/clients/ws-client.js +93 -0
  60. package/dist/src/commands/alias.js +91 -0
  61. package/dist/src/commands/analyze/index.js +313 -0
  62. package/dist/src/commands/apply/helm.js +375 -0
  63. package/dist/src/commands/apply/index.js +176 -0
  64. package/dist/src/commands/apply/k8s.js +350 -0
  65. package/dist/src/commands/apply/terraform.js +465 -0
  66. package/dist/src/commands/ask.js +137 -0
  67. package/dist/src/commands/audit/index.js +322 -0
  68. package/dist/src/commands/auth-cloud.js +345 -0
  69. package/dist/src/commands/auth-list.js +112 -0
  70. package/dist/src/commands/auth-profile.js +104 -0
  71. package/dist/src/commands/auth-refresh.js +161 -0
  72. package/dist/src/commands/auth-status.js +122 -0
  73. package/dist/src/commands/aws/ec2.js +402 -0
  74. package/dist/src/commands/aws/iam.js +304 -0
  75. package/dist/src/commands/aws/index.js +108 -0
  76. package/dist/src/commands/aws/lambda.js +317 -0
  77. package/dist/src/commands/aws/rds.js +345 -0
  78. package/dist/src/commands/aws/s3.js +346 -0
  79. package/dist/src/commands/aws/vpc.js +302 -0
  80. package/dist/src/commands/aws-discover.js +413 -0
  81. package/dist/src/commands/aws-terraform.js +618 -0
  82. package/dist/src/commands/azure/aks.js +305 -0
  83. package/dist/src/commands/azure/functions.js +200 -0
  84. package/dist/src/commands/azure/index.js +93 -0
  85. package/dist/src/commands/azure/storage.js +378 -0
  86. package/dist/src/commands/azure/vm.js +291 -0
  87. package/dist/src/commands/billing/index.js +224 -0
  88. package/dist/src/commands/chat.js +259 -0
  89. package/dist/src/commands/completions.js +255 -0
  90. package/dist/src/commands/config.js +291 -0
  91. package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
  92. package/dist/src/commands/cost/estimator.js +73 -0
  93. package/dist/src/commands/cost/index.js +625 -0
  94. package/dist/src/commands/cost/parsers/terraform.js +234 -0
  95. package/dist/src/commands/cost/parsers/types.js +4 -0
  96. package/dist/src/commands/cost/pricing/aws.js +501 -0
  97. package/dist/src/commands/cost/pricing/azure.js +462 -0
  98. package/dist/src/commands/cost/pricing/gcp.js +359 -0
  99. package/dist/src/commands/cost/pricing/index.js +24 -0
  100. package/dist/src/commands/demo.js +196 -0
  101. package/dist/src/commands/deploy.js +215 -0
  102. package/dist/src/commands/doctor.js +1291 -0
  103. package/dist/src/commands/drift/index.js +674 -0
  104. package/dist/src/commands/explain.js +235 -0
  105. package/dist/src/commands/export.js +120 -0
  106. package/dist/src/commands/feedback.js +319 -0
  107. package/dist/src/commands/fix.js +263 -0
  108. package/dist/src/commands/fs/index.js +338 -0
  109. package/dist/src/commands/gcp/compute.js +266 -0
  110. package/dist/src/commands/gcp/functions.js +221 -0
  111. package/dist/src/commands/gcp/gke.js +357 -0
  112. package/dist/src/commands/gcp/iam.js +295 -0
  113. package/dist/src/commands/gcp/index.js +105 -0
  114. package/dist/src/commands/gcp/storage.js +232 -0
  115. package/dist/src/commands/generate-helm.js +1026 -0
  116. package/dist/src/commands/generate-k8s.js +1263 -0
  117. package/dist/src/commands/generate-terraform.js +1058 -0
  118. package/dist/src/commands/gh/index.js +663 -0
  119. package/dist/src/commands/git/index.js +1208 -0
  120. package/dist/src/commands/helm/index.js +985 -0
  121. package/dist/src/commands/help.js +639 -0
  122. package/dist/src/commands/history.js +120 -0
  123. package/dist/src/commands/import.js +782 -0
  124. package/dist/src/commands/incident.js +144 -0
  125. package/dist/src/commands/index.js +109 -0
  126. package/dist/src/commands/init.js +955 -0
  127. package/dist/src/commands/k8s/index.js +979 -0
  128. package/dist/src/commands/login.js +588 -0
  129. package/dist/src/commands/logout.js +61 -0
  130. package/dist/src/commands/logs.js +160 -0
  131. package/dist/src/commands/onboarding.js +382 -0
  132. package/dist/src/commands/pipeline.js +153 -0
  133. package/dist/src/commands/plan/display.js +216 -0
  134. package/dist/src/commands/plan/index.js +525 -0
  135. package/dist/src/commands/plugin.js +325 -0
  136. package/dist/src/commands/preview.js +356 -0
  137. package/dist/src/commands/profile.js +297 -0
  138. package/dist/src/commands/questionnaire.js +1021 -0
  139. package/dist/src/commands/resume.js +35 -0
  140. package/dist/src/commands/rollback.js +259 -0
  141. package/dist/src/commands/rollout.js +74 -0
  142. package/dist/src/commands/runbook.js +307 -0
  143. package/dist/src/commands/schedule.js +202 -0
  144. package/dist/src/commands/status.js +213 -0
  145. package/dist/src/commands/team/index.js +309 -0
  146. package/dist/src/commands/team-context.js +200 -0
  147. package/dist/src/commands/template.js +204 -0
  148. package/dist/src/commands/tf/index.js +989 -0
  149. package/dist/src/commands/upgrade.js +515 -0
  150. package/dist/src/commands/usage/index.js +118 -0
  151. package/dist/src/commands/version.js +145 -0
  152. package/dist/src/commands/watch.js +127 -0
  153. package/dist/src/compat/index.js +2 -0
  154. package/dist/src/compat/runtime.js +10 -0
  155. package/dist/src/compat/sqlite.js +144 -0
  156. package/dist/src/config/index.js +6 -0
  157. package/dist/src/config/manager.js +469 -0
  158. package/dist/src/config/mode-store.js +57 -0
  159. package/dist/src/config/profiles.js +66 -0
  160. package/dist/src/config/safety-policy.js +251 -0
  161. package/dist/src/config/schema.js +107 -0
  162. package/dist/src/config/types.js +311 -0
  163. package/dist/src/config/workspace-state.js +38 -0
  164. package/dist/src/context/context-db.js +138 -0
  165. package/dist/src/demo/index.js +295 -0
  166. package/dist/src/demo/scenarios/full-journey.js +226 -0
  167. package/dist/src/demo/scenarios/getting-started.js +124 -0
  168. package/dist/src/demo/scenarios/helm-release.js +334 -0
  169. package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
  170. package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
  171. package/dist/src/demo/types.js +6 -0
  172. package/dist/src/engine/cost-estimator.js +334 -0
  173. package/dist/src/engine/diagram-generator.js +192 -0
  174. package/dist/src/engine/drift-detector.js +688 -0
  175. package/dist/src/engine/executor.js +832 -0
  176. package/dist/src/engine/index.js +39 -0
  177. package/dist/src/engine/orchestrator.js +436 -0
  178. package/dist/src/engine/planner.js +616 -0
  179. package/dist/src/engine/safety.js +609 -0
  180. package/dist/src/engine/verifier.js +664 -0
  181. package/dist/src/enterprise/audit.js +241 -0
  182. package/dist/src/enterprise/auth.js +189 -0
  183. package/dist/src/enterprise/billing.js +512 -0
  184. package/dist/src/enterprise/index.js +16 -0
  185. package/dist/src/enterprise/teams.js +315 -0
  186. package/dist/src/generator/best-practices.js +1375 -0
  187. package/dist/src/generator/helm.js +495 -0
  188. package/dist/src/generator/index.js +11 -0
  189. package/dist/src/generator/intent-parser.js +420 -0
  190. package/dist/src/generator/kubernetes.js +773 -0
  191. package/dist/src/generator/terraform.js +1472 -0
  192. package/dist/src/history/index.js +6 -0
  193. package/dist/src/history/manager.js +199 -0
  194. package/dist/src/history/types.js +6 -0
  195. package/dist/src/hooks/config.js +318 -0
  196. package/dist/src/hooks/engine.js +317 -0
  197. package/dist/src/hooks/index.js +2 -0
  198. package/dist/src/llm/auth-bridge.js +157 -0
  199. package/dist/src/llm/circuit-breaker.js +116 -0
  200. package/dist/src/llm/config-loader.js +172 -0
  201. package/dist/src/llm/cost-calculator.js +137 -0
  202. package/dist/src/llm/index.js +7 -0
  203. package/dist/src/llm/model-aliases.js +99 -0
  204. package/dist/src/llm/provider-registry.js +57 -0
  205. package/dist/src/llm/providers/anthropic.js +430 -0
  206. package/dist/src/llm/providers/bedrock.js +409 -0
  207. package/dist/src/llm/providers/google.js +344 -0
  208. package/dist/src/llm/providers/ollama.js +661 -0
  209. package/dist/src/llm/providers/openai-compatible.js +289 -0
  210. package/dist/src/llm/providers/openai.js +284 -0
  211. package/dist/src/llm/providers/openrouter.js +293 -0
  212. package/dist/src/llm/router.js +844 -0
  213. package/dist/src/llm/types.js +69 -0
  214. package/dist/src/lsp/client.js +239 -0
  215. package/dist/src/lsp/languages.js +95 -0
  216. package/dist/src/lsp/manager.js +243 -0
  217. package/dist/src/mcp/client.js +289 -0
  218. package/dist/src/mcp/index.js +5 -0
  219. package/dist/src/mcp/manager.js +113 -0
  220. package/dist/src/nimbus.js +212 -0
  221. package/dist/src/plugins/index.js +13 -0
  222. package/dist/src/plugins/loader.js +280 -0
  223. package/dist/src/plugins/manager.js +282 -0
  224. package/dist/src/plugins/types.js +23 -0
  225. package/dist/src/scanners/cicd-scanner.js +230 -0
  226. package/dist/src/scanners/cloud-scanner.js +415 -0
  227. package/dist/src/scanners/framework-scanner.js +430 -0
  228. package/dist/src/scanners/iac-scanner.js +350 -0
  229. package/dist/src/scanners/index.js +454 -0
  230. package/dist/src/scanners/language-scanner.js +258 -0
  231. package/dist/src/scanners/package-manager-scanner.js +252 -0
  232. package/dist/src/scanners/types.js +6 -0
  233. package/dist/src/sessions/manager.js +395 -0
  234. package/dist/src/sessions/types.js +4 -0
  235. package/dist/src/sharing/sync.js +238 -0
  236. package/dist/src/sharing/viewer.js +131 -0
  237. package/dist/src/snapshots/index.js +1 -0
  238. package/dist/src/snapshots/manager.js +432 -0
  239. package/dist/src/state/artifacts.js +94 -0
  240. package/dist/src/state/audit.js +73 -0
  241. package/dist/src/state/billing.js +126 -0
  242. package/dist/src/state/checkpoints.js +81 -0
  243. package/dist/src/state/config.js +58 -0
  244. package/dist/src/state/conversations.js +7 -0
  245. package/dist/src/state/credentials.js +96 -0
  246. package/dist/src/state/db.js +53 -0
  247. package/dist/src/state/index.js +23 -0
  248. package/dist/src/state/messages.js +76 -0
  249. package/dist/src/state/projects.js +92 -0
  250. package/dist/src/state/schema.js +233 -0
  251. package/dist/src/state/sessions.js +79 -0
  252. package/dist/src/state/teams.js +131 -0
  253. package/dist/src/telemetry.js +91 -0
  254. package/dist/src/tools/aws-ops.js +747 -0
  255. package/dist/src/tools/azure-ops.js +491 -0
  256. package/dist/src/tools/file-ops.js +451 -0
  257. package/dist/src/tools/gcp-ops.js +559 -0
  258. package/dist/src/tools/git-ops.js +557 -0
  259. package/dist/src/tools/github-ops.js +460 -0
  260. package/dist/src/tools/helm-ops.js +634 -0
  261. package/dist/src/tools/index.js +16 -0
  262. package/dist/src/tools/k8s-ops.js +579 -0
  263. package/dist/src/tools/schemas/converter.js +129 -0
  264. package/dist/src/tools/schemas/devops.js +3319 -0
  265. package/dist/src/tools/schemas/index.js +19 -0
  266. package/dist/src/tools/schemas/standard.js +966 -0
  267. package/dist/src/tools/schemas/types.js +409 -0
  268. package/dist/src/tools/spawn-exec.js +109 -0
  269. package/dist/src/tools/terraform-ops.js +627 -0
  270. package/dist/src/types/config.js +1 -0
  271. package/dist/src/types/drift.js +4 -0
  272. package/dist/src/types/enterprise.js +5 -0
  273. package/dist/src/types/index.js +14 -0
  274. package/dist/src/types/plan.js +1 -0
  275. package/dist/src/types/request.js +1 -0
  276. package/dist/src/types/response.js +1 -0
  277. package/dist/src/types/service.js +1 -0
  278. package/dist/src/ui/App.js +1672 -0
  279. package/dist/src/ui/DeployPreview.js +60 -0
  280. package/dist/src/ui/FileDiffModal.js +108 -0
  281. package/dist/src/ui/Header.js +46 -0
  282. package/dist/src/ui/HelpModal.js +9 -0
  283. package/dist/src/ui/InputBox.js +408 -0
  284. package/dist/src/ui/MessageList.js +795 -0
  285. package/dist/src/ui/PermissionPrompt.js +72 -0
  286. package/dist/src/ui/StatusBar.js +109 -0
  287. package/dist/src/ui/TerminalPane.js +31 -0
  288. package/dist/src/ui/ToolCallDisplay.js +303 -0
  289. package/dist/src/ui/TreePane.js +83 -0
  290. package/dist/src/ui/chat-ui.js +721 -0
  291. package/dist/src/ui/index.js +11 -0
  292. package/dist/src/ui/ink/index.js +1325 -0
  293. package/dist/src/ui/streaming.js +137 -0
  294. package/dist/src/ui/theme.js +78 -0
  295. package/dist/src/ui/types.js +7 -0
  296. package/dist/src/utils/analytics.js +61 -0
  297. package/dist/src/utils/cost-warning.js +25 -0
  298. package/dist/src/utils/env.js +42 -0
  299. package/dist/src/utils/errors.js +54 -0
  300. package/dist/src/utils/event-bus.js +22 -0
  301. package/dist/src/utils/index.js +16 -0
  302. package/dist/src/utils/logger.js +150 -0
  303. package/dist/src/utils/rate-limiter.js +90 -0
  304. package/dist/src/utils/service-auth.js +36 -0
  305. package/dist/src/utils/validation.js +39 -0
  306. package/dist/src/version.js +3 -0
  307. package/dist/src/watcher/index.js +192 -0
  308. package/dist/src/wizard/approval.js +275 -0
  309. package/dist/src/wizard/index.js +13 -0
  310. package/dist/src/wizard/prompts.js +273 -0
  311. package/dist/src/wizard/types.js +4 -0
  312. package/dist/src/wizard/ui.js +453 -0
  313. package/dist/src/wizard/wizard.js +227 -0
  314. package/package.json +31 -23
  315. package/src/__tests__/alias.test.ts +133 -0
  316. package/src/__tests__/app.test.ts +1 -1
  317. package/src/__tests__/audit.test.ts +1 -1
  318. package/src/__tests__/circuit-breaker.test.ts +1 -1
  319. package/src/__tests__/cli-run.test.ts +237 -1
  320. package/src/__tests__/compat-sqlite.test.ts +68 -0
  321. package/src/__tests__/context-manager.test.ts +131 -1
  322. package/src/__tests__/context.test.ts +1 -1
  323. package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
  324. package/src/__tests__/doctor.test.ts +48 -0
  325. package/src/__tests__/enterprise.test.ts +1 -1
  326. package/src/__tests__/export.test.ts +236 -0
  327. package/src/__tests__/gap-11-18-20.test.ts +958 -0
  328. package/src/__tests__/generator.test.ts +1 -1
  329. package/src/__tests__/helm-streaming.test.ts +127 -0
  330. package/src/__tests__/hooks.test.ts +1 -1
  331. package/src/__tests__/incident.test.ts +179 -0
  332. package/src/__tests__/init.test.ts +55 -4
  333. package/src/__tests__/intent-parser.test.ts +1 -1
  334. package/src/__tests__/llm-router.test.ts +1 -1
  335. package/src/__tests__/logs.test.ts +107 -0
  336. package/src/__tests__/loop-errors.test.ts +244 -0
  337. package/src/__tests__/lsp.test.ts +1 -1
  338. package/src/__tests__/modes.test.ts +1 -1
  339. package/src/__tests__/perf-optimizations.test.ts +847 -0
  340. package/src/__tests__/permissions.test.ts +1 -1
  341. package/src/__tests__/pipeline.test.ts +50 -0
  342. package/src/__tests__/polish-phase3.test.ts +340 -0
  343. package/src/__tests__/profile.test.ts +237 -0
  344. package/src/__tests__/rollback.test.ts +83 -0
  345. package/src/__tests__/runbook.test.ts +219 -0
  346. package/src/__tests__/schedule.test.ts +206 -0
  347. package/src/__tests__/serve.test.ts +1 -1
  348. package/src/__tests__/sessions.test.ts +96 -1
  349. package/src/__tests__/sharing.test.ts +53 -1
  350. package/src/__tests__/snapshots.test.ts +1 -1
  351. package/src/__tests__/standalone-migration.test.ts +199 -0
  352. package/src/__tests__/state-db.test.ts +1 -1
  353. package/src/__tests__/status.test.ts +158 -0
  354. package/src/__tests__/stream-with-tools.test.ts +71 -25
  355. package/src/__tests__/subagents.test.ts +1 -1
  356. package/src/__tests__/system-prompt.test.ts +82 -3
  357. package/src/__tests__/terminal-gap-v2.test.ts +395 -0
  358. package/src/__tests__/terminal-parity.test.ts +393 -0
  359. package/src/__tests__/tf-apply.test.ts +187 -0
  360. package/src/__tests__/tool-converter.test.ts +1 -1
  361. package/src/__tests__/tool-schemas.test.ts +209 -4
  362. package/src/__tests__/tools.test.ts +4 -3
  363. package/src/__tests__/version-json.test.ts +184 -0
  364. package/src/__tests__/version.test.ts +1 -1
  365. package/src/__tests__/watch.test.ts +129 -0
  366. package/src/agent/compaction-agent.ts +40 -1
  367. package/src/agent/context-manager.ts +67 -3
  368. package/src/agent/deploy-preview.ts +62 -1
  369. package/src/agent/expand-files.ts +108 -0
  370. package/src/agent/loop.ts +1312 -31
  371. package/src/agent/permissions.ts +51 -4
  372. package/src/agent/system-prompt.ts +573 -19
  373. package/src/app.ts +58 -0
  374. package/src/audit/security-scanner.ts +45 -0
  375. package/src/auth/keychain.ts +82 -0
  376. package/src/auth/oauth.ts +15 -5
  377. package/src/cli/init.ts +378 -5
  378. package/src/cli/run.ts +407 -16
  379. package/src/cli/serve.ts +78 -1
  380. package/src/cli/web.ts +10 -6
  381. package/src/cli.ts +312 -1
  382. package/src/clients/service-discovery.ts +30 -25
  383. package/src/commands/alias.ts +100 -0
  384. package/src/commands/audit/index.ts +121 -2
  385. package/src/commands/auth-cloud.ts +113 -0
  386. package/src/commands/auth-refresh.ts +187 -0
  387. package/src/commands/aws-discover.ts +144 -251
  388. package/src/commands/aws-terraform.ts +68 -118
  389. package/src/commands/chat.ts +9 -3
  390. package/src/commands/completions.ts +268 -0
  391. package/src/commands/config.ts +26 -0
  392. package/src/commands/cost/index.ts +218 -2
  393. package/src/commands/deploy.ts +260 -0
  394. package/src/commands/doctor.ts +744 -152
  395. package/src/commands/drift/index.ts +371 -23
  396. package/src/commands/export.ts +146 -0
  397. package/src/commands/generate-k8s.ts +9 -61
  398. package/src/commands/generate-terraform.ts +191 -449
  399. package/src/commands/help.ts +212 -36
  400. package/src/commands/history.ts +8 -1
  401. package/src/commands/incident.ts +166 -0
  402. package/src/commands/init.ts +5 -0
  403. package/src/commands/login.ts +86 -1
  404. package/src/commands/logs.ts +167 -0
  405. package/src/commands/onboarding.ts +211 -34
  406. package/src/commands/pipeline.ts +186 -0
  407. package/src/commands/plugin.ts +398 -0
  408. package/src/commands/profile.ts +342 -0
  409. package/src/commands/questionnaire.ts +0 -98
  410. package/src/commands/resume.ts +26 -34
  411. package/src/commands/rollback.ts +315 -0
  412. package/src/commands/rollout.ts +88 -0
  413. package/src/commands/runbook.ts +346 -0
  414. package/src/commands/schedule.ts +236 -0
  415. package/src/commands/status.ts +252 -0
  416. package/src/commands/team-context.ts +220 -0
  417. package/src/commands/template.ts +58 -57
  418. package/src/commands/tf/index.ts +70 -11
  419. package/src/commands/upgrade.ts +57 -0
  420. package/src/commands/version.ts +54 -50
  421. package/src/commands/watch.ts +153 -0
  422. package/src/compat/runtime.ts +1 -1
  423. package/src/compat/sqlite.ts +75 -5
  424. package/src/config/mode-store.ts +62 -0
  425. package/src/config/profiles.ts +84 -0
  426. package/src/config/types.ts +83 -1
  427. package/src/config/workspace-state.ts +53 -0
  428. package/src/engine/cost-estimator.ts +52 -10
  429. package/src/engine/executor.ts +33 -2
  430. package/src/engine/planner.ts +68 -1
  431. package/src/generator/terraform.ts +8 -0
  432. package/src/history/manager.ts +2 -74
  433. package/src/hooks/engine.ts +5 -4
  434. package/src/llm/cost-calculator.ts +2 -2
  435. package/src/llm/providers/anthropic.ts +50 -21
  436. package/src/llm/router.ts +76 -7
  437. package/src/lsp/languages.ts +3 -0
  438. package/src/lsp/manager.ts +21 -5
  439. package/src/nimbus.ts +37 -18
  440. package/src/sessions/manager.ts +108 -1
  441. package/src/sharing/sync.ts +4 -0
  442. package/src/sharing/viewer.ts +66 -0
  443. package/src/tools/file-ops.ts +22 -0
  444. package/src/tools/schemas/devops.ts +3007 -117
  445. package/src/tools/schemas/standard.ts +5 -1
  446. package/src/tools/schemas/types.ts +31 -1
  447. package/src/tools/spawn-exec.ts +148 -0
  448. package/src/ui/App.tsx +1183 -66
  449. package/src/ui/DeployPreview.tsx +62 -57
  450. package/src/ui/FileDiffModal.tsx +162 -0
  451. package/src/ui/Header.tsx +87 -24
  452. package/src/ui/HelpModal.tsx +57 -0
  453. package/src/ui/InputBox.tsx +163 -10
  454. package/src/ui/MessageList.tsx +487 -40
  455. package/src/ui/PermissionPrompt.tsx +17 -5
  456. package/src/ui/StatusBar.tsx +122 -3
  457. package/src/ui/TerminalPane.tsx +84 -0
  458. package/src/ui/ToolCallDisplay.tsx +252 -18
  459. package/src/ui/TreePane.tsx +132 -0
  460. package/src/ui/chat-ui.ts +41 -44
  461. package/src/ui/ink/index.ts +771 -38
  462. package/src/ui/streaming.ts +1 -1
  463. package/src/ui/theme.ts +104 -0
  464. package/src/ui/types.ts +18 -0
  465. package/src/version.ts +1 -1
  466. package/src/watcher/index.ts +66 -15
  467. package/src/wizard/types.ts +1 -0
  468. package/src/wizard/ui.ts +1 -1
  469. package/tsconfig.json +2 -2
package/src/ui/App.tsx CHANGED
@@ -18,8 +18,9 @@
18
18
  * Escape - cancel current operation
19
19
  */
20
20
 
21
- import React, { useState, useCallback, useEffect, useRef } from 'react';
21
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
22
22
  import { Box, Text, useInput, useApp } from 'ink';
23
+ import Spinner from 'ink-spinner';
23
24
  import { readFileSync } from 'node:fs';
24
25
  import { resolve } from 'node:path';
25
26
  import type { AgentMode, UIMessage, UIToolCall, SessionInfo, DeployPreviewData } from './types';
@@ -30,6 +31,10 @@ import { InputBox } from './InputBox';
30
31
  import { StatusBar } from './StatusBar';
31
32
  import { PermissionPrompt, type PermissionDecision, type RiskLevel } from './PermissionPrompt';
32
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';
33
38
 
34
39
  /* ---------------------------------------------------------------------------
35
40
  * Internal types
@@ -92,6 +97,10 @@ export interface SessionSummary {
92
97
  model: string;
93
98
  mode: string;
94
99
  updatedAt: string;
100
+ /** Token count for this session (L9). */
101
+ tokenCount?: number;
102
+ /** Cost in USD for this session (L9). */
103
+ costUSD?: number;
95
104
  }
96
105
 
97
106
  /** Callback invoked when the user types /sessions. */
@@ -115,6 +124,21 @@ export type OnModelChangeCallback = (model: string) => void;
115
124
  /** Callback invoked when the user changes the mode via /mode or Tab. */
116
125
  export type OnModeChangeCallback = (mode: AgentMode) => void;
117
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
+
118
142
  /* ---------------------------------------------------------------------------
119
143
  * Props
120
144
  * -------------------------------------------------------------------------*/
@@ -149,10 +173,28 @@ export interface AppProps {
149
173
  onModelChange?: OnModelChangeCallback;
150
174
  /** Handler for mode changes (Tab or /mode). Propagates to the agent loop. */
151
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;
152
186
  /** Called once after mount, passing imperative handles for driving TUI state. */
153
187
  onReady?: (api: AppImperativeAPI) => void;
154
188
  /** Messages to pre-populate the message list (e.g., from a resumed session). */
155
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;
156
198
  }
157
199
 
158
200
  /* ---------------------------------------------------------------------------
@@ -166,6 +208,25 @@ function nextMode(current: AgentMode): AgentMode {
166
208
  return MODES[(idx + 1) % MODES.length];
167
209
  }
168
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
+
169
230
  /* ---------------------------------------------------------------------------
170
231
  * Default session factory
171
232
  * -------------------------------------------------------------------------*/
@@ -174,7 +235,7 @@ function createDefaultSession(overrides?: Partial<SessionInfo>): SessionInfo {
174
235
  return {
175
236
  id: overrides?.id ?? crypto.randomUUID(),
176
237
  model: overrides?.model ?? 'default',
177
- mode: overrides?.mode ?? 'plan',
238
+ mode: overrides?.mode ?? 'build',
178
239
  tokenCount: overrides?.tokenCount ?? 0,
179
240
  maxTokens: overrides?.maxTokens ?? 200_000,
180
241
  costUSD: overrides?.costUSD ?? 0,
@@ -208,14 +269,23 @@ export function App({
208
269
  onClear,
209
270
  onModelChange,
210
271
  onModeChange,
272
+ onDiff,
273
+ onCost,
274
+ onInit,
275
+ onExport,
276
+ onRemember,
211
277
  onReady,
212
278
  initialMessages,
279
+ initialMode,
280
+ hasApiKey = true,
281
+ onFetchCompletions,
282
+ columns = 80,
213
283
  }: AppProps) {
214
284
  const { exit } = useApp();
215
285
 
216
286
  /* -- State ------------------------------------------------------------- */
217
287
 
218
- const [session, setSession] = useState(createDefaultSession(initialSession) as SessionInfo);
288
+ const [session, setSession] = useState(createDefaultSession({ ...initialSession, mode: initialMode ?? initialSession?.mode ?? 'build' }) as SessionInfo);
219
289
 
220
290
  const [messages, setMessages] = useState((initialMessages ?? []) as UIMessage[]);
221
291
 
@@ -223,10 +293,60 @@ export function App({
223
293
 
224
294
  const [permissionRequest, setPermissionRequest] = useState(null as PermissionRequest | null);
225
295
 
226
- const [deployPreview, setDeployPreview] = useState(null as DeployPreviewData | null);
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);
227
307
 
228
308
  const [isProcessing, setIsProcessing] = useState(false as boolean);
309
+ const [abortPending, setAbortPending] = useState(false as boolean);
229
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);
230
350
 
231
351
  /* -- Expose imperative API to external orchestrator -------------------- */
232
352
 
@@ -236,28 +356,138 @@ export function App({
236
356
  if (onReady && !onReadyCalled.current) {
237
357
  onReadyCalled.current = true;
238
358
  onReady({
239
- addMessage: (msg: UIMessage) => setMessages(prev => [...prev, msg]),
240
- updateMessage: (id: string, content: string) =>
241
- setMessages(prev => prev.map(m => (m.id === id ? { ...m, content } : m))),
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
+ },
242
368
  updateSession: (patch: Partial<SessionInfo>) => setSession(prev => ({ ...prev, ...patch })),
243
- setToolCalls: setActiveToolCalls,
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
+ },
244
398
  requestPermission: (req: PermissionRequest) => setPermissionRequest(req),
245
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 }),
246
409
  setProcessing: (v: boolean) => {
247
410
  setIsProcessing(v);
248
411
  setProcessingStartTime(v ? Date.now() : null);
249
412
  },
413
+ setLLMHealth: (health: 'checking' | 'ok' | 'error') => {
414
+ setSession(prev => ({ ...prev, llmHealth: health }));
415
+ },
250
416
  });
251
417
  }
252
418
  }, [onReady]);
253
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
+
254
435
  /* -- Callbacks --------------------------------------------------------- */
255
436
 
256
437
  /** Handle user message submission from the InputBox. */
257
438
  const handleSubmit = useCallback(
258
439
  (text: string) => {
440
+ // C3: Dismiss the API key setup banner on first message submission
441
+ setShowApiKeySetup(false);
442
+
259
443
  const trimmed = text.trim();
260
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
+
261
491
  // -----------------------------------------------------------------
262
492
  // Slash command handling
263
493
  // -----------------------------------------------------------------
@@ -314,6 +544,34 @@ export function App({
314
544
  return;
315
545
  }
316
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
+
317
575
  // /undo — revert the last file-modifying tool call
318
576
  if (trimmed === '/undo') {
319
577
  if (onUndo) {
@@ -406,38 +664,9 @@ export function App({
406
664
  return;
407
665
  }
408
666
 
409
- // /help — show available slash commands
667
+ // /help — show dismissable help modal overlay (does not pollute chat history)
410
668
  if (trimmed === '/help') {
411
- const helpContent = [
412
- 'Available commands:',
413
- ' /help — Show this help message',
414
- ' /clear — Clear conversation history',
415
- ' /compact [focus] — Compress context to free tokens',
416
- ' /context — Show context window usage',
417
- ' /model [name] — Show or switch the active model',
418
- ' /models — List all available provider models',
419
- ' /undo — Revert the last file change',
420
- ' /redo — Re-apply a reverted change',
421
- ' /sessions — List active sessions',
422
- ' /new [name] — Create a new session',
423
- ' /switch <id> — Switch to a different session',
424
- '',
425
- 'Keyboard shortcuts:',
426
- ' Tab — Cycle mode (plan → build → deploy)',
427
- ' Ctrl+R — Search input history',
428
- ' Ctrl+C — Interrupt or exit',
429
- ' Escape — Cancel current operation',
430
- '',
431
- 'Prefix a path with @ to include file contents (e.g. @src/main.ts)',
432
- ].join('\n');
433
-
434
- const msg: UIMessage = {
435
- id: crypto.randomUUID(),
436
- role: 'system',
437
- content: helpContent,
438
- timestamp: new Date(),
439
- };
440
- setMessages(prev => [...prev, msg]);
669
+ setShowHelp(true);
441
670
  return;
442
671
  }
443
672
 
@@ -476,10 +705,19 @@ export function App({
476
705
  };
477
706
  setMessages(prev => [...prev, msg]);
478
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 */ }
479
717
  const msg: UIMessage = {
480
718
  id: crypto.randomUUID(),
481
719
  role: 'system',
482
- content: `Current model: ${session.model}\n\nUsage: /model <name> (e.g. /model sonnet, /model gpt4o, /model gemini)`,
720
+ content: `Current model: ${session.model}${providerInfo || '\n\nUsage: /model <name> (e.g. /model sonnet, /model gpt4o, /model gemini)'}`,
483
721
  timestamp: new Date(),
484
722
  };
485
723
  setMessages(prev => [...prev, msg]);
@@ -497,10 +735,20 @@ export function App({
497
735
  if (newMode) {
498
736
  const validModes: AgentMode[] = ['plan', 'build', 'deploy'];
499
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
+ }
500
743
  setSession(prev => ({ ...prev, mode: newMode as AgentMode }));
501
744
  if (onModeChange) {
502
745
  onModeChange(newMode as AgentMode);
503
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 */ }
504
752
  const msg: UIMessage = {
505
753
  id: crypto.randomUUID(),
506
754
  role: 'system',
@@ -508,6 +756,20 @@ export function App({
508
756
  timestamp: new Date(),
509
757
  };
510
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
+ }
511
773
  } else {
512
774
  const msg: UIMessage = {
513
775
  id: crypto.randomUUID(),
@@ -656,7 +918,8 @@ export function App({
656
918
  for (const [provider, modelList] of Object.entries(modelsMap)) {
657
919
  lines.push(`\n ${provider}:`);
658
920
  for (const model of modelList) {
659
- lines.push(` - ${model}`);
921
+ const isActive = model === session.model;
922
+ lines.push(` ${isActive ? '[OK]' : ' '} ${model}`);
660
923
  }
661
924
  }
662
925
  if (lines.length === 1) {
@@ -701,13 +964,19 @@ export function App({
701
964
  const breakdown = onContext();
702
965
  const content = breakdown
703
966
  ? [
704
- 'Context Usage Breakdown:',
705
- ` System prompt: ${breakdown.systemPrompt.toLocaleString()} tokens`,
706
- ` NIMBUS.md: ${breakdown.nimbusInstructions.toLocaleString()} tokens`,
707
- ` Messages: ${breakdown.messages.toLocaleString()} tokens`,
708
- ` Tool definitions: ${breakdown.toolDefinitions.toLocaleString()} tokens`,
709
- ` ─────────────────────────────`,
710
- ` Total: ${breakdown.total.toLocaleString()} / ${breakdown.budget.toLocaleString()} tokens (${breakdown.usagePercent}%)`,
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}%)`,
711
980
  ].join('\n')
712
981
  : 'Context information is not available.';
713
982
 
@@ -730,6 +999,606 @@ export function App({
730
999
  return;
731
1000
  }
732
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
+
733
1602
  // -----------------------------------------------------------------
734
1603
  // Normal message — expand @file references, then send to agent
735
1604
  // -----------------------------------------------------------------
@@ -744,13 +1613,15 @@ export function App({
744
1613
  try {
745
1614
  const resolved = resolve(process.cwd(), filePath);
746
1615
  const content = readFileSync(resolved, 'utf-8');
1616
+ // GAP-6: 100KB cap (up from 10KB)
747
1617
  const truncated =
748
- content.length > 10000
749
- ? `${content.slice(0, 10000)}\n... (truncated — showing 10,000 of ${content.length.toLocaleString()} chars)`
1618
+ content.length > 100_000
1619
+ ? `${content.slice(0, 100_000)}\n... (truncated — showing 100,000 of ${content.length.toLocaleString()} chars)`
750
1620
  : content;
1621
+ const ext = filePath.split('.').pop() ?? '';
751
1622
  expandedText = expandedText.replace(
752
1623
  ref,
753
- `\n<file path="${filePath}">\n${truncated}\n</file>`
1624
+ `\n\`\`\`${ext}\n// File: ${filePath}\n${truncated}\n\`\`\``
754
1625
  );
755
1626
  } catch {
756
1627
  // File not found — leave the @reference as-is
@@ -766,7 +1637,9 @@ export function App({
766
1637
  timestamp: new Date(),
767
1638
  };
768
1639
  setMessages(prev => [...prev, userMsg]);
1640
+ setInputPrefill(undefined); // GAP-21: clear prefill after submit
769
1641
  setIsProcessing(true);
1642
+ setCurrentTurnHasOutput(false);
770
1643
  setProcessingStartTime(Date.now());
771
1644
 
772
1645
  if (onMessage) {
@@ -786,9 +1659,15 @@ export function App({
786
1659
  onClear,
787
1660
  onModelChange,
788
1661
  onModeChange,
1662
+ onDiff,
1663
+ onCost,
1664
+ onInit,
789
1665
  session.id,
790
1666
  session.model,
791
1667
  session.mode,
1668
+ pendingContextSelect,
1669
+ pendingWorkspaceSelect,
1670
+ messages,
792
1671
  ]
793
1672
  );
794
1673
 
@@ -813,11 +1692,23 @@ export function App({
813
1692
  );
814
1693
 
815
1694
  /** Handle deploy preview decisions. */
816
- const handleDeployDecision = useCallback((_decision: DeployDecision) => {
817
- // The parent orchestrator handles the actual decision; we just
818
- // close the overlay here.
1695
+ const handleDeployDecision = useCallback((decision: DeployDecision) => {
1696
+ if (deployPreview?.onDecide) {
1697
+ deployPreview.onDecide(decision);
1698
+ }
819
1699
  setDeployPreview(null);
820
- }, []);
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
+ );
821
1712
 
822
1713
  /* -- Global keyboard shortcuts ----------------------------------------- */
823
1714
 
@@ -825,15 +1716,30 @@ export function App({
825
1716
  (input, key) => {
826
1717
  // Tab: cycle modes (only when not in a modal and not typing a slash command)
827
1718
  // When input starts with '/', Tab is handled by InputBox for autocomplete
828
- if (key.tab && !permissionRequest && !deployPreview) {
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
+ }
829
1728
  setSession(prev => {
830
- const newMode = nextMode(prev.mode);
831
1729
  // Propagate mode change to the agent loop so it actually takes effect
832
1730
  if (onModeChange) {
833
1731
  onModeChange(newMode);
834
1732
  }
835
1733
  return { ...prev, mode: newMode };
836
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 */ }
837
1743
  return;
838
1744
  }
839
1745
 
@@ -841,6 +1747,11 @@ export function App({
841
1747
  if (input === 'c' && key.ctrl) {
842
1748
  if (isProcessing) {
843
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();
844
1755
  } else {
845
1756
  exit();
846
1757
  }
@@ -853,21 +1764,144 @@ export function App({
853
1764
  handlePermission('reject');
854
1765
  } else if (deployPreview) {
855
1766
  handleDeployDecision('reject');
1767
+ } else if (fileDiffRequest) {
1768
+ handleFileDiffDecision('reject');
856
1769
  } else if (isProcessing) {
857
1770
  handleAbort();
858
1771
  }
859
1772
  }
860
1773
  },
861
- // Disable the global handler when the permission or deploy prompt is
862
- // active so their own useInput handlers take priority.
863
- { isActive: !permissionRequest && !deployPreview }
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 }
864
1891
  );
865
1892
 
866
1893
  /* -- Derived state ----------------------------------------------------- */
867
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
+
868
1901
  // Collect tool calls from the last assistant message (if any) plus any
869
1902
  // currently active tool calls being streamed in.
870
- const visibleToolCalls: UIToolCall[] = (() => {
1903
+ // useMemo avoids the O(n) backwards scan on every React render.
1904
+ const visibleToolCalls: UIToolCall[] = useMemo(() => {
871
1905
  if (activeToolCalls.length > 0) {
872
1906
  return activeToolCalls;
873
1907
  }
@@ -879,20 +1913,64 @@ export function App({
879
1913
  }
880
1914
  }
881
1915
  return [];
882
- })();
1916
+ }, [activeToolCalls, messages]);
883
1917
 
884
1918
  /* -- Render ------------------------------------------------------------ */
885
1919
 
886
1920
  return (
887
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
+
888
1932
  {/* Top: Header */}
889
1933
  <Header session={session} />
890
1934
 
891
- {/* Middle: scrollable message list (grows to fill space) */}
892
- <Box flexDirection="column" flexGrow={1}>
893
- <MessageList messages={messages} mode={session.mode} />
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
+ )}
894
1960
  </Box>
895
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
+
896
1974
  {/* Inline tool call display (when tools are active) */}
897
1975
  {visibleToolCalls.length > 0 && (
898
1976
  <ToolCallDisplay toolCalls={visibleToolCalls} expanded={isProcessing} />
@@ -908,15 +1986,44 @@ export function App({
908
1986
  />
909
1987
  )}
910
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
+
911
2001
  {/* Modal: Deploy preview */}
912
2002
  {deployPreview && <DeployPreview preview={deployPreview} onDecide={handleDeployDecision} />}
913
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
+
914
2017
  {/* Input area */}
915
2018
  <InputBox
916
2019
  onSubmit={handleSubmit}
917
2020
  onAbort={handleAbort}
918
- disabled={isProcessing || !!permissionRequest || !!deployPreview}
2021
+ disabled={isProcessing || !!permissionRequest || !!deployPreview || !!fileDiffRequest || showHelp}
919
2022
  placeholder={isProcessing ? 'Agent is thinking...' : undefined}
2023
+ mode={session.mode}
2024
+ onLineCountChange={setInputLineCount}
2025
+ prefill={inputPrefill}
2026
+ onFetchCompletions={onFetchCompletions}
920
2027
  />
921
2028
 
922
2029
  {/* Bottom: Status bar */}
@@ -924,6 +2031,12 @@ export function App({
924
2031
  session={session}
925
2032
  isProcessing={isProcessing}
926
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}
927
2040
  />
928
2041
  </Box>
929
2042
  );
@@ -945,7 +2058,11 @@ export interface AppImperativeAPI {
945
2058
  setToolCalls: (calls: UIToolCall[]) => void;
946
2059
  requestPermission: (req: PermissionRequest) => void;
947
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;
948
2063
  setProcessing: (value: boolean) => void;
2064
+ /** GAP-2: Update LLM connectivity health indicator in the Header. */
2065
+ setLLMHealth: (health: 'checking' | 'ok' | 'error') => void;
949
2066
  }
950
2067
 
951
2068
  /* ---------------------------------------------------------------------------