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