@build-astron-co/nimbus 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +628 -0
- package/bin/nimbus +38 -0
- package/package.json +80 -0
- package/src/__tests__/app.test.ts +76 -0
- package/src/__tests__/audit.test.ts +877 -0
- package/src/__tests__/circuit-breaker.test.ts +116 -0
- package/src/__tests__/cli-run.test.ts +115 -0
- package/src/__tests__/context-manager.test.ts +502 -0
- package/src/__tests__/context.test.ts +242 -0
- package/src/__tests__/enterprise.test.ts +401 -0
- package/src/__tests__/generator.test.ts +433 -0
- package/src/__tests__/hooks.test.ts +582 -0
- package/src/__tests__/init.test.ts +436 -0
- package/src/__tests__/intent-parser.test.ts +229 -0
- package/src/__tests__/llm-router.test.ts +209 -0
- package/src/__tests__/lsp.test.ts +293 -0
- package/src/__tests__/modes.test.ts +336 -0
- package/src/__tests__/permissions.test.ts +338 -0
- package/src/__tests__/serve.test.ts +275 -0
- package/src/__tests__/sessions.test.ts +227 -0
- package/src/__tests__/sharing.test.ts +288 -0
- package/src/__tests__/snapshots.test.ts +581 -0
- package/src/__tests__/state-db.test.ts +334 -0
- package/src/__tests__/stream-with-tools.test.ts +732 -0
- package/src/__tests__/subagents.test.ts +176 -0
- package/src/__tests__/system-prompt.test.ts +169 -0
- package/src/__tests__/tool-converter.test.ts +256 -0
- package/src/__tests__/tool-schemas.test.ts +397 -0
- package/src/__tests__/tools.test.ts +143 -0
- package/src/__tests__/version.test.ts +49 -0
- package/src/agent/compaction-agent.ts +227 -0
- package/src/agent/context-manager.ts +435 -0
- package/src/agent/context.ts +427 -0
- package/src/agent/deploy-preview.ts +426 -0
- package/src/agent/index.ts +68 -0
- package/src/agent/loop.ts +717 -0
- package/src/agent/modes.ts +429 -0
- package/src/agent/permissions.ts +466 -0
- package/src/agent/subagents/base.ts +116 -0
- package/src/agent/subagents/cost.ts +51 -0
- package/src/agent/subagents/explore.ts +42 -0
- package/src/agent/subagents/general.ts +54 -0
- package/src/agent/subagents/index.ts +102 -0
- package/src/agent/subagents/infra.ts +59 -0
- package/src/agent/subagents/security.ts +69 -0
- package/src/agent/system-prompt.ts +436 -0
- package/src/app.ts +122 -0
- package/src/audit/activity-log.ts +290 -0
- package/src/audit/compliance-checker.ts +540 -0
- package/src/audit/cost-tracker.ts +318 -0
- package/src/audit/index.ts +23 -0
- package/src/audit/security-scanner.ts +596 -0
- package/src/auth/guard.ts +75 -0
- package/src/auth/index.ts +56 -0
- package/src/auth/oauth.ts +455 -0
- package/src/auth/providers.ts +470 -0
- package/src/auth/sso.ts +113 -0
- package/src/auth/store.ts +505 -0
- package/src/auth/types.ts +187 -0
- package/src/build.ts +141 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/init.ts +854 -0
- package/src/cli/openapi-spec.ts +356 -0
- package/src/cli/run.ts +237 -0
- package/src/cli/serve-auth.ts +80 -0
- package/src/cli/serve.ts +462 -0
- package/src/cli/web.ts +67 -0
- package/src/cli.ts +1417 -0
- package/src/clients/core-engine-client.ts +227 -0
- package/src/clients/enterprise-client.ts +334 -0
- package/src/clients/generator-client.ts +351 -0
- package/src/clients/git-client.ts +627 -0
- package/src/clients/github-client.ts +410 -0
- package/src/clients/helm-client.ts +504 -0
- package/src/clients/index.ts +80 -0
- package/src/clients/k8s-client.ts +497 -0
- package/src/clients/llm-client.ts +161 -0
- package/src/clients/rest-client.ts +130 -0
- package/src/clients/service-discovery.ts +33 -0
- package/src/clients/terraform-client.ts +482 -0
- package/src/clients/tools-client.ts +1843 -0
- package/src/clients/ws-client.ts +115 -0
- package/src/commands/analyze/index.ts +352 -0
- package/src/commands/apply/helm.ts +473 -0
- package/src/commands/apply/index.ts +213 -0
- package/src/commands/apply/k8s.ts +454 -0
- package/src/commands/apply/terraform.ts +582 -0
- package/src/commands/ask.ts +167 -0
- package/src/commands/audit/index.ts +238 -0
- package/src/commands/auth-cloud.ts +294 -0
- package/src/commands/auth-list.ts +134 -0
- package/src/commands/auth-profile.ts +121 -0
- package/src/commands/auth-status.ts +141 -0
- package/src/commands/aws/ec2.ts +501 -0
- package/src/commands/aws/iam.ts +397 -0
- package/src/commands/aws/index.ts +133 -0
- package/src/commands/aws/lambda.ts +396 -0
- package/src/commands/aws/rds.ts +439 -0
- package/src/commands/aws/s3.ts +439 -0
- package/src/commands/aws/vpc.ts +393 -0
- package/src/commands/aws-discover.ts +649 -0
- package/src/commands/aws-terraform.ts +805 -0
- package/src/commands/azure/aks.ts +376 -0
- package/src/commands/azure/functions.ts +253 -0
- package/src/commands/azure/index.ts +116 -0
- package/src/commands/azure/storage.ts +478 -0
- package/src/commands/azure/vm.ts +355 -0
- package/src/commands/billing/index.ts +256 -0
- package/src/commands/chat.ts +314 -0
- package/src/commands/config.ts +346 -0
- package/src/commands/cost/cloud-cost-estimator.ts +266 -0
- package/src/commands/cost/estimator.ts +79 -0
- package/src/commands/cost/index.ts +594 -0
- package/src/commands/cost/parsers/terraform.ts +273 -0
- package/src/commands/cost/parsers/types.ts +25 -0
- package/src/commands/cost/pricing/aws.ts +544 -0
- package/src/commands/cost/pricing/azure.ts +499 -0
- package/src/commands/cost/pricing/gcp.ts +396 -0
- package/src/commands/cost/pricing/index.ts +40 -0
- package/src/commands/demo.ts +250 -0
- package/src/commands/doctor.ts +794 -0
- package/src/commands/drift/index.ts +439 -0
- package/src/commands/explain.ts +277 -0
- package/src/commands/feedback.ts +389 -0
- package/src/commands/fix.ts +324 -0
- package/src/commands/fs/index.ts +402 -0
- package/src/commands/gcp/compute.ts +325 -0
- package/src/commands/gcp/functions.ts +271 -0
- package/src/commands/gcp/gke.ts +438 -0
- package/src/commands/gcp/iam.ts +344 -0
- package/src/commands/gcp/index.ts +129 -0
- package/src/commands/gcp/storage.ts +284 -0
- package/src/commands/generate-helm.ts +1249 -0
- package/src/commands/generate-k8s.ts +1560 -0
- package/src/commands/generate-terraform.ts +1460 -0
- package/src/commands/gh/index.ts +863 -0
- package/src/commands/git/index.ts +1343 -0
- package/src/commands/helm/index.ts +1126 -0
- package/src/commands/help.ts +539 -0
- package/src/commands/history.ts +142 -0
- package/src/commands/import.ts +868 -0
- package/src/commands/index.ts +367 -0
- package/src/commands/init.ts +1046 -0
- package/src/commands/k8s/index.ts +1137 -0
- package/src/commands/login.ts +631 -0
- package/src/commands/logout.ts +83 -0
- package/src/commands/onboarding.ts +228 -0
- package/src/commands/plan/display.ts +279 -0
- package/src/commands/plan/index.ts +599 -0
- package/src/commands/preview.ts +452 -0
- package/src/commands/questionnaire.ts +1270 -0
- package/src/commands/resume.ts +55 -0
- package/src/commands/team/index.ts +346 -0
- package/src/commands/template.ts +232 -0
- package/src/commands/tf/index.ts +1034 -0
- package/src/commands/upgrade.ts +550 -0
- package/src/commands/usage/index.ts +134 -0
- package/src/commands/version.ts +170 -0
- package/src/compat/index.ts +2 -0
- package/src/compat/runtime.ts +12 -0
- package/src/compat/sqlite.ts +107 -0
- package/src/config/index.ts +17 -0
- package/src/config/manager.ts +530 -0
- package/src/config/safety-policy.ts +358 -0
- package/src/config/schema.ts +125 -0
- package/src/config/types.ts +527 -0
- package/src/context/context-db.ts +199 -0
- package/src/demo/index.ts +349 -0
- package/src/demo/scenarios/full-journey.ts +229 -0
- package/src/demo/scenarios/getting-started.ts +127 -0
- package/src/demo/scenarios/helm-release.ts +341 -0
- package/src/demo/scenarios/k8s-deployment.ts +194 -0
- package/src/demo/scenarios/terraform-vpc.ts +170 -0
- package/src/demo/types.ts +92 -0
- package/src/engine/cost-estimator.ts +438 -0
- package/src/engine/diagram-generator.ts +256 -0
- package/src/engine/drift-detector.ts +902 -0
- package/src/engine/executor.ts +1035 -0
- package/src/engine/index.ts +76 -0
- package/src/engine/orchestrator.ts +636 -0
- package/src/engine/planner.ts +720 -0
- package/src/engine/safety.ts +743 -0
- package/src/engine/verifier.ts +770 -0
- package/src/enterprise/audit.ts +348 -0
- package/src/enterprise/auth.ts +270 -0
- package/src/enterprise/billing.ts +822 -0
- package/src/enterprise/index.ts +17 -0
- package/src/enterprise/teams.ts +443 -0
- package/src/generator/best-practices.ts +1608 -0
- package/src/generator/helm.ts +630 -0
- package/src/generator/index.ts +37 -0
- package/src/generator/intent-parser.ts +514 -0
- package/src/generator/kubernetes.ts +976 -0
- package/src/generator/terraform.ts +1867 -0
- package/src/history/index.ts +8 -0
- package/src/history/manager.ts +322 -0
- package/src/history/types.ts +34 -0
- package/src/hooks/config.ts +432 -0
- package/src/hooks/engine.ts +391 -0
- package/src/hooks/index.ts +4 -0
- package/src/llm/auth-bridge.ts +198 -0
- package/src/llm/circuit-breaker.ts +140 -0
- package/src/llm/config-loader.ts +201 -0
- package/src/llm/cost-calculator.ts +171 -0
- package/src/llm/index.ts +8 -0
- package/src/llm/model-aliases.ts +115 -0
- package/src/llm/provider-registry.ts +63 -0
- package/src/llm/providers/anthropic.ts +433 -0
- package/src/llm/providers/bedrock.ts +477 -0
- package/src/llm/providers/google.ts +405 -0
- package/src/llm/providers/ollama.ts +767 -0
- package/src/llm/providers/openai-compatible.ts +340 -0
- package/src/llm/providers/openai.ts +328 -0
- package/src/llm/providers/openrouter.ts +338 -0
- package/src/llm/router.ts +1035 -0
- package/src/llm/types.ts +232 -0
- package/src/lsp/client.ts +298 -0
- package/src/lsp/languages.ts +116 -0
- package/src/lsp/manager.ts +278 -0
- package/src/mcp/client.ts +402 -0
- package/src/mcp/index.ts +5 -0
- package/src/mcp/manager.ts +133 -0
- package/src/nimbus.ts +214 -0
- package/src/plugins/index.ts +27 -0
- package/src/plugins/loader.ts +334 -0
- package/src/plugins/manager.ts +376 -0
- package/src/plugins/types.ts +284 -0
- package/src/scanners/cicd-scanner.ts +258 -0
- package/src/scanners/cloud-scanner.ts +466 -0
- package/src/scanners/framework-scanner.ts +469 -0
- package/src/scanners/iac-scanner.ts +388 -0
- package/src/scanners/index.ts +539 -0
- package/src/scanners/language-scanner.ts +276 -0
- package/src/scanners/package-manager-scanner.ts +277 -0
- package/src/scanners/types.ts +172 -0
- package/src/sessions/manager.ts +365 -0
- package/src/sessions/types.ts +44 -0
- package/src/sharing/sync.ts +296 -0
- package/src/sharing/viewer.ts +97 -0
- package/src/snapshots/index.ts +2 -0
- package/src/snapshots/manager.ts +530 -0
- package/src/state/artifacts.ts +147 -0
- package/src/state/audit.ts +137 -0
- package/src/state/billing.ts +240 -0
- package/src/state/checkpoints.ts +117 -0
- package/src/state/config.ts +67 -0
- package/src/state/conversations.ts +14 -0
- package/src/state/credentials.ts +154 -0
- package/src/state/db.ts +58 -0
- package/src/state/index.ts +26 -0
- package/src/state/messages.ts +115 -0
- package/src/state/projects.ts +123 -0
- package/src/state/schema.ts +236 -0
- package/src/state/sessions.ts +147 -0
- package/src/state/teams.ts +200 -0
- package/src/telemetry.ts +108 -0
- package/src/tools/aws-ops.ts +952 -0
- package/src/tools/azure-ops.ts +579 -0
- package/src/tools/file-ops.ts +593 -0
- package/src/tools/gcp-ops.ts +625 -0
- package/src/tools/git-ops.ts +773 -0
- package/src/tools/github-ops.ts +799 -0
- package/src/tools/helm-ops.ts +943 -0
- package/src/tools/index.ts +17 -0
- package/src/tools/k8s-ops.ts +819 -0
- package/src/tools/schemas/converter.ts +184 -0
- package/src/tools/schemas/devops.ts +612 -0
- package/src/tools/schemas/index.ts +73 -0
- package/src/tools/schemas/standard.ts +1144 -0
- package/src/tools/schemas/types.ts +705 -0
- package/src/tools/terraform-ops.ts +862 -0
- package/src/types/ambient.d.ts +193 -0
- package/src/types/config.ts +83 -0
- package/src/types/drift.ts +116 -0
- package/src/types/enterprise.ts +335 -0
- package/src/types/index.ts +20 -0
- package/src/types/plan.ts +44 -0
- package/src/types/request.ts +65 -0
- package/src/types/response.ts +54 -0
- package/src/types/service.ts +51 -0
- package/src/ui/App.tsx +997 -0
- package/src/ui/DeployPreview.tsx +169 -0
- package/src/ui/Header.tsx +68 -0
- package/src/ui/InputBox.tsx +350 -0
- package/src/ui/MessageList.tsx +585 -0
- package/src/ui/PermissionPrompt.tsx +151 -0
- package/src/ui/StatusBar.tsx +158 -0
- package/src/ui/ToolCallDisplay.tsx +409 -0
- package/src/ui/chat-ui.ts +853 -0
- package/src/ui/index.ts +33 -0
- package/src/ui/ink/index.ts +711 -0
- package/src/ui/streaming.ts +176 -0
- package/src/ui/types.ts +57 -0
- package/src/utils/analytics.ts +72 -0
- package/src/utils/cost-warning.ts +27 -0
- package/src/utils/env.ts +46 -0
- package/src/utils/errors.ts +69 -0
- package/src/utils/event-bus.ts +38 -0
- package/src/utils/index.ts +24 -0
- package/src/utils/logger.ts +171 -0
- package/src/utils/rate-limiter.ts +121 -0
- package/src/utils/service-auth.ts +49 -0
- package/src/utils/validation.ts +53 -0
- package/src/version.ts +4 -0
- package/src/watcher/index.ts +163 -0
- package/src/wizard/approval.ts +383 -0
- package/src/wizard/index.ts +25 -0
- package/src/wizard/prompts.ts +338 -0
- package/src/wizard/types.ts +171 -0
- package/src/wizard/ui.ts +556 -0
- package/src/wizard/wizard.ts +304 -0
- package/tsconfig.json +24 -0
package/src/ui/App.tsx
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
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 } from 'react';
|
|
22
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import type { AgentMode, UIMessage, UIToolCall, SessionInfo, DeployPreviewData } from './types';
|
|
26
|
+
import { Header } from './Header';
|
|
27
|
+
import { MessageList } from './MessageList';
|
|
28
|
+
import { ToolCallDisplay } from './ToolCallDisplay';
|
|
29
|
+
import { InputBox } from './InputBox';
|
|
30
|
+
import { StatusBar } from './StatusBar';
|
|
31
|
+
import { PermissionPrompt, type PermissionDecision, type RiskLevel } from './PermissionPrompt';
|
|
32
|
+
import { DeployPreview, type DeployDecision } from './DeployPreview';
|
|
33
|
+
|
|
34
|
+
/* ---------------------------------------------------------------------------
|
|
35
|
+
* Internal types
|
|
36
|
+
* -------------------------------------------------------------------------*/
|
|
37
|
+
|
|
38
|
+
/** A pending permission request that needs user approval. */
|
|
39
|
+
interface PermissionRequest {
|
|
40
|
+
tool: string;
|
|
41
|
+
input: Record<string, unknown>;
|
|
42
|
+
riskLevel: RiskLevel;
|
|
43
|
+
onDecide: (decision: PermissionDecision) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Callback invoked when the user submits a message. */
|
|
47
|
+
export type OnMessageCallback = (text: string) => void;
|
|
48
|
+
|
|
49
|
+
/** Callback invoked when the user presses Escape or Ctrl+C during processing. */
|
|
50
|
+
export type OnAbortCallback = () => void;
|
|
51
|
+
|
|
52
|
+
/** Result returned by the /compact command handler. */
|
|
53
|
+
export interface CompactCommandResult {
|
|
54
|
+
originalTokens: number;
|
|
55
|
+
compactedTokens: number;
|
|
56
|
+
savedTokens: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Breakdown returned by the /context command handler. */
|
|
60
|
+
export interface ContextCommandResult {
|
|
61
|
+
systemPrompt: number;
|
|
62
|
+
nimbusInstructions: number;
|
|
63
|
+
messages: number;
|
|
64
|
+
toolDefinitions: number;
|
|
65
|
+
total: number;
|
|
66
|
+
budget: number;
|
|
67
|
+
usagePercent: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Callback invoked when the user types /compact [focus]. */
|
|
71
|
+
export type OnCompactCallback = (focusArea?: string) => Promise<CompactCommandResult | null>;
|
|
72
|
+
|
|
73
|
+
/** Callback invoked when the user types /context. */
|
|
74
|
+
export type OnContextCallback = () => ContextCommandResult | null;
|
|
75
|
+
|
|
76
|
+
/** Result returned by the /undo or /redo command handlers. */
|
|
77
|
+
export interface UndoRedoResult {
|
|
78
|
+
success: boolean;
|
|
79
|
+
description: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Callback invoked when the user types /undo. */
|
|
83
|
+
export type OnUndoCallback = () => Promise<UndoRedoResult>;
|
|
84
|
+
|
|
85
|
+
/** Callback invoked when the user types /redo. */
|
|
86
|
+
export type OnRedoCallback = () => Promise<UndoRedoResult>;
|
|
87
|
+
|
|
88
|
+
/** A brief session summary for /sessions listing. */
|
|
89
|
+
export interface SessionSummary {
|
|
90
|
+
id: string;
|
|
91
|
+
name: string;
|
|
92
|
+
model: string;
|
|
93
|
+
mode: string;
|
|
94
|
+
updatedAt: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Callback invoked when the user types /sessions. */
|
|
98
|
+
export type OnSessionsCallback = () => SessionSummary[];
|
|
99
|
+
|
|
100
|
+
/** Callback invoked when the user types /new [name]. */
|
|
101
|
+
export type OnNewSessionCallback = (name?: string) => SessionSummary | null;
|
|
102
|
+
|
|
103
|
+
/** Callback invoked when the user types /switch <id>. */
|
|
104
|
+
export type OnSwitchSessionCallback = (sessionId: string) => SessionSummary | null;
|
|
105
|
+
|
|
106
|
+
/** Callback invoked when the user types /models. Returns provider→model[] map. */
|
|
107
|
+
export type OnModelsCallback = () => Promise<Record<string, string[]>>;
|
|
108
|
+
|
|
109
|
+
/** Callback invoked when the user types /clear. Clears LLM conversation history. */
|
|
110
|
+
export type OnClearCallback = () => void;
|
|
111
|
+
|
|
112
|
+
/** Callback invoked when the user changes the model via /model. */
|
|
113
|
+
export type OnModelChangeCallback = (model: string) => void;
|
|
114
|
+
|
|
115
|
+
/** Callback invoked when the user changes the mode via /mode or Tab. */
|
|
116
|
+
export type OnModeChangeCallback = (mode: AgentMode) => void;
|
|
117
|
+
|
|
118
|
+
/* ---------------------------------------------------------------------------
|
|
119
|
+
* Props
|
|
120
|
+
* -------------------------------------------------------------------------*/
|
|
121
|
+
|
|
122
|
+
/** Props accepted by the App component. */
|
|
123
|
+
export interface AppProps {
|
|
124
|
+
/** Initial session metadata. */
|
|
125
|
+
initialSession?: Partial<SessionInfo>;
|
|
126
|
+
/** External handler invoked when the user submits a message. */
|
|
127
|
+
onMessage?: OnMessageCallback;
|
|
128
|
+
/** External handler invoked when the user aborts. */
|
|
129
|
+
onAbort?: OnAbortCallback;
|
|
130
|
+
/** Handler for /compact command. Returns token savings or null on failure. */
|
|
131
|
+
onCompact?: OnCompactCallback;
|
|
132
|
+
/** Handler for /context command. Returns context breakdown or null. */
|
|
133
|
+
onContext?: OnContextCallback;
|
|
134
|
+
/** Handler for /undo command. Reverts the last file-modifying tool call. */
|
|
135
|
+
onUndo?: OnUndoCallback;
|
|
136
|
+
/** Handler for /redo command. Re-applies a previously undone change. */
|
|
137
|
+
onRedo?: OnRedoCallback;
|
|
138
|
+
/** Handler for /sessions command. Lists active sessions. */
|
|
139
|
+
onSessions?: OnSessionsCallback;
|
|
140
|
+
/** Handler for /new command. Creates a new session. */
|
|
141
|
+
onNewSession?: OnNewSessionCallback;
|
|
142
|
+
/** Handler for /switch command. Switches to a different session. */
|
|
143
|
+
onSwitchSession?: OnSwitchSessionCallback;
|
|
144
|
+
/** Handler for /models command. Lists all available provider models. */
|
|
145
|
+
onModels?: OnModelsCallback;
|
|
146
|
+
/** Handler for /clear command. Resets the LLM conversation history. */
|
|
147
|
+
onClear?: OnClearCallback;
|
|
148
|
+
/** Handler for /model command. Propagates model change to the agent loop. */
|
|
149
|
+
onModelChange?: OnModelChangeCallback;
|
|
150
|
+
/** Handler for mode changes (Tab or /mode). Propagates to the agent loop. */
|
|
151
|
+
onModeChange?: OnModeChangeCallback;
|
|
152
|
+
/** Called once after mount, passing imperative handles for driving TUI state. */
|
|
153
|
+
onReady?: (api: AppImperativeAPI) => void;
|
|
154
|
+
/** Messages to pre-populate the message list (e.g., from a resumed session). */
|
|
155
|
+
initialMessages?: UIMessage[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---------------------------------------------------------------------------
|
|
159
|
+
* Mode rotation helper
|
|
160
|
+
* -------------------------------------------------------------------------*/
|
|
161
|
+
|
|
162
|
+
const MODES: AgentMode[] = ['plan', 'build', 'deploy'];
|
|
163
|
+
|
|
164
|
+
function nextMode(current: AgentMode): AgentMode {
|
|
165
|
+
const idx = MODES.indexOf(current);
|
|
166
|
+
return MODES[(idx + 1) % MODES.length];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ---------------------------------------------------------------------------
|
|
170
|
+
* Default session factory
|
|
171
|
+
* -------------------------------------------------------------------------*/
|
|
172
|
+
|
|
173
|
+
function createDefaultSession(overrides?: Partial<SessionInfo>): SessionInfo {
|
|
174
|
+
return {
|
|
175
|
+
id: overrides?.id ?? crypto.randomUUID(),
|
|
176
|
+
model: overrides?.model ?? 'default',
|
|
177
|
+
mode: overrides?.mode ?? 'plan',
|
|
178
|
+
tokenCount: overrides?.tokenCount ?? 0,
|
|
179
|
+
maxTokens: overrides?.maxTokens ?? 200_000,
|
|
180
|
+
costUSD: overrides?.costUSD ?? 0,
|
|
181
|
+
snapshotCount: overrides?.snapshotCount ?? 0,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ---------------------------------------------------------------------------
|
|
186
|
+
* App component
|
|
187
|
+
* -------------------------------------------------------------------------*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* App is the root Ink component. It maintains the full UI state and delegates
|
|
191
|
+
* rendering to focused child components. External orchestration logic can
|
|
192
|
+
* interact with the TUI by passing `onMessage` and `onAbort` callbacks, or
|
|
193
|
+
* by manipulating state through the imperative handles exposed on this
|
|
194
|
+
* component (see the exported hooks below).
|
|
195
|
+
*/
|
|
196
|
+
export function App({
|
|
197
|
+
initialSession,
|
|
198
|
+
onMessage,
|
|
199
|
+
onAbort,
|
|
200
|
+
onCompact,
|
|
201
|
+
onContext,
|
|
202
|
+
onUndo,
|
|
203
|
+
onRedo,
|
|
204
|
+
onSessions,
|
|
205
|
+
onNewSession,
|
|
206
|
+
onSwitchSession,
|
|
207
|
+
onModels,
|
|
208
|
+
onClear,
|
|
209
|
+
onModelChange,
|
|
210
|
+
onModeChange,
|
|
211
|
+
onReady,
|
|
212
|
+
initialMessages,
|
|
213
|
+
}: AppProps) {
|
|
214
|
+
const { exit } = useApp();
|
|
215
|
+
|
|
216
|
+
/* -- State ------------------------------------------------------------- */
|
|
217
|
+
|
|
218
|
+
const [session, setSession] = useState(createDefaultSession(initialSession) as SessionInfo);
|
|
219
|
+
|
|
220
|
+
const [messages, setMessages] = useState((initialMessages ?? []) as UIMessage[]);
|
|
221
|
+
|
|
222
|
+
const [activeToolCalls, setActiveToolCalls] = useState([] as UIToolCall[]);
|
|
223
|
+
|
|
224
|
+
const [permissionRequest, setPermissionRequest] = useState(null as PermissionRequest | null);
|
|
225
|
+
|
|
226
|
+
const [deployPreview, setDeployPreview] = useState(null as DeployPreviewData | null);
|
|
227
|
+
|
|
228
|
+
const [isProcessing, setIsProcessing] = useState(false as boolean);
|
|
229
|
+
const [processingStartTime, setProcessingStartTime] = useState(null as number | null);
|
|
230
|
+
|
|
231
|
+
/* -- Expose imperative API to external orchestrator -------------------- */
|
|
232
|
+
|
|
233
|
+
const onReadyCalled = useRef(false);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (onReady && !onReadyCalled.current) {
|
|
237
|
+
onReadyCalled.current = true;
|
|
238
|
+
onReady({
|
|
239
|
+
addMessage: (msg: UIMessage) => setMessages(prev => [...prev, msg]),
|
|
240
|
+
updateMessage: (id: string, content: string) =>
|
|
241
|
+
setMessages(prev => prev.map(m => (m.id === id ? { ...m, content } : m))),
|
|
242
|
+
updateSession: (patch: Partial<SessionInfo>) => setSession(prev => ({ ...prev, ...patch })),
|
|
243
|
+
setToolCalls: setActiveToolCalls,
|
|
244
|
+
requestPermission: (req: PermissionRequest) => setPermissionRequest(req),
|
|
245
|
+
showDeployPreview: (preview: DeployPreviewData) => setDeployPreview(preview),
|
|
246
|
+
setProcessing: (v: boolean) => {
|
|
247
|
+
setIsProcessing(v);
|
|
248
|
+
setProcessingStartTime(v ? Date.now() : null);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}, [onReady]);
|
|
253
|
+
|
|
254
|
+
/* -- Callbacks --------------------------------------------------------- */
|
|
255
|
+
|
|
256
|
+
/** Handle user message submission from the InputBox. */
|
|
257
|
+
const handleSubmit = useCallback(
|
|
258
|
+
(text: string) => {
|
|
259
|
+
const trimmed = text.trim();
|
|
260
|
+
|
|
261
|
+
// -----------------------------------------------------------------
|
|
262
|
+
// Slash command handling
|
|
263
|
+
// -----------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
// /compact [focus area] — manually trigger context compaction
|
|
266
|
+
if (trimmed === '/compact' || trimmed.startsWith('/compact ')) {
|
|
267
|
+
const focusArea =
|
|
268
|
+
trimmed.length > '/compact'.length ? trimmed.slice('/compact '.length).trim() : undefined;
|
|
269
|
+
|
|
270
|
+
const systemMsg: UIMessage = {
|
|
271
|
+
id: crypto.randomUUID(),
|
|
272
|
+
role: 'system',
|
|
273
|
+
content: focusArea
|
|
274
|
+
? `Compacting context (focus: ${focusArea})...`
|
|
275
|
+
: 'Compacting context...',
|
|
276
|
+
timestamp: new Date(),
|
|
277
|
+
};
|
|
278
|
+
setMessages(prev => [...prev, systemMsg]);
|
|
279
|
+
|
|
280
|
+
if (onCompact) {
|
|
281
|
+
setIsProcessing(true);
|
|
282
|
+
onCompact(focusArea)
|
|
283
|
+
.then(result => {
|
|
284
|
+
const resultMsg: UIMessage = {
|
|
285
|
+
id: crypto.randomUUID(),
|
|
286
|
+
role: 'system',
|
|
287
|
+
content: result
|
|
288
|
+
? `Context compacted! Saved ${result.savedTokens.toLocaleString()} tokens (${result.originalTokens.toLocaleString()} → ${result.compactedTokens.toLocaleString()}).`
|
|
289
|
+
: 'Compaction skipped — not enough context to compact.',
|
|
290
|
+
timestamp: new Date(),
|
|
291
|
+
};
|
|
292
|
+
setMessages(prev => [...prev, resultMsg]);
|
|
293
|
+
setIsProcessing(false);
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {
|
|
296
|
+
const errMsg: UIMessage = {
|
|
297
|
+
id: crypto.randomUUID(),
|
|
298
|
+
role: 'system',
|
|
299
|
+
content: 'Compaction failed. The conversation continues unchanged.',
|
|
300
|
+
timestamp: new Date(),
|
|
301
|
+
};
|
|
302
|
+
setMessages(prev => [...prev, errMsg]);
|
|
303
|
+
setIsProcessing(false);
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
const noHandler: UIMessage = {
|
|
307
|
+
id: crypto.randomUUID(),
|
|
308
|
+
role: 'system',
|
|
309
|
+
content: 'Compaction is not available in this session.',
|
|
310
|
+
timestamp: new Date(),
|
|
311
|
+
};
|
|
312
|
+
setMessages(prev => [...prev, noHandler]);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// /undo — revert the last file-modifying tool call
|
|
318
|
+
if (trimmed === '/undo') {
|
|
319
|
+
if (onUndo) {
|
|
320
|
+
const pendingMsg: UIMessage = {
|
|
321
|
+
id: crypto.randomUUID(),
|
|
322
|
+
role: 'system',
|
|
323
|
+
content: 'Reverting last change...',
|
|
324
|
+
timestamp: new Date(),
|
|
325
|
+
};
|
|
326
|
+
setMessages(prev => [...prev, pendingMsg]);
|
|
327
|
+
setIsProcessing(true);
|
|
328
|
+
onUndo()
|
|
329
|
+
.then(result => {
|
|
330
|
+
const msg: UIMessage = {
|
|
331
|
+
id: crypto.randomUUID(),
|
|
332
|
+
role: 'system',
|
|
333
|
+
content: result.success
|
|
334
|
+
? `Undo successful: ${result.description}`
|
|
335
|
+
: `Undo failed: ${result.description}`,
|
|
336
|
+
timestamp: new Date(),
|
|
337
|
+
};
|
|
338
|
+
setMessages(prev => [...prev, msg]);
|
|
339
|
+
setIsProcessing(false);
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {
|
|
342
|
+
const msg: UIMessage = {
|
|
343
|
+
id: crypto.randomUUID(),
|
|
344
|
+
role: 'system',
|
|
345
|
+
content: 'Undo failed unexpectedly.',
|
|
346
|
+
timestamp: new Date(),
|
|
347
|
+
};
|
|
348
|
+
setMessages(prev => [...prev, msg]);
|
|
349
|
+
setIsProcessing(false);
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
const msg: UIMessage = {
|
|
353
|
+
id: crypto.randomUUID(),
|
|
354
|
+
role: 'system',
|
|
355
|
+
content: 'Undo is not available in this session.',
|
|
356
|
+
timestamp: new Date(),
|
|
357
|
+
};
|
|
358
|
+
setMessages(prev => [...prev, msg]);
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// /redo — re-apply a previously undone change
|
|
364
|
+
if (trimmed === '/redo') {
|
|
365
|
+
if (onRedo) {
|
|
366
|
+
const pendingMsg: UIMessage = {
|
|
367
|
+
id: crypto.randomUUID(),
|
|
368
|
+
role: 'system',
|
|
369
|
+
content: 'Re-applying change...',
|
|
370
|
+
timestamp: new Date(),
|
|
371
|
+
};
|
|
372
|
+
setMessages(prev => [...prev, pendingMsg]);
|
|
373
|
+
setIsProcessing(true);
|
|
374
|
+
onRedo()
|
|
375
|
+
.then(result => {
|
|
376
|
+
const msg: UIMessage = {
|
|
377
|
+
id: crypto.randomUUID(),
|
|
378
|
+
role: 'system',
|
|
379
|
+
content: result.success
|
|
380
|
+
? `Redo successful: ${result.description}`
|
|
381
|
+
: `Redo failed: ${result.description}`,
|
|
382
|
+
timestamp: new Date(),
|
|
383
|
+
};
|
|
384
|
+
setMessages(prev => [...prev, msg]);
|
|
385
|
+
setIsProcessing(false);
|
|
386
|
+
})
|
|
387
|
+
.catch(() => {
|
|
388
|
+
const msg: UIMessage = {
|
|
389
|
+
id: crypto.randomUUID(),
|
|
390
|
+
role: 'system',
|
|
391
|
+
content: 'Redo failed unexpectedly.',
|
|
392
|
+
timestamp: new Date(),
|
|
393
|
+
};
|
|
394
|
+
setMessages(prev => [...prev, msg]);
|
|
395
|
+
setIsProcessing(false);
|
|
396
|
+
});
|
|
397
|
+
} else {
|
|
398
|
+
const msg: UIMessage = {
|
|
399
|
+
id: crypto.randomUUID(),
|
|
400
|
+
role: 'system',
|
|
401
|
+
content: 'Redo is not available in this session.',
|
|
402
|
+
timestamp: new Date(),
|
|
403
|
+
};
|
|
404
|
+
setMessages(prev => [...prev, msg]);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// /help — show available slash commands
|
|
410
|
+
if (trimmed === '/help') {
|
|
411
|
+
const helpContent = [
|
|
412
|
+
'Available commands:',
|
|
413
|
+
' /help — Show this help message',
|
|
414
|
+
' /clear — Clear conversation history',
|
|
415
|
+
' /compact [focus] — Compress context to free tokens',
|
|
416
|
+
' /context — Show context window usage',
|
|
417
|
+
' /model [name] — Show or switch the active model',
|
|
418
|
+
' /models — List all available provider models',
|
|
419
|
+
' /undo — Revert the last file change',
|
|
420
|
+
' /redo — Re-apply a reverted change',
|
|
421
|
+
' /sessions — List active sessions',
|
|
422
|
+
' /new [name] — Create a new session',
|
|
423
|
+
' /switch <id> — Switch to a different session',
|
|
424
|
+
'',
|
|
425
|
+
'Keyboard shortcuts:',
|
|
426
|
+
' Tab — Cycle mode (plan → build → deploy)',
|
|
427
|
+
' Ctrl+R — Search input history',
|
|
428
|
+
' Ctrl+C — Interrupt or exit',
|
|
429
|
+
' Escape — Cancel current operation',
|
|
430
|
+
'',
|
|
431
|
+
'Prefix a path with @ to include file contents (e.g. @src/main.ts)',
|
|
432
|
+
].join('\n');
|
|
433
|
+
|
|
434
|
+
const msg: UIMessage = {
|
|
435
|
+
id: crypto.randomUUID(),
|
|
436
|
+
role: 'system',
|
|
437
|
+
content: helpContent,
|
|
438
|
+
timestamp: new Date(),
|
|
439
|
+
};
|
|
440
|
+
setMessages(prev => [...prev, msg]);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// /clear — clear conversation history (both UI and LLM context)
|
|
445
|
+
if (trimmed === '/clear') {
|
|
446
|
+
setMessages([]);
|
|
447
|
+
if (onClear) {
|
|
448
|
+
onClear();
|
|
449
|
+
}
|
|
450
|
+
const msg: UIMessage = {
|
|
451
|
+
id: crypto.randomUUID(),
|
|
452
|
+
role: 'system',
|
|
453
|
+
content: 'Conversation cleared.',
|
|
454
|
+
timestamp: new Date(),
|
|
455
|
+
};
|
|
456
|
+
setMessages([msg]);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// /model [name] — show or switch the active model
|
|
461
|
+
if (trimmed === '/model' || trimmed.startsWith('/model ')) {
|
|
462
|
+
const newModel =
|
|
463
|
+
trimmed.length > '/model'.length ? trimmed.slice('/model '.length).trim() : undefined;
|
|
464
|
+
|
|
465
|
+
if (newModel) {
|
|
466
|
+
setSession(prev => ({ ...prev, model: newModel }));
|
|
467
|
+
// Propagate the model change to the agent loop
|
|
468
|
+
if (onModelChange) {
|
|
469
|
+
onModelChange(newModel);
|
|
470
|
+
}
|
|
471
|
+
const msg: UIMessage = {
|
|
472
|
+
id: crypto.randomUUID(),
|
|
473
|
+
role: 'system',
|
|
474
|
+
content: `Model switched to: ${newModel}`,
|
|
475
|
+
timestamp: new Date(),
|
|
476
|
+
};
|
|
477
|
+
setMessages(prev => [...prev, msg]);
|
|
478
|
+
} else {
|
|
479
|
+
const msg: UIMessage = {
|
|
480
|
+
id: crypto.randomUUID(),
|
|
481
|
+
role: 'system',
|
|
482
|
+
content: `Current model: ${session.model}\n\nUsage: /model <name> (e.g. /model sonnet, /model gpt4o, /model gemini)`,
|
|
483
|
+
timestamp: new Date(),
|
|
484
|
+
};
|
|
485
|
+
setMessages(prev => [...prev, msg]);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// /mode [plan|build|deploy] — show or switch agent mode
|
|
491
|
+
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
492
|
+
const newMode =
|
|
493
|
+
trimmed.length > '/mode'.length
|
|
494
|
+
? trimmed.slice('/mode '.length).trim().toLowerCase()
|
|
495
|
+
: undefined;
|
|
496
|
+
|
|
497
|
+
if (newMode) {
|
|
498
|
+
const validModes: AgentMode[] = ['plan', 'build', 'deploy'];
|
|
499
|
+
if (validModes.includes(newMode as AgentMode)) {
|
|
500
|
+
setSession(prev => ({ ...prev, mode: newMode as AgentMode }));
|
|
501
|
+
if (onModeChange) {
|
|
502
|
+
onModeChange(newMode as AgentMode);
|
|
503
|
+
}
|
|
504
|
+
const msg: UIMessage = {
|
|
505
|
+
id: crypto.randomUUID(),
|
|
506
|
+
role: 'system',
|
|
507
|
+
content: `Mode switched to: ${newMode}`,
|
|
508
|
+
timestamp: new Date(),
|
|
509
|
+
};
|
|
510
|
+
setMessages(prev => [...prev, msg]);
|
|
511
|
+
} else {
|
|
512
|
+
const msg: UIMessage = {
|
|
513
|
+
id: crypto.randomUUID(),
|
|
514
|
+
role: 'system',
|
|
515
|
+
content: `Invalid mode: "${newMode}". Valid modes: plan, build, deploy`,
|
|
516
|
+
timestamp: new Date(),
|
|
517
|
+
};
|
|
518
|
+
setMessages(prev => [...prev, msg]);
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
const msg: UIMessage = {
|
|
522
|
+
id: crypto.randomUUID(),
|
|
523
|
+
role: 'system',
|
|
524
|
+
content: `Current mode: ${session.mode}\n\nUsage: /mode <plan|build|deploy>`,
|
|
525
|
+
timestamp: new Date(),
|
|
526
|
+
};
|
|
527
|
+
setMessages(prev => [...prev, msg]);
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// /sessions — list active sessions
|
|
533
|
+
if (trimmed === '/sessions') {
|
|
534
|
+
if (onSessions) {
|
|
535
|
+
const sessions = onSessions();
|
|
536
|
+
const content =
|
|
537
|
+
sessions.length > 0
|
|
538
|
+
? [
|
|
539
|
+
'Active sessions:',
|
|
540
|
+
...sessions.map(
|
|
541
|
+
s =>
|
|
542
|
+
` ${s.id === session.id ? '* ' : ' '}${s.id.slice(0, 8)} ${s.name} (${s.model}, ${s.mode}) ${s.updatedAt}`
|
|
543
|
+
),
|
|
544
|
+
].join('\n')
|
|
545
|
+
: 'No sessions found.';
|
|
546
|
+
const msg: UIMessage = {
|
|
547
|
+
id: crypto.randomUUID(),
|
|
548
|
+
role: 'system',
|
|
549
|
+
content,
|
|
550
|
+
timestamp: new Date(),
|
|
551
|
+
};
|
|
552
|
+
setMessages(prev => [...prev, msg]);
|
|
553
|
+
} else {
|
|
554
|
+
const msg: UIMessage = {
|
|
555
|
+
id: crypto.randomUUID(),
|
|
556
|
+
role: 'system',
|
|
557
|
+
content: 'Session management is not available.',
|
|
558
|
+
timestamp: new Date(),
|
|
559
|
+
};
|
|
560
|
+
setMessages(prev => [...prev, msg]);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// /new [name] — create a new session
|
|
566
|
+
if (trimmed === '/new' || trimmed.startsWith('/new ')) {
|
|
567
|
+
const name =
|
|
568
|
+
trimmed.length > '/new'.length ? trimmed.slice('/new '.length).trim() : undefined;
|
|
569
|
+
if (onNewSession) {
|
|
570
|
+
const newSession = onNewSession(name);
|
|
571
|
+
if (newSession) {
|
|
572
|
+
setMessages([]);
|
|
573
|
+
setSession(prev => ({
|
|
574
|
+
...prev,
|
|
575
|
+
id: newSession.id,
|
|
576
|
+
model: newSession.model,
|
|
577
|
+
mode: newSession.mode as AgentMode,
|
|
578
|
+
}));
|
|
579
|
+
const msg: UIMessage = {
|
|
580
|
+
id: crypto.randomUUID(),
|
|
581
|
+
role: 'system',
|
|
582
|
+
content: `New session created: ${newSession.name}`,
|
|
583
|
+
timestamp: new Date(),
|
|
584
|
+
};
|
|
585
|
+
setMessages([msg]);
|
|
586
|
+
} else {
|
|
587
|
+
const msg: UIMessage = {
|
|
588
|
+
id: crypto.randomUUID(),
|
|
589
|
+
role: 'system',
|
|
590
|
+
content: 'Failed to create new session.',
|
|
591
|
+
timestamp: new Date(),
|
|
592
|
+
};
|
|
593
|
+
setMessages(prev => [...prev, msg]);
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
const msg: UIMessage = {
|
|
597
|
+
id: crypto.randomUUID(),
|
|
598
|
+
role: 'system',
|
|
599
|
+
content: 'Session management is not available.',
|
|
600
|
+
timestamp: new Date(),
|
|
601
|
+
};
|
|
602
|
+
setMessages(prev => [...prev, msg]);
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// /switch <id> — switch to a different session
|
|
608
|
+
if (trimmed.startsWith('/switch ')) {
|
|
609
|
+
const targetId = trimmed.slice('/switch '.length).trim();
|
|
610
|
+
if (onSwitchSession) {
|
|
611
|
+
const switched = onSwitchSession(targetId);
|
|
612
|
+
if (switched) {
|
|
613
|
+
setMessages([]);
|
|
614
|
+
setSession(prev => ({
|
|
615
|
+
...prev,
|
|
616
|
+
id: switched.id,
|
|
617
|
+
model: switched.model,
|
|
618
|
+
mode: switched.mode as AgentMode,
|
|
619
|
+
}));
|
|
620
|
+
const msg: UIMessage = {
|
|
621
|
+
id: crypto.randomUUID(),
|
|
622
|
+
role: 'system',
|
|
623
|
+
content: `Switched to session: ${switched.name}`,
|
|
624
|
+
timestamp: new Date(),
|
|
625
|
+
};
|
|
626
|
+
setMessages([msg]);
|
|
627
|
+
} else {
|
|
628
|
+
const msg: UIMessage = {
|
|
629
|
+
id: crypto.randomUUID(),
|
|
630
|
+
role: 'system',
|
|
631
|
+
content: `Session not found: ${targetId}`,
|
|
632
|
+
timestamp: new Date(),
|
|
633
|
+
};
|
|
634
|
+
setMessages(prev => [...prev, msg]);
|
|
635
|
+
}
|
|
636
|
+
} else {
|
|
637
|
+
const msg: UIMessage = {
|
|
638
|
+
id: crypto.randomUUID(),
|
|
639
|
+
role: 'system',
|
|
640
|
+
content: 'Session management is not available.',
|
|
641
|
+
timestamp: new Date(),
|
|
642
|
+
};
|
|
643
|
+
setMessages(prev => [...prev, msg]);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// /models — list available models from all providers
|
|
649
|
+
if (trimmed === '/models') {
|
|
650
|
+
if (onModels) {
|
|
651
|
+
setIsProcessing(true);
|
|
652
|
+
setProcessingStartTime(Date.now());
|
|
653
|
+
onModels()
|
|
654
|
+
.then(modelsMap => {
|
|
655
|
+
const lines: string[] = ['Available models:'];
|
|
656
|
+
for (const [provider, modelList] of Object.entries(modelsMap)) {
|
|
657
|
+
lines.push(`\n ${provider}:`);
|
|
658
|
+
for (const model of modelList) {
|
|
659
|
+
lines.push(` - ${model}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (lines.length === 1) {
|
|
663
|
+
lines.push(' (no providers configured)');
|
|
664
|
+
}
|
|
665
|
+
const msg: UIMessage = {
|
|
666
|
+
id: crypto.randomUUID(),
|
|
667
|
+
role: 'system',
|
|
668
|
+
content: lines.join('\n'),
|
|
669
|
+
timestamp: new Date(),
|
|
670
|
+
};
|
|
671
|
+
setMessages(prev => [...prev, msg]);
|
|
672
|
+
setIsProcessing(false);
|
|
673
|
+
setProcessingStartTime(null);
|
|
674
|
+
})
|
|
675
|
+
.catch(() => {
|
|
676
|
+
const msg: UIMessage = {
|
|
677
|
+
id: crypto.randomUUID(),
|
|
678
|
+
role: 'system',
|
|
679
|
+
content: 'Failed to list models.',
|
|
680
|
+
timestamp: new Date(),
|
|
681
|
+
};
|
|
682
|
+
setMessages(prev => [...prev, msg]);
|
|
683
|
+
setIsProcessing(false);
|
|
684
|
+
setProcessingStartTime(null);
|
|
685
|
+
});
|
|
686
|
+
} else {
|
|
687
|
+
const msg: UIMessage = {
|
|
688
|
+
id: crypto.randomUUID(),
|
|
689
|
+
role: 'system',
|
|
690
|
+
content: 'Model listing is not available in this session.',
|
|
691
|
+
timestamp: new Date(),
|
|
692
|
+
};
|
|
693
|
+
setMessages(prev => [...prev, msg]);
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// /context — show context window usage breakdown
|
|
699
|
+
if (trimmed === '/context') {
|
|
700
|
+
if (onContext) {
|
|
701
|
+
const breakdown = onContext();
|
|
702
|
+
const content = breakdown
|
|
703
|
+
? [
|
|
704
|
+
'Context Usage Breakdown:',
|
|
705
|
+
` System prompt: ${breakdown.systemPrompt.toLocaleString()} tokens`,
|
|
706
|
+
` NIMBUS.md: ${breakdown.nimbusInstructions.toLocaleString()} tokens`,
|
|
707
|
+
` Messages: ${breakdown.messages.toLocaleString()} tokens`,
|
|
708
|
+
` Tool definitions: ${breakdown.toolDefinitions.toLocaleString()} tokens`,
|
|
709
|
+
` ─────────────────────────────`,
|
|
710
|
+
` Total: ${breakdown.total.toLocaleString()} / ${breakdown.budget.toLocaleString()} tokens (${breakdown.usagePercent}%)`,
|
|
711
|
+
].join('\n')
|
|
712
|
+
: 'Context information is not available.';
|
|
713
|
+
|
|
714
|
+
const msg: UIMessage = {
|
|
715
|
+
id: crypto.randomUUID(),
|
|
716
|
+
role: 'system',
|
|
717
|
+
content,
|
|
718
|
+
timestamp: new Date(),
|
|
719
|
+
};
|
|
720
|
+
setMessages(prev => [...prev, msg]);
|
|
721
|
+
} else {
|
|
722
|
+
const msg: UIMessage = {
|
|
723
|
+
id: crypto.randomUUID(),
|
|
724
|
+
role: 'system',
|
|
725
|
+
content: 'Context tracking is not available in this session.',
|
|
726
|
+
timestamp: new Date(),
|
|
727
|
+
};
|
|
728
|
+
setMessages(prev => [...prev, msg]);
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// -----------------------------------------------------------------
|
|
734
|
+
// Normal message — expand @file references, then send to agent
|
|
735
|
+
// -----------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
// Expand @path/to/file references: replace with file contents inline
|
|
738
|
+
let expandedText = trimmed;
|
|
739
|
+
const fileRefs = trimmed.match(/@"([^"]+)"|@([\w./_~-]+)/g);
|
|
740
|
+
if (fileRefs) {
|
|
741
|
+
for (const ref of fileRefs) {
|
|
742
|
+
// Handle both @"path with spaces" and @simple/path
|
|
743
|
+
const filePath = ref.startsWith('@"') ? ref.slice(2, -1) : ref.slice(1);
|
|
744
|
+
try {
|
|
745
|
+
const resolved = resolve(process.cwd(), filePath);
|
|
746
|
+
const content = readFileSync(resolved, 'utf-8');
|
|
747
|
+
const truncated =
|
|
748
|
+
content.length > 10000
|
|
749
|
+
? `${content.slice(0, 10000)}\n... (truncated — showing 10,000 of ${content.length.toLocaleString()} chars)`
|
|
750
|
+
: content;
|
|
751
|
+
expandedText = expandedText.replace(
|
|
752
|
+
ref,
|
|
753
|
+
`\n<file path="${filePath}">\n${truncated}\n</file>`
|
|
754
|
+
);
|
|
755
|
+
} catch {
|
|
756
|
+
// File not found — leave the @reference as-is
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Append user message to the conversation
|
|
762
|
+
const userMsg: UIMessage = {
|
|
763
|
+
id: crypto.randomUUID(),
|
|
764
|
+
role: 'user',
|
|
765
|
+
content: trimmed, // Show original text in the UI
|
|
766
|
+
timestamp: new Date(),
|
|
767
|
+
};
|
|
768
|
+
setMessages(prev => [...prev, userMsg]);
|
|
769
|
+
setIsProcessing(true);
|
|
770
|
+
setProcessingStartTime(Date.now());
|
|
771
|
+
|
|
772
|
+
if (onMessage) {
|
|
773
|
+
onMessage(expandedText); // Send expanded text to the agent
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
[
|
|
777
|
+
onMessage,
|
|
778
|
+
onCompact,
|
|
779
|
+
onContext,
|
|
780
|
+
onUndo,
|
|
781
|
+
onRedo,
|
|
782
|
+
onSessions,
|
|
783
|
+
onNewSession,
|
|
784
|
+
onSwitchSession,
|
|
785
|
+
onModels,
|
|
786
|
+
onClear,
|
|
787
|
+
onModelChange,
|
|
788
|
+
onModeChange,
|
|
789
|
+
session.id,
|
|
790
|
+
session.model,
|
|
791
|
+
session.mode,
|
|
792
|
+
]
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
/** Handle abort from InputBox (Escape key). */
|
|
796
|
+
const handleAbort = useCallback(() => {
|
|
797
|
+
setIsProcessing(false);
|
|
798
|
+
setProcessingStartTime(null);
|
|
799
|
+
if (onAbort) {
|
|
800
|
+
onAbort();
|
|
801
|
+
}
|
|
802
|
+
}, [onAbort]);
|
|
803
|
+
|
|
804
|
+
/** Handle permission prompt decisions. */
|
|
805
|
+
const handlePermission = useCallback(
|
|
806
|
+
(decision: PermissionDecision) => {
|
|
807
|
+
if (permissionRequest) {
|
|
808
|
+
permissionRequest.onDecide(decision);
|
|
809
|
+
}
|
|
810
|
+
setPermissionRequest(null);
|
|
811
|
+
},
|
|
812
|
+
[permissionRequest]
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
/** Handle deploy preview decisions. */
|
|
816
|
+
const handleDeployDecision = useCallback((_decision: DeployDecision) => {
|
|
817
|
+
// The parent orchestrator handles the actual decision; we just
|
|
818
|
+
// close the overlay here.
|
|
819
|
+
setDeployPreview(null);
|
|
820
|
+
}, []);
|
|
821
|
+
|
|
822
|
+
/* -- Global keyboard shortcuts ----------------------------------------- */
|
|
823
|
+
|
|
824
|
+
useInput(
|
|
825
|
+
(input, key) => {
|
|
826
|
+
// Tab: cycle modes (only when not in a modal and not typing a slash command)
|
|
827
|
+
// When input starts with '/', Tab is handled by InputBox for autocomplete
|
|
828
|
+
if (key.tab && !permissionRequest && !deployPreview) {
|
|
829
|
+
setSession(prev => {
|
|
830
|
+
const newMode = nextMode(prev.mode);
|
|
831
|
+
// Propagate mode change to the agent loop so it actually takes effect
|
|
832
|
+
if (onModeChange) {
|
|
833
|
+
onModeChange(newMode);
|
|
834
|
+
}
|
|
835
|
+
return { ...prev, mode: newMode };
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Ctrl+C: interrupt or exit
|
|
841
|
+
if (input === 'c' && key.ctrl) {
|
|
842
|
+
if (isProcessing) {
|
|
843
|
+
handleAbort();
|
|
844
|
+
} else {
|
|
845
|
+
exit();
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Escape: cancel current operation
|
|
851
|
+
if (key.escape) {
|
|
852
|
+
if (permissionRequest) {
|
|
853
|
+
handlePermission('reject');
|
|
854
|
+
} else if (deployPreview) {
|
|
855
|
+
handleDeployDecision('reject');
|
|
856
|
+
} else if (isProcessing) {
|
|
857
|
+
handleAbort();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
// Disable the global handler when the permission or deploy prompt is
|
|
862
|
+
// active so their own useInput handlers take priority.
|
|
863
|
+
{ isActive: !permissionRequest && !deployPreview }
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
/* -- Derived state ----------------------------------------------------- */
|
|
867
|
+
|
|
868
|
+
// Collect tool calls from the last assistant message (if any) plus any
|
|
869
|
+
// currently active tool calls being streamed in.
|
|
870
|
+
const visibleToolCalls: UIToolCall[] = (() => {
|
|
871
|
+
if (activeToolCalls.length > 0) {
|
|
872
|
+
return activeToolCalls;
|
|
873
|
+
}
|
|
874
|
+
// Fall back to the tool calls from the most recent assistant message
|
|
875
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
876
|
+
const msg = messages[i];
|
|
877
|
+
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
|
878
|
+
return msg.toolCalls;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return [];
|
|
882
|
+
})();
|
|
883
|
+
|
|
884
|
+
/* -- Render ------------------------------------------------------------ */
|
|
885
|
+
|
|
886
|
+
return (
|
|
887
|
+
<Box flexDirection="column" width="100%" height="100%">
|
|
888
|
+
{/* Top: Header */}
|
|
889
|
+
<Header session={session} />
|
|
890
|
+
|
|
891
|
+
{/* Middle: scrollable message list (grows to fill space) */}
|
|
892
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
893
|
+
<MessageList messages={messages} mode={session.mode} />
|
|
894
|
+
</Box>
|
|
895
|
+
|
|
896
|
+
{/* Inline tool call display (when tools are active) */}
|
|
897
|
+
{visibleToolCalls.length > 0 && (
|
|
898
|
+
<ToolCallDisplay toolCalls={visibleToolCalls} expanded={isProcessing} />
|
|
899
|
+
)}
|
|
900
|
+
|
|
901
|
+
{/* Modal: Permission prompt */}
|
|
902
|
+
{permissionRequest && (
|
|
903
|
+
<PermissionPrompt
|
|
904
|
+
toolName={permissionRequest.tool}
|
|
905
|
+
toolInput={permissionRequest.input}
|
|
906
|
+
riskLevel={permissionRequest.riskLevel}
|
|
907
|
+
onDecide={handlePermission}
|
|
908
|
+
/>
|
|
909
|
+
)}
|
|
910
|
+
|
|
911
|
+
{/* Modal: Deploy preview */}
|
|
912
|
+
{deployPreview && <DeployPreview preview={deployPreview} onDecide={handleDeployDecision} />}
|
|
913
|
+
|
|
914
|
+
{/* Input area */}
|
|
915
|
+
<InputBox
|
|
916
|
+
onSubmit={handleSubmit}
|
|
917
|
+
onAbort={handleAbort}
|
|
918
|
+
disabled={isProcessing || !!permissionRequest || !!deployPreview}
|
|
919
|
+
placeholder={isProcessing ? 'Agent is thinking...' : undefined}
|
|
920
|
+
/>
|
|
921
|
+
|
|
922
|
+
{/* Bottom: Status bar */}
|
|
923
|
+
<StatusBar
|
|
924
|
+
session={session}
|
|
925
|
+
isProcessing={isProcessing}
|
|
926
|
+
processingStartTime={processingStartTime}
|
|
927
|
+
/>
|
|
928
|
+
</Box>
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/* ---------------------------------------------------------------------------
|
|
933
|
+
* Imperative API types (for external orchestrators)
|
|
934
|
+
* -------------------------------------------------------------------------*/
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Functions that an external orchestrator can use to drive the TUI state.
|
|
938
|
+
* These map directly to the React state setters inside App. The parent
|
|
939
|
+
* component can pass these via a ref or context if needed.
|
|
940
|
+
*/
|
|
941
|
+
export interface AppImperativeAPI {
|
|
942
|
+
addMessage: (msg: UIMessage) => void;
|
|
943
|
+
updateMessage: (id: string, content: string) => void;
|
|
944
|
+
updateSession: (patch: Partial<SessionInfo>) => void;
|
|
945
|
+
setToolCalls: (calls: UIToolCall[]) => void;
|
|
946
|
+
requestPermission: (req: PermissionRequest) => void;
|
|
947
|
+
showDeployPreview: (preview: DeployPreviewData) => void;
|
|
948
|
+
setProcessing: (value: boolean) => void;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/* ---------------------------------------------------------------------------
|
|
952
|
+
* Error Boundary
|
|
953
|
+
* -------------------------------------------------------------------------*/
|
|
954
|
+
|
|
955
|
+
interface ErrorBoundaryState {
|
|
956
|
+
hasError: boolean;
|
|
957
|
+
error: Error | null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Catches uncaught React render errors and displays a recovery message
|
|
962
|
+
* instead of crashing the entire TUI.
|
|
963
|
+
*/
|
|
964
|
+
export class AppErrorBoundary extends React.Component<
|
|
965
|
+
{ children: React.ReactNode },
|
|
966
|
+
ErrorBoundaryState
|
|
967
|
+
> {
|
|
968
|
+
constructor(props: { children: React.ReactNode }) {
|
|
969
|
+
super(props);
|
|
970
|
+
this.state = { hasError: false, error: null };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
974
|
+
return { hasError: true, error };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
render() {
|
|
978
|
+
if (this.state.hasError) {
|
|
979
|
+
const msg = this.state.error?.message || 'Unknown error';
|
|
980
|
+
return (
|
|
981
|
+
<Box flexDirection="column" padding={1}>
|
|
982
|
+
<Text color="red" bold>
|
|
983
|
+
Nimbus TUI encountered an error:
|
|
984
|
+
</Text>
|
|
985
|
+
<Text color="red">{msg}</Text>
|
|
986
|
+
<Text dimColor>
|
|
987
|
+
{'\n'}The interactive UI has crashed. You can:
|
|
988
|
+
{'\n'} 1. Restart nimbus
|
|
989
|
+
{'\n'} 2. Use readline mode: nimbus chat --ui=readline
|
|
990
|
+
</Text>
|
|
991
|
+
</Box>
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return this.props.children;
|
|
996
|
+
}
|
|
997
|
+
}
|