import type { CustomEditor } from '@earendil-works/pi-coding-agent'; import type { Component, KeybindingsManager, TUI } from '@earendil-works/pi-tui'; import { type BrokerDataFrame, type BrokerSnapshot, type ClientToBroker, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from '../../../core/runtime/broker-protocol.js'; import type { ReadOpRequest } from '../../../core/broker-client/index.js'; import { type Picker, type PickerControls } from '../overlays/pickers.js'; export interface InputControllerHooks { /** Send a command frame to the broker. */ onCommand: (frame: ClientToBroker) => void; /** Answer a blocking extension dialog. */ onDialogResponse: (resp: RpcExtensionUIResponse) => void; /** OPTIONAL (additive to the fixed interface): surface a transient notice in * the viewer. The InputController does not own the layout (T5/T7 do), so it * reports notices up the same way it reports commands. Absent → notices are * dropped silently. */ onNotice?: (message: string) => void; /** OPTIONAL: the canvas node this viewer is attached to — forwarded to the * slash context so `/promote` targets it (Unit Q wires it from runAttach). */ nodeId?: string; /** OPTIONAL: the attached node's profile identity — forwarded to `/resume-node` * so canvas browse opens scoped to this profile. */ profileId?: string; /** OPTIONAL: true for a REMOTE attach (`crtr surface attach --canvas`) — * forwarded to the slash context so it can structurally omit/no-op the * local-only native canvas commands (`/promote`, `/resume-node`, `/context`). * Defaults to `false` (local, unchanged) when absent. */ remote?: boolean; /** OPTIONAL: toggle the GRAPH overlay — forwarded to the slash context so * `/graph` opens/closes it (Unit Q wires it from runAttach). */ onGraph?: () => void; /** OPTIONAL: detach the viewer — forwarded to the slash context so `/quit` * tears down the TUI/socket (the engine keeps running). Wired by viewer.ts. */ onQuit?: () => void; /** OPTIONAL: copy the last assistant message to the clipboard — forwarded to * the slash context so `/copy` works. Wired by viewer.ts (it owns ChatView). */ onCopy?: () => void; /** OPTIONAL: recolor the editor's name chip — forwarded to the slash context * so `/color` paints it (`null` clears). Wired by viewer.ts (it owns the * TitledEditor). */ onColor?: (color: string | null) => void; /** OPTIONAL: open the focused node's metadata dossier. */ onNodeMetadata?: () => void; /** OPTIONAL: Ctrl+O / app.tools.expand — flip global tool-output expansion in * the render layer (ChatView owns the components). Absent → the key no-ops. */ onToggleToolsExpand?: () => void; /** OPTIONAL: Ctrl+T / app.thinking.toggle — flip thinking-block visibility in * the render layer. Absent → the key no-ops. */ onToggleThinking?: () => void; /** OPTIONAL: issue a correlated read-op and resolve with the broker's `data` * reply. MUST be wired by viewer.ts (`onRequest: (f) => socket.request(f)`) * for the native pickers to work; when absent every picker degrades to a * text-arg notice. */ onRequest?: (frame: ReadOpRequest) => Promise; /** OPTIONAL pair: mount/unmount a native picker INLINE in the viewer's layout * (viewer.ts slots it directly under the editor). When BOTH are wired, * showPicker uses them instead of a centered overlay; absent → overlay * fallback. The controller stays layout-agnostic either way (T5/T7 own it). */ onMountPicker?: (component: Component) => void; onUnmountPicker?: () => void; /** OPTIONAL: open the native /login provider picker (viewer-local auth flow). */ openLoginPicker?: () => void; /** OPTIONAL: open the native /logout provider picker (viewer-local auth flow). */ openLogoutPicker?: () => void; /** OPTIONAL: true while the attached node is DORMANT (no live broker to drive). * When true, a submitted prompt has no socket to reach, so `handleSubmit` * routes it through {@link onDormantSubmit} (a wake message) instead of * emitting into a dead socket. Wired by viewer.ts's reconnect supervisor. */ isDormant?: () => boolean; /** OPTIONAL: deliver `text` as an inbox message that WAKES the dormant node * (revive + deliver), so the still-open viewer's input box just works. Wired * by viewer.ts to `cliClient().sendMessage`; absent (e.g. remote attach) → * the dormant-submit path is disabled and prompts fall through as before. */ onDormantSubmit?: (text: string) => void; } export declare class InputController { private readonly tui; private readonly editor; private readonly keybindings; private readonly hooks; /** The currently-rendered blocking dialog, if any (for supersede/dismiss). */ private dialog; /** The request id of {@link dialog} — so a broker-driven `extension_ui_dismiss` * tears down ONLY the matching overlay (correlated dismissal), never an * unrelated one. */ private dialogId; /** The currently-open native picker overlay, if any (for supersede/dismiss). */ private picker; /** Monotonic open counter — a slow read-op reply whose `seq` is no longer the * latest is dropped, so an earlier picker never pops over a later request. */ private openSeq; /** Latest engine state from `welcome`/`session_info_changed` (for `/session`). */ private state; /** Timestamp of the last lone Esc, for double-tap → tree detection (0 = none). */ private lastEscapeAt; /** Pasted-image registry: placeholder ordinal → temp-file path. The editor * shows a clean `[Image #N]` token instead of the ugly temp path; on submit * each token is expanded back to its path so the agent reads it off disk. * Cleared whenever the editor is emptied (send / clear). */ private pastedImages; /** Monotonic paste counter — the `N` in `[Image #N]`. */ private pasteSeq; constructor(tui: TUI, editor: CustomEditor, keybindings: KeybindingsManager, hooks: InputControllerHooks); /** Central choke point for every command frame the input layer emits (direct * keybindings, emitDrive, and the slash-command/picker `send` sink). Local * attach (`hooks.remote` false/absent) is byte-for-byte unchanged. */ private emitCommand; /** Render extension UI requests from the broker. Blocking dialogs route their * answer back to the broker; non-blocking notify requests become the viewer's * normal notice line and NOTHING else — a notify must never tear down an * unrelated blocking dialog whose broker request is still pending. A dialog is * torn down only by (a) a NEW blocking dialog superseding it (control handoff * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog} * from the broker (the request was aborted/timed out out-of-band). */ attachDialog(req: RpcExtensionUIRequest): void; /** Broker-driven correlated dismissal: the broker resolved this request itself * (its request was aborted out-of-band — e.g. a local OAuth loopback callback * won the race against this manual-paste dialog — or its timeout fired). Tear * down ONLY the overlay whose id matches; leave any other dialog untouched. */ dismissDialog(id: string): void; /** Feed the latest engine state so read-only commands (e.g. `/session`) report * current values. Optional; the controller works without it. */ setState(state: BrokerSnapshot['state']): void; private wire; private slashContext; /** Send a command frame to the broker (picker selection sink). */ private send; /** Fetch a picker payload (narrowed on `kind`), then build + show its overlay * iff this open is still the latest (m4: a stale slow reply is dropped). Any * failure — unwired channel, wrong kind, request error, or a throwing builder * — surfaces a notice instead of an unhandled rejection (m5). */ private openPicker; /** Show a picker built by `build` (given a `close` that tears it down). * Mounted INLINE under the editor when viewer.ts wires onMountPicker/ * onUnmountPicker (the integrated chrome path); otherwise falls back to a * centered overlay. Supersedes any picker already on screen. Routes focus to * the builder's declared focus target (an inner list for fork/settings) and * ALWAYS restores editor focus on close — `OverlayHandle.hide()` only * auto-restores when the OUTER component held focus, which is not the case * once we focus an inner child. A throwing builder is caught into a notice (m5). */ private showPicker; private openModelPicker; /** Unlike the other pickers, the session picker opens IMMEDIATELY — its * component owns async loaders (with a Loading header), so the multi-second * session-dir scan runs off the open path instead of blocking it. */ private openSessionPicker; private openTreePicker; private openForkPicker; private openSettingsPicker; private openScopedModelsPicker; /** `/mcp` — the MCP server status panel. Viewer-local (no read-op): the rows * come from the adapter's on-disk config sources + tool cache * (overlays/mcp.ts), and the actions (connect / auth-start) ride a `prompt` * frame to the engine, where the adapter runtime actually lives. With a * server name (`/mcp `), act on that server directly. */ private openMcpPicker; /** Open the transcript-backed file-review picker. The caller owns the popup * process and cursor insertion; this controller only owns picker lifetime. */ openFileReviewPicker(candidates: string[], cwd: string, onChoose: (file: string) => void): void; /** Open a viewer-local auth picker (login/logout) — uses the same showPicker * machinery as read-op pickers, but with a locally-built component (no read-op * request; auth operations are viewer-local in tmux-local attach). `prepare` * is async because provider status is async; superseded opens drop on openSeq. */ openAuthPicker(prepare: () => Promise<(controls: PickerControls) => Picker>): Promise; private handleSubmit; private handleFollowUp; /** Replace every `[Image #N]` token with the temp-file path of the image the * user pasted, so the agent receives a readable path even though the editor * only ever showed the clean placeholder. Unknown ordinals are left as-is. */ private expandImagePlaceholders; /** Drop the pasted-image registry and reset the ordinal counter — called once * a submit has consumed the editor text. */ private clearPastedImages; /** Ctrl+G / app.editor.external — open the current editor text in $VISUAL/$EDITOR * and load the saved result back. Ported from pi's `openExternalEditor`: stop * the TUI to release the terminal, run the editor with inherited stdio, and on * a clean exit (status 0) replace the editor text; the socket keeps running in * the background throughout. */ private openExternalEditor; /** Alt+Up / app.message.dequeue — clear the broker's queued steering + follow-up * messages and restore them to the editor, prepended to any in-progress text * (pi's `restoreQueuedMessagesToEditor`: queued text first, joined by blank * lines). A read-AND-mutate op over the correlated request channel. */ private handleDequeue; /** Send a drive frame iff the WHOLE encoded frame fits under MAX_FRAME_BYTES * (the broker destroys the socket on any line over its 24 MiB read cap). Over * the ceiling → notify + refuse so the caller leaves the editor + pending * images intact for the user to trim, never a socket-destroying overflow. */ private emitDrive; private handlePaste; /** Register an image path in the paste registry and splice a clean `[Image #N]` * token into the editor at the caret instead of the raw path. On submit the * token is expanded back to the path (see expandImagePlaceholders), so the * agent reads the image off disk. Shared by clipboard paste and drag-drop. */ private attachImage; private notify; }