From 3998d950883185a86ffe0b7299c00986d8880f37 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 19 Jun 2026 04:48:31 +0200 Subject: [PATCH] feat: standardized tab expansion to fixed width - Removed configurable tab width support and the `display.tabWidth` setting across all packages. - Deleted obsolete utility functions `getIndentation`, `getIndentationNoescape`, and `setDefaultTabWidth`. - Standardized tab expansion logic to use a fixed `DEFAULT_TAB_WIDTH` globally. - Cleaned up related configuration schemas, test suites, and internal API signatures to remove path-dependency. --- packages/coding-agent/CHANGELOG.md | 5 +- .../src/config/settings-schema.ts | 5 - packages/coding-agent/src/config/settings.ts | 6 -- .../src/edit/file-snapshot-store.ts | 2 +- packages/coding-agent/src/edit/renderer.ts | 14 +-- .../coding-agent/src/modes/components/diff.ts | 47 +++------ .../coding-agent/test/config-spacing.test.ts | 59 ----------- .../test/helpers/settings-test-state.ts | 12 +-- packages/tui/CHANGELOG.md | 11 +++ packages/tui/src/utils.ts | 31 +++--- packages/tui/test/editor.test.ts | 13 +-- packages/tui/test/input.test.ts | 6 +- packages/tui/test/visible-width.test.ts | 4 +- packages/utils/CHANGELOG.md | 5 +- packages/utils/src/tab-spacing.ts | 77 +-------------- packages/utils/test/spacing.test.ts | 99 ++----------------- scripts/ci-test-ts.ts | 1 - 17 files changed, 73 insertions(+), 324 deletions(-) delete mode 100644 packages/coding-agent/test/config-spacing.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 191593926..42e040444 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Added - Added `__advisor.jsonl` transcript persistence for advisor model usage attribution and visibility in the Agent Hub @@ -22,6 +21,10 @@ - Refined `/compact` argument parsing to reject focus instructions for modes that do not support them (e.g., `snapcompact`) - Protocol hosts (RPC/`rpc-ui`/ACP) now host-default the full advisor settings group — `advisor.syncBacklog` and `advisor.immuneTurns` in addition to `advisor.enabled`/`advisor.subagents` — so a host that opts the advisor in gets the default tuning instead of inheriting the user's local advisor preferences. +### Removed + +- Removed `display.tabWidth` setting and configurable tab width support + ### Fixed - Fixed memory-leaking stale transcripts in the agent viewer when underlying files are deleted diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index b6989ab34..aa8482787 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -809,11 +809,6 @@ export const SETTINGS_SCHEMA = { description: "Remove the 1-character horizontal padding from the left and right of the terminal output", }, }, - // Display rendering - "display.tabWidth": { - type: "number", - default: 3, - }, "display.shimmer": { type: "enum", diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 00578c18c..2b89981c4 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -22,7 +22,6 @@ import { isEnoent, logger, procmgr, - setDefaultTabWidth, } from "@oh-my-pi/pi-utils"; import { JSONC, YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; @@ -1103,11 +1102,6 @@ const SETTING_HOOKS: Partial>> = { }); } }, - "display.tabWidth": value => { - if (typeof value === "number") { - setDefaultTabWidth(value); - } - }, "provider.appendOnlyContext": value => { if (typeof value === "string") { appendOnlyModeSignal.fire(value); diff --git a/packages/coding-agent/src/edit/file-snapshot-store.ts b/packages/coding-agent/src/edit/file-snapshot-store.ts index a1a94b0d9..3428cc2bd 100644 --- a/packages/coding-agent/src/edit/file-snapshot-store.ts +++ b/packages/coding-agent/src/edit/file-snapshot-store.ts @@ -102,7 +102,7 @@ const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/; /** * The 1-indexed file lines a hashline-formatted body actually displayed. * Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row - * (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` — + * (a `{ … }` brace pair) contributes only its boundary lines `NN` and `MM` — * the elided interior was never shown, so editing inside it must be rejected. */ export function parseSeenLinesFromHashlineBody(body: string): number[] { diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 1c81de0fa..63bc42663 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -230,7 +230,7 @@ function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined } function formatEditTitlePath(pathValue: string, maxWidth?: number): string { - return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth); + return truncateEditTitlePath(replaceTabs(shortenPath(pathValue)), maxWidth); } function formatEditPathDisplay( @@ -326,11 +326,11 @@ function renderEditHeader( return buildHeader(fitted.description); } -function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string { +function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string): string { const previewLines = sanitizeText(text).split("\n"); let preview = "\n\n"; for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) { - preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`; + preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`; } if (previewLines.length > CALL_TEXT_PREVIEW_LINES) { preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`); @@ -395,7 +395,7 @@ function formatMultiFileStreamingDiff( if (!preview.diff && !preview.error) continue; const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`); if (preview.error) { - parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`); + parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`); continue; } if (preview.diff) { @@ -637,7 +637,7 @@ export const editToolRenderer = { callPreviewCaches, ); if (applyPatchSummary?.error) { - body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`; + body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), Math.max(1, width - 2)))}`; } const bodyLines = body ? body.split("\n") : []; while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift(); @@ -733,11 +733,11 @@ function renderSingleFileResult( let body = ""; if (isError) { - if (errorText) body = uiTheme.fg("error", replaceTabs(errorText, rawPath)); + if (errorText) body = uiTheme.fg("error", replaceTabs(errorText)); } else if (details?.diff) { body = renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } else if (editDiffPreview) { - if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath)); + if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error)); else if (editDiffPreview.diff) body = renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn, diffSectionCache); } diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 33d1c161f..bccbbe874 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,4 +1,4 @@ -import { getIndentation, sanitizeText } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils"; import * as Diff from "diff"; import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme"; import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils"; @@ -13,12 +13,12 @@ const DIM_OFF = "\x1b[22m"; * before the first non-whitespace character; remaining tabs in code * content are replaced with spaces (like replaceTabs). */ -function visualizeIndent(text: string, filePath?: string): string { +function visualizeIndent(text: string): string { const match = text.match(/^([ \t]+)/); - if (!match) return replaceTabs(text, filePath); + if (!match) return replaceTabs(text); const indent = match[1]; const rest = text.slice(indent.length); - const tabWidth = getIndentation(filePath); + const tabWidth = DEFAULT_TAB_WIDTH; const leftPadding = Math.floor(tabWidth / 2); const rightPadding = Math.max(0, tabWidth - leftPadding - 1); const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`; @@ -30,7 +30,7 @@ function visualizeIndent(text: string, filePath?: string): string { visible += `${DIM}·${DIM_OFF}`; } } - return `${visible}${replaceTabs(rest, filePath)}`; + return `${visible}${replaceTabs(rest)}`; } /** @@ -153,7 +153,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s // unicode ellipsis. const trimmed = line.trim(); const isGapRow = trimmed.length === 0 || trimmed === "..." || trimmed === "…"; - result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line, options.filePath))); + result.push(theme.fg("toolDiffContext", isGapRow ? "…" : replaceTabs(line))); i++; continue; } @@ -184,47 +184,24 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s replaceTabs(added.content), ); - result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)), - ), - ); - result.push( - theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))), - ); + result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine)))); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine)))); } else { for (const removed of removedLines) { result.push( - theme.fg( - "toolDiffRemoved", - formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)), - ), + theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))), ); } for (const added of addedLines) { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content)))); } } } else if (parsed.prefix === "+") { - result.push( - theme.fg( - "toolDiffAdded", - formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)), - ), - ); + result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content)))); i++; } else { const highlighted = contextHighlights.get(i); - const content = - highlighted !== undefined - ? replaceTabs(highlighted, options.filePath) - : visualizeIndent(parsed.content, options.filePath); + const content = highlighted !== undefined ? replaceTabs(highlighted) : visualizeIndent(parsed.content); result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, content))); i++; } diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts deleted file mode 100644 index c944fa816..000000000 --- a/packages/coding-agent/test/config-spacing.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; -import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state"; - -describe("indentation resolver", () => { - let settingsState: SettingsTestState | undefined; - let tempDir = ""; - - beforeEach(async () => { - settingsState = beginSettingsTest(); - setDefaultTabWidth(3); - tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - }); - - afterEach(async () => { - restoreSettingsTestState(settingsState); - settingsState = undefined; - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - } - tempDir = ""; - }); - - it("applies current display tab width during initial settings load", async () => { - await Settings.init({ inMemory: true, cwd: tempDir, overrides: { "display.tabWidth": 7 } }); - expect(getDefaultTabWidth()).toBe(7); - expect(getIndentation()).toBe(7); - }); - - it("applies nearest editorconfig rules for the target file", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, "\tconst x = 1;\n"); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(getIndentation(filePath)).toBe(2); - expect(getIndentation(path.join(tempDir, "README.md"))).toBe(4); - }); - - it("merges editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - await fs.writeFile(filePath, "\tconst y = 2;\n"); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(getIndentation(filePath)).toBe(6); - }); -}); diff --git a/packages/coding-agent/test/helpers/settings-test-state.ts b/packages/coding-agent/test/helpers/settings-test-state.ts index e4825c4c7..44928c099 100644 --- a/packages/coding-agent/test/helpers/settings-test-state.ts +++ b/packages/coding-agent/test/helpers/settings-test-state.ts @@ -1,20 +1,12 @@ import { vi } from "bun:test"; import { resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; import { isTuiTight, setTuiTight } from "@oh-my-pi/pi-tui"; -import { - getAgentDir, - getDefaultTabWidth, - getProjectDir, - setAgentDir, - setDefaultTabWidth, - setProjectDir, -} from "@oh-my-pi/pi-utils"; +import { getAgentDir, getProjectDir, setAgentDir, setProjectDir } from "@oh-my-pi/pi-utils"; export interface SettingsTestState { agentDir: string; env: Record; projectDir: string; - tabWidth: number; tuiTight: boolean; } @@ -30,7 +22,6 @@ export function beginSettingsTest(): SettingsTestState { agentDir: getAgentDir(), env, projectDir: getProjectDir(), - tabWidth: getDefaultTabWidth(), tuiTight: isTuiTight(), }; resetSettingsForTest(); @@ -43,7 +34,6 @@ export function restoreSettingsTestState(state: SettingsTestState | undefined): if (!state) return; restoreEnv(state.env); - setDefaultTabWidth(state.tabWidth); setProjectDir(state.projectDir); setAgentDir(state.agentDir); setTuiTight(state.tuiTight); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e9eecfc84..a84d40d4e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] + +### Breaking Changes + +- Removed `getIndentation` and `getIndentationNoescape` exported utilities +- Tab-related operations no longer respect per-file or globally configured indentation settings + +### Changed + +- Standardized tab expansion to use a fixed display width instead of configurable settings +- Removed support for custom tab width configuration in text rendering and input handling + ### Fixed - Corrected logic in string truncation to prevent improper truncation of short strings diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index afef4356c..3ff0d8a1f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -7,11 +7,11 @@ import { wrapTextWithAnsi as nativeWrapTextWithAnsi, type SliceResult, } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export { Ellipsis } from "@oh-my-pi/pi-natives"; -export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; +export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; export type TextSizingScale = 1 | 2 | 3; export type TextSizingVerticalAlign = "top" | "bottom" | "center"; @@ -74,7 +74,7 @@ export function encodeTextSized(text: string, options: TextSizingOptions = {}): } export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult { - return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth()); + return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH); } export function truncateToWidth( @@ -94,12 +94,12 @@ export function truncateToWidth( maxWidth, (ellipsisKind === "" ? Ellipsis.Omit : ellipsisKind) ?? Ellipsis.Unicode, pad ?? false, - getDefaultTabWidth(), + DEFAULT_TAB_WIDTH, ); } export function wrapTextWithAnsi(text: string, width: number): string[] { - return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth()); + return nativeWrapTextWithAnsi(text, width, DEFAULT_TAB_WIDTH); } export function extractSegments( @@ -109,24 +109,17 @@ export function extractSegments( afterLen: number, strictAfter: boolean, ): ExtractSegmentsResult { - return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth()); + return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, DEFAULT_TAB_WIDTH); } // Pre-allocated space buffer for padding const SPACE_BUFFER = " ".repeat(512); -/** - * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths. - */ -export function getIndentationNoescape(file?: string): number { - return getIndentation(file, process.cwd()); -} - /* - * Replace tabs with configured spacing for consistent rendering. + * Replace tabs with the fixed display tab width for consistent rendering. */ -export function replaceTabs(text: string, file?: string): string { - return text.replaceAll("\t", " ".repeat(getIndentation(file))); +export function replaceTabs(text: string): string { + return text.replaceAll("\t", " ".repeat(DEFAULT_TAB_WIDTH)); } /** @@ -183,7 +176,7 @@ export function visibleWidth(str: string): number { for (let tabIndex = str.indexOf(TAB); tabIndex !== -1; tabIndex = str.indexOf(TAB, tabIndex + 1)) { tabCount++; } - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; return width; } @@ -200,7 +193,7 @@ export function visibleWidth(str: string): number { } } if (i === str.length) { - return tabCount === 0 ? str.length : str.length + tabCount * (getDefaultTabWidth() - 1); + return tabCount === 0 ? str.length : str.length + tabCount * (DEFAULT_TAB_WIDTH - 1); } if (tabCount === 0) { @@ -221,7 +214,7 @@ export function visibleWidth(str: string): number { // the native scanner that traps under Bun 1.3.x GC/N-API load). It strips // CSI/OSC to zero cells and shares the native engine's UAX#11 width tables. let width = Bun.stringWidth(str, STRING_WIDTH_OPTS); - if (tabCount > 0) width += tabCount * getDefaultTabWidth(); + if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH; // OSC 66: add back each stripped span as `scale * (explicit w ?? payload // width)`. Matched rather than replaced to avoid reallocating the string. diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index bbc3c4468..6ab2b78ca 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -6,7 +6,6 @@ import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { defaultEditorTheme } from "./test-themes"; describe("Editor component", () => { @@ -545,16 +544,10 @@ describe("Editor component", () => { expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß"); }); - it("uses the configured tab width when loading text programmatically", () => { + it("expands tabs to the fixed display width when loading text programmatically", () => { const editor = new Editor(defaultEditorTheme); - - try { - setDefaultTabWidth(5); - editor.setText("foo\tbar"); - expect(editor.getText()).toBe("foo bar"); - } finally { - setDefaultTabWidth(3); - } + editor.setText("foo\tbar"); + expect(editor.getText()).toBe("foo bar"); }); it("strips control characters from programmatically loaded text before render", () => { diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index 39cf31b60..9311cab60 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -3,7 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; import { Input } from "@oh-my-pi/pi-tui/components/input"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; -import { getIndentation } from "@oh-my-pi/pi-utils"; +import { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils"; function renderedWidth(input: Input, width: number): number { const [line] = input.render(width); @@ -155,7 +155,7 @@ describe("Input component", () => { setKittyProtocolActive(false); }); - it("normalizes tabs in buffered bracketed paste using configured indentation", () => { + it("normalizes tabs in buffered bracketed paste using the fixed display width", () => { const input = setupAtEnd(""); input.handleInput("\x1b[200~a\t"); @@ -165,7 +165,7 @@ describe("Input component", () => { expect(input.getValue()).toBe(""); input.handleInput("c\x1b[201~"); - expect(input.getValue()).toBe(`a${" ".repeat(getIndentation())}bc`); + expect(input.getValue()).toBe(`a${" ".repeat(DEFAULT_TAB_WIDTH)}bc`); }); it("decodes tmux re-encoded control bytes in bracketed paste without leaking tails or storing raw C0", () => { diff --git a/packages/tui/test/visible-width.test.ts b/packages/tui/test/visible-width.test.ts index d1bfd8faa..98afd6a67 100644 --- a/packages/tui/test/visible-width.test.ts +++ b/packages/tui/test/visible-width.test.ts @@ -12,12 +12,12 @@ */ import { describe, expect, it } from "bun:test"; import { visibleWidth as nativeVisibleWidth } from "@oh-my-pi/pi-natives"; -import { getDefaultTabWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { DEFAULT_TAB_WIDTH, visibleWidth } from "@oh-my-pi/pi-tui/utils"; const ESC = "\x1b"; const ST = "\x1b\\"; const BEL = "\x07"; -const TAB = getDefaultTabWidth(); +const TAB = DEFAULT_TAB_WIDTH; describe("visibleWidth — parity with the native width engine", () => { const corpus: [string, string][] = [ diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index d4465c334..eb74ceb8c 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Removed + +- Removed `getIndentation`, `setDefaultTabWidth`, and `getDefaultTabWidth` helpers ## [16.0.8] - 2026-06-18 @@ -160,4 +163,4 @@ ### Added -- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models. +- Added an XDG-aware tiny-title model cache directory helper for coding-agent local title models. \ No newline at end of file diff --git a/packages/utils/src/tab-spacing.ts b/packages/utils/src/tab-spacing.ts index ac1522123..31f680144 100644 --- a/packages/utils/src/tab-spacing.ts +++ b/packages/utils/src/tab-spacing.ts @@ -20,11 +20,8 @@ const NAME_MAX_BYTES = 255; const EDITORCONFIG_NAME = ".editorconfig"; -let defaultTabWidth = DEFAULT_TAB_WIDTH; - const editorConfigCache = new Map(); const editorConfigChainCache = new Map(); -const indentationCache = new Map(); interface EditorConfigSection { pattern: string; @@ -266,31 +263,6 @@ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | und return match; } -function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined { - if (match === undefined) return undefined; - - if (match.indentSize?.kind === "spaces") { - return match.indentSize.n; - } - - if (match.indentSize?.kind === "tab") { - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - return fallback; - } - - if (match.tabWidth !== undefined) { - return match.tabWidth; - } - - if (match.indentStyle === IndentStyle.Tab) { - return fallback; - } - - return undefined; -} - function hasOverlongPathComponent(filePath: string): boolean { for (const part of filePath.split(/[\\/]/)) { if (part.length > 0 && Buffer.byteLength(part) > NAME_MAX_BYTES) { @@ -300,53 +272,12 @@ function hasOverlongPathComponent(filePath: string): boolean { return false; } -export function getDefaultTabWidth(): number { - return defaultTabWidth; -} - -export function setDefaultTabWidth(width: number): void { - defaultTabWidth = clampTabWidth(width); -} - -/** - * Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted. - */ -export function getIndentation(file?: string | null, projectDir?: string | null): number { - const fallback = defaultTabWidth; - if (file === undefined || file === null || file === "") { - return fallback; - } - - const cwd = projectDir ?? process.cwd(); - const absoluteFile = resolveFilePath(cwd, file); - - // Renderers can hand us arbitrary strings (e.g. a malformed edit tool - // call whose `file_path` is gibberish). Reject paths whose normalized - // absolute form still has any component longer than `NAME_MAX_BYTES` — - // the editorconfig chain would only trip `ENAMETOOLONG` from - // `readFileSync` and escape. - if (hasOverlongPathComponent(absoluteFile)) { - return fallback; - } - const absKey = absoluteFile; - const cached = indentationCache.get(absKey); - if (cached !== undefined) { - return cached; - } - - const editorMatch = resolveEditorConfigMatch(absoluteFile); - const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback; - const clamped = clampTabWidth(resolved); - indentationCache.set(absKey, clamped); - return clamped; -} - /** * `.editorconfig`-derived formatting options for an LSP `textDocument/formatting` request. * * Both fields are absent when the resolved `.editorconfig` chain does not pin them, so callers * can layer their own fallbacks (content sniffing, project defaults) underneath. Returned values - * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH} for parity with {@link getIndentation}. + * are clamped to {@link MIN_TAB_WIDTH}..{@link MAX_TAB_WIDTH}. */ export interface EditorConfigFormatting { /** Effective indent width in columns, from `indent_size` or `tab_width`. */ @@ -359,8 +290,8 @@ export interface EditorConfigFormatting { * Resolve `.editorconfig` formatting hints for `file` without falling back to any default. * * Used by the LSP format-on-write path so a missing `.editorconfig` declaration falls through - * to caller-provided defaults instead of clobbering the file with the renderer's display - * `defaultTabWidth` (issue #2329). + * to caller-provided defaults instead of clobbering the file with the renderer's + * display tab width (issue #2329). */ export function getEditorConfigFormatting(file?: string | null, projectDir?: string | null): EditorConfigFormatting { if (file === undefined || file === null || file === "") { @@ -370,7 +301,7 @@ export function getEditorConfigFormatting(file?: string | null, projectDir?: str const cwd = projectDir ?? process.cwd(); const absoluteFile = resolveFilePath(cwd, file); - // Same NAME_MAX guard as `getIndentation`: editorconfig discovery is + // NAME_MAX guard: editorconfig discovery is // best-effort and must never escape as `ENAMETOOLONG` from a renderer's // stray gibberish path. if (hasOverlongPathComponent(absoluteFile)) { diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts index 45726c80e..077e8d522 100644 --- a/packages/utils/test/spacing.test.ts +++ b/packages/utils/test/spacing.test.ts @@ -4,96 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils/dirs"; import { Snowflake } from "@oh-my-pi/pi-utils/snowflake"; -import { getEditorConfigFormatting, getIndentation, setDefaultTabWidth } from "@oh-my-pi/pi-utils/tab-spacing"; - -describe("spacing", () => { - let tempDir = ""; - let previousProjectDir = ""; - - beforeEach(async () => { - previousProjectDir = getProjectDir(); - tempDir = path.join(os.tmpdir(), "pi-utils-spacing", Snowflake.next()); - await fs.mkdir(tempDir, { recursive: true }); - setProjectDir(tempDir); - setDefaultTabWidth(3); - }); - - afterEach(async () => { - setDefaultTabWidth(3); - setProjectDir(previousProjectDir); - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - it("resolves editorconfig rules for file path and falls back to default", async () => { - const filePath = path.join(tempDir, "src", "feature.ts"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - path.join(tempDir, ".editorconfig"), - ["root = true", "", "[*]", "indent_size = 2", "", "[*.md]", "indent_size = 4"].join("\n"), - ); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "README.md")))).toBe(" "); - expect(" ".repeat(getIndentation(path.join(tempDir, "missing.txt")))).toBe(" "); - }); - - it("merges nested editorconfig files from root to leaf", async () => { - const nestedDir = path.join(tempDir, "packages", "feature"); - const filePath = path.join(nestedDir, "index.ts"); - await fs.mkdir(nestedDir, { recursive: true }); - - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - await fs.writeFile(path.join(tempDir, "packages", ".editorconfig"), ["[*.ts]", "indent_size = 6"].join("\n")); - - expect(" ".repeat(getIndentation(filePath))).toBe(" "); - }); - - it("does not throw when the path's segment exceeds NAME_MAX (#1871)", () => { - // A garbage path segment (e.g. 2KiB of garbage Unicode produced by a - // hallucinating model) makes `fs.readFileSync` reject with - // ENAMETOOLONG. The editorconfig probe MUST swallow it and fall back to - // the default tab width — anything else crashes the TUI mid-render. - const huge = "a".repeat(2048); - const phonyPath = path.join(tempDir, huge, "leaf.ts"); - expect(() => getIndentation(phonyPath)).not.toThrow(); - expect(getIndentation(phonyPath)).toBe(3); - }); - - it("returns the default tab width for paths with an overlong component (no syscall)", () => { - // Repro of #1872: a malformed edit tool call lands a long gibberish - // string in `file_path`, the renderer routes it through `replaceTabs -> - // getIndentation`, and `readFileSync` of `/.editorconfig` would - // throw `ENAMETOOLONG`. The path gate must short-circuit before any - // syscall so renderers never see the exception. - const longSegment = "amálpthgadasJennzier".repeat(40); - const overlong = `${longSegment}/inner.ts`; - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(() => getIndentation(overlong)).not.toThrow(); - expect(getIndentation(overlong)).toBe(3); - }); - - it("normalizes paths before rejecting overlong components", async () => { - const longSegment = "amálpthgadasJennzier".repeat(40); - const noisyPath = path.join(tempDir, longSegment, "..", "src", "feature.ts"); - await fs.writeFile(path.join(tempDir, ".editorconfig"), ["root = true", "", "[*]", "indent_size = 2"].join("\n")); - - expect(Buffer.byteLength(longSegment)).toBeGreaterThan(255); - expect(" ".repeat(getIndentation(noisyPath))).toBe(" "); - }); - - it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { - // Defense in depth: when a non-directory sits where a directory is - // expected, `parseCachedEditorConfig` previously caught only `ENOENT` - // and let `ENOTDIR` (and `ENAMETOOLONG`, `EACCES`, `ELOOP`, …) - // escape. Editorconfig discovery is best-effort and must absorb any - // `FsError`. - const notADir = path.join(tempDir, "not-a-dir"); - await fs.writeFile(notADir, ""); - const fakeChild = path.join(notADir, "inner.ts"); - expect(() => getIndentation(fakeChild)).not.toThrow(); - expect(getIndentation(fakeChild)).toBe(3); - }); -}); +import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils/tab-spacing"; describe("getEditorConfigFormatting", () => { let tempDir = ""; @@ -176,4 +87,12 @@ describe("getEditorConfigFormatting", () => { expect(() => getEditorConfigFormatting(overlong)).not.toThrow(); expect(getEditorConfigFormatting(overlong)).toEqual({}); }); + + it("tolerates filesystem errors while walking the editorconfig chain (ENOTDIR)", async () => { + const notADir = path.join(tempDir, "not-a-dir"); + await fs.writeFile(notADir, ""); + const fakeChild = path.join(notADir, "inner.ts"); + expect(() => getEditorConfigFormatting(fakeChild)).not.toThrow(); + expect(getEditorConfigFormatting(fakeChild)).toEqual({}); + }); }); diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index 5aecdf6da..f81c7a5c4 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -136,7 +136,6 @@ const codingAgentSingletonContentMarkers = [ "Settings.instance", "resetSettingsForTest", "setAgentDir(", - "setDefaultTabWidth(", "vi.useFakeTimers(", "vi.useRealTimers(", "vi.stubEnv(",