ubume 0.1.0
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 +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -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 +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveUbumeDebugLogPath } from "../workspace/appData.js";
|
|
4
|
+
import { isTerminalResizing } from "./terminalControl.js";
|
|
5
|
+
|
|
6
|
+
export interface FrameLockOptions {
|
|
7
|
+
stdout: any;
|
|
8
|
+
env: Record<string, string | undefined>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const resizeFrameCacheResetters = new WeakMap<object, () => void>();
|
|
12
|
+
|
|
13
|
+
export function resetFrameLockForResize(stdout: object): void {
|
|
14
|
+
resizeFrameCacheResetters.get(stdout)?.();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps the stdout stream to enforce frame-level deduplication, a flush lock,
|
|
19
|
+
* and width-safe row padding via ANSI clear-to-EOL (\x1b[K) injection.
|
|
20
|
+
*/
|
|
21
|
+
export function wrapStdoutWithFrameLock({
|
|
22
|
+
stdout,
|
|
23
|
+
env,
|
|
24
|
+
}: FrameLockOptions) {
|
|
25
|
+
let lastFrame = "";
|
|
26
|
+
let isFlushing = false;
|
|
27
|
+
let debugLogStream: fs.WriteStream | null = null;
|
|
28
|
+
|
|
29
|
+
if (env.UBUME_RENDER_DEBUG === "1") {
|
|
30
|
+
try {
|
|
31
|
+
const logPath = env.UBUME_RENDER_DEBUG_FILE?.trim()
|
|
32
|
+
|| resolveUbumeDebugLogPath(env);
|
|
33
|
+
const logDir = path.dirname(logPath);
|
|
34
|
+
if (!fs.existsSync(logDir)) {
|
|
35
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
debugLogStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Gracefully fall back if logging fails (e.g. read-only filesystem)
|
|
40
|
+
void e;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const logDebug = (msg: string) => {
|
|
45
|
+
if (debugLogStream) {
|
|
46
|
+
debugLogStream.write(`${new Date().toISOString()} ${msg}\n`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const originalWrite = stdout.write.bind(stdout);
|
|
51
|
+
|
|
52
|
+
// A terminal resize can make an otherwise identical Ink frame meaningful:
|
|
53
|
+
// rows reflow at a new width and height-driven spacer/layout changes must be
|
|
54
|
+
// written even when their source text did not change. src/index.tsx owns the
|
|
55
|
+
// single resize listener and invokes this reset through the exported helper.
|
|
56
|
+
resizeFrameCacheResetters.set(stdout, () => {
|
|
57
|
+
lastFrame = "";
|
|
58
|
+
logDebug("Frame cache reset: terminal resize");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
stdout.write = (chunk: string | Uint8Array) => {
|
|
62
|
+
if (typeof chunk !== "string") {
|
|
63
|
+
return originalWrite(chunk);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Task 7: Frame Lock
|
|
67
|
+
// Ensure only one render flush can write to stdout at a time.
|
|
68
|
+
if (isFlushing) {
|
|
69
|
+
logDebug("Frame dropped: lock active (concurrent flush)");
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Task 8: Full-frame Deduplication
|
|
74
|
+
// Skip if identical to last frame.
|
|
75
|
+
if (chunk === lastFrame) {
|
|
76
|
+
logDebug("Frame dropped: identical to last frame");
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Task 4: Width-Safe Injection
|
|
81
|
+
// Ensure every row clears to the end of the line. This fixes artifacts
|
|
82
|
+
// left behind when a shorter row replaces a longer row or when terminal
|
|
83
|
+
// wrapping occurs during resize.
|
|
84
|
+
//
|
|
85
|
+
// We inject \x1b[K before every newline and at the end of the frame.
|
|
86
|
+
// padding via \x1b[K injection.
|
|
87
|
+
// Only apply if the chunk is non-empty and doesn't look like a control-only
|
|
88
|
+
// sequence (e.g. terminal title OSC).
|
|
89
|
+
let processed = chunk;
|
|
90
|
+
const isControlSequence = chunk.includes("\x1b]");
|
|
91
|
+
if (chunk.length > 0 && !isControlSequence) {
|
|
92
|
+
processed = chunk.replace(/\n/g, "\x1b[K\n");
|
|
93
|
+
if (!processed.endsWith("\x1b[K") && !processed.endsWith("\x1b[K\n")) {
|
|
94
|
+
processed += "\x1b[K";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isFlushing = true;
|
|
99
|
+
try {
|
|
100
|
+
lastFrame = chunk;
|
|
101
|
+
const resizing = isTerminalResizing();
|
|
102
|
+
logDebug(`Frame written: length=${processed.length} original=${chunk.length} resizing=${resizing}`);
|
|
103
|
+
return originalWrite(processed);
|
|
104
|
+
} finally {
|
|
105
|
+
isFlushing = false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return stdout;
|
|
110
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface StartupClearOptions {
|
|
2
|
+
write: (chunk: string) => boolean | void;
|
|
3
|
+
/** True when --no-clear was passed on the CLI. */
|
|
4
|
+
noClear: boolean;
|
|
5
|
+
env: Record<string, string | undefined>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Writes a full terminal clear (viewport + scrollback + cursor home) to stdout
|
|
10
|
+
* before the first Ink render. Skipped when --no-clear or UBUME_NO_CLEAR=1.
|
|
11
|
+
*
|
|
12
|
+
* Only call this from startApp() in TTY mode — never inside render loops.
|
|
13
|
+
*/
|
|
14
|
+
export function performStartupClear(options: StartupClearOptions): void {
|
|
15
|
+
if (options.noClear || options.env["UBUME_NO_CLEAR"] === "1") return;
|
|
16
|
+
// \x1b[2J: clear visible viewport
|
|
17
|
+
// \x1b[3J: clear scrollback buffer
|
|
18
|
+
// \x1b[H: move cursor to top-left
|
|
19
|
+
options.write("\x1b[2J\x1b[3J\x1b[H");
|
|
20
|
+
}
|
|
@@ -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
|
+
// UBUME_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.UBUME_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 Ubume 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. Ubume will continue because modern Windows terminals usually support VT; set UBUME_REQUIRE_VT=1 to hard-fail when support is not detected.";
|
|
85
|
+
|
|
86
|
+
if (input.env.UBUME_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 Ubume UI. Use Windows Terminal, the VS Code terminal, or another VT-compatible terminal, or set UBUME_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,169 @@
|
|
|
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
|
+
alternateScreenEnable: "\x1b[?1049h",
|
|
18
|
+
alternateScreenDisable: "\x1b[?1049l",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type TerminalWrite = (chunk: string) => boolean | void;
|
|
22
|
+
export type TerminalChannel = "stdout" | "stderr";
|
|
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
|
+
|
|
35
|
+
// Tracks current UI state kind for diagnostic assertions.
|
|
36
|
+
let currentUIStateKind: string = "IDLE";
|
|
37
|
+
|
|
38
|
+
export function setTerminalControlUIState(kind: string): void {
|
|
39
|
+
if (currentUIStateKind !== kind) {
|
|
40
|
+
renderDebug.traceEvent("terminal", "uiStateTransition", {
|
|
41
|
+
from: currentUIStateKind,
|
|
42
|
+
to: kind,
|
|
43
|
+
});
|
|
44
|
+
currentUIStateKind = kind;
|
|
45
|
+
setTerminalTitleLifecycleState(kind);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeTerminalControl(
|
|
50
|
+
write: TerminalWrite,
|
|
51
|
+
channel: TerminalChannel,
|
|
52
|
+
source: string,
|
|
53
|
+
sequence: string,
|
|
54
|
+
): boolean {
|
|
55
|
+
traceTerminalTitleSequences(sequence, {
|
|
56
|
+
source,
|
|
57
|
+
stream: channel,
|
|
58
|
+
origin: "ubume",
|
|
59
|
+
action: "allowed",
|
|
60
|
+
lifecycleState: currentUIStateKind,
|
|
61
|
+
});
|
|
62
|
+
renderDebug.traceTerminalWrite(channel, source, sequence);
|
|
63
|
+
const containsClearOrReset = sequence.includes("\x1b[2J")
|
|
64
|
+
|| sequence.includes("\x1b[3J")
|
|
65
|
+
|| sequence.includes("\x1bc")
|
|
66
|
+
|| sequence.includes("\x1b[H");
|
|
67
|
+
const isStartupWrite = source.includes(":startup");
|
|
68
|
+
const isTranscriptClear = source.includes(":transcriptClear");
|
|
69
|
+
const isViewportClear = source.includes(":viewportClear");
|
|
70
|
+
|
|
71
|
+
// Aggressively block any clearing or reset sequences after startup,
|
|
72
|
+
// especially during active states, to prevent the UI from disappearing.
|
|
73
|
+
if (containsClearOrReset && !isStartupWrite && !isTranscriptClear && !isViewportClear) {
|
|
74
|
+
renderDebug.traceEvent("terminal", "blockedPostStartupClearOrReset", {
|
|
75
|
+
source,
|
|
76
|
+
uiStateKind: currentUIStateKind,
|
|
77
|
+
sequenceLength: sequence.length,
|
|
78
|
+
containsViewportClear: sequence.includes("\x1b[2J"),
|
|
79
|
+
containsScrollbackClear: sequence.includes("\x1b[3J"),
|
|
80
|
+
containsCursorHome: sequence.includes("\x1b[H"),
|
|
81
|
+
containsTerminalReset: sequence.includes("\x1bc"),
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Diagnostic: warn if a viewport-clearing sequence fires during streaming
|
|
87
|
+
// even if it claims to be from startup (which shouldn't happen).
|
|
88
|
+
if (
|
|
89
|
+
(currentUIStateKind === "RESPONDING" || currentUIStateKind === "THINKING")
|
|
90
|
+
&& (sequence.includes("\x1b[2J") || sequence.includes("\x1b[3J"))
|
|
91
|
+
) {
|
|
92
|
+
renderDebug.traceEvent("terminal", "unexpectedClearDuringStreaming", {
|
|
93
|
+
source,
|
|
94
|
+
uiStateKind: currentUIStateKind,
|
|
95
|
+
sequenceLength: sequence.length,
|
|
96
|
+
isStartupWrite,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!isStartupWrite && !isTranscriptClear) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return writeGuardedTerminalOutput(write, sequence, {
|
|
105
|
+
source,
|
|
106
|
+
stream: channel,
|
|
107
|
+
origin: "ubume",
|
|
108
|
+
action: "allowed",
|
|
109
|
+
lifecycleState: currentUIStateKind,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function traceTerminalClear(source: string, fields: Record<string, unknown>): void {
|
|
114
|
+
renderDebug.traceTerminalClear(source, fields);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TerminalModeController {
|
|
118
|
+
write(sequence: string, source: string): boolean;
|
|
119
|
+
clearTranscript(source: string): void;
|
|
120
|
+
clearViewport(source: string): void;
|
|
121
|
+
setMouseReporting(enabled: boolean, source: string): void;
|
|
122
|
+
setBracketedPaste(enabled: boolean, source: string): void;
|
|
123
|
+
setAlternateScreen(enabled: boolean, source: string): void;
|
|
124
|
+
resetModes(): void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createTerminalModeController(write: TerminalWrite): TerminalModeController {
|
|
128
|
+
let mouseReporting: boolean | null = null;
|
|
129
|
+
let bracketedPaste: boolean | null = null;
|
|
130
|
+
let alternateScreen: boolean | null = null;
|
|
131
|
+
|
|
132
|
+
const writeStdout = (sequence: string, source: string) =>
|
|
133
|
+
writeTerminalControl(write, "stdout", source, sequence);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
write: writeStdout,
|
|
137
|
+
clearTranscript(source) {
|
|
138
|
+
writeStdout(TERMINAL_SEQUENCES.transcriptClear, source.includes(":transcriptClear") ? source : `${source}:transcriptClear`);
|
|
139
|
+
},
|
|
140
|
+
clearViewport(source) {
|
|
141
|
+
writeStdout(TERMINAL_SEQUENCES.viewportClear, source.includes(":viewportClear") ? source : `${source}:viewportClear`);
|
|
142
|
+
},
|
|
143
|
+
setMouseReporting(enabled, source) {
|
|
144
|
+
if (mouseReporting === enabled) return;
|
|
145
|
+
mouseReporting = enabled;
|
|
146
|
+
writeStdout(enabled ? TERMINAL_SEQUENCES.mouseEnable : TERMINAL_SEQUENCES.mouseDisable, source);
|
|
147
|
+
},
|
|
148
|
+
setBracketedPaste(enabled, source) {
|
|
149
|
+
if (bracketedPaste === enabled) return;
|
|
150
|
+
bracketedPaste = enabled;
|
|
151
|
+
writeStdout(enabled ? TERMINAL_SEQUENCES.bracketedPasteEnable : TERMINAL_SEQUENCES.bracketedPasteDisable, source);
|
|
152
|
+
},
|
|
153
|
+
setAlternateScreen(enabled, source) {
|
|
154
|
+
if (alternateScreen === enabled) return;
|
|
155
|
+
alternateScreen = enabled;
|
|
156
|
+
writeStdout(enabled ? TERMINAL_SEQUENCES.alternateScreenEnable : TERMINAL_SEQUENCES.alternateScreenDisable, source);
|
|
157
|
+
},
|
|
158
|
+
resetModes() {
|
|
159
|
+
renderDebug.traceEvent("terminal", "resetModes", {
|
|
160
|
+
mouseReporting,
|
|
161
|
+
bracketedPaste,
|
|
162
|
+
alternateScreen,
|
|
163
|
+
});
|
|
164
|
+
mouseReporting = null;
|
|
165
|
+
bracketedPaste = null;
|
|
166
|
+
alternateScreen = null;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -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
|
+
}
|