@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.
- package/README.md +320 -0
- package/bin/codexa.js +445 -0
- package/package.json +45 -0
- package/scripts/audit-codexa-capabilities.mjs +466 -0
- package/scripts/smoke-terminal-bench.mjs +35 -0
- package/src/app.tsx +4561 -0
- package/src/appRenderStability.test.ts +131 -0
- package/src/commands/handler.test.ts +643 -0
- package/src/commands/handler.ts +875 -0
- package/src/config/launchArgs.test.ts +158 -0
- package/src/config/launchArgs.ts +186 -0
- package/src/config/layeredConfig.test.ts +143 -0
- package/src/config/layeredConfig.ts +836 -0
- package/src/config/persistence.test.ts +110 -0
- package/src/config/persistence.ts +311 -0
- package/src/config/runtimeConfig.test.ts +218 -0
- package/src/config/runtimeConfig.ts +554 -0
- package/src/config/settings.test.ts +155 -0
- package/src/config/settings.ts +401 -0
- package/src/config/toml-serialize.ts +98 -0
- package/src/config/trustStore.test.ts +29 -0
- package/src/config/trustStore.ts +68 -0
- package/src/core/attachments.test.ts +155 -0
- package/src/core/attachments.ts +71 -0
- package/src/core/auth/codexAuth.test.ts +68 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/cleanupFastFail.test.ts +76 -0
- package/src/core/cleanupFastFail.ts +67 -0
- package/src/core/clipboard.ts +24 -0
- package/src/core/codex.ts +124 -0
- package/src/core/codexExecArgs.test.ts +195 -0
- package/src/core/codexExecArgs.ts +152 -0
- package/src/core/codexLaunch.test.ts +205 -0
- package/src/core/codexLaunch.ts +162 -0
- package/src/core/codexPrompt.test.ts +252 -0
- package/src/core/codexPrompt.ts +428 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.test.ts +212 -0
- package/src/core/executables/codexExecutable.ts +159 -0
- package/src/core/executables/executableResolver.test.ts +129 -0
- package/src/core/executables/executableResolver.ts +138 -0
- package/src/core/executables/geminiExecutable.test.ts +116 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/executables/pathSanityScan.test.ts +47 -0
- package/src/core/githubDiagnostics.test.ts +92 -0
- package/src/core/githubDiagnostics.ts +222 -0
- package/src/core/hollowResponseFormat.test.ts +58 -0
- package/src/core/hollowResponseFormat.ts +39 -0
- package/src/core/inputDebug.ts +51 -0
- package/src/core/launchContext.test.ts +157 -0
- package/src/core/launchContext.ts +266 -0
- package/src/core/models/codexCapabilities.test.ts +45 -0
- package/src/core/models/codexCapabilities.ts +95 -0
- package/src/core/models/codexModelCapabilities.test.ts +246 -0
- package/src/core/models/codexModelCapabilities.ts +571 -0
- package/src/core/models/modelSpecs.test.ts +283 -0
- package/src/core/models/modelSpecs.ts +300 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.test.ts +230 -0
- package/src/core/perf/renderDebug.ts +373 -0
- package/src/core/planStorage.test.ts +143 -0
- package/src/core/planStorage.ts +141 -0
- package/src/core/process/CommandRunner.test.ts +105 -0
- package/src/core/process/CommandRunner.ts +269 -0
- package/src/core/process/processValidation.ts +101 -0
- package/src/core/projectInstructions.test.ts +50 -0
- package/src/core/projectInstructions.ts +54 -0
- package/src/core/providerLauncher/launcher.test.ts +238 -0
- package/src/core/providerLauncher/launcher.ts +203 -0
- package/src/core/providerLauncher/registry.test.ts +324 -0
- package/src/core/providerLauncher/registry.ts +253 -0
- package/src/core/providerLauncher/types.ts +84 -0
- package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
- package/src/core/providerLauncher/workspaceConfig.ts +407 -0
- package/src/core/providerRuntime/anthropic.test.ts +1120 -0
- package/src/core/providerRuntime/anthropic.ts +576 -0
- package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
- package/src/core/providerRuntime/capabilityProfile.ts +288 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
- package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
- package/src/core/providerRuntime/contextMetadata.ts +409 -0
- package/src/core/providerRuntime/gemini.test.ts +437 -0
- package/src/core/providerRuntime/gemini.ts +784 -0
- package/src/core/providerRuntime/lmstudio.test.ts +168 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.test.ts +787 -0
- package/src/core/providerRuntime/local.ts +754 -0
- package/src/core/providerRuntime/models.ts +150 -0
- package/src/core/providerRuntime/reasoning.ts +17 -0
- package/src/core/providerRuntime/registry.test.ts +233 -0
- package/src/core/providerRuntime/registry.ts +203 -0
- package/src/core/providerRuntime/types.ts +103 -0
- package/src/core/providers/codexJsonStream.test.ts +148 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.test.ts +68 -0
- package/src/core/providers/codexSubprocess.ts +372 -0
- package/src/core/providers/codexTranscript.test.ts +284 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +59 -0
- package/src/core/terminal/terminalCapabilities.test.ts +93 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.test.ts +75 -0
- package/src/core/terminal/terminalControl.ts +147 -0
- package/src/core/terminal/terminalSanitize.test.ts +22 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalSelection.test.ts +42 -0
- package/src/core/terminal/terminalSelection.ts +66 -0
- package/src/core/terminal/terminalTitle.test.ts +328 -0
- package/src/core/terminal/terminalTitle.ts +483 -0
- package/src/core/workspaceActivity.test.ts +163 -0
- package/src/core/workspaceActivity.ts +380 -0
- package/src/core/workspaceGuard.test.ts +151 -0
- package/src/core/workspaceGuard.ts +288 -0
- package/src/core/workspaceRoot.test.ts +23 -0
- package/src/core/workspaceRoot.ts +47 -0
- package/src/exec.test.ts +13 -0
- package/src/exec.ts +72 -0
- package/src/headless/execArgs.test.ts +147 -0
- package/src/headless/execArgs.ts +294 -0
- package/src/headless/execRunner.test.ts +434 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.test.tsx +618 -0
- package/src/index.tsx +296 -0
- package/src/session/appSession.test.ts +897 -0
- package/src/session/appSession.ts +761 -0
- package/src/session/chatLifecycle.test.ts +64 -0
- package/src/session/chatLifecycle.ts +951 -0
- package/src/session/liveRenderScheduler.test.ts +201 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/planFlow.test.ts +103 -0
- package/src/session/planFlow.ts +149 -0
- package/src/session/planTranscript.test.ts +65 -0
- package/src/session/planTranscript.ts +15 -0
- package/src/session/promptRunSchedule.test.ts +36 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +228 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/ActionRequiredBlock.tsx +38 -0
- package/src/ui/ActivityBars.tsx +68 -0
- package/src/ui/ActivityIndicator.test.tsx +58 -0
- package/src/ui/ActivityIndicator.tsx +58 -0
- package/src/ui/AgentBlock.test.ts +6 -0
- package/src/ui/AgentBlock.tsx +130 -0
- package/src/ui/AnimatedStatusText.test.ts +16 -0
- package/src/ui/AnimatedStatusText.tsx +69 -0
- package/src/ui/AppShell.test.tsx +1739 -0
- package/src/ui/AppShell.tsx +698 -0
- package/src/ui/AttachmentImportPanel.test.tsx +204 -0
- package/src/ui/AttachmentImportPanel.tsx +98 -0
- package/src/ui/AuthPanel.tsx +113 -0
- package/src/ui/BackendPicker.tsx +28 -0
- package/src/ui/BottomComposer.test.ts +674 -0
- package/src/ui/BottomComposer.tsx +1028 -0
- package/src/ui/CodexLogo.tsx +55 -0
- package/src/ui/DashCard.tsx +82 -0
- package/src/ui/Markdown.test.ts +157 -0
- package/src/ui/Markdown.tsx +310 -0
- package/src/ui/ModePicker.tsx +27 -0
- package/src/ui/ModelPicker.tsx +31 -0
- package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
- package/src/ui/ModelPickerScreen.test.tsx +99 -0
- package/src/ui/ModelPickerScreen.tsx +416 -0
- package/src/ui/ModelPickerState.test.tsx +151 -0
- package/src/ui/ModelReasoningPicker.test.tsx +447 -0
- package/src/ui/ModelReasoningPicker.tsx +458 -0
- package/src/ui/Panel.tsx +51 -0
- package/src/ui/PermissionsPanel.tsx +78 -0
- package/src/ui/PlanActionPicker.tsx +119 -0
- package/src/ui/PlanReviewPanel.test.tsx +267 -0
- package/src/ui/PlanReviewPanel.tsx +212 -0
- package/src/ui/PromptCardBorder.test.tsx +161 -0
- package/src/ui/ProviderPicker.test.tsx +289 -0
- package/src/ui/ProviderPicker.tsx +321 -0
- package/src/ui/ProviderShortcut.test.tsx +143 -0
- package/src/ui/ReasoningPicker.tsx +46 -0
- package/src/ui/RunFooter.tsx +65 -0
- package/src/ui/SelectionPanel.tsx +67 -0
- package/src/ui/SettingsPanel.test.tsx +233 -0
- package/src/ui/SettingsPanel.tsx +156 -0
- package/src/ui/Spinner.tsx +25 -0
- package/src/ui/StaticIntroItem.tsx +54 -0
- package/src/ui/StaticTranscriptItem.tsx +56 -0
- package/src/ui/TextEntryPanel.tsx +139 -0
- package/src/ui/ThemePicker.tsx +31 -0
- package/src/ui/ThinkingBlock.tsx +100 -0
- package/src/ui/Timeline.test.ts +2067 -0
- package/src/ui/Timeline.tsx +1472 -0
- package/src/ui/TimelineNavigation.test.tsx +201 -0
- package/src/ui/TopHeader.test.tsx +239 -0
- package/src/ui/TopHeader.tsx +257 -0
- package/src/ui/TurnGroup.test.tsx +365 -0
- package/src/ui/TurnGroup.tsx +657 -0
- package/src/ui/busyStatusAnimation.test.ts +30 -0
- package/src/ui/busyStatusAnimation.ts +11 -0
- package/src/ui/commandNormalize.test.ts +142 -0
- package/src/ui/commandNormalize.ts +66 -0
- package/src/ui/diffRenderer.test.ts +102 -0
- package/src/ui/diffRenderer.ts +116 -0
- package/src/ui/focus.ts +61 -0
- package/src/ui/focusFlow.test.tsx +1098 -0
- package/src/ui/inputBuffer.test.ts +151 -0
- package/src/ui/inputBuffer.ts +203 -0
- package/src/ui/layout.test.ts +145 -0
- package/src/ui/layout.ts +287 -0
- package/src/ui/modeDisplay.test.ts +42 -0
- package/src/ui/modeDisplay.ts +52 -0
- package/src/ui/outputPipeline.ts +64 -0
- package/src/ui/progressEntries.ts +156 -0
- package/src/ui/runActivityView.test.ts +89 -0
- package/src/ui/runActivityView.ts +37 -0
- package/src/ui/runLifecycleView.test.tsx +237 -0
- package/src/ui/slashCommands.ts +41 -0
- package/src/ui/statusRenderIsolation.test.tsx +654 -0
- package/src/ui/terminalAnswerFormat.test.ts +19 -0
- package/src/ui/terminalAnswerFormat.ts +128 -0
- package/src/ui/textLayout.test.ts +18 -0
- package/src/ui/textLayout.ts +338 -0
- package/src/ui/theme.tsx +395 -0
- package/src/ui/themeFlow.test.ts +53 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timelineMeasure.ts +3088 -0
- package/src/ui/timelineMeasureCache.test.ts +986 -0
- 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
|
+
}
|