@golba98/codexa 1.0.2 → 1.0.4

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