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
79 changes: 79 additions & 0 deletions clients/tui/__tests__/BodyLines.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "./helpers/renderTui";
import { BodyLines } from "../src/components/BodyLines.js";
import {
MAX_BODY_LINE_CHARS,
MAX_BODY_LINES,
clipLine,
formatBody,
layoutBody,
} from "../src/utils/bodyLines.js";

describe("bodyLines", () => {
it("formatBody pretty-prints JSON and passes anything else through", () => {
expect(formatBody('{"a":1}')).toBe('{\n "a": 1\n}');
expect(formatBody("not json{")).toBe("not json{");
});

it("clipLine leaves a short line alone and notes what it cut from a long one", () => {
expect(clipLine("abc", 3)).toBe("abc");
expect(clipLine("abcdef", 3)).toBe("abc… (+3 chars)");
});

it("layoutBody returns a small body whole", () => {
expect(layoutBody('{"a":1}')).toEqual({
lines: ["{", ' "a": 1', "}"],
hiddenLines: 0,
totalLines: 3,
});
});

it("layoutBody caps the line count and reports the rest", () => {
const body = JSON.stringify(Array.from({ length: 1000 }, (_, i) => i));
const { lines, hiddenLines, totalLines } = layoutBody(body);
expect(totalLines).toBe(1002);
expect(lines).toHaveLength(MAX_BODY_LINES);
expect(hiddenLines).toBe(1002 - MAX_BODY_LINES);
});

it("layoutBody clips one enormous line, e.g. an embedded base64 blob", () => {
const blob = "x".repeat(MAX_BODY_LINE_CHARS + 50);
const { lines } = layoutBody(JSON.stringify({ blob }));
expect(lines[1].length).toBeLessThan(MAX_BODY_LINE_CHARS + 30);
expect(lines[1]).toContain("chars)");
});

it("layoutBody applies caller-supplied caps to a raw body", () => {
expect(layoutBody("a\nb\nc", 2, 10)).toEqual({
lines: ["a", "b"],
hiddenLines: 1,
totalLines: 3,
});
});
});

describe("BodyLines", () => {
it("renders a small JSON body pretty-printed with no truncation note", () => {
const { lastFrame } = render(
<BodyLines body='{"ok":true}' keyPrefix="b" />,
);
const frame = lastFrame() ?? "";
expect(frame).toContain('"ok": true');
expect(frame).not.toContain("more lines not shown");
});

it("renders at most the cap and says how much was cut", () => {
const body = Array.from(
{ length: MAX_BODY_LINES + 25 },
(_, i) => `line-${i}`,
).join("\n");
const { lastFrame } = render(<BodyLines body={body} keyPrefix="b" />);
const frame = lastFrame() ?? "";
expect(frame).toContain(`line-${MAX_BODY_LINES - 1}`);
expect(frame).not.toContain(`line-${MAX_BODY_LINES}\n`);
expect(frame).toContain(
`… 25 more lines not shown (${MAX_BODY_LINES + 25} total)`,
);
});
});
49 changes: 3 additions & 46 deletions clients/tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { ToolTestModal } from "./components/ToolTestModal.js";
import { ResourceTestModal } from "./components/ResourceTestModal.js";
import { PromptTestModal } from "./components/PromptTestModal.js";
import { DetailsModal } from "./components/DetailsModal.js";
import { BodyLines } from "./components/BodyLines.js";
import type { TuiServer } from "./tui-servers.js";

// Header branding. The version is the single source of truth — the root
Expand Down Expand Up @@ -1279,29 +1280,7 @@ function App({
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(request.requestBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`req-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{request.requestBody}</Text>
</Box>
);
}
})()}
<BodyLines body={request.requestBody} keyPrefix="req-body" />
</>
)}
{request.responseHeaders &&
Expand All @@ -1324,29 +1303,7 @@ function App({
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(request.responseBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`resp-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{request.responseBody}</Text>
</Box>
);
}
})()}
<BodyLines body={request.responseBody} keyPrefix="resp-body" />
</>
)}
</>
Expand Down
40 changes: 40 additions & 0 deletions clients/tui/src/components/BodyLines.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import { Box, Text } from "ink";
import { layoutBody } from "../utils/bodyLines.js";

/**
* Renders a request/response body as indented, dimmed lines with a bounded
* component count (#2407) — see `utils/bodyLines.ts` for the caps and why both
* exist. Shared by the Requests tab and the App details view, which previously
* carried four copies of an uncapped line map.
*/
export function BodyLines({
body,
keyPrefix,
}: {
body: string;
keyPrefix: string;
}) {
const { lines, hiddenLines, totalLines } = layoutBody(body);
return (
<>
{lines.map((line, idx) => (
<Box
key={`${keyPrefix}-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
))}
{hiddenLines > 0 && (
<Box paddingLeft={2} flexShrink={0}>
<Text dimColor italic>
… {hiddenLines} more lines not shown ({totalLines} total)
</Text>
</Box>
)}
</>
);
}
55 changes: 9 additions & 46 deletions clients/tui/src/components/RequestsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Box, Text, useInput, type Key } from "ink";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import type { FetchRequestEntry } from "@inspector/core/mcp/index.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
import { BodyLines } from "./BodyLines.js";

interface RequestsTabProps {
serverName: string | null;
Expand Down Expand Up @@ -262,29 +263,10 @@ export function RequestsTab({
<Box marginTop={1} flexShrink={0}>
<Text bold>Request Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(selectedRequest.requestBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`req-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{selectedRequest.requestBody}</Text>
</Box>
);
}
})()}
<BodyLines
body={selectedRequest.requestBody}
keyPrefix="req-body"
/>
</>
)}

Expand Down Expand Up @@ -318,29 +300,10 @@ export function RequestsTab({
<Box marginTop={1} flexShrink={0}>
<Text bold>Response Body:</Text>
</Box>
{(() => {
try {
const parsed = JSON.parse(selectedRequest.responseBody);
return JSON.stringify(parsed, null, 2)
.split("\n")
.map((line: string, idx: number) => (
<Box
key={`resp-body-${idx}`}
marginTop={idx === 0 ? 1 : 0}
paddingLeft={2}
flexShrink={0}
>
<Text dimColor>{line}</Text>
</Box>
));
} catch {
return (
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
<Text dimColor>{selectedRequest.responseBody}</Text>
</Box>
);
}
})()}
<BodyLines
body={selectedRequest.responseBody}
keyPrefix="resp-body"
/>
</>
)}
</ScrollView>
Expand Down
58 changes: 58 additions & 0 deletions clients/tui/src/utils/bodyLines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Bounded line layout for an HTTP request/response body shown in the TUI.
*
* Ink renders each body line as its own `<Box>`, so an uncapped body — a large
* file listing, an embedded resource, a big search result — became thousands
* of components in one render pass and could freeze the terminal (#2407). This
* caps both the line count and each line's length: a pretty-printed embedded
* resource is typically a single enormous base64 line, which a line cap alone
* does nothing about. What was cut is reported rather than dropped silently,
* so a truncated body never reads as the whole payload.
*
* Pure by design (utils = compute): the component that renders the result is
* `components/BodyLines.tsx`.
*/

/** Most body lines rendered before the rest are summarized. */
export const MAX_BODY_LINES = 500;

/** Longest single line rendered before its tail is summarized. */
export const MAX_BODY_LINE_CHARS = 2000;

export interface BodyLayout {
/** The lines to render, each already clipped to `maxLineChars`. */
lines: string[];
/** Lines of the formatted body not included in `lines`. */
hiddenLines: number;
/** Line count of the full formatted body. */
totalLines: number;
}

/** Pretty-prints `body` when it parses as JSON; otherwise returns it as is. */
export function formatBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}

/** Clips `line` to `maxChars`, noting how many characters were cut. */
export function clipLine(line: string, maxChars: number): string {
if (line.length <= maxChars) return line;
return `${line.slice(0, maxChars)}… (+${line.length - maxChars} chars)`;
}

/** Formats `body` and bounds it to at most `maxLines` lines of `maxLineChars`. */
export function layoutBody(
body: string,
maxLines: number = MAX_BODY_LINES,
maxLineChars: number = MAX_BODY_LINE_CHARS,
): BodyLayout {
const all = formatBody(body).split("\n");
return {
lines: all.slice(0, maxLines).map((line) => clipLine(line, maxLineChars)),
hiddenLines: Math.max(all.length - maxLines, 0),
totalLines: all.length,
};
}
Loading