@golba98/codexa 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +396 -100
  2. package/bin/codexa.js +62 -144
  3. package/package.json +14 -8
  4. package/src/app.tsx +596 -306
  5. package/src/commands/handler.ts +6 -6
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/layeredConfig.ts +1 -1
  8. package/src/config/persistence.ts +10 -0
  9. package/src/config/runtimeConfig.ts +1 -1
  10. package/src/config/settings.ts +8 -16
  11. package/src/config/trustStore.ts +1 -1
  12. package/src/config/updateCheckCache.ts +19 -1
  13. package/src/core/README.md +52 -0
  14. package/src/core/agent/loop.ts +282 -0
  15. package/src/core/agent/protocol.ts +211 -0
  16. package/src/core/agent/tools.ts +414 -0
  17. package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
  18. package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
  19. package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
  20. package/src/core/debug/modelStateDebug.ts +34 -0
  21. package/src/core/executables/antigravityExecutable.ts +48 -0
  22. package/src/core/executables/codexExecutable.ts +1 -0
  23. package/src/core/executables/executableResolver.ts +65 -43
  24. package/src/core/perf/renderDebug.ts +10 -6
  25. package/src/core/process/processValidation.ts +9 -5
  26. package/src/core/providerLauncher/launcher.ts +59 -42
  27. package/src/core/providerLauncher/registry.ts +30 -14
  28. package/src/core/providerLauncher/types.ts +11 -9
  29. package/src/core/providerLauncher/workspaceConfig.ts +41 -26
  30. package/src/core/providerRuntime/anthropic.ts +7 -1
  31. package/src/core/providerRuntime/antigravity.ts +305 -0
  32. package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
  33. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  34. package/src/core/providerRuntime/contextMetadata.ts +12 -24
  35. package/src/core/providerRuntime/local.ts +129 -51
  36. package/src/core/providerRuntime/models.ts +22 -11
  37. package/src/core/providerRuntime/registry.ts +58 -31
  38. package/src/core/providerRuntime/types.ts +19 -14
  39. package/src/core/providers/codexSubprocess.ts +2 -2
  40. package/src/core/providers/types.ts +1 -1
  41. package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
  42. package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
  43. package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
  44. package/src/core/terminal/clearFrameBoundary.ts +814 -0
  45. package/src/core/terminal/frameLock.ts +109 -0
  46. package/src/core/terminal/inkRenderReset.ts +123 -0
  47. package/src/core/terminal/terminalControl.ts +22 -0
  48. package/src/core/terminal/terminalTitle.ts +16 -102
  49. package/src/core/version/channel.ts +23 -0
  50. package/src/core/version/updateCheck.ts +193 -0
  51. package/src/core/{launchContext.ts → workspace/launchContext.ts} +32 -39
  52. package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
  53. package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
  54. package/src/headless/execRunner.ts +2 -2
  55. package/src/index.tsx +43 -89
  56. package/src/session/appSession.ts +10 -7
  57. package/src/session/chatLifecycle.ts +1 -1
  58. package/src/session/liveRenderScheduler.ts +1 -1
  59. package/src/session/types.ts +3 -2
  60. package/src/ui/ActionRequiredBlock.tsx +5 -5
  61. package/src/ui/ActivityBars.tsx +3 -3
  62. package/src/ui/ActivityIndicator.tsx +6 -6
  63. package/src/ui/AgentBlock.tsx +6 -6
  64. package/src/ui/AnimatedStatusText.tsx +1 -1
  65. package/src/ui/AppShell.tsx +670 -719
  66. package/src/ui/AttachmentImportPanel.tsx +8 -8
  67. package/src/ui/AuthPanel.tsx +20 -20
  68. package/src/ui/BottomComposer.tsx +158 -118
  69. package/src/ui/DashCard.tsx +3 -3
  70. package/src/ui/Markdown.tsx +17 -17
  71. package/src/ui/ModelPickerScreen.tsx +222 -42
  72. package/src/ui/ModelReasoningPicker.tsx +15 -15
  73. package/src/ui/Panel.tsx +3 -3
  74. package/src/ui/PlanActionPicker.tsx +6 -6
  75. package/src/ui/PlanReviewPanel.tsx +9 -9
  76. package/src/ui/ProviderPicker.tsx +735 -321
  77. package/src/ui/RunFooter.tsx +3 -3
  78. package/src/ui/RuntimeStatusBar.tsx +108 -0
  79. package/src/ui/SelectionPanel.tsx +8 -4
  80. package/src/ui/SettingsPanel.tsx +9 -9
  81. package/src/ui/Spinner.tsx +1 -1
  82. package/src/ui/TextEntryPanel.tsx +11 -11
  83. package/src/ui/ThinkingBlock.tsx +8 -8
  84. package/src/ui/Timeline.tsx +1625 -1472
  85. package/src/ui/TopHeader.tsx +437 -293
  86. package/src/ui/TranscriptShell.tsx +322 -0
  87. package/src/ui/TurnGroup.tsx +33 -33
  88. package/src/ui/UpdateAvailableCard.tsx +41 -0
  89. package/src/ui/UpdatePromptPanel.tsx +197 -0
  90. package/src/ui/focus.ts +3 -0
  91. package/src/ui/layout.ts +299 -25
  92. package/src/ui/layoutListWindow.ts +145 -0
  93. package/src/ui/logoVariants.ts +103 -0
  94. package/src/ui/modeDisplay.ts +12 -12
  95. package/src/ui/runtimeDisplay.ts +112 -0
  96. package/src/ui/textLayout.ts +15 -4
  97. package/src/ui/theme.tsx +274 -395
  98. package/src/ui/timelineMeasure.ts +218 -136
  99. package/scripts/audit-codexa-capabilities.mjs +0 -466
  100. package/scripts/gen-build-info.mjs +0 -33
  101. package/scripts/smoke-terminal-bench.mjs +0 -35
  102. package/src/appRenderStability.test.ts +0 -131
  103. package/src/commands/handler.test.ts +0 -655
  104. package/src/config/launchArgs.test.ts +0 -189
  105. package/src/config/layeredConfig.test.ts +0 -143
  106. package/src/config/persistence.test.ts +0 -114
  107. package/src/config/runtimeConfig.test.ts +0 -218
  108. package/src/config/settings.test.ts +0 -155
  109. package/src/config/trustStore.test.ts +0 -29
  110. package/src/core/attachments.test.ts +0 -155
  111. package/src/core/auth/codexAuth.test.ts +0 -68
  112. package/src/core/cleanupFastFail.test.ts +0 -76
  113. package/src/core/codex.ts +0 -124
  114. package/src/core/codexExecArgs.test.ts +0 -195
  115. package/src/core/codexLaunch.test.ts +0 -205
  116. package/src/core/codexPrompt.test.ts +0 -252
  117. package/src/core/executables/codexExecutable.test.ts +0 -212
  118. package/src/core/executables/executableResolver.test.ts +0 -129
  119. package/src/core/executables/geminiExecutable.test.ts +0 -116
  120. package/src/core/executables/pathSanityScan.test.ts +0 -47
  121. package/src/core/githubDiagnostics.test.ts +0 -92
  122. package/src/core/hollowResponseFormat.test.ts +0 -58
  123. package/src/core/launchContext.test.ts +0 -157
  124. package/src/core/models/codexCapabilities.test.ts +0 -45
  125. package/src/core/models/codexModelCapabilities.test.ts +0 -246
  126. package/src/core/models/modelSpecs.test.ts +0 -283
  127. package/src/core/perf/renderDebug.test.ts +0 -230
  128. package/src/core/planStorage.test.ts +0 -143
  129. package/src/core/process/CommandRunner.test.ts +0 -105
  130. package/src/core/projectInstructions.test.ts +0 -50
  131. package/src/core/providerLauncher/launcher.test.ts +0 -238
  132. package/src/core/providerLauncher/registry.test.ts +0 -324
  133. package/src/core/providerLauncher/workspaceConfig.test.ts +0 -638
  134. package/src/core/providerRuntime/anthropic.test.ts +0 -1120
  135. package/src/core/providerRuntime/capabilityProfile.test.ts +0 -311
  136. package/src/core/providerRuntime/contextMetadata.test.ts +0 -468
  137. package/src/core/providerRuntime/gemini.test.ts +0 -437
  138. package/src/core/providerRuntime/lmstudio.test.ts +0 -168
  139. package/src/core/providerRuntime/local.test.ts +0 -787
  140. package/src/core/providerRuntime/registry.test.ts +0 -233
  141. package/src/core/providers/codexJsonStream.test.ts +0 -148
  142. package/src/core/providers/codexSubprocess.test.ts +0 -68
  143. package/src/core/providers/codexTranscript.test.ts +0 -284
  144. package/src/core/terminal/startupClear.test.ts +0 -55
  145. package/src/core/terminal/terminalCapabilities.test.ts +0 -93
  146. package/src/core/terminal/terminalControl.test.ts +0 -75
  147. package/src/core/terminal/terminalSanitize.test.ts +0 -22
  148. package/src/core/terminal/terminalSelection.test.ts +0 -42
  149. package/src/core/terminal/terminalTitle.test.ts +0 -328
  150. package/src/core/updateCheck.test.ts +0 -194
  151. package/src/core/updateCheck.ts +0 -172
  152. package/src/core/workspaceActivity.test.ts +0 -163
  153. package/src/core/workspaceGuard.test.ts +0 -151
  154. package/src/core/workspaceRoot.test.ts +0 -23
  155. package/src/exec.test.ts +0 -13
  156. package/src/headless/execArgs.test.ts +0 -147
  157. package/src/headless/execRunner.test.ts +0 -436
  158. package/src/index.test.tsx +0 -620
  159. package/src/session/appSession.test.ts +0 -897
  160. package/src/session/chatLifecycle.test.ts +0 -64
  161. package/src/session/liveRenderScheduler.test.ts +0 -201
  162. package/src/session/planFlow.test.ts +0 -103
  163. package/src/session/planTranscript.test.ts +0 -65
  164. package/src/session/promptRunSchedule.test.ts +0 -36
  165. package/src/ui/ActivityIndicator.test.tsx +0 -58
  166. package/src/ui/AgentBlock.test.ts +0 -6
  167. package/src/ui/AnimatedStatusText.test.ts +0 -16
  168. package/src/ui/AppShell.test.tsx +0 -1776
  169. package/src/ui/AttachmentImportPanel.test.tsx +0 -204
  170. package/src/ui/BottomComposer.test.ts +0 -674
  171. package/src/ui/CodexLogo.tsx +0 -55
  172. package/src/ui/Markdown.test.ts +0 -157
  173. package/src/ui/ModelPickerProviderScope.test.tsx +0 -411
  174. package/src/ui/ModelPickerScreen.test.tsx +0 -99
  175. package/src/ui/ModelPickerState.test.tsx +0 -151
  176. package/src/ui/ModelReasoningPicker.test.tsx +0 -447
  177. package/src/ui/PlanReviewPanel.test.tsx +0 -267
  178. package/src/ui/PromptCardBorder.test.tsx +0 -161
  179. package/src/ui/ProviderPicker.test.tsx +0 -289
  180. package/src/ui/ProviderShortcut.test.tsx +0 -143
  181. package/src/ui/SettingsPanel.test.tsx +0 -233
  182. package/src/ui/StaticTranscriptItem.tsx +0 -56
  183. package/src/ui/Timeline.test.ts +0 -2067
  184. package/src/ui/TimelineNavigation.test.tsx +0 -201
  185. package/src/ui/TopHeader.test.tsx +0 -254
  186. package/src/ui/TurnGroup.test.tsx +0 -365
  187. package/src/ui/busyStatusAnimation.test.ts +0 -30
  188. package/src/ui/commandNormalize.test.ts +0 -142
  189. package/src/ui/diffRenderer.test.ts +0 -102
  190. package/src/ui/focusFlow.test.tsx +0 -1098
  191. package/src/ui/inputBuffer.test.ts +0 -151
  192. package/src/ui/layout.test.ts +0 -146
  193. package/src/ui/modeDisplay.test.ts +0 -42
  194. package/src/ui/runActivityView.test.ts +0 -89
  195. package/src/ui/runLifecycleView.test.tsx +0 -237
  196. package/src/ui/statusRenderIsolation.test.tsx +0 -654
  197. package/src/ui/terminalAnswerFormat.test.ts +0 -19
  198. package/src/ui/textLayout.test.ts +0 -18
  199. package/src/ui/themeFlow.test.ts +0 -53
  200. package/src/ui/timelineMeasureCache.test.ts +0 -986
  201. /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
  202. /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
  203. /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
  204. /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
  205. /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
  206. /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isTerminalResizing } from "./terminalControl.js";
4
+
5
+ export interface FrameLockOptions {
6
+ stdout: any;
7
+ env: Record<string, string | undefined>;
8
+ }
9
+
10
+ const resizeFrameCacheResetters = new WeakMap<object, () => void>();
11
+
12
+ export function resetFrameLockForResize(stdout: object): void {
13
+ resizeFrameCacheResetters.get(stdout)?.();
14
+ }
15
+
16
+ /**
17
+ * Wraps the stdout stream to enforce frame-level deduplication, a flush lock,
18
+ * and width-safe row padding via ANSI clear-to-EOL (\x1b[K) injection.
19
+ */
20
+ export function wrapStdoutWithFrameLock({
21
+ stdout,
22
+ env,
23
+ }: FrameLockOptions) {
24
+ let lastFrame = "";
25
+ let isFlushing = false;
26
+ let debugLogStream: fs.WriteStream | null = null;
27
+
28
+ if (env.CODEXA_RENDER_DEBUG === "1") {
29
+ try {
30
+ const logPath = env.CODEXA_RENDER_DEBUG_FILE?.trim()
31
+ || path.join(process.cwd(), ".codexa", "debug", "render-status.log");
32
+ const logDir = path.dirname(logPath);
33
+ if (!fs.existsSync(logDir)) {
34
+ fs.mkdirSync(logDir, { recursive: true });
35
+ }
36
+ debugLogStream = fs.createWriteStream(logPath, { flags: "a" });
37
+ } catch (e) {
38
+ // Gracefully fall back if logging fails (e.g. read-only filesystem)
39
+ void e;
40
+ }
41
+ }
42
+
43
+ const logDebug = (msg: string) => {
44
+ if (debugLogStream) {
45
+ debugLogStream.write(`${new Date().toISOString()} ${msg}\n`);
46
+ }
47
+ };
48
+
49
+ const originalWrite = stdout.write.bind(stdout);
50
+
51
+ // A terminal resize can make an otherwise identical Ink frame meaningful:
52
+ // rows reflow at a new width and height-driven spacer/layout changes must be
53
+ // written even when their source text did not change. src/index.tsx owns the
54
+ // single resize listener and invokes this reset through the exported helper.
55
+ resizeFrameCacheResetters.set(stdout, () => {
56
+ lastFrame = "";
57
+ logDebug("Frame cache reset: terminal resize");
58
+ });
59
+
60
+ stdout.write = (chunk: string | Uint8Array) => {
61
+ if (typeof chunk !== "string") {
62
+ return originalWrite(chunk);
63
+ }
64
+
65
+ // Task 7: Frame Lock
66
+ // Ensure only one render flush can write to stdout at a time.
67
+ if (isFlushing) {
68
+ logDebug("Frame dropped: lock active (concurrent flush)");
69
+ return true;
70
+ }
71
+
72
+ // Task 8: Full-frame Deduplication
73
+ // Skip if identical to last frame.
74
+ if (chunk === lastFrame) {
75
+ logDebug("Frame dropped: identical to last frame");
76
+ return true;
77
+ }
78
+
79
+ // Task 4: Width-Safe Injection
80
+ // Ensure every row clears to the end of the line. This fixes artifacts
81
+ // left behind when a shorter row replaces a longer row or when terminal
82
+ // wrapping occurs during resize.
83
+ //
84
+ // We inject \x1b[K before every newline and at the end of the frame.
85
+ // padding via \x1b[K injection.
86
+ // Only apply if the chunk is non-empty and doesn't look like a control-only
87
+ // sequence (e.g. terminal title OSC).
88
+ let processed = chunk;
89
+ const isControlSequence = chunk.includes("\x1b]");
90
+ if (chunk.length > 0 && !isControlSequence) {
91
+ processed = chunk.replace(/\n/g, "\x1b[K\n");
92
+ if (!processed.endsWith("\x1b[K") && !processed.endsWith("\x1b[K\n")) {
93
+ processed += "\x1b[K";
94
+ }
95
+ }
96
+
97
+ isFlushing = true;
98
+ try {
99
+ lastFrame = chunk;
100
+ const resizing = isTerminalResizing();
101
+ logDebug(`Frame written: length=${processed.length} original=${chunk.length} resizing=${resizing}`);
102
+ return originalWrite(processed);
103
+ } finally {
104
+ isFlushing = false;
105
+ }
106
+ };
107
+
108
+ return stdout;
109
+ }
@@ -0,0 +1,123 @@
1
+ import * as renderDebug from "../perf/renderDebug.js";
2
+
3
+ /**
4
+ * Typed subset of the internal Ink class instance we reach into.
5
+ *
6
+ * `unsubscribeResize` is used at startup to disable Ink's competing resize
7
+ * handler (see src/index.tsx). The remaining fields are Ink's per-frame render
8
+ * caches; we reset them on the explicit /clear boundary so the next frame is
9
+ * written authoritatively from a clean baseline — exactly like the first frame
10
+ * after a cold startup — rather than diffed against pre-clear output.
11
+ *
12
+ * All fields are optional so the type also describes the minimal capability
13
+ * needed by callers that only disable resize, and so resolution degrades
14
+ * gracefully across Ink versions / test mocks.
15
+ */
16
+ export interface InkRenderInstance {
17
+ unsubscribeResize?: () => void;
18
+ renderInteractiveFrame?: (output: string, outputHeight: number, staticOutput: string) => void;
19
+ lastOutput?: string;
20
+ lastOutputToRender?: string;
21
+ lastOutputHeight?: number;
22
+ lastTerminalWidth?: number;
23
+ fullStaticOutput?: string;
24
+ log?: { reset?: () => void };
25
+ throttledOnRender?: { cancel?: () => void };
26
+ throttledLog?: { cancel?: () => void };
27
+ }
28
+
29
+ export interface AppStdoutLike {
30
+ columns?: number;
31
+ rows?: number;
32
+ }
33
+
34
+ /**
35
+ * Resolve the real Ink class instance via Ink's internal WeakMap<stdout, Ink>.
36
+ * Returns null if resolution fails (e.g. different Ink version, test mocks).
37
+ *
38
+ * Bun doesn't resolve bare subpath imports like "ink/build/instances.js" so we
39
+ * resolve ink's main entry first, then derive the sibling path.
40
+ */
41
+ export function resolveInkRenderInstance(stdout: object): InkRenderInstance | null {
42
+ try {
43
+ const { createRequire } = require("node:module");
44
+ const req = createRequire(import.meta.url);
45
+ const inkMain = req.resolve("ink");
46
+ const instancesPath = inkMain.replace(/index\.js$/, "instances.js");
47
+ const instances = req(instancesPath);
48
+ const weakMap: WeakMap<object, InkRenderInstance> =
49
+ instances.default ?? instances;
50
+ return weakMap.get(stdout) ?? null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export interface ResetInkOutputOptions {
57
+ instance: InkRenderInstance | null;
58
+ /** Current terminal column count, used to reseat lastTerminalWidth. */
59
+ columns?: number;
60
+ }
61
+
62
+ /**
63
+ * Reset Ink's render-state caches to the fresh-startup baseline.
64
+ *
65
+ * Call this immediately AFTER the terminal has been physically cleared (e.g. by
66
+ * terminalControl.clearTranscript on /clear) and BEFORE the post-clear React
67
+ * render commits. It mirrors what a brand-new Ink instance looks like at cold
68
+ * startup: empty frame caches and zero previous height, so the next frame is
69
+ * authoritative and the subsequent resize math (Ink's renderInteractiveFrame /
70
+ * shouldClearTerminalForFrame) is evaluated against truthful state.
71
+ *
72
+ * This does NOT emit any clear/erase escape sequences itself — the terminal is
73
+ * already physically cleared, and Ink's log.reset() zeroes log-update's
74
+ * accounting without writing. Returns false (graceful no-op) if no live Ink
75
+ * instance is available.
76
+ */
77
+ export function resetInkOutputForFreshFrame({ instance, columns }: ResetInkOutputOptions): boolean {
78
+ if (!instance) {
79
+ renderDebug.traceEvent("terminal", "clearRenderReset", { instanceResolved: false });
80
+ return false;
81
+ }
82
+
83
+ const before = {
84
+ lastOutputLength: instance.lastOutput?.length ?? 0,
85
+ lastOutputToRenderLength: instance.lastOutputToRender?.length ?? 0,
86
+ lastOutputHeight: instance.lastOutputHeight ?? 0,
87
+ fullStaticOutputLength: instance.fullStaticOutput?.length ?? 0,
88
+ lastTerminalWidth: instance.lastTerminalWidth,
89
+ };
90
+
91
+ // Drop any pending throttled render/log so a stale pre-clear frame can't be
92
+ // flushed after we reset the caches.
93
+ instance.throttledOnRender?.cancel?.();
94
+ instance.throttledLog?.cancel?.();
95
+
96
+ // Zero log-update's previousOutput/previousLineCount WITHOUT emitting escape
97
+ // sequences (the terminal was already physically cleared).
98
+ instance.log?.reset?.();
99
+
100
+ // Reset Ink's frame caches to the constructor/startup state.
101
+ instance.lastOutput = "";
102
+ instance.lastOutputToRender = "";
103
+ instance.lastOutputHeight = 0;
104
+ instance.fullStaticOutput = "";
105
+ if (typeof columns === "number" && Number.isFinite(columns)) {
106
+ instance.lastTerminalWidth = columns;
107
+ }
108
+
109
+ renderDebug.traceEvent("terminal", "clearRenderReset", {
110
+ instanceResolved: true,
111
+ columns,
112
+ before,
113
+ after: {
114
+ lastOutputLength: instance.lastOutput.length,
115
+ lastOutputToRenderLength: instance.lastOutputToRender.length,
116
+ lastOutputHeight: instance.lastOutputHeight,
117
+ fullStaticOutputLength: instance.fullStaticOutput.length,
118
+ lastTerminalWidth: instance.lastTerminalWidth,
119
+ },
120
+ });
121
+
122
+ return true;
123
+ }
@@ -14,11 +14,24 @@ export const TERMINAL_SEQUENCES = {
14
14
  mouseEnable: "\x1b[?1000h\x1b[?1006h",
15
15
  mouseDisable: "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l",
16
16
  title: `\x1b]0;${TERMINAL_TITLE}\x07\x1b]2;${TERMINAL_TITLE}\x07`,
17
+ alternateScreenEnable: "\x1b[?1049h",
18
+ alternateScreenDisable: "\x1b[?1049l",
17
19
  } as const;
18
20
 
19
21
  export type TerminalWrite = (chunk: string) => boolean | void;
20
22
  export type TerminalChannel = "stdout" | "stderr";
21
23
 
24
+ // Tracks whether the terminal is currently in a live resize debounce.
25
+ let terminalResizing = false;
26
+
27
+ export function setTerminalResizing(resizing: boolean): void {
28
+ terminalResizing = resizing;
29
+ }
30
+
31
+ export function isTerminalResizing(): boolean {
32
+ return terminalResizing;
33
+ }
34
+
22
35
  // Tracks current UI state kind for diagnostic assertions.
23
36
  let currentUIStateKind: string = "IDLE";
24
37
 
@@ -107,12 +120,14 @@ export interface TerminalModeController {
107
120
  clearViewport(source: string): void;
108
121
  setMouseReporting(enabled: boolean, source: string): void;
109
122
  setBracketedPaste(enabled: boolean, source: string): void;
123
+ setAlternateScreen(enabled: boolean, source: string): void;
110
124
  resetModes(): void;
111
125
  }
112
126
 
113
127
  export function createTerminalModeController(write: TerminalWrite): TerminalModeController {
114
128
  let mouseReporting: boolean | null = null;
115
129
  let bracketedPaste: boolean | null = null;
130
+ let alternateScreen: boolean | null = null;
116
131
 
117
132
  const writeStdout = (sequence: string, source: string) =>
118
133
  writeTerminalControl(write, "stdout", source, sequence);
@@ -135,13 +150,20 @@ export function createTerminalModeController(write: TerminalWrite): TerminalMode
135
150
  bracketedPaste = enabled;
136
151
  writeStdout(enabled ? TERMINAL_SEQUENCES.bracketedPasteEnable : TERMINAL_SEQUENCES.bracketedPasteDisable, source);
137
152
  },
153
+ setAlternateScreen(enabled, source) {
154
+ if (alternateScreen === enabled) return;
155
+ alternateScreen = enabled;
156
+ writeStdout(enabled ? TERMINAL_SEQUENCES.alternateScreenEnable : TERMINAL_SEQUENCES.alternateScreenDisable, source);
157
+ },
138
158
  resetModes() {
139
159
  renderDebug.traceEvent("terminal", "resetModes", {
140
160
  mouseReporting,
141
161
  bracketedPaste,
162
+ alternateScreen,
142
163
  });
143
164
  mouseReporting = null;
144
165
  bracketedPaste = null;
166
+ alternateScreen = null;
145
167
  },
146
168
  };
147
169
  }
@@ -1,6 +1,7 @@
1
- import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
2
- import { appendFileSync, mkdirSync } from "fs";
3
- import { dirname, join } from "path";
1
+ import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
2
+ import { appendFileSync, mkdirSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import * as renderDebug from "../perf/renderDebug.js";
4
5
 
5
6
  export const DEFAULT_TERMINAL_TITLE = APP_NAME;
6
7
 
@@ -49,16 +50,17 @@ function findIncompleteOscTitleStart(text: string): number {
49
50
  return -1;
50
51
  }
51
52
 
52
- const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["CODEXA_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
53
- || join(process.cwd(), ".codexa-debug", "terminal-title-debug.log");
53
+ const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["CODEXA_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
54
+ || process.env["CODEXA_RENDER_DEBUG_FILE"]?.trim()
55
+ || join(process.cwd(), ".codexa", "debug", "render-status.log");
54
56
 
55
57
  let terminalTitleLifecycleState = "unknown";
56
58
 
57
- function debugLog(msg: string): void {
58
- if (DEBUG_TERMINAL_TITLE) {
59
- process.stderr.write(`[codexa:terminal-title] ${msg}\n`);
60
- }
61
- }
59
+ function debugLog(msg: string): void {
60
+ if (DEBUG_TERMINAL_TITLE) {
61
+ writeTerminalTitleDebugRecord({ event: "debug", message: msg });
62
+ }
63
+ }
62
64
 
63
65
  function writeTerminalTitleDebugRecord(fields: Record<string, unknown>): void {
64
66
  if (!DEBUG_TERMINAL_TITLE) return;
@@ -144,8 +146,9 @@ export function writeCodexaTerminalTitle(title: string, options?: TerminalTitleO
144
146
 
145
147
  lastWrittenTerminalTitle = cleanTitle;
146
148
 
147
- const sequence = buildTerminalTitleSequence(cleanTitle);
148
- writeTerminalTitleDebugRecord({
149
+ const sequence = buildTerminalTitleSequence(cleanTitle);
150
+ renderDebug.traceTerminalWrite("stdout", `terminalTitle:${options?.reason ?? "unknown"}`, sequence);
151
+ writeTerminalTitleDebugRecord({
149
152
  event: "codexaTitleWrite",
150
153
  title: cleanTitle,
151
154
  reason: options?.reason ?? "unknown",
@@ -353,76 +356,7 @@ export function writeGuardedTerminalOutput(
353
356
  return write(safeText) !== false;
354
357
  }
355
358
 
356
- // ─── Startup guard ────────────────────────────────────────────────────────────
357
-
358
- /**
359
- * Schedules a sequence of title assertions to outlast Windows Terminal's
360
- * shell integration, which overwrites the process title at unpredictable
361
- * points during startup (shell prompt init, profile scripts, etc.).
362
- * The retries at 50/250/500/1000ms are intentional: no single delay is
363
- * reliable across all terminal configurations, so we assert multiple times.
364
- */
365
- export function beginColdStartSequence(title: string, options?: { write?: (chunk: string) => void }) {
366
- setIntendedTerminalTitle(title, { force: true, write: options?.write, reason: "cold-start-immediate" });
367
-
368
- const timers: ReturnType<typeof setTimeout>[] = [];
369
- let cancelled = false;
370
-
371
- const scheduleRetry = (delayMs: number) => {
372
- const id = setTimeout(() => {
373
- if (!cancelled) {
374
- debugLog(`cold-start retry t=${delayMs}ms fired`);
375
- reassertIntendedTerminalTitle({ write: options?.write, reason: `cold-start-retry-${delayMs}ms` });
376
- }
377
- }, delayMs);
378
- timers.push(id);
379
- };
380
-
381
- scheduleRetry(50);
382
- scheduleRetry(250);
383
- scheduleRetry(500);
384
- scheduleRetry(1000);
385
-
386
- return () => {
387
- cancelled = true;
388
- timers.forEach(clearTimeout);
389
- debugLog("cold-start sequence cancelled");
390
- };
391
- }
392
-
393
- export function startTerminalTitleStartupGuard(options?: {
394
- write?: (chunk: string) => void;
395
- intervalMs?: number;
396
- durationMs?: number;
397
- reason?: string;
398
- }): () => void {
399
- const intervalMs = options?.intervalMs ?? 150;
400
- const durationMs = options?.durationMs ?? 3500;
401
- const reason = options?.reason ?? "startup-guard";
402
- let stopped = false;
403
- const startedAt = Date.now();
404
-
405
- reassertIntendedTerminalTitle({ write: options?.write, reason: `${reason}-start` });
406
- const interval = setInterval(() => {
407
- if (stopped) return;
408
- if (Date.now() - startedAt >= durationMs) {
409
- stopped = true;
410
- clearInterval(interval);
411
- reassertIntendedTerminalTitle({ write: options?.write, reason: `${reason}-end` });
412
- return;
413
- }
414
- reassertIntendedTerminalTitle({ write: options?.write, reason });
415
- }, intervalMs);
416
- interval.unref?.();
417
-
418
- return () => {
419
- if (stopped) return;
420
- stopped = true;
421
- clearInterval(interval);
422
- };
423
- }
424
-
425
- export function sanitizeTerminalTitle(title: string): string {
359
+ export function sanitizeTerminalTitle(title: string): string {
426
360
  return title
427
361
  .replace(/[\u0000-\u001f\u007f]/g, " ")
428
362
  .replace(/\u001b/g, "")
@@ -461,23 +395,3 @@ export function reassertTerminalTitle(
461
395
  write(buildTerminalTitleSequence(title));
462
396
  }
463
397
 
464
- /**
465
- * Acquire a run-scoped title guard that immediately asserts the title,
466
- * reasserts it periodically while work is active, and emits one final
467
- * assertion when released.
468
- */
469
- export function acquireTerminalTitleGuard(
470
- intervalMs = 250,
471
- reassert: () => void = () => reassertTerminalTitle(),
472
- ): () => void {
473
- let released = false;
474
- reassert();
475
- const timer = setInterval(reassert, intervalMs);
476
- timer.unref?.();
477
- return () => {
478
- if (released) return;
479
- released = true;
480
- clearInterval(timer);
481
- reassert();
482
- };
483
- }
@@ -0,0 +1,23 @@
1
+ import { APP_VERSION } from "../../config/settings.js";
2
+
3
+ export const CODEXA_CHANNEL_ENV = "CODEXA_CHANNEL";
4
+ export const LOCAL_DEV_CHANNEL = "local-dev";
5
+
6
+ export function getCodexaChannel(env: NodeJS.ProcessEnv = process.env): string {
7
+ return env[CODEXA_CHANNEL_ENV]?.trim() || "published";
8
+ }
9
+
10
+ export function isLocalDevChannel(env: NodeJS.ProcessEnv = process.env): boolean {
11
+ return getCodexaChannel(env) === LOCAL_DEV_CHANNEL;
12
+ }
13
+
14
+ export function formatCodexaVersionLabel(
15
+ version: string = APP_VERSION,
16
+ env: NodeJS.ProcessEnv = process.env,
17
+ ): string {
18
+ return isLocalDevChannel(env) ? `${version}-dev local` : version;
19
+ }
20
+
21
+ export function formatCodexaBrandLabel(env: NodeJS.ProcessEnv = process.env): string {
22
+ return `Codexa v${formatCodexaVersionLabel(APP_VERSION, env)}`;
23
+ }
@@ -0,0 +1,193 @@
1
+ import { APP_VERSION } from "../../config/settings.js";
2
+ import { isLocalDevChannel } from "./channel.js";
3
+
4
+ export const CODEXA_NPM_PACKAGE = "@golba98/codexa";
5
+ export const CODEXA_NPM_REGISTRY_URL = "https://registry.npmjs.org/@golba98%2Fcodexa";
6
+ export const CODEXA_UPDATE_COMMAND = `npm install -g ${CODEXA_NPM_PACKAGE}@latest`;
7
+
8
+ export type UpdateStatus = "up-to-date" | "update-available" | "unknown" | "error";
9
+
10
+ export interface NpmRegistryMetadata {
11
+ "dist-tags"?: { latest?: unknown };
12
+ }
13
+
14
+ export interface UpdateCheckResult {
15
+ status: UpdateStatus;
16
+ currentVersion: string;
17
+ latestVersion: string | null;
18
+ errorMessage?: string;
19
+ checkedAt: number;
20
+ source?: "npm" | "cache";
21
+ }
22
+
23
+ const FETCH_TIMEOUT_MS = 5000;
24
+
25
+ /** Strip a leading "v" so "v1.0.2" and "1.0.2" are treated as equal. */
26
+ export function normalizeVersion(v: string): string {
27
+ return v.startsWith("v") ? v.slice(1) : v;
28
+ }
29
+
30
+ export function formatVersionLabel(version: string): string {
31
+ const normalized = normalizeVersion(version.trim());
32
+ return normalized ? `v${normalized}` : version;
33
+ }
34
+
35
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
36
+
37
+ /** Returns true for valid semver strings with or without a leading "v". */
38
+ export function isValidSemver(v: string): boolean {
39
+ return SEMVER_RE.test(normalizeVersion(v));
40
+ }
41
+
42
+ export function shouldRunStartupUpdateCheck(
43
+ env: NodeJS.ProcessEnv = process.env,
44
+ enabled = true,
45
+ ): boolean {
46
+ return enabled && !isLocalDevChannel(env);
47
+ }
48
+
49
+ // Compares two semver strings numerically. Returns negative if a < b, 0 if equal, positive if a > b.
50
+ // Pre-release versions (e.g. 1.0.2-beta.1) sort below their release counterpart (1.0.2 > 1.0.2-beta.1).
51
+ // Leading "v" is stripped before comparison.
52
+ export function compareSemver(a: string, b: string): number {
53
+ const parseParts = (v: string): { numeric: number[]; prerelease: string | null } => {
54
+ const norm = normalizeVersion(v);
55
+ const dashIdx = norm.indexOf("-");
56
+ const base = dashIdx === -1 ? norm : norm.slice(0, dashIdx);
57
+ const prerelease = dashIdx === -1 ? null : norm.slice(dashIdx + 1);
58
+ const numeric = base.split(".").map((p) => parseInt(p, 10) || 0);
59
+ return { numeric, prerelease };
60
+ };
61
+
62
+ const pa = parseParts(a);
63
+ const pb = parseParts(b);
64
+ const len = Math.max(pa.numeric.length, pb.numeric.length);
65
+
66
+ for (let i = 0; i < len; i++) {
67
+ const diff = (pa.numeric[i] ?? 0) - (pb.numeric[i] ?? 0);
68
+ if (diff !== 0) return diff;
69
+ }
70
+
71
+ // Same numeric version: no pre-release > has pre-release
72
+ if (pa.prerelease === null && pb.prerelease !== null) return 1;
73
+ if (pa.prerelease !== null && pb.prerelease === null) return -1;
74
+ if (pa.prerelease !== null && pb.prerelease !== null) {
75
+ return pa.prerelease < pb.prerelease ? -1 : pa.prerelease > pb.prerelease ? 1 : 0;
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ export function isNewerVersion(candidate: string, current: string): boolean {
81
+ return compareSemver(candidate, current) > 0;
82
+ }
83
+
84
+ export interface UpdateCheckOverrides {
85
+ currentVersion?: string;
86
+ fetchNpmMetadataFn?: (url: string) => Promise<NpmRegistryMetadata>;
87
+ }
88
+
89
+ async function defaultFetchNpmMetadata(url: string): Promise<NpmRegistryMetadata> {
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
92
+ try {
93
+ const res = await fetch(url, {
94
+ signal: controller.signal,
95
+ headers: { "User-Agent": `${CODEXA_NPM_PACKAGE}-update-checker/1.0` },
96
+ });
97
+ if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status}`);
98
+ return await res.json() as NpmRegistryMetadata;
99
+ } finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+
104
+ export async function checkForUpdates(
105
+ opts?: { enabled?: boolean },
106
+ overrides?: UpdateCheckOverrides,
107
+ ): Promise<UpdateCheckResult> {
108
+ const currentVersion = normalizeVersion(overrides?.currentVersion ?? APP_VERSION);
109
+
110
+ if (opts?.enabled === false) {
111
+ return { status: "unknown", currentVersion, latestVersion: null, checkedAt: Date.now(), source: "npm" };
112
+ }
113
+
114
+ try {
115
+ const fetchFn = overrides?.fetchNpmMetadataFn ?? defaultFetchNpmMetadata;
116
+ const metadata = await fetchFn(CODEXA_NPM_REGISTRY_URL);
117
+ const rawLatest = metadata["dist-tags"]?.latest;
118
+
119
+ if (typeof rawLatest !== "string" || !rawLatest.trim()) {
120
+ return {
121
+ status: "error",
122
+ currentVersion,
123
+ latestVersion: null,
124
+ errorMessage: "npm registry response did not include dist-tags.latest",
125
+ checkedAt: Date.now(),
126
+ source: "npm",
127
+ };
128
+ }
129
+
130
+ const latestVersion = normalizeVersion(rawLatest.trim());
131
+
132
+ if (!isValidSemver(latestVersion) || !isValidSemver(currentVersion)) {
133
+ return {
134
+ status: "unknown",
135
+ currentVersion,
136
+ latestVersion: rawLatest,
137
+ errorMessage: `Invalid semver — current: "${currentVersion}", latest: "${rawLatest}"`,
138
+ checkedAt: Date.now(),
139
+ source: "npm",
140
+ };
141
+ }
142
+
143
+ const status = isNewerVersion(latestVersion, currentVersion) ? "update-available" : "up-to-date";
144
+ return { status, currentVersion, latestVersion, checkedAt: Date.now(), source: "npm" };
145
+ } catch (err) {
146
+ return {
147
+ status: "error",
148
+ currentVersion,
149
+ latestVersion: null,
150
+ errorMessage: err instanceof Error ? err.message : String(err),
151
+ checkedAt: Date.now(),
152
+ source: "npm",
153
+ };
154
+ }
155
+ }
156
+
157
+ export function formatUpdateInstructions(result: UpdateCheckResult | null): string {
158
+ const current = result?.currentVersion ?? APP_VERSION;
159
+ const latest = result?.latestVersion ?? "unknown";
160
+
161
+ if (result?.status === "error") {
162
+ return [
163
+ `Current installed version: ${current}`,
164
+ `npm latest version: ${latest}`,
165
+ `Error checking npm update status: ${result.errorMessage ?? "unknown error"}`,
166
+ ].join("\n");
167
+ }
168
+
169
+ let statusLine: string;
170
+ if (result?.status === "update-available" && result.latestVersion) {
171
+ statusLine = `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`;
172
+ } else if (result?.status === "up-to-date") {
173
+ statusLine = "Already up to date.";
174
+ } else {
175
+ statusLine = "Status unknown — could not reach npm registry.";
176
+ }
177
+
178
+ return [
179
+ `Current installed version: ${current}`,
180
+ `npm latest version: ${latest}`,
181
+ `Status: ${statusLine}`,
182
+ "",
183
+ `Run: ${CODEXA_UPDATE_COMMAND}`,
184
+ ].join("\n");
185
+ }
186
+
187
+ export function formatLocalDevUpdateStatus(): string {
188
+ return [
189
+ "Running local-dev Codexa.",
190
+ "Automatic published npm update prompts are disabled for this channel.",
191
+ "Run /update check to explicitly check the published npm package.",
192
+ ].join("\n");
193
+ }