diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index da7a5b40..7dada9bc 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -123,6 +123,7 @@ export type { SetLabelHandler, SetModelHandler, SetThinkingLevelHandler, + TerminalInputHandler, // Events - Tool ToolCallEvent, ToolCallEventResult, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index e32b1e7b..06ee38bc 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -170,6 +170,7 @@ const noOpUIContext: ExtensionUIContext = { confirm: async () => false, input: async () => undefined, notify: () => {}, + onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWidget: () => {}, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 62a3e567..c3e3183a 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 772afd39..b5bf79ba 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -100,6 +100,7 @@ export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource, + TerminalInputHandler, ToolCallEvent, ToolDefinition, ToolInfo, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f7d9b356..a00bade8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index af5a2085..89d35210 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -144,6 +144,11 @@ export async function runRpcMode(session: AgentSession): Promise { } 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({ diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index cff5f9c6..02f5cdbd 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(); /** 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;