@golba98/codexa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,698 @@
1
+ import React, { memo, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useStdin } from "ink";
3
+ import type { RuntimeSummary } from "../config/runtimeConfig.js";
4
+ import type { CodexAuthState } from "../core/auth/codexAuth.js";
5
+ import { HEADER_CONFIG_DEFAULTS, type HeaderConfig } from "../config/settings.js";
6
+ import * as renderDebug from "../core/perf/renderDebug.js";
7
+ import type { Screen, TimelineEvent, UIState } from "../session/types.js";
8
+ import { isBusy } from "../session/types.js";
9
+ import {
10
+ getShellHeight,
11
+ getShellWidth,
12
+ type Layout,
13
+ type LayoutMode,
14
+ } from "./layout.js";
15
+ import {
16
+ buildActiveRenderItems,
17
+ buildStaticRenderItems,
18
+ buildTimelineItems,
19
+ parseTimelineNavigationInput,
20
+ Timeline,
21
+ TimelineRowView,
22
+ type TimelineItem,
23
+ } from "./Timeline.js";
24
+ import { buildNativeTranscriptParts, type NativeTranscriptRowItem, type TimelineRow } from "./timelineMeasure.js";
25
+ import type { TerminalSelectionProfile } from "../core/terminal/terminalSelection.js";
26
+ import { MemoizedTopHeader, measureTopHeaderRows } from "./TopHeader.js";
27
+
28
+ const COMPACT_HEADER_TO_COMPOSER_GAP_ROWS = 2;
29
+ const MEDIUM_HEADER_TO_COMPOSER_GAP_ROWS = 4;
30
+ const TALL_HEADER_TO_COMPOSER_GAP_ROWS = 6;
31
+
32
+ // ─── Types & constants ────────────────────────────────────────────────────────
33
+
34
+ type AppShellLayout = Layout & { layoutEpoch?: number };
35
+ type NativeStaticItem =
36
+ { type: "rows" } & NativeTranscriptRowItem;
37
+
38
+ export interface AppShellProps {
39
+ layout: AppShellLayout;
40
+ screen: Screen;
41
+ authState: CodexAuthState;
42
+ workspaceLabel: string;
43
+ workspaceRoot?: string | null;
44
+ runtimeSummary?: RuntimeSummary | null;
45
+ staticEvents: TimelineEvent[];
46
+ activeEvents: TimelineEvent[];
47
+ uiState: UIState;
48
+ panel: React.ReactNode;
49
+ mainPanel?: React.ReactNode;
50
+ mainPanelMode?: "viewport" | "full-output";
51
+ composer: React.ReactNode;
52
+ composerRows: number;
53
+ panelHint?: React.ReactNode;
54
+ verboseMode?: boolean;
55
+ mouseCapture?: boolean;
56
+ onMouseActivity?: () => void;
57
+ selectionProfile?: TerminalSelectionProfile;
58
+ clearCount?: number;
59
+ headerConfig?: HeaderConfig;
60
+ }
61
+
62
+ // ─── Helpers & subcomponents ─────────────────────────────────────────────────
63
+
64
+ export function isCrampedViewport(rows: number | undefined): boolean {
65
+ return (rows ?? 24) <= 24;
66
+ }
67
+
68
+ export function calculateNativeSpacerRows({
69
+ shellRows,
70
+ introRows,
71
+ composerRows,
72
+ staticRows,
73
+ liveRows,
74
+ }: {
75
+ shellRows: number;
76
+ introRows: number;
77
+ composerRows: number;
78
+ staticRows: number;
79
+ liveRows: number;
80
+ }): number {
81
+ const availableBodyRows = Math.max(0, shellRows - introRows - composerRows);
82
+ const visibleBodyContentRows = Math.max(0, staticRows) + Math.max(0, liveRows);
83
+ return Math.max(0, availableBodyRows - visibleBodyContentRows);
84
+ }
85
+
86
+ export function calculateColdStartSpacerRows({
87
+ shellRows,
88
+ headerRows,
89
+ composerRows,
90
+ layoutMode,
91
+ availableRows,
92
+ }: {
93
+ shellRows: number;
94
+ headerRows: number;
95
+ composerRows: number;
96
+ layoutMode: LayoutMode;
97
+ availableRows: number;
98
+ }): number {
99
+ if (availableRows <= 0) return 0;
100
+
101
+ const rowsAfterHeaderAndComposer = Math.max(0, shellRows - headerRows - composerRows);
102
+ const preferredRows = layoutMode === "micro" || shellRows <= 18
103
+ ? 1
104
+ : shellRows >= 36
105
+ ? TALL_HEADER_TO_COMPOSER_GAP_ROWS
106
+ : shellRows >= 28
107
+ ? MEDIUM_HEADER_TO_COMPOSER_GAP_ROWS
108
+ : COMPACT_HEADER_TO_COMPOSER_GAP_ROWS;
109
+
110
+ return Math.max(0, Math.min(availableRows, rowsAfterHeaderAndComposer, preferredRows));
111
+ }
112
+
113
+ export function calculateHeaderToContentGapRows(layout: Layout): number {
114
+ if (layout.mode === "micro" || layout.rows <= 18) return 0;
115
+ return 1;
116
+ }
117
+
118
+ function NativeRowsItem({ rows }: { rows: TimelineRow[] }) {
119
+ return (
120
+ <Box flexDirection="column">
121
+ {rows.map((row) => (
122
+ <TimelineRowView key={row.key} row={row} />
123
+ ))}
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ function NativePauseBar({ unseenRows }: { unseenRows: number }) {
129
+ return (
130
+ <Box width="100%" paddingX={1}>
131
+ <Text dimColor>
132
+ {unseenRows > 0
133
+ ? `↓ ${unseenRows} new rows · End to follow`
134
+ : "↓ New output · End to follow"}
135
+ </Text>
136
+ </Box>
137
+ );
138
+ }
139
+
140
+ // ─── Component ────────────────────────────────────────────────────────────────
141
+
142
+ function AppShellInner({
143
+ layout,
144
+ screen,
145
+ authState,
146
+ workspaceLabel,
147
+ workspaceRoot = null,
148
+ runtimeSummary = null,
149
+ staticEvents,
150
+ activeEvents,
151
+ uiState,
152
+ panel,
153
+ mainPanel,
154
+ mainPanelMode = "viewport",
155
+ composer,
156
+ composerRows,
157
+ panelHint,
158
+ verboseMode = false,
159
+ mouseCapture = false,
160
+ onMouseActivity,
161
+ selectionProfile,
162
+ clearCount = 0,
163
+ headerConfig = HEADER_CONFIG_DEFAULTS,
164
+ }: AppShellProps) {
165
+ renderDebug.useRenderDebug("AppShell", {
166
+ cols: layout.cols,
167
+ rows: layout.rows,
168
+ mode: layout.mode,
169
+ layoutEpoch: layout.layoutEpoch,
170
+ screen,
171
+ authState,
172
+ workspaceLabel,
173
+ workspaceRoot,
174
+ runtimeSummary,
175
+ staticEvents,
176
+ activeEvents,
177
+ uiState,
178
+ composer,
179
+ composerRows,
180
+ verboseMode,
181
+ });
182
+ renderDebug.useLifecycleDebug("AppShell", {
183
+ screen,
184
+ cols: layout.cols,
185
+ rows: layout.rows,
186
+ mode: layout.mode,
187
+ });
188
+
189
+ const shellWidth = getShellWidth(layout.cols);
190
+ const shellHeight = getShellHeight(layout.rows);
191
+ const headerRows = measureTopHeaderRows(layout);
192
+ const headerToContentGapRows = calculateHeaderToContentGapRows(layout);
193
+ const showComposer = screen === "main";
194
+ const showMainPanel = screen === "main" && mainPanel !== undefined && mainPanel !== null;
195
+ const showMainPanelFullOutput = showMainPanel && mainPanelMode === "full-output";
196
+ const showTimeline = screen === "main" && !showMainPanel;
197
+ const showPanelStage = screen !== "main";
198
+ const hasUserPrompt = useMemo(
199
+ () => staticEvents.some((e) => e.type === "user") || activeEvents.some((e) => e.type === "user"),
200
+ [staticEvents, activeEvents],
201
+ );
202
+ const previousMeasurements = useRef<{
203
+ timelineRows: number;
204
+ composerRows: number;
205
+ shellHeight: number;
206
+ shellWidth: number;
207
+ } | null>(null);
208
+
209
+ // ── Native mode scroll-pause state ────────────────────────────────────────
210
+ // When the user presses Page Up or Home during streaming, we freeze nativeAllRows
211
+ // so Ink's lastOutputHeight stays constant and the terminal stops auto-scrolling.
212
+ const [nativePaused, setNativePaused] = useState(false);
213
+ const nativePausedRef = useRef(false);
214
+ const frozenNativeRowsRef = useRef<TimelineRow[]>([]);
215
+ const frozenLiveRowCountRef = useRef(0);
216
+ const { stdin } = useStdin();
217
+
218
+ useEffect(() => {
219
+ if (mouseCapture) return;
220
+ if (!isBusy(uiState) && nativePausedRef.current) {
221
+ setNativePaused(false);
222
+ nativePausedRef.current = false;
223
+ }
224
+ }, [mouseCapture, uiState]);
225
+
226
+ useEffect(() => {
227
+ if (mouseCapture || !stdin) return;
228
+
229
+ function handleScrollKeys(chunk: Buffer | string) {
230
+ const raw = typeof chunk === "string" ? chunk : chunk.toString("utf8");
231
+ const actions = parseTimelineNavigationInput(raw);
232
+ if (actions.length === 0) return;
233
+
234
+ const wantsUp = actions.includes("pageUp") || actions.includes("home");
235
+ const wantsDown = actions.includes("pageDown") || actions.includes("end");
236
+
237
+ if (wantsDown && nativePausedRef.current) {
238
+ setNativePaused(false);
239
+ nativePausedRef.current = false;
240
+ } else if (wantsUp && !nativePausedRef.current && isBusy(uiState)) {
241
+ setNativePaused(true);
242
+ nativePausedRef.current = true;
243
+ }
244
+ }
245
+
246
+ stdin.on("data", handleScrollKeys);
247
+ return () => { stdin.off("data", handleScrollKeys); };
248
+ }, [mouseCapture, stdin, uiState]);
249
+ // ── End native mode scroll-pause state ────────────────────────────────────
250
+
251
+ const effectiveShowComposer = showComposer;
252
+ const effectiveComposerRows = effectiveShowComposer ? composerRows : 0;
253
+ const panelHintRows = showPanelStage && panelHint ? 2 : 0;
254
+ const canUseColdStartGap = effectiveShowComposer && !hasUserPrompt && screen === "main" && !showMainPanel;
255
+
256
+ // Timeline/panel owns all vertical space between the live header and fixed composer.
257
+ const coldStartAvailableRows = Math.max(
258
+ 0,
259
+ shellHeight - headerRows - headerToContentGapRows - effectiveComposerRows - panelHintRows - 2,
260
+ );
261
+ const coldStartComposerGapRows = canUseColdStartGap
262
+ ? calculateColdStartSpacerRows({
263
+ shellRows: shellHeight,
264
+ headerRows,
265
+ composerRows: effectiveComposerRows,
266
+ layoutMode: layout.mode,
267
+ availableRows: coldStartAvailableRows,
268
+ })
269
+ : 0;
270
+ const fixedComposerLeadGapRows = mouseCapture ? coldStartComposerGapRows : 0;
271
+ const calculatedTimelineRowsRaw = shellHeight
272
+ - headerRows
273
+ - headerToContentGapRows
274
+ - effectiveComposerRows
275
+ - panelHintRows
276
+ - fixedComposerLeadGapRows;
277
+ const calculatedTimelineRows = Math.max(2, calculatedTimelineRowsRaw);
278
+
279
+ const { finalShellHeight, finalShellWidth, finalTimelineRows } = useMemo(() => {
280
+ const prev = previousMeasurements.current;
281
+ const isValid = shellHeight > 0
282
+ && shellWidth > 0
283
+ && Number.isFinite(shellHeight)
284
+ && Number.isFinite(shellWidth)
285
+ && Number.isFinite(calculatedTimelineRowsRaw)
286
+ && calculatedTimelineRowsRaw >= 2;
287
+
288
+ if (!isValid && prev) {
289
+ renderDebug.traceEvent("layout", "measurementFallback", {
290
+ reason: "invalid-shell-or-timeline-rows",
291
+ shellHeight,
292
+ shellWidth,
293
+ calculatedTimelineRowsRaw,
294
+ previousTimelineRows: prev.timelineRows,
295
+ });
296
+ return {
297
+ finalShellHeight: prev.shellHeight,
298
+ finalShellWidth: prev.shellWidth,
299
+ finalTimelineRows: prev.timelineRows,
300
+ };
301
+ }
302
+
303
+ if (!isValid) {
304
+ renderDebug.traceEvent("layout", "measurementFallback", {
305
+ reason: "invalid-initial-shell-or-timeline-rows",
306
+ shellHeight,
307
+ shellWidth,
308
+ calculatedTimelineRowsRaw,
309
+ clampedTimelineRows: calculatedTimelineRows,
310
+ });
311
+ }
312
+
313
+ return {
314
+ finalShellHeight: shellHeight,
315
+ finalShellWidth: shellWidth,
316
+ finalTimelineRows: Math.max(2, calculatedTimelineRows),
317
+ };
318
+ }, [shellHeight, shellWidth, calculatedTimelineRows, calculatedTimelineRowsRaw]);
319
+
320
+ const nativeTranscriptParts = useMemo(() => {
321
+ if (mouseCapture) {
322
+ return { staticItems: [], liveRows: [] };
323
+ }
324
+
325
+ const staticItems = buildTimelineItems(staticEvents);
326
+ const activeItems = buildTimelineItems(activeEvents);
327
+ const turnIds = [...staticItems, ...activeItems]
328
+ .filter((item): item is Extract<TimelineItem, { type: "turn" }> => item.type === "turn")
329
+ .map((item) => item.turnId);
330
+
331
+ const parts = buildNativeTranscriptParts(
332
+ [
333
+ ...buildStaticRenderItems(staticItems, turnIds, null, null, null),
334
+ ...buildActiveRenderItems(activeItems, turnIds, uiState),
335
+ ],
336
+ {
337
+ totalWidth: finalShellWidth,
338
+ verboseMode,
339
+ debugLabel: "app-shell-native",
340
+ workspaceRoot,
341
+ },
342
+ );
343
+
344
+ // If we're not supposed to show the timeline yet (e.g. during early mount
345
+ // or when in a full-panel mode), we still calculate the static parts
346
+ // but hide the live rows. This keeps the committed row array stable,
347
+ // preventing unnecessary re-renders.
348
+ if (!showTimeline) {
349
+ return { ...parts, liveRows: [] };
350
+ }
351
+
352
+ return parts;
353
+ }, [activeEvents, finalShellWidth, mouseCapture, showTimeline, staticEvents, uiState, verboseMode, workspaceRoot]);
354
+
355
+ // In native mode the root box is content-sized (no fixed height), so without an
356
+ // explicit spacer the composer appears immediately after the intro instead of being
357
+ // anchored near the terminal bottom. The spacer fills the gap between whatever
358
+ // live content exists and where the composer should sit.
359
+ const nativeStaticTranscriptRows = useMemo(
360
+ () => nativeTranscriptParts.staticItems.reduce((total, item) => total + item.rows.length, 0),
361
+ [nativeTranscriptParts.staticItems],
362
+ );
363
+ const nativeSpacerRows = useMemo(() => {
364
+ if (mouseCapture || !effectiveShowComposer || showMainPanel) return 0;
365
+ const rows = calculateNativeSpacerRows({
366
+ shellRows: finalShellHeight,
367
+ introRows: headerRows + headerToContentGapRows,
368
+ composerRows: effectiveComposerRows,
369
+ staticRows: nativeStaticTranscriptRows,
370
+ liveRows: nativeTranscriptParts.liveRows.length,
371
+ });
372
+ // Before the user sends their first prompt, keep a small fixed gap so the
373
+ // composer sits near the logo without a large blank area in between.
374
+ // This cap persists across model/auth system events so the layout stays
375
+ // stable after config changes on cold start.
376
+ if (!hasUserPrompt) {
377
+ return calculateColdStartSpacerRows({
378
+ shellRows: finalShellHeight,
379
+ headerRows,
380
+ composerRows: effectiveComposerRows,
381
+ layoutMode: layout.mode,
382
+ availableRows: rows,
383
+ });
384
+ }
385
+ return rows;
386
+ }, [mouseCapture, effectiveShowComposer, showMainPanel, finalShellHeight, headerRows, headerToContentGapRows, effectiveComposerRows, layout.mode, nativeStaticTranscriptRows, nativeTranscriptParts.liveRows.length, hasUserPrompt]);
387
+
388
+ // In native mode (no SGR capture), committed rows still render inside the
389
+ // body below the live header. Do not use Ink's static output component here
390
+ // because it permanently prepends static output above live output, which
391
+ // would place transcript content before the header regardless of JSX order.
392
+ const nativeStaticAllItems = useMemo<NativeStaticItem[]>(
393
+ () => {
394
+ if (mouseCapture) return [];
395
+ return nativeTranscriptParts.staticItems.map((item) => ({ ...item, type: "rows" as const }));
396
+ },
397
+ [mouseCapture, clearCount, nativeTranscriptParts.staticItems],
398
+ );
399
+ const nativeAllRows = useMemo<TimelineRow[]>(
400
+ () => {
401
+ if (mouseCapture) return [];
402
+
403
+ // When the user has paused auto-follow (pressed Page Up while busy),
404
+ // return the frozen snapshot so Ink's lastOutputHeight stays constant
405
+ // and the terminal stops auto-scrolling to the cursor on each frame.
406
+ if (nativePaused) {
407
+ return frozenNativeRowsRef.current;
408
+ }
409
+
410
+ const allStaticRows = nativeStaticAllItems.flatMap((item) => item.rows);
411
+ // Trim old static rows so lastOutputHeight stays bounded to ~2 terminal heights.
412
+ // Rows that fall off the top are already in terminal scrollback.
413
+ const maxStaticRows = finalShellHeight > 0 ? finalShellHeight * 2 : allStaticRows.length;
414
+ const trimmedStaticRows = allStaticRows.slice(Math.max(0, allStaticRows.length - maxStaticRows));
415
+
416
+ const rows = [
417
+ ...trimmedStaticRows,
418
+ ...(showTimeline ? nativeTranscriptParts.liveRows : []),
419
+ ];
420
+ frozenLiveRowCountRef.current = showTimeline ? nativeTranscriptParts.liveRows.length : 0;
421
+ frozenNativeRowsRef.current = rows;
422
+ return rows;
423
+ },
424
+ [mouseCapture, nativePaused, nativeStaticAllItems, nativeTranscriptParts.liveRows, showTimeline, finalShellHeight],
425
+ );
426
+
427
+ // When paused, count how many live rows have arrived since the freeze point.
428
+ const nativeUnseenRows = nativePaused
429
+ ? Math.max(0, (showTimeline ? nativeTranscriptParts.liveRows.length : 0) - frozenLiveRowCountRef.current)
430
+ : 0;
431
+
432
+ renderDebug.traceEvent("layout", "nativeTranscript", {
433
+ nativeMode: !mouseCapture,
434
+ mouseCapture,
435
+ showTimeline,
436
+ activeEvents: activeEvents.length,
437
+ staticEvents: staticEvents.length,
438
+ staticItems: nativeStaticAllItems.length,
439
+ liveRows: nativeTranscriptParts.liveRows.length,
440
+ contentSized: true,
441
+ finalTimelineRows,
442
+ composerRows: effectiveComposerRows,
443
+ headerRows,
444
+ });
445
+
446
+ renderDebug.traceLayoutValidity("AppShell", {
447
+ cols: layout.cols,
448
+ rows: layout.rows,
449
+ shellWidth,
450
+ shellHeight,
451
+ timelineRows: finalTimelineRows,
452
+ calculatedTimelineRowsRaw,
453
+ composerRows: effectiveComposerRows,
454
+ });
455
+ if (!Number.isFinite(calculatedTimelineRowsRaw) || calculatedTimelineRowsRaw <= 0) {
456
+ renderDebug.traceBlankFrame("AppShell", {
457
+ reason: "invalid-available-timeline-rows",
458
+ availableTimelineRows: calculatedTimelineRowsRaw,
459
+ finalTimelineRows,
460
+ composerRows: effectiveComposerRows,
461
+ shellHeight: finalShellHeight,
462
+ screen,
463
+ uiStateKind: uiState.kind,
464
+ });
465
+ }
466
+
467
+ useEffect(() => {
468
+ const previous = previousMeasurements.current;
469
+ const changed: string[] = [];
470
+ if (!previous) {
471
+ changed.push("mount");
472
+ } else {
473
+ if (previous.timelineRows !== finalTimelineRows) changed.push("availableTimelineRows");
474
+ if (previous.composerRows !== effectiveComposerRows) changed.push("composerRows");
475
+ if (previous.shellHeight !== finalShellHeight) changed.push("height");
476
+ }
477
+
478
+ if (changed.length > 0) {
479
+ renderDebug.traceEvent("layout", "measurementUpdate", {
480
+ reason: changed.join(","),
481
+ availableTimelineRows: finalTimelineRows,
482
+ rawAvailableTimelineRows: calculatedTimelineRowsRaw,
483
+ composerRows: effectiveComposerRows,
484
+ shellHeight: finalShellHeight,
485
+ showComposer,
486
+ showTimeline,
487
+ showMainPanelFullOutput,
488
+ });
489
+ }
490
+
491
+ previousMeasurements.current = {
492
+ timelineRows: finalTimelineRows,
493
+ composerRows: effectiveComposerRows,
494
+ shellHeight: finalShellHeight,
495
+ shellWidth: finalShellWidth,
496
+ };
497
+ }, [calculatedTimelineRowsRaw, effectiveComposerRows, finalShellHeight, finalShellWidth, showComposer, showMainPanelFullOutput, showTimeline, finalTimelineRows]);
498
+
499
+ const clonedComposer = React.isValidElement(composer)
500
+ ? React.cloneElement(composer as React.ReactElement<{ selectionProfile?: TerminalSelectionProfile }>, { selectionProfile })
501
+ : composer;
502
+
503
+ if (showMainPanelFullOutput) {
504
+ return (
505
+ <Box flexDirection="column" width="100%">
506
+ <Box flexDirection="column" width={finalShellWidth}>
507
+ <MemoizedTopHeader
508
+ authState={authState}
509
+ workspaceLabel={workspaceLabel}
510
+ layout={layout}
511
+ runtimeSummary={runtimeSummary}
512
+ headerConfig={headerConfig}
513
+ />
514
+
515
+ {headerToContentGapRows > 0 && (
516
+ <Box height={headerToContentGapRows} />
517
+ )}
518
+
519
+ {mainPanel}
520
+
521
+ {showComposer && (
522
+ <Box flexDirection="column" flexShrink={0}>
523
+ {composer}
524
+ </Box>
525
+ )}
526
+ </Box>
527
+ </Box>
528
+ );
529
+ }
530
+
531
+ // Native mode: no fixed shell height — content-sized so Ink's lastOutputHeight stays small.
532
+ // All visible content remains in one live tree so the header is always the
533
+ // first physical output region.
534
+ if (!mouseCapture) {
535
+ return (
536
+ <Box flexDirection="column" width={finalShellWidth}>
537
+ <MemoizedTopHeader
538
+ authState={authState}
539
+ workspaceLabel={workspaceLabel}
540
+ layout={layout}
541
+ runtimeSummary={runtimeSummary}
542
+ headerConfig={headerConfig}
543
+ />
544
+
545
+ {headerToContentGapRows > 0 && (
546
+ <Box height={headerToContentGapRows} />
547
+ )}
548
+
549
+ {nativeAllRows.length > 0 && (
550
+ <NativeRowsItem rows={nativeAllRows} />
551
+ )}
552
+
553
+ {nativePaused && isBusy(uiState) && (
554
+ <NativePauseBar unseenRows={nativeUnseenRows} />
555
+ )}
556
+
557
+ {showMainPanel && (
558
+ <Box flexDirection="column" paddingY={1} justifyContent="center">
559
+ {mainPanel}
560
+ </Box>
561
+ )}
562
+
563
+ {showPanelStage && (
564
+ <Box
565
+ flexDirection="column"
566
+ overflow="hidden"
567
+ paddingY={1}
568
+ >
569
+ {panel}
570
+ </Box>
571
+ )}
572
+
573
+ {nativeSpacerRows > 0 && (
574
+ <Box height={nativeSpacerRows} />
575
+ )}
576
+
577
+ {effectiveShowComposer && (
578
+ <Box flexDirection="column" flexShrink={0}>
579
+ {composer}
580
+ </Box>
581
+ )}
582
+
583
+ {showPanelStage && panelHint}
584
+ </Box>
585
+ );
586
+ }
587
+
588
+ return (
589
+ <Box flexDirection="column" width="100%" height={finalShellHeight}>
590
+ <Box flexDirection="column" width={finalShellWidth}>
591
+ <MemoizedTopHeader
592
+ authState={authState}
593
+ workspaceLabel={workspaceLabel}
594
+ layout={layout}
595
+ runtimeSummary={runtimeSummary}
596
+ headerConfig={headerConfig}
597
+ />
598
+
599
+ {headerToContentGapRows > 0 && (
600
+ <Box height={headerToContentGapRows} />
601
+ )}
602
+
603
+ {/* Keep Timeline always mounted so its viewport scroll state survives panel open/close.
604
+ display="none" removes it from yoga layout (0 height) without unmounting. */}
605
+ <Box
606
+ flexDirection="column"
607
+ height={finalTimelineRows}
608
+ overflow="hidden"
609
+ display={showTimeline ? "flex" : "none"}
610
+ >
611
+ <Timeline
612
+ key={`timeline-${clearCount}`}
613
+ staticEvents={staticEvents}
614
+ activeEvents={activeEvents}
615
+ layout={layout}
616
+ uiState={uiState}
617
+ viewportRows={finalTimelineRows}
618
+ verboseMode={verboseMode}
619
+ authState={authState}
620
+ workspaceLabel={workspaceLabel}
621
+ workspaceRoot={workspaceRoot}
622
+ mouseCapture={mouseCapture}
623
+ onMouseActivity={onMouseActivity}
624
+ contentSized
625
+ />
626
+ </Box>
627
+
628
+ {showMainPanel && (
629
+ <Box flexDirection="column" height={finalTimelineRows} overflow="hidden" justifyContent="center">
630
+ {mainPanel}
631
+ </Box>
632
+ )}
633
+
634
+ {showPanelStage && (
635
+ <Box flexDirection="column" flexGrow={1} overflow="hidden" paddingY={1}>
636
+ {panel}
637
+ </Box>
638
+ )}
639
+
640
+ {fixedComposerLeadGapRows > 0 && (
641
+ <Box height={fixedComposerLeadGapRows} />
642
+ )}
643
+
644
+ {effectiveShowComposer && (
645
+ <Box flexDirection="column" flexShrink={0}>
646
+ {clonedComposer}
647
+ </Box>
648
+ )}
649
+
650
+ {showPanelStage && panelHint}
651
+ </Box>
652
+ </Box>
653
+ );
654
+ }
655
+
656
+ /**
657
+ * Memoized AppShell — prevents re-renders when irrelevant App state changes.
658
+ *
659
+ * The App component re-renders on every streaming delta (via dispatchSession),
660
+ * cursor move, and conversationChars update. AppShell itself only needs to
661
+ * re-render when the layout, screen, event lists, uiState, or composer
662
+ * layout rows actually change.
663
+ *
664
+ * `composer` must remain in the comparator because MemoizedBottomComposer
665
+ * receives value/cursor updates through this prop; without it the composer
666
+ * would display stale input. Non-main panels are compared so picker content can
667
+ * refresh while the active screen stays unchanged, such as model discovery
668
+ * replacing the loading model picker with the interactive picker.
669
+ */
670
+ export const AppShell = memo(AppShellInner, (prev, next) => {
671
+ const panelPropsEqual = next.screen === "main"
672
+ ? prev.mainPanel === next.mainPanel
673
+ : (prev.panel === next.panel && prev.panelHint === next.panelHint);
674
+
675
+ return (
676
+ prev.layout.cols === next.layout.cols &&
677
+ prev.layout.rows === next.layout.rows &&
678
+ prev.layout.mode === next.layout.mode &&
679
+ prev.layout.layoutEpoch === next.layout.layoutEpoch &&
680
+ prev.screen === next.screen &&
681
+ prev.authState === next.authState &&
682
+ prev.workspaceLabel === next.workspaceLabel &&
683
+ prev.workspaceRoot === next.workspaceRoot &&
684
+ prev.runtimeSummary === next.runtimeSummary &&
685
+ prev.staticEvents === next.staticEvents &&
686
+ prev.activeEvents === next.activeEvents &&
687
+ prev.uiState === next.uiState &&
688
+ prev.composerRows === next.composerRows &&
689
+ prev.composer === next.composer &&
690
+ prev.mainPanel === next.mainPanel &&
691
+ prev.mainPanelMode === next.mainPanelMode &&
692
+ prev.verboseMode === next.verboseMode &&
693
+ prev.mouseCapture === next.mouseCapture &&
694
+ prev.onMouseActivity === next.onMouseActivity &&
695
+ prev.clearCount === next.clearCount &&
696
+ panelPropsEqual
697
+ );
698
+ });