Skip to content

Commit e61a021

Browse files
committed
🤖 fix: normalize live bash output rendering
Change-Id: If9a4747c2cd9cc9461d1730445e83f9cf219a61d Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 791374b commit e61a021

File tree

3 files changed

+41
-8
lines changed

3 files changed

+41
-8
lines changed

src/browser/components/tools/BashToolCall.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface BashToolCallProps {
4141
const EMPTY_LIVE_OUTPUT = {
4242
stdout: "",
4343
stderr: "",
44+
combined: "",
4445
truncated: false,
4546
};
4647

@@ -68,7 +69,7 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
6869
};
6970

7071
const liveOutputView = liveOutput ?? EMPTY_LIVE_OUTPUT;
71-
const combinedLiveOutput = liveOutputView.stdout + liveOutputView.stderr;
72+
const combinedLiveOutput = liveOutputView.combined;
7273

7374
useEffect(() => {
7475
const el = outputRef.current;
@@ -110,8 +111,6 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
110111
const showLiveOutput =
111112
!isBackground && (status === "executing" || (Boolean(liveOutput) && !resultHasOutput));
112113

113-
const liveLabelSuffix = status === "executing" ? " (live)" : " (tail)";
114-
115114
return (
116115
<ToolContainer expanded={expanded}>
117116
<ToolHeader onClick={toggleExpanded}>
@@ -189,7 +188,7 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
189188
)}
190189

191190
<DetailSection>
192-
<DetailLabel>{`Output${liveLabelSuffix}`}</DetailLabel>
191+
<DetailLabel>Output</DetailLabel>
193192
<DetailContent
194193
ref={outputRef}
195194
onScroll={(e) => updatePinned(e.currentTarget)}

src/browser/utils/messages/liveBashOutputBuffer.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,36 @@ describe("appendLiveBashOutputChunk", () => {
66
const a = appendLiveBashOutputChunk(undefined, { text: "out1\n", isError: false }, 1024);
77
expect(a.stdout).toBe("out1\n");
88
expect(a.stderr).toBe("");
9+
expect(a.combined).toBe("out1\n");
910
expect(a.truncated).toBe(false);
1011

1112
const b = appendLiveBashOutputChunk(a, { text: "err1\n", isError: true }, 1024);
1213
expect(b.stdout).toBe("out1\n");
1314
expect(b.stderr).toBe("err1\n");
15+
expect(b.combined).toBe("out1\nerr1\n");
1416
expect(b.truncated).toBe(false);
1517
});
1618

19+
it("normalizes carriage returns to newlines", () => {
20+
const a = appendLiveBashOutputChunk(undefined, { text: "a\rb", isError: false }, 1024);
21+
expect(a.stdout).toBe("a\nb");
22+
expect(a.combined).toBe("a\nb");
23+
24+
const b = appendLiveBashOutputChunk(undefined, { text: "a\r\nb", isError: false }, 1024);
25+
expect(b.stdout).toBe("a\nb");
26+
expect(b.combined).toBe("a\nb");
27+
});
28+
1729
it("drops the oldest segments to enforce maxBytes", () => {
1830
const maxBytes = 5;
1931
const a = appendLiveBashOutputChunk(undefined, { text: "1234", isError: false }, maxBytes);
2032
expect(a.stdout).toBe("1234");
33+
expect(a.combined).toBe("1234");
2134
expect(a.truncated).toBe(false);
2235

2336
const b = appendLiveBashOutputChunk(a, { text: "abc", isError: false }, maxBytes);
2437
expect(b.stdout).toBe("abc");
38+
expect(b.combined).toBe("abc");
2539
expect(b.truncated).toBe(true);
2640
});
2741

@@ -34,13 +48,15 @@ describe("appendLiveBashOutputChunk", () => {
3448
// total "a" (1) + "bb" (2) + "ccc" (3) = 6 (fits)
3549
expect(c.stdout).toBe("accc");
3650
expect(c.stderr).toBe("bb");
51+
expect(c.combined).toBe("abbccc");
3752
expect(c.truncated).toBe(false);
3853

3954
const d = appendLiveBashOutputChunk(c, { text: "DD", isError: true }, maxBytes);
4055
// total would be 8, so drop oldest segments until <= 6.
4156
// Drops stdout "a" (1) then stderr "bb" (2) => remaining "ccc" (3) + "DD" (2) = 5
4257
expect(d.stdout).toBe("ccc");
4358
expect(d.stderr).toBe("DD");
59+
expect(d.combined).toBe("cccDD");
4460
expect(d.truncated).toBe(true);
4561
});
4662

@@ -49,6 +65,7 @@ describe("appendLiveBashOutputChunk", () => {
4965
const a = appendLiveBashOutputChunk(undefined, { text: "hello", isError: false }, maxBytes);
5066
expect(a.stdout).toBe("");
5167
expect(a.stderr).toBe("");
68+
expect(a.combined).toBe("");
5269
expect(a.truncated).toBe(true);
5370
});
5471
});

src/browser/utils/messages/liveBashOutputBuffer.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export interface LiveBashOutputView {
22
stdout: string;
33
stderr: string;
4+
/** Combined output in emission order (stdout/stderr interleaved). */
5+
combined: string;
46
truncated: boolean;
57
}
68

@@ -21,6 +23,11 @@ export interface LiveBashOutputInternal extends LiveBashOutputView {
2123
totalBytes: number;
2224
}
2325

26+
function normalizeNewlines(text: string): string {
27+
// Many CLIs print "progress" output using carriage returns so they can update a single line.
28+
// In our UI, that reads better as actual line breaks.
29+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
30+
}
2431
function getUtf8ByteLength(text: string): number {
2532
return new TextEncoder().encode(text).length;
2633
}
@@ -39,30 +46,34 @@ export function appendLiveBashOutputChunk(
3946
({
4047
stdout: "",
4148
stderr: "",
49+
combined: "",
4250
truncated: false,
4351
segments: [],
4452
totalBytes: 0,
4553
} satisfies LiveBashOutputInternal);
4654

47-
if (chunk.text.length === 0) return base;
55+
const normalizedText = normalizeNewlines(chunk.text);
56+
if (normalizedText.length === 0) return base;
4857

4958
// Clone for purity (tests + avoids hidden mutation assumptions).
5059
const next: LiveBashOutputInternal = {
5160
stdout: base.stdout,
5261
stderr: base.stderr,
62+
combined: base.combined,
5363
truncated: base.truncated,
5464
segments: base.segments.slice(),
5565
totalBytes: base.totalBytes,
5666
};
5767

5868
const segment: LiveBashOutputSegment = {
5969
isError: chunk.isError,
60-
text: chunk.text,
61-
bytes: getUtf8ByteLength(chunk.text),
70+
text: normalizedText,
71+
bytes: getUtf8ByteLength(normalizedText),
6272
};
6373

6474
next.segments.push(segment);
6575
next.totalBytes += segment.bytes;
76+
next.combined += segment.text;
6677
if (segment.isError) {
6778
next.stderr += segment.text;
6879
} else {
@@ -75,6 +86,7 @@ export function appendLiveBashOutputChunk(
7586

7687
next.totalBytes -= removed.bytes;
7788
next.truncated = true;
89+
next.combined = next.combined.slice(removed.text.length);
7890

7991
if (removed.isError) {
8092
next.stderr = next.stderr.slice(removed.text.length);
@@ -91,5 +103,10 @@ export function appendLiveBashOutputChunk(
91103
}
92104

93105
export function toLiveBashOutputView(state: LiveBashOutputInternal): LiveBashOutputView {
94-
return { stdout: state.stdout, stderr: state.stderr, truncated: state.truncated };
106+
return {
107+
stdout: state.stdout,
108+
stderr: state.stderr,
109+
combined: state.combined,
110+
truncated: state.truncated,
111+
};
95112
}

0 commit comments

Comments
 (0)