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,
SetModelHandler,
SetThinkingLevelHandler,
TerminalInputHandler,
// Events - Tool
ToolCallEvent,
ToolCallEventResult,

View file

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

View file

@ -97,6 +97,9 @@ export interface ExtensionWidgetOptions {
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.
* Each mode (interactive, RPC, print) provides its own implementation.
@ -114,6 +117,9 @@ export interface ExtensionUIContext {
/** Show a notification to the user. */
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. */
setStatus(key: string, text: string | undefined): void;

View file

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

View file

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

View file

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

View file

@ -39,6 +39,9 @@ export interface Component {
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.
* 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 previousWidth = 0;
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. */
public onDebug?: () => void;
@ -377,6 +381,17 @@ export class TUI extends Container {
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 {
// Only query if terminal supports images (cell size is only used for image rendering)
if (!getCapabilities().images) {
@ -424,6 +439,23 @@ export class TUI extends Container {
}
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 (this.cellSizeQueryPending) {
this.inputBuffer += data;