Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0d88211636 fix(terminal): preserve adjacent wide characters
Terminal snapshots converted zero-width continuation cells into spaces, which altered captured and restored output.
2026-07-22 12:13:27 +00:00
9 changed files with 74 additions and 79 deletions
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
type StreamStrategy,
@@ -311,34 +310,6 @@ describe("layoutStream", () => {
expect(footerOwners(layout)).toEqual([assistant.id]);
});
it.each(["web", "android"] as const)(
"copies every assistant block when a completed turn spans history and live head on %s",
(platform) => {
const strategy = strategyFor(platform);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
assistantMessage("first block", 2, { groupId: "turn-1", index: 0 }),
assistantMessage("second block", 3, { groupId: "turn-1", index: 1 }),
],
head: [assistantMessage("final block", 4, { groupId: "turn-1", index: 2 })],
});
const footer = layout.auxiliaryTurnFooter;
expect(footer).not.toBeNull();
expect(
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items: footer!.items,
startIndex: footer!.startIndex,
precedingItems: footer!.precedingItems,
precedingStartIndex: footer!.precedingStartIndex,
}),
).toBe("first block\n\nsecond block\n\nfinal block");
},
);
it.each(["web", "android"] as const)(
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
-13
View File
@@ -10,8 +10,6 @@ export interface TurnFooterHost {
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
precedingItems: StreamItem[] | null;
precedingStartIndex: number | null;
}
export interface StreamLayoutItem {
@@ -68,16 +66,12 @@ function createTurnFooterHost(input: {
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
precedingItems: input.precedingItems ?? null,
precedingStartIndex: input.precedingStartIndex ?? null,
};
}
@@ -147,11 +141,6 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
precedingItems: footerItems === input.liveHead ? input.history : null,
precedingStartIndex:
footerItems === input.liveHead
? input.strategy.getHistoryLiveBoundaryIndex(input.history)
: null,
});
}
@@ -185,8 +174,6 @@ function resolveCompletedFooter(input: {
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
precedingItems: input.boundaryAboveItems,
precedingStartIndex: input.boundaryAboveIndex,
});
}
+1 -18
View File
@@ -272,25 +272,8 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): string {
const content = params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
if (!params.precedingItems || params.precedingStartIndex == null) {
return content;
}
const precedingContent = params.strategy.collectAssistantTurnContent(
params.precedingItems,
params.precedingStartIndex,
);
if (!precedingContent) {
return content;
}
if (!content) {
return precedingContent;
}
return `${precedingContent}\n\n${content}`;
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
}
export function isNearBottomForStreamRenderStrategy(
+27 -15
View File
@@ -3,6 +3,8 @@ import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
@@ -60,7 +62,9 @@ export const TurnFooter = memo(function TurnFooter({
return (
<CompletedTurnFooterRow
strategy={strategy}
host={host}
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn}
/>
@@ -69,12 +73,16 @@ export const TurnFooter = memo(function TurnFooter({
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
host,
items,
timing,
startIndex,
supportsTimelineCursor,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
host: TurnFooterHost;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
@@ -82,7 +90,9 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
<TurnFooterRow>
<CompletedTurnFooter
strategy={strategy}
host={host}
items={items}
timing={timing}
startIndex={startIndex}
supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn}
/>
@@ -123,12 +133,16 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
function CompletedTurnFooter({
strategy,
host,
items,
timing,
startIndex,
supportsTimelineCursor,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
host: TurnFooterHost;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
@@ -136,16 +150,14 @@ function CompletedTurnFooter({
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items: host.items,
startIndex: host.startIndex,
precedingItems: host.precedingItems,
precedingStartIndex: host.precedingStartIndex,
items,
startIndex,
}),
[strategy, host],
[strategy, items, startIndex],
);
const boundary = resolveAssistantTurnForkBoundary({
items: host.items,
startIndex: host.startIndex,
items,
startIndex,
supportsTimelineCursor,
});
const handleFork = useCallback(
@@ -161,8 +173,8 @@ function CompletedTurnFooter({
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={host.timing?.completedAt}
durationMs={host.timing?.durationMs}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
/>
</View>
+3 -1
View File
@@ -153,7 +153,9 @@ function renderStreamItemWithTurnFooter(input: {
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
host={footerHost}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
supportsTimelineCursor={input.supportsTimelineCursor}
onForkAssistantTurn={input.onForkAssistantTurn}
/>
@@ -7,6 +7,20 @@ function cells(text: string): TerminalState["grid"][number] {
}
describe("renderTerminalSnapshotToAnsi", () => {
it("preserves adjacent wide characters", () => {
const state: TerminalState = {
rows: 1,
cols: 4,
scrollback: [],
grid: [[{ char: "\u4e2d" }, { char: "" }, { char: "\u6587" }, { char: "" }]],
cursor: { row: 0, col: 4 },
};
const ansi = renderTerminalSnapshotToAnsi(state);
expect(ansi).toContain("\u4e2d\u6587");
});
it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => {
// The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into
// two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1.
+4 -1
View File
@@ -87,12 +87,15 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
if (cell.char === "") {
continue;
}
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
output.push(cell.char);
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
@@ -1626,6 +1626,29 @@ describe("capture", () => {
expect(capture.totalLines).toBeGreaterThan(0);
}, 15000);
test("preserves adjacent CJK characters in captured output", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: `node -e "process.stdout.write('START\\u4e2d\\u6587END\\n')"\r`,
});
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("START\u4e2d\u6587END"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
expect(capture.lines).toContain("START\u4e2d\u6587END");
}, 15000);
test("captures with start/end line range", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
+2 -2
View File
@@ -494,7 +494,7 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
return {
char: cell.getChars() || " ",
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
@@ -553,7 +553,7 @@ function extractScrollback(
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
rowCells.push({
char: cell.getChars() || " ",
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,