refactor: address PR feedback - merge setWidget, use KeyId for shortcuts

1. Merge setWidget and setWidgetComponent into single overloaded method
   - Accepts either string[] or component factory function
   - Uses single Map<string, Component> internally
   - String arrays wrapped in Container with Text components

2. Use KeyId type for registerShortcut instead of plain string
   - Import Key from @mariozechner/pi-tui
   - Update plan-mode example to use Key.shift('p')
   - Type-safe shortcut registration

3. Fix tool API docs
   - Both built-in and custom tools can be enabled/disabled
   - Removed incorrect 'custom tools always active' statement

4. Use matchesKey instead of matchShortcut (already done in rebase)
This commit is contained in:
Helmut Januschka 2026-01-04 00:09:44 +01:00 committed by Mario Zechner
parent e3c2616713
commit 8ecb1d6c0b
11 changed files with 76 additions and 106 deletions

View file

@ -40,9 +40,8 @@
- Hook API: `pi.getActiveTools()` and `pi.setActiveTools(toolNames)` for dynamically enabling/disabling tools from hooks
- Hook API: `pi.getAllTools()` to enumerate all configured tools (built-in via --tools or default, plus custom tools)
- Hook API: `pi.registerFlag(name, options)` and `pi.getFlag(name)` for hooks to register custom CLI flags (parsed automatically)
- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts (e.g., `shift+p`, `ctrl+shift+x`). Conflicts with built-in shortcuts are skipped, conflicts between hooks logged as warnings.
- Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking)
- Hook API: `ctx.ui.setWidgetComponent(key, factory)` for custom TUI components as widgets (no focus, renders inline)
- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts using `KeyId` (e.g., `Key.shift("p")`). Conflicts with built-in shortcuts are skipped, conflicts between hooks logged as warnings.
- Hook API: `ctx.ui.setWidget(key, content)` for status displays above the editor. Accepts either a string array or a component factory function.
- Hook API: `theme.strikethrough(text)` for strikethrough text styling
- `/hotkeys` command now shows hook-registered shortcuts in a separate "Hooks" section
- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode:

View file

@ -446,22 +446,22 @@ const currentText = ctx.ui.getEditorText();
**Widget notes:**
- Widgets are multi-line displays shown above the editor (below "Working..." indicator)
- Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically)
- Use `setWidget()` for simple styled text, `setWidgetComponent()` for custom components
- `setWidget()` accepts either a string array or a component factory function
- Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`)
- **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets.
**Custom widget components:**
For more complex widgets, use `setWidgetComponent()` to render a custom TUI component:
For more complex widgets, pass a factory function to `setWidget()`:
```typescript
ctx.ui.setWidgetComponent("my-widget", (tui, theme) => {
ctx.ui.setWidget("my-widget", (tui, theme) => {
// Return any Component that implements render(width): string[]
return new MyCustomComponent(tui, theme);
});
// Clear the widget
ctx.ui.setWidgetComponent("my-widget", undefined);
ctx.ui.setWidget("my-widget", undefined);
```
Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor.
@ -815,7 +815,7 @@ pi.setActiveTools(["read", "bash", "grep", "find", "ls"]);
pi.setActiveTools(["read", "bash", "edit", "write"]);
```
Only built-in tools can be enabled/disabled. Custom tools are always active. Unknown tool names are ignored.
Both built-in and custom tools can be enabled/disabled. Unknown tool names are ignored.
### pi.registerFlag(name, options)

View file

@ -20,6 +20,7 @@
*/
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
import { Key } from "@mariozechner/pi-tui";
// Read-only tools for plan mode
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
@ -292,7 +293,7 @@ export default function planModeHook(pi: HookAPI) {
});
// Register Shift+P shortcut
pi.registerShortcut("shift+p", {
pi.registerShortcut(Key.shift("p"), {
description: "Toggle plan mode",
handler: async (ctx) => {
togglePlanMode(ctx);

View file

@ -93,7 +93,6 @@ function createNoOpUIContext(): HookUIContext {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",

View file

@ -7,6 +7,7 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { KeyId } from "@mariozechner/pi-tui";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookMessage } from "../messages.js";
@ -103,8 +104,8 @@ export interface HookFlag {
* Keyboard shortcut registered by a hook.
*/
export interface HookShortcut {
/** Shortcut string (e.g., "shift+p", "ctrl+shift+x") */
shortcut: string;
/** Key identifier (e.g., Key.shift("p"), "ctrl+x") */
shortcut: KeyId;
/** Description for help */
description?: string;
/** Handler function */
@ -153,7 +154,7 @@ export interface LoadedHook {
/** Flag values (set after CLI parsing) */
flagValues: Map<string, boolean | string>;
/** Keyboard shortcuts registered by this hook */
shortcuts: Map<string, HookShortcut>;
shortcuts: Map<KeyId, HookShortcut>;
/** Set the send message handler for this hook's pi.sendMessage() */
setSendMessageHandler: (handler: SendMessageHandler) => void;
/** Set the append entry handler for this hook's pi.appendEntry() */
@ -226,7 +227,7 @@ function createHookAPI(
commands: Map<string, RegisteredCommand>;
flags: Map<string, HookFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<string, HookShortcut>;
shortcuts: Map<KeyId, HookShortcut>;
setSendMessageHandler: (handler: SendMessageHandler) => void;
setAppendEntryHandler: (handler: AppendEntryHandler) => void;
setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void;
@ -249,7 +250,7 @@ function createHookAPI(
const commands = new Map<string, RegisteredCommand>();
const flags = new Map<string, HookFlag>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<string, HookShortcut>();
const shortcuts = new Map<KeyId, HookShortcut>();
// Cast to HookAPI - the implementation is more general (string event names)
// but the interface has specific overloads for type safety in hooks
@ -300,7 +301,7 @@ function createHookAPI(
return flagValues.get(name);
},
registerShortcut(
shortcut: string,
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: HookContext) => Promise<void> | void;

View file

@ -4,6 +4,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
@ -52,7 +53,6 @@ const noOpUIContext: HookUIContext = {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
@ -223,11 +223,12 @@ export class HookRunner {
* Conflicts with built-in shortcuts are skipped with a warning.
* Conflicts between hooks are logged as warnings.
*/
getShortcuts(): Map<string, HookShortcut> {
const allShortcuts = new Map<string, HookShortcut>();
getShortcuts(): Map<KeyId, HookShortcut> {
const allShortcuts = new Map<KeyId, HookShortcut>();
for (const hook of this.hooks) {
for (const [key, shortcut] of hook.shortcuts) {
const normalizedKey = key.toLowerCase();
// Normalize to lowercase for comparison (KeyId is string at runtime)
const normalizedKey = key.toLowerCase() as KeyId;
// Check for built-in shortcut conflicts
if (HookRunner.RESERVED_SHORTCUTS.has(normalizedKey)) {

View file

@ -7,7 +7,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component, TUI } from "@mariozechner/pi-tui";
import type { Component, KeyId, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
@ -79,13 +79,13 @@ export interface HookUIContext {
* Supports multi-line content. Pass undefined to clear.
* Text can include ANSI escape codes for styling.
*
* For simple text displays, use this method. For custom components, use setWidgetComponent().
* Accepts either an array of styled strings, or a factory function that creates a Component.
*
* @param key - Unique key to identify this widget (e.g., hook name)
* @param lines - Array of lines to display, or undefined to clear
* @param content - Array of lines to display, or undefined to clear
*
* @example
* // Show a todo list
* // Show a todo list with styled strings
* ctx.ui.setWidget("plan-todos", [
* theme.fg("accent", "Plan Progress:"),
* "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")),
@ -96,7 +96,7 @@ export interface HookUIContext {
* // Clear the widget
* ctx.ui.setWidget("plan-todos", undefined);
*/
setWidget(key: string, lines: string[] | undefined): void;
setWidget(key: string, content: string[] | undefined): void;
/**
* Set a custom component as a widget (above the editor, below "Working..." indicator).
@ -107,21 +107,18 @@ export interface HookUIContext {
* Components are rendered inline without taking focus - they cannot handle keyboard input.
*
* @param key - Unique key to identify this widget (e.g., hook name)
* @param factory - Function that creates the component, or undefined to clear
* @param content - Factory function that creates the component, or undefined to clear
*
* @example
* // Show a custom progress component
* ctx.ui.setWidgetComponent("my-progress", (tui, theme) => {
* ctx.ui.setWidget("my-progress", (tui, theme) => {
* return new MyProgressComponent(tui, theme);
* });
*
* // Clear the widget
* ctx.ui.setWidgetComponent("my-progress", undefined);
* ctx.ui.setWidget("my-progress", undefined);
*/
setWidgetComponent(
key: string,
factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,
): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/**
* Show a custom component with keyboard focus.
@ -813,7 +810,7 @@ export interface HookAPI {
/**
* Set the active tools by name.
* Only built-in tools can be enabled/disabled. Custom tools are always active.
* Both built-in and custom tools can be enabled/disabled.
* Changes take effect on the next agent turn.
* Note: This will invalidate prompt caching for the next request.
*
@ -871,11 +868,13 @@ export interface HookAPI {
* Register a keyboard shortcut for this hook.
* The handler is called when the shortcut is pressed in interactive mode.
*
* @param shortcut - Shortcut definition (e.g., "shift+p", "ctrl+shift+x")
* @param shortcut - Key identifier (e.g., Key.shift("p"), "ctrl+x")
* @param options - Shortcut configuration
*
* @example
* pi.registerShortcut("shift+p", {
* import { Key } from "@mariozechner/pi-tui";
*
* pi.registerShortcut(Key.shift("p"), {
* description: "Toggle plan mode",
* handler: async (ctx) => {
* // toggle plan mode
@ -883,7 +882,7 @@ export interface HookAPI {
* });
*/
registerShortcut(
shortcut: string,
shortcut: KeyId,
options: {
/** Description shown in help */
description?: string;

View file

@ -31,6 +31,7 @@
import { Agent, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
@ -349,7 +350,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
const commands = new Map<string, any>();
const flags = new Map<string, any>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<string, any>();
const shortcuts = new Map<KeyId, any>();
let sendMessageHandler: (
message: any,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
@ -389,7 +390,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
}
},
getFlag: (name: string) => flagValues.get(name),
registerShortcut: (shortcut: string, options: any) => {
registerShortcut: (shortcut: KeyId, options: any) => {
shortcuts.set(shortcut, { shortcut, hookPath, ...options });
},
newSession: (options?: any) => newSessionHandler(options),

View file

@ -141,9 +141,8 @@ export class InteractiveMode {
private hookInput: HookInputComponent | undefined = undefined;
private hookEditor: HookEditorComponent | undefined = undefined;
// Hook widgets (multi-line status displays or custom components)
private hookWidgets = new Map<string, string[]>();
private hookWidgetComponents = new Map<string, Component & { dispose?(): void }>();
// Hook widgets (components rendered above the editor)
private hookWidgets = new Map<string, Component & { dispose?(): void }>();
private widgetContainer!: Container;
// Custom tools for custom rendering
@ -623,41 +622,32 @@ export class InteractiveMode {
}
/**
* Set a hook widget (multi-line status display).
* Set a hook widget (string array or custom component).
*/
private setHookWidget(key: string, lines: string[] | undefined): void {
if (lines === undefined) {
this.hookWidgets.delete(key);
} else {
// Clear any component widget with same key
const existing = this.hookWidgetComponents.get(key);
if (existing?.dispose) existing.dispose();
this.hookWidgetComponents.delete(key);
this.hookWidgets.set(key, lines);
}
this.renderWidgets();
}
/**
* Set a hook widget component (custom component without focus).
*/
private setHookWidgetComponent(
private setHookWidget(
key: string,
factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
): void {
// Dispose existing component
const existing = this.hookWidgetComponents.get(key);
// Dispose and remove existing widget
const existing = this.hookWidgets.get(key);
if (existing?.dispose) existing.dispose();
if (factory === undefined) {
this.hookWidgetComponents.delete(key);
} else {
// Clear any string widget with same key
if (content === undefined) {
this.hookWidgets.delete(key);
const component = factory(this.ui, theme);
this.hookWidgetComponents.set(key, component);
} else if (Array.isArray(content)) {
// Wrap string array in a Container with Text components
const container = new Container();
for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
container.addChild(new Text(line, 1, 0));
}
if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
}
this.hookWidgets.set(key, container);
} else {
// Factory function - create component
const component = content(this.ui, theme);
this.hookWidgets.set(key, component);
}
this.renderWidgets();
}
@ -672,30 +662,12 @@ export class InteractiveMode {
if (!this.widgetContainer) return;
this.widgetContainer.clear();
const hasStringWidgets = this.hookWidgets.size > 0;
const hasComponentWidgets = this.hookWidgetComponents.size > 0;
if (!hasStringWidgets && !hasComponentWidgets) {
if (this.hookWidgets.size === 0) {
this.ui.requestRender();
return;
}
// Render string widgets first, respecting max lines
let totalLines = 0;
for (const [_key, lines] of this.hookWidgets) {
for (const line of lines) {
if (totalLines >= InteractiveMode.MAX_WIDGET_LINES) {
this.widgetContainer.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
this.ui.requestRender();
return;
}
this.widgetContainer.addChild(new Text(line, 1, 0));
totalLines++;
}
}
// Render component widgets
for (const [_key, component] of this.hookWidgetComponents) {
for (const [_key, component] of this.hookWidgets) {
this.widgetContainer.addChild(component);
}
@ -712,8 +684,7 @@ export class InteractiveMode {
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
setWidget: (key, lines) => this.setHookWidget(key, lines),
setWidgetComponent: (key, factory) => this.setHookWidgetComponent(key, factory),
setWidget: (key, content) => this.setHookWidget(key, content),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),

View file

@ -131,19 +131,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} as RpcHookUIRequest);
},
setWidget(key: string, lines: string[] | undefined): void {
// Fire and forget - host can implement widget display
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "setWidget",
widgetKey: key,
widgetLines: lines,
} as RpcHookUIRequest);
},
setWidgetComponent(): void {
// Custom components not supported in RPC mode - host would need to implement
setWidget(key: string, content: unknown): void {
// Only support string arrays in RPC mode - factory functions are ignored
if (content === undefined || Array.isArray(content)) {
output({
type: "hook_ui_request",
id: crypto.randomUUID(),
method: "setWidget",
widgetKey: key,
widgetLines: content as string[] | undefined,
} as RpcHookUIRequest);
}
// Component factories are not supported in RPC mode - would need TUI access
},
async custom() {

View file

@ -121,7 +121,6 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",