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