@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,128 @@
|
|
|
1
|
+
const MARKDOWN_LINK_RE = /!?\[([^\]\n]+)\]\(([^)\n]+)\)/g;
|
|
2
|
+
const FILE_URL_RE = /\bfile:\/\/\/?[^\s`"'<>)]*/gi;
|
|
3
|
+
const WINDOWS_ABSOLUTE_PATH_RE = /\b[A-Za-z]:[\\/][^\n`"'<>)]*?\.(?:tsx?|jsx?|md|json|ya?ml|toml|css|html|py|txt|mjs|cjs)(?:#L\d+(?:-L?\d+)?)?/g;
|
|
4
|
+
const UNIX_ABSOLUTE_PATH_RE = /(?:^|[\s(])((?:\/Users|\/home|\/workspace|\/workspaces|\/mnt\/[a-z])\/[^\n`"'<>)]*?\.(?:tsx?|jsx?|md|json|ya?ml|toml|css|html|py|txt|mjs|cjs)(?:#L\d+(?:-L?\d+)?)?)/g;
|
|
5
|
+
|
|
6
|
+
const PATH_ROOT_SEGMENTS = new Set([
|
|
7
|
+
".github",
|
|
8
|
+
"app",
|
|
9
|
+
"bin",
|
|
10
|
+
"components",
|
|
11
|
+
"docs",
|
|
12
|
+
"lib",
|
|
13
|
+
"pages",
|
|
14
|
+
"preview",
|
|
15
|
+
"scripts",
|
|
16
|
+
"src",
|
|
17
|
+
"test",
|
|
18
|
+
"tests",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const ROOT_FILE_RE = /^(?:README|CHANGELOG|LICENSE|SECURITY|package|tsconfig|vite\.config|next\.config|bun\.lock)(?:\.[a-z0-9]+)?$/i;
|
|
22
|
+
|
|
23
|
+
function decodePath(value: string): string {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(value);
|
|
26
|
+
} catch {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stripLinkLabelMarkdown(label: string): string {
|
|
32
|
+
return label.trim().replace(/^`([^`]+)`$/, "$1").replace(/^\*\*([^*]+)\*\*$/, "$1");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function splitLineAnchor(value: string): { path: string; lineSuffix: string } {
|
|
36
|
+
const anchorMatch = /#L(\d+)(?:-L?(\d+))?$/i.exec(value);
|
|
37
|
+
if (!anchorMatch) {
|
|
38
|
+
return { path: value, lineSuffix: "" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const start = anchorMatch[1]!;
|
|
42
|
+
const end = anchorMatch[2];
|
|
43
|
+
return {
|
|
44
|
+
path: value.slice(0, anchorMatch.index),
|
|
45
|
+
lineSuffix: end ? `:${start}-${end}` : `:${start}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeLocalPathPathname(rawPath: string): { path: string; lineSuffix: string } {
|
|
50
|
+
let value = rawPath.trim();
|
|
51
|
+
value = value.replace(/^<|>$/g, "");
|
|
52
|
+
value = value.replace(/^file:(?:\/\/)?/i, "");
|
|
53
|
+
value = value.replace(/^\/([A-Za-z]:[\\/])/, "$1");
|
|
54
|
+
value = decodePath(value);
|
|
55
|
+
value = value.replace(/\\/g, "/");
|
|
56
|
+
value = value.replace(/[?#][^#]*$/, (suffix) => suffix.startsWith("#L") ? suffix : "");
|
|
57
|
+
return splitLineAnchor(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function looksLikeLocalTarget(target: string): boolean {
|
|
61
|
+
const trimmed = target.trim();
|
|
62
|
+
return /^file:/i.test(trimmed)
|
|
63
|
+
|| /^[A-Za-z]:[\\/]/.test(trimmed)
|
|
64
|
+
|| /^\/(?:Users|home|workspace|workspaces|mnt\/[a-z])\//i.test(trimmed);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function looksLikePathLabel(label: string): boolean {
|
|
68
|
+
return /[\\/]/.test(label) || /^[\w.-]+\.[A-Za-z0-9]+(?::\d+(?:-\d+)?)?$/.test(label);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatLocalPathForTerminal(rawPath: string): string {
|
|
72
|
+
if (/^https?:\/\//i.test(rawPath.trim())) {
|
|
73
|
+
return rawPath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { path, lineSuffix } = normalizeLocalPathPathname(rawPath);
|
|
77
|
+
const segments = path.split("/").filter(Boolean);
|
|
78
|
+
if (segments.length === 0) {
|
|
79
|
+
return path + lineSuffix;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rootIndex = segments.findIndex((segment) => PATH_ROOT_SEGMENTS.has(segment));
|
|
83
|
+
if (rootIndex >= 0) {
|
|
84
|
+
return segments.slice(rootIndex).join("/") + lineSuffix;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fileName = segments[segments.length - 1]!;
|
|
88
|
+
if (ROOT_FILE_RE.test(fileName)) {
|
|
89
|
+
return fileName + lineSuffix;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return segments.slice(-1).join("/") + lineSuffix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatLocalMarkdownLink(label: string, target: string): string {
|
|
96
|
+
const cleanLabel = stripLinkLabelMarkdown(label);
|
|
97
|
+
const compactTarget = formatLocalPathForTerminal(target);
|
|
98
|
+
const lineSuffix = /(:\d+(?:-\d+)?)$/.exec(compactTarget)?.[1] ?? "";
|
|
99
|
+
|
|
100
|
+
if (looksLikePathLabel(cleanLabel)) {
|
|
101
|
+
const compactLabel = formatLocalPathForTerminal(cleanLabel);
|
|
102
|
+
return lineSuffix && !compactLabel.endsWith(lineSuffix)
|
|
103
|
+
? `${compactLabel}${lineSuffix}`
|
|
104
|
+
: compactLabel;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return cleanLabel ? `${cleanLabel} (${compactTarget})` : compactTarget;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatTerminalAnswerInline(text: string): string {
|
|
111
|
+
if (!text) return text;
|
|
112
|
+
|
|
113
|
+
let formatted = text.replace(MARKDOWN_LINK_RE, (full, label: string, target: string) => {
|
|
114
|
+
if (!looksLikeLocalTarget(target)) {
|
|
115
|
+
return full;
|
|
116
|
+
}
|
|
117
|
+
return formatLocalMarkdownLink(label, target);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
formatted = formatted.replace(FILE_URL_RE, (match) => formatLocalPathForTerminal(match));
|
|
121
|
+
formatted = formatted.replace(WINDOWS_ABSOLUTE_PATH_RE, (match) => formatLocalPathForTerminal(match));
|
|
122
|
+
formatted = formatted.replace(UNIX_ABSOLUTE_PATH_RE, (full, path: string) => {
|
|
123
|
+
const prefix = full.slice(0, full.length - path.length);
|
|
124
|
+
return `${prefix}${formatLocalPathForTerminal(path)}`;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return formatted;
|
|
128
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { wrapCommandText } from "./textLayout.js";
|
|
4
|
+
|
|
5
|
+
test("wrapCommandText breaks on spaces and indents continuation lines", () => {
|
|
6
|
+
const result = wrapCommandText("if (Get-Command rg) { rg --files } else { Get-ChildItem -Recurse -File }", 40);
|
|
7
|
+
assert.equal(result.length, 2);
|
|
8
|
+
assert.equal(result[0].trimEnd(), "if (Get-Command rg) { rg --files } else");
|
|
9
|
+
assert.equal(result[1].trimEnd(), " { Get-ChildItem -Recurse -File }");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("wrapCommandText handles extremely long unbroken tokens by breaking them", () => {
|
|
13
|
+
const result = wrapCommandText("A_Very_Long_Token_Without_Spaces_That_Exceeds_Max_Width", 20);
|
|
14
|
+
assert.equal(result.length, 3);
|
|
15
|
+
assert.equal(result[0], "A_Very_Long_Token_Wi");
|
|
16
|
+
assert.equal(result[1], " thout_Spaces_That_");
|
|
17
|
+
assert.equal(result[2], " Exceeds_Max_Width");
|
|
18
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
|
|
3
|
+
interface WindowSlice {
|
|
4
|
+
text: string;
|
|
5
|
+
cursorColumn: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TextUnit {
|
|
9
|
+
text: string;
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
width: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WrappedTextRow {
|
|
16
|
+
text: string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
breakType: "soft" | "hard" | "end";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Character measurement ───────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export function getCharWidth(char: string): number {
|
|
25
|
+
return Math.max(1, stringWidth(char));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeLineBreaks(text: string): string {
|
|
29
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getTextUnits(text: string): TextUnit[] {
|
|
33
|
+
const units: TextUnit[] = [];
|
|
34
|
+
let offset = 0;
|
|
35
|
+
|
|
36
|
+
for (const char of text) {
|
|
37
|
+
const length = char.length;
|
|
38
|
+
units.push({
|
|
39
|
+
text: char,
|
|
40
|
+
start: offset,
|
|
41
|
+
end: offset + length,
|
|
42
|
+
width: getCharWidth(char),
|
|
43
|
+
});
|
|
44
|
+
offset += length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return units;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getTextWidth(text: string): number {
|
|
51
|
+
return stringWidth(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function trimToWidthFromEnd(text: string, maxWidth: number): string {
|
|
55
|
+
if (maxWidth <= 0) return "";
|
|
56
|
+
let width = 0;
|
|
57
|
+
const kept: TextUnit[] = [];
|
|
58
|
+
const units = getTextUnits(text);
|
|
59
|
+
|
|
60
|
+
for (let index = units.length - 1; index >= 0; index -= 1) {
|
|
61
|
+
const unit = units[index]!;
|
|
62
|
+
if (width + unit.width > maxWidth) break;
|
|
63
|
+
kept.unshift(unit);
|
|
64
|
+
width += unit.width;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return kept.map((unit) => unit.text).join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function trimToWidthFromStart(text: string, maxWidth: number): string {
|
|
71
|
+
if (maxWidth <= 0) return "";
|
|
72
|
+
let width = 0;
|
|
73
|
+
let output = "";
|
|
74
|
+
|
|
75
|
+
for (const unit of getTextUnits(text)) {
|
|
76
|
+
if (width + unit.width > maxWidth) break;
|
|
77
|
+
output += unit.text;
|
|
78
|
+
width += unit.width;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return output;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Input window ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function flattenInputForDisplay(text: string, cursor: number): { text: string; cursor: number } {
|
|
87
|
+
const normalized = normalizeLineBreaks(text);
|
|
88
|
+
const units = getTextUnits(normalized);
|
|
89
|
+
let output = "";
|
|
90
|
+
let mappedCursor = 0;
|
|
91
|
+
|
|
92
|
+
for (const unit of units) {
|
|
93
|
+
if (unit.start === cursor) {
|
|
94
|
+
mappedCursor = output.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (unit.text === "\n") {
|
|
98
|
+
output += " ↩ ";
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (unit.text === "\t") {
|
|
102
|
+
output += " ";
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
output += unit.text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (cursor >= normalized.length) {
|
|
109
|
+
mappedCursor = output.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { text: output, cursor: mappedCursor };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createInlineInputWindow(text: string, cursor: number, maxWidth: number): WindowSlice {
|
|
116
|
+
const safeWidth = Math.max(1, maxWidth);
|
|
117
|
+
const flattened = flattenInputForDisplay(text, cursor);
|
|
118
|
+
const units = getTextUnits(flattened.text);
|
|
119
|
+
const charStartWidths: number[] = [];
|
|
120
|
+
let totalWidth = 0;
|
|
121
|
+
|
|
122
|
+
for (const unit of units) {
|
|
123
|
+
charStartWidths.push(totalWidth);
|
|
124
|
+
totalWidth += unit.width;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cursorWidth = getTextWidth(flattened.text.slice(0, flattened.cursor));
|
|
128
|
+
if (totalWidth <= safeWidth) {
|
|
129
|
+
return { text: flattened.text, cursorColumn: cursorWidth };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const preferredStart = Math.max(0, cursorWidth - Math.floor(safeWidth * 0.65));
|
|
133
|
+
let windowStart = preferredStart;
|
|
134
|
+
if (windowStart + safeWidth > totalWidth) {
|
|
135
|
+
windowStart = Math.max(0, totalWidth - safeWidth);
|
|
136
|
+
}
|
|
137
|
+
const windowEnd = windowStart + safeWidth;
|
|
138
|
+
|
|
139
|
+
let startIndex = 0;
|
|
140
|
+
while (startIndex < units.length && charStartWidths[startIndex]! + units[startIndex]!.width <= windowStart) {
|
|
141
|
+
startIndex += 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let endIndex = startIndex;
|
|
145
|
+
while (endIndex < units.length && charStartWidths[endIndex]! < windowEnd) {
|
|
146
|
+
endIndex += 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let visibleText = units.slice(startIndex, endIndex).map((unit) => unit.text).join("");
|
|
150
|
+
let cursorColumn = Math.max(0, cursorWidth - (charStartWidths[startIndex] ?? 0));
|
|
151
|
+
const truncatedLeft = startIndex > 0;
|
|
152
|
+
const truncatedRight = endIndex < units.length;
|
|
153
|
+
|
|
154
|
+
if (truncatedLeft) {
|
|
155
|
+
const ellipsis = "…";
|
|
156
|
+
const available = Math.max(1, safeWidth - getCharWidth(ellipsis) - (truncatedRight ? getCharWidth(ellipsis) : 0));
|
|
157
|
+
visibleText = ellipsis + trimToWidthFromEnd(visibleText, available);
|
|
158
|
+
cursorColumn = Math.min(getTextWidth(visibleText), Math.max(getCharWidth(ellipsis), cursorColumn + getCharWidth(ellipsis)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (truncatedRight) {
|
|
162
|
+
const ellipsis = "…";
|
|
163
|
+
const available = Math.max(1, safeWidth - (truncatedLeft ? getCharWidth(ellipsis) : 0) - getCharWidth(ellipsis));
|
|
164
|
+
const baseText = truncatedLeft ? visibleText.slice(1) : visibleText;
|
|
165
|
+
visibleText = `${truncatedLeft ? "…" : ""}${trimToWidthFromStart(baseText, available)}${ellipsis}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
text: visibleText,
|
|
170
|
+
cursorColumn: Math.max(0, Math.min(getTextWidth(visibleText), cursorColumn)),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function splitTextAtColumn(text: string, column: number): { before: string; current: string; after: string } {
|
|
175
|
+
const safeColumn = Math.max(0, column);
|
|
176
|
+
let width = 0;
|
|
177
|
+
const units = getTextUnits(text);
|
|
178
|
+
|
|
179
|
+
for (const unit of units) {
|
|
180
|
+
if (width + unit.width > safeColumn) {
|
|
181
|
+
return {
|
|
182
|
+
before: text.slice(0, unit.start),
|
|
183
|
+
current: unit.text,
|
|
184
|
+
after: text.slice(unit.end),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
width += unit.width;
|
|
189
|
+
if (width > safeColumn) break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
before: text,
|
|
194
|
+
current: "",
|
|
195
|
+
after: "",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Text wrapping ────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export function wrapTextRows(text: string, maxWidth: number): WrappedTextRow[] {
|
|
202
|
+
const normalized = normalizeLineBreaks(text);
|
|
203
|
+
const safeWidth = Math.max(1, maxWidth);
|
|
204
|
+
const rows: WrappedTextRow[] = [];
|
|
205
|
+
let rowStart = 0;
|
|
206
|
+
let rowText = "";
|
|
207
|
+
let rowWidth = 0;
|
|
208
|
+
|
|
209
|
+
for (const unit of getTextUnits(normalized)) {
|
|
210
|
+
if (unit.text === "\n") {
|
|
211
|
+
rows.push({
|
|
212
|
+
text: rowText,
|
|
213
|
+
start: rowStart,
|
|
214
|
+
end: unit.start,
|
|
215
|
+
breakType: "hard",
|
|
216
|
+
});
|
|
217
|
+
rowStart = unit.end;
|
|
218
|
+
rowText = "";
|
|
219
|
+
rowWidth = 0;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (rowText.length > 0 && rowWidth + unit.width > safeWidth) {
|
|
224
|
+
rows.push({
|
|
225
|
+
text: rowText,
|
|
226
|
+
start: rowStart,
|
|
227
|
+
end: unit.start,
|
|
228
|
+
breakType: "soft",
|
|
229
|
+
});
|
|
230
|
+
rowStart = unit.start;
|
|
231
|
+
rowText = unit.text;
|
|
232
|
+
rowWidth = unit.width;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
rowText += unit.text;
|
|
237
|
+
rowWidth += unit.width;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
rows.push({
|
|
241
|
+
text: rowText,
|
|
242
|
+
start: rowStart,
|
|
243
|
+
end: normalized.length,
|
|
244
|
+
breakType: "end",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return rows.length > 0 ? rows : [{
|
|
248
|
+
text: "",
|
|
249
|
+
start: 0,
|
|
250
|
+
end: 0,
|
|
251
|
+
breakType: "end",
|
|
252
|
+
}];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function wrapPlainText(text: string, maxWidth: number): string[] {
|
|
256
|
+
return wrapTextRows(text, maxWidth).map((row) => row.text);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function wrapCommandText(text: string, maxWidth: number): string[] {
|
|
260
|
+
if (maxWidth <= 2) return [];
|
|
261
|
+
const normalized = normalizeLineBreaks(text);
|
|
262
|
+
const rows: string[] = [];
|
|
263
|
+
let currentLine = "";
|
|
264
|
+
let currentWidth = 0;
|
|
265
|
+
|
|
266
|
+
// Split on whitespace, but keep the whitespace tokens
|
|
267
|
+
const tokens = normalized.split(/([ \t]+)/);
|
|
268
|
+
|
|
269
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
270
|
+
let token = tokens[i];
|
|
271
|
+
if (!token) continue;
|
|
272
|
+
|
|
273
|
+
if (token === "\n") {
|
|
274
|
+
rows.push(currentLine);
|
|
275
|
+
currentLine = " ";
|
|
276
|
+
currentWidth = 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let tokenWidth = getTextWidth(token);
|
|
281
|
+
|
|
282
|
+
// Skip leading whitespace on continuation lines
|
|
283
|
+
if (/^[ \t]+$/.test(token) && currentLine === " ") {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (currentWidth + tokenWidth > maxWidth) {
|
|
288
|
+
if (/^[ \t]+$/.test(token)) {
|
|
289
|
+
// Space that pushes us over the edge, ignore it and break
|
|
290
|
+
rows.push(currentLine);
|
|
291
|
+
currentLine = " ";
|
|
292
|
+
currentWidth = 2;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (currentWidth > 2) {
|
|
297
|
+
// We have some content on this line, push it and start a new line
|
|
298
|
+
rows.push(currentLine);
|
|
299
|
+
currentLine = " ";
|
|
300
|
+
currentWidth = 2;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Now check if the token alone exceeds the available width (maxWidth - 2)
|
|
304
|
+
while (tokenWidth > maxWidth - currentWidth) {
|
|
305
|
+
const available = maxWidth - currentWidth;
|
|
306
|
+
const split = splitTextAtColumn(token, available);
|
|
307
|
+
|
|
308
|
+
currentLine += split.before;
|
|
309
|
+
rows.push(currentLine);
|
|
310
|
+
|
|
311
|
+
token = split.current + split.after;
|
|
312
|
+
tokenWidth = getTextWidth(token);
|
|
313
|
+
currentLine = " ";
|
|
314
|
+
currentWidth = 2;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
currentLine += token;
|
|
318
|
+
currentWidth += tokenWidth;
|
|
319
|
+
|
|
320
|
+
} else {
|
|
321
|
+
currentLine += token;
|
|
322
|
+
currentWidth += tokenWidth;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (currentLine.trim().length > 0 || currentLine === " ") {
|
|
327
|
+
// only push if there is actual content, or if it is an intentionally empty line (rare)
|
|
328
|
+
if (currentLine !== " ") {
|
|
329
|
+
rows.push(currentLine);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (rows.length > 0 && rows[0].startsWith(" ") && !text.startsWith(" ")) {
|
|
334
|
+
rows[0] = rows[0].substring(2);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return rows;
|
|
338
|
+
}
|