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__/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") });
});
});
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,
+ };
}