@golba98/codexa 1.0.1
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/README.md +320 -0
- package/bin/codexa.js +445 -0
- package/package.json +45 -0
- package/scripts/audit-codexa-capabilities.mjs +466 -0
- package/scripts/smoke-terminal-bench.mjs +35 -0
- package/src/app.tsx +4561 -0
- package/src/appRenderStability.test.ts +131 -0
- package/src/commands/handler.test.ts +643 -0
- package/src/commands/handler.ts +875 -0
- package/src/config/launchArgs.test.ts +158 -0
- package/src/config/launchArgs.ts +186 -0
- package/src/config/layeredConfig.test.ts +143 -0
- package/src/config/layeredConfig.ts +836 -0
- package/src/config/persistence.test.ts +110 -0
- package/src/config/persistence.ts +311 -0
- package/src/config/runtimeConfig.test.ts +218 -0
- package/src/config/runtimeConfig.ts +554 -0
- package/src/config/settings.test.ts +155 -0
- package/src/config/settings.ts +401 -0
- package/src/config/toml-serialize.ts +98 -0
- package/src/config/trustStore.test.ts +29 -0
- package/src/config/trustStore.ts +68 -0
- package/src/core/attachments.test.ts +155 -0
- package/src/core/attachments.ts +71 -0
- package/src/core/auth/codexAuth.test.ts +68 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/cleanupFastFail.test.ts +76 -0
- package/src/core/cleanupFastFail.ts +67 -0
- package/src/core/clipboard.ts +24 -0
- package/src/core/codex.ts +124 -0
- package/src/core/codexExecArgs.test.ts +195 -0
- package/src/core/codexExecArgs.ts +152 -0
- package/src/core/codexLaunch.test.ts +205 -0
- package/src/core/codexLaunch.ts +162 -0
- package/src/core/codexPrompt.test.ts +252 -0
- package/src/core/codexPrompt.ts +428 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.test.ts +212 -0
- package/src/core/executables/codexExecutable.ts +159 -0
- package/src/core/executables/executableResolver.test.ts +129 -0
- package/src/core/executables/executableResolver.ts +138 -0
- package/src/core/executables/geminiExecutable.test.ts +116 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/executables/pathSanityScan.test.ts +47 -0
- package/src/core/githubDiagnostics.test.ts +92 -0
- package/src/core/githubDiagnostics.ts +222 -0
- package/src/core/hollowResponseFormat.test.ts +58 -0
- package/src/core/hollowResponseFormat.ts +39 -0
- package/src/core/inputDebug.ts +51 -0
- package/src/core/launchContext.test.ts +157 -0
- package/src/core/launchContext.ts +266 -0
- package/src/core/models/codexCapabilities.test.ts +45 -0
- package/src/core/models/codexCapabilities.ts +95 -0
- package/src/core/models/codexModelCapabilities.test.ts +246 -0
- package/src/core/models/codexModelCapabilities.ts +571 -0
- package/src/core/models/modelSpecs.test.ts +283 -0
- package/src/core/models/modelSpecs.ts +300 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.test.ts +230 -0
- package/src/core/perf/renderDebug.ts +373 -0
- package/src/core/planStorage.test.ts +143 -0
- package/src/core/planStorage.ts +141 -0
- package/src/core/process/CommandRunner.test.ts +105 -0
- package/src/core/process/CommandRunner.ts +269 -0
- package/src/core/process/processValidation.ts +101 -0
- package/src/core/projectInstructions.test.ts +50 -0
- package/src/core/projectInstructions.ts +54 -0
- package/src/core/providerLauncher/launcher.test.ts +238 -0
- package/src/core/providerLauncher/launcher.ts +203 -0
- package/src/core/providerLauncher/registry.test.ts +324 -0
- package/src/core/providerLauncher/registry.ts +253 -0
- package/src/core/providerLauncher/types.ts +84 -0
- package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
- package/src/core/providerLauncher/workspaceConfig.ts +407 -0
- package/src/core/providerRuntime/anthropic.test.ts +1120 -0
- package/src/core/providerRuntime/anthropic.ts +576 -0
- package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
- package/src/core/providerRuntime/capabilityProfile.ts +288 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
- package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
- package/src/core/providerRuntime/contextMetadata.ts +409 -0
- package/src/core/providerRuntime/gemini.test.ts +437 -0
- package/src/core/providerRuntime/gemini.ts +784 -0
- package/src/core/providerRuntime/lmstudio.test.ts +168 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.test.ts +787 -0
- package/src/core/providerRuntime/local.ts +754 -0
- package/src/core/providerRuntime/models.ts +150 -0
- package/src/core/providerRuntime/reasoning.ts +17 -0
- package/src/core/providerRuntime/registry.test.ts +233 -0
- package/src/core/providerRuntime/registry.ts +203 -0
- package/src/core/providerRuntime/types.ts +103 -0
- package/src/core/providers/codexJsonStream.test.ts +148 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.test.ts +68 -0
- package/src/core/providers/codexSubprocess.ts +372 -0
- package/src/core/providers/codexTranscript.test.ts +284 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +59 -0
- package/src/core/terminal/terminalCapabilities.test.ts +93 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.test.ts +75 -0
- package/src/core/terminal/terminalControl.ts +147 -0
- package/src/core/terminal/terminalSanitize.test.ts +22 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalSelection.test.ts +42 -0
- package/src/core/terminal/terminalSelection.ts +66 -0
- package/src/core/terminal/terminalTitle.test.ts +328 -0
- package/src/core/terminal/terminalTitle.ts +483 -0
- package/src/core/workspaceActivity.test.ts +163 -0
- package/src/core/workspaceActivity.ts +380 -0
- package/src/core/workspaceGuard.test.ts +151 -0
- package/src/core/workspaceGuard.ts +288 -0
- package/src/core/workspaceRoot.test.ts +23 -0
- package/src/core/workspaceRoot.ts +47 -0
- package/src/exec.test.ts +13 -0
- package/src/exec.ts +72 -0
- package/src/headless/execArgs.test.ts +147 -0
- package/src/headless/execArgs.ts +294 -0
- package/src/headless/execRunner.test.ts +434 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.test.tsx +618 -0
- package/src/index.tsx +296 -0
- package/src/session/appSession.test.ts +897 -0
- package/src/session/appSession.ts +761 -0
- package/src/session/chatLifecycle.test.ts +64 -0
- package/src/session/chatLifecycle.ts +951 -0
- package/src/session/liveRenderScheduler.test.ts +201 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/planFlow.test.ts +103 -0
- package/src/session/planFlow.ts +149 -0
- package/src/session/planTranscript.test.ts +65 -0
- package/src/session/planTranscript.ts +15 -0
- package/src/session/promptRunSchedule.test.ts +36 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +228 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/ActionRequiredBlock.tsx +38 -0
- package/src/ui/ActivityBars.tsx +68 -0
- package/src/ui/ActivityIndicator.test.tsx +58 -0
- package/src/ui/ActivityIndicator.tsx +58 -0
- package/src/ui/AgentBlock.test.ts +6 -0
- package/src/ui/AgentBlock.tsx +130 -0
- package/src/ui/AnimatedStatusText.test.ts +16 -0
- package/src/ui/AnimatedStatusText.tsx +69 -0
- package/src/ui/AppShell.test.tsx +1739 -0
- package/src/ui/AppShell.tsx +698 -0
- package/src/ui/AttachmentImportPanel.test.tsx +204 -0
- package/src/ui/AttachmentImportPanel.tsx +98 -0
- package/src/ui/AuthPanel.tsx +113 -0
- package/src/ui/BackendPicker.tsx +28 -0
- package/src/ui/BottomComposer.test.ts +674 -0
- package/src/ui/BottomComposer.tsx +1028 -0
- package/src/ui/CodexLogo.tsx +55 -0
- package/src/ui/DashCard.tsx +82 -0
- package/src/ui/Markdown.test.ts +157 -0
- package/src/ui/Markdown.tsx +310 -0
- package/src/ui/ModePicker.tsx +27 -0
- package/src/ui/ModelPicker.tsx +31 -0
- package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
- package/src/ui/ModelPickerScreen.test.tsx +99 -0
- package/src/ui/ModelPickerScreen.tsx +416 -0
- package/src/ui/ModelPickerState.test.tsx +151 -0
- package/src/ui/ModelReasoningPicker.test.tsx +447 -0
- package/src/ui/ModelReasoningPicker.tsx +458 -0
- package/src/ui/Panel.tsx +51 -0
- package/src/ui/PermissionsPanel.tsx +78 -0
- package/src/ui/PlanActionPicker.tsx +119 -0
- package/src/ui/PlanReviewPanel.test.tsx +267 -0
- package/src/ui/PlanReviewPanel.tsx +212 -0
- package/src/ui/PromptCardBorder.test.tsx +161 -0
- package/src/ui/ProviderPicker.test.tsx +289 -0
- package/src/ui/ProviderPicker.tsx +321 -0
- package/src/ui/ProviderShortcut.test.tsx +143 -0
- package/src/ui/ReasoningPicker.tsx +46 -0
- package/src/ui/RunFooter.tsx +65 -0
- package/src/ui/SelectionPanel.tsx +67 -0
- package/src/ui/SettingsPanel.test.tsx +233 -0
- package/src/ui/SettingsPanel.tsx +156 -0
- package/src/ui/Spinner.tsx +25 -0
- package/src/ui/StaticIntroItem.tsx +54 -0
- package/src/ui/StaticTranscriptItem.tsx +56 -0
- package/src/ui/TextEntryPanel.tsx +139 -0
- package/src/ui/ThemePicker.tsx +31 -0
- package/src/ui/ThinkingBlock.tsx +100 -0
- package/src/ui/Timeline.test.ts +2067 -0
- package/src/ui/Timeline.tsx +1472 -0
- package/src/ui/TimelineNavigation.test.tsx +201 -0
- package/src/ui/TopHeader.test.tsx +239 -0
- package/src/ui/TopHeader.tsx +257 -0
- package/src/ui/TurnGroup.test.tsx +365 -0
- package/src/ui/TurnGroup.tsx +657 -0
- package/src/ui/busyStatusAnimation.test.ts +30 -0
- package/src/ui/busyStatusAnimation.ts +11 -0
- package/src/ui/commandNormalize.test.ts +142 -0
- package/src/ui/commandNormalize.ts +66 -0
- package/src/ui/diffRenderer.test.ts +102 -0
- package/src/ui/diffRenderer.ts +116 -0
- package/src/ui/focus.ts +61 -0
- package/src/ui/focusFlow.test.tsx +1098 -0
- package/src/ui/inputBuffer.test.ts +151 -0
- package/src/ui/inputBuffer.ts +203 -0
- package/src/ui/layout.test.ts +145 -0
- package/src/ui/layout.ts +287 -0
- package/src/ui/modeDisplay.test.ts +42 -0
- package/src/ui/modeDisplay.ts +52 -0
- package/src/ui/outputPipeline.ts +64 -0
- package/src/ui/progressEntries.ts +156 -0
- package/src/ui/runActivityView.test.ts +89 -0
- package/src/ui/runActivityView.ts +37 -0
- package/src/ui/runLifecycleView.test.tsx +237 -0
- package/src/ui/slashCommands.ts +41 -0
- package/src/ui/statusRenderIsolation.test.tsx +654 -0
- package/src/ui/terminalAnswerFormat.test.ts +19 -0
- package/src/ui/terminalAnswerFormat.ts +128 -0
- package/src/ui/textLayout.test.ts +18 -0
- package/src/ui/textLayout.ts +338 -0
- package/src/ui/theme.tsx +395 -0
- package/src/ui/themeFlow.test.ts +53 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timelineMeasure.ts +3088 -0
- package/src/ui/timelineMeasureCache.test.ts +986 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, useFocus, useInput, useStdin } from "ink";
|
|
3
|
+
import { formatContextCompact } from "../core/providerRuntime/contextMetadata.js";
|
|
4
|
+
import type { ModelSpec } from "../core/models/modelSpecs.js";
|
|
5
|
+
import type { ExternalCliStatus, UIState } from "../session/types.js";
|
|
6
|
+
import { FOCUS_IDS } from "./focus.js";
|
|
7
|
+
import {
|
|
8
|
+
createInputViewport,
|
|
9
|
+
deleteInputBackward,
|
|
10
|
+
deleteInputForward,
|
|
11
|
+
getComposerBodyWidth,
|
|
12
|
+
insertInputText,
|
|
13
|
+
moveCursorLeft,
|
|
14
|
+
moveCursorRight,
|
|
15
|
+
normalizeInputText,
|
|
16
|
+
normalizeCursorOffset,
|
|
17
|
+
} from "./inputBuffer.js";
|
|
18
|
+
import { getModeDisplaySpec } from "./modeDisplay.js";
|
|
19
|
+
import { ActivityIndicator } from "./ActivityIndicator.js";
|
|
20
|
+
import { measureRunFooterRows, MemoizedRunFooter } from "./RunFooter.js";
|
|
21
|
+
import { useTheme } from "./theme.js";
|
|
22
|
+
import { clampVisualText, getShellWidth, type Layout } from "./layout.js";
|
|
23
|
+
import { getTextWidth, splitTextAtColumn } from "./textLayout.js";
|
|
24
|
+
import { useThrottledValue } from "./useThrottledValue.js";
|
|
25
|
+
import { sanitizeTerminalOutput } from "../core/terminal/terminalSanitize.js";
|
|
26
|
+
import { getStdinDebugState, traceInputDebug } from "../core/inputDebug.js";
|
|
27
|
+
import * as renderDebug from "../core/perf/renderDebug.js";
|
|
28
|
+
import { AnimatedStatusText } from "./AnimatedStatusText.js";
|
|
29
|
+
import { isAnimatedBusyState } from "./busyStatusAnimation.js";
|
|
30
|
+
import { Spinner } from "./Spinner.js";
|
|
31
|
+
import type { TerminalSelectionProfile } from "../core/terminal/terminalSelection.js";
|
|
32
|
+
import { getSlashCommandSuggestions, type CommandSuggestion } from "./slashCommands.js";
|
|
33
|
+
|
|
34
|
+
// ─── Types & constants ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
type ComposerPersona = "idle" | "busy" | "answer" | "error";
|
|
37
|
+
type DeleteIntent = "backspace" | "delete";
|
|
38
|
+
|
|
39
|
+
const BRACKETED_PASTE_START = /(?:\u001B)?\[200~/;
|
|
40
|
+
const BRACKETED_PASTE_END = /(?:\u001B)?\[201~/;
|
|
41
|
+
const DELETE_ESCAPE_SEQUENCE = /^\u001b\[3(?:;\d+)?~$/;
|
|
42
|
+
const BACKTAB_ESCAPE_SEQUENCE = /\u001b\[Z/;
|
|
43
|
+
const CTRL_M_ESCAPE_SEQUENCE = /^\u001b\[(?:109|13);5u$/;
|
|
44
|
+
const CTRL_ALT_P_ESCAPE_SEQUENCE = /(?:\x1b\x10|\x1b\[112;[78]u)/;
|
|
45
|
+
const MAX_VISIBLE_INPUT_ROWS = 5;
|
|
46
|
+
|
|
47
|
+
function resolveDeleteIntentFromRawInput(raw: string): DeleteIntent | null {
|
|
48
|
+
if (raw === "\b" || raw === "\x08" || raw === "\u007f" || raw === "\u001b\u007f") {
|
|
49
|
+
return "backspace";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (DELETE_ESCAPE_SEQUENCE.test(raw)) {
|
|
53
|
+
return "delete";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatApprox(n: number): string {
|
|
60
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
61
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
62
|
+
return `${n}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatElapsed(seconds: number): string {
|
|
66
|
+
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
|
|
67
|
+
const s = (seconds % 60).toString().padStart(2, "0");
|
|
68
|
+
return `${m}:${s}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Exported helpers ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export function getTokenBarDisplay(tokensUsed: number, modelSpec: ModelSpec) {
|
|
74
|
+
if (modelSpec.status !== "verified") {
|
|
75
|
+
return {
|
|
76
|
+
usedText: "Context",
|
|
77
|
+
limitText: "Unknown",
|
|
78
|
+
percentage: null as number | null,
|
|
79
|
+
isEstimatedLimit: false,
|
|
80
|
+
hasKnownLimit: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const isEstimated = modelSpec.isEstimated === true;
|
|
84
|
+
const pct = modelSpec.contextWindow > 0
|
|
85
|
+
? Math.min(100, Math.floor((tokensUsed / modelSpec.contextWindow) * 100))
|
|
86
|
+
: 0;
|
|
87
|
+
return {
|
|
88
|
+
usedText: tokensUsed.toLocaleString("en-US"),
|
|
89
|
+
limitText: isEstimated
|
|
90
|
+
? `~${formatContextCompact(modelSpec.contextWindow)}`
|
|
91
|
+
: modelSpec.contextWindow.toLocaleString("en-US"),
|
|
92
|
+
percentage: pct,
|
|
93
|
+
isEstimatedLimit: isEstimated,
|
|
94
|
+
hasKnownLimit: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface BottomComposerProps {
|
|
99
|
+
layout: Layout;
|
|
100
|
+
uiState: UIState;
|
|
101
|
+
themeName?: string;
|
|
102
|
+
mode?: string;
|
|
103
|
+
model?: string;
|
|
104
|
+
reasoningLevel?: string;
|
|
105
|
+
planMode?: boolean;
|
|
106
|
+
showBusyLoader?: boolean;
|
|
107
|
+
tokensUsed?: number;
|
|
108
|
+
modelSpec?: ModelSpec;
|
|
109
|
+
value: string;
|
|
110
|
+
cursor: number;
|
|
111
|
+
onChangeInput: (value: string, cursor: number) => void;
|
|
112
|
+
onSubmit: () => void;
|
|
113
|
+
onCancel: () => void;
|
|
114
|
+
onChangeValue: (value: string) => void;
|
|
115
|
+
onChangeCursor: (cursor: number) => void;
|
|
116
|
+
onHistoryUp: () => void;
|
|
117
|
+
onHistoryDown: () => void;
|
|
118
|
+
onOpenBackendPicker: () => void;
|
|
119
|
+
onOpenProviderPicker?: () => void;
|
|
120
|
+
onOpenModelPicker: () => void;
|
|
121
|
+
onOpenModePicker: () => void;
|
|
122
|
+
onOpenThemePicker: () => void;
|
|
123
|
+
onOpenAuthPanel: () => void;
|
|
124
|
+
onTogglePlanMode: () => void;
|
|
125
|
+
onClear: () => void;
|
|
126
|
+
onCycleMode: () => void;
|
|
127
|
+
onQuit: () => void;
|
|
128
|
+
activeProviderId?: string;
|
|
129
|
+
externalCliStatus?: ExternalCliStatus;
|
|
130
|
+
selectionProfile?: TerminalSelectionProfile;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface BottomComposerMeasureParams {
|
|
134
|
+
layout: Layout;
|
|
135
|
+
uiState: UIState;
|
|
136
|
+
mode?: string;
|
|
137
|
+
model?: string;
|
|
138
|
+
reasoningLevel?: string;
|
|
139
|
+
tokensUsed?: number;
|
|
140
|
+
modelSpec?: ModelSpec;
|
|
141
|
+
value: string;
|
|
142
|
+
cursor: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CommandSuggestionState {
|
|
146
|
+
showSuggestions: boolean;
|
|
147
|
+
reserveSuggestionRow: boolean;
|
|
148
|
+
suggestions: readonly CommandSuggestion[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const FALLBACK_MODEL_SPEC: ModelSpec = {
|
|
152
|
+
status: "unknown",
|
|
153
|
+
contextWindow: null,
|
|
154
|
+
maxOutputTokens: null,
|
|
155
|
+
sourceUrl: "",
|
|
156
|
+
verifiedAt: null,
|
|
157
|
+
error: null,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function getComposerPersona(uiState: UIState): ComposerPersona {
|
|
161
|
+
if (isAnimatedBusyState(uiState.kind)) {
|
|
162
|
+
return "busy";
|
|
163
|
+
}
|
|
164
|
+
if (uiState.kind === "AWAITING_USER_ACTION") {
|
|
165
|
+
return "answer";
|
|
166
|
+
}
|
|
167
|
+
if (uiState.kind === "ERROR") {
|
|
168
|
+
return "error";
|
|
169
|
+
}
|
|
170
|
+
return "idle";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function shouldRenderBusyFooter(layout: Layout, uiState: UIState): boolean {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getComposerToFooterGapRows(layout: Layout): number {
|
|
178
|
+
return layout.mode === "micro" || layout.rows <= 24 ? 0 : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function getCommandSuggestionState({
|
|
182
|
+
value,
|
|
183
|
+
allowCommands,
|
|
184
|
+
inputLocked,
|
|
185
|
+
}: {
|
|
186
|
+
value: string;
|
|
187
|
+
allowCommands: boolean;
|
|
188
|
+
inputLocked: boolean;
|
|
189
|
+
}): CommandSuggestionState {
|
|
190
|
+
const isCmdPrefix = allowCommands && value.startsWith("/");
|
|
191
|
+
const cmdPrefix = value.split(" ")[0]?.toLowerCase() ?? "";
|
|
192
|
+
const canSuggest = !inputLocked && isCmdPrefix && !value.includes(" ");
|
|
193
|
+
const matchingSuggestions = canSuggest ? getSlashCommandSuggestions(cmdPrefix) : [];
|
|
194
|
+
const exactMatch = matchingSuggestions.find((command) => command.cmd === cmdPrefix);
|
|
195
|
+
const exactMatchAliases = exactMatch && "aliases" in exactMatch ? exactMatch.aliases : undefined;
|
|
196
|
+
const suppressExactMatch = exactMatch ? !(exactMatchAliases?.length ?? 0) : true;
|
|
197
|
+
const suggestions = matchingSuggestions.filter((command) => !(suppressExactMatch && command.cmd === cmdPrefix));
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
showSuggestions: canSuggest,
|
|
201
|
+
reserveSuggestionRow: matchingSuggestions.length > 0,
|
|
202
|
+
suggestions,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function measureBottomComposerRows({
|
|
207
|
+
layout,
|
|
208
|
+
uiState,
|
|
209
|
+
value,
|
|
210
|
+
cursor,
|
|
211
|
+
}: BottomComposerMeasureParams): number {
|
|
212
|
+
if (shouldRenderBusyFooter(layout, uiState)) {
|
|
213
|
+
return measureRunFooterRows();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const persona = getComposerPersona(uiState);
|
|
217
|
+
const inputLocked = persona === "busy";
|
|
218
|
+
const allowCommands = persona !== "answer";
|
|
219
|
+
const composerWidth = getShellWidth(layout.cols);
|
|
220
|
+
const composerBodyWidth = getComposerBodyWidth(composerWidth);
|
|
221
|
+
const promptWidth = Math.max(4, composerBodyWidth - getTextWidth("❯ "));
|
|
222
|
+
const normalizedValue = normalizeInputText(value);
|
|
223
|
+
const normalizedCursor = normalizeCursorOffset(normalizedValue, cursor);
|
|
224
|
+
const promptViewport = createInputViewport({
|
|
225
|
+
text: normalizedValue,
|
|
226
|
+
cursorOffset: normalizedCursor,
|
|
227
|
+
width: promptWidth,
|
|
228
|
+
maxVisibleRows: MAX_VISIBLE_INPUT_ROWS,
|
|
229
|
+
scrollRow: 0,
|
|
230
|
+
});
|
|
231
|
+
const commandSuggestionState = getCommandSuggestionState({
|
|
232
|
+
value: normalizedValue,
|
|
233
|
+
allowCommands,
|
|
234
|
+
inputLocked,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ALWAYS reserve 1 row for the status line to prevent height shifting between idle and busy states.
|
|
238
|
+
const statusLineReservedRows = 1;
|
|
239
|
+
const showMetadata = layout.mode !== "micro" && layout.rows > 24;
|
|
240
|
+
const bottomPadding = layout.mode === "micro" || layout.rows <= 24 ? 0 : 1;
|
|
241
|
+
const footerGapRows = getComposerToFooterGapRows(layout);
|
|
242
|
+
|
|
243
|
+
const visiblePromptRows = inputLocked ? 1 : promptViewport.visibleRows.length;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
visiblePromptRows
|
|
247
|
+
+ 2
|
|
248
|
+
+ (commandSuggestionState.reserveSuggestionRow ? 1 : 0)
|
|
249
|
+
+ footerGapRows
|
|
250
|
+
+ statusLineReservedRows
|
|
251
|
+
+ (showMetadata ? 1 : 0)
|
|
252
|
+
+ bottomPadding
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getExternalCliLabel(providerId: string): string | null {
|
|
257
|
+
if (providerId === "google") return "Gemini CLI";
|
|
258
|
+
if (providerId === "anthropic") return "Claude Code";
|
|
259
|
+
if (providerId === "openai") return "Codex CLI";
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getProviderReadyLabel(providerId: string): string | null {
|
|
264
|
+
if (providerId === "google") return "Gemini";
|
|
265
|
+
if (providerId === "anthropic") return "Claude";
|
|
266
|
+
if (providerId === "openai") return "Codex";
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getStatusLine(
|
|
271
|
+
uiState: UIState,
|
|
272
|
+
activeProviderId?: string,
|
|
273
|
+
runElapsedSeconds?: number,
|
|
274
|
+
externalCliStatus?: ExternalCliStatus,
|
|
275
|
+
): string | null {
|
|
276
|
+
if (uiState.kind === "THINKING") {
|
|
277
|
+
const cliLabel = activeProviderId ? getExternalCliLabel(activeProviderId) : null;
|
|
278
|
+
if (cliLabel && externalCliStatus !== "ready") {
|
|
279
|
+
const elapsed = runElapsedSeconds ?? 0;
|
|
280
|
+
const timerStr = elapsed > 0 ? ` ${formatElapsed(elapsed)}` : "";
|
|
281
|
+
if (elapsed >= 15) return `Still waiting for ${cliLabel}${timerStr}`;
|
|
282
|
+
if (elapsed >= 5) return `${cliLabel} is still starting. The upstream CLI can take a moment${timerStr}`;
|
|
283
|
+
return `Starting ${cliLabel}${timerStr}`;
|
|
284
|
+
}
|
|
285
|
+
return "✧ Codexa is thinking";
|
|
286
|
+
}
|
|
287
|
+
if (uiState.kind === "RESPONDING") {
|
|
288
|
+
const readyLabel = activeProviderId ? getProviderReadyLabel(activeProviderId) : null;
|
|
289
|
+
if (readyLabel) return `✧ ${readyLabel} ready`;
|
|
290
|
+
return "✧ Codexa is thinking";
|
|
291
|
+
}
|
|
292
|
+
if (uiState.kind === "ANSWER_VISIBLE") return "✧ Codexa response complete";
|
|
293
|
+
if (uiState.kind === "SHELL_RUNNING") return "✧ Codexa is running command";
|
|
294
|
+
if (uiState.kind === "AWAITING_USER_ACTION") return "✧ waiting for your answer";
|
|
295
|
+
if (uiState.kind === "ERROR") return uiState.message;
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getVisibleComposerStatusLine({
|
|
300
|
+
uiState,
|
|
301
|
+
value,
|
|
302
|
+
allowCommands,
|
|
303
|
+
activeProviderId,
|
|
304
|
+
runElapsedSeconds,
|
|
305
|
+
externalCliStatus,
|
|
306
|
+
}: {
|
|
307
|
+
uiState: UIState;
|
|
308
|
+
value: string;
|
|
309
|
+
allowCommands: boolean;
|
|
310
|
+
activeProviderId?: string;
|
|
311
|
+
runElapsedSeconds?: number;
|
|
312
|
+
externalCliStatus?: ExternalCliStatus;
|
|
313
|
+
}): string {
|
|
314
|
+
const persona = getComposerPersona(uiState);
|
|
315
|
+
const rawStatusLine = getStatusLine(uiState, activeProviderId, runElapsedSeconds, externalCliStatus) ?? "";
|
|
316
|
+
const isCommandDraft = allowCommands && value.startsWith("/");
|
|
317
|
+
|
|
318
|
+
if (rawStatusLine.length === 0 || persona === "answer" || isCommandDraft) {
|
|
319
|
+
return "";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return rawStatusLine;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getPlaceholder(persona: ComposerPersona): string {
|
|
326
|
+
switch (persona) {
|
|
327
|
+
case "answer":
|
|
328
|
+
return "Type your answer...";
|
|
329
|
+
case "error":
|
|
330
|
+
return "Ask again or use /command";
|
|
331
|
+
case "busy":
|
|
332
|
+
return "";
|
|
333
|
+
case "idle":
|
|
334
|
+
default:
|
|
335
|
+
return "Ask Codexa, run !shell, or use /command";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
export function BottomComposer({
|
|
342
|
+
layout,
|
|
343
|
+
uiState,
|
|
344
|
+
themeName = "purple",
|
|
345
|
+
mode = "",
|
|
346
|
+
model = "",
|
|
347
|
+
reasoningLevel = "",
|
|
348
|
+
planMode = false,
|
|
349
|
+
showBusyLoader = true,
|
|
350
|
+
tokensUsed = 0,
|
|
351
|
+
modelSpec = FALLBACK_MODEL_SPEC,
|
|
352
|
+
value,
|
|
353
|
+
cursor,
|
|
354
|
+
onChangeInput,
|
|
355
|
+
onSubmit,
|
|
356
|
+
onCancel,
|
|
357
|
+
onChangeValue,
|
|
358
|
+
onChangeCursor,
|
|
359
|
+
onHistoryUp,
|
|
360
|
+
onHistoryDown,
|
|
361
|
+
onOpenBackendPicker,
|
|
362
|
+
onOpenProviderPicker = () => undefined,
|
|
363
|
+
onOpenModelPicker,
|
|
364
|
+
onOpenModePicker,
|
|
365
|
+
onOpenThemePicker,
|
|
366
|
+
onOpenAuthPanel,
|
|
367
|
+
onTogglePlanMode,
|
|
368
|
+
onClear,
|
|
369
|
+
onCycleMode,
|
|
370
|
+
onQuit,
|
|
371
|
+
activeProviderId = "",
|
|
372
|
+
externalCliStatus,
|
|
373
|
+
selectionProfile,
|
|
374
|
+
}: BottomComposerProps) {
|
|
375
|
+
renderDebug.useRenderDebug("Composer", {
|
|
376
|
+
cols: layout.cols,
|
|
377
|
+
rows: layout.rows,
|
|
378
|
+
mode: layout.mode,
|
|
379
|
+
uiStateKind: uiState.kind,
|
|
380
|
+
themeName,
|
|
381
|
+
runtimeMode: mode,
|
|
382
|
+
model,
|
|
383
|
+
reasoningLevel,
|
|
384
|
+
planMode,
|
|
385
|
+
tokensUsed,
|
|
386
|
+
modelSpecStatus: modelSpec.status,
|
|
387
|
+
value,
|
|
388
|
+
cursor,
|
|
389
|
+
});
|
|
390
|
+
renderDebug.useLifecycleDebug("Composer", {
|
|
391
|
+
uiStateKind: uiState.kind,
|
|
392
|
+
cols: layout.cols,
|
|
393
|
+
rows: layout.rows,
|
|
394
|
+
mode: layout.mode,
|
|
395
|
+
});
|
|
396
|
+
renderDebug.traceLayoutValidity("Composer", {
|
|
397
|
+
cols: layout.cols,
|
|
398
|
+
rows: layout.rows,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const { stdin } = useStdin();
|
|
402
|
+
const theme = useTheme();
|
|
403
|
+
const { cols, mode: layoutMode } = layout;
|
|
404
|
+
const crampedViewport = layout.rows <= 24;
|
|
405
|
+
const { isFocused } = useFocus({ id: FOCUS_IDS.composer, autoFocus: true });
|
|
406
|
+
const [cursorVisible, setCursorVisible] = useState(true);
|
|
407
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
408
|
+
const [scrollRow, setScrollRow] = useState(0);
|
|
409
|
+
const persona = getComposerPersona(uiState);
|
|
410
|
+
const [runElapsedSeconds, setRunElapsedSeconds] = useState(0);
|
|
411
|
+
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
if (uiState.kind !== "THINKING") {
|
|
414
|
+
setRunElapsedSeconds(0);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
setRunElapsedSeconds(0);
|
|
418
|
+
const interval = setInterval(() => {
|
|
419
|
+
setRunElapsedSeconds((s) => s + 1);
|
|
420
|
+
}, 1_000);
|
|
421
|
+
return () => clearInterval(interval);
|
|
422
|
+
}, [uiState.kind]);
|
|
423
|
+
|
|
424
|
+
const inputLocked = persona === "busy";
|
|
425
|
+
const allowCommands = persona !== "answer";
|
|
426
|
+
const allowHistory = persona === "idle" || persona === "error";
|
|
427
|
+
const promptPrefix = "❯ ";
|
|
428
|
+
const composerWidth = getShellWidth(cols);
|
|
429
|
+
const composerBodyWidth = getComposerBodyWidth(composerWidth);
|
|
430
|
+
const promptWidth = Math.max(4, composerBodyWidth - getTextWidth(promptPrefix));
|
|
431
|
+
const valueRef = useRef(value);
|
|
432
|
+
const cursorRef = useRef(cursor);
|
|
433
|
+
const lastPropsValueRef = useRef(value);
|
|
434
|
+
const lastPropsCursorRef = useRef(cursor);
|
|
435
|
+
const pasteBufferRef = useRef<string | null>(null);
|
|
436
|
+
const deleteIntentRef = useRef<DeleteIntent | null>(null);
|
|
437
|
+
const backtabEventTickRef = useRef(false);
|
|
438
|
+
const ctrlMEventTickRef = useRef(false);
|
|
439
|
+
const ctrlAltPEventTickRef = useRef(false);
|
|
440
|
+
const mouseEventTickRef = useRef(false);
|
|
441
|
+
const backtabEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
442
|
+
const ctrlMEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
443
|
+
const ctrlAltPEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
444
|
+
const mouseEventTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
const handleRawInput = (chunk: Buffer | string) => {
|
|
448
|
+
const raw = typeof chunk === "string" ? chunk : chunk.toString();
|
|
449
|
+
const intent = resolveDeleteIntentFromRawInput(raw);
|
|
450
|
+
if (intent) {
|
|
451
|
+
deleteIntentRef.current = intent;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (BACKTAB_ESCAPE_SEQUENCE.test(raw)) {
|
|
455
|
+
backtabEventTickRef.current = true;
|
|
456
|
+
if (backtabEventTimeoutRef.current) clearTimeout(backtabEventTimeoutRef.current);
|
|
457
|
+
backtabEventTimeoutRef.current = setTimeout(() => {
|
|
458
|
+
backtabEventTickRef.current = false;
|
|
459
|
+
}, 64);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Ctrl+M is not consistently surfaced as input="m" with key.ctrl.
|
|
463
|
+
// Terminals using CSI-u style modified key reporting often emit
|
|
464
|
+
// ESC[109;5u or ESC[13;5u instead. We also support Ctrl+O as a
|
|
465
|
+
// reliable cross-terminal alternative for opening the model picker.
|
|
466
|
+
if (CTRL_M_ESCAPE_SEQUENCE.test(raw)) {
|
|
467
|
+
ctrlMEventTickRef.current = true;
|
|
468
|
+
if (ctrlMEventTimeoutRef.current) clearTimeout(ctrlMEventTimeoutRef.current);
|
|
469
|
+
ctrlMEventTimeoutRef.current = setTimeout(() => {
|
|
470
|
+
ctrlMEventTickRef.current = false;
|
|
471
|
+
}, 64);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ESC ^P or CSI u style modified key reporting for Ctrl+Alt+P.
|
|
475
|
+
if (CTRL_ALT_P_ESCAPE_SEQUENCE.test(raw)) {
|
|
476
|
+
ctrlAltPEventTickRef.current = true;
|
|
477
|
+
if (ctrlAltPEventTimeoutRef.current) clearTimeout(ctrlAltPEventTimeoutRef.current);
|
|
478
|
+
ctrlAltPEventTimeoutRef.current = setTimeout(() => {
|
|
479
|
+
ctrlAltPEventTickRef.current = false;
|
|
480
|
+
}, 64);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Explicitly detect terminal mouse reporting escape sequences to swallow
|
|
484
|
+
// the fragments (e.g. "[<0;26;24M") that Ink's readline parser sequentially
|
|
485
|
+
// emits after stripping the ESC prefix.
|
|
486
|
+
if (/\u001b\[<(\d+);(\d+);(\d+)([Mm])/.test(raw) || /\u001b\[M/.test(raw)) {
|
|
487
|
+
mouseEventTickRef.current = true;
|
|
488
|
+
if (mouseEventTimeoutRef.current) clearTimeout(mouseEventTimeoutRef.current);
|
|
489
|
+
mouseEventTimeoutRef.current = setTimeout(() => {
|
|
490
|
+
mouseEventTickRef.current = false;
|
|
491
|
+
}, 32);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
stdin.on("data", handleRawInput);
|
|
496
|
+
return () => {
|
|
497
|
+
stdin.off("data", handleRawInput);
|
|
498
|
+
if (backtabEventTimeoutRef.current) clearTimeout(backtabEventTimeoutRef.current);
|
|
499
|
+
if (ctrlMEventTimeoutRef.current) clearTimeout(ctrlMEventTimeoutRef.current);
|
|
500
|
+
if (mouseEventTimeoutRef.current) clearTimeout(mouseEventTimeoutRef.current);
|
|
501
|
+
};
|
|
502
|
+
}, [stdin]);
|
|
503
|
+
|
|
504
|
+
// Sync from props only when props actually change from an external source
|
|
505
|
+
// or after a render cycle has confirmed our local change.
|
|
506
|
+
useEffect(() => {
|
|
507
|
+
if (value !== lastPropsValueRef.current || cursor !== lastPropsCursorRef.current) {
|
|
508
|
+
valueRef.current = value;
|
|
509
|
+
cursorRef.current = cursor;
|
|
510
|
+
lastPropsValueRef.current = value;
|
|
511
|
+
lastPropsCursorRef.current = cursor;
|
|
512
|
+
}
|
|
513
|
+
}, [cursor, value]);
|
|
514
|
+
|
|
515
|
+
const commandSuggestionState = getCommandSuggestionState({
|
|
516
|
+
value,
|
|
517
|
+
allowCommands,
|
|
518
|
+
inputLocked,
|
|
519
|
+
});
|
|
520
|
+
const { showSuggestions, suggestions } = commandSuggestionState;
|
|
521
|
+
const suggestionText = suggestions
|
|
522
|
+
.map((suggestion, index) => `${index === selectedIndex ? "›" : "·"} ${suggestion.cmd}`)
|
|
523
|
+
.join(" ");
|
|
524
|
+
|
|
525
|
+
const rawStatusLine = getVisibleComposerStatusLine({ uiState, value, allowCommands, activeProviderId, runElapsedSeconds, externalCliStatus });
|
|
526
|
+
const showStatusLine = rawStatusLine.length > 0;
|
|
527
|
+
const footerGapRows = getComposerToFooterGapRows(layout);
|
|
528
|
+
|
|
529
|
+
const promptViewport = useMemo(
|
|
530
|
+
() => createInputViewport({
|
|
531
|
+
text: value,
|
|
532
|
+
cursorOffset: normalizeCursorOffset(value, cursor),
|
|
533
|
+
width: promptWidth,
|
|
534
|
+
maxVisibleRows: MAX_VISIBLE_INPUT_ROWS,
|
|
535
|
+
scrollRow,
|
|
536
|
+
}),
|
|
537
|
+
[cursor, promptWidth, scrollRow, value],
|
|
538
|
+
);
|
|
539
|
+
const placeholderText = clampVisualText(getPlaceholder(persona), Math.max(1, promptWidth - 1));
|
|
540
|
+
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
setSelectedIndex(0);
|
|
543
|
+
}, [value]);
|
|
544
|
+
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (promptViewport.scrollRow !== scrollRow) {
|
|
547
|
+
setScrollRow(promptViewport.scrollRow);
|
|
548
|
+
}
|
|
549
|
+
}, [promptViewport.scrollRow, scrollRow]);
|
|
550
|
+
|
|
551
|
+
const commitInputChange = (nextValue: string, nextCursor: number) => {
|
|
552
|
+
const normalizedValue = normalizeInputText(nextValue);
|
|
553
|
+
const normalizedCursor = normalizeCursorOffset(normalizedValue, nextCursor);
|
|
554
|
+
|
|
555
|
+
// Update refs immediately to avoid race conditions with fast input events
|
|
556
|
+
valueRef.current = normalizedValue;
|
|
557
|
+
cursorRef.current = normalizedCursor;
|
|
558
|
+
lastPropsValueRef.current = normalizedValue;
|
|
559
|
+
lastPropsCursorRef.current = normalizedCursor;
|
|
560
|
+
|
|
561
|
+
onChangeInput(normalizedValue, normalizedCursor);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const insertText = (text: string) => {
|
|
565
|
+
if (!text) return;
|
|
566
|
+
const next = insertInputText({
|
|
567
|
+
value: valueRef.current,
|
|
568
|
+
cursorOffset: cursorRef.current,
|
|
569
|
+
text,
|
|
570
|
+
});
|
|
571
|
+
commitInputChange(next.value, next.cursorOffset);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const handlePastedInput = (chunk: string) => {
|
|
575
|
+
let remaining = chunk;
|
|
576
|
+
|
|
577
|
+
while (remaining.length > 0) {
|
|
578
|
+
if (pasteBufferRef.current !== null) {
|
|
579
|
+
const endMatch = BRACKETED_PASTE_END.exec(remaining);
|
|
580
|
+
if (!endMatch) {
|
|
581
|
+
pasteBufferRef.current += remaining;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
pasteBufferRef.current += remaining.slice(0, endMatch.index);
|
|
586
|
+
const pastedText = normalizeInputText(pasteBufferRef.current);
|
|
587
|
+
pasteBufferRef.current = null;
|
|
588
|
+
insertText(pastedText);
|
|
589
|
+
remaining = remaining.slice(endMatch.index + endMatch[0].length);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const startMatch = BRACKETED_PASTE_START.exec(remaining);
|
|
594
|
+
if (!startMatch) {
|
|
595
|
+
insertText(normalizeInputText(remaining));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const prefix = remaining.slice(0, startMatch.index);
|
|
600
|
+
if (prefix) {
|
|
601
|
+
insertText(normalizeInputText(prefix));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
pasteBufferRef.current = "";
|
|
605
|
+
remaining = remaining.slice(startMatch.index + startMatch[0].length);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
useInput((input, key) => {
|
|
610
|
+
if (mouseEventTickRef.current) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (backtabEventTickRef.current) {
|
|
615
|
+
backtabEventTickRef.current = false;
|
|
616
|
+
if (backtabEventTimeoutRef.current) {
|
|
617
|
+
clearTimeout(backtabEventTimeoutRef.current);
|
|
618
|
+
backtabEventTimeoutRef.current = null;
|
|
619
|
+
}
|
|
620
|
+
onTogglePlanMode();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (ctrlMEventTickRef.current) {
|
|
625
|
+
ctrlMEventTickRef.current = false;
|
|
626
|
+
if (ctrlMEventTimeoutRef.current) {
|
|
627
|
+
clearTimeout(ctrlMEventTimeoutRef.current);
|
|
628
|
+
ctrlMEventTimeoutRef.current = null;
|
|
629
|
+
}
|
|
630
|
+
if (!inputLocked) {
|
|
631
|
+
traceInputDebug("model_picker_shortcut_received", {
|
|
632
|
+
handler: "BottomComposer.useInput",
|
|
633
|
+
source: "ctrl-m-csi-u",
|
|
634
|
+
inputLocked,
|
|
635
|
+
allowCommands,
|
|
636
|
+
isFocused,
|
|
637
|
+
stdin: getStdinDebugState(stdin),
|
|
638
|
+
});
|
|
639
|
+
onOpenModelPicker();
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (ctrlAltPEventTickRef.current) {
|
|
645
|
+
ctrlAltPEventTickRef.current = false;
|
|
646
|
+
if (ctrlAltPEventTimeoutRef.current) {
|
|
647
|
+
clearTimeout(ctrlAltPEventTimeoutRef.current);
|
|
648
|
+
ctrlAltPEventTimeoutRef.current = null;
|
|
649
|
+
}
|
|
650
|
+
if (!inputLocked && allowCommands) {
|
|
651
|
+
onOpenProviderPicker();
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (key.ctrl) {
|
|
657
|
+
switch (input) {
|
|
658
|
+
case "q":
|
|
659
|
+
case "c":
|
|
660
|
+
onQuit();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (key.escape) {
|
|
666
|
+
onCancel();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (inputLocked) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (allowCommands && key.ctrl) {
|
|
675
|
+
switch (input) {
|
|
676
|
+
case "b": onOpenBackendPicker(); return;
|
|
677
|
+
case "p":
|
|
678
|
+
if (key.meta) {
|
|
679
|
+
onOpenProviderPicker();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
onOpenModePicker();
|
|
683
|
+
return;
|
|
684
|
+
case "m": onOpenModelPicker(); return;
|
|
685
|
+
case "o":
|
|
686
|
+
traceInputDebug("ctrl_o_received", {
|
|
687
|
+
handler: "BottomComposer.useInput",
|
|
688
|
+
source: "ctrl-o",
|
|
689
|
+
inputLocked,
|
|
690
|
+
allowCommands,
|
|
691
|
+
isFocused,
|
|
692
|
+
stdin: getStdinDebugState(stdin),
|
|
693
|
+
});
|
|
694
|
+
onOpenModelPicker();
|
|
695
|
+
return;
|
|
696
|
+
case "t": onOpenThemePicker(); return;
|
|
697
|
+
case "a": onOpenAuthPanel(); return;
|
|
698
|
+
case "l": onClear(); return;
|
|
699
|
+
case "y": onCycleMode(); return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (allowCommands && key.ctrl && key.return) {
|
|
704
|
+
onOpenModelPicker();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (key.ctrl && (input === "j" || input === "\n")) {
|
|
709
|
+
insertText("\n");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (key.upArrow) {
|
|
714
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
715
|
+
setSelectedIndex((current) => Math.max(0, current - 1));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (allowHistory) onHistoryUp();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (key.downArrow) {
|
|
723
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
724
|
+
setSelectedIndex((current) => Math.min(suggestions.length - 1, current + 1));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (allowHistory) onHistoryDown();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if ((key.tab || key.rightArrow) && showSuggestions && suggestions.length > 0) {
|
|
732
|
+
const selected = suggestions[selectedIndex]?.cmd;
|
|
733
|
+
if (selected) {
|
|
734
|
+
commitInputChange(`${selected} `, selected.length + 1);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (key.return) {
|
|
740
|
+
if (showSuggestions && suggestions.length > 0) {
|
|
741
|
+
const selected = suggestions[selectedIndex];
|
|
742
|
+
const trimmedValue = value.trim().toLowerCase();
|
|
743
|
+
const selectedAliases = selected && "aliases" in selected ? selected.aliases : undefined;
|
|
744
|
+
const isExactPrimary = selected ? trimmedValue === selected.cmd : false;
|
|
745
|
+
const isExactAlias = selectedAliases?.some((alias) => alias === trimmedValue) ?? false;
|
|
746
|
+
if (selected && !isExactPrimary && !isExactAlias) {
|
|
747
|
+
const selectedCmd = selected.cmd;
|
|
748
|
+
commitInputChange(`${selectedCmd} `, selectedCmd.length + 1);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (selected) {
|
|
752
|
+
onSubmit();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (!value.trim()) return;
|
|
758
|
+
onSubmit();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (key.leftArrow) {
|
|
763
|
+
const nextCursor = moveCursorLeft(valueRef.current, cursorRef.current);
|
|
764
|
+
commitInputChange(valueRef.current, nextCursor);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (key.rightArrow) {
|
|
769
|
+
const nextCursor = moveCursorRight(valueRef.current, cursorRef.current);
|
|
770
|
+
commitInputChange(valueRef.current, nextCursor);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (key.backspace || input === "\b" || (input === "\u007f" && !key.delete)) {
|
|
775
|
+
deleteIntentRef.current = null;
|
|
776
|
+
const next = deleteInputBackward({
|
|
777
|
+
value: valueRef.current,
|
|
778
|
+
cursorOffset: cursorRef.current,
|
|
779
|
+
});
|
|
780
|
+
commitInputChange(next.value, next.cursorOffset);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (key.delete || (input === "\u007f" && key.delete)) {
|
|
785
|
+
const deleteIntent = deleteIntentRef.current;
|
|
786
|
+
deleteIntentRef.current = null;
|
|
787
|
+
|
|
788
|
+
if (deleteIntent === "backspace") {
|
|
789
|
+
const next = deleteInputBackward({
|
|
790
|
+
value: valueRef.current,
|
|
791
|
+
cursorOffset: cursorRef.current,
|
|
792
|
+
});
|
|
793
|
+
commitInputChange(next.value, next.cursorOffset);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const next = deleteInputForward({
|
|
798
|
+
value: valueRef.current,
|
|
799
|
+
cursorOffset: cursorRef.current,
|
|
800
|
+
});
|
|
801
|
+
commitInputChange(next.value, next.cursorOffset);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!key.ctrl && !key.meta && !key.escape && input && input.length > 0 && input !== "\u007f" && input !== "\b") {
|
|
806
|
+
handlePastedInput(input);
|
|
807
|
+
}
|
|
808
|
+
}, { isActive: isFocused });
|
|
809
|
+
|
|
810
|
+
const modeDisplay = getModeDisplaySpec(mode, theme);
|
|
811
|
+
const tokenDisplay = getTokenBarDisplay(tokensUsed, modelSpec);
|
|
812
|
+
const tokenColor = tokenDisplay.percentage === null ? theme.DIM
|
|
813
|
+
: tokenDisplay.percentage >= 90 ? theme.ERROR
|
|
814
|
+
: tokenDisplay.percentage >= 70 ? theme.WARNING
|
|
815
|
+
: theme.SUCCESS;
|
|
816
|
+
const reasoningSuffix = reasoningLevel ? ` (${reasoningLevel})` : "";
|
|
817
|
+
const isAnswerMode = persona === "answer";
|
|
818
|
+
const showBusyFooter = shouldRenderBusyFooter(layout, uiState);
|
|
819
|
+
const promptPrefixColor = inputLocked ? theme.DIM : theme.TEXT;
|
|
820
|
+
const lockedInputText = promptViewport.visibleRows[0]?.text ?? " ";
|
|
821
|
+
|
|
822
|
+
// The prompt line is shared between bordered and non-bordered layouts.
|
|
823
|
+
const promptLine = (
|
|
824
|
+
<Box flexDirection="row" width="100%">
|
|
825
|
+
<Text color={promptPrefixColor} bold={!inputLocked}>{promptPrefix}</Text>
|
|
826
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
827
|
+
{value.length === 0 && !inputLocked ? (
|
|
828
|
+
<Box width="100%" overflow="hidden">
|
|
829
|
+
<Text backgroundColor={cursorVisible ? theme.TEXT : undefined} color={cursorVisible ? theme.PANEL : undefined}>{" "}</Text>
|
|
830
|
+
<Text color={theme.DIM}>{placeholderText}</Text>
|
|
831
|
+
</Box>
|
|
832
|
+
) : inputLocked ? (
|
|
833
|
+
<Box key="busy-locked-input" width="100%" overflow="hidden">
|
|
834
|
+
<Text color={theme.DIM}>{lockedInputText || " "}</Text>
|
|
835
|
+
</Box>
|
|
836
|
+
) : (
|
|
837
|
+
promptViewport.visibleRows.map((row, index) => {
|
|
838
|
+
const visibleCursorRow = promptViewport.cursorRow - promptViewport.scrollRow;
|
|
839
|
+
const isCursorRow = index === visibleCursorRow;
|
|
840
|
+
const segments = isCursorRow
|
|
841
|
+
? splitTextAtColumn(row.text, promptViewport.cursorColumn)
|
|
842
|
+
: null;
|
|
843
|
+
|
|
844
|
+
return (
|
|
845
|
+
<Box key={`${row.start}-${row.end}-${index}`} width="100%" overflow="hidden">
|
|
846
|
+
{isCursorRow && segments ? (
|
|
847
|
+
<>
|
|
848
|
+
<Text color={theme.TEXT}>{segments.before}</Text>
|
|
849
|
+
<Text backgroundColor={cursorVisible ? theme.TEXT : undefined} color={cursorVisible ? theme.PANEL : undefined}>
|
|
850
|
+
{segments.current || " "}
|
|
851
|
+
</Text>
|
|
852
|
+
<Text color={theme.TEXT}>{segments.after}</Text>
|
|
853
|
+
</>
|
|
854
|
+
) : (
|
|
855
|
+
<Text color={theme.TEXT}>{row.text || " "}</Text>
|
|
856
|
+
)}
|
|
857
|
+
</Box>
|
|
858
|
+
);
|
|
859
|
+
})
|
|
860
|
+
)}
|
|
861
|
+
</Box>
|
|
862
|
+
</Box>
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
if (showBusyFooter) {
|
|
866
|
+
return <MemoizedRunFooter uiState={uiState} showBusyLoader={showBusyLoader} onCancel={onCancel} onQuit={onQuit} />;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return (
|
|
870
|
+
<Box flexDirection="column" paddingBottom={layoutMode === "micro" || crampedViewport ? 0 : 1} width="100%">
|
|
871
|
+
{isAnswerMode ? (
|
|
872
|
+
// Answer mode: Highlighted prompt
|
|
873
|
+
<Box
|
|
874
|
+
flexDirection="column"
|
|
875
|
+
width="100%"
|
|
876
|
+
paddingX={1}
|
|
877
|
+
paddingY={0}
|
|
878
|
+
borderStyle="round"
|
|
879
|
+
borderColor={theme.WARNING}
|
|
880
|
+
>
|
|
881
|
+
{promptLine}
|
|
882
|
+
</Box>
|
|
883
|
+
) : (
|
|
884
|
+
// Normal mode: clean prompt in rounded border
|
|
885
|
+
<Box
|
|
886
|
+
flexDirection="column"
|
|
887
|
+
width="100%"
|
|
888
|
+
paddingX={1}
|
|
889
|
+
paddingY={0}
|
|
890
|
+
borderStyle="round"
|
|
891
|
+
borderColor={theme.BORDER_SUBTLE}
|
|
892
|
+
>
|
|
893
|
+
{promptLine}
|
|
894
|
+
</Box>
|
|
895
|
+
)}
|
|
896
|
+
|
|
897
|
+
{commandSuggestionState.reserveSuggestionRow && (
|
|
898
|
+
<Box paddingLeft={1} marginTop={0} width="100%" overflow="hidden">
|
|
899
|
+
<Text color={theme.DIM} wrap="truncate">{suggestionText || " "}</Text>
|
|
900
|
+
</Box>
|
|
901
|
+
)}
|
|
902
|
+
|
|
903
|
+
{footerGapRows > 0 && (
|
|
904
|
+
<Box height={footerGapRows} />
|
|
905
|
+
)}
|
|
906
|
+
|
|
907
|
+
{/* Always reserve the status line height */}
|
|
908
|
+
<Box paddingX={1} marginTop={0} height={1} width="100%" justifyContent="space-between" overflow="hidden">
|
|
909
|
+
{showStatusLine && (
|
|
910
|
+
<>
|
|
911
|
+
<Box flexShrink={1} flexGrow={1} overflow="hidden" flexDirection="row">
|
|
912
|
+
{!!getExternalCliLabel(activeProviderId ?? "") && uiState.kind === "THINKING" && (
|
|
913
|
+
<>
|
|
914
|
+
<Spinner color={theme.ACCENT} />
|
|
915
|
+
<Text>{" "}</Text>
|
|
916
|
+
</>
|
|
917
|
+
)}
|
|
918
|
+
<AnimatedStatusText
|
|
919
|
+
baseText={rawStatusLine}
|
|
920
|
+
isActive={!getExternalCliLabel(activeProviderId ?? "") && inputLocked && showBusyLoader}
|
|
921
|
+
isError={persona === "error"}
|
|
922
|
+
/>
|
|
923
|
+
</Box>
|
|
924
|
+
{inputLocked && (
|
|
925
|
+
<Box flexShrink={0}>
|
|
926
|
+
<Text color={theme.DIM}>Esc cancel Ctrl+C quit</Text>
|
|
927
|
+
</Box>
|
|
928
|
+
)}
|
|
929
|
+
{!inputLocked && selectionProfile && (
|
|
930
|
+
<Box flexShrink={0}>
|
|
931
|
+
<Text color={theme.DIM}>{selectionProfile.shortHint}</Text>
|
|
932
|
+
</Box>
|
|
933
|
+
)}
|
|
934
|
+
</>
|
|
935
|
+
)}
|
|
936
|
+
</Box>
|
|
937
|
+
|
|
938
|
+
{layoutMode !== "micro" && !crampedViewport && (
|
|
939
|
+
<Box paddingLeft={1} paddingRight={1} marginTop={0} width="100%" justifyContent="space-between">
|
|
940
|
+
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
941
|
+
<Text
|
|
942
|
+
color={modeDisplay.ringColor}
|
|
943
|
+
backgroundColor={modeDisplay.ringFill}
|
|
944
|
+
bold={modeDisplay.ringBold}
|
|
945
|
+
>
|
|
946
|
+
{modeDisplay.ringGlyph}
|
|
947
|
+
</Text>
|
|
948
|
+
<Text color={theme.DIM}>{" "}</Text>
|
|
949
|
+
<Text color={modeDisplay.labelColor} bold={modeDisplay.labelBold}>{modeDisplay.label}</Text>
|
|
950
|
+
<Text color={theme.DIM}>{" "}{model}{reasoningSuffix}{" Ctrl+O Ctrl+Alt+P"}</Text>
|
|
951
|
+
{planMode && <Text color={theme.ACCENT}>{" Plan"}</Text>}
|
|
952
|
+
</Box>
|
|
953
|
+
<Box flexShrink={0}>
|
|
954
|
+
{tokenDisplay.hasKnownLimit ? (
|
|
955
|
+
<>
|
|
956
|
+
<Text color={theme.DIM}>Context: </Text>
|
|
957
|
+
<Text color={theme.TEXT}>{tokenDisplay.usedText}</Text>
|
|
958
|
+
<Text color={theme.DIM}>
|
|
959
|
+
{" / "}{tokenDisplay.limitText}
|
|
960
|
+
{tokenDisplay.percentage !== null ? ` · ${tokenDisplay.isEstimatedLimit ? "~" : ""}${tokenDisplay.percentage}%` : ""}
|
|
961
|
+
</Text>
|
|
962
|
+
</>
|
|
963
|
+
) : (
|
|
964
|
+
<Text color={theme.DIM}>Context: Unknown</Text>
|
|
965
|
+
)}
|
|
966
|
+
</Box>
|
|
967
|
+
</Box>
|
|
968
|
+
)}
|
|
969
|
+
</Box>
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Helper to extract the relevant uiState kind for comparison
|
|
974
|
+
function getUiStateKey(uiState: UIState): string {
|
|
975
|
+
// Only re-render when the kind changes to a different persona-relevant state
|
|
976
|
+
// THINKING/RESPONDING/AWAITING_USER_ACTION are all "busy" states
|
|
977
|
+
// We don't need to re-render for every streaming update within RESPONDING
|
|
978
|
+
if (isAnimatedBusyState(uiState.kind)) {
|
|
979
|
+
return "busy";
|
|
980
|
+
}
|
|
981
|
+
if (uiState.kind === "AWAITING_USER_ACTION") {
|
|
982
|
+
return "answer";
|
|
983
|
+
}
|
|
984
|
+
if (uiState.kind === "ERROR") {
|
|
985
|
+
return "error";
|
|
986
|
+
}
|
|
987
|
+
return "idle";
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ─── Memoized export ─────────────────────────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
// Memoize to prevent re-renders during streaming when props haven't meaningfully changed
|
|
993
|
+
export const MemoizedBottomComposer = memo(BottomComposer, (prev, next) => {
|
|
994
|
+
// Always re-render if the uiState kind changes to a different persona
|
|
995
|
+
const prevKey = getUiStateKey(prev.uiState);
|
|
996
|
+
const nextKey = getUiStateKey(next.uiState);
|
|
997
|
+
if (prevKey !== nextKey) return false;
|
|
998
|
+
|
|
999
|
+
// Re-render if input-related props change
|
|
1000
|
+
if (prev.value !== next.value) return false;
|
|
1001
|
+
if (prev.cursor !== next.cursor) return false;
|
|
1002
|
+
|
|
1003
|
+
// Re-render if display props change
|
|
1004
|
+
if (prev.mode !== next.mode) return false;
|
|
1005
|
+
if (prev.model !== next.model) return false;
|
|
1006
|
+
if (prev.reasoningLevel !== next.reasoningLevel) return false;
|
|
1007
|
+
if (prev.planMode !== next.planMode) return false;
|
|
1008
|
+
if (prev.showBusyLoader !== next.showBusyLoader) return false;
|
|
1009
|
+
if (prev.tokensUsed !== next.tokensUsed) return false;
|
|
1010
|
+
|
|
1011
|
+
// Re-render if layout changes
|
|
1012
|
+
if (prev.layout.cols !== next.layout.cols) return false;
|
|
1013
|
+
if (prev.layout.rows !== next.layout.rows) return false;
|
|
1014
|
+
if (prev.layout.mode !== next.layout.mode) return false;
|
|
1015
|
+
if (prev.themeName !== next.themeName) return false;
|
|
1016
|
+
|
|
1017
|
+
if (prev.modelSpec?.status !== next.modelSpec?.status) return false;
|
|
1018
|
+
if (prev.modelSpec?.contextWindow !== next.modelSpec?.contextWindow) return false;
|
|
1019
|
+
|
|
1020
|
+
// Re-render if selection profile changes
|
|
1021
|
+
if (prev.selectionProfile?.id !== next.selectionProfile?.id) return false;
|
|
1022
|
+
|
|
1023
|
+
// Re-render if active provider changes (affects status line text)
|
|
1024
|
+
if (prev.activeProviderId !== next.activeProviderId) return false;
|
|
1025
|
+
|
|
1026
|
+
// Skip re-render - streaming updates within RESPONDING don't affect composer
|
|
1027
|
+
return true;
|
|
1028
|
+
});
|