From fd6a584fdcf052bef4e6050a1bd72d242c4e7551 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 24 Sep 2026 01:06:46 -0400 Subject: [PATCH] fix(tui): cap rendered request/response body lines (#2407) The TUI rendered an HTTP body as one Ink per pretty-printed line, with no bound, in four copies (App.tsx details and RequestsTab, request and response each). A large tool result became thousands of components in one render pass. Add a pure layoutBody() in utils/bodyLines.ts that pretty-prints JSON, caps the body at 500 lines and each line at 2000 characters, and reports what it cut. Render it through one BodyLines component used by all four sites; the note under a truncated body says how many lines were hidden. The per-line character cap is needed because an embedded resource pretty-prints as a single enormous base64 line that a line cap alone leaves untouched. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- clients/tui/__tests__/BodyLines.test.tsx | 79 ++++++++++++++++++++++ clients/tui/src/App.tsx | 49 +------------- clients/tui/src/components/BodyLines.tsx | 40 +++++++++++ clients/tui/src/components/RequestsTab.tsx | 55 +++------------ clients/tui/src/utils/bodyLines.ts | 58 ++++++++++++++++ 5 files changed, 189 insertions(+), 92 deletions(-) create mode 100644 clients/tui/__tests__/BodyLines.test.tsx create mode 100644 clients/tui/src/components/BodyLines.tsx create mode 100644 clients/tui/src/utils/bodyLines.ts diff --git a/clients/tui/__tests__/BodyLines.test.tsx b/clients/tui/__tests__/BodyLines.test.tsx new file mode 100644 index 0000000000..8c1b9a1f04 --- /dev/null +++ b/clients/tui/__tests__/BodyLines.test.tsx @@ -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( + , + ); + 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(); + 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)`, + ); + }); +}); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 2bdbe50ead..cf205d7143 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -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 @@ -1279,29 +1280,7 @@ function App({ Request Body: - {(() => { - try { - const parsed = JSON.parse(request.requestBody); - return JSON.stringify(parsed, null, 2) - .split("\n") - .map((line: string, idx: number) => ( - - {line} - - )); - } catch { - return ( - - {request.requestBody} - - ); - } - })()} + )} {request.responseHeaders && @@ -1324,29 +1303,7 @@ function App({ Response Body: - {(() => { - try { - const parsed = JSON.parse(request.responseBody); - return JSON.stringify(parsed, null, 2) - .split("\n") - .map((line: string, idx: number) => ( - - {line} - - )); - } catch { - return ( - - {request.responseBody} - - ); - } - })()} + )} diff --git a/clients/tui/src/components/BodyLines.tsx b/clients/tui/src/components/BodyLines.tsx new file mode 100644 index 0000000000..2a9d2d51d6 --- /dev/null +++ b/clients/tui/src/components/BodyLines.tsx @@ -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) => ( + + {line} + + ))} + {hiddenLines > 0 && ( + + + … {hiddenLines} more lines not shown ({totalLines} total) + + + )} + + ); +} diff --git a/clients/tui/src/components/RequestsTab.tsx b/clients/tui/src/components/RequestsTab.tsx index e49d781af5..0dc692cf0c 100644 --- a/clients/tui/src/components/RequestsTab.tsx +++ b/clients/tui/src/components/RequestsTab.tsx @@ -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; @@ -262,29 +263,10 @@ export function RequestsTab({ Request Body: - {(() => { - try { - const parsed = JSON.parse(selectedRequest.requestBody); - return JSON.stringify(parsed, null, 2) - .split("\n") - .map((line: string, idx: number) => ( - - {line} - - )); - } catch { - return ( - - {selectedRequest.requestBody} - - ); - } - })()} + )} @@ -318,29 +300,10 @@ export function RequestsTab({ Response Body: - {(() => { - try { - const parsed = JSON.parse(selectedRequest.responseBody); - return JSON.stringify(parsed, null, 2) - .split("\n") - .map((line: string, idx: number) => ( - - {line} - - )); - } catch { - return ( - - {selectedRequest.responseBody} - - ); - } - })()} + )} diff --git a/clients/tui/src/utils/bodyLines.ts b/clients/tui/src/utils/bodyLines.ts new file mode 100644 index 0000000000..277318f293 --- /dev/null +++ b/clients/tui/src/utils/bodyLines.ts @@ -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 ``, 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, + }; +}