pi-mono/packages/coding-agent/test/extension-keybinding-hints.test.ts
Petr Houška 9376c8d17c
fix(coding-agent): pass host keybinding hints to extension renderers (#11630)
Fixes #4748.

## Problem

Extensions that import `keyText`/`keyHint` from
`@earendil-works/pi-coding-agent` can resolve their own copy of the
package (their own `node_modules`, or the bundled `cli.js` vs
`dist/index.js`). That copy's keybindings are never set by the host, so
it falls back to a manager with only `tui.*` bindings, and
`keyHint("app.tools.expand", "to expand")` renders as ` to expand`.

## Change

The host passes its own `keyText` and `keyHint` to extension renderers,
the same way it passes `theme`:

- `ToolRenderContext` (`renderCall`/`renderResult` context):
`context.keyHint(...)`
- `MessageRenderOptions` and `EntryRenderOptions`:
`options.keyHint(...)`

All three extend a new exported `KeybindingHints` interface. The docs
and the `debug-provider` example now use the injected functions and warn
against importing them in renderers.

Not done:

- Moving the `pi-tui` keybindings singleton to `globalThis`. Per the
discussion in #7011, extension-facing state should be host-owned and
passed through the extension API instead of shared through process-wide
module state.
- `ctx.ui.keyText`/`keyHint`. No reported case needs them outside
renderers, RPC and print modes never install app keybindings, and adding
them to `ExtensionUIContext` would break SDK hosts that implement it.

## Tests

- `test/extension-keybinding-hints.test.ts`: tool, message, and entry
renderers format a user-overridden `app.tools.expand` binding.
- `npm run check` passes.
2026-10-30 23:01:20 +01:00

82 lines
3.1 KiB
TypeScript

// Issue #4748: extensions that import keyText/keyHint can resolve their own package copy, which does not see the
// host's keybindings. The host passes its own keyText/keyHint to renderers instead.
import { setKeybindings, Text, type TUI } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { beforeAll, describe, expect, test } from "vitest";
import type { KeybindingHints, ToolDefinition } from "../src/core/extensions/types.ts";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import type { CustomMessage } from "../src/core/messages.ts";
import type { CustomEntry } from "../src/core/session-manager.ts";
import { CustomEntryComponent } from "../src/modes/interactive/components/custom-entry.ts";
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
function hintText(hints: KeybindingHints): string {
return `${hints.keyText("app.tools.expand")} | ${stripAnsi(hints.keyHint("app.tools.expand", "to expand"))}`;
}
const EXPECTED = "ctrl+e | ctrl+e to expand";
describe("host keybinding hints for extensions", () => {
beforeAll(() => {
initTheme("dark");
// A user override proves the hints read the host's configured keybindings, not the defaults.
setKeybindings(new KeybindingsManager({ "app.tools.expand": "ctrl+e" }));
});
test("tool renderers receive keyText and keyHint in the render context", () => {
const seen: string[] = [];
const toolDefinition: ToolDefinition = {
name: "custom_tool",
label: "custom_tool",
description: "custom tool",
parameters: Type.Any(),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
renderCall: (_args, _theme, context) => {
seen.push(hintText(context));
return new Text("call", 0, 0);
},
};
const tui = { requestRender: () => {} } as unknown as TUI;
new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, tui, process.cwd()).render(80);
expect(seen.at(-1)).toBe(EXPECTED);
});
test("message renderers receive keyText and keyHint in the render options", () => {
const seen: string[] = [];
const message: CustomMessage = {
role: "custom",
customType: "test",
content: "custom",
display: true,
timestamp: Date.now(),
};
new CustomMessageComponent(message, (_message, options) => {
seen.push(hintText(options));
return new Text("custom", 0, 0);
});
expect(seen).toEqual([EXPECTED]);
});
test("entry renderers receive keyText and keyHint in the render options", () => {
const seen: string[] = [];
const entry: CustomEntry = {
type: "custom",
id: "entry-1",
parentId: null,
timestamp: new Date().toISOString(),
customType: "test",
data: undefined,
};
new CustomEntryComponent(entry, (_entry, options) => {
seen.push(hintText(options));
return new Text("custom", 0, 0);
});
expect(seen).toEqual([EXPECTED]);
});
});