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
19 changes: 19 additions & 0 deletions clients/tui/__tests__/PromptsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PromptsTab
prompts={[makePrompt({ name: "a_very_long_prompt_name" })]}
inspectorClient={null}
width={42}
height={17}
/>,
);
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);
});
});
18 changes: 18 additions & 0 deletions clients/tui/__tests__/ResourcesTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ResourcesTab
resources={[makeResource({ name: "a_very_long_resource_nm" })]}
inspectorClient={null}
width={42}
height={17}
/>,
);
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);
});
});
132 changes: 132 additions & 0 deletions clients/tui/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ToolsTab
tools={narrowTools}
isConnected={false}
width={42}
height={30}
focusedPane="list"
/>,
);
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(
<ToolsTab
tools={narrowTools}
isConnected
width={42}
height={17}
focusedPane="list"
/>,
);
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(
<ToolsTab {...props} height={30} />,
);
for (let i = 0; i < names.length - 1; i++) {
stdin.write(DOWN);
await tick();
}
rerender(<ToolsTab {...props} height={12} />);
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(
<ToolsTab
tools={[makeTool({ name: "mcp_app_demo" })]}
isConnected
width={42}
height={17}
/>,
);
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(
<ToolsTab
tools={[makeTool({ name: "echo" })]}
isConnected
width={120}
height={17}
/>,
);
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<Tool>);
const { lastFrame } = render(
<ToolsTab tools={[flagged]} isConnected={false} width={42} height={17} />,
);
const frame = lastFrame() ?? "";
const row =
frame
.split("\n")
.map((l) => l.split("│")[0] ?? "")
.find((l) => l.includes("▶ get_anno")) ?? "";
expect(row).toMatch(/… !\s*$/);
});
});
16 changes: 12 additions & 4 deletions clients/tui/__tests__/saveResult.queue.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 () => {
Expand All @@ -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"]);
Expand All @@ -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") });
});
});
13 changes: 13 additions & 0 deletions clients/tui/__tests__/useSelectableList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Harness itemCount={10} visibleCount={10} />,
);
stdin.write("9");
await tick();
expect(lastFrame()).toBe("sel=9 first=0");
// A terminal resize: the selection did not move, the window did.
rerender(<Harness itemCount={10} visibleCount={3} />);
await tick();
expect(lastFrame()).toBe("sel=9 first=7");
});
});
4 changes: 3 additions & 1 deletion clients/tui/src/components/PromptsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ export function PromptsTab({
paddingY={0}
flexShrink={0}
>
<Text>
{/* One rendered line per row (#2826); the detail pane
shows the full name. */}
<Text wrap="truncate-end">
{isSelected ? "▶ " : " "}
{prompt.name || `Prompt ${ordinal + 1}`}
</Text>
Expand Down
4 changes: 3 additions & 1 deletion clients/tui/src/components/ResourcesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@ export function ResourcesTab({
}`;
return (
<Box key={key} paddingY={0} flexShrink={0}>
<Text>
{/* One rendered line per row (#2826); the detail pane
shows the full name. */}
<Text wrap="truncate-end">
{isSelected ? "▶ " : " "}
{label}
</Text>
Expand Down
23 changes: 17 additions & 6 deletions clients/tui/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,19 @@ export function ToolsTab({
paddingY={0}
flexShrink={0}
>
<Text>
{/* 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. */}
<Text wrap="truncate-end">
{isSelected ? "▶ " : " "}
{tool.name || `Tool ${ordinal + 1}`}
{marker && (
<Text color={marker.color}> {marker.glyph}</Text>
)}
</Text>
{marker && (
<Box flexShrink={0}>
<Text color={marker.color}> {marker.glyph}</Text>
</Box>
)}
</Box>
);
})}
Expand All @@ -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). */}
<Box
flexShrink={0}
flexDirection="row"
flexWrap="wrap"
justifyContent="space-between"
columnGap={1}
paddingTop={1}
>
<Text
Expand All @@ -265,11 +276,11 @@ export function ToolsTab({
{selectedTool.name}
</Text>
{isConnected && (
<Text>
<Box flexShrink={0}>
<Text color="cyan" bold>
[Enter to Test]
</Text>
</Text>
</Box>
)}
</Box>

Expand Down
10 changes: 9 additions & 1 deletion clients/tui/src/hooks/useSelectableList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Loading