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