feat: add terminal input hook for extensions

This commit is contained in:
Sam Fold 2026-02-05 17:12:26 +00:00 committed by Mario Zechner
parent 6da488a5aa
commit 30fd99bd82
7 changed files with 47 additions and 0 deletions

View file

@ -123,6 +123,7 @@ export type {
SetLabelHandler, SetLabelHandler,
SetModelHandler, SetModelHandler,
SetThinkingLevelHandler, SetThinkingLevelHandler,
TerminalInputHandler,
// Events - Tool // Events - Tool
ToolCallEvent, ToolCallEvent,
ToolCallEventResult, ToolCallEventResult,

View file

@ -170,6 +170,7 @@ const noOpUIContext: ExtensionUIContext = {
confirm: async () => false, confirm: async () => false,
input: async () => undefined, input: async () => undefined,
notify: () => {}, notify: () => {},
onTerminalInput: () => () => {},
setStatus: () => {}, setStatus: () => {},
setWorkingMessage: () => {}, setWorkingMessage: () => {},
setWidget: () => {}, setWidget: () => {},

View file

@ -97,6 +97,9 @@ export interface ExtensionWidgetOptions {
placement?: WidgetPlacement; placement?: WidgetPlacement;
} }
/** Raw terminal input listener for extensions. */
export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
/** /**
* UI context for extensions to request interactive UI. * UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation. * Each mode (interactive, RPC, print) provides its own implementation.
@ -114,6 +117,9 @@ export interface ExtensionUIContext {
/** Show a notification to the user. */ /** Show a notification to the user. */
notify(message: string, type?: "info" | "warning" | "error"): void; notify(message: string, type?: "info" | "warning" | "error"): void;
/** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */
onTerminalInput(handler: TerminalInputHandler): () => void;
/** Set status text in the footer/status bar. Pass undefined to clear. */ /** Set status text in the footer/status bar. Pass undefined to clear. */
setStatus(key: string, text: string | undefined): void; setStatus(key: string, text: string | undefined): void;

View file

@ -100,6 +100,7 @@ export type {
SlashCommandInfo, SlashCommandInfo,
SlashCommandLocation, SlashCommandLocation,
SlashCommandSource, SlashCommandSource,
TerminalInputHandler,
ToolCallEvent, ToolCallEvent,
ToolDefinition, ToolDefinition,
ToolInfo, ToolInfo,

View file

@ -1368,6 +1368,7 @@ export class InteractiveMode {
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type), notify: (message, type) => this.showExtensionNotify(message, type),
onTerminalInput: (handler) => this.ui.addInputListener(handler),
setStatus: (key, text) => this.setExtensionStatus(key, text), setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => { setWorkingMessage: (message) => {
if (this.loadingAnimation) { if (this.loadingAnimation) {

View file

@ -144,6 +144,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} as RpcExtensionUIRequest); } as RpcExtensionUIRequest);
}, },
onTerminalInput(): () => void {
// Raw terminal input not supported in RPC mode
return () => {};
},
setStatus(key: string, text: string | undefined): void { setStatus(key: string, text: string | undefined): void {
// Fire and forget - no response needed // Fire and forget - no response needed
output({ output({

View file

@ -39,6 +39,9 @@ export interface Component {
invalidate(): void; invalidate(): void;
} }
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
type InputListener = (data: string) => InputListenerResult;
/** /**
* Interface for components that can receive focus and display a hardware cursor. * Interface for components that can receive focus and display a hardware cursor.
* When focused, the component should emit CURSOR_MARKER at the cursor position * When focused, the component should emit CURSOR_MARKER at the cursor position
@ -200,6 +203,7 @@ export class TUI extends Container {
private previousLines: string[] = []; private previousLines: string[] = [];
private previousWidth = 0; private previousWidth = 0;
private focusedComponent: Component | null = null; private focusedComponent: Component | null = null;
private inputListeners = new Set<InputListener>();
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
public onDebug?: () => void; public onDebug?: () => void;
@ -377,6 +381,17 @@ export class TUI extends Container {
this.requestRender(); this.requestRender();
} }
addInputListener(listener: InputListener): () => void {
this.inputListeners.add(listener);
return () => {
this.inputListeners.delete(listener);
};
}
removeInputListener(listener: InputListener): void {
this.inputListeners.delete(listener);
}
private queryCellSize(): void { private queryCellSize(): void {
// Only query if terminal supports images (cell size is only used for image rendering) // Only query if terminal supports images (cell size is only used for image rendering)
if (!getCapabilities().images) { if (!getCapabilities().images) {
@ -424,6 +439,23 @@ export class TUI extends Container {
} }
private handleInput(data: string): void { private handleInput(data: string): void {
if (this.inputListeners.size > 0) {
let current = data;
for (const listener of this.inputListeners) {
const result = listener(current);
if (result?.consume) {
return;
}
if (result?.data !== undefined) {
current = result.data;
}
}
if (current.length === 0) {
return;
}
data = current;
}
// If we're waiting for cell size response, buffer input and parse // If we're waiting for cell size response, buffer input and parse
if (this.cellSizeQueryPending) { if (this.cellSizeQueryPending) {
this.inputBuffer += data; this.inputBuffer += data;