diff --git a/crates/pi-natives/src/text.rs b/crates/pi-natives/src/text.rs index 95185a770..9c6d2908f 100644 --- a/crates/pi-natives/src/text.rs +++ b/crates/pi-natives/src/text.rs @@ -5,7 +5,7 @@ //! identically in UTF-16. Text segments convert to UTF-8 only for grapheme //! iteration (unavoidable - unicode libraries work on &str). -use napi::{Env, JsString, JsStringUtf16, bindgen_prelude::*}; +use napi::{Env, JsString, bindgen_prelude::*}; use napi_derive::napi; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -24,14 +24,14 @@ pub struct SliceResult { /// Before/after segments extracted around an overlay region. #[napi(object)] -pub struct ExtractSegmentsResult { +pub struct ExtractSegmentsResult<'e> { /// Text before the overlay region. - pub before: String, + pub before: JsString<'e>, /// Visible width of `before` in columns. #[napi(js_name = "beforeWidth")] pub before_width: u32, /// Text after the overlay region. - pub after: String, + pub after: JsString<'e>, /// Visible width of `after` in columns. #[napi(js_name = "afterWidth")] pub after_width: u32, @@ -43,7 +43,7 @@ fn clamp_u32(value: usize) -> u32 { } #[inline] -fn ascii_cell_width(b: u16) -> usize { +const fn ascii_cell_width(b: u16) -> usize { match b { 0x09 => TAB_WIDTH, // '\t' 0x20..=0x7e => 1, // printable ASCII @@ -153,19 +153,19 @@ where // ============================================================================ /// Measure visible width, stopping early if it exceeds `limit`. -/// Returns (width_so_far, exceeded_limit). +/// Returns (`width_so_far`, `exceeded_limit`). fn visible_width_up_to(data: &[u16], limit: usize) -> (usize, bool) { let mut w = 0usize; let mut i = 0usize; while i < data.len() { - if data[i] == ESC { - if let Some(len) = ansi_len(data, i) { - i += len; - continue; - } - // Not a valid ANSI sequence, treat as control char (width 0) + if data[i] == ESC + && let Some(len) = ansi_len(data, i) + { + i += len; + continue; } + // Not a valid ANSI sequence, treat as control char (width 0) let next_esc = find_esc(data, i + 1).unwrap_or(data.len()); let end = if next_esc == i { i + 1 } else { next_esc }; @@ -200,20 +200,20 @@ fn visible_width_full(data: &[u16]) -> usize { } /// Copy a prefix (including ANSI codes) while visible width stays <= -/// `target_width`. Returns (visible_width_copied, saw_any_ansi). +/// `target_width`. Returns (`visible_width_copied`, `saw_any_ansi`). fn push_prefix_with_ansi(text: &[u16], target_width: usize, out: &mut Vec) -> (usize, bool) { let mut w = 0usize; let mut i = 0usize; let mut saw_ansi = false; while i < text.len() { - if text[i] == ESC { - if let Some(len) = ansi_len(text, i) { - out.extend_from_slice(&text[i..i + len]); - saw_ansi = true; - i += len; - continue; - } + if text[i] == ESC + && let Some(len) = ansi_len(text, i) + { + out.extend_from_slice(&text[i..i + len]); + saw_ansi = true; + i += len; + continue; } let next_esc = find_esc(text, i + 1).unwrap_or(text.len()); @@ -271,13 +271,13 @@ enum ColorCode { impl Default for ColorCode { fn default() -> Self { - ColorCode::Basic(39) + Self::Basic(39) } } impl SgrState { #[inline] - fn reset(&mut self) { + const fn reset(&mut self) { self.flags = 0; self.fg = None; self.bg = None; @@ -420,7 +420,7 @@ struct ParamIterU16<'a> { impl<'a> ParamIterU16<'a> { #[inline] - fn new(data: &'a [u16]) -> Self { + const fn new(data: &'a [u16]) -> Self { Self { data, i: 0 } } @@ -439,7 +439,7 @@ impl<'a> ParamIterU16<'a> { if (0x30..=0x39).contains(&c) { // '0'-'9' has_digit = true; - n = n.saturating_mul(10).saturating_add((c - 0x30) as u16); + n = n.saturating_mul(10).saturating_add(c - 0x30); } self.i += 1; } @@ -464,7 +464,7 @@ fn push_u16_decimal(out: &mut Vec, mut n: u16) { while n > 0 { i -= 1; - buf[i] = 0x30 + (n % 10) as u16; + buf[i] = 0x30 + (n % 10); n /= 10; } @@ -510,39 +510,36 @@ fn write_color_u16(out: &mut Vec, first: &mut bool, c: ColorCode) { } } -// ============================================================================ -// JsString Helpers -// ============================================================================ - -#[inline] -fn js_string_from_u16<'e>(env: &'e Env, data: &[u16]) -> Result> { - unsafe { env.create_string_utf16(data) } -} - -/// Convert Vec to String for struct fields. -/// Uses from_utf16_lossy which handles surrogates gracefully. -#[inline] -fn u16_to_string(data: Vec) -> String { - String::from_utf16_lossy(&data) -} - // ============================================================================ // truncateToWidth // ============================================================================ /// Truncate text to a visible width, preserving ANSI codes. +/// +/// `ellipsis_kind`: 0 = "…", 1 = "...", 2 = "" (omit) #[napi(js_name = "truncateToWidth")] pub fn truncate_to_width<'e>( env: &'e Env, text: JsString, max_width: u32, - ellipsis: JsString, + ellipsis_kind: u8, pad: bool, ) -> Result> { let text_u16 = text.into_utf16()?; let text_data = text_u16.as_slice(); let max_width = max_width as usize; + // Map ellipsis kind to UTF-16 data and width + const ELLIPSIS_UNICODE: &[u16] = &[0x2026]; // "…" + const ELLIPSIS_ASCII: &[u16] = &[0x2e, 0x2e, 0x2e]; // "..." + const ELLIPSIS_OMIT: &[u16] = &[]; + + let (ellipsis_data, ellipsis_w): (&[u16], usize) = match ellipsis_kind { + 0 => (ELLIPSIS_UNICODE, 1), + 1 => (ELLIPSIS_ASCII, 3), + _ => (ELLIPSIS_OMIT, 0), + }; + // 1) Quick width check with early exit let (w, exceeded) = visible_width_up_to(text_data, max_width); @@ -550,7 +547,7 @@ pub fn truncate_to_width<'e>( if !pad { // Best case: return original string (but we consumed it with into_utf16) // Recreate from the same data - this is still cheaper than full processing - return js_string_from_u16(&env, text_data); + return env.create_string_utf16(text_data); } // Padding required @@ -558,20 +555,17 @@ pub fn truncate_to_width<'e>( let mut out = Vec::with_capacity(text_data.len() + pad_spaces); out.extend_from_slice(text_data); out.resize(out.len() + pad_spaces, 0x20); // ' ' - return js_string_from_u16(&env, &out); + return env.create_string_utf16(&out); } - // 2) Truncation needed - now measure ellipsis (lazy) - let ellipsis_u16 = ellipsis.into_utf16()?; - let ellipsis_data = ellipsis_u16.as_slice(); - let ellipsis_w = visible_width_full(ellipsis_data); + // 2) Truncation needed let target_w = max_width.saturating_sub(ellipsis_w); // If ellipsis alone doesn't fit if target_w == 0 { let mut out = Vec::with_capacity(ellipsis_data.len().min(32)); let _ = push_prefix_with_ansi(ellipsis_data, max_width, &mut out); - return js_string_from_u16(&env, &out); + return env.create_string_utf16(&out); } // 3) Build truncated prefix + reset + ellipsis @@ -590,7 +584,7 @@ pub fn truncate_to_width<'e>( } } - js_string_from_u16(&env, &out) + env.create_string_utf16(&out) } // ============================================================================ @@ -614,16 +608,16 @@ fn slice_with_width_impl( let mut i = 0usize; while i < line.len() { - if line[i] == ESC { - if let Some(len) = ansi_len(line, i) { - if current_col >= start_col && current_col < end_col { - out.extend_from_slice(&line[i..i + len]); - } else if current_col < start_col { - pending_ansi.push((i, len)); - } - i += len; - continue; + if line[i] == ESC + && let Some(len) = ansi_len(line, i) + { + if current_col >= start_col && current_col < end_col { + out.extend_from_slice(&line[i..i + len]); + } else if current_col < start_col { + pending_ansi.push((i, len)); } + i += len; + continue; } let next_esc = find_esc(line, i + 1).unwrap_or(line.len()); @@ -631,7 +625,7 @@ fn slice_with_width_impl( let chunk = &line[i..end]; if is_ascii_u16(chunk) { - for (idx, &c) in chunk.iter().enumerate() { + for &c in chunk { let w = ascii_cell_width(c); let in_range = current_col >= start_col && current_col < end_col; let fits = !strict || current_col + w <= end_col; @@ -695,7 +689,7 @@ pub fn slice_with_width( let (out, out_w) = slice_with_width_impl(line_data, start_col as usize, length as usize, strict); - Ok(SliceResult { text: u16_to_string(out), width: clamp_u32(out_w) }) + Ok(SliceResult { text: String::from_utf16_lossy(&out), width: clamp_u32(out_w) }) } // ============================================================================ @@ -725,20 +719,20 @@ fn extract_segments_impl( let mut i = 0usize; while i < line.len() { - if line[i] == ESC { - if let Some(len) = ansi_len(line, i) { - let code = &line[i..i + len]; - tracker.process_ansi(code); + if line[i] == ESC + && let Some(len) = ansi_len(line, i) + { + let code = &line[i..i + len]; + tracker.process_ansi(code); - if current_col < before_end { - pending_before_ansi.push((i, len)); - } else if current_col >= after_start && current_col < after_end && after_started { - after.extend_from_slice(code); - } - - i += len; - continue; + if current_col < before_end { + pending_before_ansi.push((i, len)); + } else if current_col >= after_start && current_col < after_end && after_started { + after.extend_from_slice(code); } + + i += len; + continue; } let next_esc = find_esc(line, i + 1).unwrap_or(line.len()); @@ -841,13 +835,14 @@ fn extract_segments_impl( /// Extract the before/after slices around an overlay region. #[napi(js_name = "extractSegments")] -pub fn extract_segments( +pub fn extract_segments<'e>( + env: &'e Env, line: JsString, before_end: u32, after_start: u32, after_len: u32, strict_after: bool, -) -> Result { +) -> Result> { let line_u16 = line.into_utf16()?; let line_data = line_u16.as_slice(); @@ -860,9 +855,9 @@ pub fn extract_segments( ); Ok(ExtractSegmentsResult { - before: u16_to_string(before_out), + before: env.create_string_utf16(&before_out)?, before_width: clamp_u32(before_w), - after: u16_to_string(after_out), + after: env.create_string_utf16(&after_out)?, after_width: clamp_u32(after_w), }) } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 98e25e5a5..873d1413d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] +### Changed + +- Replaced hardcoded ellipsis strings with Unicode ellipsis character (…) throughout rendering code +- Removed `format.ellipsis` symbol from theme configuration; ellipsis now uses literal Unicode character +- Updated `truncate()` function to `truncateToWidth()` with simplified API accepting default ellipsis parameter +- Simplified `formatMoreItems()` function signature by removing theme parameter dependency + +### Removed + +- Removed `format.ellipsis` symbol key from theme symbol maps (Unicode, Nerd, and ASCII presets) +- Removed `ellipsis` property from `SymbolTheme` type ## [9.6.1] - 2026-02-01 diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index cbda74de3..4cae91ec3 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -157,14 +157,14 @@ Example: ```json { - "symbols": { - "preset": "ascii", - "overrides": { - "icon.model": "[M]", - "sep.powerlineLeft": ">", - "sep.powerlineRight": "<" - } - } + "symbols": { + "preset": "ascii", + "overrides": { + "icon.model": "[M]", + "sep.powerlineLeft": ">", + "sep.powerlineRight": "<" + } + } } ``` @@ -179,7 +179,7 @@ Symbol keys by category: - Icons: `icon.model`, `icon.folder`, `icon.file`, `icon.git`, `icon.branch`, `icon.tokens`, `icon.context`, `icon.cost`, `icon.time`, `icon.pi`, `icon.agents`, `icon.cache`, `icon.input`, `icon.output`, `icon.host`, `icon.session`, `icon.package`, `icon.warning`, `icon.rewind`, `icon.auto`, `icon.extensionSkill`, `icon.extensionTool`, `icon.extensionSlashCommand`, `icon.extensionMcp`, `icon.extensionRule`, `icon.extensionHook`, `icon.extensionPrompt`, `icon.extensionContextFile`, `icon.extensionInstruction` - Thinking: `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh` - Checkboxes: `checkbox.checked`, `checkbox.unchecked` -- Formatting: `format.ellipsis`, `format.bullet`, `format.dash` +- Formatting: `format.bullet`, `format.dash` - Markdown: `md.quoteBorder`, `md.hrChar`, `md.bullet` ### Color Values @@ -620,7 +620,6 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello")); **Color resolution:** 1. **Detect terminal capabilities:** - - Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support) - Check `$TERM` env var (`*-256color` → 256-color support) - Fallback to 256-color mode if detection fails @@ -644,13 +643,11 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello")); 4. **Convert colors to ANSI codes based on terminal capability:** **Truecolor mode (24-bit):** - - Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m` - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is) - Empty string (`""`) → `\x1b[39m` **256-color mode:** - - Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m` - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is) - Empty string (`""`) → `\x1b[39m` diff --git a/packages/coding-agent/src/exa/render.ts b/packages/coding-agent/src/exa/render.ts index a636fc1eb..7e339d211 100644 --- a/packages/coding-agent/src/exa/render.ts +++ b/packages/coding-agent/src/exa/render.ts @@ -17,7 +17,7 @@ import { getPreviewLines, PREVIEW_LIMITS, TRUNCATE_LENGTHS, - truncate, + truncateToWidth, } from "../tools/render-utils"; import type { ExaRenderDetails } from "./types"; @@ -72,14 +72,14 @@ export function renderExaResult( const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg( "toolOutput", - truncate(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis), + truncateToWidth(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN), )}`; } if (remaining > 0) { text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg( "muted", - formatMoreItems(remaining, "line", uiTheme), + formatMoreItems(remaining, "line"), )}`; } @@ -119,17 +119,17 @@ export function renderExaResult( const first = results[0]; const previewText = first.text ?? first.title ?? ""; const previewLines = previewText - ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis) + ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN) : []; const safePreviewLines = previewLines.length > 0 ? previewLines : ["No preview text"]; const totalLines = previewText.split("\n").filter(l => l.trim()).length; const remainingLines = Math.max(0, totalLines - previewLines.length); const extraItems: string[] = []; if (remainingLines > 0) { - extraItems.push(formatMoreItems(remainingLines, "line", uiTheme)); + extraItems.push(formatMoreItems(remainingLines, "line")); } if (resultCount > 1) { - extraItems.push(formatMoreItems(resultCount - 1, "result", uiTheme)); + extraItems.push(formatMoreItems(resultCount - 1, "result")); } for (let i = 0; i < safePreviewLines.length; i++) { @@ -160,7 +160,7 @@ export function renderExaResult( const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; const cont = isLast ? " " : uiTheme.tree.vertical; - const title = truncate(res.title ?? "Untitled", MAX_TITLE_LEN, uiTheme.format.ellipsis); + const title = truncateToWidth(res.title ?? "Untitled", MAX_TITLE_LEN); const domain = res.url ? getDomain(res.url) : ""; const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : ""; @@ -193,13 +193,13 @@ export function renderExaResult( for (const line of displayLines) { text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( "toolOutput", - truncate(line.trim(), EXPANDED_TEXT_LINE_LEN, uiTheme.format.ellipsis), + truncateToWidth(line.trim(), EXPANDED_TEXT_LINE_LEN), )}`; } if (textLines.length > EXPANDED_TEXT_LINES) { text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( "muted", - formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line", uiTheme), + formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line"), )}`; } } @@ -214,13 +214,13 @@ export function renderExaResult( const h = res.highlights[j]; text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( "muted", - `${uiTheme.format.dash} ${truncate(h, MAX_HIGHLIGHT_LEN, uiTheme.format.ellipsis)}`, + `${uiTheme.format.dash} ${truncateToWidth(h, MAX_HIGHLIGHT_LEN)}`, )}`; } if (res.highlights.length > maxHighlights) { text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg( "muted", - formatMoreItems(res.highlights.length - maxHighlights, "highlight", uiTheme), + formatMoreItems(res.highlights.length - maxHighlights, "highlight"), )}`; } } @@ -232,7 +232,7 @@ export function renderExaResult( /** Render Exa call (query/args preview) */ export function renderExaCall(args: Record, toolName: string, uiTheme: Theme): Component { const toolLabel = toolName || "Exa Search"; - const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "?"; + const query = typeof args.query === "string" ? truncateToWidth(args.query, 80) : "?"; const numResults = typeof args.num_results === "number" ? args.num_results : undefined; let text = `${uiTheme.fg("toolTitle", toolLabel)} ${uiTheme.fg("accent", query)}`; diff --git a/packages/coding-agent/src/lsp/render.ts b/packages/coding-agent/src/lsp/render.ts index fd74d8228..042fc62f2 100644 --- a/packages/coding-agent/src/lsp/render.ts +++ b/packages/coding-agent/src/lsp/render.ts @@ -17,7 +17,7 @@ import { formatStatusIcon, shortenPath, TRUNCATE_LENGTHS, - truncate, + truncateToWidth, } from "../tools/render-utils"; import { renderOutputBlock, renderStatusLine } from "../tui"; import type { LspParams, LspToolDetails } from "./types"; @@ -32,10 +32,8 @@ import type { LspParams, LspToolDetails } from "./types"; */ export function renderCall(args: LspParams, theme: Theme): Text { const actionLabel = (args.action ?? "request").replace(/_/g, " "); - const queryPreview = args.query ? truncate(args.query, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis) : undefined; - const replacementPreview = args.replacement - ? truncate(args.replacement, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis) - : undefined; + const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined; + const replacementPreview = args.replacement ? truncateToWidth(args.replacement, TRUNCATE_LENGTHS.SHORT) : undefined; let target: string | undefined; let hasFileTarget = false; @@ -265,7 +263,7 @@ function renderHover( let output = `${icon}${langLabel}${expandHint}`; if (beforeCode) { - const preview = truncate(beforeCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis); + const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; } const h = theme.boxSharp.horizontal; @@ -274,14 +272,11 @@ function renderHover( output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`; if (codeLines.length > 1) { - output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${codeLines.length - 1} more lines`, - )}`; + output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg("muted", `… ${codeLines.length - 1} more lines`)}`; } if (afterCode) { - const docPreview = truncate(afterCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis); + const docPreview = truncateToWidth(afterCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`; } else { output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`; @@ -377,7 +372,7 @@ function renderDiagnostics( if (item.message) { output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg( "muted", - truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis), + truncateToWidth(item.message, TRUNCATE_LENGTHS.LINE), )}`; } } @@ -402,15 +397,12 @@ function renderDiagnostics( const severityColor = severityToColor(item.severity); const location = formatDiagnosticLocation(item.file, item.line, item.col, theme); const message = item.message - ? ` ${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}` + ? ` ${theme.fg("muted", truncateToWidth(item.message, TRUNCATE_LENGTHS.CONTENT))}` : ""; output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`; } if (remaining > 0) { - output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${remaining} more`, - )}`; + output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${remaining} more`)}`; } return output.split("\n"); @@ -473,14 +465,14 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: const context = `at ${file}:${line}:${col}`; output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg( "muted", - truncate(context, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis), + truncateToWidth(context, TRUNCATE_LENGTHS.LINE), )}`; } } if (locs.length > maxLocsPerFile) { output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${locs.length - maxLocsPerFile} more`, + `… ${locs.length - maxLocsPerFile} more`, )}`; } } @@ -489,7 +481,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: if (files.length > maxFiles) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - formatMoreItems(files.length - maxFiles, "file", theme), + formatMoreItems(files.length - maxFiles, "file"), )}`; } @@ -596,10 +588,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded )}`; } if (topLevelCount > 3) { - output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( - "muted", - `${theme.format.ellipsis} ${topLevelCount - 3} more`, - )}`; + output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${topLevelCount - 3} more`)}`; } return output.split("\n"); @@ -635,10 +624,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: const firstLine = lines[0] || "No output"; const expandHint = formatExpandHint(theme, expanded, lines.length > 1); - let output = `${icon} ${theme.fg( - "dim", - truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis), - )}${expandHint}`; + let output = `${icon} ${theme.fg("dim", truncateToWidth(firstLine, TRUNCATE_LENGTHS.TITLE))}${expandHint}`; if (lines.length > 1) { const previewLines = lines.slice(1, 4); @@ -647,13 +633,13 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: const branch = isLast ? theme.tree.last : theme.tree.branch; output += `\n ${theme.fg("dim", branch)} ${theme.fg( "dim", - truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis), + truncateToWidth(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT), )}`; } if (lines.length > 4) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - formatMoreItems(lines.length - 4, "line", theme), + formatMoreItems(lines.length - 4, "line"), )}`; } } diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index 8ba869710..3f0c14900 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -48,7 +48,7 @@ export class BashExecutionComponent extends Container { ui, spinner => theme.fg(colorKey, spinner), text => theme.fg("muted", text), - `Running${theme.format.ellipsis} (esc to cancel)`, + `Running… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.contentContainer.addChild(this.loader); @@ -150,9 +150,7 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { - statusParts.push( - theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`), - ); + statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); } if (this.status === "cancelled") { diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 8bde96838..c79fab140 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -87,7 +87,7 @@ export class CustomMessageComponent extends Container { if (!this._expanded) { const lines = text.split("\n"); if (lines.length > 5) { - text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`; + text = `${lines.slice(0, 5).join("\n")}\n…`; } } diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index eb78bd7a1..fec1a703a 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -317,7 +317,7 @@ export class FooterComponent implements Component { .map(([, text]) => sanitizeStatusText(text)); const statusLine = sortedStatuses.join(" "); // Truncate to terminal width with dim ellipsis for consistency with footer style - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + lines.push(truncateToWidth(statusLine, width)); } return lines; diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index 97934dc7c..7fd66c975 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -1,6 +1,7 @@ import { type Component, Container, + EllipsisKind, Input, matchesKey, padding, @@ -55,13 +56,13 @@ class HistoryResultsList implements Component { const maxWidth = width - cursorWidth; const normalized = entry.prompt.replace(/\s+/g, " ").trim(); - const truncated = truncateToWidth(normalized, maxWidth, theme.format.ellipsis); + const truncated = truncateToWidth(normalized, maxWidth); lines.push(cursor + (isSelected ? theme.bold(truncated) : truncated)); } if (startIndex > 0 || endIndex < this.results.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.results.length})`; - lines.push(theme.fg("muted", truncateToWidth(scrollText, width, ""))); + lines.push(theme.fg("muted", truncateToWidth(scrollText, width, EllipsisKind.Omit))); } return lines; diff --git a/packages/coding-agent/src/modes/components/hook-message.ts b/packages/coding-agent/src/modes/components/hook-message.ts index ae13c9867..07c6757b5 100644 --- a/packages/coding-agent/src/modes/components/hook-message.ts +++ b/packages/coding-agent/src/modes/components/hook-message.ts @@ -88,7 +88,7 @@ export class HookMessageComponent extends Container { if (!this._expanded) { const lines = text.split("\n"); if (lines.length > 5) { - text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`; + text = `${lines.slice(0, 5).join("\n")}\n…`; } } diff --git a/packages/coding-agent/src/modes/components/python-execution.ts b/packages/coding-agent/src/modes/components/python-execution.ts index d02d4c9f8..1df6f3e48 100644 --- a/packages/coding-agent/src/modes/components/python-execution.ts +++ b/packages/coding-agent/src/modes/components/python-execution.ts @@ -51,7 +51,7 @@ export class PythonExecutionComponent extends Container { ui, spinner => theme.fg(colorKey, spinner), text => theme.fg("muted", text), - `Running${theme.format.ellipsis} (esc to cancel)`, + `Running… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.contentContainer.addChild(this.loader); @@ -136,9 +136,7 @@ export class PythonExecutionComponent extends Container { const statusParts: string[] = []; if (hiddenLineCount > 0) { - statusParts.push( - theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`), - ); + statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); } if (this.status === "cancelled") { diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index 31ec45f8a..793f36e7f 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -105,7 +105,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa private formatPath(entry: ReadEntry): string { const filePath = shortenPath(entry.path); - let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", theme.format.ellipsis); + let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…"); if (entry.offset !== undefined || entry.limit !== undefined) { const startLine = entry.offset ?? 1; const endLine = entry.limit !== undefined ? startLine + entry.limit - 1 : ""; diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 09d988885..43908f96d 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -74,14 +74,14 @@ class SessionList implements Component { if (this.filteredSessions.length === 0) { if (this.showCwd) { // "All" scope - no sessions anywhere that match filter - lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width, theme.format.ellipsis)); + lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width)); } else { // "Current folder" scope - hint to try "all" lines.push( truncateToWidth( theme.fg("muted", " No sessions in current folder. Press Tab to view all."), width, - theme.format.ellipsis, + "…", ), ); } @@ -128,16 +128,16 @@ class SessionList implements Component { if (session.title) { // Has title: show title on first line, dimmed first message on second line - const truncatedTitle = truncateToWidth(session.title, maxWidth, theme.format.ellipsis); + const truncatedTitle = truncateToWidth(session.title, maxWidth); const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle); lines.push(titleLine); // Second line: dimmed first message preview - const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis); + const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth); lines.push(` ${theme.fg("dim", truncatedPreview)}`); } else { // No title: show first message as main line - const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis); + const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth); const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); lines.push(messageLine); } @@ -146,7 +146,7 @@ class SessionList implements Component { const modified = formatDate(session.modified); const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`; const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`; - const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, theme.format.ellipsis)); + const metadataLine = theme.fg("dim", truncateToWidth(metadata, width)); lines.push(metadataLine); lines.push(""); // Blank line between sessions @@ -155,7 +155,7 @@ class SessionList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.filteredSessions.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`; - const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, theme.format.ellipsis)); + const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width)); lines.push(scrollInfo); } diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index b27f884f0..44fbe3432 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -423,6 +423,6 @@ export class StatusLineComponent implements Component { .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const hookLine = sortedStatuses.join(" "); - return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", theme.format.ellipsis))]; + return [truncateToWidth(hookLine, width)]; } } diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 41cc13bee..318ecf37d 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -102,7 +102,7 @@ const pathSegment: StatusLineSegment = { const maxLen = opts.maxLength ?? 40; if (pwd.length > maxLen) { - const ellipsis = theme.format.ellipsis; + const ellipsis = "…"; const sliceLen = Math.max(0, maxLen - ellipsis.length); pwd = `${ellipsis}${pwd.slice(-sliceLen)}`; } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 36fb39ae4..be89121b3 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -54,7 +54,7 @@ function formatCompactValue(value: unknown, maxLength: number): string { } if (rendered.length > maxLength) { - rendered = `${rendered.slice(0, maxLength - 1)}${theme.format.ellipsis}`; + rendered = `${rendered.slice(0, maxLength - 1)}…`; } return rendered; @@ -630,7 +630,7 @@ export class ToolExecutionComponent extends Container { text += `\n${theme.fg("dim", "(none)")}`; } if (argsPreview.remaining > 0) { - text += theme.fg("dim", `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`); + text += theme.fg("dim", `\n… (${argsPreview.remaining} more args) (ctrl+o to expand)`); } const output = this.getTextOutput().trim(); @@ -643,7 +643,7 @@ export class ToolExecutionComponent extends Container { text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`; text += `\n${displayLines.map(line => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`); + text += theme.fg("dim", `\n… (${remaining} earlier lines) (ctrl+o to expand)`); } } else { text += ` ${theme.fg("dim", "(empty)")}`; diff --git a/packages/coding-agent/src/modes/components/ttsr-notification.ts b/packages/coding-agent/src/modes/components/ttsr-notification.ts index 00958b25c..7ee02f7a3 100644 --- a/packages/coding-agent/src/modes/components/ttsr-notification.ts +++ b/packages/coding-agent/src/modes/components/ttsr-notification.ts @@ -59,7 +59,7 @@ export class TtsrNotificationComponent extends Container { // Truncate to first 2 lines const lines = displayText.split("\n"); if (lines.length > 2) { - displayText = `${lines.slice(0, 2).join("\n")}${theme.format.ellipsis}`; + displayText = `${lines.slice(0, 2).join("\n")}…`; } } diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 3aaaa0492..77c8a53ca 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -165,7 +165,7 @@ export class WelcomeComponent implements Component { private centerText(text: string, width: number): string { const visLen = visibleWidth(text); if (visLen >= width) { - return truncateToWidth(text, width, theme.format.ellipsis); + return truncateToWidth(text, width); } const leftPad = Math.floor((width - visLen) / 2); const rightPad = width - visLen - leftPad; @@ -206,7 +206,7 @@ export class WelcomeComponent implements Component { private fitToWidth(str: string, width: number): string { const visLen = visibleWidth(str); if (visLen > width) { - const ellipsis = theme.format.ellipsis; + const ellipsis = "…"; const ellipsisWidth = visibleWidth(ellipsis); const maxWidth = Math.max(0, width - ellipsisWidth); let truncated = ""; diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 307fc60db..34d20a18d 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -66,7 +66,7 @@ export class EventController { this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), - `Working${theme.format.ellipsis} (esc to interrupt)`, + `Working… (esc to interrupt)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.loadingAnimation); @@ -286,7 +286,7 @@ export class EventController { this.ctx.ui, spinner => theme.fg("accent", spinner), text => theme.fg("muted", text), - `${reasonText}Auto-compacting${theme.format.ellipsis} (esc to cancel)`, + `${reasonText}Auto-compacting… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader); @@ -337,7 +337,7 @@ export class EventController { this.ctx.ui, spinner => theme.fg("warning", spinner), text => theme.fg("muted", text), - `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s${theme.format.ellipsis} (esc to cancel)`, + `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(this.ctx.retryLoader); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index be45b7775..57ee892de 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -110,7 +110,7 @@ export class InteractiveMode implements InteractiveModeContext { public autoCompactionLoader: Loader | undefined = undefined; public retryLoader: Loader | undefined = undefined; private pendingWorkingMessage: string | undefined; - private readonly defaultWorkingMessage = `Working${theme.format.ellipsis} (esc to interrupt)`; + private readonly defaultWorkingMessage = `Working… (esc to interrupt)`; public autoCompactionEscapeHandler?: () => void; public retryEscapeHandler?: () => void; public unsubscribe?: () => void; diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 0712a7b8e..356a944d8 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -124,7 +124,6 @@ export type SymbolKey = | "checkbox.checked" | "checkbox.unchecked" // Text Formatting - | "format.ellipsis" | "format.bullet" | "format.dash" | "format.bracketLeft" @@ -357,9 +356,6 @@ const UNICODE_SYMBOLS: SymbolMap = { "checkbox.checked": "☑", // pick: ☐ | alt: □ ▢ "checkbox.unchecked": "☐", - // Text Formatting - // pick: … | alt: ⋯ ... - "format.ellipsis": "…", // pick: • | alt: · ▪ ◦ "format.bullet": "•", // pick: – | alt: — ― - @@ -598,9 +594,6 @@ const NERD_SYMBOLS: SymbolMap = { "checkbox.checked": "\uf14a", // pick:  | alt:  "checkbox.unchecked": "\uf096", - // Text Formatting - // pick: … | alt: ⋯ ... - "format.ellipsis": "\u2026", // pick:  | alt:   • "format.bullet": "\uf111", // pick: – | alt: — ― - @@ -752,8 +745,6 @@ const ASCII_SYMBOLS: SymbolMap = { // Checkboxes "checkbox.checked": "[x]", "checkbox.unchecked": "[ ]", - // Text Formatting - "format.ellipsis": "...", "format.bullet": "*", "format.dash": "-", "format.bracketLeft": "[", @@ -1458,7 +1449,6 @@ export class Theme { get format() { return { - ellipsis: this.symbols["format.ellipsis"], bullet: this.symbols["format.bullet"], dash: this.symbols["format.dash"], bracketLeft: this.symbols["format.bracketLeft"], @@ -2171,7 +2161,6 @@ export function getSymbolTheme(): SymbolTheme { return { cursor: theme.nav.cursor, inputCursor: preset === "ascii" ? "|" : "▏", - ellipsis: theme.format.ellipsis, boxRound: theme.boxRound, boxSharp: theme.boxSharp, table: theme.boxSharp, diff --git a/packages/coding-agent/src/patch/shared.ts b/packages/coding-agent/src/patch/shared.ts index 6aa0d3c9b..1113ca40f 100644 --- a/packages/coding-agent/src/patch/shared.ts +++ b/packages/coding-agent/src/patch/shared.ts @@ -103,10 +103,10 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme): str let text = "\n\n"; if (hidden > 0) { - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`); + text += uiTheme.fg("dim", `… (${hidden} earlier lines)\n`); } text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath }); - text += uiTheme.fg("dim", `\n${uiTheme.format.ellipsis} (streaming)`); + text += uiTheme.fg("dim", `\n… (streaming)`); return text; } @@ -147,10 +147,7 @@ function renderDiffSection( const remainder: string[] = []; if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`); if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`); - text += uiTheme.fg( - "toolOutput", - `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`, - ); + text += uiTheme.fg("toolOutput", `\n… (${remainder.join(", ")}) ${formatExpandHint(uiTheme)}`); } return text; } @@ -164,7 +161,7 @@ export const editToolRenderer = { const filePath = shortenPath(rawPath); const editLanguage = getLanguageFromPath(rawPath) ?? "text"; const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage)); - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); + let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); // Add arrow for move/rename operations if (args.rename) { @@ -188,7 +185,7 @@ export const editToolRenderer = { text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`; } if (previewLines.length > maxLines) { - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} ${previewLines.length - maxLines} more lines`); + text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`); } } else if (args.newText || args.patch) { const previewLines = (args.newText ?? args.patch ?? "").split("\n"); @@ -198,7 +195,7 @@ export const editToolRenderer = { text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`; } if (previewLines.length > maxLines) { - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} ${previewLines.length - maxLines} more lines`); + text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`); } } @@ -225,7 +222,7 @@ export const editToolRenderer = { const rename = args?.rename || result.details?.rename; // Build path display with line number if available - let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); + let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const firstChangedLine = (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined); diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 93fcf67d0..69f76a5d9 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -15,7 +15,7 @@ import { formatMoreItems, formatStatusIcon, formatTokens, - truncate, + truncateToWidth, } from "../tools/render-utils"; import { type FindingPriority, @@ -67,10 +67,10 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`; } -function formatJsonScalar(value: unknown, theme: Theme): string { +function formatJsonScalar(value: unknown, _theme: Theme): string { if (value === null) return "null"; if (typeof value === "string") { - const trimmed = truncate(value, 70, theme.format.ellipsis); + const trimmed = truncateToWidth(value, 70); return `"${trimmed}"`; } if (typeof value === "number" || typeof value === "boolean") return String(value); @@ -150,7 +150,7 @@ function renderJsonTreeLines( pushLine( `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg( "dim", - theme.format.ellipsis, + "…", )}`, ); return; @@ -183,7 +183,7 @@ function renderJsonTreeLines( pushLine( `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg( "dim", - theme.format.ellipsis, + "…", )}`, ); return; @@ -253,7 +253,7 @@ function renderOutputSection( lines.push( `${continuePrefix} ${theme.fg("warning", theme.status.warning)} ${theme.fg( "dim", - truncate(warning, 80, theme.format.ellipsis), + truncateToWidth(warning, 80), )}`, ); @@ -276,7 +276,7 @@ function renderOutputSection( lines.push(`${continuePrefix} ${line}`); } if (tree.truncated) { - lines.push(`${continuePrefix} ${theme.fg("dim", theme.format.ellipsis)}`); + lines.push(`${continuePrefix} ${theme.fg("dim", "…")}`); } return lines; } @@ -288,12 +288,12 @@ function renderOutputSection( const outputLines = output.trimEnd().split("\n"); const previewCount = expanded ? maxExpanded : maxCollapsed; for (const line of outputLines.slice(0, previewCount)) { - lines.push(`${continuePrefix} ${theme.fg("dim", truncate(line, 70, theme.format.ellipsis))}`); + lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(line, 70))}`); } if (outputLines.length > previewCount) { lines.push( - `${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line", theme))}`, + `${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line"))}`, ); } @@ -318,7 +318,7 @@ function renderOutputSection( lines.push(`${continuePrefix} ${line}`); } if (tree.truncated) { - lines.push(`${continuePrefix} ${theme.fg("dim", theme.format.ellipsis)}`); + lines.push(`${continuePrefix} ${theme.fg("dim", "…")}`); } return lines; } @@ -332,19 +332,17 @@ function renderOutputSection( const outputLines = output.trimEnd().split("\n"); const previewCount = expanded ? maxExpanded : maxCollapsed; for (const line of outputLines.slice(0, previewCount)) { - lines.push(`${continuePrefix} ${theme.fg("dim", truncate(line, 70, theme.format.ellipsis))}`); + lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(line, 70))}`); } if (outputLines.length > previewCount) { - lines.push( - `${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line", theme))}`, - ); + lines.push(`${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line"))}`); } return lines; } -function formatArgsInline(args: Record, theme: Theme): string { +function formatArgsInline(args: Record, _theme: Theme): string { const entries = Object.entries(args); if (entries.length === 0) return "No arguments"; @@ -352,11 +350,11 @@ function formatArgsInline(args: Record, theme: Theme): string { if (entries.length === 1) { const [key, value] = entries[0]; const humanKey = humanizeKey(key); - const displayValue = `"${truncate(value, 32, theme.format.ellipsis)}"`; + const displayValue = `"${truncateToWidth(value, 32)}"`; return `${humanKey}: ${displayValue}`; } - const pairs = entries.map(([key, value]) => `${key}=${truncate(value, 24, theme.format.ellipsis)}`); + const pairs = entries.map(([key, value]) => `${key}=${truncateToWidth(value, 24)}`); return `Args: ${pairs.join(", ")}`; } @@ -365,12 +363,12 @@ function humanizeKey(key: string): string { return key.replace(/[-_]/g, " ").replace(/\b\w/g, c => c.toUpperCase()); } -function formatScalarInline(value: unknown, maxLen: number, theme: Theme): string { +function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): string { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); - if (typeof value === "string") return `"${truncate(value, maxLen, theme.format.ellipsis)}"`; + if (typeof value === "string") return `"${truncateToWidth(value, maxLen)}"`; if (Array.isArray(value)) return `[${value.length} items]`; if (typeof value === "object") { const keys = Object.keys(value); @@ -391,7 +389,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string if (Array.isArray(data)) { if (data.length === 0) return "Output: []"; const preview = formatScalarInline(data[0], 40, theme); - return `Output: [${data.length} items] ${preview}${data.length > 1 ? theme.format.ellipsis : ""}`; + return `Output: [${data.length} items] ${preview}${data.length > 1 ? "…" : ""}`; } // For objects, show key=value pairs inline @@ -407,7 +405,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", " if (totalLen + addLen > maxWidth && pairs.length > 0) { - pairs.push(theme.format.ellipsis); + pairs.push("…"); break; } @@ -442,7 +440,7 @@ function renderArgsSection( if (entries.length === 1) { const [key, value] = entries[0]; const humanKey = humanizeKey(key); - const displayValue = `"${truncate(value, 60, theme.format.ellipsis)}"`; + const displayValue = `"${truncateToWidth(value, 60)}"`; lines.push(`${continuePrefix}${theme.fg("dim", `${humanKey}: ${displayValue}`)}`); return lines; } @@ -453,7 +451,7 @@ function renderArgsSection( lines.push(`${continuePrefix} ${line}`); } if (tree.truncated) { - lines.push(`${continuePrefix} ${theme.fg("dim", theme.format.ellipsis)}`); + lines.push(`${continuePrefix} ${theme.fg("dim", "…")}`); } return lines; @@ -531,7 +529,7 @@ function renderAgentProgress( if (progress.status === "running") { if (!description) { - const taskPreview = truncate(progress.task, 40, theme.format.ellipsis); + const taskPreview = truncateToWidth(progress.task, 40); statusLine += ` ${theme.fg("muted", taskPreview)}`; } statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`; @@ -552,7 +550,7 @@ function renderAgentProgress( if (progress.currentTool) { let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", progress.currentTool)}`; if (progress.currentToolArgs) { - toolLine += `: ${theme.fg("dim", truncate(progress.currentToolArgs, 40, theme.format.ellipsis))}`; + toolLine += `: ${theme.fg("dim", truncateToWidth(progress.currentToolArgs, 40))}`; } if (progress.currentToolStartMs) { const elapsed = Date.now() - progress.currentToolStartMs; @@ -566,7 +564,7 @@ function renderAgentProgress( const recent = progress.recentTools[0]; let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", recent.tool)}`; if (recent.args) { - toolLine += `: ${theme.fg("dim", truncate(recent.args, 40, theme.format.ellipsis))}`; + toolLine += `: ${theme.fg("dim", truncateToWidth(recent.args, 40))}`; } lines.push(toolLine); } @@ -612,7 +610,7 @@ function renderAgentProgress( lines.push( `${continuePrefix}${theme.fg( "dim", - formatMoreItems((dataArray as unknown[]).length - displayCount, "item", theme), + formatMoreItems((dataArray as unknown[]).length - displayCount, "item"), )}`, ); } @@ -661,7 +659,7 @@ function renderReviewResult( } } else { // Preview: first sentence or ~100 chars - const preview = truncate(`${summary.explanation.split(/[.!?]/)[0]}.`, 100, theme.format.ellipsis); + const preview = truncateToWidth(`${summary.explanation.split(/[.!?]/)[0]}.`, 100); lines.push(`${continuePrefix}${theme.fg("dim", preview)}`); } } @@ -718,7 +716,7 @@ function renderFindings( } if (!expanded && findings.length > 3) { - lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(findings.length - 3, "finding", theme))}`); + lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(findings.length - 3, "finding"))}`); } return lines; @@ -830,7 +828,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool lines.push( `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg( "dim", - truncate(missingCompleteWarning, 80, theme.format.ellipsis), + truncateToWidth(missingCompleteWarning, 80), )}`, ); } @@ -848,7 +846,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool // Error message if (result.error && !success) { - lines.push(`${continuePrefix}${theme.fg("error", truncate(result.error, 70, theme.format.ellipsis))}`); + lines.push(`${continuePrefix}${theme.fg("error", truncateToWidth(result.error, 70))}`); } return lines; @@ -869,7 +867,7 @@ export function renderResult( if (!details) { // Fallback to simple text const text = result.content.find(c => c.type === "text")?.text || ""; - return new Text(theme.fg("dim", truncate(text, 100, theme.format.ellipsis)), 0, 0); + return new Text(theme.fg("dim", truncateToWidth(text, 100)), 0, 0); } const lines: string[] = []; @@ -911,7 +909,7 @@ export function renderResult( if (lines.length === 0) { const text = fallbackText.trim() ? fallbackText : "No results"; - return new Text(theme.fg("dim", truncate(text, 140, theme.format.ellipsis)), 0, 0); + return new Text(theme.fg("dim", truncateToWidth(text, 140)), 0, 0); } if (fallbackText.trim()) { diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 3bb7622f9..306f7c1e7 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -182,8 +182,8 @@ interface BashRenderContext { timeout?: number; } -function formatBashCommand(args: BashRenderArgs, uiTheme: Theme): string { - const command = args.command || uiTheme.format.ellipsis; +function formatBashCommand(args: BashRenderArgs, _uiTheme: Theme): string { + const command = args.command || "…"; const prompt = "$"; const cwd = process.cwd(); let displayWorkdir = args.cwd; @@ -285,7 +285,7 @@ export const bashToolRenderer = { outputLines.push( uiTheme.fg( "dim", - `${uiTheme.format.ellipsis} (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`, + `… (${result.skippedCount} earlier lines, showing ${result.visualLines.length} of ${result.skippedCount + result.visualLines.length}) (ctrl+o to expand)`, ), ); } diff --git a/packages/coding-agent/src/tools/calculator.ts b/packages/coding-agent/src/tools/calculator.ts index 7086b7252..8282906df 100644 --- a/packages/coding-agent/src/tools/calculator.ts +++ b/packages/coding-agent/src/tools/calculator.ts @@ -15,7 +15,7 @@ import { formatErrorMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS, - truncate, + truncateToWidth, } from "./render-utils"; // ============================================================================= @@ -454,7 +454,7 @@ export const calculatorToolRenderer = { renderCall(args: CalculatorRenderArgs, uiTheme: Theme): Component { const count = args.calculations?.length ?? 0; const firstExpression = args.calculations?.[0]?.expression; - const description = firstExpression ? truncate(firstExpression, TRUNCATE_LENGTHS.TITLE, "...") : undefined; + const description = firstExpression ? truncateToWidth(firstExpression, TRUNCATE_LENGTHS.TITLE) : undefined; const meta = count > 0 ? [formatCount("calc", count)] : []; const text = renderStatusLine({ icon: "pending", title: "Calc", description, meta }, uiTheme); return new Text(text, 0, 0); @@ -495,7 +495,7 @@ export const calculatorToolRenderer = { } const description = args?.calculations?.[0]?.expression - ? truncate(args.calculations[0].expression, TRUNCATE_LENGTHS.TITLE, "...") + ? truncateToWidth(args.calculations[0].expression, TRUNCATE_LENGTHS.TITLE) : undefined; const header = renderStatusLine( { icon: "success", title: "Calc", description, meta: [formatCount("result", outputs.length)] }, diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index aeff395f7..95c53f417 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -967,7 +967,7 @@ export function renderFetchCall( uiTheme: Theme = theme, ): Component { const domain = getDomain(args.url); - const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis); + const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, "…"); const description = `${domain}${path ? ` ${path}` : ""}`.trim(); const meta: string[] = []; if (args.raw) meta.push("raw"); @@ -990,7 +990,7 @@ export function renderFetchResult( } const domain = getDomain(details.finalUrl); - const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, uiTheme.format.ellipsis); + const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, "…"); const hasRedirect = details.url !== details.finalUrl; const hasNotes = details.notes.length > 0; const truncation = details.meta?.truncation; @@ -1035,15 +1035,13 @@ export function renderFetchResult( const previewLimit = expanded ? 12 : 3; const previewList = applyListLimit(contentLines, { headLimit: previewLimit }); - const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, uiTheme.format.ellipsis)); + const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…")); const remaining = Math.max(0, contentLines.length - previewLines.length); const contentPreviewLines = previewLines.length > 0 ? previewLines.map(line => uiTheme.fg("dim", line)) : [uiTheme.fg("dim", "(no content)")]; if (remaining > 0) { const hint = formatExpandHint(uiTheme, expanded, true); - contentPreviewLines.push( - uiTheme.fg("muted", `${uiTheme.format.ellipsis} ${remaining} more lines${hint ? ` ${hint}` : ""}`), - ); + contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`)); } return { diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index 622137215..e4fbe9667 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Text, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { Text } from "@oh-my-pi/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; @@ -17,7 +17,7 @@ import type { ToolSession } from "."; import type { OutputMeta } from "./output-meta"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; import { resolveToCwd } from "./path-utils"; -import { shortenPath, ToolUIKit, truncate } from "./render-utils"; +import { shortenPath, ToolUIKit, truncateToWidth } from "./render-utils"; import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_BYTES } from "./truncate"; @@ -494,7 +494,7 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { case "find": case "glob": parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`); - if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`); + if (data.pattern) parts.push(`for "${truncateToWidth(String(data.pattern), 20)}"`); break; case "grep": parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`); @@ -502,16 +502,16 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string { break; case "rgrep": parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`); - if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`); + if (data.pattern) parts.push(`for "${truncateToWidth(String(data.pattern), 20)}"`); break; case "ls": parts.push(`${data.count} entr${(data.count as number) !== 1 ? "ies" : "y"}`); break; case "env": if (data.action === "set") { - parts.push(`set ${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`); + parts.push(`set ${data.key}=${truncateToWidth(String(data.value ?? ""), 30)}`); } else if (data.action === "get") { - parts.push(`${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`); + parts.push(`${data.key}=${truncateToWidth(String(data.value ?? ""), 30)}`); } else { parts.push(`${data.count} variable${(data.count as number) !== 1 ? "s" : ""}`); } @@ -617,7 +617,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri lines.push(` ${theme.fg("dim", formatter(arr[i]))}`); } if (arr.length > max) { - lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${arr.length - max} more`)}`); + lines.push(` ${theme.fg("dim", `… ${arr.length - max} more`)}`); } }; @@ -625,11 +625,11 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri const addPreview = (preview: string, maxLines = 3) => { const previewLines = String(preview).split("\n").slice(0, maxLines); for (const line of previewLines) { - lines.push(` ${theme.fg("toolOutput", truncate(line, 80, theme.format.ellipsis))}`); + lines.push(` ${theme.fg("toolOutput", truncateToWidth(line, 80))}`); } const totalLines = String(preview).split("\n").length; if (totalLines > maxLines) { - lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${totalLines - maxLines} more lines`)}`); + lines.push(` ${theme.fg("dim", `… ${totalLines - maxLines} more lines`)}`); } }; @@ -645,7 +645,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri if (data.hits) { addItems(data.hits as unknown[], h => { const hit = h as { line: number; text: string }; - return `${hit.line}: ${truncate(hit.text, 60, theme.format.ellipsis)}`; + return `${hit.line}: ${truncateToWidth(hit.text, 60)}`; }); } break; @@ -653,7 +653,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri if (data.hits) { addItems(data.hits as unknown[], h => { const hit = h as { file: string; line: number; text: string }; - return `${shortenPath(hit.file)}:${hit.line}: ${truncate(hit.text, 50, theme.format.ellipsis)}`; + return `${shortenPath(hit.file)}:${hit.line}: ${truncateToWidth(hit.text, 50)}`; }); } break; @@ -672,7 +672,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri if (data.entries) { addItems(data.entries as unknown[], e => { const entry = e as { sha: string; subject: string }; - return `${entry.sha} ${truncate(entry.subject, 50, theme.format.ellipsis)}`; + return `${entry.sha} ${truncateToWidth(entry.subject, 50)}`; }); } break; @@ -725,13 +725,9 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: } if (!expanded && events.length > maxCollapsed) { - lines.push( - `${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `${theme.format.ellipsis} ${events.length - maxCollapsed} more`)}`, - ); + lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxCollapsed} more`)}`); } else if (expanded && events.length > maxExpanded) { - lines.push( - `${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `${theme.format.ellipsis} ${events.length - maxExpanded} more`)}`, - ); + lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxExpanded} more`)}`); } return lines; @@ -781,7 +777,7 @@ export const pythonToolRenderer = { if (cells.length === 0) { const prompt = uiTheme.fg("accent", ">>>"); const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : ""; - const text = ui.title(`${prompt} ${prefix}${uiTheme.format.ellipsis}`); + const text = ui.title(`${prompt} ${prefix}…`); return new Text(text, 0, 0); } @@ -896,10 +892,7 @@ export const pythonToolRenderer = { const outputLines = [...outputContent.lines]; if (!expanded && outputContent.hiddenCount > 0) { outputLines.push( - uiTheme.fg( - "dim", - `${uiTheme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`, - ), + uiTheme.fg("dim", `… ${outputContent.hiddenCount} more lines (ctrl+o to expand)`), ); } if (statusLines.length > 0) { @@ -1007,24 +1000,22 @@ export const pythonToolRenderer = { outputLines.push(""); const skippedLine = uiTheme.fg( "dim", - `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`, + `… (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`, ); - outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + outputLines.push(truncateToWidth(skippedLine, width)); } outputLines.push(...cachedLines); if (statusLines.length > 0) { - outputLines.push( - truncateToWidth(uiTheme.fg("dim", "Status"), width, uiTheme.fg("dim", uiTheme.format.ellipsis)), - ); + outputLines.push(truncateToWidth(uiTheme.fg("dim", "Status"), width)); for (const statusLine of statusLines) { - outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + outputLines.push(truncateToWidth(statusLine, width)); } } if (timeoutLine) { - outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis))); + outputLines.push(truncateToWidth(timeoutLine, width)); } if (warningLine) { - outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis))); + outputLines.push(truncateToWidth(warningLine, width)); } return outputLines; }, diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 3b8c55071..c39eabef1 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1003,7 +1003,7 @@ export const readToolRenderer = { const offset = args.offset; const limit = args.limit; - let pathDisplay = filePath || uiTheme.format.ellipsis; + let pathDisplay = filePath || "…"; if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 42cb76066..4df437930 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -5,9 +5,12 @@ * tool renderers to ensure a unified TUI experience. */ import * as os from "node:os"; +import { EllipsisKind, truncateToWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; import { getTreeBranch } from "../tui/utils"; +export { EllipsisKind, truncateToWidth } from "@oh-my-pi/pi-tui"; + // ============================================================================= // Standardized Display Constants // ============================================================================= @@ -47,22 +50,17 @@ export const EXPAND_HINT = "(Ctrl+O for more)"; // Text Truncation Utilities // ============================================================================= -/** - * Truncate text to max length with ellipsis. - * The most commonly duplicated utility across renderers. - */ -export function truncate(text: string, maxLen: number, ellipsis: string): string { - if (text.length <= maxLen) return text; - const sliceLen = Math.max(0, maxLen - ellipsis.length); - return `${text.slice(0, sliceLen)}${ellipsis}`; -} - /** * Get first N lines of text as preview, with each line truncated. */ -export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] { +export function getPreviewLines( + text: string, + maxLines: number, + maxLineLen: number, + ellipsis: EllipsisKind = EllipsisKind.Unicode, +): string[] { const lines = text.split("\n").filter(l => l.trim()); - return lines.slice(0, maxLines).map(l => truncate(l.trim(), maxLineLen, ellipsis)); + return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis)); } // ============================================================================= @@ -194,9 +192,9 @@ export function formatBadge(label: string, color: ToolUIColor, theme: Theme): st * Build a "more items" suffix line for truncated lists. * Uses consistent wording pattern. */ -export function formatMoreItems(remaining: number, itemType: string, theme: Theme): string { +export function formatMoreItems(remaining: number, itemType: string): string { const safeRemaining = Number.isFinite(remaining) ? remaining : 0; - return `${theme.format.ellipsis} ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`; + return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`; } export function formatMeta(meta: string[], theme: Theme): string { @@ -252,7 +250,7 @@ export class ToolUIKit { } moreItems(remaining: number, itemType: string): string { - return formatMoreItems(remaining, itemType, this.theme); + return formatMoreItems(remaining, itemType); } expandHint(expanded: boolean, hasMore: boolean): string { @@ -288,11 +286,11 @@ export class ToolUIKit { } truncate(text: string, maxLen: number): string { - return truncate(text, maxLen, this.theme.format.ellipsis); + return truncateToWidth(text, maxLen); } previewLines(text: string, maxLines: number, maxLineLen: number): string[] { - return getPreviewLines(text, maxLines, maxLineLen, this.theme.format.ellipsis); + return getPreviewLines(text, maxLines, maxLineLen); } formatBytes(bytes: number): string { @@ -457,7 +455,7 @@ export function formatDiagnostics( const remaining = totalDiags - diagsShown; output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", - `${theme.format.ellipsis} ${remaining} more`, + `… ${remaining} more`, )} ${formatExpandHint(theme)}`; } @@ -697,9 +695,7 @@ export function renderTreeList( if (!expanded && items.length > maxCollapsed) { const remaining = items.length - maxCollapsed; - lines.push( - ` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`, - ); + lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType))}`); } return lines; diff --git a/packages/coding-agent/src/tools/review.ts b/packages/coding-agent/src/tools/review.ts index 66e977b3e..3c91f5f2c 100644 --- a/packages/coding-agent/src/tools/review.ts +++ b/packages/coding-agent/src/tools/review.ts @@ -181,13 +181,7 @@ subprocessToolRegistry.register("report_finding", { } if (allData.length > displayCount) { - container.addChild( - new Text( - theme.fg("dim", ` ${theme.format.ellipsis} ${allData.length - displayCount} more findings`), - 0, - 0, - ), - ); + container.addChild(new Text(theme.fg("dim", ` … ${allData.length - displayCount} more findings`), 0, 0)); } return container; diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index f3e6aec8e..8ba203ff5 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -237,8 +237,8 @@ interface SshRenderContext { export const sshToolRenderer = { renderCall(args: SshRenderArgs, uiTheme: Theme): Component { - const host = args.host || uiTheme.format.ellipsis; - const command = args.command || uiTheme.format.ellipsis; + const host = args.host || "…"; + const command = args.command || "…"; const text = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme); return new Text(text, 0, 0); }, @@ -254,8 +254,8 @@ export const sshToolRenderer = { ): Component { const { expanded, renderContext } = options; const details = result.details; - const host = args?.host || uiTheme.format.ellipsis; - const command = args?.command || uiTheme.format.ellipsis; + const host = args?.host || "…"; + const command = args?.command || "…"; const header = renderStatusLine( { icon: "success", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme, @@ -274,7 +274,7 @@ export const sshToolRenderer = { outputLines.push( uiTheme.fg( "dim", - `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`, + `… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`, ), ); } @@ -289,9 +289,7 @@ export const sshToolRenderer = { const remaining = outputLinesRaw.length - maxLines; outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line))); if (remaining > 0) { - outputLines.push( - uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`), - ); + outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`)); } } } diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index b1ea1348b..38075d081 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -155,12 +155,12 @@ function formatStreamingContent(content: string, uiTheme: Theme, ui: ToolUIKit): let text = "\n\n"; if (hidden > 0) { - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`); + text += uiTheme.fg("dim", `… (${hidden} earlier lines)\n`); } for (const line of displayLines) { text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`; } - text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`); + text += uiTheme.fg("dim", `… (streaming)`); return text; } @@ -177,7 +177,7 @@ function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme } if (!expanded && hidden > 0) { const hint = formatExpandHint(uiTheme, expanded, hidden > 0); - const moreLine = `${formatMoreItems(hidden, "line", uiTheme)}${hint ? ` ${hint}` : ""}`; + const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`; text += uiTheme.fg("dim", moreLine); } return text; @@ -190,7 +190,7 @@ export const writeToolRenderer = { const filePath = shortenPath(rawPath); const lang = getLanguageFromPath(rawPath) ?? "text"; const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang)); - const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); + const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const spinner = options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : ""; @@ -218,7 +218,7 @@ export const writeToolRenderer = { const fileContent = args?.content || ""; const lang = getLanguageFromPath(rawPath); const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang)); - const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis); + const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const lineCount = countLines(fileContent); // Build header with status icon diff --git a/packages/coding-agent/src/tui/code-cell.ts b/packages/coding-agent/src/tui/code-cell.ts index e6d0a0cf3..7d589736f 100644 --- a/packages/coding-agent/src/tui/code-cell.ts +++ b/packages/coding-agent/src/tui/code-cell.ts @@ -80,7 +80,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[] const hiddenCodeLines = rawCodeLines.length - codeLines.length; if (hiddenCodeLines > 0) { const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0); - const moreLine = `${formatMoreItems(hiddenCodeLines, "line", theme)}${hint ? ` ${hint}` : ""}`; + const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`; codeLines.push(theme.fg("dim", moreLine)); } @@ -95,7 +95,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[] const remaining = rawLines.length - maxLines; if (remaining > 0) { const hint = formatExpandHint(theme, expanded, remaining > 0); - const moreLine = `${formatMoreItems(remaining, "line", theme)}${hint ? ` ${hint}` : ""}`; + const moreLine = `${formatMoreItems(remaining, "line")}${hint ? ` ${hint}` : ""}`; outputLines.push(theme.fg("dim", moreLine)); } } diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index 6195b9659..c4f5e9346 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -42,7 +42,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const leftWidth = visibleWidth(left); const rightWidth = visibleWidth(right); const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth); - const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, theme.format.ellipsis); + const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth); const labelWidth = visibleWidth(trimmedLabel); const fillCount = Math.max(0, lineWidth - leftWidth - labelWidth - rightWidth); return `${left}${trimmedLabel}${border(h.repeat(fillCount))}${right}`; @@ -69,7 +69,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st } const allLines = section.lines.flatMap(l => l.split("\n")); for (const line of allLines) { - const text = truncateToWidth(line, contentWidth, theme.format.ellipsis); + const text = truncateToWidth(line, contentWidth); const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(text))); const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`; lines.push(padToWidth(fullLine, lineWidth, bgFn)); diff --git a/packages/coding-agent/src/tui/tree-list.ts b/packages/coding-agent/src/tui/tree-list.ts index 8b714f7ab..79f25110c 100644 --- a/packages/coding-agent/src/tui/tree-list.ts +++ b/packages/coding-agent/src/tui/tree-list.ts @@ -46,9 +46,7 @@ export function renderTreeList(options: TreeListOptions, theme: Theme): st if (!expanded && items.length > maxItems) { const remaining = items.length - maxItems; - lines.push( - `${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType, theme))}`, - ); + lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, itemType))}`); } return lines; diff --git a/packages/coding-agent/src/tui/utils.ts b/packages/coding-agent/src/tui/utils.ts index a723a0f3d..055127a0d 100644 --- a/packages/coding-agent/src/tui/utils.ts +++ b/packages/coding-agent/src/tui/utils.ts @@ -1,10 +1,12 @@ /** * Shared helpers for tool-rendered UI components. */ -import { padding, truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui"; +import { padding, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme, ThemeBg } from "../modes/theme/theme"; import type { IconType, State } from "./types"; +export { truncateToWidth } from "@oh-my-pi/pi-tui"; + export function buildTreePrefix(ancestors: boolean[], theme: Theme): string { return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join(""); } @@ -17,10 +19,6 @@ export function getTreeContinuePrefix(isLast: boolean, theme: Theme): string { return isLast ? " " : `${theme.tree.vertical} `; } -export function truncateToWidth(text: string, width: number, ellipsis: string): string { - return truncateToWidthBase(text, width, ellipsis); -} - export function padToWidth(text: string, width: number, bgFn?: (s: string) => string): string { if (width <= 0) return bgFn ? bgFn(text) : text; const paddingNeeded = Math.max(0, width - visibleWidth(text)); diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index b4fce0c96..d16543438 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -17,7 +17,7 @@ import { getPreviewLines, PREVIEW_LIMITS, TRUNCATE_LENGTHS, - truncate, + truncateToWidth, } from "../../tools/render-utils"; import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui"; import type { WebSearchResponse } from "./types"; @@ -35,7 +35,7 @@ const MAX_REQUEST_ID_LEN = 36; function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component { const lines = contentText.split("\n").filter(line => line.trim()); const maxLines = expanded ? lines.length : 6; - const displayLines = lines.slice(0, maxLines).map(line => truncate(line.trim(), 110, theme.format.ellipsis)); + const displayLines = lines.slice(0, maxLines).map(line => truncateToWidth(line.trim(), 110)); const remaining = lines.length - displayLines.length; const headerIcon = formatStatusIcon("warning", theme); @@ -54,7 +54,7 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme } if (!expanded && remaining > 0) { - text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line", theme))}`; + text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(remaining, "line"))}`; } return new Text(text, 0, 0); @@ -104,9 +104,7 @@ export function renderWebSearchResult( const contentText = answerText || rawText; const totalAnswerLines = contentText ? contentText.split("\n").filter(l => l.trim()).length : 0; const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES; - const answerPreview = contentText - ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis) - : []; + const answerPreview = contentText ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, "…") : []; const providerLabel = provider === "anthropic" @@ -117,9 +115,9 @@ export function renderWebSearchResult( ? "Exa" : "Unknown"; const queryPreview = args?.query - ? truncate(args.query, 80, theme.format.ellipsis) + ? truncateToWidth(args.query, 80) : searchQueries[0] - ? truncate(searchQueries[0], 80, theme.format.ellipsis) + ? truncateToWidth(searchQueries[0], 80) : undefined; const header = renderStatusLine( { @@ -144,7 +142,7 @@ export function renderWebSearchResult( theme, ); if (remainingAnswer > 0) { - answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line", theme))); + answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line"))); } const sourceTree = renderTreeList( @@ -160,7 +158,7 @@ export function renderWebSearchResult( : typeof src.url === "string" && src.url.trim() ? src.url : "Untitled"; - const title = truncate(titleText, 70, theme.format.ellipsis); + const title = truncateToWidth(titleText, 70); const url = typeof src.url === "string" ? src.url : ""; const domain = url ? getDomain(url) : ""; const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : ""); @@ -173,12 +171,7 @@ export function renderWebSearchResult( const lines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; const snippetText = typeof src.snippet === "string" ? src.snippet : ""; if (snippetText.trim()) { - const snippetLines = getPreviewLines( - snippetText, - MAX_SNIPPET_LINES, - MAX_SNIPPET_LINE_LEN, - theme.format.ellipsis, - ); + const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN, "…"); for (const snippetLine of snippetLines) { lines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); } @@ -202,7 +195,7 @@ export function renderWebSearchResult( theme, ); if (!expanded && relatedCount > MAX_COLLAPSED_ITEMS) { - relatedTree.push(theme.fg("muted", formatMoreItems(relatedCount - MAX_COLLAPSED_ITEMS, "question", theme))); + relatedTree.push(theme.fg("muted", formatMoreItems(relatedCount - MAX_COLLAPSED_ITEMS, "question"))); } const metaLines: string[] = []; @@ -223,16 +216,13 @@ export function renderWebSearchResult( } if (response.requestId) { metaLines.push( - `${theme.fg("muted", "Request:")} ${theme.fg( - "text", - truncate(response.requestId, MAX_REQUEST_ID_LEN, theme.format.ellipsis), - )}`, + `${theme.fg("muted", "Request:")} ${theme.fg("text", truncateToWidth(response.requestId, MAX_REQUEST_ID_LEN))}`, ); } if (searchQueries.length > 0) { const queriesPreview = searchQueries.slice(0, MAX_QUERY_PREVIEW); - const queryList = queriesPreview.map(q => truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)); - const suffix = searchQueries.length > queriesPreview.length ? theme.format.ellipsis : ""; + const queryList = queriesPreview.map(q => truncateToWidth(q, MAX_QUERY_LEN)); + const suffix = searchQueries.length > queriesPreview.length ? "…" : ""; metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`); } @@ -274,7 +264,7 @@ export function renderWebSearchCall( theme: Theme, ): Component { const provider = args.provider ?? "auto"; - const query = truncate(args.query, 80, theme.format.ellipsis); + const query = truncateToWidth(args.query, 80); const text = renderStatusLine({ icon: "pending", title: "Web Search", description: query, meta: [provider] }, theme); return new Text(text, 0, 0); } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 8f8b066cb..2e13a7389 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,10 +1,22 @@ # Changelog ## [Unreleased] + +### Breaking Changes + +- Changed `TextInput` type parameter to `string` in `truncateToWidth()`, `visibleWidth()`, `sliceWithWidth()`, and `extractSegments()` functions—Uint8Array is no longer accepted +- Removed `TextInput` type export from public API + ### Added - Added `visibleWidth()` function to measure the visible width of text, excluding ANSI codes +### Changed + +- Improved JSDoc documentation for `truncateToWidth()` with clearer parameter descriptions and behavior details +- Added early return optimization in `truncateToWidth()` to skip native call when text fits within maxWidth and padding is not requested +- Added early return optimization in `sliceWithWidth()` to return empty result when length is zero or negative + ## [9.6.1] - 2026-02-01 ### Added diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index 10bde1f85..3062814e5 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -70,6 +70,7 @@ export { // ============================================================================= export { + EllipsisKind, type ExtractSegmentsResult, extractSegments, type SliceWithWidthResult, diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index a21df5f66..a96533de9 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -11,7 +11,7 @@ import type { } from "./grep/types"; import type { HighlightColors } from "./highlight/index"; import type { HtmlToMarkdownOptions } from "./html/types"; -import type { ExtractSegmentsResult, SliceWithWidthResult, TextInput } from "./text/index"; +import type { ExtractSegmentsResult, SliceWithWidthResult } from "./text/index"; export interface NativePhotonImage { getWidth(): number; @@ -55,11 +55,11 @@ export interface NativeBindings { getSupportedLanguages(): string[]; SamplingFilter: NativeSamplingFilter; PhotonImage: NativePhotonImageConstructor; - truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput, pad: boolean): string; - sliceWithWidth(line: TextInput, startCol: number, length: number, strict: boolean): SliceWithWidthResult; - visibleWidth(text: TextInput): number; + truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean): string; + sliceWithWidth(line: string, startCol: number, length: number, strict: boolean): SliceWithWidthResult; + visibleWidth(text: string): number; extractSegments( - line: TextInput, + line: string, beforeEnd: number, afterStart: number, afterLen: number, diff --git a/packages/natives/src/text/index.ts b/packages/natives/src/text/index.ts index 4cdf5c380..414bac452 100644 --- a/packages/natives/src/text/index.ts +++ b/packages/natives/src/text/index.ts @@ -16,31 +16,45 @@ export interface ExtractSegmentsResult { afterWidth: number; } -export type TextInput = string | Uint8Array; +export const enum EllipsisKind { + Unicode = 0, // "…" + Ascii = 1, // "..." + Omit = 2, // "" +} /** - * Truncate a string to a visible width, preserving ANSI codes. + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * Optionally pad with spaces to reach exactly maxWidth. + * Properly handles ANSI escape codes (they don't count toward width). + * + * @param text - Text to truncate (may contain ANSI codes) + * @param maxWidth - Maximum visible width + * @param ellipsis - Ellipsis kind to append when truncating (default: Unicode "…") + * @param pad - If true, pad result with spaces to exactly maxWidth (default: false) + * @returns Truncated text, optionally padded to exactly maxWidth */ -export function truncateToWidth(text: TextInput, maxWidth: number, ellipsis: TextInput = "…", pad = false): string { +export function truncateToWidth( + text: string, + maxWidth: number, + ellipsis: EllipsisKind = EllipsisKind.Unicode, + pad = false, +): string { + if (text.length <= maxWidth && !pad) return text; return native.truncateToWidth(text, maxWidth, ellipsis, pad); } /** * Measure the visible width of text (excluding ANSI codes). */ -export function visibleWidth(text: TextInput): number { +export function visibleWidth(text: string): number { return native.visibleWidth(text); } /** * Slice a range of visible columns from a line. */ -export function sliceWithWidth( - line: TextInput, - startCol: number, - length: number, - strict = false, -): SliceWithWidthResult { +export function sliceWithWidth(line: string, startCol: number, length: number, strict = false): SliceWithWidthResult { + if (length <= 0) return { text: "", width: 0 }; return native.sliceWithWidth(line, startCol, length, strict); } @@ -48,7 +62,7 @@ export function sliceWithWidth( * Extract before/after segments around an overlay region. */ export function extractSegments( - line: TextInput, + line: string, beforeEnd: number, afterStart: number, afterLen: number, diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 80b896f5d..e38ea4864 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,12 +1,16 @@ # Changelog ## [Unreleased] + ### Changed +- Changed hardcoded ellipsis character from theme-configurable to literal "…" in editor truncation - Refactored `visibleWidth` function to use caching wrapper around new `visibleWidthRaw` implementation for improved performance ### Removed +- Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @oh-my-pi/pi-natives) +- Removed `ellipsis` property from `SymbolTheme` interface - Removed `extractAnsiCode` function from public API ## [9.6.1] - 2026-02-01 diff --git a/packages/tui/README.md b/packages/tui/README.md index 01603a479..d426f698c 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -542,16 +542,16 @@ interface Terminal { ## Utilities ```typescript -import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; +import { EllipsisKind, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; // Get visible width of string (ignoring ANSI codes, uses Bun.stringWidth) const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5 // Truncate string to width (preserving ANSI codes, adds ellipsis) -const truncated = truncateToWidth("Hello World", 8); // "Hello..." +const truncated = truncateToWidth("Hello World", 8); // "Hello…" (default: EllipsisKind.Unicode) // Truncate without ellipsis -const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo" +const truncatedNoEllipsis = truncateToWidth("Hello World", 8, EllipsisKind.Omit); // "Hello Wo" // Wrap text to width (Bun.wrapAnsi word wrap, trims line ends, preserves ANSI) const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20); diff --git a/packages/tui/bench/text-layout.ts b/packages/tui/bench/text-layout.ts index 4bd2e8467..508cfe95d 100644 --- a/packages/tui/bench/text-layout.ts +++ b/packages/tui/bench/text-layout.ts @@ -1,4 +1,4 @@ -import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments } from "../src/utils"; +import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments, EllipsisKind } from "../src/utils"; import { matchesKey } from "../src/keys"; const ITERATIONS = 2000; @@ -35,7 +35,7 @@ bench("visibleWidth/ansi", () => { }); bench("truncateToWidth/ansi", () => { - truncateToWidth(samples.ansi, 32, "…", true); + truncateToWidth(samples.ansi, 32, EllipsisKind.Unicode, true); }); bench("wrapTextWithAnsi/ansi", () => { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 16eaa1ec6..f3a7d6cc2 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -516,7 +516,7 @@ export class Editor implements Component, Focusable { result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); } else { // Status too long - truncate it - const truncated = truncateToWidth(content, topFillWidth - 1, this.borderColor(this.theme.symbols.ellipsis)); + const truncated = truncateToWidth(content, topFillWidth - 1); const truncatedWidth = visibleWidth(truncated); const fillWidth = Math.max(0, topFillWidth - truncatedWidth); result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index bc865c538..5b9c08f2a 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,7 +1,7 @@ import { matchesKey } from "../keys"; import type { SymbolTheme } from "../symbols"; import type { Component } from "../tui"; -import { padding, truncateToWidth, visibleWidth } from "../utils"; +import { EllipsisKind, padding, truncateToWidth, visibleWidth } from "../utils"; export interface SelectItem { value: string; @@ -83,7 +83,7 @@ export class SelectList implements Component { if (item.description && width > 40) { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefixWidth - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, EllipsisKind.Omit); const spacing = padding(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description using visible widths @@ -91,18 +91,20 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + const truncatedDesc = truncateToWidth(item.description, remainingWidth, EllipsisKind.Omit); // Apply selectedText to entire line content line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); } else { // Not enough space for description const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`); + line = this.theme.selectedText( + `${prefix}${truncateToWidth(displayValue, maxWidth, EllipsisKind.Omit)}`, + ); } } else { // No description or not enough width const maxWidth = width - prefixWidth - 2; - line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, "")}`); + line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, EllipsisKind.Omit)}`); } } else { const displayValue = item.label || item.value; @@ -111,7 +113,7 @@ export class SelectList implements Component { if (item.description && width > 40) { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefix.length - 4); - const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, EllipsisKind.Omit); const spacing = padding(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description @@ -119,18 +121,18 @@ export class SelectList implements Component { const remainingWidth = width - descriptionStart - 2; // -2 for safety if (remainingWidth > 10) { - const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + const truncatedDesc = truncateToWidth(item.description, remainingWidth, EllipsisKind.Omit); const descText = this.theme.description(spacing + truncatedDesc); line = prefix + truncatedValue + descText; } else { // Not enough space for description const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, ""); + line = prefix + truncateToWidth(displayValue, maxWidth, EllipsisKind.Omit); } } else { // No description or not enough width const maxWidth = width - prefix.length - 2; - line = prefix + truncateToWidth(displayValue, maxWidth, ""); + line = prefix + truncateToWidth(displayValue, maxWidth, EllipsisKind.Omit); } } @@ -141,7 +143,7 @@ export class SelectList implements Component { if (startIndex > 0 || endIndex < this.filteredItems.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; // Truncate if too long for terminal - lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, ""))); + lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, EllipsisKind.Omit))); } return lines; diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 5b768743c..d6bdf3575 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,6 @@ import { matchesKey } from "../keys"; import type { Component } from "../tui"; -import { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { EllipsisKind, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; export interface SettingItem { /** Unique identifier for this setting */ @@ -108,7 +108,10 @@ export class SettingsList implements Component { const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); const valueMaxWidth = width - usedWidth - 2; - const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + const valueText = this.theme.value( + truncateToWidth(item.currentValue, valueMaxWidth, EllipsisKind.Omit), + isSelected, + ); lines.push(prefix + labelText + separator + valueText); } @@ -116,7 +119,7 @@ export class SettingsList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.items.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; - lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, EllipsisKind.Omit))); } // Add description for selected item diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index b81a63d03..dd1089f24 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -85,4 +85,4 @@ export { } from "./terminal-image"; export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui"; // Utilities -export { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils"; +export { EllipsisKind, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils"; diff --git a/packages/tui/src/symbols.ts b/packages/tui/src/symbols.ts index ba9aab5c5..d118a2190 100644 --- a/packages/tui/src/symbols.ts +++ b/packages/tui/src/symbols.ts @@ -15,7 +15,6 @@ export interface BoxSymbols { export interface SymbolTheme { cursor: string; inputCursor: string; - ellipsis: string; boxRound: Omit; boxSharp: BoxSymbols; table: BoxSymbols; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 7b9e764ee..83f354eb1 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,8 +1,6 @@ -import { - extractSegments as nativeExtractSegments, - sliceWithWidth as nativeSliceWithWidth, - truncateToWidth as nativeTruncateToWidth, -} from "@oh-my-pi/pi-natives"; +import { sliceWithWidth } from "@oh-my-pi/pi-natives"; + +export { EllipsisKind, extractSegments, sliceWithWidth, truncateToWidth } from "@oh-my-pi/pi-natives"; // Pre-allocated space buffer for padding const SPACE_BUFFER = " ".repeat(512); @@ -132,21 +130,6 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: return bgFn(withPadding); } -/** - * Truncate text to fit within a maximum visible width, adding ellipsis if needed. - * Optionally pad with spaces to reach exactly maxWidth. - * Properly handles ANSI escape codes (they don't count toward width). - * - * @param text - Text to truncate (may contain ANSI codes) - * @param maxWidth - Maximum visible width - * @param ellipsis - Ellipsis string to append when truncating (default: "…") - * @param pad - If true, pad result with spaces to exactly maxWidth (default: false) - * @returns Truncated text, optionally padded to exactly maxWidth - */ -export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "…", pad: boolean = false): string { - return nativeTruncateToWidth(text, maxWidth, ellipsis, pad); -} - /** * Extract a range of visible columns from a line. Handles ANSI codes and wide chars. * @param strict - If true, exclude wide chars at boundary that would extend past the range @@ -154,29 +137,3 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string { return sliceWithWidth(line, startCol, length, strict).text; } - -/** Like sliceByColumn but also returns the actual visible width of the result. */ -export function sliceWithWidth( - line: string, - startCol: number, - length: number, - strict = false, -): { text: string; width: number } { - if (length <= 0) return { text: "", width: 0 }; - return nativeSliceWithWidth(line, startCol, length, strict); -} - -/** - * Extract "before" and "after" segments from a line in a single pass. - * Used for overlay compositing where we need content before and after the overlay region. - * Preserves styling from before the overlay that should affect content after it. - */ -export function extractSegments( - line: string, - beforeEnd: number, - afterStart: number, - afterLen: number, - strictAfter = false, -): { before: string; beforeWidth: number; after: string; afterWidth: number } { - return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter); -} diff --git a/packages/tui/test/test-themes.ts b/packages/tui/test/test-themes.ts index 3bc50d6f2..55d0330fb 100644 --- a/packages/tui/test/test-themes.ts +++ b/packages/tui/test/test-themes.ts @@ -9,7 +9,6 @@ const chalk = new Chalk({ level: 3 }); const defaultSymbols: SymbolTheme = { cursor: ">", inputCursor: "|", - ellipsis: "...", boxRound: { topLeft: "+", topRight: "+", diff --git a/packages/tui/test/text-utils.test.ts b/packages/tui/test/text-utils.test.ts index 1938475a6..2016f6859 100644 --- a/packages/tui/test/text-utils.test.ts +++ b/packages/tui/test/text-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { extractSegments, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; +import { EllipsisKind, extractSegments, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils"; describe("text utils", () => { it("computes visible width for ANSI and tabs", () => { @@ -14,7 +14,7 @@ describe("text utils", () => { it("truncates ANSI text with ellipsis", () => { const text = "\x1b[31mhello world\x1b[0m"; - const result = truncateToWidth(text, 6, "…", false); + const result = truncateToWidth(text, 6, EllipsisKind.Unicode); expect(result.includes("\x1b[0m…")).toBe(true); expect(visibleWidth(result)).toBe(6); });