@selesai/code 0.10.1 → 0.11.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/config.d.ts +5 -3
  3. package/dist/config.js +6 -4
  4. package/dist/core/skills.d.ts +2 -0
  5. package/dist/core/skills.js +1 -0
  6. package/dist/defaults/settings.json +0 -3
  7. package/dist/extensions/auto-session-name.test.ts +157 -0
  8. package/dist/extensions/auto-session-name.ts +96 -0
  9. package/dist/extensions/cost-reconcile.test.ts +76 -0
  10. package/dist/extensions/cost-reconcile.ts +278 -0
  11. package/dist/extensions/enable-readonly-tools.test.ts +11 -0
  12. package/dist/extensions/enable-readonly-tools.ts +7 -3
  13. package/dist/extensions/package.json +3 -0
  14. package/dist/extensions/pi-tool-display/CHANGELOG.md +329 -0
  15. package/dist/extensions/pi-tool-display/LICENSE +21 -0
  16. package/dist/extensions/pi-tool-display/README.md +417 -0
  17. package/dist/extensions/pi-tool-display/config/config.example.json +40 -0
  18. package/dist/extensions/pi-tool-display/index.ts +3 -0
  19. package/dist/extensions/pi-tool-display/package-lock.json +2555 -0
  20. package/dist/extensions/pi-tool-display/package.json +86 -0
  21. package/dist/extensions/pi-tool-display/src/agent-dir.ts +35 -0
  22. package/dist/extensions/pi-tool-display/src/ansi-utils.ts +116 -0
  23. package/dist/extensions/pi-tool-display/src/bash-display.ts +213 -0
  24. package/dist/extensions/pi-tool-display/src/capabilities.ts +88 -0
  25. package/dist/extensions/pi-tool-display/src/config-modal.ts +541 -0
  26. package/dist/extensions/pi-tool-display/src/config-store.ts +304 -0
  27. package/dist/extensions/pi-tool-display/src/debug-logger.ts +150 -0
  28. package/dist/extensions/pi-tool-display/src/diff-presentation.ts +72 -0
  29. package/dist/extensions/pi-tool-display/src/diff-renderer.ts +2517 -0
  30. package/dist/extensions/pi-tool-display/src/disposable.ts +34 -0
  31. package/dist/extensions/pi-tool-display/src/extension-lifecycle.ts +14 -0
  32. package/dist/extensions/pi-tool-display/src/index.ts +109 -0
  33. package/dist/extensions/pi-tool-display/src/line-width-safety.ts +93 -0
  34. package/dist/extensions/pi-tool-display/src/modal-icons.ts +57 -0
  35. package/dist/extensions/pi-tool-display/src/pending-diff-preview.ts +370 -0
  36. package/dist/extensions/pi-tool-display/src/presets.ts +117 -0
  37. package/dist/extensions/pi-tool-display/src/render-utils.ts +186 -0
  38. package/dist/extensions/pi-tool-display/src/settings-inspector-modal.ts +475 -0
  39. package/dist/extensions/pi-tool-display/src/thinking-label.ts +343 -0
  40. package/dist/extensions/pi-tool-display/src/tool-call-box.ts +181 -0
  41. package/dist/extensions/pi-tool-display/src/tool-metadata.ts +139 -0
  42. package/dist/extensions/pi-tool-display/src/tool-overrides.ts +2168 -0
  43. package/dist/extensions/pi-tool-display/src/types.ts +109 -0
  44. package/dist/extensions/pi-tool-display/src/user-message-box-markdown.ts +64 -0
  45. package/dist/extensions/pi-tool-display/src/user-message-box-native.ts +66 -0
  46. package/dist/extensions/pi-tool-display/src/user-message-box-patch.ts +72 -0
  47. package/dist/extensions/pi-tool-display/src/user-message-box-renderer.ts +419 -0
  48. package/dist/extensions/pi-tool-display/src/user-message-box-utils.ts +106 -0
  49. package/dist/extensions/pi-tool-display/src/write-display-utils.ts +31 -0
  50. package/dist/extensions/pi-tool-display/src/zellij-modal.ts +1027 -0
  51. package/dist/extensions/pi-tool-display/tests/active-backlog-red.test.ts +65 -0
  52. package/dist/extensions/pi-tool-display/tests/ansi-utils.test.ts +345 -0
  53. package/dist/extensions/pi-tool-display/tests/bash-display.test.ts +442 -0
  54. package/dist/extensions/pi-tool-display/tests/capabilities-edge.test.ts +360 -0
  55. package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +54 -0
  56. package/dist/extensions/pi-tool-display/tests/config-modal.test.ts +360 -0
  57. package/dist/extensions/pi-tool-display/tests/config-store.test.ts +87 -0
  58. package/dist/extensions/pi-tool-display/tests/custom-tool-overrides.test.ts +415 -0
  59. package/dist/extensions/pi-tool-display/tests/debug-logger-edge.test.ts +630 -0
  60. package/dist/extensions/pi-tool-display/tests/debug-logger.test.ts +62 -0
  61. package/dist/extensions/pi-tool-display/tests/diff-renderer-ansi.test.ts +55 -0
  62. package/dist/extensions/pi-tool-display/tests/diff-renderer-edge.test.ts +886 -0
  63. package/dist/extensions/pi-tool-display/tests/diff-renderer-numbering.test.ts +135 -0
  64. package/dist/extensions/pi-tool-display/tests/diff-renderer-width.test.ts +454 -0
  65. package/dist/extensions/pi-tool-display/tests/diff-renderer-wrap.test.ts +169 -0
  66. package/dist/extensions/pi-tool-display/tests/index-integration.test.ts +354 -0
  67. package/dist/extensions/pi-tool-display/tests/pending-diff-preview.test.ts +79 -0
  68. package/dist/extensions/pi-tool-display/tests/presets-edge.test.ts +229 -0
  69. package/dist/extensions/pi-tool-display/tests/presets.test.ts +26 -0
  70. package/dist/extensions/pi-tool-display/tests/reload-behavior.test.ts +1241 -0
  71. package/dist/extensions/pi-tool-display/tests/render-utils.test.ts +710 -0
  72. package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +450 -0
  73. package/dist/extensions/pi-tool-display/tests/thinking-label-edge.test.ts +904 -0
  74. package/dist/extensions/pi-tool-display/tests/thinking-label.test.ts +72 -0
  75. package/dist/extensions/pi-tool-display/tests/tool-call-box.test.ts +105 -0
  76. package/dist/extensions/pi-tool-display/tests/tool-overrides-config.test.ts +613 -0
  77. package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +499 -0
  78. package/dist/extensions/pi-tool-display/tests/tool-overrides-registration.test.ts +315 -0
  79. package/dist/extensions/pi-tool-display/tests/tool-overrides-write-meta.test.ts +68 -0
  80. package/dist/extensions/pi-tool-display/tests/tool-ui-utils.test.ts +413 -0
  81. package/dist/extensions/pi-tool-display/tests/user-message-box-performance-red.test.ts +176 -0
  82. package/dist/extensions/pi-tool-display/tests/user-message-box.test.ts +908 -0
  83. package/dist/extensions/pi-tool-display/tests/zellij-modal.test.ts +848 -0
  84. package/dist/extensions/pi-tool-display/tool-display-api-consumer.d.ts +30 -0
  85. package/dist/extensions/pi-tool-display/tool-display-api-consumer.js +40 -0
  86. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +46 -1
  87. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +10 -0
  88. package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +1 -1
  89. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +3 -3
  90. package/dist/extensions/pi-zentui/test/format.test.ts +50 -2
  91. package/dist/modes/interactive/components/startup-box.d.ts +38 -0
  92. package/dist/modes/interactive/components/startup-box.js +90 -0
  93. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  94. package/dist/modes/interactive/interactive-mode.js +85 -122
  95. package/dist/skills/agent-browser/SKILL.md +1 -0
  96. package/dist/skills/batch-grill-me/SKILL.md +1 -0
  97. package/dist/skills/brandkit/SKILL.md +1 -0
  98. package/dist/skills/design-references/SKILL.md +1 -0
  99. package/dist/skills/design-taste-frontend/SKILL.md +1 -0
  100. package/dist/skills/design-taste-frontend-v1/SKILL.md +1 -0
  101. package/dist/skills/full-output-enforcement/SKILL.md +1 -0
  102. package/dist/skills/gpt-taste/SKILL.md +1 -0
  103. package/dist/skills/grill-me/SKILL.md +1 -0
  104. package/dist/skills/handoff/SKILL.md +1 -0
  105. package/dist/skills/handoff-text/SKILL.md +1 -0
  106. package/dist/skills/high-end-visual-design/SKILL.md +1 -0
  107. package/dist/skills/image-to-code/SKILL.md +1 -0
  108. package/dist/skills/imagegen-frontend-mobile/SKILL.md +1 -0
  109. package/dist/skills/imagegen-frontend-web/SKILL.md +1 -0
  110. package/dist/skills/implanger/SKILL.md +1 -0
  111. package/dist/skills/improve-codebase/SKILL.md +1 -0
  112. package/dist/skills/industrial-brutalist-ui/SKILL.md +1 -0
  113. package/dist/skills/minimalist-ui/SKILL.md +1 -0
  114. package/dist/skills/pi-subagents/SKILL.md +1 -0
  115. package/dist/skills/planger/SKILL.md +1 -0
  116. package/dist/skills/ponytail/SKILL.md +1 -0
  117. package/dist/skills/ponytail-audit/SKILL.md +1 -0
  118. package/dist/skills/ponytail-debt/SKILL.md +1 -0
  119. package/dist/skills/ponytail-gain/SKILL.md +1 -0
  120. package/dist/skills/ponytail-help/SKILL.md +1 -0
  121. package/dist/skills/ponytail-review/SKILL.md +1 -0
  122. package/dist/skills/redesign-existing-projects/SKILL.md +1 -0
  123. package/dist/skills/selesai-handoff/SKILL.md +1 -0
  124. package/dist/skills/stitch-design-taste/SKILL.md +1 -0
  125. package/dist/skills/unlazy/SKILL.md +1 -0
  126. package/dist/skills/web-design-guidelines/SKILL.md +1 -0
  127. package/dist/skills/workflow/SKILL.md +1 -0
  128. package/package.json +3 -3
@@ -0,0 +1,34 @@
1
+ // Track cleanup callbacks for reload safety
2
+ let cleanupCallbacks: Array<() => void> = [];
3
+ let isDisposed = false;
4
+
5
+ export function registerCleanup(callback: () => void): void {
6
+ if (isDisposed) {
7
+ callback();
8
+ return;
9
+ }
10
+ cleanupCallbacks.push(callback);
11
+ }
12
+
13
+ export function registerTimer(timer: ReturnType<typeof setInterval> | ReturnType<typeof setTimeout>): void {
14
+ registerCleanup(() => clearInterval(timer as ReturnType<typeof setInterval>));
15
+ }
16
+
17
+ export function disposeAll(): void {
18
+ if (isDisposed) return;
19
+ isDisposed = true;
20
+ // Run in reverse order (LIFO)
21
+ for (let i = cleanupCallbacks.length - 1; i >= 0; i--) {
22
+ try { cleanupCallbacks[i](); } catch (cleanupError) { void cleanupError; }
23
+ }
24
+ cleanupCallbacks = [];
25
+ }
26
+
27
+ export function resetDisposed(): void {
28
+ isDisposed = false;
29
+ cleanupCallbacks = [];
30
+ }
31
+
32
+ export function getCleanupCount(): number {
33
+ return cleanupCallbacks.length;
34
+ }
@@ -0,0 +1,14 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Register a session_shutdown handler that runs cleanup only when the
5
+ * session is shutting down for a reload. Centralizes the reload-detection
6
+ * check shared by extension registration modules.
7
+ */
8
+ export function onReloadShutdown(pi: ExtensionAPI, cleanup: () => void): void {
9
+ pi.on("session_shutdown", async (event: { reason?: string }) => {
10
+ if (event?.reason === "reload") {
11
+ cleanup();
12
+ }
13
+ });
14
+ }
@@ -0,0 +1,109 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ loadToolDisplayConfig,
7
+ normalizeToolDisplayConfig,
8
+ saveToolDisplayConfig,
9
+ } from "./config-store.js";
10
+ import {
11
+ applyCapabilityConfigGuards,
12
+ detectToolDisplayCapabilities,
13
+ type ToolDisplayCapabilities,
14
+ } from "./capabilities.js";
15
+ import { registerToolDisplayOverrides } from "./tool-overrides.js";
16
+ import { disposeAll, resetDisposed } from "./disposable.js";
17
+ import { registerThinkingLabeling } from "./thinking-label.js";
18
+ import registerNativeUserMessageBox from "./user-message-box-native.js";
19
+ import {
20
+ BUILT_IN_TOOL_OVERRIDE_NAMES,
21
+ type ToolDisplayConfig,
22
+ } from "./types.js";
23
+
24
+ function ownershipChanged(
25
+ previous: ToolDisplayConfig,
26
+ next: ToolDisplayConfig,
27
+ ): boolean {
28
+ return BUILT_IN_TOOL_OVERRIDE_NAMES.some(
29
+ (toolName) =>
30
+ previous.registerToolOverrides[toolName] !==
31
+ next.registerToolOverrides[toolName],
32
+ );
33
+ }
34
+
35
+ export default function toolDisplayExtension(pi: ExtensionAPI): void {
36
+ const initial = loadToolDisplayConfig();
37
+ if (!initial.config.enabled) {
38
+ return;
39
+ }
40
+
41
+ resetDisposed();
42
+
43
+ pi.on("session_shutdown", (event: { reason: string }) => {
44
+ if (event.reason === "reload") {
45
+ disposeAll();
46
+ }
47
+ });
48
+
49
+ let config: ToolDisplayConfig = initial.config;
50
+ let pendingLoadError = initial.error;
51
+ let capabilities: ToolDisplayCapabilities = {
52
+ hasMcpTooling: false,
53
+ hasRtkOptimizer: false,
54
+ };
55
+
56
+ const refreshCapabilities = (): void => {
57
+ capabilities = detectToolDisplayCapabilities(pi, process.cwd());
58
+ };
59
+
60
+ const getConfig = (): ToolDisplayConfig => config;
61
+ const getCapabilities = (): ToolDisplayCapabilities => capabilities;
62
+ const getEffectiveConfig = (): ToolDisplayConfig =>
63
+ applyCapabilityConfigGuards(config, capabilities);
64
+
65
+ const setConfig = (
66
+ next: ToolDisplayConfig,
67
+ ctx: ExtensionCommandContext,
68
+ ): void => {
69
+ const normalized = normalizeToolDisplayConfig(next);
70
+ const requiresReload = ownershipChanged(config, normalized);
71
+ config = normalized;
72
+
73
+ const saved = saveToolDisplayConfig(normalized);
74
+ if (!saved.success && saved.error) {
75
+ ctx.ui.notify(saved.error, "error");
76
+ }
77
+
78
+ if (requiresReload) {
79
+ ctx.ui.notify(
80
+ "Tool ownership updates apply after /reload.",
81
+ "warning",
82
+ );
83
+ }
84
+ };
85
+
86
+ registerToolDisplayOverrides(pi, getEffectiveConfig);
87
+ registerNativeUserMessageBox(pi, getConfig);
88
+ registerThinkingLabeling(pi);
89
+
90
+ pi.registerCommand("tool-display", {
91
+ description: "Configure tool output rendering (OpenCode/boxed styles)",
92
+ handler: async (args, ctx) => {
93
+ const { runToolDisplayCommandHandler } = await import("./config-modal.js");
94
+ await runToolDisplayCommandHandler(args, ctx, { getConfig, setConfig, getCapabilities });
95
+ },
96
+ });
97
+
98
+ pi.on("session_start", async (_event, ctx) => {
99
+ refreshCapabilities();
100
+ if (pendingLoadError) {
101
+ ctx.ui.notify(pendingLoadError, "warning");
102
+ pendingLoadError = undefined;
103
+ }
104
+ });
105
+
106
+ pi.on("before_agent_start", async () => {
107
+ refreshCapabilities();
108
+ });
109
+ }
@@ -0,0 +1,93 @@
1
+ import { normalizeDiffRenderWidth } from "./diff-presentation.js";
2
+ import { pluralize } from "./render-utils.js";
3
+
4
+ export interface WidthMeasurementOps {
5
+ measure(text: string): number;
6
+ truncate(text: string, maxWidth: number): string;
7
+ }
8
+
9
+ export interface CollapsedDiffHintOptions {
10
+ remainingLines: number;
11
+ hiddenHunks: number;
12
+ }
13
+
14
+ function guardSafeWidth(width: number): number | undefined {
15
+ const safe = normalizeDiffRenderWidth(width);
16
+ return safe === 0 ? undefined : safe;
17
+ }
18
+
19
+ function renderWithSafeWidth<T>(
20
+ width: number,
21
+ fallback: T,
22
+ render: (safeWidth: number) => T,
23
+ ): T {
24
+ const safeWidth = guardSafeWidth(width);
25
+ if (safeWidth === undefined) {
26
+ return fallback;
27
+ }
28
+ return render(safeWidth);
29
+ }
30
+
31
+ export function clampRenderedLineToWidth(
32
+ text: string,
33
+ width: number,
34
+ ops: WidthMeasurementOps,
35
+ ): string {
36
+ return renderWithSafeWidth(width, "", (safeWidth) => {
37
+ if (ops.measure(text) <= safeWidth) {
38
+ return text;
39
+ }
40
+
41
+ for (let targetWidth = safeWidth; targetWidth >= 0; targetWidth--) {
42
+ const candidate = ops.truncate(text, targetWidth);
43
+ if (ops.measure(candidate) <= safeWidth) {
44
+ return candidate;
45
+ }
46
+ }
47
+
48
+ return "";
49
+ });
50
+ }
51
+
52
+ export function clampRenderedLinesToWidth(
53
+ lines: string[],
54
+ width: number,
55
+ ops: WidthMeasurementOps,
56
+ ): string[] {
57
+ return lines.map((line) => clampRenderedLineToWidth(line, width, ops));
58
+ }
59
+
60
+ export function buildCollapsedDiffHintText(
61
+ options: CollapsedDiffHintOptions,
62
+ width: number,
63
+ ops: WidthMeasurementOps,
64
+ ): string {
65
+ return renderWithSafeWidth(width, "", (safeWidth) => {
66
+ const remainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "diff line")}`;
67
+ const hiddenHunksText = options.hiddenHunks > 0
68
+ ? `${options.hiddenHunks} more ${pluralize(options.hiddenHunks, "hunk")}`
69
+ : undefined;
70
+ const shortRemainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "line")}`;
71
+ const shortHiddenHunksText = options.hiddenHunks > 0
72
+ ? `${options.hiddenHunks} ${pluralize(options.hiddenHunks, "hunk")}`
73
+ : undefined;
74
+
75
+ const candidates = [
76
+ `… (${[remainingText, hiddenHunksText, "Ctrl+O to expand"].filter(Boolean).join(" • ")})`,
77
+ `… (${[remainingText, hiddenHunksText].filter(Boolean).join(" • ")})`,
78
+ `… (${[shortRemainingText, shortHiddenHunksText].filter(Boolean).join(" • ")})`,
79
+ options.hiddenHunks > 0
80
+ ? `… (+${options.remainingLines} • +${options.hiddenHunks}h)`
81
+ : `… (+${options.remainingLines})`,
82
+ "…",
83
+ ];
84
+
85
+ for (const candidate of candidates) {
86
+ if (ops.measure(candidate) <= safeWidth) {
87
+ return candidate;
88
+ }
89
+ }
90
+
91
+ return clampRenderedLineToWidth(candidates[candidates.length - 1] ?? "", safeWidth, ops);
92
+ });
93
+ }
@@ -0,0 +1,57 @@
1
+ export interface ModalIconSet {
2
+ search: string;
3
+ }
4
+
5
+ const NERD_MODAL_ICONS: ModalIconSet = {
6
+ search: "\uF002",
7
+ };
8
+
9
+ const EMOJI_MODAL_ICONS: ModalIconSet = {
10
+ search: "🔍",
11
+ };
12
+
13
+ const NERD_FONT_TERMINAL_HINTS = [
14
+ "iterm",
15
+ "wezterm",
16
+ "kitty",
17
+ "ghostty",
18
+ "alacritty",
19
+ "xfce4-terminal",
20
+ "gnome-terminal",
21
+ "tilix",
22
+ "terminator",
23
+ "konsole",
24
+ ] as const;
25
+
26
+ function parseBooleanEnv(value: string | undefined): boolean | undefined {
27
+ if (value === "1" || value?.toLowerCase() === "true") {
28
+ return true;
29
+ }
30
+
31
+ if (value === "0" || value?.toLowerCase() === "false") {
32
+ return false;
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
+ function detectNerdFonts(): boolean {
39
+ const explicitPreference =
40
+ parseBooleanEnv(process.env.PI_NERD_FONTS) ?? parseBooleanEnv(process.env.POWERLINE_NERD_FONTS);
41
+ if (explicitPreference !== undefined) {
42
+ return explicitPreference;
43
+ }
44
+
45
+ if (process.env.GHOSTTY_RESOURCES_DIR) {
46
+ return true;
47
+ }
48
+
49
+ const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
50
+ const term = (process.env.TERM || "").toLowerCase();
51
+ const fingerprint = `${termProgram} ${term}`;
52
+ return NERD_FONT_TERMINAL_HINTS.some((terminal) => fingerprint.includes(terminal));
53
+ }
54
+
55
+ export function getModalIcons(): ModalIconSet {
56
+ return detectNerdFonts() ? NERD_MODAL_ICONS : EMOJI_MODAL_ICONS;
57
+ }
@@ -0,0 +1,370 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+
5
+ export interface PendingDiffPreviewData {
6
+ filePath: string;
7
+ previousContent?: string;
8
+ nextContent?: string;
9
+ fileExistedBeforeWrite: boolean;
10
+ headerLabel: string;
11
+ notice?: string;
12
+ }
13
+
14
+ type EditPreviewInput = {
15
+ path?: unknown;
16
+ file_path?: unknown;
17
+ oldText?: unknown;
18
+ newText?: unknown;
19
+ edits?: unknown;
20
+ };
21
+
22
+ type EditReplacement = {
23
+ oldText: string;
24
+ newText: string;
25
+ };
26
+
27
+ type FileReadResult = {
28
+ exists: boolean;
29
+ content?: string;
30
+ error?: string;
31
+ };
32
+
33
+ const MAX_PREVIEW_READ_BYTES = 1_000_000;
34
+
35
+ type ProjectedEditResult =
36
+ | {
37
+ ok: true;
38
+ content: string;
39
+ }
40
+ | {
41
+ ok: false;
42
+ reason: string;
43
+ };
44
+
45
+ function trimPath(value: unknown): string | undefined {
46
+ if (typeof value !== "string") {
47
+ return undefined;
48
+ }
49
+
50
+ const trimmed = value.trim();
51
+ return trimmed ? trimmed : undefined;
52
+ }
53
+
54
+ function resolvePreviewPath(cwd: string, rawPath: string): string {
55
+ const trimmed = rawPath.trim();
56
+ if (!trimmed) {
57
+ return cwd;
58
+ }
59
+
60
+ const expandedHome = trimmed === "~"
61
+ ? homedir()
62
+ : trimmed.startsWith("~/") || trimmed.startsWith("~\\")
63
+ ? `${homedir()}${trimmed.slice(1)}`
64
+ : trimmed;
65
+
66
+ return isAbsolute(expandedHome) ? expandedHome : resolve(cwd, expandedHome);
67
+ }
68
+
69
+ function isWithinWorkspace(workspacePath: string, targetPath: string): boolean {
70
+ const relativePath = relative(safeRealpath(workspacePath), safeRealpath(targetPath));
71
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
72
+ }
73
+
74
+ function safeRealpath(path: string): string {
75
+ try {
76
+ return realpathSync(path);
77
+ } catch {
78
+ return resolve(path);
79
+ }
80
+ }
81
+
82
+ function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: string; error?: string } {
83
+ const workspacePath = safeRealpath(cwd);
84
+ const resolvedPath = resolvePreviewPath(cwd, rawPath);
85
+
86
+ if (!isWithinWorkspace(workspacePath, resolvedPath)) {
87
+ return {
88
+ resolvedPath,
89
+ error: "Preview unavailable because the target path is outside the current workspace.",
90
+ };
91
+ }
92
+
93
+ if (!existsSync(resolvedPath)) {
94
+ return { resolvedPath };
95
+ }
96
+
97
+ try {
98
+ const targetPath = realpathSync(resolvedPath);
99
+ if (!isWithinWorkspace(workspacePath, targetPath)) {
100
+ return {
101
+ resolvedPath,
102
+ error: "Preview unavailable because the target path resolves outside the current workspace.",
103
+ };
104
+ }
105
+ } catch (error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ return {
108
+ resolvedPath,
109
+ error: `Unable to resolve '${resolvedPath}': ${message}`,
110
+ };
111
+ }
112
+
113
+ return { resolvedPath };
114
+ }
115
+
116
+ export function readWorkspaceUtf8File(cwd: string, rawPath: string): FileReadResult {
117
+ const safePath = resolveWorkspaceReadPath(cwd, rawPath);
118
+ if (safePath.error) {
119
+ return { exists: false, error: safePath.error };
120
+ }
121
+
122
+ if (!existsSync(safePath.resolvedPath)) {
123
+ return { exists: false };
124
+ }
125
+
126
+ try {
127
+ const stats = statSync(safePath.resolvedPath);
128
+ if (!stats.isFile()) {
129
+ return {
130
+ exists: true,
131
+ error: `Preview unavailable because '${safePath.resolvedPath}' is not a regular file.`,
132
+ };
133
+ }
134
+ if (stats.size > MAX_PREVIEW_READ_BYTES) {
135
+ return {
136
+ exists: true,
137
+ error: `Preview unavailable because '${safePath.resolvedPath}' exceeds the ${MAX_PREVIEW_READ_BYTES} byte preview read limit.`,
138
+ };
139
+ }
140
+
141
+ return {
142
+ exists: true,
143
+ content: readFileSync(safePath.resolvedPath, "utf8"),
144
+ };
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ return {
148
+ exists: true,
149
+ error: `Unable to read '${safePath.resolvedPath}': ${message}`,
150
+ };
151
+ }
152
+ }
153
+
154
+ function countSubstringMatches(haystack: string, needle: string): number {
155
+ if (!needle) {
156
+ return 0;
157
+ }
158
+
159
+ let count = 0;
160
+ let cursor = 0;
161
+ while (cursor <= haystack.length) {
162
+ const index = haystack.indexOf(needle, cursor);
163
+ if (index === -1) {
164
+ break;
165
+ }
166
+ count++;
167
+ cursor = index + 1;
168
+ }
169
+ return count;
170
+ }
171
+
172
+ function stripBom(content: string): { bom: string; text: string } {
173
+ return content.startsWith("\uFEFF")
174
+ ? { bom: "\uFEFF", text: content.slice(1) }
175
+ : { bom: "", text: content };
176
+ }
177
+
178
+ function detectLineEnding(content: string): "\r\n" | "\n" {
179
+ const crlfIndex = content.indexOf("\r\n");
180
+ const lfIndex = content.indexOf("\n");
181
+ if (lfIndex === -1 || crlfIndex === -1) {
182
+ return "\n";
183
+ }
184
+ return crlfIndex < lfIndex ? "\r\n" : "\n";
185
+ }
186
+
187
+ function normalizeToLf(content: string): string {
188
+ return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
189
+ }
190
+
191
+ function restoreLineEndings(content: string, ending: "\r\n" | "\n"): string {
192
+ return ending === "\r\n" ? content.replace(/\n/g, "\r\n") : content;
193
+ }
194
+
195
+ function toEditInput(value: unknown): EditPreviewInput {
196
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
197
+ return {};
198
+ }
199
+
200
+ return value as EditPreviewInput;
201
+ }
202
+
203
+ function getToolPath(input: unknown, preferFilePath: boolean): string | undefined {
204
+ const record = toEditInput(input);
205
+ const filePath = trimPath(record.file_path);
206
+ const path = trimPath(record.path);
207
+ return preferFilePath ? filePath ?? path : path ?? filePath;
208
+ }
209
+
210
+ function getWriteContent(input: unknown): string | undefined {
211
+ const record = toEditInput(input);
212
+ return typeof record.newText === "string"
213
+ ? undefined
214
+ : typeof (record as { content?: unknown }).content === "string"
215
+ ? (record as { content: string }).content
216
+ : undefined;
217
+ }
218
+
219
+ function getEditReplacements(input: unknown): EditReplacement[] {
220
+ const record = toEditInput(input);
221
+ if (Array.isArray(record.edits)) {
222
+ return record.edits.flatMap((entry) => {
223
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
224
+ return [];
225
+ }
226
+ const edit = entry as { oldText?: unknown; newText?: unknown };
227
+ return typeof edit.oldText === "string" && typeof edit.newText === "string"
228
+ ? [{ oldText: edit.oldText, newText: edit.newText }]
229
+ : [];
230
+ });
231
+ }
232
+
233
+ return typeof record.oldText === "string" && typeof record.newText === "string"
234
+ ? [{ oldText: record.oldText, newText: record.newText }]
235
+ : [];
236
+ }
237
+
238
+ function buildProjectedEditContent(originalContent: string, replacements: readonly EditReplacement[]): ProjectedEditResult {
239
+ if (replacements.length === 0) {
240
+ return {
241
+ ok: false,
242
+ reason: "Preview not shown: the edit request did not include exact replacement blocks.",
243
+ };
244
+ }
245
+
246
+ const { bom, text } = stripBom(originalContent);
247
+ const originalLineEnding = detectLineEnding(text);
248
+ const normalizedContent = normalizeToLf(text);
249
+ const normalizedReplacements = replacements.map((replacement) => ({
250
+ oldText: normalizeToLf(replacement.oldText),
251
+ newText: normalizeToLf(replacement.newText),
252
+ }));
253
+
254
+ const ranges: Array<{ start: number; end: number; replacement: string }> = [];
255
+ for (const [index, replacement] of normalizedReplacements.entries()) {
256
+ if (!replacement.oldText) {
257
+ return {
258
+ ok: false,
259
+ reason: `Preview not shown: edit #${index + 1} has an empty oldText block.`,
260
+ };
261
+ }
262
+
263
+ const matchCount = countSubstringMatches(normalizedContent, replacement.oldText);
264
+ if (matchCount !== 1) {
265
+ return {
266
+ ok: false,
267
+ reason: matchCount === 0
268
+ ? `Preview not shown: edit #${index + 1} did not match the current file contents.`
269
+ : `Preview not shown: edit #${index + 1} matched ${matchCount} regions instead of exactly one.`,
270
+ };
271
+ }
272
+
273
+ const start = normalizedContent.indexOf(replacement.oldText);
274
+ ranges.push({
275
+ start,
276
+ end: start + replacement.oldText.length,
277
+ replacement: replacement.newText,
278
+ });
279
+ }
280
+
281
+ ranges.sort((left, right) => left.start - right.start);
282
+ for (let index = 1; index < ranges.length; index++) {
283
+ const previous = ranges[index - 1];
284
+ const current = ranges[index];
285
+ if (previous && current && current.start < previous.end) {
286
+ return {
287
+ ok: false,
288
+ reason: "Preview not shown: the requested edits overlap in the original file.",
289
+ };
290
+ }
291
+ }
292
+
293
+ let cursor = 0;
294
+ let output = "";
295
+ for (const range of ranges) {
296
+ output += normalizedContent.slice(cursor, range.start);
297
+ output += range.replacement;
298
+ cursor = range.end;
299
+ }
300
+ output += normalizedContent.slice(cursor);
301
+
302
+ return {
303
+ ok: true,
304
+ content: `${bom}${restoreLineEndings(output, originalLineEnding)}`,
305
+ };
306
+ }
307
+
308
+ export function buildPendingWritePreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
309
+ const filePath = getToolPath(input, false);
310
+ const nextContent = getWriteContent(input);
311
+ if (!filePath || typeof nextContent !== "string") {
312
+ return undefined;
313
+ }
314
+
315
+ const existing = readWorkspaceUtf8File(cwd, filePath);
316
+ return {
317
+ filePath,
318
+ previousContent: existing.content,
319
+ nextContent,
320
+ fileExistedBeforeWrite: existing.exists,
321
+ headerLabel: existing.exists ? "pending overwrite" : "pending create",
322
+ notice: existing.error,
323
+ };
324
+ }
325
+
326
+ export function buildPendingEditPreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
327
+ const filePath = getToolPath(input, true);
328
+ if (!filePath) {
329
+ return undefined;
330
+ }
331
+
332
+ const existing = readWorkspaceUtf8File(cwd, filePath);
333
+ if (existing.error) {
334
+ return {
335
+ filePath,
336
+ fileExistedBeforeWrite: false,
337
+ headerLabel: "pending edit",
338
+ notice: existing.error,
339
+ };
340
+ }
341
+
342
+ if (!existing.exists || typeof existing.content !== "string") {
343
+ return {
344
+ filePath,
345
+ fileExistedBeforeWrite: false,
346
+ headerLabel: "pending edit",
347
+ notice: "Preview unavailable because the target file does not exist yet.",
348
+ };
349
+ }
350
+
351
+ const projected = buildProjectedEditContent(existing.content, getEditReplacements(input));
352
+ if (!projected.ok) {
353
+ const failedProjection = projected as Extract<ProjectedEditResult, { ok: false }>;
354
+ return {
355
+ filePath,
356
+ previousContent: existing.content,
357
+ fileExistedBeforeWrite: true,
358
+ headerLabel: "pending edit",
359
+ notice: failedProjection.reason,
360
+ };
361
+ }
362
+
363
+ return {
364
+ filePath,
365
+ previousContent: existing.content,
366
+ nextContent: projected.content,
367
+ fileExistedBeforeWrite: true,
368
+ headerLabel: "pending edit",
369
+ };
370
+ }