@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
@@ -69,14 +69,14 @@ function UserInputCard({
69
69
  dim: boolean;
70
70
  }) {
71
71
  const theme = useTheme();
72
- const borderColor = theme.BORDER_SUBTLE;
72
+ const borderColor = theme.border;
73
73
  const contentWidth = Math.max(1, cols - 7);
74
74
  const lines = wrapPlainText(sanitizeTerminalOutput(prompt), contentWidth);
75
75
 
76
76
  return (
77
77
  <DashCard cols={cols} title="PROMPT" borderColor={borderColor}>
78
78
  {lines.map((line, i) => (
79
- <Text key={i} color={dim ? theme.DIM : theme.TEXT}>
79
+ <Text key={i} color={dim ? theme.textDim : theme.text}>
80
80
  {i === 0 ? "❯ " : " "}{line}
81
81
  </Text>
82
82
  ))}
@@ -122,38 +122,38 @@ function ImpactSummary({
122
122
 
123
123
  const opColor = (op: string) => {
124
124
  switch (op) {
125
- case "created": return theme.SUCCESS;
126
- case "deleted": return theme.ERROR;
127
- default: return theme.INFO;
125
+ case "created": return theme.success;
126
+ case "deleted": return theme.error;
127
+ default: return theme.info;
128
128
  }
129
129
  };
130
130
 
131
131
  return (
132
132
  <Box flexDirection="column" width="100%" paddingX={1} marginTop={0}>
133
133
  {hasDeletes && (
134
- <Text color={theme.WARNING}>{"⚠ Destructive changes detected:"}</Text>
134
+ <Text color={theme.warning}>{"⚠ Destructive changes detected:"}</Text>
135
135
  )}
136
136
  {hasFiles && (
137
137
  <>
138
- <Text color={theme.DIM}>{" Changes:"}</Text>
138
+ <Text color={theme.textDim}>{" Changes:"}</Text>
139
139
  {recentFiles.map((file: RunFileActivity, i: number) => {
140
140
  const diffInfo = file.addedLines != null || file.removedLines != null
141
141
  ? ` (+${file.addedLines ?? 0} -${file.removedLines ?? 0})`
142
142
  : "";
143
143
  return (
144
144
  <Text key={i}>
145
- <Text color={theme.DIM}>{" "}</Text>
145
+ <Text color={theme.textDim}>{" "}</Text>
146
146
  <Text color={opColor(file.operation)}>{opLabel(file.operation)}</Text>
147
- <Text color={theme.TEXT}>{" "}{file.path}</Text>
148
- <Text color={theme.DIM}>{diffInfo}</Text>
147
+ <Text color={theme.text}>{" "}{file.path}</Text>
148
+ <Text color={theme.textDim}>{diffInfo}</Text>
149
149
  </Text>
150
150
  );
151
151
  })}
152
152
  </>
153
153
  )}
154
- <Text color={theme.DIM}>
154
+ <Text color={theme.textDim}>
155
155
  {" "}
156
- <Text color={theme.SUCCESS}>{"✔ "}</Text>
156
+ <Text color={theme.success}>{"✔ "}</Text>
157
157
  {run.touchedFileCount > 0 && `${run.touchedFileCount} file${run.touchedFileCount === 1 ? "" : "s"}`}
158
158
  {hasTools && `${hasFiles ? " • " : ""}${run.toolActivities.length} action${run.toolActivities.length === 1 ? "" : "s"}`}
159
159
  {run.durationMs != null && ` • ${formatDuration(run.durationMs)}`}
@@ -172,11 +172,11 @@ function FileScanCard({ run, cols }: { run: RunEvent; cols: number }) {
172
172
  return (
173
173
  <DashCard cols={cols} title="Scanning workspace ..." rightBadge={badge}>
174
174
  {hiddenCount > 0 && (
175
- <Text color={theme.DIM}>{`... ${hiddenCount} more`}</Text>
175
+ <Text color={theme.textDim}>{`... ${hiddenCount} more`}</Text>
176
176
  )}
177
177
  {visible.map((file, i) => (
178
- <Text key={i} color={theme.SUCCESS}>
179
- {"● "}<Text color={theme.TEXT}>{file.path}</Text>
178
+ <Text key={i} color={theme.success}>
179
+ {"● "}<Text color={theme.text}>{file.path}</Text>
180
180
  </Text>
181
181
  ))}
182
182
  </DashCard>
@@ -307,9 +307,9 @@ function PlanPanel({
307
307
  cols={cols}
308
308
  title="Plan"
309
309
  rightBadge={approved ? "approved" : undefined}
310
- borderColor={theme.ACCENT}
311
- titleColor={theme.TEXT}
312
- badgeColor={theme.SUCCESS}
310
+ borderColor={theme.accent}
311
+ titleColor={theme.text}
312
+ badgeColor={theme.success}
313
313
  >
314
314
  <MemoizedRenderMessage segments={formatted} width={contentWidth} brightHeadings />
315
315
  </DashCard>
@@ -337,12 +337,12 @@ function ActionEventCard({
337
337
  const actionLabel = getFriendlyActionLabel(actionNormalized);
338
338
 
339
339
  const statusIcon = tool.status === "failed" ? "✕" : tool.status === "completed" ? "✔" : "▸";
340
- const statusColor = tool.status === "failed" ? theme.ERROR : tool.status === "completed" ? theme.SUCCESS : theme.INFO;
341
- const borderColor = dim ? theme.BORDER_SUBTLE : tool.status === "running" ? theme.BORDER_ACTIVE : theme.BORDER_SUBTLE;
340
+ const statusColor = tool.status === "failed" ? theme.error : tool.status === "completed" ? theme.success : theme.info;
341
+ const borderColor = dim ? theme.border : tool.status === "running" ? theme.borderFocused : theme.border;
342
342
  const detailText = isLiveCursorTarget && tool.status === "running"
343
343
  ? "▌"
344
344
  : tool.summary?.trim() ? tool.summary : " ";
345
- const detailColor = isLiveCursorTarget && tool.status === "running" ? theme.ACCENT : theme.MUTED;
345
+ const detailColor = isLiveCursorTarget && tool.status === "running" ? theme.accent : theme.textMuted;
346
346
  const duration = tool.completedAt != null
347
347
  ? formatDuration(tool.completedAt - tool.startedAt)
348
348
  : null;
@@ -356,10 +356,10 @@ function ActionEventCard({
356
356
  <>
357
357
  <Box>
358
358
  <Text color={statusColor}>{statusIcon + " "}</Text>
359
- <Text color={dim ? theme.DIM : theme.TEXT}>{actionLabel}</Text>
359
+ <Text color={dim ? theme.textDim : theme.text}>{actionLabel}</Text>
360
360
  </Box>
361
361
  {commandLines.map((line, i) => (
362
- <Text key={i} color={theme.MUTED}>{" "}{line || " "}</Text>
362
+ <Text key={i} color={theme.textMuted}>{" "}{line || " "}</Text>
363
363
  ))}
364
364
  </>
365
365
  ) : (
@@ -367,10 +367,10 @@ function ActionEventCard({
367
367
  {commandLines.map((line, i) => (
368
368
  <Box key={i}>
369
369
  <Text color={i === 0 ? statusColor : undefined}>{i === 0 ? statusIcon + " " : " "}</Text>
370
- <Text color={dim ? theme.DIM : theme.TEXT}>{line || " "}</Text>
370
+ <Text color={dim ? theme.textDim : theme.text}>{line || " "}</Text>
371
371
  </Box>
372
372
  ))}
373
- <Text color={theme.MUTED}>{" "}</Text>
373
+ <Text color={theme.textMuted}>{" "}</Text>
374
374
  </>
375
375
  )}
376
376
  <Text color={detailColor}>{" "}{detailText}</Text>
@@ -405,14 +405,14 @@ function CodexThinkingBlock({
405
405
 
406
406
  return (
407
407
  <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
408
- <Text color={theme.MUTED} bold>Codexa</Text>
408
+ <Text color={theme.textMuted} bold>Codexa</Text>
409
409
  {formatProgressBlockBodyLines(block.text, contentWidth)
410
410
  .slice(0, verboseMode ? undefined : COMPACT_PROCESSING_BODY_LINE_CAP)
411
411
  .map((line, i) => (
412
- <Text key={i} color={theme.DIM}>{line || " "}</Text>
412
+ <Text key={i} color={theme.textDim}>{line || " "}</Text>
413
413
  ))}
414
414
  {isLiveCursorTarget && block.status === "active" && (
415
- <Text color={theme.ACCENT}>▌</Text>
415
+ <Text color={theme.accent}>▌</Text>
416
416
  )}
417
417
  </Box>
418
418
  );
@@ -453,11 +453,11 @@ function CodexResponseBlock({
453
453
 
454
454
  return (
455
455
  <Box flexDirection="column" width="100%" paddingLeft={transcriptContentIndent} paddingRight={1}>
456
- <Text color={theme.MUTED} bold>Codexa</Text>
456
+ <Text color={theme.textMuted} bold>Codexa</Text>
457
457
  {run.status === "failed" && !streaming && isLast && (
458
458
  <Box flexDirection="column">
459
459
  {wrapPlainText(sanitizeTerminalOutput(run.errorMessage ?? run.summary), contentWidth).map((row, i) => (
460
- <Text key={i} color={theme.ERROR}>{i === 0 ? `✕ ${row}` : ` ${row}`}</Text>
460
+ <Text key={i} color={theme.error}>{i === 0 ? `✕ ${row}` : ` ${row}`}</Text>
461
461
  ))}
462
462
  </Box>
463
463
  )}
@@ -466,7 +466,7 @@ function CodexResponseBlock({
466
466
  width={contentWidth}
467
467
  />
468
468
  {isLiveCursorTarget && segmentStreaming && (
469
- <Text color={theme.ACCENT}>▌</Text>
469
+ <Text color={theme.accent}>▌</Text>
470
470
  )}
471
471
  </Box>
472
472
  );
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { CODEXA_UPDATE_COMMAND } from "../core/updateCheck.js";
4
+ import { clampVisualText } from "./layout.js";
5
+ import { useTheme } from "./theme.js";
6
+
7
+ export const UPDATE_CARD_CONTENT_ROWS = 4; // title + available + using + command
8
+ export const UPDATE_CARD_ROWS = UPDATE_CARD_CONTENT_ROWS + 2; // +2 for top/bottom border rows
9
+
10
+ export interface UpdateAvailableCardProps {
11
+ latestVersion: string;
12
+ currentVersion: string;
13
+ /** Total box width including borders. Long lines are truncated to fit. */
14
+ width?: number;
15
+ }
16
+
17
+ export function UpdateAvailableCard({ latestVersion, currentVersion, width }: UpdateAvailableCardProps) {
18
+ const theme = useTheme();
19
+ const command = `Run: ${CODEXA_UPDATE_COMMAND}`;
20
+ // Inner content width = boxWidth - 2 (left/right border cols)
21
+ const innerWidth = width !== undefined ? Math.max(8, width - 2) : undefined;
22
+
23
+ function clamp(text: string): string {
24
+ return innerWidth !== undefined ? clampVisualText(text, innerWidth) : text;
25
+ }
26
+
27
+ return (
28
+ <Box
29
+ borderStyle="round"
30
+ borderColor={theme.accent}
31
+ flexDirection="column"
32
+ width={width}
33
+ flexShrink={0}
34
+ >
35
+ <Text color={theme.text} bold>{clamp("Update available")}</Text>
36
+ <Text color={theme.textMuted}>{clamp(`Codexa ${latestVersion} is available`)}</Text>
37
+ <Text color={theme.textMuted}>{clamp(`You are using ${currentVersion}`)}</Text>
38
+ <Text color={theme.textDim}>{clamp(command)}</Text>
39
+ </Box>
40
+ );
41
+ }
@@ -0,0 +1,203 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import { spawn } from "child_process";
4
+ import { useTheme } from "./theme.js";
5
+ import { CODEXA_NPM_PACKAGE, CODEXA_UPDATE_COMMAND } from "../core/updateCheck.js";
6
+
7
+ type Phase = "menu" | "running" | "done" | "error";
8
+
9
+ const MENU_ITEMS = [
10
+ { label: "Update now" },
11
+ { label: "Skip" },
12
+ { label: "Skip until next version" },
13
+ ] as const;
14
+
15
+ interface UpdatePromptPanelProps {
16
+ focusId: string;
17
+ currentVersion: string;
18
+ latestVersion: string;
19
+ onSkip: () => void;
20
+ onSkipUntilNextVersion: (version: string) => void;
21
+ }
22
+
23
+ export function UpdatePromptPanel({
24
+ focusId,
25
+ currentVersion,
26
+ latestVersion,
27
+ onSkip,
28
+ onSkipUntilNextVersion,
29
+ }: UpdatePromptPanelProps) {
30
+ const theme = useTheme();
31
+ const { isFocused } = useFocus({ id: focusId, autoFocus: true });
32
+
33
+ const [phase, setPhase] = useState<Phase>("menu");
34
+ const [selectedIndex, setSelectedIndex] = useState(0);
35
+ const [outputLines, setOutputLines] = useState<string[]>([]);
36
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
37
+
38
+ const spawnStartedRef = useRef(false);
39
+
40
+ useInput((input, key) => {
41
+ if (phase === "menu") {
42
+ if (key.upArrow || input === "k") {
43
+ setSelectedIndex((i) => Math.max(0, i - 1));
44
+ return;
45
+ }
46
+ if (key.downArrow || input === "j") {
47
+ setSelectedIndex((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
48
+ return;
49
+ }
50
+ if (key.return) {
51
+ if (selectedIndex === 0) {
52
+ setPhase("running");
53
+ } else if (selectedIndex === 1) {
54
+ onSkip();
55
+ } else {
56
+ onSkipUntilNextVersion(latestVersion);
57
+ }
58
+ return;
59
+ }
60
+ if (key.escape) {
61
+ onSkip();
62
+ return;
63
+ }
64
+ } else if (phase === "done" || phase === "error") {
65
+ if (key.return || key.escape) {
66
+ onSkip();
67
+ }
68
+ }
69
+ }, { isActive: isFocused });
70
+
71
+ useEffect(() => {
72
+ if (phase !== "running") return;
73
+ if (spawnStartedRef.current) return;
74
+ spawnStartedRef.current = true;
75
+
76
+ const child = spawn("npm", ["install", "-g", `${CODEXA_NPM_PACKAGE}@latest`], {
77
+ shell: false,
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ });
80
+
81
+ const appendLines = (buf: Buffer) => {
82
+ const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
83
+ if (lines.length > 0) {
84
+ setOutputLines((prev) => [...prev, ...lines]);
85
+ }
86
+ };
87
+
88
+ child.stdout?.on("data", appendLines);
89
+ child.stderr?.on("data", appendLines);
90
+
91
+ child.once("error", (err: Error) => {
92
+ setErrorMessage(err.message);
93
+ setPhase("error");
94
+ });
95
+
96
+ child.once("close", (code: number | null) => {
97
+ if (code === 0) {
98
+ setPhase("done");
99
+ } else {
100
+ setErrorMessage(`npm exited with code ${code ?? "unknown"}.`);
101
+ setPhase("error");
102
+ }
103
+ });
104
+
105
+ return () => {
106
+ try { child.kill(); } catch { /* ignore */ }
107
+ };
108
+ }, [phase]);
109
+
110
+ const hintText = phase === "menu"
111
+ ? "↑↓/jk select Enter confirm Esc skip"
112
+ : "Press Enter to close";
113
+
114
+ return (
115
+ <Box flexDirection="column" width="100%" marginTop={1}>
116
+ <Box
117
+ borderStyle="round"
118
+ borderColor={theme.border}
119
+ paddingX={2}
120
+ paddingY={1}
121
+ width="100%"
122
+ flexDirection="column"
123
+ >
124
+ <Box>
125
+ <Text color={theme.accent} bold>Update available </Text>
126
+ <Text color={theme.textMuted}>{hintText}</Text>
127
+ </Box>
128
+ <Box marginTop={1}>
129
+ <Text color={theme.text}>{`✨ ${currentVersion} -> ${latestVersion}`}</Text>
130
+ </Box>
131
+ <Box>
132
+ <Text color={theme.textMuted}>{`Package: ${CODEXA_NPM_PACKAGE}`}</Text>
133
+ </Box>
134
+ <Box>
135
+ <Text color={theme.textMuted}>{`Run: ${CODEXA_UPDATE_COMMAND}`}</Text>
136
+ </Box>
137
+ </Box>
138
+
139
+ <Box
140
+ borderStyle="round"
141
+ borderColor={theme.borderFocused}
142
+ paddingX={2}
143
+ paddingY={1}
144
+ marginTop={1}
145
+ width="100%"
146
+ flexDirection="column"
147
+ >
148
+ {phase === "menu" && (
149
+ <>
150
+ {MENU_ITEMS.map((item, index) => (
151
+ <Box key={item.label}>
152
+ <Text color={index === selectedIndex ? theme.accent : theme.textMuted}>
153
+ {index === selectedIndex ? "› " : " "}
154
+ </Text>
155
+ <Text
156
+ color={index === selectedIndex ? theme.text : theme.textMuted}
157
+ bold={index === selectedIndex}
158
+ >
159
+ {`${index + 1}. ${item.label}`}
160
+ </Text>
161
+ </Box>
162
+ ))}
163
+ <Box marginTop={1}>
164
+ <Text color={theme.textDim}>Press enter to continue</Text>
165
+ </Box>
166
+ </>
167
+ )}
168
+
169
+ {phase === "running" && (
170
+ <>
171
+ <Text color={theme.text}>{`Installing Codexa ${latestVersion}...`}</Text>
172
+ {outputLines.map((line, i) => (
173
+ <Text key={i} color={theme.textMuted}>{line}</Text>
174
+ ))}
175
+ </>
176
+ )}
177
+
178
+ {phase === "done" && (
179
+ <>
180
+ <Text color={theme.success}>{"Codexa was updated successfully."}</Text>
181
+ <Text color={theme.textMuted}>{"Restart Codexa to use the new version."}</Text>
182
+ <Box marginTop={1}>
183
+ <Text color={theme.textDim}>Press Enter to close</Text>
184
+ </Box>
185
+ </>
186
+ )}
187
+
188
+ {phase === "error" && (
189
+ <>
190
+ <Text color={theme.error}>{"Update failed."}</Text>
191
+ {errorMessage != null && <Text color={theme.textMuted}>{errorMessage}</Text>}
192
+ {outputLines.slice(-5).map((line, i) => (
193
+ <Text key={i} color={theme.textDim}>{line}</Text>
194
+ ))}
195
+ <Box marginTop={1}>
196
+ <Text color={theme.textDim}>Press Enter to close</Text>
197
+ </Box>
198
+ </>
199
+ )}
200
+ </Box>
201
+ </Box>
202
+ );
203
+ }
package/src/ui/focus.ts CHANGED
@@ -18,6 +18,7 @@ export const FOCUS_IDS = {
18
18
  permissionsAddWritableRoot: "permissions-add-writable-root",
19
19
  permissionsRemoveWritableRoot: "permissions-remove-writable-root",
20
20
  importConfirmationPanel: "import-confirmation",
21
+ updatePrompt: "update-prompt",
21
22
  } as const;
22
23
 
23
24
  export type FocusTargetId = (typeof FOCUS_IDS)[keyof typeof FOCUS_IDS];
@@ -54,6 +55,8 @@ export function getFocusTargetForScreen(screen: Screen): FocusTargetId {
54
55
  return FOCUS_IDS.permissionsRemoveWritableRoot;
55
56
  case "import-confirmation":
56
57
  return FOCUS_IDS.importConfirmationPanel;
58
+ case "update-prompt":
59
+ return FOCUS_IDS.updatePrompt;
57
60
  case "main":
58
61
  default:
59
62
  return FOCUS_IDS.composer;
package/src/ui/layout.ts CHANGED
@@ -20,7 +20,7 @@ export const MIN_VIEWPORT_ROWS = 10;
20
20
  export const RESTORE_SETTLE_MS = 100;
21
21
  export const STARTUP_TINY_MIN_COLS = 40;
22
22
  export const STARTUP_TINY_MIN_ROWS = 14;
23
- export const STARTUP_FULL_MIN_COLS = BREAKPOINT_FULL;
23
+ export const STARTUP_FULL_MIN_COLS = 100; // matches LOGO_LARGE_MIN_COLS in logoVariants.ts
24
24
  export const STARTUP_FULL_MIN_BODY_ROWS = 4;
25
25
  export const STARTUP_FULL_SAFE_PADDING_ROWS = 1;
26
26
  export const STARTUP_COMPACT_INTRO_ROWS = 4;
@@ -0,0 +1,107 @@
1
+ import { getTextWidth } from "./textLayout.js";
2
+
3
+ // ─── Logo constants ───────────────────────────────────────────────────────────
4
+ // Each variant is an array of exact terminal rows.
5
+ //
6
+ // IMPORTANT: Never apply `bold` when rendering these rows. Bold rendering of
7
+ // Unicode full-block (█) and box-drawing (╔═╗╝) characters causes per-glyph
8
+ // width/stroke differences in most terminal fonts (Ptyxis, GNOME Terminal,
9
+ // VS Code), producing visible gaps between characters that should be flush.
10
+ // The companion `wrap="truncate"` rule keeps each row on exactly one terminal
11
+ // line regardless of the surrounding Ink flex layout.
12
+
13
+ // Canonical Codexa brand wordmark — the ██ block art is the authoritative logo.
14
+ // Always shown on any normal-width terminal (≥ LOGO_LARGE_MIN_COLS cols).
15
+ export const CODEXA_WORDMARK = [
16
+ " ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗ █████╗ ",
17
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝╚██╗██╔╝██╔══██╗",
18
+ "██║ ██║ ██║██║ ██║█████╗ ╚███╔╝ ███████║",
19
+ "██║ ██║ ██║██║ ██║██╔══╝ ██╔██╗ ██╔══██║",
20
+ "╚██████╗╚██████╔╝██████╔╝███████╗██╔╝ ██╗██║ ██║",
21
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝",
22
+ ].join("\n");
23
+
24
+ /** 6-row ANSI Shadow block-char logo. Requires cols ≥ LOGO_LARGE_MIN_COLS. */
25
+ export const LOGO_LARGE: readonly string[] = CODEXA_WORDMARK.split("\n");
26
+
27
+ /** 4-row pure-ASCII art logo. Requires cols ≥ LOGO_MEDIUM_MIN_COLS. */
28
+ export const LOGO_MEDIUM: readonly string[] = [
29
+ " ____ ___ ____ _____ _ __ _ ",
30
+ " / ___| / _ \\| _ \\| ____| |/ / / \\ ",
31
+ "| | | | | | | | | _| | ' / / _ \\ ",
32
+ "|_| \\___/|_| |_|_____|_|\\_\\ /_/ \\_\\ ",
33
+ ];
34
+
35
+ /** 1-row compact logo. Requires cols ≥ LOGO_COMPACT_MIN_COLS. */
36
+ export const LOGO_COMPACT: readonly string[] = [
37
+ "✦ CODEXA",
38
+ ];
39
+
40
+ // ─── Breakpoints ──────────────────────────────────────────────────────────────
41
+
42
+ // Aligned with MEDIUM_HEADER_MIN_COLUMNS so any side-by-side-capable terminal
43
+ // shows the canonical block wordmark instead of the thin ASCII fallback.
44
+ export const LOGO_LARGE_MIN_COLS = 72;
45
+ export const LOGO_MEDIUM_MIN_COLS = 72;
46
+ export const LOGO_COMPACT_MIN_COLS = 48;
47
+
48
+ // Minimum terminal rows each variant needs to render without crowding out the
49
+ // metadata + composer. A wide-but-short terminal (e.g. VS Code's bottom panel)
50
+ // must step DOWN to a smaller logo instead of dropping straight to text-only.
51
+ export const LOGO_LARGE_MIN_ROWS = 24;
52
+ export const LOGO_MEDIUM_MIN_ROWS = 16;
53
+ export const LOGO_COMPACT_MIN_ROWS = 12;
54
+
55
+ // LOGO_MEDIUM is kept as an exported constant but intentionally omitted from
56
+ // LOGO_VARIANTS. At 72+ cols, LOGO_LARGE_MIN_COLS = LOGO_MEDIUM_MIN_COLS = 72,
57
+ // so LOGO_LARGE always wins. Below 72 cols the viewport is too narrow for the
58
+ // thin ASCII art to add value over the compact single-line fallback.
59
+ const LOGO_VARIANTS: readonly { logo: readonly string[]; minCols: number; minRows: number }[] = [
60
+ { logo: LOGO_LARGE, minCols: LOGO_LARGE_MIN_COLS, minRows: LOGO_LARGE_MIN_ROWS },
61
+ { logo: LOGO_COMPACT, minCols: LOGO_COMPACT_MIN_COLS, minRows: LOGO_COMPACT_MIN_ROWS },
62
+ ];
63
+
64
+ // ─── Selection ────────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Returns the best logo variant for the given terminal column count.
68
+ *
69
+ * Env overrides:
70
+ * CODEXA_NO_ASCII_LOGO=1 → always text-only (empty array)
71
+ * CODEXA_COMPACT_LOGO=1 → always compact single-line logo
72
+ */
73
+ export function selectLogoVariant(cols: number): readonly string[] {
74
+ if (process.env["CODEXA_NO_ASCII_LOGO"] === "1") return [];
75
+ if (process.env["CODEXA_COMPACT_LOGO"] === "1") return LOGO_COMPACT;
76
+ if (cols >= LOGO_LARGE_MIN_COLS) return LOGO_LARGE;
77
+ if (cols >= LOGO_COMPACT_MIN_COLS) return LOGO_COMPACT;
78
+ return [];
79
+ }
80
+
81
+ /**
82
+ * Returns the largest logo variant that fits BOTH the available columns and
83
+ * rows. Unlike {@link selectLogoVariant} (columns-only), this degrades a
84
+ * too-tall logo to a shorter one before falling back to text-only — so a
85
+ * wide-but-short terminal keeps a logo instead of collapsing to a flat line.
86
+ * Returns an empty array only when even the 1-row compact logo cannot fit.
87
+ *
88
+ * Honours the same env overrides as {@link selectLogoVariant}.
89
+ */
90
+ export function selectLogoVariantForViewport(cols: number, rows: number): readonly string[] {
91
+ if (process.env["CODEXA_NO_ASCII_LOGO"] === "1") return [];
92
+ if (process.env["CODEXA_COMPACT_LOGO"] === "1") {
93
+ return rows >= LOGO_COMPACT_MIN_ROWS ? LOGO_COMPACT : [];
94
+ }
95
+ if (cols >= LOGO_LARGE_MIN_COLS) return LOGO_LARGE;
96
+ for (const variant of LOGO_VARIANTS) {
97
+ if (cols >= variant.minCols && rows >= variant.minRows) {
98
+ return variant.logo;
99
+ }
100
+ }
101
+ return [];
102
+ }
103
+
104
+ /** Returns the visual column width of the widest row in a logo variant. */
105
+ export function getLogoWidth(logo: readonly string[]): number {
106
+ return logo.reduce((max, line) => Math.max(max, getTextWidth(line)), 0);
107
+ }
@@ -18,10 +18,10 @@ export function getModeDisplaySpec(mode: string, theme: Theme): ModeDisplaySpec
18
18
  return {
19
19
  label: formatModeLabel(mode),
20
20
  ringGlyph: "◉",
21
- ringColor: theme.WARNING,
22
- ringFill: theme.BORDER_SUBTLE,
23
- iconColor: theme.WARNING,
24
- labelColor: theme.TEXT,
21
+ ringColor: theme.warning,
22
+ ringFill: theme.border,
23
+ iconColor: theme.warning,
24
+ labelColor: theme.text,
25
25
  labelBold: true,
26
26
  ringBold: true,
27
27
  };
@@ -29,10 +29,10 @@ export function getModeDisplaySpec(mode: string, theme: Theme): ModeDisplaySpec
29
29
  return {
30
30
  label: formatModeLabel(mode),
31
31
  ringGlyph: "◎",
32
- ringColor: theme.BORDER_ACTIVE,
33
- ringFill: theme.PANEL_ALT,
34
- iconColor: theme.PROMPT,
35
- labelColor: theme.TEXT,
32
+ ringColor: theme.borderFocused,
33
+ ringFill: theme.surfaceMuted,
34
+ iconColor: theme.prompt,
35
+ labelColor: theme.text,
36
36
  labelBold: true,
37
37
  ringBold: false,
38
38
  };
@@ -41,10 +41,10 @@ export function getModeDisplaySpec(mode: string, theme: Theme): ModeDisplaySpec
41
41
  return {
42
42
  label: formatModeLabel(mode),
43
43
  ringGlyph: "○",
44
- ringColor: theme.SUCCESS,
45
- ringFill: theme.PANEL_SOFT,
46
- iconColor: theme.SUCCESS,
47
- labelColor: theme.MUTED,
44
+ ringColor: theme.success,
45
+ ringFill: theme.surfaceMuted,
46
+ iconColor: theme.success,
47
+ labelColor: theme.textMuted,
48
48
  labelBold: false,
49
49
  ringBold: false,
50
50
  };