diff --git a/.changeset/log-viewer-line-wrapping.md b/.changeset/log-viewer-line-wrapping.md new file mode 100644 index 0000000..5147657 --- /dev/null +++ b/.changeset/log-viewer-line-wrapping.md @@ -0,0 +1,8 @@ +--- +"@aliou/pi-processes": minor +--- + +The `/ps:logs` overlay now has an opt-in soft-wrap mode (`w` key toggle). +When enabled, long log lines expand into multiple display rows instead of +being hard-clipped, making full content visible. Truncation remains the +default for the `/ps` overview, dock, and the logs overlay. diff --git a/extensions/processes-logs/components/log-file-viewer.test.ts b/extensions/processes-logs/components/log-file-viewer.test.ts index 82545ec..b5feb03 100644 --- a/extensions/processes-logs/components/log-file-viewer.test.ts +++ b/extensions/processes-logs/components/log-file-viewer.test.ts @@ -274,4 +274,139 @@ describe("LogFileViewer", () => { expect(underline).toHaveBeenCalledTimes(1); }); + + it("defaults to truncation (no wrap)", () => { + const viewer = new LogFileViewer( + [{ type: "stdout", text: "the quick brown fox jumps over the lazy dog" }], + makeTheme(), + { followEnabled: false, maxBufferLines: 10 }, + ); + + expect(viewer.isWrapEnabled()).toBe(false); + + const rows = trimLines(viewer.render(10, 1)); + expect(rows).toHaveLength(1); + // Truncated: the tail is clipped, not wrapped. A → indicator is shown. + expect(rows[0]).not.toContain("lazy"); + expect(rows[0]).toContain("→"); + }); + + it("toggles wrap mode and shows full content across multiple rows", () => { + const viewer = new LogFileViewer( + [{ type: "stdout", text: "the quick brown fox jumps over the lazy dog" }], + makeTheme(), + { followEnabled: false, maxBufferLines: 10 }, + ); + + viewer.toggleWrap(); + expect(viewer.isWrapEnabled()).toBe(true); + + // With width 10, this 43-char line should wrap into multiple rows. + const rows = viewer.render(10, 10); + expect(rows.length).toBe(10); // padded to height + + // Skip leading empty padding rows to find the content rows. + const content = rows.filter((r) => r.trimEnd().length > 0); + expect(content.length).toBeGreaterThanOrEqual(2); + + // The first content row is the start of the line (no continuation prefix). + expect(content[0].trimEnd()).toContain("the quick"); + // Continuation rows have the ↳ prefix. + expect(content[1]).toContain("↳"); + + // The full content should be visible somewhere in the rendered rows. + const combined = content + .map((r) => r.replace(/↳ /gu, "").trimEnd()) + .join(""); + expect(combined).toContain("the quick"); + expect(combined).toContain("brown"); + expect(combined).toContain("lazy"); + expect(combined).toContain("dog"); + }); + + it("wrap mode viewport scrolls by display rows", () => { + const viewer = new LogFileViewer( + [ + { type: "stdout", text: "aaaa aaaa aaaa aaaa aaaa" }, + { type: "stdout", text: "bbbb bbbb bbbb bbbb bbbb" }, + ], + makeTheme(), + { followEnabled: false, maxBufferLines: 10 }, + ); + + viewer.toggleWrap(); + // Each 24-char line wraps to 3 rows at width 10 → 6 total display rows. + const allRows = trimLines(viewer.render(10, 6)); + expect(allRows).toHaveLength(6); + expect(allRows[0]).toContain("aaaa"); + expect(allRows[5]).toContain("bbbb"); + + // Scroll up by 3 display rows (positive delta = earlier content) to + // see only the first logical line's wrapped chunks. + viewer.scrollBy(3); + const scrolled = trimLines(viewer.render(10, 3)); + expect(scrolled).toHaveLength(3); + for (const row of scrolled) { + expect(row).toContain("aaaa"); + } + }); + + it("wrap mode status shows 'wrap' indicator", () => { + const viewer = new LogFileViewer( + [{ type: "stdout", text: "short" }], + makeTheme(), + { followEnabled: false, maxBufferLines: 10 }, + ); + + const withoutWrap = viewer.getStatusParts(); + expect(withoutWrap.right.join(" ")).not.toContain("wrap"); + + viewer.toggleWrap(); + // Need a render first so lastRenderWidth is set. + viewer.render(20, 5); + const withWrap = viewer.getStatusParts(); + expect(withWrap.right.join(" ")).toContain("wrap"); + }); + + it("wrap mode preserves search match emphasis across wrapped chunks", () => { + const bold = vi.fn((text: string) => text); + const inverse = vi.fn((text: string) => text); + const theme = { + ...makeTheme(), + bold, + inverse, + } as unknown as Theme; + const viewer = new LogFileViewer( + [{ type: "stdout", text: "match this long line that wraps around" }], + theme, + { followEnabled: false, maxBufferLines: 10 }, + ); + + viewer.toggleWrap(); + viewer.setSearch("match"); + + viewer.render(10, 6); + // The current search match is bold+inverse; all wrapped chunks of that + // logical line should be toned. + expect(inverse).toHaveBeenCalled(); + expect(bold).toHaveBeenCalled(); + }); + + it("toggling wrap off returns to truncation", () => { + const viewer = new LogFileViewer( + [{ type: "stdout", text: "the quick brown fox jumps over the lazy dog" }], + makeTheme(), + { followEnabled: false, maxBufferLines: 10 }, + ); + + viewer.toggleWrap(); + expect(viewer.isWrapEnabled()).toBe(true); + viewer.toggleWrap(); + expect(viewer.isWrapEnabled()).toBe(false); + + const rows = trimLines(viewer.render(10, 1)); + expect(rows).toHaveLength(1); + expect(rows[0]).not.toContain("lazy"); + expect(rows[0]).toContain("→"); + }); }); diff --git a/extensions/processes-logs/components/log-file-viewer.ts b/extensions/processes-logs/components/log-file-viewer.ts index 1944bec..60f63c9 100644 --- a/extensions/processes-logs/components/log-file-viewer.ts +++ b/extensions/processes-logs/components/log-file-viewer.ts @@ -9,10 +9,16 @@ import { displayTextOf, type LogLineEmphasis, renderLogLine, + renderLogLineWrap, } from "../../shared/log-line"; import { truncateToWidth } from "../../shared/truncate"; import type { ProcessLogLine } from "../logs-client"; +interface DisplayRow { + text: string; + logicalIndex: number; +} + export type StreamFilter = "both" | "stdout" | "stderr"; interface LogFileViewerOptions { @@ -30,6 +36,8 @@ export class LogFileViewer { private searchMatches: number[] = []; private searchCurrentMatch = -1; private centerTarget: number | null = null; + private wrapEnabled = false; + private lastRenderWidth = 0; private readonly notifyLines = new Set(); constructor( @@ -59,12 +67,11 @@ export class LogFileViewer { } scrollBy(delta: number): void { - const visible = this.visibleLines(); this.follow = false; - this.anchorEnd ??= visible.length; + this.anchorEnd ??= this.totalDisplayRows(0); this.anchorEnd = Math.max( 0, - Math.min(visible.length, this.anchorEnd - delta), + Math.min(this.totalDisplayRows(0), this.anchorEnd - delta), ); } @@ -75,12 +82,12 @@ export class LogFileViewer { scrollToBottom(): void { this.follow = false; - this.anchorEnd = this.visibleLines().length; + this.anchorEnd = this.totalDisplayRows(0); } toggleFollow(): boolean { this.follow = !this.follow; - this.anchorEnd = this.follow ? null : this.visibleLines().length; + this.anchorEnd = this.follow ? null : this.totalDisplayRows(0); return this.follow; } @@ -88,6 +95,18 @@ export class LogFileViewer { return this.follow; } + toggleWrap(): boolean { + this.wrapEnabled = !this.wrapEnabled; + // Reset anchor so the viewport snaps to the latest content in the new + // display-row space (row counts change when wrap toggles). + this.anchorEnd = this.follow ? null : this.totalDisplayRows(0); + return this.wrapEnabled; + } + + isWrapEnabled(): boolean { + return this.wrapEnabled; + } + cycleStreamFilter(): StreamFilter { this.streamFilter = this.streamFilter === "both" @@ -95,7 +114,7 @@ export class LogFileViewer { : this.streamFilter === "stdout" ? "stderr" : "both"; - this.anchorEnd = this.visibleLines().length; + this.anchorEnd = this.totalDisplayRows(0); this.refreshMatches(); return this.streamFilter; } @@ -106,7 +125,7 @@ export class LogFileViewer { setSearch(query: string): void { this.follow = false; - this.anchorEnd ??= this.visibleLines().length; + this.anchorEnd ??= this.totalDisplayRows(0); this.searchQuery = query; this.refreshMatches(); if (this.searchMatches.length > 0) { @@ -168,11 +187,24 @@ export class LogFileViewer { } render(width: number, height: number): string[] { + this.lastRenderWidth = width; const visible = this.visibleLines(); if (visible.length === 0) { return this.renderEmpty(width, height); } + if (!this.wrapEnabled) { + return this.renderTruncated(visible, width, height); + } + + return this.renderWrapped(visible, width, height); + } + + private renderTruncated( + visible: ProcessLogLine[], + width: number, + height: number, + ): string[] { if (this.centerTarget !== null) { const half = Math.floor(height / 2); this.anchorEnd = Math.min(visible.length, this.centerTarget + half + 1); @@ -211,12 +243,78 @@ export class LogFileViewer { return rendered.slice(-height); } + private renderWrapped( + visible: ProcessLogLine[], + width: number, + height: number, + ): string[] { + // Build a flat list of display rows from all visible logical lines. + // Each entry carries its source logical-line index so emphasis can be + // applied per logical line (not per wrapped chunk). + const matchSet = new Set(this.searchMatches); + const currentMatchIndex = + this.searchCurrentMatch >= 0 + ? this.searchMatches[this.searchCurrentMatch] + : undefined; + + const rows: DisplayRow[] = []; + // Track the starting display-row offset of each logical line so we can + // resolve centerTarget (a logical-line index) to a display row. + const logicalToDisplayRow: number[] = []; + + for (let index = 0; index < visible.length; index++) { + logicalToDisplayRow[index] = rows.length; + const line = visible[index]; + const emphasis: LogLineEmphasis = + index === currentMatchIndex + ? "search-current" + : matchSet.has(index) + ? "search" + : this.notifyLines.has(line.text) || + this.notifyLines.has(displayTextOf(line)) + ? "notify" + : "none"; + const wrapped = renderLogLineWrap(line, { + theme: this.theme, + width, + emphasis, + }); + for (const text of wrapped) { + rows.push({ text, logicalIndex: index }); + } + } + + const totalDisplayRows = rows.length; + + // Resolve centerTarget from logical-line index to display-row index. + if (this.centerTarget !== null) { + const half = Math.floor(height / 2); + const displayRow = logicalToDisplayRow[this.centerTarget] ?? 0; + this.anchorEnd = Math.min(totalDisplayRows, displayRow + half + 1); + this.centerTarget = null; + } + + const rawEnd = this.follow + ? totalDisplayRows + : (this.anchorEnd ?? totalDisplayRows); + const end = Math.min( + totalDisplayRows, + Math.max(Math.min(height, totalDisplayRows), rawEnd), + ); + const start = Math.max(0, end - height); + + const rendered = rows.slice(start, end).map((row) => row.text); + + while (rendered.length < height) rendered.unshift(""); + return rendered.slice(-height); + } + getStatusParts(): { left: string[]; right: string[] } { const dim = (value: string) => this.theme.fg("dim", value); const accent = (value: string) => this.theme.fg("accent", value); const error = (value: string) => this.theme.fg("error", value); const visible = this.visibleLines(); - const total = visible.length; + const total = this.wrapEnabled ? this.totalDisplayRows(0) : visible.length; const left: string[] = []; const search = this.getSearchInfo(); @@ -239,6 +337,7 @@ export class LogFileViewer { right.push(dim(`${pct}% L${end}/${total}`)); } if (this.streamFilter !== "both") right.push(dim(`[${this.streamFilter}]`)); + if (this.wrapEnabled) right.push(dim("wrap")); return { left, right }; } @@ -257,6 +356,26 @@ export class LogFileViewer { return this.lines.filter((line) => line.type === this.streamFilter); } + /** + * Total display rows for the visible lines at the last render width. + * When wrapping is off (or no width is known), equals the logical-line + * count. The `fallbackWidth` is used before the first render. + */ + private totalDisplayRows(fallbackWidth: number): number { + if (!this.wrapEnabled) return this.visibleLines().length; + const width = this.lastRenderWidth || fallbackWidth; + if (width <= 0) return this.visibleLines().length; + let total = 0; + for (const line of this.visibleLines()) { + const wrapped = renderLogLineWrap(line, { + theme: this.theme, + width, + }); + total += Math.max(1, wrapped.length); + } + return total; + } + private refreshMatches(): void { if (!this.searchQuery) { this.searchMatches = []; diff --git a/extensions/processes-logs/components/log-overlay-component.test.ts b/extensions/processes-logs/components/log-overlay-component.test.ts index 06e69a5..6cf1927 100644 --- a/extensions/processes-logs/components/log-overlay-component.test.ts +++ b/extensions/processes-logs/components/log-overlay-component.test.ts @@ -167,3 +167,34 @@ describe("LogOverlayComponent viewer cache", () => { expect(footer(overlay, width)).not.toContain("L2/2"); }); }); + +describe("LogOverlayComponent wrap toggle", () => { + it("toggles wrap mode with the w key and shows it in the footer", () => { + const { overlay, width } = makeOverlay(); + + // Default: wrap is off, footer shows dim "wrap". + expect(footer(overlay, width)).toContain("wrap"); + + // Press w to enable wrap. + overlay.handleInput("w"); + const wrappedFooter = footer(overlay, width); + // The footer should still contain "wrap" (now accent-styled, but the + // test theme strips styling so we just check presence). + expect(wrappedFooter).toContain("wrap"); + }); + + it("preserves wrap state across a tab round-trip", () => { + const { overlay, width } = makeOverlay(); + + // Enable wrap on proc_1. + overlay.handleInput("w"); + + // Switch to proc_2 and back. + overlay.handleInput(TAB); + overlay.handleInput(TAB); + + // Wrap should still be enabled (cached viewer preserves it). + // We verify by checking the footer contains the wrap indicator. + expect(footer(overlay, width)).toContain("wrap"); + }); +}); diff --git a/extensions/processes-logs/components/log-overlay-component.ts b/extensions/processes-logs/components/log-overlay-component.ts index 1cbef11..e49ef03 100644 --- a/extensions/processes-logs/components/log-overlay-component.ts +++ b/extensions/processes-logs/components/log-overlay-component.ts @@ -196,6 +196,7 @@ export class LogOverlayComponent implements Component { else if (data === "G") this.viewer?.scrollToBottom(); else if (data === "s") this.viewer?.cycleStreamFilter(); else if (data === "f") this.viewer?.toggleFollow(); + else if (data === "w") this.viewer?.toggleWrap(); else if (data === "/") this.startSearch(); this.opts.tui.requestRender(); @@ -631,8 +632,11 @@ export class LogOverlayComponent implements Component { ? accent("stderr") : dim("stderr"); + const wrapOn = this.viewer?.isWrapEnabled() ?? false; + const wrap = wrapOn ? accent("wrap") : dim("wrap"); + return truncateToWidth( - `${dim("tab/shift+tab")} switch ${dim("g/G")} top/bot ${dim("j/k")} scroll ${dim("/")} search ${dim("s:")}${stdout}${dim("+")}${stderr} ${dim("f")} follow ${dim("q")} close`, + `${dim("tab/shift+tab")} switch ${dim("g/G")} top/bot ${dim("j/k")} scroll ${dim("/")} search ${dim("s:")}${stdout}${dim("+")}${stderr} ${dim("f")} follow ${dim("w:")}${wrap} ${dim("q")} close`, width, ); } diff --git a/extensions/shared/log-line.test.ts b/extensions/shared/log-line.test.ts index 63b0fa5..105c76b 100644 --- a/extensions/shared/log-line.test.ts +++ b/extensions/shared/log-line.test.ts @@ -2,7 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; -import { displayTextOf, renderLogLine } from "./log-line"; +import { displayTextOf, renderLogLine, renderLogLineWrap } from "./log-line"; const ESC = String.fromCodePoint(0x001b); @@ -79,6 +79,28 @@ describe("renderLogLine", () => { expect(line.endsWith(`${ESC}[0m`)).toBe(true); }); + it("shows a → indicator when a line is truncated", () => { + const line = renderLogLine( + { type: "stdout", text: "the quick brown fox jumps over the lazy dog" }, + { theme, width: 10 }, + ); + + expect(visibleWidth(line)).toBe(10); + expect(line).toContain("→"); + // The tail is clipped. + expect(line).not.toContain("lazy"); + }); + + it("does not show → when the line fits", () => { + const line = renderLogLine( + { type: "stdout", text: "short" }, + { theme, width: 10 }, + ); + + expect(line.trimEnd()).toBe("short"); + expect(line).not.toContain("→"); + }); + it("tones stderr and match state by priority", () => { const stderr = renderLogLine( { type: "stderr", text: "err" }, @@ -127,3 +149,100 @@ describe("renderLogLine", () => { ).toBe("red"); }); }); + +describe("renderLogLineWrap", () => { + const theme = makeTheme(); + + it("returns a single padded row for short text", () => { + const rows = renderLogLineWrap( + { type: "stdout", text: "hi" }, + { theme, width: 6 }, + ); + expect(rows).toEqual(["hi "]); + }); + + it("wraps a long line into multiple display rows", () => { + const rows = renderLogLineWrap( + { type: "stdout", text: "the quick brown fox jumps" }, + { theme, width: 10 }, + ); + expect(rows.length).toBeGreaterThanOrEqual(2); + // The first row should not have the continuation prefix. + expect(rows[0].trimEnd()).toBe("the quick"); + // Continuation rows should have the ↳ prefix (styled by the test theme). + expect(rows[1]).toContain("↳"); + // The full text should be visible across all rows. Strip styling + // tags and the continuation prefix to reconstruct the text. + const combined = rows + .map((r) => + r + .replace(/\[\/?\w+\]/gu, "") + .replace(/↳ /gu, "") + .trimEnd(), + ) + .join(""); + expect(combined).toContain("the quick"); + expect(combined).toContain("brown"); + expect(combined).toContain("fox"); + expect(combined).toContain("jumps"); + }); + + it("tones stderr rows with warning colour", () => { + const rows = renderLogLineWrap( + { type: "stderr", text: "error happened here" }, + { theme, width: 10 }, + ); + for (const row of rows) { + // stderr is toned with [warning]...[/warning]; continuation rows have + // the dim ↳ prefix before the tone. + expect(row).toContain("[warning]"); + } + }); + + it("applies search-current emphasis to all wrapped chunks", () => { + const rows = renderLogLineWrap( + { type: "stdout", text: "match this long line" }, + { theme, width: 6, emphasis: "search-current" }, + ); + expect(rows.length).toBeGreaterThanOrEqual(2); + for (const row of rows) { + // search-current is toned with [b][inv]...[/inv][/b]; continuation + // rows have the dim ↳ prefix before the tone. + expect(row).toContain("[b]"); + expect(row).toContain("[inv]"); + } + }); + + it("carries SGR colour across wrapped chunks", () => { + const text = `${ESC}[31mred text that keeps going and going${ESC}[0m`; + const rows = renderLogLineWrap( + { type: "stdout", text }, + { theme, width: 10 }, + ); + expect(rows.length).toBeGreaterThanOrEqual(2); + // First chunk starts with red SGR. + expect(rows[0].includes(`${ESC}[31m`)).toBe(true); + // Continuation chunks should also contain the red SGR (re-opened). + expect(rows[1].includes(`${ESC}[31m`)).toBe(true); + }); + + it("continuation rows have a dim ↳ prefix", () => { + const rows = renderLogLineWrap( + { type: "stdout", text: "the quick brown fox jumps" }, + { theme, width: 10 }, + ); + expect(rows.length).toBeGreaterThanOrEqual(2); + // First row: no continuation prefix. + expect(rows[0]).not.toContain("↳"); + // Continuation rows: have the dim ↳ prefix. + for (const row of rows.slice(1)) { + expect(row).toContain("↳"); + } + }); + + it("returns empty array for zero width", () => { + expect( + renderLogLineWrap({ type: "stdout", text: "hi" }, { theme, width: 0 }), + ).toEqual([]); + }); +}); diff --git a/extensions/shared/log-line.ts b/extensions/shared/log-line.ts index 7856224..a9c5fac 100644 --- a/extensions/shared/log-line.ts +++ b/extensions/shared/log-line.ts @@ -16,7 +16,7 @@ import { sanitizeForDisplay, stripSgr, } from "./display-text"; -import { truncateToWidth } from "./truncate"; +import { truncateToWidth, wrapToWidth } from "./truncate"; export interface DisplayLogLine { type: "stdout" | "stderr"; @@ -56,13 +56,60 @@ export function renderLogLine( const prefixWidth = visibleWidth(prefix); const textWidth = Math.max(1, width - prefixWidth); const safe = sanitizeForDisplay(line.text); + // Use "→" as the truncation indicator so the user can see that a line + // was clipped (wrap mode is available via the `w` key in the overlay). const text = closeSgr( - truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "", true), + truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "→", true), ); return `${prefix}${toneLogText(text, line.type, emphasis, theme)}`; } +/** + * Wrap a log line into multiple display rows instead of truncating. + * + * Each returned row is toned (by stream / match state) and padded to `width`. + * SGR state is carried across wrapped chunks so colours survive wrapping. + * Continuation rows (every row after the first) are indented with a dim + * `continuationPrefix` so the user can visually distinguish a wrapped chunk + * from a new log line — matching `less`/`journalctl` behaviour. + * Used by the `/ps:logs` overlay soft-wrap mode; `renderLogLine` (truncate) + * remains the default for the `/ps` preview and dock. + */ +export function renderLogLineWrap( + line: DisplayLogLine, + options: RenderLogLineOptions, +): string[] { + const { + theme, + width, + emphasis = "none", + prefix = "", + plain = false, + } = options; + if (width <= 0) return []; + + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const safe = sanitizeForDisplay(line.text); + const source = plain ? stripSgr(safe) : safe; + + // Continuation rows get a dim arrow indent so wrapped chunks are + // visually distinct from new log lines. + const contMarker = "↳ "; + const contIndent = visibleWidth(contMarker); + + const wrapped = wrapToWidth(source, textWidth, contIndent); + + return wrapped.map((row, index) => { + const toned = toneLogText(row, line.type, emphasis, theme); + if (index === 0) { + return `${prefix}${toned}`; + } + return `${theme.fg("dim", contMarker)}${toned}`; + }); +} + /** Text of a log line as the views display it, for match comparisons. */ export function displayTextOf(line: DisplayLogLine): string { return plainTextForDisplay(line.text); diff --git a/extensions/shared/truncate.test.ts b/extensions/shared/truncate.test.ts index 71c6a9e..cd9c644 100644 --- a/extensions/shared/truncate.test.ts +++ b/extensions/shared/truncate.test.ts @@ -1,7 +1,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; -import { truncateToWidth } from "./truncate"; +import { truncateToWidth, wrapToWidth } from "./truncate"; const ESC = String.fromCodePoint(0x001b); const RED = `${ESC}[31m`; @@ -60,3 +60,113 @@ describe("truncateToWidth", () => { ); }); }); + +describe("wrapToWidth", () => { + it("returns a single padded row for short text", () => { + expect(wrapToWidth("hi", 10)).toEqual(["hi "]); + expect(wrapToWidth("", 4)).toEqual([" "]); + }); + + it("wraps a long line into multiple rows", () => { + const rows = wrapToWidth("the quick brown fox", 10); + expect(rows).toHaveLength(2); + expect(rows[0]).toBe("the quick "); + expect(rows[1]).toBe("brown fox "); + for (const row of rows) { + expect(visibleWidth(row)).toBe(10); + } + }); + + it("returns empty array for zero width", () => { + expect(wrapToWidth("text", 0)).toEqual([]); + }); + + it("wraps ASCII text at exact column boundaries", () => { + const rows = wrapToWidth("abcdef", 3); + expect(rows).toEqual(["abc", "def"]); + }); + + it("never splits a wide character across rows", () => { + // Each CJK char is 2 cells. With width 3, one char (2 cells) fits but + // two (4 cells) do not, so each row holds one char + 1 space pad. + const rows = wrapToWidth("\u65e5\u672c\u8a9e", 3); + expect(rows).toHaveLength(3); + expect(visibleWidth(rows[0])).toBe(3); + expect(rows[0]).toBe("\u65e5 "); + expect(rows[1]).toBe("\u672c "); + expect(rows[2]).toBe("\u8a9e "); + }); + + it("wraps text with ANSI SGR and carries colour across chunks", () => { + const rows = wrapToWidth(`${RED}red text that is long${RESET}`, 8); + expect(rows.length).toBeGreaterThanOrEqual(2); + for (const row of rows) { + expect(visibleWidth(row)).toBe(8); + } + // The first chunk should start with the SGR opener. + expect(rows[0].startsWith(RED)).toBe(true); + // Every continuation chunk should re-open the colour so it is not lost. + expect(rows[1].startsWith(RED)).toBe(true); + // The last chunk should end with a reset. + expect(rows[rows.length - 1].trimEnd().endsWith(RESET)).toBe(true); + }); + + it("does not re-open colour after a reset mid-string", () => { + const text = `${RED}red${RESET} normal text here that is long`; + const rows = wrapToWidth(text, 8); + expect(rows.length).toBeGreaterThanOrEqual(2); + // The first row starts with RED (it contains the red text + reset). + expect(rows[0].startsWith(RED)).toBe(true); + // Continuation rows after the reset should NOT start with RED. + for (const row of rows.slice(1)) { + expect(row.startsWith(RED)).toBe(false); + } + }); + + it("expands tabs to 3 columns when wrapping", () => { + // A tab is 3 cells; with width 6 the tab + "ab" (5 cells) fits on row 1, + // and the remaining text wraps to row 2. + const rows = wrapToWidth("\tabcdef", 6); + expect(rows.length).toBeGreaterThanOrEqual(2); + for (const row of rows) { + expect(visibleWidth(row)).toBe(6); + } + }); + + it("handles a mix of ANSI, wide chars, and tabs", () => { + const text = `${RED}\u65e5\u672c\ttext${RESET} more stuff here`; + const rows = wrapToWidth(text, 6); + expect(rows.length).toBeGreaterThanOrEqual(2); + for (const row of rows) { + expect(visibleWidth(row)).toBe(6); + } + // No row should contain a partial surrogate pair. + for (const row of rows) { + expect(row).not.toMatch(/[\uD800-\uDBFF]$/u); + } + }); + + it("pads every row to the full width", () => { + const rows = wrapToWidth("short", 10); + expect(rows).toEqual(["short "]); + }); + + it("narrows continuation rows when contIndent is set", () => { + // With contIndent=2, first row gets 10 cells, continuation rows get 8. + const rows = wrapToWidth("the quick brown fox jumps", 10, 2); + expect(rows.length).toBeGreaterThanOrEqual(2); + expect(visibleWidth(rows[0])).toBe(10); + // Continuation rows are narrower (8 cells of text content). + for (const row of rows.slice(1)) { + expect(visibleWidth(row)).toBe(8); + } + }); + + it("contIndent=0 wraps all rows to the same width", () => { + const rows = wrapToWidth("the quick brown fox jumps", 10, 0); + expect(rows.length).toBeGreaterThanOrEqual(2); + for (const row of rows) { + expect(visibleWidth(row)).toBe(10); + } + }); +}); diff --git a/extensions/shared/truncate.ts b/extensions/shared/truncate.ts index 727b729..5ed759f 100644 --- a/extensions/shared/truncate.ts +++ b/extensions/shared/truncate.ts @@ -1,5 +1,194 @@ import { visibleWidth } from "@earendil-works/pi-tui"; +/** + * Wrap text to `maxWidth` display cells, returning one string per row. + * + * Walks graphemes like `truncateToWidth`, expands tabs to 3-column stops, + * handles wide characters (never splits one across a row boundary), and + * carries open SGR state across wrapped chunks so colours survive wrapping. + * + * Each returned chunk is padded to its row width and closed with a reset if + * any SGR is left open, matching the contract of `truncateToWidth(_, _, "", true)`. + * + * When `contIndent` > 0, the first row wraps to `maxWidth` and every + * continuation row wraps to `maxWidth - contIndent` (the caller prepends the + * indent prefix to those rows). This mirrors `less` narrowing wrapped rows. + */ +export function wrapToWidth( + text: string, + maxWidth: number, + contIndent = 0, +): string[] { + if (maxWidth <= 0) return []; + const contWidth = Math.max(1, maxWidth - contIndent); + if (text.length === 0) return [" ".repeat(maxWidth)]; + + if (isPrintableAscii(text)) { + const rows: string[] = []; + let rowWidth = maxWidth; + let index = 0; + while (index < text.length) { + const slice = text.slice(index, index + rowWidth); + rows.push(slice + " ".repeat(rowWidth - slice.length)); + index += rowWidth; + rowWidth = contWidth; // continuation rows are narrower + } + return rows; + } + + const hasAnsi = text.includes("\u001b"); + const hasTabs = text.includes("\t"); + + if (!hasAnsi && !hasTabs) { + return wrapPlainGraphemes(text, maxWidth, contWidth); + } + + return wrapWithAnsiAndTabs(text, maxWidth, contWidth); +} + +function wrapPlainGraphemes( + text: string, + firstWidth: number, + contWidth: number, +): string[] { + const rows: string[] = []; + let current = ""; + let width = 0; + let rowWidth = firstWidth; + + for (const { segment } of segmenter.segment(text)) { + const segmentWidth = visibleWidth(segment); + if (width + segmentWidth > rowWidth && current.length > 0) { + rows.push(current + " ".repeat(rowWidth - width)); + current = ""; + width = 0; + rowWidth = contWidth; + } + current += segment; + width += segmentWidth; + } + + rows.push(current + " ".repeat(rowWidth - width)); + return rows; +} + +function wrapWithAnsiAndTabs( + text: string, + firstWidth: number, + contWidth: number, +): string[] { + const ESCAPE = "\u001b"; + const RESET = `${ESCAPE}[0m`; + const SGR_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;:]*m`, "u"); + const rows: string[] = []; + let current = ""; + let width = 0; + let rowWidth = firstWidth; + let pendingAnsi = ""; + /** SGR sequences seen so far that are still "open" (not reset). */ + let openSgr = ""; + + const flushRow = () => { + const padded = current + " ".repeat(rowWidth - width); + // Close any open SGR so colour does not bleed into the padding or the + // next row. The continuation row will re-open it. + if (openSgr && !padded.trimEnd().endsWith(RESET)) { + rows.push(`${padded}${RESET}`); + } else { + rows.push(padded); + } + current = ""; + width = 0; + rowWidth = contWidth; + }; + + let index = 0; + while (index < text.length) { + const ansi = readAnsiSequence(text, index); + if (ansi) { + pendingAnsi += ansi; + // Track SGR state: a reset clears open styles; any other SGR + // accumulates so we can re-emit it at the start of continuation rows. + if (SGR_PATTERN.test(ansi)) { + if (ansi === RESET) { + openSgr = ""; + } else { + openSgr += ansi; + } + } + index += ansi.length; + continue; + } + + if (text[index] === "\t") { + const tabWidth = 3; + if (width + tabWidth > rowWidth && current.length > 0) { + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + flushRow(); + // Re-emit open SGR at the start of the continuation row. + if (openSgr) { + current = openSgr; + } + } + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + current += " "; + width += tabWidth; + index++; + continue; + } + + // Gather a run of non-ANSI, non-tab characters, then segment it. + let end = index; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = readAnsiSequence(text, end); + if (nextAnsi) break; + end++; + } + + for (const { segment } of segmenter.segment(text.slice(index, end))) { + const segmentWidth = visibleWidth(segment); + if (width + segmentWidth > rowWidth && current.length > 0) { + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + flushRow(); + // Re-emit open SGR at the start of the continuation row. + if (openSgr) { + current = openSgr; + } + } + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + current += segment; + width += segmentWidth; + } + + index = end; + } + + // Flush the final row. + if (pendingAnsi) { + current += pendingAnsi; + } + const padded = current + " ".repeat(rowWidth - width); + if (openSgr && !padded.trimEnd().endsWith(RESET)) { + rows.push(`${padded}${RESET}`); + } else { + rows.push(padded); + } + + return rows; +} + export function truncateToWidth( text: string, maxWidth: number,