pi-mono/packages/coding-agent/test/custom-message.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

44 lines
1.4 KiB
TypeScript

import { Text } from "@earendil-works/pi-tui";
import { describe, expect, test } from "vitest";
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
import type { CustomMessage } from "../src/core/messages.ts";
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("CustomMessageComponent", () => {
test("provides output padding to custom renderers and updates it", () => {
initTheme("dark");
const optionsSeen: MessageRenderOptions[] = [];
const renderer: MessageRenderer = (_message, options) => {
optionsSeen.push(options);
return new Text("custom", options.outputPad, 0);
};
const message: CustomMessage = {
role: "custom",
customType: "test",
content: "custom",
display: true,
timestamp: Date.now(),
};
const component = new CustomMessageComponent(message, renderer, undefined, 1);
expect(optionsSeen).toMatchObject([{ expanded: false, outputPad: 1 }]);
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith(" custom")),
).toBe(true);
component.setOutputPad(0);
expect(optionsSeen.at(-1)).toMatchObject({ expanded: false, outputPad: 0 });
expect(
component
.render(40)
.map(stripAnsi)
.some((line) => line.startsWith("custom")),
).toBe(true);
});
});