diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index ca36c0cd7..f33339f60 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -16,9 +16,26 @@ use smallvec::{SmallVec, smallvec}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -const TAB_WIDTH: usize = 3; +const DEFAULT_TAB_WIDTH: usize = 3; +const MIN_TAB_WIDTH: usize = 1; +const MAX_TAB_WIDTH: usize = 16; const ESC: u16 = 0x1b; +#[inline] +const fn clamp_tab_width(tab_width: Option) -> usize { + let width = match tab_width { + Some(tab_width) => tab_width as usize, + None => DEFAULT_TAB_WIDTH, + }; + if width < MIN_TAB_WIDTH { + MIN_TAB_WIDTH + } else if width > MAX_TAB_WIDTH { + MAX_TAB_WIDTH + } else { + width + } +} + fn build_utf16_string(mut data: Vec) -> Utf16String { while data.last() == Some(&0) { data.pop(); @@ -350,19 +367,19 @@ fn is_sgr_u16(seq: &[u16]) -> bool { // ============================================================================ #[inline] -const fn ascii_cell_width_u16(u: u16) -> usize { +const fn ascii_cell_width_u16(u: u16, tab_width: usize) -> usize { let b = u as u8; match b { - b'\t' => TAB_WIDTH, + b'\t' => tab_width, 0x20..=0x7e => 1, _ => 0, } } #[inline] -fn grapheme_width_str(g: &str) -> usize { +fn grapheme_width_str(g: &str, tab_width: usize) -> usize { if g == "\t" { - return TAB_WIDTH; + return tab_width; } let mut it = g.chars(); let Some(c0) = it.next() else { @@ -382,7 +399,7 @@ thread_local! { /// /// Callback returns `true` to continue, `false` to stop early. #[inline] -fn for_each_grapheme_u16_slow(segment: &[u16], mut f: F) -> bool +fn for_each_grapheme_u16_slow(segment: &[u16], tab_width: usize, mut f: F) -> bool where F: FnMut(&[u16], usize) -> bool, { @@ -400,7 +417,7 @@ where let mut utf16_pos = 0usize; for g in scratch.graphemes(true) { - let w = grapheme_width_str(g); + let w = grapheme_width_str(g, tab_width); let g_u16_len: usize = g.chars().map(|c| c.len_utf16()).sum(); let u16_slice = &segment[utf16_pos..utf16_pos + g_u16_len]; @@ -416,7 +433,7 @@ where } /// Visible width, with early-exit if width exceeds `limit`. -fn visible_width_u16_up_to(data: &[u16], limit: usize) -> (usize, bool) { +fn visible_width_u16_up_to(data: &[u16], limit: usize, tab_width: usize) -> (usize, bool) { let mut width = 0usize; let mut i = 0usize; let len = data.len(); @@ -443,13 +460,13 @@ fn visible_width_u16_up_to(data: &[u16], limit: usize) -> (usize, bool) { if is_ascii { for &u in seg { - width += ascii_cell_width_u16(u); + width += ascii_cell_width_u16(u, tab_width); if width > limit { return (width, true); } } } else { - let ok = for_each_grapheme_u16_slow(seg, |_, w| { + let ok = for_each_grapheme_u16_slow(seg, tab_width, |_, w| { width += w; width <= limit }); @@ -462,8 +479,8 @@ fn visible_width_u16_up_to(data: &[u16], limit: usize) -> (usize, bool) { (width, width > limit) } -fn visible_width_u16(data: &[u16]) -> usize { - visible_width_u16_up_to(data, usize::MAX).0 +fn visible_width_u16(data: &[u16], tab_width: usize) -> usize { + visible_width_u16_up_to(data, usize::MAX, tab_width).0 } // ============================================================================ @@ -572,7 +589,12 @@ fn split_into_tokens_with_ansi(line: &[u16]) -> SmallVec<[Vec; 4]> { tokens } -fn break_long_word(word: &[u16], width: usize, state: &mut AnsiState) -> SmallVec<[Vec; 4]> { +fn break_long_word( + word: &[u16], + width: usize, + tab_width: usize, + state: &mut AnsiState, +) -> SmallVec<[Vec; 4]> { let mut lines = SmallVec::<[Vec; 4]>::new(); let mut current_line = Vec::::new(); write_active_codes(state, &mut current_line); @@ -604,7 +626,7 @@ fn break_long_word(word: &[u16], width: usize, state: &mut AnsiState) -> SmallVe if is_ascii { for &u in seg { - let gw = ascii_cell_width_u16(u); + let gw = ascii_cell_width_u16(u, tab_width); if current_width + gw > width { write_line_end_reset(state, &mut current_line); lines.push(current_line); @@ -616,7 +638,7 @@ fn break_long_word(word: &[u16], width: usize, state: &mut AnsiState) -> SmallVe current_width += gw; } } else { - let _ = for_each_grapheme_u16_slow(seg, |gu16, gw| { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { if current_width + gw > width { write_line_end_reset(state, &mut current_line); lines.push(std::mem::take(&mut current_line)); @@ -637,12 +659,12 @@ fn break_long_word(word: &[u16], width: usize, state: &mut AnsiState) -> SmallVe lines } -fn wrap_single_line(line: &[u16], width: usize) -> SmallVec<[Vec; 4]> { +fn wrap_single_line(line: &[u16], width: usize, tab_width: usize) -> SmallVec<[Vec; 4]> { if line.is_empty() { return smallvec![Vec::new()]; } - if visible_width_u16(line) <= width { + if visible_width_u16(line, tab_width) <= width { return smallvec![line.to_vec()]; } @@ -653,7 +675,7 @@ fn wrap_single_line(line: &[u16], width: usize) -> SmallVec<[Vec; 4]> { let mut state = AnsiState::new(); for token in tokens { - let token_width = visible_width_u16(&token); + let token_width = visible_width_u16(&token, tab_width); let is_whitespace = token_is_whitespace(&token); if token_width > width && !is_whitespace { @@ -664,11 +686,11 @@ fn wrap_single_line(line: &[u16], width: usize) -> SmallVec<[Vec; 4]> { current_width = 0; } - let mut broken = break_long_word(&token, width, &mut state); + let mut broken = break_long_word(&token, width, tab_width, &mut state); if let Some(last) = broken.pop() { wrapped.extend(broken); current_line = last; - current_width = visible_width_u16(¤t_line); + current_width = visible_width_u16(¤t_line, tab_width); } continue; } @@ -711,7 +733,11 @@ fn wrap_single_line(line: &[u16], width: usize) -> SmallVec<[Vec; 4]> { wrapped } -fn wrap_text_with_ansi_impl(text: &[u16], width: usize) -> SmallVec<[Vec; 4]> { +fn wrap_text_with_ansi_impl( + text: &[u16], + width: usize, + tab_width: usize, +) -> SmallVec<[Vec; 4]> { if text.is_empty() { return smallvec![Vec::new()]; } @@ -729,7 +755,7 @@ fn wrap_text_with_ansi_impl(text: &[u16], width: usize) -> SmallVec<[Vec; 4 } line_with_prefix.extend_from_slice(line); - let wrapped = wrap_single_line(&line_with_prefix, width); + let wrapped = wrap_single_line(&line_with_prefix, width, tab_width); result.extend(wrapped); update_state_from_text(line, &mut state); line_start = i + 1; @@ -748,9 +774,14 @@ fn wrap_text_with_ansi_impl(text: &[u16], width: usize) -> SmallVec<[Vec; 4 /// /// Returns UTF-16 lines with active SGR codes carried across line boundaries. #[napi(js_name = "wrapTextWithAnsi")] -pub fn wrap_text_with_ansi(text: JsString, width: u32) -> Result> { +pub fn wrap_text_with_ansi( + text: JsString, + width: u32, + tab_width: Option, +) -> Result> { let text_u16 = text.into_utf16()?; - let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize); + let tab_width = clamp_tab_width(tab_width); + let lines = wrap_text_with_ansi_impl(text_u16.as_slice(), width as usize, tab_width); Ok(lines.into_iter().map(build_utf16_string).collect()) } @@ -768,8 +799,10 @@ pub fn truncate_to_width( max_width: u32, ellipsis_kind: u8, pad: bool, + tab_width: Option, ) -> Result, Utf16String>> { let max_width = max_width as usize; + let tab_width = clamp_tab_width(tab_width); // Keep original handle so we can return it without allocating. let original = text; @@ -778,7 +811,7 @@ pub fn truncate_to_width( let text = text_u16.as_slice(); // Fast path: early-exit width check - let (text_w, exceeded) = visible_width_u16_up_to(text, max_width); + let (text_w, exceeded) = visible_width_u16_up_to(text, max_width, tab_width); if !exceeded { if !pad { // Return original JsString handle: zero output allocation. @@ -814,7 +847,7 @@ pub fn truncate_to_width( if target_w == 0 { let mut out = Vec::with_capacity(ellipsis.len().min(max_width * 2)); let mut w = 0usize; - let _ = for_each_grapheme_u16_slow(ellipsis, |gu16, gw| { + let _ = for_each_grapheme_u16_slow(ellipsis, tab_width, |gu16, gw| { if w + gw > max_width { return false; } @@ -865,7 +898,7 @@ pub fn truncate_to_width( if is_ascii { for &u in seg { - let gw = ascii_cell_width_u16(u); + let gw = ascii_cell_width_u16(u, tab_width); if w + gw > target_w { break; } @@ -876,7 +909,7 @@ pub fn truncate_to_width( break; } } else { - let keep_going = for_each_grapheme_u16_slow(seg, |gu16, gw| { + let keep_going = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { if w + gw > target_w { return false; } @@ -915,6 +948,7 @@ fn slice_with_width_impl( start_col: usize, length: usize, strict: bool, + tab_width: usize, ) -> (Vec, usize) { let end_col = start_col.saturating_add(length); @@ -961,7 +995,7 @@ fn slice_with_width_impl( if current_col >= end_col { break; } - let gw = ascii_cell_width_u16(u); + let gw = ascii_cell_width_u16(u, tab_width); let in_range = current_col >= start_col; let fits = !strict || current_col + gw <= end_col; @@ -978,7 +1012,7 @@ fn slice_with_width_impl( current_col += gw; } } else { - let _ = for_each_grapheme_u16_slow(seg, |gu16, gw| { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { if current_col >= end_col { return false; } @@ -1028,11 +1062,14 @@ pub fn slice_with_width( start_col: u32, length: u32, strict: bool, + tab_width: Option, ) -> Result { let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); - let (out, w) = slice_with_width_impl(line, start_col as usize, length as usize, strict); + let tab_width = clamp_tab_width(tab_width); + let (out, w) = + slice_with_width_impl(line, start_col as usize, length as usize, strict, tab_width); Ok(SliceResult { text: build_utf16_string(out), width: crate::utils::clamp_u32(w as u64) }) } @@ -1047,6 +1084,7 @@ fn extract_segments_impl( after_start: usize, after_len: usize, strict_after: bool, + tab_width: usize, ) -> (Vec, usize, Vec, usize) { let after_end = after_start.saturating_add(after_len); @@ -1114,7 +1152,7 @@ fn extract_segments_impl( if current_col >= done_col { break; } - let gw = ascii_cell_width_u16(u); + let gw = ascii_cell_width_u16(u, tab_width); if current_col < before_end { if !pending_before_ansi.is_empty() { @@ -1139,7 +1177,7 @@ fn extract_segments_impl( current_col += gw; } } else { - let _ = for_each_grapheme_u16_slow(seg, |gu16, gw| { + let _ = for_each_grapheme_u16_slow(seg, tab_width, |gu16, gw| { if current_col >= done_col { return false; } @@ -1185,16 +1223,19 @@ pub fn extract_segments( after_start: u32, after_len: u32, strict_after: bool, + tab_width: Option, ) -> Result { let line_u16 = line.into_utf16()?; let line = line_u16.as_slice(); + let tab_width = clamp_tab_width(tab_width); let (before, bw, after, aw) = extract_segments_impl( line, before_end as usize, after_start as usize, after_len as usize, strict_after, + tab_width, ); Ok(ExtractSegmentsResult { @@ -1296,9 +1337,10 @@ pub fn sanitize_text(text: JsString<'_>) -> Result, Utf16Str /// /// Tabs count as a fixed-width cell. #[napi(js_name = "visibleWidth")] -pub fn visible_width_napi(text: JsString) -> Result { +pub fn visible_width_napi(text: JsString, tab_width: Option) -> Result { let text_u16 = text.into_utf16()?; - Ok(crate::utils::clamp_u32(visible_width_u16(text_u16.as_slice()) as u64)) + let tab_width = clamp_tab_width(tab_width); + Ok(crate::utils::clamp_u32(visible_width_u16(text_u16.as_slice(), tab_width) as u64)) } #[cfg(test)] @@ -1311,10 +1353,10 @@ mod tests { #[test] fn test_visible_width() { - assert_eq!(visible_width_u16(&to_u16("hello")), 5); - assert_eq!(visible_width_u16(&to_u16("\x1b[31mhello\x1b[0m")), 5); - assert_eq!(visible_width_u16(&to_u16("\x1b[38;5;196mred\x1b[0m")), 3); - assert_eq!(visible_width_u16(&to_u16("a\tb")), 1 + TAB_WIDTH + 1); + assert_eq!(visible_width_u16(&to_u16("hello"), DEFAULT_TAB_WIDTH), 5); + assert_eq!(visible_width_u16(&to_u16("\x1b[31mhello\x1b[0m"), DEFAULT_TAB_WIDTH), 5); + assert_eq!(visible_width_u16(&to_u16("\x1b[38;5;196mred\x1b[0m"), DEFAULT_TAB_WIDTH), 3); + assert_eq!(visible_width_u16(&to_u16("a\tb"), DEFAULT_TAB_WIDTH), 1 + DEFAULT_TAB_WIDTH + 1); } #[test] @@ -1327,7 +1369,7 @@ mod tests { #[test] fn test_slice_basic() { let data = to_u16("hello world"); - let (out, width) = slice_with_width_impl(&data, 0, 5, false); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); assert_eq!(String::from_utf16_lossy(&out), "hello"); assert_eq!(width, 5); } @@ -1335,7 +1377,7 @@ mod tests { #[test] fn test_slice_with_ansi() { let data = to_u16("\x1b[31mhello\x1b[0m world"); - let (out, width) = slice_with_width_impl(&data, 0, 5, false); + let (out, width) = slice_with_width_impl(&data, 0, 5, false, DEFAULT_TAB_WIDTH); assert_eq!(String::from_utf16_lossy(&out), "\x1b[31mhello\x1b[0m"); assert_eq!(width, 5); } @@ -1356,7 +1398,7 @@ mod tests { #[test] fn test_early_exit() { let data = to_u16(&"a]b".repeat(1000)); - let (w, exceeded) = visible_width_u16_up_to(&data, 10); + let (w, exceeded) = visible_width_u16_up_to(&data, 10, DEFAULT_TAB_WIDTH); assert!(exceeded); assert!(w > 10); } @@ -1364,7 +1406,7 @@ mod tests { #[test] fn test_wrap_text_with_ansi_preserves_color() { let data = to_u16("\x1b[38;2;156;163;176mhello world\x1b[0m"); - let lines = wrap_text_with_ansi_impl(&data, 5); + let lines = wrap_text_with_ansi_impl(&data, 5, DEFAULT_TAB_WIDTH); assert_eq!(lines.len(), 2); let first = String::from_utf16_lossy(&lines[0]); let second = String::from_utf16_lossy(&lines[1]); diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 88bd91b07..1177433f1 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -176,6 +176,16 @@ export const SETTINGS_SCHEMA = { description: "Use blue instead of green for diff additions", }, }, + "display.tabWidth": { + type: "number", + default: 3, + ui: { + tab: "display", + label: "Tab width", + description: "Default number of spaces used when rendering tab characters", + submenu: true, + }, + }, defaultThinkingLevel: { type: "enum", values: ["off", "minimal", "low", "medium", "high", "xhigh"] as const, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 41c904eb7..45fd32ef0 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -13,7 +13,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils"; +import { isEnoent, logger, procmgr, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { getAgentDbPath, getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs"; import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; @@ -438,6 +438,7 @@ export class Settings { // Build merged view this.#rebuildMerged(); + setDefaultTabWidth(this.get("display.tabWidth")); return this; } @@ -666,6 +667,11 @@ const SETTING_HOOKS: Partial>> = { }); } }, + "display.tabWidth": value => { + if (typeof value === "number") { + setDefaultTabWidth(value); + } + }, }; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/modes/components/diff.ts b/packages/coding-agent/src/modes/components/diff.ts index 4c16fe836..f83f58fe2 100644 --- a/packages/coding-agent/src/modes/components/diff.ts +++ b/packages/coding-agent/src/modes/components/diff.ts @@ -1,3 +1,4 @@ +import { getIndentation } from "@oh-my-pi/pi-utils"; import * as Diff from "diff"; import { theme } from "../../modes/theme/theme"; import { replaceTabs } from "../../tools/render-utils"; @@ -12,26 +13,30 @@ 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): string { +function visualizeIndent(text: string, filePath?: string): string { const match = text.match(/^([ \t]+)/); - if (!match) return replaceTabs(text); + if (!match) return replaceTabs(text, filePath); const indent = match[1]; const rest = text.slice(indent.length); - // Normalize: collapse 3-space groups (tab-width) into tab arrows, - // then handle remaining tabs and lone spaces. - const normalized = indent.replaceAll("\t", " "); + const indentation = getIndentation(filePath); + const tabWidth = indentation.length; + const leftPadding = Math.floor(tabWidth / 2); + const rightPadding = Math.max(0, tabWidth - leftPadding - 1); + const tabMarker = `${DIM}${" ".repeat(leftPadding)}→${" ".repeat(rightPadding)}${DIM_OFF}`; + // Normalize: collapse configured tab-width groups into tab markers, then handle remaining spaces. + const normalized = indent.replaceAll("\t", indentation); let visible = ""; let pos = 0; while (pos < normalized.length) { - if (pos + 3 <= normalized.length && normalized.slice(pos, pos + 3) === " ") { - visible += `${DIM} → ${DIM_OFF}`; - pos += 3; + if (pos + tabWidth <= normalized.length && normalized.slice(pos, pos + tabWidth) === indentation) { + visible += tabMarker; + pos += tabWidth; } else { visible += `${DIM}·${DIM_OFF}`; pos++; } } - return `${visible}${replaceTabs(rest)}`; + return `${visible}${replaceTabs(rest, filePath)}`; } /** @@ -96,7 +101,7 @@ function renderIntraLineDiff(oldContent: string, newContent: string): { removedL } export interface RenderDiffOptions { - /** File path (unused, kept for API compatibility) */ + /** File path used to resolve indentation (.editorconfig + defaults) */ filePath?: string; } @@ -106,7 +111,7 @@ export interface RenderDiffOptions { * - Removed lines: red, with inverse on changed tokens * - Added lines: green, with inverse on changed tokens */ -export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string { +export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string { const lines = diffText.split("\n"); const result: string[] = []; @@ -154,30 +159,55 @@ export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): const added = addedLines[0]; const { removedLine, addedLine } = renderIntraLineDiff( - replaceTabs(removed.content), - replaceTabs(added.content), + replaceTabs(removed.content, options.filePath), + replaceTabs(added.content, options.filePath), ); - result.push(theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removedLine)))); - result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine)))); + result.push( + theme.fg( + "toolDiffRemoved", + formatLine("-", removed.lineNum, visualizeIndent(removedLine, options.filePath)), + ), + ); + result.push( + theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(addedLine, options.filePath))), + ); } else { // Show all removed lines first, then all added lines for (const removed of removedLines) { result.push( - theme.fg("toolDiffRemoved", formatLine("-", removed.lineNum, visualizeIndent(removed.content))), + theme.fg( + "toolDiffRemoved", + formatLine("-", removed.lineNum, visualizeIndent(removed.content, options.filePath)), + ), ); } for (const added of addedLines) { - result.push(theme.fg("toolDiffAdded", formatLine("+", added.lineNum, visualizeIndent(added.content)))); + result.push( + theme.fg( + "toolDiffAdded", + formatLine("+", added.lineNum, visualizeIndent(added.content, options.filePath)), + ), + ); } } } else if (parsed.prefix === "+") { // Standalone added line - result.push(theme.fg("toolDiffAdded", formatLine("+", parsed.lineNum, visualizeIndent(parsed.content)))); + result.push( + theme.fg( + "toolDiffAdded", + formatLine("+", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)), + ), + ); i++; } else { // Context line - result.push(theme.fg("toolDiffContext", formatLine(" ", parsed.lineNum, visualizeIndent(parsed.content)))); + result.push( + theme.fg( + "toolDiffContext", + formatLine(" ", parsed.lineNum, visualizeIndent(parsed.content, options.filePath)), + ), + ); i++; } } diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index b0b1a955e..a123dc7e1 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -6,11 +6,14 @@ */ import * as os from "node:os"; import { type Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; -import { pluralize } from "@oh-my-pi/pi-utils"; +import { getIndentation, pluralize } from "@oh-my-pi/pi-utils"; import type { Theme } from "../modes/theme/theme"; -export { Ellipsis, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui"; +export { Ellipsis, truncateToWidth } from "@oh-my-pi/pi-tui"; +export function replaceTabs(text: string, file?: string): string { + return text.replaceAll("\t", getIndentation(file)); +} // ============================================================================= // Standardized Display Constants // ============================================================================= diff --git a/packages/coding-agent/test/config-spacing.test.ts b/packages/coding-agent/test/config-spacing.test.ts new file mode 100644 index 000000000..48948f2ea --- /dev/null +++ b/packages/coding-agent/test/config-spacing.test.ts @@ -0,0 +1,66 @@ +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 { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { getDefaultTabWidth, getIndentation, Snowflake, setDefaultTabWidth } from "@oh-my-pi/pi-utils"; + +describe("indentation resolver", () => { + let tempDir = ""; + + beforeEach(async () => { + _resetSettingsForTest(); + setDefaultTabWidth(3); + tempDir = path.join(os.tmpdir(), "pi-spacing", Snowflake.next()); + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + _resetSettingsForTest(); + setDefaultTabWidth(3); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("falls back to hard default when settings are not initialized", () => { + expect(getDefaultTabWidth()).toBe(3); + expect(getIndentation()).toBe(" "); + }); + + it("uses configured default tab width from settings", async () => { + const runtimeSettings = await Settings.init({ inMemory: true, cwd: tempDir }); + runtimeSettings.set("display.tabWidth", 5); + expect(getDefaultTabWidth()).toBe(5); + expect(getIndentation()).toBe(" "); + }); + + 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(" "); + }); + + 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(" "); + expect(getIndentation(path.join(tempDir, "README.md"))).toBe(" "); + }); + + 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(" "); + }); +}); diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts index 4d1f4c822..4c78a4f0f 100644 --- a/packages/natives/src/text/index.ts +++ b/packages/natives/src/text/index.ts @@ -2,7 +2,8 @@ * ANSI-aware text utilities powered by native bindings. */ -import { Ellipsis, type SliceWithWidthResult } from "@oh-my-pi/pi-natives"; +import { Ellipsis, type ExtractSegmentsResult, type SliceWithWidthResult } from "@oh-my-pi/pi-natives"; +import { getDefaultTabWidth } from "@oh-my-pi/pi-utils"; import { native } from "../native"; export type { ExtractSegmentsResult, SliceWithWidthResult } from "./types"; @@ -24,8 +25,9 @@ export function truncateToWidth( maxWidth: number, ellipsis: Ellipsis = Ellipsis.Unicode, pad = false, + tabWidth = getDefaultTabWidth(), ): string { - return native.truncateToWidth(text, maxWidth, ellipsis, pad); + return native.truncateToWidth(text, maxWidth, ellipsis, pad, tabWidth); } /** @@ -36,9 +38,60 @@ export function truncateToWidth( * @param strict - Whether to strictly enforce the length * @returns The sliced line */ -export function sliceWithWidth(line: string, startCol: number, length: number, strict = false): SliceWithWidthResult { +export function sliceWithWidth( + line: string, + startCol: number, + length: number, + strict = false, + tabWidth = getDefaultTabWidth(), +): SliceWithWidthResult { if (length <= 0) return { text: "", width: 0 }; - return native.sliceWithWidth(line, startCol, length, strict); + return native.sliceWithWidth(line, startCol, length, strict, tabWidth); } -export const { wrapTextWithAnsi, visibleWidth, extractSegments, sanitizeText } = native; +/** + * Wrap text to a visible width while preserving ANSI color/style sequences. + * + * @param text - Input text, optionally containing ANSI escape codes + * @param width - Maximum visible width per output line + * @param tabWidth - Width used when measuring tab characters (default: configured tab width) + * @returns Wrapped lines with ANSI state preserved across breaks + */ +export function wrapTextWithAnsi(text: string, width: number, tabWidth = getDefaultTabWidth()): string[] { + return native.wrapTextWithAnsi(text, width, tabWidth); +} + +/** + * Measure visible terminal width of text, excluding ANSI escape sequences. + * + * @param text - Input text, optionally containing ANSI escape codes + * @param tabWidth - Width used when measuring tab characters (default: configured tab width) + * @returns Visible width in terminal cells + */ +export function visibleWidth(text: string, tabWidth = getDefaultTabWidth()): number { + return native.visibleWidth(text, tabWidth); +} + +/** + * Extract before/after segments around an overlay range using visible-column boundaries. + * + * @param line - Input line, optionally containing ANSI escape codes + * @param beforeEnd - Visible column where the `before` segment ends + * @param afterStart - Visible column where the `after` segment starts + * @param afterLen - Visible width to include in the `after` segment + * @param strictAfter - When true, graphemes that overflow `afterLen` are dropped + * @param tabWidth - Width used when measuring tab characters (default: configured tab width) + * @returns Visible-width-aware before/after segments + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter: boolean, + tabWidth = getDefaultTabWidth(), +): ExtractSegmentsResult { + return native.extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, tabWidth); +} + +export const { sanitizeText } = native; diff --git a/packages/natives/src/text/types.ts b/packages/natives/src/text/types.ts index 6e87cecf0..d4a63f315 100644 --- a/packages/natives/src/text/types.ts +++ b/packages/natives/src/text/types.ts @@ -41,7 +41,7 @@ declare module "../bindings" { * @param ellipsisKind Ellipsis strategy (see {@link Ellipsis}). * @param pad Whether to pad the output to `maxWidth`. */ - truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean): string; + truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean, tabWidth?: number): string; /** * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings. */ @@ -52,7 +52,7 @@ declare module "../bindings" { * @param text UTF-16 input text with optional ANSI escapes. * @param width Maximum visible width per line. */ - wrapTextWithAnsi(text: string, width: number): string[]; + wrapTextWithAnsi(text: string, width: number, tabWidth?: number): string[]; /** * Slice a range of visible columns from a line. * @param line UTF-16 input line with optional ANSI escapes. @@ -60,12 +60,18 @@ declare module "../bindings" { * @param length Number of visible cells to include. * @param strict Whether to drop graphemes that overflow the range. */ - sliceWithWidth(line: string, startCol: number, length: number, strict: boolean): SliceWithWidthResult; + sliceWithWidth( + line: string, + startCol: number, + length: number, + strict: boolean, + tabWidth?: number, + ): SliceWithWidthResult; /** * Measure the visible width of text (excluding ANSI codes). * @param text UTF-16 input text with optional ANSI escapes. */ - visibleWidth(text: string): number; + visibleWidth(text: string, tabWidth?: number): number; /** Extract before/after segments around an overlay region. * @param line UTF-16 input line with optional ANSI escapes. * @param beforeEnd Column where the "before" segment ends. @@ -79,6 +85,7 @@ declare module "../bindings" { afterStart: number, afterLen: number, strictAfter: boolean, + tabWidth?: number, ): ExtractSegmentsResult; } } diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index 698339e66..ded85b63b 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -12,6 +12,9 @@ import { invalidateFsScanCache, PtySession, sanitizeText, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, } from "../src/index"; let testDir: string; @@ -177,6 +180,19 @@ describe("pi-natives", () => { }); }); + describe("text tab width", () => { + it("uses default tab width and supports explicit overrides", () => { + expect(visibleWidth("a\tb")).toBe(5); + expect(visibleWidth("a\tb", 4)).toBe(6); + expect(visibleWidth("a\tb", 2)).toBe(4); + }); + + it("applies explicit tab width in truncate and wrap", () => { + expect(truncateToWidth("\tfoo", 6, undefined, false, 4)).toBe("\tf…"); + expect(wrapTextWithAnsi("\tfoo", 4, 4)).toEqual(["\t", "foo"]); + }); + }); + describe("pty", () => { it("should time out detached background workloads without hanging", async () => { if (process.platform === "win32" || !Bun.which("bash")) { diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 5122bc660..688f3b1da 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,4 +1,5 @@ import { sliceWithWidth } from "@oh-my-pi/pi-natives"; +import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils"; export { Ellipsis, extractSegments, sliceWithWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-natives"; @@ -6,10 +7,10 @@ export { Ellipsis, extractSegments, sliceWithWidth, truncateToWidth, wrapTextWit const SPACE_BUFFER = " ".repeat(512); /* - * Replace tabs with 3 spaces for consistent rendering. + * Replace tabs with configured spacing for consistent rendering. */ export function replaceTabs(text: string): string { - return text.replaceAll("\t", " "); + return text.replaceAll("\t", getIndentation()); } /** @@ -42,10 +43,11 @@ export function visibleWidthRaw(str: string): number { // Fast path: pure ASCII printable let isPureAscii = true; let tabLength = 0; + const tabWidth = getDefaultTabWidth(); for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code === 9) { - tabLength += 3; + tabLength += tabWidth; } else if (code < 0x20 || code > 0x7e) { isPureAscii = false; } diff --git a/packages/utils/src/indent.ts b/packages/utils/src/indent.ts new file mode 100644 index 000000000..f1cf3dc7c --- /dev/null +++ b/packages/utils/src/indent.ts @@ -0,0 +1,269 @@ +/** + * Shared tab indentation resolution utilities. + * + * Resolves tab width from a configurable default and optional per-file `.editorconfig` rules. + * This module intentionally has no dependency on higher-level settings systems. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { getProjectDir } from "./dirs"; + +const DEFAULT_TAB_WIDTH = 3; +const MIN_TAB_WIDTH = 1; +const MAX_TAB_WIDTH = 16; +const EDITORCONFIG_NAME = ".editorconfig"; + +/** Parsed `.editorconfig` section `[pattern]` with normalized key/value properties */ +interface EditorConfigSection { + pattern: string; + properties: Record; +} + +/** Parsed `.editorconfig` document with top-level `root` flag and ordered sections */ +interface ParsedEditorConfig { + root: boolean; + sections: EditorConfigSection[]; +} + +/** Cached parsed `.editorconfig` value keyed by file path and mtime */ +interface CachedEditorConfig { + mtimeMs: number; + parsed: ParsedEditorConfig; +} + +/** Effective editorconfig indent-related properties merged for one target file */ +interface EditorConfigMatch { + indentStyle?: "space" | "tab"; + indentSize?: number | "tab"; + tabWidth?: number; +} + +const editorConfigCache = new Map(); +let defaultTabWidth = DEFAULT_TAB_WIDTH; + +function clampTabWidth(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_TAB_WIDTH; + return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.round(value))); +} + +function parsePositiveInteger(value: string | undefined): number | undefined { + if (!value) return undefined; + if (!/^\d+$/.test(value)) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return clampTabWidth(parsed); +} + +function parseEditorConfigFile(content: string): ParsedEditorConfig { + const parsed: ParsedEditorConfig = { root: false, sections: [] }; + let currentSection: EditorConfigSection | null = null; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + if (line.startsWith("#") || line.startsWith(";")) continue; + + const sectionMatch = line.match(/^\[(.+)\]$/); + if (sectionMatch) { + const pattern = sectionMatch[1].trim(); + if (pattern.length === 0) { + currentSection = null; + continue; + } + currentSection = { pattern, properties: {} }; + parsed.sections.push(currentSection); + continue; + } + + const equalsIndex = line.indexOf("="); + if (equalsIndex === -1) continue; + + const key = line.slice(0, equalsIndex).trim().toLowerCase(); + const value = line + .slice(equalsIndex + 1) + .trim() + .toLowerCase(); + if (key.length === 0) continue; + + if (currentSection === null) { + if (key === "root") parsed.root = value === "true"; + continue; + } + + currentSection.properties[key] = value; + } + + return parsed; +} + +function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | null { + let stat: fs.Stats; + try { + stat = fs.statSync(configPath); + } catch { + return null; + } + + if (!stat.isFile()) return null; + + const cached = editorConfigCache.get(configPath); + if (cached && cached.mtimeMs === stat.mtimeMs) { + return cached.parsed; + } + + let content: string; + try { + content = fs.readFileSync(configPath, "utf8"); + } catch { + return null; + } + + const parsed = parseEditorConfigFile(content); + editorConfigCache.set(configPath, { mtimeMs: stat.mtimeMs, parsed }); + return parsed; +} + +function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean { + const normalizedPattern = pattern.replace(/^\//, ""); + if (normalizedPattern.length === 0) return false; + + const candidates = new Set(); + candidates.add(normalizedPattern); + if (!normalizedPattern.includes("/")) { + candidates.add(`**/${normalizedPattern}`); + } + + for (const candidate of candidates) { + try { + if (new Bun.Glob(candidate).match(relativePath)) { + return true; + } + } catch {} + } + + return false; +} + +function resolveFilePath(file: string): string { + if (path.isAbsolute(file)) return path.normalize(file); + return path.normalize(path.resolve(getProjectDir(), file)); +} + +function collectEditorConfigChain(startDir: string): Array<{ dir: string; parsed: ParsedEditorConfig }> { + const chain: Array<{ dir: string; parsed: ParsedEditorConfig }> = []; + let cursor = path.resolve(startDir); + + while (true) { + const configPath = path.join(cursor, EDITORCONFIG_NAME); + const parsed = parseCachedEditorConfig(configPath); + if (parsed) { + chain.push({ dir: cursor, parsed }); + if (parsed.root) break; + } + + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + + return chain.reverse(); +} + +function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | null { + const fileDir = path.dirname(absoluteFile); + const chain = collectEditorConfigChain(fileDir); + if (chain.length === 0) return null; + + const match: EditorConfigMatch = {}; + + for (const { dir, parsed } of chain) { + const relativePath = path.relative(dir, absoluteFile).split(path.sep).join("/"); + for (const section of parsed.sections) { + if (!matchesEditorConfigPattern(section.pattern, relativePath)) continue; + const indentStyle = section.properties.indent_style; + if (indentStyle === "space" || indentStyle === "tab") { + match.indentStyle = indentStyle; + } + + const indentSizeRaw = section.properties.indent_size; + if (indentSizeRaw === "tab") { + match.indentSize = "tab"; + } else { + const indentSize = parsePositiveInteger(indentSizeRaw); + if (indentSize !== undefined) { + match.indentSize = indentSize; + } + } + + const tabWidth = parsePositiveInteger(section.properties.tab_width); + if (tabWidth !== undefined) { + match.tabWidth = tabWidth; + } + } + } + + if (match.indentStyle || match.indentSize !== undefined || match.tabWidth !== undefined) { + return match; + } + return null; +} + +function resolveEditorConfigTabWidth(match: EditorConfigMatch | null, fallbackWidth: number): number | null { + if (!match) return null; + + if (typeof match.indentSize === "number") { + return match.indentSize; + } + + if (match.indentSize === "tab") { + if (typeof match.tabWidth === "number") return match.tabWidth; + return fallbackWidth; + } + + if (typeof match.tabWidth === "number") { + return match.tabWidth; + } + + if (match.indentStyle === "tab") { + return fallbackWidth; + } + + return null; +} + +/** + * Sets the process-wide default tab width used when no file-specific override applies. + * + * @param width Desired tab width in spaces. Values are clamped to a safe range. + */ +export function setDefaultTabWidth(width: number): void { + defaultTabWidth = clampTabWidth(width); +} + +/** + * Gets the current process-wide default tab width. + */ +export function getDefaultTabWidth(): number { + return defaultTabWidth; +} + +/** + * Returns indentation used to replace a tab character. + * + * If `file` is provided, `.editorconfig` rules are resolved for that file path and applied. + * Otherwise, the configured default tab width is used. + * + * @param file Optional absolute or project-relative file path for editorconfig resolution + * @returns A string containing N spaces representing one tab + */ +export function getIndentation(file?: string): string { + const fallbackWidth = getDefaultTabWidth(); + if (!file) return " ".repeat(fallbackWidth); + + const absoluteFile = resolveFilePath(file); + const editorConfigMatch = resolveEditorConfigMatch(absoluteFile); + const resolvedWidth = resolveEditorConfigTabWidth(editorConfigMatch, fallbackWidth) ?? fallbackWidth; + const width = clampTabWidth(resolvedWidth); + return " ".repeat(width); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 53b9aec1a..05e302467 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -5,6 +5,7 @@ export * from "./env"; export * from "./format"; export * from "./fs-error"; export * from "./glob"; +export * from "./indent"; export * as logger from "./logger"; export * as postmortem from "./postmortem"; export * as procmgr from "./procmgr"; diff --git a/packages/utils/test/spacing.test.ts b/packages/utils/test/spacing.test.ts new file mode 100644 index 000000000..cd06eea27 --- /dev/null +++ b/packages/utils/test/spacing.test.ts @@ -0,0 +1,59 @@ +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 { getProjectDir, setProjectDir } from "../src/dirs"; +import { getDefaultTabWidth, getIndentation, setDefaultTabWidth } from "../src/indent"; +import { Snowflake } from "../src/snowflake"; + +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("uses configurable default tab width", () => { + expect(getDefaultTabWidth()).toBe(3); + expect(getIndentation()).toBe(" "); + + setDefaultTabWidth(5); + expect(getDefaultTabWidth()).toBe(5); + expect(getIndentation()).toBe(" "); + }); + + 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(getIndentation(filePath)).toBe(" "); + expect(getIndentation(path.join(tempDir, "README.md"))).toBe(" "); + expect(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(getIndentation(filePath)).toBe(" "); + }); +});