From 02c62b4e4fd75c873d87091cefca98471701017a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 Oct 2026 23:42:51 -0400 Subject: [PATCH 1/2] test(tui): build saveResult queue paths with node:path resolve (#2810) saveResultToFile resolves its target with node:path resolve(cwd, path), so on Windows the mocked writeFile receives D:\d\a.json while the test asserted POSIX literals. Build the expected paths the same way. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/saveResult.queue.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/clients/tui/__tests__/saveResult.queue.test.ts b/clients/tui/__tests__/saveResult.queue.test.ts index c004bf91d..c69bf4f3a 100644 --- a/clients/tui/__tests__/saveResult.queue.test.ts +++ b/clients/tui/__tests__/saveResult.queue.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, it, expect, vi } from "vitest"; // A writeFile double whose calls settle only when the test says so, so the @@ -17,6 +18,11 @@ vi.mock("node:fs/promises", () => ({ import { saveResultToFile } from "../src/utils/saveResult.js"; +// saveResultToFile resolves its target with node:path `resolve`, so the +// expected path is built the same way: "/d/a.json" on POSIX, "D:\\d\\a.json" +// on Windows (#2810). +const inD = (name: string) => resolve("/d", name); + const TEXT = { content: [{ type: "text", text: "hi" }] }; const flush = async () => { @@ -34,11 +40,11 @@ describe("saveResultToFile queueing (#2571)", () => { order.push("b"); }); await flush(); - expect(writes.map((w) => w.path)).toEqual(["/d/a.json"]); + expect(writes.map((w) => w.path)).toEqual([inD("a.json")]); writes[0]!.resolve(); await first; await flush(); - expect(writes.map((w) => w.path)).toEqual(["/d/a.json", "/d/b.json"]); + expect(writes.map((w) => w.path)).toEqual([inD("a.json"), inD("b.json")]); writes[1]!.resolve(); await second; expect(order).toEqual(["a", "b"]); @@ -51,10 +57,12 @@ describe("saveResultToFile queueing (#2571)", () => { await flush(); expect(writes).toHaveLength(1); writes[0]!.reject(new Error("EACCES")); - await expect(failed).rejects.toThrow("Could not write /d/x.json: EACCES"); + await expect(failed).rejects.toThrow( + `Could not write ${inD("x.json")}: EACCES`, + ); await flush(); expect(writes).toHaveLength(2); writes[1]!.resolve(); - await expect(next).resolves.toMatchObject({ path: "/d/y.json" }); + await expect(next).resolves.toMatchObject({ path: inD("y.json") }); }); }); From 1fb5cfea5d489c8a2f92dd19efe76f431fba4ea0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 Oct 2026 23:42:51 -0400 Subject: [PATCH 2/2] fix(tui): keep list rows to one line so the selection stays in view (#2826) Tools, Resources and Prompts rows now render with wrap="truncate-end", so a long name no longer wraps into extra rows that the item-counting scroll window cannot see. The window is also re-clamped every render, so a terminal resize cannot leave the selected row outside it. The Tools detail header wraps its [Enter to Test] hint onto its own line when it does not fit beside the full tool name, instead of overlapping it, and a row's schema-portability marker sits outside the truncated text. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/PromptsTab.test.tsx | 19 +++ clients/tui/__tests__/ResourcesTab.test.tsx | 18 +++ clients/tui/__tests__/ToolsTab.test.tsx | 132 ++++++++++++++++++ .../tui/__tests__/useSelectableList.test.tsx | 13 ++ clients/tui/src/components/PromptsTab.tsx | 4 +- clients/tui/src/components/ResourcesTab.tsx | 4 +- clients/tui/src/components/ToolsTab.tsx | 23 ++- clients/tui/src/hooks/useSelectableList.ts | 10 +- 8 files changed, 214 insertions(+), 9 deletions(-) diff --git a/clients/tui/__tests__/PromptsTab.test.tsx b/clients/tui/__tests__/PromptsTab.test.tsx index 8675653bc..77fe052bb 100644 --- a/clients/tui/__tests__/PromptsTab.test.tsx +++ b/clients/tui/__tests__/PromptsTab.test.tsx @@ -325,3 +325,22 @@ describe("PromptsTab list filter (#2430)", () => { expect(lastFrame()).toContain("Prompts (3)"); }); }); + +describe("PromptsTab in a narrow terminal (#2826)", () => { + it("truncates a long row to one line and shows the full name in the pane", () => { + const { lastFrame } = render( + , + ); + const lines = (lastFrame() ?? "").split("\n"); + const listColumn = lines.map((l) => l.split("│")[0] ?? ""); + expect(listColumn.some((l) => /▶ a_very_lo\S*…/.test(l))).toBe(true); + // The tail of the name never lands on a list line of its own. + expect(listColumn.some((l) => l.includes("name"))).toBe(false); + expect(lines.some((l) => l.includes("a_very_long_prompt_name"))).toBe(true); + }); +}); diff --git a/clients/tui/__tests__/ResourcesTab.test.tsx b/clients/tui/__tests__/ResourcesTab.test.tsx index 68f2ec194..7eeae0e3a 100644 --- a/clients/tui/__tests__/ResourcesTab.test.tsx +++ b/clients/tui/__tests__/ResourcesTab.test.tsx @@ -482,3 +482,21 @@ describe("ResourcesTab list filter — template titles (#2430)", () => { expect(frame).not.toContain("other"); }); }); + +describe("ResourcesTab in a narrow terminal (#2826)", () => { + it("truncates a long row to one line and shows the full name in the pane", () => { + const { lastFrame } = render( + , + ); + const lines = (lastFrame() ?? "").split("\n"); + const listColumn = lines.map((l) => l.split("│")[0] ?? ""); + expect(listColumn.some((l) => /▶ a_very_lo\S*…/.test(l))).toBe(true); + expect(listColumn.some((l) => l.includes("_nm"))).toBe(false); + expect(lines.some((l) => l.includes("a_very_long_resource_nm"))).toBe(true); + }); +}); diff --git a/clients/tui/__tests__/ToolsTab.test.tsx b/clients/tui/__tests__/ToolsTab.test.tsx index 15bbd36a0..ff0855c23 100644 --- a/clients/tui/__tests__/ToolsTab.test.tsx +++ b/clients/tui/__tests__/ToolsTab.test.tsx @@ -372,3 +372,135 @@ describe("ToolsTab list filter — duplicate names (#2430)", () => { expect(frame).toContain("second copy"); }); }); + +describe("ToolsTab in a narrow terminal (#2826)", () => { + // The issue's own fixture: the stdio test server's eight tools, the last of + // which is selected at 60x18. A 42-column tab gives a 16-column list (12 + // usable after border and padding) and a 26-column detail pane. + const names = [ + "echo", + "get_annotated_message", + "get_sum", + "long_running_operation", + "print_env", + "send_notification", + "write_to_stderr", + "mcp_app_demo", + ]; + const narrowTools = names.map((name) => + makeTool({ name, description: `${name} desc` }), + ); + + it("renders each row on one line, truncated with an ellipsis", () => { + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + // Truncated, not wrapped: the tail never lands on a line of its own. + expect(frame).toContain("…"); + expect(frame).not.toMatch(/^\s*sage\b/m); + expect(frame).not.toMatch(/│\s*sage\s/); + // Every tool still has a row, and the list holds one line per tool. + const rows = frame + .split("\n") + .filter((line) => /^\s*(▶ | {2})[a-z]/.test(line.split("│")[0] ?? "")); + expect(rows).toHaveLength(names.length); + }); + + it("keeps the selected row visible after moving to the last tool", async () => { + const { lastFrame, stdin } = render( + , + ); + for (let i = 0; i < names.length - 1; i++) { + stdin.write(DOWN); + await tick(); + } + const frame = lastFrame() ?? ""; + const listColumn = frame.split("\n").map((l) => l.split("│")[0] ?? ""); + expect(listColumn.some((l) => l.includes("▶ mcp_app_d"))).toBe(true); + // The full name is in the detail pane even though the row truncates it. + expect(frame).toContain("mcp_app_demo"); + }); + + it("keeps the selection in view when the terminal shrinks", async () => { + const props = { + tools: narrowTools, + isConnected: false, + width: 42, + focusedPane: "list" as const, + }; + const { lastFrame, stdin, rerender } = render( + , + ); + for (let i = 0; i < names.length - 1; i++) { + stdin.write(DOWN); + await tick(); + } + rerender(); + await tick(); + const listColumn = (lastFrame() ?? "") + .split("\n") + .map((l) => l.split("│")[0] ?? ""); + expect(listColumn.some((l) => l.includes("▶ mcp_app_d"))).toBe(true); + }); + + it("drops the Enter-to-Test hint below the title rather than overlapping it", () => { + const { lastFrame } = render( + , + ); + const lines = (lastFrame() ?? "").split("\n"); + const titleLine = lines.findIndex((l) => l.includes("mcp_app_demo")); + const hintLine = lines.findIndex((l) => l.includes("[Enter to Test]")); + expect(titleLine).toBeGreaterThanOrEqual(0); + expect(hintLine).toBe(titleLine + 1); + }); + + it("keeps the hint beside the title when there is room", () => { + const { lastFrame } = render( + , + ); + const lines = (lastFrame() ?? "").split("\n"); + expect( + lines.some((l) => l.includes("echo") && l.includes("[Enter to Test]")), + ).toBe(true); + }); + + it("keeps a row's schema marker visible when its name is truncated", () => { + const flagged = makeTool({ + name: "get_annotated_message", + outputSchema: { type: "object", properties: { data: true } }, + } as Partial); + const { lastFrame } = render( + , + ); + const frame = lastFrame() ?? ""; + const row = + frame + .split("\n") + .map((l) => l.split("│")[0] ?? "") + .find((l) => l.includes("▶ get_anno")) ?? ""; + expect(row).toMatch(/… !\s*$/); + }); +}); diff --git a/clients/tui/__tests__/useSelectableList.test.tsx b/clients/tui/__tests__/useSelectableList.test.tsx index 9ac86a9ae..c8f603196 100644 --- a/clients/tui/__tests__/useSelectableList.test.tsx +++ b/clients/tui/__tests__/useSelectableList.test.tsx @@ -131,4 +131,17 @@ describe("useSelectableList", () => { // itemCount === 0 => no clamp, selection retained expect(lastFrame()).toBe("sel=8 first=4"); }); + + it("keeps the selection in the window when visibleCount shrinks (#2826)", async () => { + const { lastFrame, stdin, rerender } = render( + , + ); + stdin.write("9"); + await tick(); + expect(lastFrame()).toBe("sel=9 first=0"); + // A terminal resize: the selection did not move, the window did. + rerender(); + await tick(); + expect(lastFrame()).toBe("sel=9 first=7"); + }); }); diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index 6392ab06e..fe56871e6 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -220,7 +220,9 @@ export function PromptsTab({ paddingY={0} flexShrink={0} > - + {/* One rendered line per row (#2826); the detail pane + shows the full name. */} + {isSelected ? "▶ " : " "} {prompt.name || `Prompt ${ordinal + 1}`} diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index 6c0ce3529..f0e58dea3 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -315,7 +315,9 @@ export function ResourcesTab({ }`; return ( - + {/* One rendered line per row (#2826); the detail pane + shows the full name. */} + {isSelected ? "▶ " : " "} {label} diff --git a/clients/tui/src/components/ToolsTab.tsx b/clients/tui/src/components/ToolsTab.tsx index 55c1bb27a..3a0baaf2e 100644 --- a/clients/tui/src/components/ToolsTab.tsx +++ b/clients/tui/src/components/ToolsTab.tsx @@ -224,13 +224,19 @@ export function ToolsTab({ paddingY={0} flexShrink={0} > - + {/* One rendered line per row (#2826): the window counts + items, so a wrapped name would push the selected row + out of view. The marker sits outside the truncated + text so an ellipsis never swallows it. */} + {isSelected ? "▶ " : " "} {tool.name || `Tool ${ordinal + 1}`} - {marker && ( - {marker.glyph} - )} + {marker && ( + + {marker.glyph} + + )} ); })} @@ -249,10 +255,15 @@ export function ToolsTab({ {selectedTool ? ( <> {/* Fixed header */} + {/* The title shows the full name (rows truncate it), and the + hint wraps onto its own line when the two do not fit side by + side rather than overlapping the title (#2826). */} {isConnected && ( - + [Enter to Test] - + )} diff --git a/clients/tui/src/hooks/useSelectableList.ts b/clients/tui/src/hooks/useSelectableList.ts index 054624e9b..86a62afff 100644 --- a/clients/tui/src/hooks/useSelectableList.ts +++ b/clients/tui/src/hooks/useSelectableList.ts @@ -57,5 +57,13 @@ export function useSelectableList( } }, [itemCount, selectedIndex, visibleCount]); - return { selectedIndex, firstVisible, setSelection }; + // The stored window is re-clamped on every render, not only on a selection + // move: a terminal resize shrinks `visibleCount` without moving the + // selection, and the stale window would then slice the selected row off the + // bottom of the list (#2826). Pure, so it is safe to compute during render. + return { + selectedIndex, + firstVisible: clampFirstVisible(firstVisible, selectedIndex, visibleCount), + setSelection, + }; }