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.
- package/README.md +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- 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
|
+
});
|