Skip to content

Commit 108d9a7

Browse files
committed
strip ANSI resets from truncateToWidth in collapsed spawn renderer
1 parent a334991 commit 108d9a7

2 files changed

Lines changed: 109 additions & 10 deletions

File tree

agenticoding.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ const theme = {
3434
bold: (text: string) => text,
3535
} as unknown as Theme;
3636

37+
const ansiTheme = {
38+
fg: (_name: string, text: string) => `\u001b[38;5;245m${text}\u001b[39m`,
39+
bg: (_name: string, text: string) => `\u001b[48;5;236m${text}\u001b[49m`,
40+
bold: (text: string) => text,
41+
} as unknown as Theme;
42+
3743
function createRenderContext(overrides: Record<string, unknown> = {}): Record<string, unknown> {
3844
return {
3945
expanded: false,
@@ -59,6 +65,23 @@ function stripAnsi(text: string): string {
5965
return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\u001b\][^\u0007]*\u0007/g, "");
6066
}
6167

68+
function getRenderedLine(lines: string[], match: (plain: string) => boolean): string {
69+
const line = lines.find(candidate => match(stripAnsi(candidate)));
70+
assert.ok(line);
71+
return line;
72+
}
73+
74+
function getLineContaining(lines: string[], text: string): string {
75+
const line = lines.find(candidate => candidate.includes(text));
76+
assert.ok(line);
77+
return line;
78+
}
79+
80+
function assertShellBackgroundPreserved(line: string): void {
81+
assert.equal(line.includes("\u001b[0m"), false);
82+
assert.match(line, /\u001b\[48;/);
83+
}
84+
6285
function createDeferred() {
6386
let resolve!: () => void;
6487
const promise = new Promise<void>((r) => { resolve = r; });
@@ -580,6 +603,71 @@ test("collapsed nested spawn render keeps all text blocks from the last assistan
580603
assert.ok(lines.some((l: string) => l.includes("second")));
581604
});
582605

606+
test("collapsed nested spawn truncation preserves shell background across preview and stats lines", () => {
607+
const state = createState();
608+
const childSpawnTool = createChildSpawnTool(state);
609+
const session = createSession([
610+
{ role: "assistant", content: [{ type: "text", text: "Research the nudge on toggle off TODO from the readonly mode plan." }] },
611+
]);
612+
state.childSessions.set("tool-call-1", session);
613+
614+
const component = childSpawnTool.renderResult(
615+
{
616+
content: [{ type: "text", text: "ignored" }],
617+
details: {
618+
model: "mock-model",
619+
thinking: "medium",
620+
truncated: true,
621+
stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 },
622+
},
623+
},
624+
{ expanded: false },
625+
ansiTheme,
626+
createRenderContext(),
627+
) as any;
628+
629+
const lines = component.render(24);
630+
const previewLine = getRenderedLine(lines, plain => plain.includes("Research"));
631+
const statsLine = getRenderedLine(lines, plain => plain.includes("tok 12/34"));
632+
assertShellBackgroundPreserved(previewLine);
633+
assertShellBackgroundPreserved(statsLine);
634+
assert.match(stripAnsi(statsLine), /tok 12\/34/);
635+
});
636+
637+
test("collapsed nested spawn keeps truncated stats line calm", () => {
638+
const markerTheme = {
639+
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
640+
bg: (_name: string, text: string) => text,
641+
bold: (text: string) => text,
642+
} as unknown as Theme;
643+
const state = createState();
644+
const childSpawnTool = createChildSpawnTool(state);
645+
const session = createSession([
646+
{ role: "assistant", content: [{ type: "text", text: "short preview" }] },
647+
]);
648+
state.childSessions.set("tool-call-1", session);
649+
650+
const component = childSpawnTool.renderResult(
651+
{
652+
content: [{ type: "text", text: "ignored" }],
653+
details: {
654+
model: "mock-model",
655+
thinking: "medium",
656+
truncated: true,
657+
stats: { inputTokens: 12, outputTokens: 34, turns: 2, cost: 0.125 },
658+
},
659+
},
660+
{ expanded: false },
661+
markerTheme,
662+
createRenderContext(),
663+
) as any;
664+
665+
const lines = component.render(120);
666+
const statsLine = getLineContaining(lines, "tok 12/34");
667+
assert.match(statsLine, /<dim>.*tok 12\/34.*trunc.*<\/dim>/);
668+
assert.equal(statsLine.includes("<warning>"), false);
669+
});
670+
583671
test("nested spawn render is safe without details", () => {
584672
const state = createState();
585673
const childSpawnTool = createChildSpawnTool(state);

spawn/renderer.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ function wrapSpawnShell(lines: string[], width: number, theme: Theme | undefined
198198
];
199199
}
200200

201+
function truncatePlainText(text: string, width: number): string {
202+
// truncateToWidth() may inject ANSI resets even when truncating plain
203+
// unicode text. Strip them here so outer shell/background styling stays intact.
204+
return truncateToWidth(text, width).replace(/\u001b\[[0-9;]*m/g, "");
205+
}
206+
207+
function truncateAndColor(text: string, width: number, color: (name: ThemeColor, text: string) => string, colorName: ThemeColor): string {
208+
return color(colorName, truncatePlainText(text, width));
209+
}
210+
211+
201212
function formatCollapsedStats(details: SpawnResultDetails): { text: string; color: ThemeColor } | undefined {
202213
if (details.stats) {
203214
const s = details.stats;
@@ -809,19 +820,21 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
809820

810821
// Identity line — distinguishes nested spawns in collapsed view
811822
if (details) {
812-
lines.push(truncateToWidth(
813-
color("dim", `${getOutcomeMarker(outcome)}${details.model}${details.thinking}`),
823+
lines.push(truncateAndColor(
824+
`${getOutcomeMarker(outcome)}${details.model}${details.thinking}`,
814825
width,
826+
color,
827+
"dim",
815828
));
816829
}
817830

818831
if (outcome === "running") {
819832
const liveSummary = this.lastAction || "⏳ initializing…";
820-
lines.push(truncateToWidth(color("dim", liveSummary), width));
833+
lines.push(truncateAndColor(liveSummary, width, color, "dim"));
821834
} else if (outcome !== "success") {
822835
const outcomeText = getOutcomeStatusText(outcome);
823836
if (outcomeText) {
824-
lines.push(truncateToWidth(color(outcome === "error" ? "warning" : "dim", outcomeText), width));
837+
lines.push(truncateAndColor(outcomeText, width, color, outcome === "error" ? "warning" : "dim"));
825838
}
826839
}
827840

@@ -832,20 +845,17 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
832845
const maxLines = COLLAPSED_PREVIEW_MAX_LINES;
833846
const shown = textLines.slice(0, maxLines);
834847
for (const line of shown) {
835-
lines.push(truncateToWidth(color("toolOutput", line), width));
848+
lines.push(truncateAndColor(line, width, color, "toolOutput"));
836849
}
837850
const remaining = textLines.length - maxLines;
838851
if (remaining > 0) {
839-
lines.push(truncateToWidth(
840-
color("muted", `... ${remaining} more lines`),
841-
width,
842-
));
852+
lines.push(truncateAndColor(`... ${remaining} more lines`, width, color, "muted"));
843853
}
844854
}
845855

846856
const statsLine = details ? formatCollapsedStats(details) : undefined;
847857
if (statsLine) {
848-
lines.push(truncateToWidth(color(statsLine.color, statsLine.text), width));
858+
lines.push(truncateAndColor(statsLine.text, width, color, statsLine.color));
849859
}
850860

851861
return lines;
@@ -863,6 +873,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
863873

864874
// Show identity header when expanded — anchors which nested session this is
865875
const colorExpanded = (name: ThemeColor, text: string) => this.nestTheme ? this.nestTheme.fg(name, text) : text;
876+
// Expanded mode has no shell background — safe to color before truncation
866877
if (this.details) {
867878
const header = `${getOutcomeMarker(this.liveOutcome)}${this.details.model}${this.details.thinking}`;
868879
lines.push(leftPad + truncateToWidth(

0 commit comments

Comments
 (0)