@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.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. 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
+ });