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