ubume 0.1.0

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