mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 01:28:27 +00:00
fix (tui): preserve copy-sensitive token wrapping
This commit is contained in:
35
src/terminal/note.test.ts
Normal file
35
src/terminal/note.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,10 @@ import { note as clackNote } from "@clack/prompts";
|
|||||||
import { visibleWidth } from "./ansi.js";
|
import { visibleWidth } from "./ansi.js";
|
||||||
import { stylePromptTitle } from "./prompt-style.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[] {
|
function splitLongWord(word: string, maxLen: number): string[] {
|
||||||
if (maxLen <= 0) {
|
if (maxLen <= 0) {
|
||||||
return [word];
|
return [word];
|
||||||
@@ -14,6 +18,31 @@ function splitLongWord(word: string, maxLen: number): string[] {
|
|||||||
return parts.length > 0 ? parts : [word];
|
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[] {
|
function wrapLine(line: string, maxWidth: number): string[] {
|
||||||
if (line.trim().length === 0) {
|
if (line.trim().length === 0) {
|
||||||
return [line];
|
return [line];
|
||||||
@@ -36,6 +65,10 @@ function wrapLine(line: string, maxWidth: number): string[] {
|
|||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
if (visibleWidth(word) > available) {
|
if (visibleWidth(word) > available) {
|
||||||
|
if (isCopySensitiveToken(word)) {
|
||||||
|
current = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parts = splitLongWord(word, available);
|
const parts = splitLongWord(word, available);
|
||||||
const first = parts.shift() ?? "";
|
const first = parts.shift() ?? "";
|
||||||
lines.push(prefix + first);
|
lines.push(prefix + first);
|
||||||
@@ -61,6 +94,10 @@ function wrapLine(line: string, maxWidth: number): string[] {
|
|||||||
available = nextWidth;
|
available = nextWidth;
|
||||||
|
|
||||||
if (visibleWidth(word) > available) {
|
if (visibleWidth(word) > available) {
|
||||||
|
if (isCopySensitiveToken(word)) {
|
||||||
|
current = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parts = splitLongWord(word, available);
|
const parts = splitLongWord(word, available);
|
||||||
const first = parts.shift() ?? "";
|
const first = parts.shift() ?? "";
|
||||||
lines.push(prefix + first);
|
lines.push(prefix + first);
|
||||||
|
|||||||
@@ -160,4 +160,27 @@ describe("sanitizeRenderableText", () => {
|
|||||||
|
|
||||||
expect(longestSegment).toBeLessThanOrEqual(32);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const MAX_TOKEN_CHARS = 32;
|
|||||||
const LONG_TOKEN_RE = /\S{33,}/g;
|
const LONG_TOKEN_RE = /\S{33,}/g;
|
||||||
const LONG_TOKEN_TEST_RE = /\S{33,}/;
|
const LONG_TOKEN_TEST_RE = /\S{33,}/;
|
||||||
const BINARY_LINE_REPLACEMENT_THRESHOLD = 12;
|
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 {
|
function hasControlChars(text: string): boolean {
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
@@ -47,6 +50,35 @@ function chunkToken(token: string, maxChars: number): string[] {
|
|||||||
return chunks;
|
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 {
|
function redactBinaryLikeLine(line: string): string {
|
||||||
const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length;
|
const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length;
|
||||||
if (
|
if (
|
||||||
@@ -80,7 +112,7 @@ export function sanitizeRenderableText(text: string): string {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
: withoutControlChars;
|
: withoutControlChars;
|
||||||
return LONG_TOKEN_TEST_RE.test(redacted)
|
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;
|
: redacted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user