From 69418cca2091efde2463cf7884fabd50d7228f00 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 15 Feb 2026 13:11:40 -0800 Subject: [PATCH] fix (tui): preserve copy-sensitive token wrapping --- src/terminal/note.test.ts | 35 ++++++++++++++++++++++++++++++++ src/terminal/note.ts | 37 ++++++++++++++++++++++++++++++++++ src/tui/tui-formatters.test.ts | 23 +++++++++++++++++++++ src/tui/tui-formatters.ts | 34 ++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/terminal/note.test.ts diff --git a/src/terminal/note.test.ts b/src/terminal/note.test.ts new file mode 100644 index 0000000000..7e51037483 --- /dev/null +++ b/src/terminal/note.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { wrapNoteMessage } from "./note.js"; + +describe("wrapNoteMessage", () => { + it("preserves long filesystem paths without inserting spaces/newlines", () => { + const input = + "/Users/user/Documents/Github/impact-signals-pipeline/with/really/long/segments/file.txt"; + const wrapped = wrapNoteMessage(input, { maxWidth: 22, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("preserves long urls without inserting spaces/newlines", () => { + const input = + "https://example.com/this/is/a/very/long/url/segment/that/should/not/be/split/for-copy"; + const wrapped = wrapNoteMessage(input, { maxWidth: 24, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("preserves long file-like underscore tokens for copy safety", () => { + const input = "administrators_authorized_keys_with_extra_suffix"; + const wrapped = wrapNoteMessage(input, { maxWidth: 14, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("still chunks generic long opaque tokens to avoid pathological line width", () => { + const input = "x".repeat(70); + const wrapped = wrapNoteMessage(input, { maxWidth: 20, columns: 80 }); + + expect(wrapped).toContain("\n"); + expect(wrapped.replace(/\n/g, "")).toBe(input); + }); +}); diff --git a/src/terminal/note.ts b/src/terminal/note.ts index 48bca06fec..e1dc5717f1 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -2,6 +2,10 @@ import { note as clackNote } from "@clack/prompts"; import { visibleWidth } from "./ansi.js"; import { stylePromptTitle } from "./prompt-style.js"; +const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; + function splitLongWord(word: string, maxLen: number): string[] { if (maxLen <= 0) { return [word]; @@ -14,6 +18,31 @@ function splitLongWord(word: string, maxLen: number): string[] { return parts.length > 0 ? parts : [word]; } +function isCopySensitiveToken(word: string): boolean { + if (!word) { + return false; + } + if (URL_PREFIX_RE.test(word)) { + return true; + } + if ( + word.startsWith("/") || + word.startsWith("~/") || + word.startsWith("./") || + word.startsWith("../") + ) { + return true; + } + if (WINDOWS_DRIVE_RE.test(word) || word.startsWith("\\\\")) { + return true; + } + if (word.includes("/") || word.includes("\\")) { + return true; + } + // Preserve common file-like tokens (for example administrators_authorized_keys). + return word.includes("_") && FILE_LIKE_RE.test(word); +} + function wrapLine(line: string, maxWidth: number): string[] { if (line.trim().length === 0) { return [line]; @@ -36,6 +65,10 @@ function wrapLine(line: string, maxWidth: number): string[] { for (const word of words) { if (!current) { if (visibleWidth(word) > available) { + if (isCopySensitiveToken(word)) { + current = word; + continue; + } const parts = splitLongWord(word, available); const first = parts.shift() ?? ""; lines.push(prefix + first); @@ -61,6 +94,10 @@ function wrapLine(line: string, maxWidth: number): string[] { available = nextWidth; if (visibleWidth(word) > available) { + if (isCopySensitiveToken(word)) { + current = word; + continue; + } const parts = splitLongWord(word, available); const first = parts.shift() ?? ""; lines.push(prefix + first); diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 7e2a0bbf27..13368748fe 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -160,4 +160,27 @@ describe("sanitizeRenderableText", () => { expect(longestSegment).toBeLessThanOrEqual(32); }); + + it("preserves long filesystem paths verbatim for copy safety", () => { + const input = + "/Users/jasonshawn/PerfectXiao/a_very_long_directory_name_designed_specifically_to_test_the_line_wrapping_issue/file.txt"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); + + it("preserves long urls verbatim for copy safety", () => { + const input = + "https://example.com/this/is/a/very/long/url/segment/that/should/remain/contiguous/when/rendered"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); + + it("preserves long file-like underscore tokens for copy safety", () => { + const input = "administrators_authorized_keys_with_extra_suffix".repeat(2); + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); }); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index ff7b7d49c6..804d8ca4a5 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -7,6 +7,9 @@ const MAX_TOKEN_CHARS = 32; const LONG_TOKEN_RE = /\S{33,}/g; const LONG_TOKEN_TEST_RE = /\S{33,}/; const BINARY_LINE_REPLACEMENT_THRESHOLD = 12; +const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; function hasControlChars(text: string): boolean { for (const char of text) { @@ -47,6 +50,35 @@ function chunkToken(token: string, maxChars: number): string[] { return chunks; } +function isCopySensitiveToken(token: string): boolean { + if (URL_PREFIX_RE.test(token)) { + return true; + } + if ( + token.startsWith("/") || + token.startsWith("~/") || + token.startsWith("./") || + token.startsWith("../") + ) { + return true; + } + if (WINDOWS_DRIVE_RE.test(token) || token.startsWith("\\\\")) { + return true; + } + if (token.includes("/") || token.includes("\\")) { + return true; + } + return token.includes("_") && FILE_LIKE_RE.test(token); +} + +function normalizeLongTokenForDisplay(token: string): string { + // Preserve copy-sensitive tokens exactly (paths/urls/file-like names). + if (isCopySensitiveToken(token)) { + return token; + } + return chunkToken(token, MAX_TOKEN_CHARS).join(" "); +} + function redactBinaryLikeLine(line: string): string { const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length; if ( @@ -80,7 +112,7 @@ export function sanitizeRenderableText(text: string): string { .join("\n") : withoutControlChars; return LONG_TOKEN_TEST_RE.test(redacted) - ? redacted.replace(LONG_TOKEN_RE, (token) => chunkToken(token, MAX_TOKEN_CHARS).join(" ")) + ? redacted.replace(LONG_TOKEN_RE, normalizeLongTokenForDisplay) : redacted; }