diff --git a/src/renderer/ActivityLogOutput.tsx b/src/renderer/ActivityLogOutput.tsx
index b2d60d1..f3c7633 100644
--- a/src/renderer/ActivityLogOutput.tsx
+++ b/src/renderer/ActivityLogOutput.tsx
@@ -172,7 +172,10 @@ export function ActivityLogOutput({ log, wrapLines, onCopyRun, runId = null }: {
const ActivityLogBlockView = memo(function ActivityLogBlockView({ block }: { block: ActivityLogBlock }): ReactNode {
return
- {block.kind === "output" ? {block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream} : null}
+ {/* A continuation segment carries on the previous segment's stream, so it repeats no label. */}
+ {block.kind === "output" ?
+ {block.continuation ? null : block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream}
+ : null}
;
});
diff --git a/src/renderer/activityLog.test.ts b/src/renderer/activityLog.test.ts
index 1af0912..edf6320 100644
--- a/src/renderer/activityLog.test.ts
+++ b/src/renderer/activityLog.test.ts
@@ -4,6 +4,8 @@ import type { GitOutputEvent } from "../shared/types";
import {
ACTIVITY_LOG_MAX_BLOCKS,
ACTIVITY_LOG_MAX_RAW_CHARS,
+ ACTIVITY_LOG_SEGMENT_CHARS,
+ ACTIVITY_LOG_SEGMENT_MAX_CHARS,
appendActivityLogEvent,
appendActivityOperationResult,
createActivityLogState,
@@ -74,6 +76,30 @@ describe("activityLog", () => {
expect(getActivityLogRawText(state)).toBe("[stdout] one two\n");
});
+ it("closes segments only at line boundaries when chunks end mid-line", () => {
+ let state = createActivityLogState();
+ const lines: string[] = [];
+ for (let index = 0; index < 500; index++) lines.push(`[${index}/500] Compile [x64] Module.Example.${index}.cpp\n`);
+ const text = lines.join("");
+ // Uneven chunks that rarely end at a newline, like a build tool's piped output.
+ for (let offset = 0; offset < text.length; offset += 37) state = appendActivityLogEvent(state, output({ text: text.slice(offset, offset + 37) }));
+
+ expect(state.blocks.length).toBeGreaterThan(1);
+ for (const block of state.blocks.slice(0, -1)) expect(block.rawText.endsWith("\n")).toBe(true);
+ expect(state.blocks.slice(1).every((block) => block.continuation)).toBe(true);
+ expect(state.blocks.map((block) => block.rawText).join("")).toBe(text);
+ expect(getActivityLogRawText(state)).toBe(`[stdout] ${text}`);
+ });
+
+ it("splits a single line only beyond the hard segment cap", () => {
+ const line = `${"a".repeat(ACTIVITY_LOG_SEGMENT_CHARS * 2)}\n`;
+ let state = appendActivityLogEvent(createActivityLogState(), output({ text: line }));
+ expect(state.blocks.map((block) => block.rawText)).toEqual([line]);
+
+ state = appendActivityLogEvent(createActivityLogState(), output({ text: "b".repeat(ACTIVITY_LOG_SEGMENT_MAX_CHARS + 10) }));
+ expect(state.blocks.map((block) => block.rawText.length)).toEqual([ACTIVITY_LOG_SEGMENT_MAX_CHARS, 10]);
+ });
+
it("starts a new block when the stream changes", () => {
let state = createActivityLogState();
@@ -203,7 +229,7 @@ it("bounds segments and preserves ANSI state across interleaved streams and trim
state = appendActivityLogEvent(state, output({ text: "\u001b[31m" + "a".repeat(ACTIVITY_LOG_MAX_RAW_CHARS) }));
state = appendActivityLogEvent(state, output({ stream: "stderr", text: "progress\n" }));
state = appendActivityLogEvent(state, output({ text: "red tail\u001b[0m\n" }));
- expect(state.blocks.every((block) => block.rawText.length <= 4096)).toBe(true);
+ expect(state.blocks.every((block) => block.rawText.length <= ACTIVITY_LOG_SEGMENT_MAX_CHARS)).toBe(true);
expect(state.blocks.at(-1)?.html).toContain("ansi-red-fg");
expect(state.blocks.at(-1)?.html).toContain("red tail");
expect(state.rawTextLength).toBeLessThanOrEqual(ACTIVITY_LOG_MAX_RAW_CHARS);
diff --git a/src/renderer/activityLog.ts b/src/renderer/activityLog.ts
index f050742..ce2c151 100644
--- a/src/renderer/activityLog.ts
+++ b/src/renderer/activityLog.ts
@@ -48,6 +48,8 @@ export const ACTIVITY_LOG_MAX_RAW_CHARS = 2_000_000;
export const ACTIVITY_LOG_MAX_BLOCKS = 3_000;
export const ACTIVITY_LOG_MAX_RUNS = 100;
export const ACTIVITY_LOG_SEGMENT_CHARS = 4_096;
+/** A segment may exceed ACTIVITY_LOG_SEGMENT_CHARS up to this size to finish its last line. */
+export const ACTIVITY_LOG_SEGMENT_MAX_CHARS = 16_384;
const TRIM_NOTICE_TEXT = "... older output trimmed ...\n";
const TERMINAL_HYPERLINK_PREFIX = "\u001B]8;";
@@ -119,15 +121,8 @@ export function appendActivityLogEvent(state: ActivityLogState, event: GitOutput
while (offset < event.text.length && parser) {
const last = blocks.at(-1);
const adjacent = last?.kind === "output" && last.runId === event.runId && last.stream === event.stream && last.action === event.action;
- const append = adjacent && !last.sealed && last.rawText.length < ACTIVITY_LOG_SEGMENT_CHARS;
- const remaining = ACTIVITY_LOG_SEGMENT_CHARS - (append ? last.rawText.length : 0);
- let text = event.text.slice(offset, offset + remaining);
- const sealed = text.length === remaining;
- // Prefer complete lines when closing a segment. A single oversized line still has a hard cap.
- if (sealed) {
- const newline = text.lastIndexOf("\n");
- if (newline >= 0) text = text.slice(0, newline + 1);
- }
+ const append = adjacent && !last.sealed;
+ const { text, sealed } = nextSegmentText(event.text, offset, append ? last.rawText.length : 0);
const html = parser.converter.ansi_to_html(completeAnsiSequences(parser, stripTerminalHyperlinks(parser, text)));
if (append) {
blocks[blocks.length - 1] = { ...last, rawText: last.rawText + text, html: last.html + html, sealed };
@@ -172,6 +167,22 @@ export function getActivityLogRawText(state: ActivityLogState, runId?: string):
}).join("");
}
+/**
+ * Takes the next part of an event for the open segment. A segment closes only after a newline so that
+ * no line is split across rows. A segment grows past its target size to finish a line; a single line
+ * longer than the hard cap still closes the segment mid-line.
+ */
+function nextSegmentText(eventText: string, offset: number, segmentLength: number): { text: string; sealed: boolean } {
+ const target = Math.max(0, ACTIVITY_LOG_SEGMENT_CHARS - segmentLength);
+ const hardCap = Math.max(1, ACTIVITY_LOG_SEGMENT_MAX_CHARS - segmentLength);
+ const rest = eventText.slice(offset, offset + hardCap);
+ if (segmentLength + eventText.length - offset < ACTIVITY_LOG_SEGMENT_CHARS) return { text: rest, sealed: false };
+ const lastNewline = rest.lastIndexOf("\n", target - 1);
+ const newline = lastNewline >= 0 ? lastNewline : rest.indexOf("\n", target);
+ if (newline >= 0) return { text: rest.slice(0, newline + 1), sealed: true };
+ return { text: rest, sealed: rest.length === hardCap };
+}
+
function trimActivityLog(state: ActivityLogState): ActivityLogState {
let { blocks, runs, rawTextLength, nextBlockId, trimmed } = state;
if (runs.length > ACTIVITY_LOG_MAX_RUNS) {