@golba98/codexa 1.0.2 → 1.0.3

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 (150) hide show
  1. package/README.md +391 -94
  2. package/package.json +12 -7
  3. package/src/app.tsx +149 -117
  4. package/src/commands/handler.ts +4 -4
  5. package/src/config/buildInfo.ts +2 -2
  6. package/src/config/persistence.ts +10 -0
  7. package/src/config/settings.ts +7 -15
  8. package/src/config/updateCheckCache.ts +19 -1
  9. package/src/core/channel.ts +23 -0
  10. package/src/core/executables/codexExecutable.ts +1 -0
  11. package/src/core/executables/executableResolver.ts +61 -43
  12. package/src/core/launchContext.ts +33 -33
  13. package/src/core/process/processValidation.ts +9 -5
  14. package/src/core/providerLauncher/launcher.ts +59 -42
  15. package/src/core/updateCheck.ts +111 -95
  16. package/src/session/types.ts +2 -1
  17. package/src/ui/ActionRequiredBlock.tsx +5 -5
  18. package/src/ui/ActivityBars.tsx +3 -3
  19. package/src/ui/ActivityIndicator.tsx +6 -6
  20. package/src/ui/AgentBlock.tsx +6 -6
  21. package/src/ui/AnimatedStatusText.tsx +1 -1
  22. package/src/ui/AppShell.tsx +9 -24
  23. package/src/ui/AttachmentImportPanel.tsx +8 -8
  24. package/src/ui/AuthPanel.tsx +20 -20
  25. package/src/ui/BottomComposer.tsx +117 -78
  26. package/src/ui/DashCard.tsx +3 -3
  27. package/src/ui/Markdown.tsx +17 -17
  28. package/src/ui/ModelPickerScreen.tsx +10 -10
  29. package/src/ui/ModelReasoningPicker.tsx +14 -14
  30. package/src/ui/Panel.tsx +3 -3
  31. package/src/ui/PlanActionPicker.tsx +6 -6
  32. package/src/ui/PlanReviewPanel.tsx +8 -8
  33. package/src/ui/ProviderPicker.tsx +18 -18
  34. package/src/ui/RunFooter.tsx +3 -3
  35. package/src/ui/SelectionPanel.tsx +4 -4
  36. package/src/ui/SettingsPanel.tsx +8 -8
  37. package/src/ui/Spinner.tsx +1 -1
  38. package/src/ui/TextEntryPanel.tsx +10 -10
  39. package/src/ui/ThinkingBlock.tsx +8 -8
  40. package/src/ui/Timeline.tsx +21 -17
  41. package/src/ui/TopHeader.tsx +421 -293
  42. package/src/ui/TurnGroup.tsx +31 -31
  43. package/src/ui/UpdateAvailableCard.tsx +41 -0
  44. package/src/ui/UpdatePromptPanel.tsx +203 -0
  45. package/src/ui/focus.ts +3 -0
  46. package/src/ui/layout.ts +1 -1
  47. package/src/ui/logoVariants.ts +107 -0
  48. package/src/ui/modeDisplay.ts +12 -12
  49. package/src/ui/runtimeDisplay.ts +100 -0
  50. package/src/ui/theme.tsx +274 -395
  51. package/src/ui/timelineMeasure.ts +29 -19
  52. package/scripts/audit-codexa-capabilities.mjs +0 -466
  53. package/scripts/gen-build-info.mjs +0 -33
  54. package/scripts/smoke-terminal-bench.mjs +0 -35
  55. package/src/appRenderStability.test.ts +0 -131
  56. package/src/commands/handler.test.ts +0 -655
  57. package/src/config/launchArgs.test.ts +0 -189
  58. package/src/config/layeredConfig.test.ts +0 -143
  59. package/src/config/persistence.test.ts +0 -114
  60. package/src/config/runtimeConfig.test.ts +0 -218
  61. package/src/config/settings.test.ts +0 -155
  62. package/src/config/trustStore.test.ts +0 -29
  63. package/src/core/attachments.test.ts +0 -155
  64. package/src/core/auth/codexAuth.test.ts +0 -68
  65. package/src/core/cleanupFastFail.test.ts +0 -76
  66. package/src/core/codexExecArgs.test.ts +0 -195
  67. package/src/core/codexLaunch.test.ts +0 -205
  68. package/src/core/codexPrompt.test.ts +0 -252
  69. package/src/core/executables/codexExecutable.test.ts +0 -212
  70. package/src/core/executables/executableResolver.test.ts +0 -129
  71. package/src/core/executables/geminiExecutable.test.ts +0 -116
  72. package/src/core/executables/pathSanityScan.test.ts +0 -47
  73. package/src/core/githubDiagnostics.test.ts +0 -92
  74. package/src/core/hollowResponseFormat.test.ts +0 -58
  75. package/src/core/launchContext.test.ts +0 -157
  76. package/src/core/models/codexCapabilities.test.ts +0 -45
  77. package/src/core/models/codexModelCapabilities.test.ts +0 -246
  78. package/src/core/models/modelSpecs.test.ts +0 -283
  79. package/src/core/perf/renderDebug.test.ts +0 -230
  80. package/src/core/planStorage.test.ts +0 -143
  81. package/src/core/process/CommandRunner.test.ts +0 -105
  82. package/src/core/projectInstructions.test.ts +0 -50
  83. package/src/core/providerLauncher/launcher.test.ts +0 -238
  84. package/src/core/providerLauncher/registry.test.ts +0 -324
  85. package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
  86. package/src/core/providerRuntime/anthropic.test.ts +0 -1120
  87. package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
  88. package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
  89. package/src/core/providerRuntime/gemini.test.ts +0 -437
  90. package/src/core/providerRuntime/lmstudio.test.ts +0 -168
  91. package/src/core/providerRuntime/local.test.ts +0 -787
  92. package/src/core/providerRuntime/registry.test.ts +0 -233
  93. package/src/core/providers/codexJsonStream.test.ts +0 -148
  94. package/src/core/providers/codexSubprocess.test.ts +0 -68
  95. package/src/core/providers/codexTranscript.test.ts +0 -284
  96. package/src/core/terminal/startupClear.test.ts +0 -55
  97. package/src/core/terminal/terminalCapabilities.test.ts +0 -93
  98. package/src/core/terminal/terminalControl.test.ts +0 -75
  99. package/src/core/terminal/terminalSanitize.test.ts +0 -22
  100. package/src/core/terminal/terminalSelection.test.ts +0 -42
  101. package/src/core/terminal/terminalTitle.test.ts +0 -328
  102. package/src/core/updateCheck.test.ts +0 -194
  103. package/src/core/workspaceActivity.test.ts +0 -163
  104. package/src/core/workspaceGuard.test.ts +0 -151
  105. package/src/core/workspaceRoot.test.ts +0 -23
  106. package/src/exec.test.ts +0 -13
  107. package/src/headless/execArgs.test.ts +0 -147
  108. package/src/headless/execRunner.test.ts +0 -436
  109. package/src/index.test.tsx +0 -620
  110. package/src/session/appSession.test.ts +0 -897
  111. package/src/session/chatLifecycle.test.ts +0 -64
  112. package/src/session/liveRenderScheduler.test.ts +0 -201
  113. package/src/session/planFlow.test.ts +0 -103
  114. package/src/session/planTranscript.test.ts +0 -65
  115. package/src/session/promptRunSchedule.test.ts +0 -36
  116. package/src/ui/ActivityIndicator.test.tsx +0 -58
  117. package/src/ui/AgentBlock.test.ts +0 -6
  118. package/src/ui/AnimatedStatusText.test.ts +0 -16
  119. package/src/ui/AppShell.test.tsx +0 -1776
  120. package/src/ui/AttachmentImportPanel.test.tsx +0 -204
  121. package/src/ui/BottomComposer.test.ts +0 -674
  122. package/src/ui/CodexLogo.tsx +0 -55
  123. package/src/ui/Markdown.test.ts +0 -157
  124. package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
  125. package/src/ui/ModelPickerScreen.test.tsx +0 -99
  126. package/src/ui/ModelPickerState.test.tsx +0 -151
  127. package/src/ui/ModelReasoningPicker.test.tsx +0 -447
  128. package/src/ui/PlanReviewPanel.test.tsx +0 -267
  129. package/src/ui/PromptCardBorder.test.tsx +0 -161
  130. package/src/ui/ProviderPicker.test.tsx +0 -289
  131. package/src/ui/ProviderShortcut.test.tsx +0 -143
  132. package/src/ui/SettingsPanel.test.tsx +0 -233
  133. package/src/ui/Timeline.test.ts +0 -2067
  134. package/src/ui/TimelineNavigation.test.tsx +0 -201
  135. package/src/ui/TopHeader.test.tsx +0 -254
  136. package/src/ui/TurnGroup.test.tsx +0 -365
  137. package/src/ui/busyStatusAnimation.test.ts +0 -30
  138. package/src/ui/commandNormalize.test.ts +0 -142
  139. package/src/ui/diffRenderer.test.ts +0 -102
  140. package/src/ui/focusFlow.test.tsx +0 -1098
  141. package/src/ui/inputBuffer.test.ts +0 -151
  142. package/src/ui/layout.test.ts +0 -146
  143. package/src/ui/modeDisplay.test.ts +0 -42
  144. package/src/ui/runActivityView.test.ts +0 -89
  145. package/src/ui/runLifecycleView.test.tsx +0 -237
  146. package/src/ui/statusRenderIsolation.test.tsx +0 -654
  147. package/src/ui/terminalAnswerFormat.test.ts +0 -19
  148. package/src/ui/textLayout.test.ts +0 -18
  149. package/src/ui/themeFlow.test.ts +0 -53
  150. package/src/ui/timelineMeasureCache.test.ts +0 -986
@@ -1,293 +1,421 @@
1
- import React, { memo } from "react";
2
- import { Box, Text } from "ink";
3
- import { APP_VERSION, HEADER_CONFIG_DEFAULTS, type HeaderConfig } from "../config/settings.js";
4
- import type { RuntimeSummary } from "../config/runtimeConfig.js";
5
- import type { CodexAuthState } from "../core/auth/codexAuth.js";
6
- import { getAuthStateLabel } from "../core/auth/codexAuth.js";
7
- import * as renderDebug from "../core/perf/renderDebug.js";
8
- import { useTheme } from "./theme.js";
9
- import type { Layout } from "./layout.js";
10
- import { getTextWidth } from "./textLayout.js";
11
-
12
- export const HEADER_WORDMARK_LINES = [
13
- " ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ",
14
- "██╔════╝██╔═══██╗██╔══██╗██╔════╝╚██╗██╔╝██╔══██╗",
15
- "██║ ██║ ██║██║ ██║█████╗ ╚███╔╝ ███████║",
16
- "██║ ██║ ██║██║ ██║██╔══╝ ██╔██╗ ██╔══██║",
17
- "╚██████╗╚██████╔╝██████╔╝███████╗██╔╝ ██╗██║ ██║",
18
- " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
19
- ];
20
-
21
- const MIN_METADATA_COLUMN_WIDTH = 60;
22
- const STACKED_METADATA_GAP_ROWS = 1;
23
- const MIN_LOGO_TERMINAL_WIDTH = getWordmarkWidth() + 2;
24
- const MIN_LOGO_TERMINAL_ROWS = 24;
25
-
26
- export type HeaderHeroMode = "wide" | "stacked" | "compact";
27
-
28
- export interface HeaderHeroLayout {
29
- mode: HeaderHeroMode;
30
- topMarginRows: number;
31
- bottomMarginRows: number;
32
- metadataGapColumns: number;
33
- metadataGapRows: number;
34
- logoRows: number;
35
- metadataRows: number;
36
- totalRows: number;
37
- }
38
-
39
- type HeaderMetadataLine = { key: string; text: string; color: string; bold: boolean };
40
-
41
- interface TopHeaderProps {
42
- authState: CodexAuthState;
43
- workspaceLabel: string;
44
- layout: Layout;
45
- runtimeSummary?: RuntimeSummary | null;
46
- headerConfig?: HeaderConfig;
47
- }
48
-
49
- function getWordmarkWidth(): number {
50
- return HEADER_WORDMARK_LINES.reduce((maxWidth, line) => Math.max(maxWidth, getTextWidth(line)), 0);
51
- }
52
-
53
- function getMetadataRowCount(headerConfig: HeaderConfig): number {
54
- return [
55
- headerConfig.showBrand,
56
- headerConfig.showAuthStatus,
57
- headerConfig.showWorkspace,
58
- headerConfig.showProvider,
59
- headerConfig.showContext,
60
- ].filter(Boolean).length;
61
- }
62
-
63
- function getHeaderVerticalMargins(layout: Layout): { topMarginRows: number; bottomMarginRows: number } {
64
- if (layout.mode !== "full") {
65
- return {
66
- topMarginRows: 0,
67
- bottomMarginRows: 0,
68
- };
69
- }
70
-
71
- if (layout.rows <= 24) {
72
- return { topMarginRows: 0, bottomMarginRows: 0 };
73
- }
74
-
75
- return {
76
- topMarginRows: 1,
77
- bottomMarginRows: 0,
78
- };
79
- }
80
-
81
- export function getHeaderHeroLayout(
82
- layout: Layout,
83
- headerConfig: HeaderConfig = HEADER_CONFIG_DEFAULTS,
84
- ): HeaderHeroLayout {
85
- const { topMarginRows, bottomMarginRows } = getHeaderVerticalMargins(layout);
86
- const metadataRows = getMetadataRowCount(headerConfig);
87
- const canRenderLogo = layout.cols >= MIN_LOGO_TERMINAL_WIDTH && layout.rows >= MIN_LOGO_TERMINAL_ROWS;
88
-
89
- if (!canRenderLogo) {
90
- return {
91
- mode: "compact",
92
- topMarginRows,
93
- bottomMarginRows,
94
- metadataGapColumns: 0,
95
- metadataGapRows: 0,
96
- logoRows: 1,
97
- metadataRows: 0,
98
- totalRows: topMarginRows + 1 + bottomMarginRows,
99
- };
100
- }
101
-
102
- const logoRows = HEADER_WORDMARK_LINES.length;
103
- const metadataGapColumns = layout.cols >= 150 ? 6 : layout.cols >= 130 ? 5 : 4;
104
- const canUseWideHero = layout.cols >= getWordmarkWidth() + metadataGapColumns + MIN_METADATA_COLUMN_WIDTH + 2;
105
- const metadataGapRows = canUseWideHero || metadataRows === 0 ? 0 : STACKED_METADATA_GAP_ROWS;
106
- const contentRows = canUseWideHero
107
- ? logoRows
108
- : logoRows + metadataGapRows + metadataRows;
109
-
110
- return {
111
- mode: canUseWideHero ? "wide" : "stacked",
112
- topMarginRows,
113
- bottomMarginRows,
114
- metadataGapColumns,
115
- metadataGapRows,
116
- logoRows,
117
- metadataRows,
118
- totalRows: topMarginRows + contentRows + bottomMarginRows,
119
- };
120
- }
121
-
122
- export function measureTopHeaderRows(
123
- layout: Layout,
124
- headerConfig: HeaderConfig = HEADER_CONFIG_DEFAULTS,
125
- ): number {
126
- return getHeaderHeroLayout(layout, headerConfig).totalRows;
127
- }
128
-
129
- /** Truncate a path to fit within maxWidth, keeping the end and prefixing with "... " if needed */
130
- function truncatePath(path: string, maxWidth: number): string {
131
- if (maxWidth <= 4 || path.length <= maxWidth) return path;
132
- // Prefix with "... " (4 chars) and take the last (maxWidth - 4) chars
133
- return "... " + path.slice(path.length - (maxWidth - 4));
134
- }
135
-
136
- export function TopHeader({
137
- authState,
138
- workspaceLabel,
139
- layout,
140
- runtimeSummary = null,
141
- headerConfig = HEADER_CONFIG_DEFAULTS,
142
- }: TopHeaderProps) {
143
- renderDebug.useRenderDebug("Header", {
144
- authState,
145
- workspaceLabel,
146
- cols: layout.cols,
147
- rows: layout.rows,
148
- mode: layout.mode,
149
- });
150
- renderDebug.useLifecycleDebug("Header", {
151
- authState,
152
- cols: layout.cols,
153
- rows: layout.rows,
154
- mode: layout.mode,
155
- });
156
-
157
- const { cols } = layout;
158
- const theme = useTheme();
159
-
160
- const authLabelRaw = getAuthStateLabel(authState);
161
- const authLabel = authLabelRaw.length > 0
162
- ? authLabelRaw[0]!.toUpperCase() + authLabelRaw.slice(1)
163
- : authLabelRaw;
164
-
165
- const wsDisplay = truncatePath(workspaceLabel, Math.max(18, cols - 40));
166
- const heroLayout = getHeaderHeroLayout(layout, headerConfig);
167
- const metadataLines = [
168
- headerConfig.showBrand ? { key: "brand", text: `Codexa v${APP_VERSION}`, color: theme.TEXT, bold: true } : null,
169
- headerConfig.showAuthStatus ? { key: "auth", text: `Auth: ${authLabel}`, color: theme.TEXT, bold: false } : null,
170
- headerConfig.showWorkspace ? { key: "workspace", text: `Workspace: ${wsDisplay}`, color: theme.MUTED, bold: false } : null,
171
- headerConfig.showProvider && runtimeSummary?.providerLabel
172
- ? { key: "provider", text: `Provider: ${runtimeSummary.providerLabel}`, color: theme.TEXT, bold: false }
173
- : null,
174
- headerConfig.showContext
175
- ? { key: "context", text: `Context: ${runtimeSummary?.contextLabel ?? "Unknown"}`, color: theme.MUTED, bold: false }
176
- : null,
177
- ].filter((line): line is HeaderMetadataLine => Boolean(line));
178
-
179
- // Add a 1-row gap between the version line and workspace line in wide mode
180
- // so the two pieces of metadata have breathing room beside the logo.
181
- const hasMetadataGap = heroLayout.mode === "wide"
182
- && metadataLines.length <= 3
183
- && metadataLines.some((l) => l.key === "brand")
184
- && metadataLines.some((l) => l.key === "workspace");
185
- const metadataVisualRows = metadataLines.length + (hasMetadataGap ? 1 : 0);
186
-
187
- const metadataColumn = (
188
- <Box flexDirection="column" flexGrow={1} minWidth={Math.min(MIN_METADATA_COLUMN_WIDTH, Math.max(1, cols - getWordmarkWidth() - heroLayout.metadataGapColumns - 2))}>
189
- {metadataLines.map((line) =>
190
- hasMetadataGap && line.key === "workspace" ? (
191
- <Box key={line.key} marginTop={1}>
192
- <Text color={line.color} bold={line.bold} wrap="truncate">{line.text}</Text>
193
- </Box>
194
- ) : (
195
- <Text key={line.key} color={line.color} bold={line.bold} wrap="truncate">{line.text}</Text>
196
- )
197
- )}
198
- </Box>
199
- );
200
-
201
- const logoColumn = (
202
- <Box flexDirection="column" flexShrink={0}>
203
- {HEADER_WORDMARK_LINES.map((line, i) => (
204
- <Text key={i} color={theme.ACCENT} bold>{line}</Text>
205
- ))}
206
- </Box>
207
- );
208
-
209
- if (heroLayout.mode !== "compact") {
210
- const metadataTopOffset = Math.max(0, Math.floor((HEADER_WORDMARK_LINES.length - metadataVisualRows) / 2));
211
-
212
- return (
213
- <Box flexDirection="column" paddingX={1} width="100%">
214
- {heroLayout.topMarginRows > 0 && (
215
- <Box height={heroLayout.topMarginRows} />
216
- )}
217
-
218
- {heroLayout.mode === "wide" ? (
219
- <Box flexDirection="row" width="100%" alignItems="flex-start">
220
- {logoColumn}
221
- <Box width={heroLayout.metadataGapColumns} flexShrink={0} />
222
- <Box flexDirection="column" flexGrow={1} paddingTop={metadataTopOffset}>
223
- {metadataColumn}
224
- </Box>
225
- </Box>
226
- ) : (
227
- <Box flexDirection="column" width="100%">
228
- {logoColumn}
229
- {heroLayout.metadataGapRows > 0 && (
230
- <Box height={heroLayout.metadataGapRows} />
231
- )}
232
- {metadataColumn}
233
- </Box>
234
- )}
235
-
236
- {heroLayout.bottomMarginRows > 0 && (
237
- <Box height={heroLayout.bottomMarginRows} />
238
- )}
239
- </Box>
240
- );
241
- }
242
-
243
- // Compact / micro / activity-collapsed: single-line header
244
- const compactParts: React.ReactNode[] = [];
245
- if (headerConfig.showBrand) {
246
- compactParts.push(
247
- <Text key="brand" color={theme.TEXT} bold>{`Codexa v${APP_VERSION}`}</Text>,
248
- );
249
- }
250
- if (headerConfig.showAuthStatus) {
251
- if (compactParts.length > 0) compactParts.push(<Text key="sep-auth" color={theme.DIM}>{" · "}</Text>);
252
- compactParts.push(<Text key="auth" color={theme.TEXT}>{authLabel}</Text>);
253
- }
254
- if (headerConfig.showWorkspace) {
255
- if (compactParts.length > 0) compactParts.push(<Text key="sep-ws" color={theme.DIM}>{" · "}</Text>);
256
- compactParts.push(<Text key="ws" color={theme.MUTED} wrap="truncate">{`Workspace: ${wsDisplay}`}</Text>);
257
- }
258
- if (headerConfig.showProvider && runtimeSummary?.providerLabel) {
259
- if (compactParts.length > 0) compactParts.push(<Text key="sep-provider" color={theme.DIM}>{" · "}</Text>);
260
- compactParts.push(<Text key="provider" color={theme.TEXT} wrap="truncate">{`Provider: ${runtimeSummary.providerLabel}`}</Text>);
261
- }
262
- if (headerConfig.showContext) {
263
- if (compactParts.length > 0) compactParts.push(<Text key="sep-context" color={theme.DIM}>{" · "}</Text>);
264
- compactParts.push(<Text key="context" color={theme.MUTED} wrap="truncate">{`Context: ${runtimeSummary?.contextLabel ?? "Unknown"}`}</Text>);
265
- }
266
-
267
- return (
268
- <Box flexDirection="column" paddingX={1} width="100%">
269
- {heroLayout.topMarginRows > 0 && (
270
- <Box height={heroLayout.topMarginRows} />
271
- )}
272
- <Box flexDirection="row" width="100%">
273
- {compactParts}
274
- </Box>
275
- {heroLayout.bottomMarginRows > 0 && (
276
- <Box height={heroLayout.bottomMarginRows} />
277
- )}
278
- </Box>
279
- );
280
- }
281
-
282
- // Memoize to prevent re-renders during streaming when props haven't changed
283
- export const MemoizedTopHeader = memo(TopHeader, (prev, next) => {
284
- return (
285
- prev.authState === next.authState &&
286
- prev.workspaceLabel === next.workspaceLabel &&
287
- prev.layout.cols === next.layout.cols &&
288
- prev.layout.rows === next.layout.rows &&
289
- prev.layout.mode === next.layout.mode &&
290
- prev.runtimeSummary === next.runtimeSummary &&
291
- prev.headerConfig === next.headerConfig
292
- );
293
- });
1
+ import React, { memo } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { HEADER_CONFIG_DEFAULTS, type HeaderConfig } from "../config/settings.js";
4
+ import { formatCodexaBrandLabel } from "../core/channel.js";
5
+ import type { RuntimeSummary } from "../config/runtimeConfig.js";
6
+ import type { CodexAuthState } from "../core/auth/codexAuth.js";
7
+ import { getAuthStateLabel } from "../core/auth/codexAuth.js";
8
+ import * as renderDebug from "../core/perf/renderDebug.js";
9
+ import { useTheme } from "./theme.js";
10
+ import { clampVisualText, type Layout } from "./layout.js";
11
+ import { getTextWidth } from "./textLayout.js";
12
+ import { UPDATE_CARD_ROWS, UpdateAvailableCard } from "./UpdateAvailableCard.js";
13
+ import {
14
+ LOGO_LARGE,
15
+ LOGO_COMPACT_MIN_COLS,
16
+ LOGO_LARGE_MIN_COLS,
17
+ LOGO_LARGE_MIN_ROWS,
18
+ LOGO_MEDIUM_MIN_COLS,
19
+ selectLogoVariantForViewport,
20
+ getLogoWidth,
21
+ } from "./logoVariants.js";
22
+
23
+ // Re-exported for backward compatibility with existing tests.
24
+ export const HEADER_WORDMARK_LINES = LOGO_LARGE;
25
+
26
+ const HEADER_PADDING_COLUMNS = 2;
27
+ const SHELL_GUTTER_COLUMNS = 1;
28
+ // Require 130+ cols for wide side-by-side so the UpdateAvailableCard has room.
29
+ const WIDE_HEADER_MIN_COLUMNS = 130;
30
+ // Require 72+ cols (= LOGO_MEDIUM_MIN_COLS) for medium side-by-side — both
31
+ // LOGO_LARGE (≥100) and LOGO_MEDIUM (72–99) fit cleanly in side-by-side layout.
32
+ const MEDIUM_HEADER_MIN_COLUMNS = LOGO_MEDIUM_MIN_COLS; // 72
33
+ const MIN_SIDE_BY_SIDE_METADATA_WIDTH = 18;
34
+ const STACKED_METADATA_GAP_ROWS = 1;
35
+ // Gap row between the UpdateAvailableCard and the metadata lines.
36
+ const UPDATE_CARD_GAP_ROWS = 1;
37
+ // Recommended terminal size shown in the compact-mode hint when the viewport is
38
+ // too small to render any logo art.
39
+ const RECOMMENDED_FULL_HEADER_HINT = `Resize to ≥${LOGO_LARGE_MIN_COLS}×${LOGO_LARGE_MIN_ROWS} for the full Codexa header`;
40
+
41
+ export type HeaderHeroMode = "wide" | "medium" | "narrow" | "compact";
42
+
43
+ export interface HeaderHeroLayout {
44
+ mode: HeaderHeroMode;
45
+ topMarginRows: number;
46
+ bottomMarginRows: number;
47
+ metadataGapColumns: number;
48
+ metadataGapRows: number;
49
+ logoRows: number;
50
+ metadataRows: number;
51
+ // Extra rows used by the compact-mode recommended-size hint (0 in non-compact
52
+ // modes). Threaded through so measureTopHeaderRows matches what renders.
53
+ compactHintRows: number;
54
+ totalRows: number;
55
+ }
56
+
57
+ type HeaderMetadataLine = { key: string; text: string; color: string; bold: boolean };
58
+
59
+ export interface UpdateAvailableInfo {
60
+ latestVersion: string;
61
+ currentVersion: string;
62
+ }
63
+
64
+ interface TopHeaderProps {
65
+ authState: CodexAuthState;
66
+ workspaceLabel: string;
67
+ layout: Layout;
68
+ runtimeSummary?: RuntimeSummary | null;
69
+ headerConfig?: HeaderConfig;
70
+ updateAvailable?: UpdateAvailableInfo | null;
71
+ }
72
+
73
+ function getHeaderContentWidth(cols: number): number {
74
+ return Math.max(1, cols - SHELL_GUTTER_COLUMNS - HEADER_PADDING_COLUMNS);
75
+ }
76
+
77
+ function getMetadataRowCount(headerConfig: HeaderConfig): number {
78
+ return [
79
+ headerConfig.showBrand,
80
+ headerConfig.showAuthStatus,
81
+ headerConfig.showWorkspace,
82
+ headerConfig.showProvider,
83
+ ].filter(Boolean).length;
84
+ }
85
+
86
+ function getHeaderVerticalMargins(layout: Layout): { topMarginRows: number; bottomMarginRows: number } {
87
+ if (layout.mode !== "full") {
88
+ return {
89
+ topMarginRows: 0,
90
+ bottomMarginRows: 0,
91
+ };
92
+ }
93
+
94
+ if (layout.rows <= 24) {
95
+ return { topMarginRows: 0, bottomMarginRows: 0 };
96
+ }
97
+
98
+ return {
99
+ topMarginRows: 1,
100
+ bottomMarginRows: 0,
101
+ };
102
+ }
103
+
104
+ export function getHeaderHeroLayout(
105
+ layout: Layout,
106
+ headerConfig: HeaderConfig = HEADER_CONFIG_DEFAULTS,
107
+ hasUpdate = false,
108
+ ): HeaderHeroLayout {
109
+ const { topMarginRows, bottomMarginRows } = getHeaderVerticalMargins(layout);
110
+ const metadataRows = getMetadataRowCount(headerConfig);
111
+ const contentWidth = getHeaderContentWidth(layout.cols);
112
+
113
+ // Pick the largest logo that fits both the columns AND the rows. This degrades
114
+ // large → medium → compact on a short terminal instead of collapsing straight
115
+ // to the flat text-only header.
116
+ const logo = selectLogoVariantForViewport(layout.cols, layout.rows);
117
+ const logoWidth = logo.length > 0 ? getLogoWidth(logo) : 0;
118
+ const canRenderLogo = logo.length > 0;
119
+
120
+ if (!canRenderLogo) {
121
+ // Compact / text-only header. Show a recommended-size hint when the logo was
122
+ // dropped purely for size (not because the user disabled ASCII art).
123
+ const sizeHintRows = process.env["CODEXA_NO_ASCII_LOGO"] === "1" ? 0 : 1;
124
+ return {
125
+ mode: "compact",
126
+ topMarginRows,
127
+ bottomMarginRows,
128
+ metadataGapColumns: 0,
129
+ metadataGapRows: 0,
130
+ logoRows: 1,
131
+ metadataRows: 0,
132
+ compactHintRows: sizeHintRows,
133
+ totalRows: topMarginRows + 1 + sizeHintRows + bottomMarginRows,
134
+ };
135
+ }
136
+
137
+ const logoRowCount = logo.length;
138
+ const metadataGapColumns = layout.cols >= WIDE_HEADER_MIN_COLUMNS ? 4 : 2;
139
+ const metadataColumnWidth = contentWidth - logoWidth - metadataGapColumns;
140
+ const canUseSideBySide = metadataRows === 0
141
+ || metadataColumnWidth >= MIN_SIDE_BY_SIDE_METADATA_WIDTH;
142
+ const mode: HeaderHeroMode = canUseSideBySide && layout.cols >= WIDE_HEADER_MIN_COLUMNS
143
+ ? "wide"
144
+ : canUseSideBySide && layout.cols >= MEDIUM_HEADER_MIN_COLUMNS
145
+ ? "medium"
146
+ : "narrow";
147
+ const metadataGapRows = mode === "narrow" && metadataRows > 0 ? STACKED_METADATA_GAP_ROWS : 0;
148
+
149
+ const isSideBySide = mode === "wide" || mode === "medium";
150
+
151
+ // In side-by-side mode the right column grows to: update card + gap + metadata.
152
+ // In narrow/stacked mode, the update card is a single extra line below metadata.
153
+ const rightColRows = isSideBySide
154
+ ? (hasUpdate ? UPDATE_CARD_ROWS + UPDATE_CARD_GAP_ROWS : 0) + metadataRows
155
+ : 0;
156
+
157
+ const contentRows = isSideBySide
158
+ ? Math.max(logoRowCount, rightColRows)
159
+ : logoRowCount + metadataGapRows + metadataRows + (hasUpdate ? STACKED_METADATA_GAP_ROWS + 1 : 0);
160
+
161
+ return {
162
+ mode,
163
+ topMarginRows,
164
+ bottomMarginRows,
165
+ metadataGapColumns,
166
+ metadataGapRows,
167
+ logoRows: logoRowCount,
168
+ metadataRows,
169
+ compactHintRows: 0,
170
+ totalRows: topMarginRows + contentRows + bottomMarginRows,
171
+ };
172
+ }
173
+
174
+ export function measureTopHeaderRows(
175
+ layout: Layout,
176
+ headerConfig: HeaderConfig = HEADER_CONFIG_DEFAULTS,
177
+ hasUpdate = false,
178
+ ): number {
179
+ return getHeaderHeroLayout(layout, headerConfig, hasUpdate).totalRows;
180
+ }
181
+
182
+ function takeVisualSuffix(text: string, maxWidth: number): string {
183
+ if (maxWidth <= 0) return "";
184
+ let output = "";
185
+
186
+ for (const char of Array.from(text).reverse()) {
187
+ if (getTextWidth(char + output) > maxWidth) break;
188
+ output = char + output;
189
+ }
190
+
191
+ return output;
192
+ }
193
+
194
+ export function shortenHeaderWorkspaceLabel(workspaceLabel: string, maxWidth: number): string {
195
+ const trimmed = workspaceLabel.trim();
196
+ if (!trimmed || maxWidth <= 0) return "";
197
+ if (getTextWidth(trimmed) <= maxWidth) return trimmed;
198
+ if (maxWidth <= 1) return "…";
199
+
200
+ const normalized = trimmed.replace(/[\\/]+$/, "");
201
+ const separatorMatch = normalized.match(/[\\/]/g);
202
+ const separator = normalized.includes("\\") && (!separatorMatch || normalized.lastIndexOf("\\") >= normalized.lastIndexOf("/"))
203
+ ? "\\"
204
+ : "/";
205
+ const lastSlash = Math.max(normalized.lastIndexOf("\\"), normalized.lastIndexOf("/"));
206
+ const leaf = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
207
+ const prefix = lastSlash >= 0 ? `…${separator}` : "…";
208
+ const leafWidth = Math.max(1, maxWidth - getTextWidth(prefix));
209
+
210
+ return prefix + takeVisualSuffix(leaf, leafWidth);
211
+ }
212
+
213
+ function clampMetadataText(text: string, maxWidth: number): string {
214
+ return clampVisualText(text, Math.max(1, maxWidth));
215
+ }
216
+
217
+ export function TopHeader({
218
+ authState,
219
+ workspaceLabel,
220
+ layout,
221
+ runtimeSummary = null,
222
+ headerConfig = HEADER_CONFIG_DEFAULTS,
223
+ updateAvailable = null,
224
+ }: TopHeaderProps) {
225
+ renderDebug.useRenderDebug("Header", {
226
+ authState,
227
+ workspaceLabel,
228
+ cols: layout.cols,
229
+ rows: layout.rows,
230
+ mode: layout.mode,
231
+ });
232
+ renderDebug.useLifecycleDebug("Header", {
233
+ authState,
234
+ cols: layout.cols,
235
+ rows: layout.rows,
236
+ mode: layout.mode,
237
+ });
238
+
239
+ const theme = useTheme();
240
+
241
+ const authLabelRaw = getAuthStateLabel(authState);
242
+ const authLabel = authLabelRaw.length > 0
243
+ ? authLabelRaw[0]!.toUpperCase() + authLabelRaw.slice(1)
244
+ : authLabelRaw;
245
+
246
+ const heroLayout = getHeaderHeroLayout(layout, headerConfig, !!updateAvailable);
247
+ const selectedLogo = selectLogoVariantForViewport(layout.cols, layout.rows);
248
+ const selectedLogoWidth = selectedLogo.length > 0 ? getLogoWidth(selectedLogo) : 0;
249
+
250
+ const contentWidth = getHeaderContentWidth(layout.cols);
251
+ const sideBySideMetadataWidth = Math.max(1, contentWidth - selectedLogoWidth - heroLayout.metadataGapColumns);
252
+ const metadataWidth = heroLayout.mode === "wide" || heroLayout.mode === "medium"
253
+ ? sideBySideMetadataWidth
254
+ : contentWidth;
255
+ const workspaceValueWidth = Math.max(1, metadataWidth - getTextWidth("Workspace: "));
256
+ const wsDisplay = shortenHeaderWorkspaceLabel(workspaceLabel, workspaceValueWidth);
257
+ const brandLabel = formatCodexaBrandLabel();
258
+ const metadataLinesRaw = [
259
+ headerConfig.showBrand ? { key: "brand", text: brandLabel, color: theme.text, bold: true } : null,
260
+ headerConfig.showAuthStatus ? { key: "auth", text: `Auth: ${authLabel}`, color: theme.text, bold: false } : null,
261
+ headerConfig.showWorkspace ? { key: "workspace", text: `Workspace: ${wsDisplay}`, color: theme.textMuted, bold: false } : null,
262
+ headerConfig.showProvider && runtimeSummary?.providerLabel
263
+ ? { key: "provider", text: `Provider: ${runtimeSummary.providerLabel}`, color: theme.text, bold: false }
264
+ : null,
265
+ ].filter((line): line is HeaderMetadataLine => Boolean(line));
266
+ const metadataLines = metadataLinesRaw.map((line) => ({
267
+ ...line,
268
+ text: clampMetadataText(line.text, metadataWidth),
269
+ }));
270
+
271
+ // Add a 1-row gap between the version line and workspace line in wide mode
272
+ // so the two pieces of metadata have breathing room beside the logo.
273
+ const hasMetadataGap = heroLayout.mode === "wide"
274
+ && metadataLines.length <= 3
275
+ && metadataLines.some((l) => l.key === "brand")
276
+ && metadataLines.some((l) => l.key === "workspace");
277
+ const metadataColumn = (
278
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} width={metadataWidth}>
279
+ {metadataLines.map((line) =>
280
+ hasMetadataGap && line.key === "workspace" ? (
281
+ <Box key={line.key} marginTop={1}>
282
+ <Text color={line.color} bold={line.bold} wrap="truncate">{line.text}</Text>
283
+ </Box>
284
+ ) : (
285
+ <Text key={line.key} color={line.color} bold={line.bold} wrap="truncate">{line.text}</Text>
286
+ )
287
+ )}
288
+ </Box>
289
+ );
290
+
291
+ // Canonical CODEXA wordmark — uses per-line LOGO palette defined in each theme.
292
+ // No `bold`: bold on Unicode block/box-drawing characters causes per-glyph
293
+ // spacing artifacts in common terminal fonts (Ptyxis, GNOME Terminal).
294
+ // wrap="truncate" keeps each row on exactly one terminal line.
295
+ const logoColumn = (
296
+ <Box flexDirection="column" flexShrink={0}>
297
+ {selectedLogo.map((line, i) => {
298
+ let lineColor = theme.logoPrimary;
299
+ if (selectedLogo.length === 6) {
300
+ if (i === 2 || i === 3) lineColor = theme.logoSecondary;
301
+ else if (i === 4 || i === 5) lineColor = theme.logoShadow;
302
+ }
303
+ return (
304
+ <Text key={i} color={lineColor} wrap="truncate">{line}</Text>
305
+ );
306
+ })}
307
+ </Box>
308
+ );
309
+
310
+ if (heroLayout.mode !== "compact") {
311
+ const isSideBySide = heroLayout.mode === "wide" || heroLayout.mode === "medium";
312
+ const metadataTopOffset = 0;
313
+
314
+ return (
315
+ <Box flexDirection="column" paddingX={1} width="100%">
316
+ {heroLayout.topMarginRows > 0 && (
317
+ <Box height={heroLayout.topMarginRows} />
318
+ )}
319
+
320
+ {isSideBySide ? (
321
+ <Box flexDirection="row" width="100%" alignItems="flex-start">
322
+ {logoColumn}
323
+ <Box width={heroLayout.metadataGapColumns} flexShrink={0} />
324
+ <Box flexDirection="column" flexGrow={1} paddingTop={metadataTopOffset}>
325
+ {updateAvailable && (
326
+ <>
327
+ <UpdateAvailableCard
328
+ latestVersion={updateAvailable.latestVersion}
329
+ currentVersion={updateAvailable.currentVersion}
330
+ width={metadataWidth}
331
+ />
332
+ <Box height={UPDATE_CARD_GAP_ROWS} />
333
+ </>
334
+ )}
335
+ {metadataColumn}
336
+ </Box>
337
+ </Box>
338
+ ) : (
339
+ <Box flexDirection="column" width="100%">
340
+ {logoColumn}
341
+ {heroLayout.metadataGapRows > 0 && (
342
+ <Box height={heroLayout.metadataGapRows} />
343
+ )}
344
+ {metadataColumn}
345
+ {updateAvailable && (
346
+ <Text color={theme.warning} wrap="truncate">{`↑ Update: Codexa ${updateAvailable.latestVersion} is available — run: npm install -g @golba98/codexa@latest`}</Text>
347
+ )}
348
+ </Box>
349
+ )}
350
+
351
+ {heroLayout.bottomMarginRows > 0 && (
352
+ <Box height={heroLayout.bottomMarginRows} />
353
+ )}
354
+ </Box>
355
+ );
356
+ }
357
+
358
+ // Compact / micro / activity-collapsed: single-line header.
359
+ const compactMetadataWidth = contentWidth;
360
+ const compactWorkspaceValueWidth = Math.max(1, compactMetadataWidth - getTextWidth("Workspace: "));
361
+ const compactWorkspaceDisplay = shortenHeaderWorkspaceLabel(workspaceLabel, compactWorkspaceValueWidth);
362
+ // A leading ✦ accent makes the single-line header read as a deliberate
363
+ // compact Codexa header rather than a broken fallback.
364
+ const compactParts: React.ReactNode[] = [
365
+ <Text key="accent" color={theme.accent} bold>{"✦ "}</Text>,
366
+ ];
367
+ if (headerConfig.showBrand) {
368
+ compactParts.push(
369
+ <Text key="brand" color={theme.text} bold>{brandLabel}</Text>,
370
+ );
371
+ }
372
+ if (headerConfig.showAuthStatus) {
373
+ if (compactParts.length > 0) compactParts.push(<Text key="sep-auth" color={theme.textDim}>{" · "}</Text>);
374
+ compactParts.push(<Text key="auth" color={theme.text}>{authLabel}</Text>);
375
+ }
376
+ if (headerConfig.showWorkspace) {
377
+ if (compactParts.length > 0) compactParts.push(<Text key="sep-ws" color={theme.textDim}>{" · "}</Text>);
378
+ compactParts.push(<Text key="ws" color={theme.textMuted} wrap="truncate">{`Workspace: ${compactWorkspaceDisplay}`}</Text>);
379
+ }
380
+ if (headerConfig.showProvider && runtimeSummary?.providerLabel) {
381
+ if (compactParts.length > 0) compactParts.push(<Text key="sep-provider" color={theme.textDim}>{" · "}</Text>);
382
+ compactParts.push(<Text key="provider" color={theme.text} wrap="truncate">{`Provider: ${runtimeSummary.providerLabel}`}</Text>);
383
+ }
384
+
385
+ return (
386
+ <Box flexDirection="column" paddingX={1} width="100%">
387
+ {heroLayout.topMarginRows > 0 && (
388
+ <Box height={heroLayout.topMarginRows} />
389
+ )}
390
+ <Box flexDirection="row" width="100%">
391
+ {compactParts}
392
+ </Box>
393
+ {heroLayout.compactHintRows > 0 && (
394
+ <Text color={theme.textDim} wrap="truncate">{clampMetadataText(RECOMMENDED_FULL_HEADER_HINT, compactMetadataWidth)}</Text>
395
+ )}
396
+ {heroLayout.bottomMarginRows > 0 && (
397
+ <Box height={heroLayout.bottomMarginRows} />
398
+ )}
399
+ </Box>
400
+ );
401
+ }
402
+
403
+ // Memoize to prevent re-renders during streaming when props haven't changed
404
+ export const MemoizedTopHeader = memo(TopHeader, (prev, next) => {
405
+ return (
406
+ prev.authState === next.authState &&
407
+ prev.workspaceLabel === next.workspaceLabel &&
408
+ prev.layout.cols === next.layout.cols &&
409
+ prev.layout.rows === next.layout.rows &&
410
+ prev.layout.mode === next.layout.mode &&
411
+ prev.runtimeSummary === next.runtimeSummary &&
412
+ prev.headerConfig === next.headerConfig &&
413
+ prev.updateAvailable === next.updateAvailable
414
+ );
415
+ });
416
+
417
+ // Minimum terminal cols to render any logo art (the compact 1-row variant).
418
+ export const MIN_LOGO_TERMINAL_WIDTH = LOGO_COMPACT_MIN_COLS;
419
+
420
+ // Re-export for consumers that reference these constants directly.
421
+ export { LOGO_LARGE_MIN_COLS, LOGO_MEDIUM_MIN_COLS, LOGO_COMPACT_MIN_COLS };