@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,483 @@
|
|
|
1
|
+
import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_TERMINAL_TITLE = APP_NAME;
|
|
6
|
+
|
|
7
|
+
// ─── Constants & diagnostics ──────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const DEBUG_TERMINAL_TITLE = Boolean(process.env["CODEXA_DEBUG_TERMINAL_TITLE"]);
|
|
10
|
+
|
|
11
|
+
/** Hard cap applied at every entry-point to prevent O(n²) scanning cost on adversarial input. */
|
|
12
|
+
const MAX_TERMINAL_TITLE_INPUT_LENGTH = 65536;
|
|
13
|
+
|
|
14
|
+
// Returns start index of the next ESC]0; or ESC]2; header at/after `from`, or -1.
|
|
15
|
+
function findNextOscTitleStart(text: string, from: number): number {
|
|
16
|
+
const limit = text.length - 3;
|
|
17
|
+
for (let i = from; i <= limit; i++) {
|
|
18
|
+
if (
|
|
19
|
+
text.charCodeAt(i) === 0x1b &&
|
|
20
|
+
text.charCodeAt(i + 1) === 0x5d &&
|
|
21
|
+
(text.charCodeAt(i + 2) === 0x30 || text.charCodeAt(i + 2) === 0x32) &&
|
|
22
|
+
text.charCodeAt(i + 3) === 0x3b
|
|
23
|
+
) return i;
|
|
24
|
+
}
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Finds the first BEL (0x07) or ST (ESC \) at/after `from`.
|
|
29
|
+
// Returns { contentEnd: terminator-start, seqEnd: after-terminator } or null.
|
|
30
|
+
function findOscSequenceEnd(text: string, from: number): { contentEnd: number; seqEnd: number } | null {
|
|
31
|
+
for (let i = from; i < text.length; i++) {
|
|
32
|
+
if (text.charCodeAt(i) === 0x07) return { contentEnd: i, seqEnd: i + 1 };
|
|
33
|
+
if (text.charCodeAt(i) === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c)
|
|
34
|
+
return { contentEnd: i, seqEnd: i + 2 };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Returns the start of the first unterminated OSC 0/2 sequence, or -1 if none.
|
|
40
|
+
function findIncompleteOscTitleStart(text: string): number {
|
|
41
|
+
let pos = 0;
|
|
42
|
+
while (pos < text.length) {
|
|
43
|
+
const start = findNextOscTitleStart(text, pos);
|
|
44
|
+
if (start === -1) return -1;
|
|
45
|
+
const term = findOscSequenceEnd(text, start + 4);
|
|
46
|
+
if (term === null) return start;
|
|
47
|
+
pos = term.seqEnd;
|
|
48
|
+
}
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["CODEXA_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
|
|
53
|
+
|| join(process.cwd(), ".codexa-debug", "terminal-title-debug.log");
|
|
54
|
+
|
|
55
|
+
let terminalTitleLifecycleState = "unknown";
|
|
56
|
+
|
|
57
|
+
function debugLog(msg: string): void {
|
|
58
|
+
if (DEBUG_TERMINAL_TITLE) {
|
|
59
|
+
process.stderr.write(`[codexa:terminal-title] ${msg}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeTerminalTitleDebugRecord(fields: Record<string, unknown>): void {
|
|
64
|
+
if (!DEBUG_TERMINAL_TITLE) return;
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dirname(TERMINAL_TITLE_DEBUG_LOG_PATH), { recursive: true });
|
|
67
|
+
appendFileSync(
|
|
68
|
+
TERMINAL_TITLE_DEBUG_LOG_PATH,
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
ts: Date.now(),
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
lifecycleState: terminalTitleLifecycleState,
|
|
73
|
+
...fields,
|
|
74
|
+
}) + "\n",
|
|
75
|
+
"utf8",
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
// Diagnostics must never disturb the TUI.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Title write helpers ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
let lastWrittenTerminalTitle = "";
|
|
85
|
+
let intendedTerminalTitle = DEFAULT_TERMINAL_TITLE;
|
|
86
|
+
|
|
87
|
+
/** FOR TESTING ONLY: Reset the cached title. */
|
|
88
|
+
export function __resetTerminalTitleCache() {
|
|
89
|
+
lastWrittenTerminalTitle = "";
|
|
90
|
+
intendedTerminalTitle = DEFAULT_TERMINAL_TITLE;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setTerminalTitleLifecycleState(state: string): void {
|
|
94
|
+
terminalTitleLifecycleState = state;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TerminalTitleOptions {
|
|
98
|
+
force?: boolean;
|
|
99
|
+
/** Optional custom write function, e.g. for testing or using a specific stdout/stderr instance. */
|
|
100
|
+
write?: (chunk: string) => void;
|
|
101
|
+
reason?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function looksLikeRawWindowsPath(title: string): boolean {
|
|
105
|
+
return /^[a-zA-Z]:[\\/]/.test(title.trim()) || /^\\\\/.test(title.trim());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeTerminalTitle(title: string | null | undefined): string {
|
|
109
|
+
const cleanTitle = sanitizeTerminalTitle(title ?? "");
|
|
110
|
+
if (!cleanTitle || looksLikeRawWindowsPath(cleanTitle)) {
|
|
111
|
+
return DEFAULT_TERMINAL_TITLE;
|
|
112
|
+
}
|
|
113
|
+
return cleanTitle;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function setIntendedTerminalTitle(title: string | null | undefined, options?: TerminalTitleOptions): string {
|
|
117
|
+
intendedTerminalTitle = normalizeTerminalTitle(title);
|
|
118
|
+
writeCodexaTerminalTitle(intendedTerminalTitle, {
|
|
119
|
+
...options,
|
|
120
|
+
reason: options?.reason ?? "set-intended-title",
|
|
121
|
+
});
|
|
122
|
+
return intendedTerminalTitle;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getIntendedTerminalTitle(): string {
|
|
126
|
+
return intendedTerminalTitle;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function reassertIntendedTerminalTitle(options?: TerminalTitleOptions): void {
|
|
130
|
+
writeCodexaTerminalTitle(intendedTerminalTitle, {
|
|
131
|
+
force: true,
|
|
132
|
+
...options,
|
|
133
|
+
reason: options?.reason ?? "reassert-intended-title",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeCodexaTerminalTitle(title: string, options?: TerminalTitleOptions) {
|
|
138
|
+
const cleanTitle = normalizeTerminalTitle(title);
|
|
139
|
+
|
|
140
|
+
if (!options?.force && cleanTitle === lastWrittenTerminalTitle) {
|
|
141
|
+
debugLog(`skipped title="${cleanTitle}" reason=${options?.reason ?? "unknown"} (unchanged)`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lastWrittenTerminalTitle = cleanTitle;
|
|
146
|
+
|
|
147
|
+
const sequence = buildTerminalTitleSequence(cleanTitle);
|
|
148
|
+
writeTerminalTitleDebugRecord({
|
|
149
|
+
event: "codexaTitleWrite",
|
|
150
|
+
title: cleanTitle,
|
|
151
|
+
reason: options?.reason ?? "unknown",
|
|
152
|
+
force: !!options?.force,
|
|
153
|
+
bytes: Buffer.byteLength(sequence),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (options?.write) {
|
|
157
|
+
options.write(sequence);
|
|
158
|
+
debugLog(`write(custom) title="${cleanTitle}" force=${!!options?.force} reason=${options?.reason ?? "unknown"}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stdoutIsTTY = Boolean(process.stdout?.isTTY);
|
|
163
|
+
const stderrIsTTY = Boolean(process.stderr?.isTTY);
|
|
164
|
+
|
|
165
|
+
debugLog(`writeCodexaTerminalTitle("${cleanTitle}") force=${!!options?.force} reason=${options?.reason ?? "unknown"} stdoutIsTTY=${stdoutIsTTY} stderrIsTTY=${stderrIsTTY}`);
|
|
166
|
+
|
|
167
|
+
if (stderrIsTTY) {
|
|
168
|
+
process.stderr.write(sequence);
|
|
169
|
+
debugLog(`write(stderr) sequence length=${sequence.length}`);
|
|
170
|
+
} else if (stdoutIsTTY) {
|
|
171
|
+
process.stdout.write(sequence);
|
|
172
|
+
debugLog(`write(stdout) sequence length=${sequence.length}`);
|
|
173
|
+
} else {
|
|
174
|
+
// Fallback if neither is explicitly a TTY but we might still be in a terminal (e.g. Bun quirk)
|
|
175
|
+
process.stdout.write(sequence);
|
|
176
|
+
debugLog(`write(stdout-fallback) sequence length=${sequence.length} (no TTY detected)`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Directly writes the terminal title escape sequence to process.stdout or process.stderr,
|
|
182
|
+
* bypassing any Ink/React state management to ensure it reaches the terminal.
|
|
183
|
+
*/
|
|
184
|
+
export function setTerminalTitle(title: string, options?: TerminalTitleOptions) {
|
|
185
|
+
writeCodexaTerminalTitle(title, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Pure mapper that converts terminal title mode and workspace into a displayable string.
|
|
190
|
+
*/
|
|
191
|
+
export function computeTerminalTitle(options: {
|
|
192
|
+
terminalTitleMode: "dir" | "name" | "simple";
|
|
193
|
+
workspaceName?: string;
|
|
194
|
+
appName?: string;
|
|
195
|
+
}) {
|
|
196
|
+
const appName = options.appName || "Codexa";
|
|
197
|
+
|
|
198
|
+
if (options.terminalTitleMode === "dir") {
|
|
199
|
+
return options.workspaceName || appName;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return appName;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Force a refresh of the terminal title using current settings and workspace.
|
|
207
|
+
*/
|
|
208
|
+
export function refreshTerminalTitle(options: {
|
|
209
|
+
terminalTitleMode: "dir" | "name" | "simple";
|
|
210
|
+
workspaceName?: string;
|
|
211
|
+
appName?: string;
|
|
212
|
+
force?: boolean;
|
|
213
|
+
write?: (chunk: string) => void;
|
|
214
|
+
debugEventName?: string;
|
|
215
|
+
busyState?: boolean;
|
|
216
|
+
}) {
|
|
217
|
+
const title = normalizeTerminalTitle(computeTerminalTitle(options));
|
|
218
|
+
if (DEBUG_TERMINAL_TITLE) {
|
|
219
|
+
debugLog(
|
|
220
|
+
`refreshTerminalTitle(event=${options.debugEventName || "unknown"}, mode=${options.terminalTitleMode}, workspace=${options.workspaceName}, busy=${!!options.busyState}) -> "${title}"`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
setIntendedTerminalTitle(title, {
|
|
224
|
+
force: options.force,
|
|
225
|
+
write: options.write,
|
|
226
|
+
reason: options.debugEventName ?? "refreshTerminalTitle",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Sequence stripping ───────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export interface TerminalTitleSequenceTraceContext {
|
|
233
|
+
source: string;
|
|
234
|
+
stream: "stdout" | "stderr" | "unknown";
|
|
235
|
+
origin: "codexa" | "child" | "shell" | "codex-cli" | "unknown";
|
|
236
|
+
action?: "observed" | "stripped" | "allowed";
|
|
237
|
+
lifecycleState?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function traceTerminalTitleSequences(
|
|
241
|
+
input: Buffer | string,
|
|
242
|
+
context: TerminalTitleSequenceTraceContext,
|
|
243
|
+
): boolean {
|
|
244
|
+
const raw = Buffer.isBuffer(input) ? input.toString("utf8") : input;
|
|
245
|
+
const text = raw.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH);
|
|
246
|
+
if (!text) return false;
|
|
247
|
+
|
|
248
|
+
let found = false;
|
|
249
|
+
let pos = 0;
|
|
250
|
+
|
|
251
|
+
while (pos < text.length) {
|
|
252
|
+
const start = findNextOscTitleStart(text, pos);
|
|
253
|
+
if (start === -1) break;
|
|
254
|
+
const term = findOscSequenceEnd(text, start + 4);
|
|
255
|
+
if (term === null) break; // unterminated — nothing to trace
|
|
256
|
+
|
|
257
|
+
found = true;
|
|
258
|
+
const title = text.slice(start + 4, term.contentEnd);
|
|
259
|
+
const oscCode = text.charCodeAt(start + 2); // 0x30='0', 0x32='2'
|
|
260
|
+
writeTerminalTitleDebugRecord({
|
|
261
|
+
event: "terminalTitleSequence",
|
|
262
|
+
source: context.source,
|
|
263
|
+
stream: context.stream,
|
|
264
|
+
origin: context.origin,
|
|
265
|
+
action: context.action ?? "observed",
|
|
266
|
+
lifecycleState: context.lifecycleState ?? terminalTitleLifecycleState,
|
|
267
|
+
osc: oscCode === 0x32 ? "OSC 2" : "OSC 0",
|
|
268
|
+
title,
|
|
269
|
+
containsWindowsSystem: title.toLowerCase().includes("c:\\windows\\system"),
|
|
270
|
+
bytes: Buffer.byteLength(text.slice(start, term.seqEnd)),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pos = term.seqEnd;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return found;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function stripTerminalTitleSequences(input: string): string {
|
|
280
|
+
const text = input.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH);
|
|
281
|
+
if (!text) return "";
|
|
282
|
+
|
|
283
|
+
let result = "";
|
|
284
|
+
let pos = 0;
|
|
285
|
+
|
|
286
|
+
while (pos < text.length) {
|
|
287
|
+
const start = findNextOscTitleStart(text, pos);
|
|
288
|
+
if (start === -1) { result += text.slice(pos); break; }
|
|
289
|
+
result += text.slice(pos, start);
|
|
290
|
+
const term = findOscSequenceEnd(text, start + 4);
|
|
291
|
+
if (term === null) { result += text.slice(start); break; } // unterminated: pass through unchanged
|
|
292
|
+
pos = term.seqEnd;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function stripTerminalTitleSequencesFromChunk(chunk: Buffer | string): string {
|
|
299
|
+
return stripTerminalTitleSequences(
|
|
300
|
+
Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Each stripper maintains a carryover buffer so that a title escape sequence
|
|
305
|
+
// split across two Node `data` events is still detected and stripped correctly.
|
|
306
|
+
export function createTerminalTitleSequenceStripper(context: TerminalTitleSequenceTraceContext): {
|
|
307
|
+
process(chunk: Buffer | string): string;
|
|
308
|
+
flush(): string;
|
|
309
|
+
} {
|
|
310
|
+
let carryover = "";
|
|
311
|
+
|
|
312
|
+
const strip = (text: string): string => {
|
|
313
|
+
if (!text) return "";
|
|
314
|
+
const found = traceTerminalTitleSequences(text, { ...context, action: "stripped" });
|
|
315
|
+
return found ? stripTerminalTitleSequences(text) : text;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
process(chunk: Buffer | string): string {
|
|
320
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
321
|
+
const input = carryover + text;
|
|
322
|
+
carryover = "";
|
|
323
|
+
|
|
324
|
+
const incompleteStart = findIncompleteOscTitleStart(input.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH));
|
|
325
|
+
if (incompleteStart !== -1) {
|
|
326
|
+
carryover = input.slice(incompleteStart);
|
|
327
|
+
return strip(input.slice(0, incompleteStart));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return strip(input);
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
flush(): string {
|
|
334
|
+
const remaining = carryover;
|
|
335
|
+
carryover = "";
|
|
336
|
+
return strip(remaining);
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function writeGuardedTerminalOutput(
|
|
342
|
+
write: (chunk: string) => boolean | void,
|
|
343
|
+
chunk: Buffer | string,
|
|
344
|
+
context: TerminalTitleSequenceTraceContext,
|
|
345
|
+
): boolean {
|
|
346
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
347
|
+
if (context.action === "allowed") {
|
|
348
|
+
return write(text) !== false;
|
|
349
|
+
}
|
|
350
|
+
const found = traceTerminalTitleSequences(text, { ...context, action: "stripped" });
|
|
351
|
+
const safeText = found ? stripTerminalTitleSequences(text) : text;
|
|
352
|
+
if (!safeText) return true;
|
|
353
|
+
return write(safeText) !== false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Startup guard ────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Schedules a sequence of title assertions to outlast Windows Terminal's
|
|
360
|
+
* shell integration, which overwrites the process title at unpredictable
|
|
361
|
+
* points during startup (shell prompt init, profile scripts, etc.).
|
|
362
|
+
* The retries at 50/250/500/1000ms are intentional: no single delay is
|
|
363
|
+
* reliable across all terminal configurations, so we assert multiple times.
|
|
364
|
+
*/
|
|
365
|
+
export function beginColdStartSequence(title: string, options?: { write?: (chunk: string) => void }) {
|
|
366
|
+
setIntendedTerminalTitle(title, { force: true, write: options?.write, reason: "cold-start-immediate" });
|
|
367
|
+
|
|
368
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
369
|
+
let cancelled = false;
|
|
370
|
+
|
|
371
|
+
const scheduleRetry = (delayMs: number) => {
|
|
372
|
+
const id = setTimeout(() => {
|
|
373
|
+
if (!cancelled) {
|
|
374
|
+
debugLog(`cold-start retry t=${delayMs}ms fired`);
|
|
375
|
+
reassertIntendedTerminalTitle({ write: options?.write, reason: `cold-start-retry-${delayMs}ms` });
|
|
376
|
+
}
|
|
377
|
+
}, delayMs);
|
|
378
|
+
timers.push(id);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
scheduleRetry(50);
|
|
382
|
+
scheduleRetry(250);
|
|
383
|
+
scheduleRetry(500);
|
|
384
|
+
scheduleRetry(1000);
|
|
385
|
+
|
|
386
|
+
return () => {
|
|
387
|
+
cancelled = true;
|
|
388
|
+
timers.forEach(clearTimeout);
|
|
389
|
+
debugLog("cold-start sequence cancelled");
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function startTerminalTitleStartupGuard(options?: {
|
|
394
|
+
write?: (chunk: string) => void;
|
|
395
|
+
intervalMs?: number;
|
|
396
|
+
durationMs?: number;
|
|
397
|
+
reason?: string;
|
|
398
|
+
}): () => void {
|
|
399
|
+
const intervalMs = options?.intervalMs ?? 150;
|
|
400
|
+
const durationMs = options?.durationMs ?? 3500;
|
|
401
|
+
const reason = options?.reason ?? "startup-guard";
|
|
402
|
+
let stopped = false;
|
|
403
|
+
const startedAt = Date.now();
|
|
404
|
+
|
|
405
|
+
reassertIntendedTerminalTitle({ write: options?.write, reason: `${reason}-start` });
|
|
406
|
+
const interval = setInterval(() => {
|
|
407
|
+
if (stopped) return;
|
|
408
|
+
if (Date.now() - startedAt >= durationMs) {
|
|
409
|
+
stopped = true;
|
|
410
|
+
clearInterval(interval);
|
|
411
|
+
reassertIntendedTerminalTitle({ write: options?.write, reason: `${reason}-end` });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
reassertIntendedTerminalTitle({ write: options?.write, reason });
|
|
415
|
+
}, intervalMs);
|
|
416
|
+
interval.unref?.();
|
|
417
|
+
|
|
418
|
+
return () => {
|
|
419
|
+
if (stopped) return;
|
|
420
|
+
stopped = true;
|
|
421
|
+
clearInterval(interval);
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function sanitizeTerminalTitle(title: string): string {
|
|
426
|
+
return title
|
|
427
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
428
|
+
.replace(/\u001b/g, "")
|
|
429
|
+
.trim();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function buildTerminalTitleSequence(title: string): string {
|
|
433
|
+
const safeTitle = normalizeTerminalTitle(title);
|
|
434
|
+
// \x1b]0; sets both icon name and window title.
|
|
435
|
+
// \x1b]2; sets window title.
|
|
436
|
+
// We use both for compatibility.
|
|
437
|
+
return `\x1b]0;${safeTitle}\x07\x1b]2;${safeTitle}\x07`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function formatTerminalTitleLabel(
|
|
441
|
+
workspaceRoot: string,
|
|
442
|
+
terminalTitleMode: TerminalTitleMode,
|
|
443
|
+
): string {
|
|
444
|
+
return formatTerminalTitlePath(workspaceRoot, terminalTitleMode);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function deriveTerminalTitle(
|
|
448
|
+
workspaceRoot: string,
|
|
449
|
+
terminalTitleMode: TerminalTitleMode,
|
|
450
|
+
): string {
|
|
451
|
+
return formatTerminalTitlePath(workspaceRoot, terminalTitleMode) || DEFAULT_TERMINAL_TITLE;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Write the title sequence to stdout to (re-)assert the window title. */
|
|
455
|
+
export function reassertTerminalTitle(
|
|
456
|
+
title: string = DEFAULT_TERMINAL_TITLE,
|
|
457
|
+
write: (chunk: string) => void = (chunk) => {
|
|
458
|
+
process.stdout.write(chunk);
|
|
459
|
+
},
|
|
460
|
+
): void {
|
|
461
|
+
write(buildTerminalTitleSequence(title));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Acquire a run-scoped title guard that immediately asserts the title,
|
|
466
|
+
* reasserts it periodically while work is active, and emits one final
|
|
467
|
+
* assertion when released.
|
|
468
|
+
*/
|
|
469
|
+
export function acquireTerminalTitleGuard(
|
|
470
|
+
intervalMs = 250,
|
|
471
|
+
reassert: () => void = () => reassertTerminalTitle(),
|
|
472
|
+
): () => void {
|
|
473
|
+
let released = false;
|
|
474
|
+
reassert();
|
|
475
|
+
const timer = setInterval(reassert, intervalMs);
|
|
476
|
+
timer.unref?.();
|
|
477
|
+
return () => {
|
|
478
|
+
if (released) return;
|
|
479
|
+
released = true;
|
|
480
|
+
clearInterval(timer);
|
|
481
|
+
reassert();
|
|
482
|
+
};
|
|
483
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
captureWorkspaceSnapshot,
|
|
8
|
+
createWorkspaceActivityTracker,
|
|
9
|
+
createTextDiffExcerpt,
|
|
10
|
+
diffWorkspaceSnapshots,
|
|
11
|
+
} from "./workspaceActivity.js";
|
|
12
|
+
|
|
13
|
+
function createTempWorkspace(): string {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), "codex-workspace-activity-"));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("detects created files in the workspace snapshot diff", () => {
|
|
18
|
+
const root = createTempWorkspace();
|
|
19
|
+
try {
|
|
20
|
+
const before = captureWorkspaceSnapshot(root);
|
|
21
|
+
writeFileSync(join(root, "new-file.ts"), "const value = 1;\n", "utf8");
|
|
22
|
+
const after = captureWorkspaceSnapshot(root);
|
|
23
|
+
const activity = diffWorkspaceSnapshots(before, after, 123);
|
|
24
|
+
|
|
25
|
+
assert.equal(activity.length, 1);
|
|
26
|
+
assert.equal(activity[0]?.operation, "created");
|
|
27
|
+
assert.equal(activity[0]?.path, "new-file.ts");
|
|
28
|
+
assert.equal(activity[0]?.addedLines, 1);
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("detects modified files with diff preview", () => {
|
|
35
|
+
const root = createTempWorkspace();
|
|
36
|
+
try {
|
|
37
|
+
const file = join(root, "edited.py");
|
|
38
|
+
writeFileSync(file, "print('before')\nvalue = 1\n", "utf8");
|
|
39
|
+
const before = captureWorkspaceSnapshot(root);
|
|
40
|
+
|
|
41
|
+
writeFileSync(file, "print('after')\nvalue = 2\nextra = True\n", "utf8");
|
|
42
|
+
const after = captureWorkspaceSnapshot(root);
|
|
43
|
+
const activity = diffWorkspaceSnapshots(before, after, 456);
|
|
44
|
+
|
|
45
|
+
assert.equal(activity.length, 1);
|
|
46
|
+
assert.equal(activity[0]?.operation, "modified");
|
|
47
|
+
assert.equal(activity[0]?.path, "edited.py");
|
|
48
|
+
assert.equal(activity[0]?.addedLines, 3);
|
|
49
|
+
assert.equal(activity[0]?.removedLines, 2);
|
|
50
|
+
assert.equal(activity[0]?.diffLines?.[0]?.kind, "removed");
|
|
51
|
+
assert.equal(activity[0]?.diffLines?.at(-1)?.kind, "added");
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("detects deleted files", () => {
|
|
58
|
+
const root = createTempWorkspace();
|
|
59
|
+
try {
|
|
60
|
+
const file = join(root, "gone.txt");
|
|
61
|
+
writeFileSync(file, "goodbye\n", "utf8");
|
|
62
|
+
const before = captureWorkspaceSnapshot(root);
|
|
63
|
+
|
|
64
|
+
unlinkSync(file);
|
|
65
|
+
const after = captureWorkspaceSnapshot(root);
|
|
66
|
+
const activity = diffWorkspaceSnapshots(before, after, 789);
|
|
67
|
+
|
|
68
|
+
assert.equal(activity.length, 1);
|
|
69
|
+
assert.equal(activity[0]?.operation, "deleted");
|
|
70
|
+
assert.equal(activity[0]?.path, "gone.txt");
|
|
71
|
+
assert.equal(activity[0]?.removedLines, 1);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("ignores excluded directories like node_modules and .git", () => {
|
|
78
|
+
const root = createTempWorkspace();
|
|
79
|
+
try {
|
|
80
|
+
const before = captureWorkspaceSnapshot(root);
|
|
81
|
+
mkdirSync(join(root, "node_modules"), { recursive: true });
|
|
82
|
+
mkdirSync(join(root, ".git"), { recursive: true });
|
|
83
|
+
writeFileSync(join(root, "node_modules", "ignored.js"), "console.log('x')", "utf8");
|
|
84
|
+
writeFileSync(join(root, ".git", "ignored.txt"), "internal", "utf8");
|
|
85
|
+
const after = captureWorkspaceSnapshot(root);
|
|
86
|
+
const activity = diffWorkspaceSnapshots(before, after, 111);
|
|
87
|
+
|
|
88
|
+
assert.equal(activity.length, 0);
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("workspace activity tracker can reuse a provided initial snapshot", async () => {
|
|
95
|
+
const root = createTempWorkspace();
|
|
96
|
+
try {
|
|
97
|
+
writeFileSync(join(root, "edited.txt"), "before\n", "utf8");
|
|
98
|
+
writeFileSync(join(root, "gone.txt"), "delete me\n", "utf8");
|
|
99
|
+
const before = captureWorkspaceSnapshot(root);
|
|
100
|
+
|
|
101
|
+
writeFileSync(join(root, "new-file.ts"), "const value = 1;\n", "utf8");
|
|
102
|
+
writeFileSync(join(root, "edited.txt"), "after\n", "utf8");
|
|
103
|
+
unlinkSync(join(root, "gone.txt"));
|
|
104
|
+
|
|
105
|
+
const activity = await new Promise<ReturnType<typeof diffWorkspaceSnapshots>>((resolve, reject) => {
|
|
106
|
+
let tracker: ReturnType<typeof createWorkspaceActivityTracker> | null = null;
|
|
107
|
+
const timeout = setTimeout(() => {
|
|
108
|
+
tracker?.stop();
|
|
109
|
+
reject(new Error("Timed out waiting for workspace activity."));
|
|
110
|
+
}, 500);
|
|
111
|
+
tracker = createWorkspaceActivityTracker({
|
|
112
|
+
rootDir: root,
|
|
113
|
+
initialSnapshot: before,
|
|
114
|
+
pollIntervalMs: 10,
|
|
115
|
+
onActivity: (items) => {
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
tracker?.stop();
|
|
118
|
+
resolve(items);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const operationsByPath = new Map(activity.map((item) => [item.path, item.operation]));
|
|
124
|
+
assert.equal(operationsByPath.get("new-file.ts"), "created");
|
|
125
|
+
assert.equal(operationsByPath.get("edited.txt"), "modified");
|
|
126
|
+
assert.equal(operationsByPath.get("gone.txt"), "deleted");
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(root, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("skips inline diff previews for oversized files", () => {
|
|
133
|
+
const root = createTempWorkspace();
|
|
134
|
+
try {
|
|
135
|
+
const file = join(root, "large.txt");
|
|
136
|
+
const largeContent = `${"a".repeat(129 * 1024)}\n`;
|
|
137
|
+
writeFileSync(file, largeContent, "utf8");
|
|
138
|
+
const before = captureWorkspaceSnapshot(root);
|
|
139
|
+
|
|
140
|
+
writeFileSync(file, `${largeContent}tail\n`, "utf8");
|
|
141
|
+
const after = captureWorkspaceSnapshot(root);
|
|
142
|
+
const activity = diffWorkspaceSnapshots(before, after, 222);
|
|
143
|
+
|
|
144
|
+
assert.equal(activity.length, 1);
|
|
145
|
+
assert.equal(activity[0]?.operation, "modified");
|
|
146
|
+
assert.equal(activity[0]?.diffLines, undefined);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("creates green/red diff excerpts for mixed edits", () => {
|
|
153
|
+
const diff = createTextDiffExcerpt(
|
|
154
|
+
["alpha", "beta", "gamma"].join("\n"),
|
|
155
|
+
["alpha", "beta-2", "gamma", "delta"].join("\n"),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
assert(diff);
|
|
159
|
+
assert.equal(diff.addedLines, 2);
|
|
160
|
+
assert.equal(diff.removedLines, 1);
|
|
161
|
+
assert.equal(diff.diffLines?.some((line) => line.kind === "added"), true);
|
|
162
|
+
assert.equal(diff.diffLines?.some((line) => line.kind === "removed"), true);
|
|
163
|
+
});
|