@golba98/codexa 1.0.1

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 (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,21 @@
1
+ import { AVAILABLE_BACKENDS, DEFAULT_BACKEND } from "../../config/settings.js";
2
+ import { codexSubprocessProvider } from "./codexSubprocess.js";
3
+ import { openaiNativeProvider } from "./openaiNative.js";
4
+ import type { BackendProvider } from "./types.js";
5
+
6
+ export const BACKEND_PROVIDERS: BackendProvider[] = [
7
+ codexSubprocessProvider,
8
+ openaiNativeProvider,
9
+ ];
10
+
11
+ export function getBackendProvider(id: string): BackendProvider {
12
+ return (
13
+ BACKEND_PROVIDERS.find((provider) => provider.id === id) ??
14
+ BACKEND_PROVIDERS.find((provider) => provider.id === DEFAULT_BACKEND) ??
15
+ codexSubprocessProvider
16
+ );
17
+ }
18
+
19
+ export function listBackendSummaries(): string {
20
+ return AVAILABLE_BACKENDS.map((backend, index) => ` ${index + 1}. ${backend.label} (${backend.id})`).join("\n");
21
+ }
@@ -0,0 +1,59 @@
1
+ import type { AvailableBackend } from "../../config/settings.js";
2
+ import type { ResolvedRuntimeConfig } from "../../config/runtimeConfig.js";
3
+ import type { ProjectInstructions } from "../projectInstructions.js";
4
+ import type { RunProgressSource, RunToolActivity } from "../../session/types.js";
5
+
6
+ export interface BackendProgressUpdate {
7
+ id: string;
8
+ source: RunProgressSource;
9
+ text: string;
10
+ }
11
+
12
+ export type BackendAuthState = "delegated" | "api-key-required" | "coming-soon";
13
+
14
+ export interface BackendRunHandlers {
15
+ onResponse: (response: string) => void;
16
+ onError: (message: string, rawOutput?: string) => void;
17
+ /** Called with each new structured thinking/progress update while the process is still running. */
18
+ onProgress?: (update: BackendProgressUpdate) => void;
19
+ /** Called with each new assistant content delta while the process is still running. */
20
+ onAssistantDelta?: (chunk: string) => void;
21
+ /** Called when the backend indicates the final assistant answer is complete and visible. */
22
+ onFinalAnswerObserved?: (response: string) => void;
23
+ /** Called when the backend starts or finishes a tool/shell action during a run. */
24
+ onToolActivity?: (activity: RunToolActivity) => void;
25
+ /** Called around backend child-process lifecycle boundaries. */
26
+ onProcessLifecycle?: (event: "before-spawn" | "spawned" | "exit" | "error" | "cleanup") => void;
27
+ /** Lightweight hooks used only by headless benchmark diagnostics. */
28
+ benchmarkHooks?: {
29
+ onProviderPromptPrepared?: (context: { policy: "raw" | "wrapped"; characterCount: number }) => void;
30
+ onProviderPrepStart?: () => void;
31
+ onProviderPrepComplete?: () => void;
32
+ onCodexProcessSpawned?: (context: { executable: string; argv: string[] }) => void;
33
+ onFirstStdout?: (observed?: boolean) => void;
34
+ onFirstStderr?: (observed?: boolean) => void;
35
+ onCodexProcessExit?: (exitCode: number | null) => void;
36
+ onCleanupStart?: () => void;
37
+ onCleanupComplete?: (context: { skipped: boolean }) => void;
38
+ };
39
+ }
40
+
41
+ export interface BackendProvider {
42
+ id: AvailableBackend;
43
+ label: string;
44
+ description: string;
45
+ authState: BackendAuthState;
46
+ authLabel: string;
47
+ statusMessage: string;
48
+ supportsModels: (model: string) => boolean;
49
+ run?: (
50
+ prompt: string,
51
+ options: {
52
+ runtime: ResolvedRuntimeConfig;
53
+ workspaceRoot: string;
54
+ projectInstructions?: ProjectInstructions | null;
55
+ promptPolicy?: "raw" | "wrapped";
56
+ },
57
+ handlers: BackendRunHandlers,
58
+ ) => () => void;
59
+ }
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { getTerminalCapability } from "./terminalCapabilities.js";
4
+
5
+ test("allows Windows Terminal", () => {
6
+ const result = getTerminalCapability({
7
+ stdinIsTTY: true,
8
+ stdoutIsTTY: true,
9
+ platform: "win32",
10
+ env: { WT_SESSION: "1" },
11
+ });
12
+
13
+ assert.equal(result.supported, true);
14
+ assert.equal(result.reason, "supported");
15
+ assert.equal(result.warning, undefined);
16
+ });
17
+
18
+ test("allows the VS Code terminal", () => {
19
+ const result = getTerminalCapability({
20
+ stdinIsTTY: true,
21
+ stdoutIsTTY: true,
22
+ platform: "win32",
23
+ env: { TERM_PROGRAM: "vscode" },
24
+ });
25
+
26
+ assert.equal(result.supported, true);
27
+ assert.equal(result.reason, "supported");
28
+ assert.equal(result.warning, undefined);
29
+ });
30
+
31
+ test("allows modern Windows TTY when TERM is missing but warns", () => {
32
+ const result = getTerminalCapability({
33
+ stdinIsTTY: true,
34
+ stdoutIsTTY: true,
35
+ platform: "win32",
36
+ env: {},
37
+ });
38
+
39
+ assert.equal(result.supported, true);
40
+ assert.equal(result.reason, "supported");
41
+ assert.match(result.warning ?? "", /continue/i);
42
+ });
43
+
44
+ test("CODEXA_FORCE_VT bypasses VT compatibility detection", () => {
45
+ const result = getTerminalCapability({
46
+ stdinIsTTY: true,
47
+ stdoutIsTTY: true,
48
+ platform: "win32",
49
+ env: { CODEXA_FORCE_VT: "1", TERM: "dumb" },
50
+ });
51
+
52
+ assert.equal(result.supported, true);
53
+ assert.equal(result.reason, "supported");
54
+ assert.equal(result.warning, undefined);
55
+ });
56
+
57
+ test("CODEXA_REQUIRE_VT hard-fails when Windows VT support is not detected", () => {
58
+ const result = getTerminalCapability({
59
+ stdinIsTTY: true,
60
+ stdoutIsTTY: true,
61
+ platform: "win32",
62
+ env: { CODEXA_REQUIRE_VT: "1" },
63
+ });
64
+
65
+ assert.equal(result.supported, false);
66
+ assert.equal(result.reason, "unsupported-terminal");
67
+ assert.match(result.message, /CODEXA_FORCE_VT=1/i);
68
+ });
69
+
70
+ test("rejects a dumb terminal even when it is interactive", () => {
71
+ const result = getTerminalCapability({
72
+ stdinIsTTY: true,
73
+ stdoutIsTTY: true,
74
+ platform: "linux",
75
+ env: { TERM: "dumb" },
76
+ });
77
+
78
+ assert.equal(result.supported, false);
79
+ assert.equal(result.reason, "unsupported-terminal");
80
+ });
81
+
82
+ test("rejects redirected or non-interactive output", () => {
83
+ const result = getTerminalCapability({
84
+ stdinIsTTY: true,
85
+ stdoutIsTTY: false,
86
+ platform: "linux",
87
+ env: { TERM: "xterm-256color" },
88
+ });
89
+
90
+ assert.equal(result.supported, false);
91
+ assert.equal(result.reason, "notty");
92
+ assert.match(result.message, /interactive terminal/i);
93
+ });
@@ -0,0 +1,100 @@
1
+ export interface TerminalCapabilityInput {
2
+ stdinIsTTY: boolean;
3
+ stdoutIsTTY: boolean;
4
+ platform: NodeJS.Platform | string;
5
+ env: Record<string, string | undefined>;
6
+ }
7
+
8
+ export interface TerminalCapabilityResult {
9
+ supported: boolean;
10
+ reason: "supported" | "notty" | "unsupported-terminal";
11
+ message: string;
12
+ warning?: string;
13
+ }
14
+
15
+ const WINDOWS_SUPPORTED_TERM_PATTERNS = [
16
+ /^xterm/i,
17
+ /^screen/i,
18
+ /^tmux/i,
19
+ /^vt\d+/i,
20
+ /^ansi/i,
21
+ /^cygwin/i,
22
+ /^linux/i,
23
+ ];
24
+
25
+ function hasSupportedWindowsTerminal(env: Record<string, string | undefined>): boolean {
26
+ const term = env.TERM ?? "";
27
+ const termProgram = env.TERM_PROGRAM ?? "";
28
+
29
+ if (env.WT_SESSION) return true;
30
+ if (env.ANSICON) return true;
31
+ if ((env.ConEmuANSI ?? "").toUpperCase() === "ON") return true;
32
+ if (termProgram.toLowerCase() === "vscode") return true;
33
+ if (termProgram.toLowerCase() === "hyper") return true;
34
+ if (termProgram.toLowerCase() === "jetbrains-jediterm") return true;
35
+
36
+ return WINDOWS_SUPPORTED_TERM_PATTERNS.some((pattern) => pattern.test(term));
37
+ }
38
+
39
+ export function getTerminalCapability(input: TerminalCapabilityInput): TerminalCapabilityResult {
40
+ if (!input.stdinIsTTY || !input.stdoutIsTTY) {
41
+ return {
42
+ supported: false,
43
+ reason: "notty",
44
+ message: "This UI requires an interactive terminal.",
45
+ };
46
+ }
47
+
48
+ // CODEXA_FORCE_VT=1 bypasses terminal detection entirely — useful when the terminal
49
+ // doesn't advertise VT support through standard env vars but is actually compatible.
50
+ if (input.env.CODEXA_FORCE_VT === "1") {
51
+ return {
52
+ supported: true,
53
+ reason: "supported",
54
+ message: "",
55
+ };
56
+ }
57
+
58
+ const term = (input.env.TERM ?? "").trim().toLowerCase();
59
+ if (term === "dumb") {
60
+ return {
61
+ supported: false,
62
+ reason: "unsupported-terminal",
63
+ message: "This terminal does not support the VT control sequences required by the Codexa UI. Use a VT-compatible terminal such as Windows Terminal or the VS Code terminal.",
64
+ };
65
+ }
66
+
67
+ if (input.platform !== "win32") {
68
+ return {
69
+ supported: true,
70
+ reason: "supported",
71
+ message: "",
72
+ };
73
+ }
74
+
75
+ // Windows-specific terminal detection below.
76
+ if (hasSupportedWindowsTerminal(input.env)) {
77
+ return {
78
+ supported: true,
79
+ reason: "supported",
80
+ message: "",
81
+ };
82
+ }
83
+
84
+ const message = "This terminal does not advertise VT control sequence support. Codexa will continue because modern Windows terminals usually support VT; set CODEXA_REQUIRE_VT=1 to hard-fail when support is not detected.";
85
+
86
+ if (input.env.CODEXA_REQUIRE_VT === "1") {
87
+ return {
88
+ supported: false,
89
+ reason: "unsupported-terminal",
90
+ message: "This terminal does not appear to support the VT control sequences required by the Codexa UI. Use Windows Terminal, the VS Code terminal, or another VT-compatible terminal, or set CODEXA_FORCE_VT=1 to bypass this check.",
91
+ };
92
+ }
93
+
94
+ return {
95
+ supported: true,
96
+ reason: "supported",
97
+ message: "",
98
+ warning: message,
99
+ };
100
+ }
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createTerminalModeController, setTerminalControlUIState, TERMINAL_SEQUENCES, writeTerminalControl } from "./terminalControl.js";
4
+
5
+ test("mouse reporting enables only normal mouse tracking with SGR coordinates", () => {
6
+ let writes = "";
7
+ const controller = createTerminalModeController((chunk) => {
8
+ writes += chunk;
9
+ });
10
+
11
+ controller.setMouseReporting(true, "test");
12
+
13
+ assert.equal(writes, "\x1b[?1000h\x1b[?1006h");
14
+ assert.equal(TERMINAL_SEQUENCES.mouseEnable, "\x1b[?1000h\x1b[?1006h");
15
+ assert.doesNotMatch(writes, /\x1b\[\?1002h|\x1b\[\?1003h|\x1b\[\?1004h|\x1b\[\?1005h|\x1b\[\?1015h/);
16
+ assert.doesNotMatch(writes, /\x1b\[\?1049h|\x1b\[\?1049l|\x1b\[3J/);
17
+ });
18
+
19
+ test("mouse reporting cleanup disables broad modes defensively", () => {
20
+ let writes = "";
21
+ const controller = createTerminalModeController((chunk) => {
22
+ writes += chunk;
23
+ });
24
+
25
+ controller.setMouseReporting(false, "test");
26
+
27
+ assert.match(writes, /\x1b\[\?1000l/);
28
+ assert.match(writes, /\x1b\[\?1002l/);
29
+ assert.match(writes, /\x1b\[\?1003l/);
30
+ assert.match(writes, /\x1b\[\?1006l/);
31
+ assert.match(writes, /\x1b\[\?1015l/);
32
+ });
33
+
34
+ test("post-startup viewport clears are blocked except for transcript clear", () => {
35
+ let writes = "";
36
+ const write = (chunk: string) => {
37
+ writes += chunk;
38
+ };
39
+
40
+ writeTerminalControl(write, "stdout", "test:resize", TERMINAL_SEQUENCES.viewportClear);
41
+ assert.equal(writes, "");
42
+
43
+ writeTerminalControl(write, "stdout", "test:transcriptClear", TERMINAL_SEQUENCES.transcriptClear);
44
+ assert.equal(writes, TERMINAL_SEQUENCES.transcriptClear);
45
+ assert.match(writes, /\x1b\[3J/);
46
+ });
47
+
48
+ test("terminal controller exposes an intentional transcript clear with scrollback erase", () => {
49
+ let writes = "";
50
+ const controller = createTerminalModeController((chunk) => {
51
+ writes += chunk;
52
+ });
53
+
54
+ controller.clearTranscript("test");
55
+
56
+ assert.equal(writes, TERMINAL_SEQUENCES.transcriptClear);
57
+ assert.match(writes, /\x1b\[2J/);
58
+ assert.match(writes, /\x1b\[3J/);
59
+ assert.match(writes, /\x1b\[H/);
60
+ });
61
+
62
+ test("transcript clear is allowed while streaming state is active", () => {
63
+ let writes = "";
64
+ setTerminalControlUIState("RESPONDING");
65
+
66
+ try {
67
+ writeTerminalControl((chunk) => {
68
+ writes += chunk;
69
+ }, "stdout", "test:transcriptClear", TERMINAL_SEQUENCES.transcriptClear);
70
+ } finally {
71
+ setTerminalControlUIState("IDLE");
72
+ }
73
+
74
+ assert.equal(writes, TERMINAL_SEQUENCES.transcriptClear);
75
+ });
@@ -0,0 +1,147 @@
1
+ import * as renderDebug from "../perf/renderDebug.js";
2
+ import { APP_NAME } from "../../config/settings.js";
3
+ import { setTerminalTitleLifecycleState, traceTerminalTitleSequences, writeGuardedTerminalOutput } from "./terminalTitle.js";
4
+
5
+ export const TERMINAL_TITLE = APP_NAME;
6
+
7
+ export const TERMINAL_SEQUENCES = {
8
+ // \x1b[2J clears the visible viewport; \x1b[3J clears scrollback.
9
+ hardRepaint: "\x1b[2J\x1b[H",
10
+ viewportClear: "\x1b[2J\x1b[H",
11
+ transcriptClear: "\x1b[2J\x1b[3J\x1b[H",
12
+ bracketedPasteEnable: "\x1b[?2004h",
13
+ bracketedPasteDisable: "\x1b[?2004l",
14
+ mouseEnable: "\x1b[?1000h\x1b[?1006h",
15
+ mouseDisable: "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l",
16
+ title: `\x1b]0;${TERMINAL_TITLE}\x07\x1b]2;${TERMINAL_TITLE}\x07`,
17
+ } as const;
18
+
19
+ export type TerminalWrite = (chunk: string) => boolean | void;
20
+ export type TerminalChannel = "stdout" | "stderr";
21
+
22
+ // Tracks current UI state kind for diagnostic assertions.
23
+ let currentUIStateKind: string = "IDLE";
24
+
25
+ export function setTerminalControlUIState(kind: string): void {
26
+ if (currentUIStateKind !== kind) {
27
+ renderDebug.traceEvent("terminal", "uiStateTransition", {
28
+ from: currentUIStateKind,
29
+ to: kind,
30
+ });
31
+ currentUIStateKind = kind;
32
+ setTerminalTitleLifecycleState(kind);
33
+ }
34
+ }
35
+
36
+ export function writeTerminalControl(
37
+ write: TerminalWrite,
38
+ channel: TerminalChannel,
39
+ source: string,
40
+ sequence: string,
41
+ ): boolean {
42
+ traceTerminalTitleSequences(sequence, {
43
+ source,
44
+ stream: channel,
45
+ origin: "codexa",
46
+ action: "allowed",
47
+ lifecycleState: currentUIStateKind,
48
+ });
49
+ renderDebug.traceTerminalWrite(channel, source, sequence);
50
+ const containsClearOrReset = sequence.includes("\x1b[2J")
51
+ || sequence.includes("\x1b[3J")
52
+ || sequence.includes("\x1bc")
53
+ || sequence.includes("\x1b[H");
54
+ const isStartupWrite = source.includes(":startup");
55
+ const isTranscriptClear = source.includes(":transcriptClear");
56
+ const isViewportClear = source.includes(":viewportClear");
57
+
58
+ // Aggressively block any clearing or reset sequences after startup,
59
+ // especially during active states, to prevent the UI from disappearing.
60
+ if (containsClearOrReset && !isStartupWrite && !isTranscriptClear && !isViewportClear) {
61
+ renderDebug.traceEvent("terminal", "blockedPostStartupClearOrReset", {
62
+ source,
63
+ uiStateKind: currentUIStateKind,
64
+ sequenceLength: sequence.length,
65
+ containsViewportClear: sequence.includes("\x1b[2J"),
66
+ containsScrollbackClear: sequence.includes("\x1b[3J"),
67
+ containsCursorHome: sequence.includes("\x1b[H"),
68
+ containsTerminalReset: sequence.includes("\x1bc"),
69
+ });
70
+ return true;
71
+ }
72
+
73
+ // Diagnostic: warn if a viewport-clearing sequence fires during streaming
74
+ // even if it claims to be from startup (which shouldn't happen).
75
+ if (
76
+ (currentUIStateKind === "RESPONDING" || currentUIStateKind === "THINKING")
77
+ && (sequence.includes("\x1b[2J") || sequence.includes("\x1b[3J"))
78
+ ) {
79
+ renderDebug.traceEvent("terminal", "unexpectedClearDuringStreaming", {
80
+ source,
81
+ uiStateKind: currentUIStateKind,
82
+ sequenceLength: sequence.length,
83
+ isStartupWrite,
84
+ });
85
+
86
+ if (!isStartupWrite && !isTranscriptClear) {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ return writeGuardedTerminalOutput(write, sequence, {
92
+ source,
93
+ stream: channel,
94
+ origin: "codexa",
95
+ action: "allowed",
96
+ lifecycleState: currentUIStateKind,
97
+ });
98
+ }
99
+
100
+ export function traceTerminalClear(source: string, fields: Record<string, unknown>): void {
101
+ renderDebug.traceTerminalClear(source, fields);
102
+ }
103
+
104
+ export interface TerminalModeController {
105
+ write(sequence: string, source: string): boolean;
106
+ clearTranscript(source: string): void;
107
+ clearViewport(source: string): void;
108
+ setMouseReporting(enabled: boolean, source: string): void;
109
+ setBracketedPaste(enabled: boolean, source: string): void;
110
+ resetModes(): void;
111
+ }
112
+
113
+ export function createTerminalModeController(write: TerminalWrite): TerminalModeController {
114
+ let mouseReporting: boolean | null = null;
115
+ let bracketedPaste: boolean | null = null;
116
+
117
+ const writeStdout = (sequence: string, source: string) =>
118
+ writeTerminalControl(write, "stdout", source, sequence);
119
+
120
+ return {
121
+ write: writeStdout,
122
+ clearTranscript(source) {
123
+ writeStdout(TERMINAL_SEQUENCES.transcriptClear, source.includes(":transcriptClear") ? source : `${source}:transcriptClear`);
124
+ },
125
+ clearViewport(source) {
126
+ writeStdout(TERMINAL_SEQUENCES.viewportClear, source.includes(":viewportClear") ? source : `${source}:viewportClear`);
127
+ },
128
+ setMouseReporting(enabled, source) {
129
+ if (mouseReporting === enabled) return;
130
+ mouseReporting = enabled;
131
+ writeStdout(enabled ? TERMINAL_SEQUENCES.mouseEnable : TERMINAL_SEQUENCES.mouseDisable, source);
132
+ },
133
+ setBracketedPaste(enabled, source) {
134
+ if (bracketedPaste === enabled) return;
135
+ bracketedPaste = enabled;
136
+ writeStdout(enabled ? TERMINAL_SEQUENCES.bracketedPasteEnable : TERMINAL_SEQUENCES.bracketedPasteDisable, source);
137
+ },
138
+ resetModes() {
139
+ renderDebug.traceEvent("terminal", "resetModes", {
140
+ mouseReporting,
141
+ bracketedPaste,
142
+ });
143
+ mouseReporting = null;
144
+ bracketedPaste = null;
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ sanitizeTerminalInput,
5
+ sanitizeTerminalLines,
6
+ sanitizeTerminalOutput,
7
+ } from "./terminalSanitize.js";
8
+
9
+ test("sanitizeTerminalOutput strips ANSI/OSC/control bytes and normalizes line breaks", () => {
10
+ const raw = "A\u001B[31mred\u001B[0m\u001B]0;title\u0007\r\nB\rC\u0007";
11
+ assert.equal(sanitizeTerminalOutput(raw), "Ared\nB\nC");
12
+ });
13
+
14
+ test("sanitizeTerminalInput removes layout-breaking escapes", () => {
15
+ const raw = "hello\u001B[2J\u001B[H\tworld";
16
+ assert.equal(sanitizeTerminalInput(raw), "hello world");
17
+ });
18
+
19
+ test("sanitizeTerminalLines sanitizes and drops empty lines", () => {
20
+ const lines = ["\u001B[32mok\u001B[0m", " \u0007", "next\rline"];
21
+ assert.deepEqual(sanitizeTerminalLines(lines), ["ok", "next\nline"]);
22
+ });
@@ -0,0 +1,147 @@
1
+ // ─── terminalSanitize ─────────────────────────────────────────────────────────
2
+ // Strips unsafe ANSI/control sequences from subprocess and user input.
3
+ //
4
+ // Two levels of sanitization are provided:
5
+ //
6
+ // 1. sanitizeTerminalOutput / sanitizeTerminalLines / sanitizeTerminalInput
7
+ // — Full strip: removes ALL escape sequences (OSC, CSI, DCS, etc.) plus
8
+ // non-printable control bytes. Used for arbitrary subprocess output,
9
+ // user-typed text, and assistant deltas where we cannot trust the source.
10
+ //
11
+ // 2. sanitizeDiffOutput
12
+ // — Safe-passthrough mode for diff content that has already been classified
13
+ // as a code/diff segment by the markdown parser. Strips dangerous
14
+ // sequences (cursor movement, screen clear, bracketed-paste toggle, OSC
15
+ // hyperlinks, etc.) but INTENTIONALLY preserves SGR colour-only sequences
16
+ // (e.g. \x1b[32m … \x1b[0m) so that raw diff colour codes from tools like
17
+ // `git diff --color=always` survive into the Ink layer.
18
+ // NOTE: The recommended rendering path still uses React/Ink theme-based
19
+ // colouring (no raw ANSI needed), so this helper is available for future
20
+ // use but diff colour is primarily applied via getDiffTone() tones.
21
+
22
+ export interface SanitizeTerminalOptions {
23
+ preserveTabs?: boolean;
24
+ tabSize?: number;
25
+ }
26
+
27
+ const DEFAULT_TAB_SIZE = 2;
28
+
29
+ // ── Dangerous sequence patterns (always stripped) ─────────────────────────────
30
+ // OSC: Operating System Command — can set window titles, hyperlinks, etc.
31
+ const OSC_SEQUENCE = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
32
+ // DCS/PM/APC: Device Control String and friends — rare but risky
33
+ const DCS_PM_APC_SEQUENCE = /\u001B[PX^_][\s\S]*?\u001B\\/g;
34
+ // CSI: Control Sequence Introducer — covers cursor movement, erase, colour, etc.
35
+ const CSI_SEQUENCE = /\u001B\[[0-?]*[ -/]*[@-~]/g;
36
+ // ESC + single intermediate — Fe sequences (e.g. ESC M = reverse index)
37
+ const ESC_INTERMEDIATE_SEQUENCE = /\u001B[@-Z\\-_]/g;
38
+ // C1 control codes (0x80–0x9F) — can masquerade as CSI openers on some terminals
39
+ const SINGLE_C1_SEQUENCE = /[\u0080-\u009F]/g;
40
+ // Remaining non-printable bytes after the above passes
41
+ const DISALLOWED_CONTROL_BYTES = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
42
+
43
+ // ── SGR-only colour-safe allowlist (used by sanitizeDiffOutput) ───────────────
44
+ // Matches ONLY SGR (Select Graphic Rendition) sequences — the subset of CSI
45
+ // that carries colour/style information and has no side-effects on terminal
46
+ // state (no cursor movement, no erase, no mode changes).
47
+ // Pattern: ESC [ <params> m where <params> is digits/semicolons only.
48
+ // We keep these sequences when the caller asserts the content is safe diff output.
49
+ const SGR_COLOUR_SEQUENCE = /\u001B\[[\d;]*m/g;
50
+
51
+ function normalizeTabs(text: string, preserveTabs: boolean, tabSize: number): string {
52
+ if (preserveTabs) return text;
53
+ return text.replace(/\t/g, " ".repeat(Math.max(1, tabSize)));
54
+ }
55
+
56
+ /** Strip all terminal escape sequences. */
57
+ function stripTerminalSequences(raw: string): string {
58
+ return raw
59
+ .replace(OSC_SEQUENCE, "")
60
+ .replace(DCS_PM_APC_SEQUENCE, "")
61
+ .replace(CSI_SEQUENCE, "")
62
+ .replace(ESC_INTERMEDIATE_SEQUENCE, "")
63
+ .replace(SINGLE_C1_SEQUENCE, "");
64
+ }
65
+
66
+ /**
67
+ * Strip only the dangerous subset of terminal sequences, preserving SGR colour codes.
68
+ * Used for content we know is diff output and want to pass colour through.
69
+ * NOTE: Only call this on content that has already been classified as a safe diff
70
+ * segment — never on arbitrary subprocess or user input.
71
+ */
72
+ function stripDangerousSequencesPreserveSGR(raw: string): string {
73
+ return raw
74
+ .replace(OSC_SEQUENCE, "") // OSC: hyperlinks, titles — always strip
75
+ .replace(DCS_PM_APC_SEQUENCE, "") // DCS/PM/APC — always strip
76
+ // Strip CSI sequences that are NOT pure SGR colour codes.
77
+ // First mark safe SGR sequences with a placeholder, strip all CSI,
78
+ // then restore the safe ones.
79
+ .replace(SGR_COLOUR_SEQUENCE, (match) => `\u0000SGR:${match}\u0000`) // protect SGR
80
+ .replace(CSI_SEQUENCE, "") // strip dangerous CSI
81
+ .replace(/\u0000SGR:(\u001B\[[\d;]*m)\u0000/g, "$1") // restore SGR
82
+ .replace(ESC_INTERMEDIATE_SEQUENCE, "")
83
+ .replace(SINGLE_C1_SEQUENCE, "");
84
+ }
85
+
86
+ function stripUnsafeControls(text: string): string {
87
+ return text.replace(DISALLOWED_CONTROL_BYTES, "");
88
+ }
89
+
90
+ // ── Public API ────────────────────────────────────────────────────────────────
91
+
92
+ export function sanitizeTerminalOutput(raw: string, options: SanitizeTerminalOptions = {}): string {
93
+ if (!raw) return "";
94
+ const preserveTabs = options.preserveTabs ?? false;
95
+ const tabSize = options.tabSize ?? DEFAULT_TAB_SIZE;
96
+
97
+ const withoutSequences = stripTerminalSequences(raw);
98
+ const normalizedBreaks = withoutSequences
99
+ .replace(/\r\n/g, "\n")
100
+ .replace(/\r/g, "\n");
101
+ const withoutUnsafeControls = stripUnsafeControls(normalizedBreaks);
102
+ return normalizeTabs(withoutUnsafeControls, preserveTabs, tabSize);
103
+ }
104
+
105
+ export function sanitizeTerminalInput(raw: string): string {
106
+ return sanitizeTerminalOutput(raw, { preserveTabs: false, tabSize: DEFAULT_TAB_SIZE });
107
+ }
108
+
109
+ export function sanitizeTerminalLines(lines: string[]): string[] {
110
+ return lines
111
+ .map((line) => sanitizeTerminalOutput(line))
112
+ .map((line) => line.trimEnd())
113
+ .filter((line) => line.length > 0);
114
+ }
115
+
116
+ /**
117
+ * Sanitize diff output while preserving SGR colour escape sequences.
118
+ *
119
+ * This is intentionally less aggressive than sanitizeTerminalOutput so that
120
+ * diffs piped through `git diff --color=always` or similar tools retain their
121
+ * ANSI colour information. Only safe SGR (colour/style) codes are preserved;
122
+ * all cursor-movement, erase, and other side-effecting sequences are stripped.
123
+ *
124
+ * Width measurement of the resulting text must account for the invisible ANSI
125
+ * bytes — use stripAnsiForMeasurement() on the string before measuring.
126
+ *
127
+ * Safety: Never call this on arbitrary subprocess or user input. Only use it
128
+ * after the markdown parser has classified a segment as a code/diff block.
129
+ */
130
+ export function sanitizeDiffOutput(raw: string): string {
131
+ if (!raw) return "";
132
+ const withSafrSgr = stripDangerousSequencesPreserveSGR(raw);
133
+ const normalizedBreaks = withSafrSgr
134
+ .replace(/\r\n/g, "\n")
135
+ .replace(/\r/g, "\n");
136
+ return stripUnsafeControls(normalizedBreaks);
137
+ }
138
+
139
+ /**
140
+ * Strip ALL ANSI sequences from a string purely for width measurement purposes.
141
+ * Use this when you need the visual width of a string that may contain SGR codes.
142
+ */
143
+ export function stripAnsiForMeasurement(text: string): string {
144
+ return text
145
+ .replace(SGR_COLOUR_SEQUENCE, "")
146
+ .replace(DISALLOWED_CONTROL_BYTES, "");
147
+ }