Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/log-viewer-line-wrapping.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions extensions/processes-logs/components/log-file-viewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("→");
});
});
135 changes: 127 additions & 8 deletions extensions/processes-logs/components/log-file-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string>();

constructor(
Expand Down Expand Up @@ -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),
);
}

Expand All @@ -75,27 +82,39 @@ 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;
}

isFollowing(): boolean {
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"
? "stdout"
: this.streamFilter === "stdout"
? "stderr"
: "both";
this.anchorEnd = this.visibleLines().length;
this.anchorEnd = this.totalDisplayRows(0);
this.refreshMatches();
return this.streamFilter;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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 };
}
Expand All @@ -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 = [];
Expand Down
Loading