fix (tui): preserve copy-sensitive token wrapping

This commit is contained in:
Vignesh Natarajan
2026-02-15 13:11:40 -08:00
parent 5c233f4ded
commit 69418cca20
4 changed files with 128 additions and 1 deletions

35
src/terminal/note.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -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);