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
1 change: 1 addition & 0 deletions clients/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ The TUI provides terminal-native tabs and panes for interacting with your MCP se
- Press **Enter** to select an item, execute a tool, or fetch a resource.
- Press **`/`** on a focused Resources, Prompts, Skills or Tools list to filter it as you type (case-insensitive, by name or title — and URI, for resources). **Enter** keeps the filter and returns the keys to the list; **Escape** clears it. While the filter is being typed, the tab accelerators and Escape-to-exit are paused, so a query can contain any letter. To clear a kept filter, press `/` then **Escape**.
- Press **Escape** or `Ctrl+C` to exit the application.
- Press **`?`** for an in-app list of the keybindings for the current tab; press `?` or **Escape** to close it. The list is the `src/utils/keybindings.ts` table, so a new keybinding is added there.

## Copying values

Expand Down
136 changes: 136 additions & 0 deletions clients/tui/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,18 @@ describe("App (status, layout, modals)", () => {
expect(h.disconnect).toHaveBeenCalled();
});

it("types '?' into a list filter instead of opening the help (#2430 + #2436)", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, "/", "?"]);
await expectFrame(r, "/?");
expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts");
// Once the filter is cleared, '?' is the help key again.
await press(r, [ESC, "?"]);
await expectFrame(r, "Keyboard shortcuts");
});

it("opens the tool details modal with '+' and closes it on ESC", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
Expand Down Expand Up @@ -2065,3 +2077,127 @@ describe("App (OAuth result branches)", () => {
await expectFrame(r, "web error");
});
});

describe("App (keybinding help, #2436)", () => {
it("advertises the help key in the footer", async () => {
const r = await mount(stdioServer());
await expectFrame(r, "? help");
});

it("opens with '?' showing the active tab's bindings and closes with '?'", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", "?"]);
await expectFrame(r, "Keyboard shortcuts");
expect(r.lastFrame() ?? "").toContain("Tools tab");
expect(r.lastFrame() ?? "").toContain("Test the tool");
await press(r, ["?"]);
await waitUntil(
() => !(r.lastFrame() ?? "").includes("Keyboard shortcuts"),
);
expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts");
});

it("keeps the panes underneath inert, then restores focus on close", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, TAB, "?"]); // tool details focused, then help
await expectFrame(r, "Keyboard shortcuts");
// '+' would open the details dialog if the details pane still had focus;
// a tab accelerator and disconnect are global keys it must swallow too.
await press(r, ["+", "i", "d"]);
expect(h.disconnect).not.toHaveBeenCalled();
await press(r, ["?"]); // close the help
await waitUntil(
() => !(r.lastFrame() ?? "").includes("Keyboard shortcuts"),
);
expect(r.lastFrame() ?? "").not.toContain("Full JSON:");
expect(r.lastFrame() ?? "").toContain("Tools (1)");
await press(r, ["+"]); // focus is back on the details pane
await expectFrame(r, "Full JSON:");
});

it("closes on ESC without exiting the app", async () => {
const r = await mount(stdioServer());
await press(r, ["?"]);
await expectFrame(r, "Keyboard shortcuts");
await press(r, [ESC]);
await waitUntil(
() => !(r.lastFrame() ?? "").includes("Keyboard shortcuts"),
);
// Still mounted and listening: the help opens again.
await press(r, ["?"]);
await expectFrame(r, "Keyboard shortcuts");
});

it("lists only the visible tabs' accelerators", async () => {
// A stdio server shows no Auth, Network or Skills tab, so `a`, `n` and `k`
// do nothing and must not be advertised.
const r = await mount(oneStdio());
await press(r, ["?"]);
await expectFrame(r, "Keyboard shortcuts");
expect(r.lastFrame() ?? "").toContain("i r m t p o ");
});

it("keeps a focus move made while it is open from waking a hidden pane", async () => {
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
// Connected, so the global `c` (Connect) is inert and `c` can only mean
// the Auth pane's cancel.
h.ctrl.status = "connected";
const r = await mount(oneHttp());
await press(r, ["?"]);
await expectFrame(r, "Keyboard shortcuts");
// A step-up landing now moves focus to the Auth pane underneath.
h.fireClientEvent("authChallengeInteractive", {
authorizationUrl: new URL("https://as.example/authorize"),
challenge: {
reason: "insufficient_scope" as const,
requiredScopes: ["env:read"],
authorizationScopes: ["tools:read", "env:read"],
context: { toolName: "get-env" },
},
});
await expectFrame(r, "Auth tab");
await press(r, ["c"]); // would cancel the step-up if Auth were live
await press(r, ["?"]);
await expectFrame(r, "needs additional OAuth scopes");
expect(r.lastFrame() ?? "").not.toContain("Authorization cancelled");
// Once the help closes, the step-up's focus move takes effect.
await press(r, ["c"]);
await expectFrame(r, "Authorization cancelled");
});

it("holds back a details dialog that arrives while it is open", async () => {
h.ctrl.status = "connected";
h.ctrl.prompts = [{ name: "greet", description: "no-arg prompt" }];
const r = await mount(oneStdio());
let resolvePrompt: (value: { result: { messages: [] } }) => void = () => {};
const pending = new Promise<{ result: { messages: [] } }>((resolve) => {
resolvePrompt = resolve;
});
for (const client of h.clientInstances) {
Object.assign(client, { getPrompt: vi.fn(() => pending) });
}
await press(r, ["m", TAB, ENTER, "?"]); // fetch starts, then help opens
await expectFrame(r, "Keyboard shortcuts");
resolvePrompt({ result: { messages: [] } });
await tick();
await press(r, [ESC]); // closes only the help
await expectFrame(r, "Prompt: greet");
await press(r, [ESC]); // and then the details dialog
await waitUntil(() => !(r.lastFrame() ?? "").includes("Prompt: greet"));
expect(r.lastFrame() ?? "").not.toContain("Prompt: greet");
});

it("ignores '?' while another dialog is open", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
const r = await mount(oneStdio());
await press(r, ["t", TAB, TAB, "+"]);
await expectFrame(r, "Full JSON:");
await press(r, ["?"]);
expect(r.lastFrame() ?? "").not.toContain("Keyboard shortcuts");
});
});
104 changes: 104 additions & 0 deletions clients/tui/__tests__/HelpOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "./helpers/renderTui";
import { Box } from "ink";

// ScrollView: a passthrough like the shared `inkScrollViewMock`, but with a
// spied `scrollBy`, so the scroll keys' deltas are asserted rather than merely
// exercised (the shared double's `scrollBy` is a no-op).
const scroll = vi.hoisted(() => ({ scrollBy: vi.fn(), viewportHeight: 7 }));
vi.mock("ink-scroll-view", async () => {
const React = await import("react");
const { Box } = await import("ink");
const ScrollView = React.forwardRef<unknown, { children?: React.ReactNode }>(
function ScrollView({ children }, ref) {
React.useImperativeHandle(ref, () => ({
scrollBy: scroll.scrollBy,
scrollTo: () => {},
getViewportHeight: () => scroll.viewportHeight,
}));
return React.createElement(Box, { flexDirection: "column" }, children);
},
);
return { ScrollView };
});

import { HelpOverlay } from "../src/components/HelpOverlay.js";
import { keybindingSections } from "../src/utils/keybindings.js";

// Ink processes stdin keypresses asynchronously — await this after stdin.write.
const tick = async () => {
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};

const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;

/**
* The overlay is `position="absolute"`, which a bare render does not lay out
* into the frame; a sized parent gives it a box to draw into, as `App` does.
*/
function renderOverlay(onClose: () => void) {
return render(
<Box width={100} height={60}>
<HelpOverlay
sections={keybindingSections("tools")}
width={100}
height={60}
onClose={onClose}
/>
</Box>,
);
}

describe("HelpOverlay", () => {
it("lists the global and active-tab bindings", async () => {
const r = renderOverlay(() => {});
await tick();
const frame = r.lastFrame() ?? "";
expect(frame).toContain("Keyboard shortcuts");
expect(frame).toContain("Global");
expect(frame).toContain("Tools tab");
expect(frame).toContain("Test the tool");
expect(frame).toContain("Show or hide this help");
r.unmount();
});

it("scrolls by a line or a viewport, without closing", async () => {
scroll.scrollBy.mockClear();
const onClose = vi.fn();
const r = renderOverlay(onClose);
await tick();
for (const k of [DOWN, UP, PAGE_DOWN, PAGE_UP, "x"]) {
r.stdin.write(k);
await tick();
}
expect(scroll.scrollBy.mock.calls).toEqual([[1], [-1], [7], [-7]]);
expect(onClose).not.toHaveBeenCalled();
r.unmount();
});

it("closes on '?'", async () => {
const onClose = vi.fn();
const r = renderOverlay(onClose);
await tick();
r.stdin.write("?");
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
r.unmount();
});

it("closes on ESC", async () => {
const onClose = vi.fn();
const r = renderOverlay(onClose);
await tick();
r.stdin.write(ESC);
await tick();
expect(onClose).toHaveBeenCalledTimes(1);
r.unmount();
});
});
78 changes: 78 additions & 0 deletions clients/tui/__tests__/keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { tabs } from "../src/components/tabsConfig.js";
import {
HELP_BINDINGS,
TAB_BINDINGS,
globalBindings,
keyColumnWidth,
keybindingSections,
} from "../src/utils/keybindings.js";

describe("keybindings", () => {
it("documents every tab with at least one binding", () => {
for (const tab of tabs) {
expect(TAB_BINDINGS[tab.id].length).toBeGreaterThan(0);
}
});

it("titles every tab's section with its tab-bar label", () => {
for (const id of Object.keys(
TAB_BINDINGS,
) as (keyof typeof TAB_BINDINGS)[]) {
const label = tabs.find((tab) => tab.id === id)?.label;
expect(label).toBeDefined();
expect(keybindingSections(id)[1]!.title).toBe(`${label} tab`);
}
});

it("lists every tab accelerator in the global section, from tabsConfig", () => {
const accelerators = globalBindings().find((b) =>
b.action.includes("underlined letter"),
);
expect(accelerators?.keys.split(" ")).toEqual(
tabs.map((tab) => tab.accelerator),
);
});

it("lists only the accelerators of the tabs it is told are visible", () => {
const visible = tabs.filter(
(tab) => tab.id === "info" || tab.id === "tools",
);
const sections = keybindingSections("tools", visible);
const row = sections[0]!.bindings.find((b) =>
b.action.includes("underlined letter"),
);
expect(row?.keys).toBe("i t");
});

it("documents the help toggle itself", () => {
expect(globalBindings().some((b) => b.keys === "?")).toBe(true);
expect(HELP_BINDINGS.some((b) => b.keys.includes("Esc"))).toBe(true);
});

it("returns Global, the active tab's section by label, then the help section", () => {
const sections = keybindingSections("messages");
expect(sections.map((s) => s.title)).toEqual([
"Global",
"Protocol tab",
"This help",
]);
expect(sections[1]!.bindings).toBe(TAB_BINDINGS.messages);
});

it("measures the widest keys column across all sections", () => {
expect(
keyColumnWidth([
{ title: "a", bindings: [{ keys: "ab", action: "x" }] },
{
title: "b",
bindings: [
{ keys: "abcd", action: "y" },
{ keys: "a", action: "z" },
],
},
]),
).toBe(4);
expect(keyColumnWidth([])).toBe(0);
});
});
Loading
Loading