yapi_core/extensions/ui.rs
1//! What extensions show, as pi's `ExtensionUIContext`: dialogs,
2//! notifications, footer statuses, widgets, the editor and custom components.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use futures_util::future::BoxFuture;
8use serde_json::Value;
9use tokio_util::sync::CancellationToken;
10
11/// How a notification is shown.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum NotifyKind {
14 /// A status message.
15 #[default]
16 Info,
17 /// A warning.
18 Warning,
19 /// An error.
20 Error,
21}
22
23impl NotifyKind {
24 /// pi's name for the kind.
25 pub fn as_str(self) -> &'static str {
26 match self {
27 NotifyKind::Info => "info",
28 NotifyKind::Warning => "warning",
29 NotifyKind::Error => "error",
30 }
31 }
32}
33
34/// What extensions see of the terminal.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct TerminalState {
37 /// Its size in columns and rows.
38 pub size: (usize, usize),
39 /// The prompt editor, or an extension's editor in its place, has the
40 /// keyboard, rather than a selector, a dialog or an extension component,
41 /// as pi-tui's `focusedComponent` tells.
42 pub editor_focused: bool,
43}
44
45/// pi's `ExtensionUIDialogOptions`: when a dialog closes on its own.
46#[derive(Clone, Debug, Default)]
47pub struct DialogOptions {
48 /// Closes the dialog as cancelled after this long. Interactive dialogs
49 /// count down in their title.
50 pub timeout: Option<Duration>,
51 /// Closes the dialog as cancelled once cancelled.
52 pub cancel: Option<CancellationToken>,
53}
54
55/// pi's `WidgetPlacement`: where a widget goes.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Placement {
58 /// Above the editor.
59 #[default]
60 AboveEditor,
61 /// Below the editor.
62 BelowEditor,
63}
64
65/// pi's `WorkingIndicatorOptions`: the animation shown while the agent works.
66#[derive(Clone, Debug, Default, PartialEq)]
67pub struct WorkingIndicator {
68 /// Frames drawn as given, escape sequences included. `None` keeps the
69 /// default spinner frames, and an empty list hides the indicator.
70 pub frames: Option<Vec<String>>,
71 /// Milliseconds per frame, or `None` for the default.
72 pub interval_ms: Option<u64>,
73}
74
75/// What a widget shows.
76#[derive(Clone, Debug)]
77pub enum Widget {
78 /// Lines of text with escape sequences.
79 Lines(Vec<String>),
80 /// A component the extension renders.
81 Component(RemoteComponent),
82}
83
84/// The extension runtime side of components: renders them and delivers
85/// input to them.
86pub trait ComponentHost: Send + Sync {
87 /// The lines of component `handle` at `width` columns: text with escape
88 /// sequences, and pi-tui's cursor marker where a focused component wants
89 /// the cursor. Empty once the component is gone.
90 fn render(&self, handle: u32, width: u16) -> BoxFuture<'static, Vec<String>>;
91
92 /// Delivers raw terminal input to component `handle`.
93 fn input(&self, handle: u32, data: &str);
94
95 /// Delivers pi-tui's `TuiMouseEvent` `event` to component `handle`,
96 /// after the input delivered before it. Resolves to whether the
97 /// component took it, as pi-tui's `dispatchMouseEvent` decides.
98 fn mouse(&self, handle: u32, event: &Value) -> BoxFuture<'static, bool>;
99
100 /// Sends `op` to editor component `handle`, after the input delivered
101 /// before it: `{"op": "setText", "text"}`, `{"op": "addToHistory",
102 /// "text"}`, `{"op": "insertTextAtCursor", "text", "apart"}`, or
103 /// `{"op": "configure", "border", "paddingX", "autocompleteMaxVisible",
104 /// "focused", "rows"}`. `apart` sets the text apart from the words
105 /// around the cursor, and `border` is a thinking level or `bashMode`.
106 fn editor_op(&self, handle: u32, op: &Value);
107
108 /// Runs the runtime's `onTerminalInput` listeners over raw input `keys`,
109 /// in order. Answers the keys left to handle, as the listeners
110 /// transformed them, without those they consumed or emptied.
111 fn terminal_input(&self, keys: Vec<String>) -> BoxFuture<'static, Vec<String>>;
112
113 /// The suggestions of the runtime's autocomplete providers for pi's
114 /// provider arguments `request`, as for [`ExtensionUi::suggestions`],
115 /// with what applying each item gives: `{"prefix", "items", "applied":
116 /// [{"lines", "cursorLine", "cursorCol"}]}`, or `Null`.
117 fn suggestions(&self, request: Value) -> BoxFuture<'static, Value>;
118}
119
120/// A pi-tui component that lives in an extension runtime.
121#[derive(Clone)]
122pub struct RemoteComponent {
123 runtime: u64,
124 handle: u32,
125 host: Arc<dyn ComponentHost>,
126}
127
128impl std::fmt::Debug for RemoteComponent {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("RemoteComponent")
131 .field("runtime", &self.runtime)
132 .field("handle", &self.handle)
133 .finish_non_exhaustive()
134 }
135}
136
137impl RemoteComponent {
138 /// Component `handle` of `host`, the runtime numbered `runtime`.
139 pub fn new(runtime: u64, handle: u32, host: Arc<dyn ComponentHost>) -> RemoteComponent {
140 RemoteComponent {
141 runtime,
142 handle,
143 host,
144 }
145 }
146
147 /// What identifies it across runtimes: the runtime's number and its
148 /// handle there.
149 pub fn key(&self) -> (u64, u32) {
150 (self.runtime, self.handle)
151 }
152
153 /// Its lines at `width` columns (see [`ComponentHost::render`]).
154 pub fn render(&self, width: u16) -> BoxFuture<'static, Vec<String>> {
155 self.host.render(self.handle, width)
156 }
157
158 /// Delivers raw terminal input.
159 pub fn input(&self, data: &str) {
160 self.host.input(self.handle, data);
161 }
162
163 /// Delivers a mouse event (see [`ComponentHost::mouse`]).
164 pub fn mouse(&self, event: &Value) -> BoxFuture<'static, bool> {
165 self.host.mouse(self.handle, event)
166 }
167
168 /// Sends an operation to the editor it is, as
169 /// [`ComponentHost::editor_op`] describes.
170 pub fn editor_op(&self, op: &Value) {
171 self.host.editor_op(self.handle, op);
172 }
173}
174
175/// The options of pi's `custom`: how a custom component is shown.
176#[derive(Clone, Debug, Default)]
177pub struct CustomOptions {
178 /// Over the screen instead of in the editor's place.
179 pub overlay: bool,
180 /// pi's `OverlayOptions`: anchor, size and margins.
181 pub overlay_options: Value,
182}
183
184/// pi's `ExtensionUIContext` as a mode implements it. Dialogs a mode cannot
185/// show resolve as cancelled. Everything else does nothing by default.
186pub trait ExtensionUi: Send + Sync {
187 /// Whether a person can answer dialogs.
188 fn has_ui(&self) -> bool;
189
190 /// pi's `ctx.shutdown()`: the mode exits once the agent is idle.
191 fn shutdown(&self) {}
192
193 /// Shows a message.
194 fn notify(&self, message: &str, kind: NotifyKind);
195
196 /// Shows a message that names no kind. Hosts show it as information,
197 /// and RPC forwards it without a kind, as pi does.
198 fn notify_untyped(&self, message: &str) {
199 self.notify(message, NotifyKind::Info);
200 }
201
202 /// Asks to pick one of `options`, resolving to `None` when cancelled.
203 fn select(
204 &self,
205 _title: &str,
206 _options: Vec<String>,
207 _dialog: DialogOptions,
208 ) -> BoxFuture<'static, Option<String>> {
209 Box::pin(async { None })
210 }
211
212 /// Asks for a line of text, resolving to `None` when cancelled.
213 fn input(
214 &self,
215 _title: &str,
216 _placeholder: Option<&str>,
217 _dialog: DialogOptions,
218 ) -> BoxFuture<'static, Option<String>> {
219 Box::pin(async { None })
220 }
221
222 /// Asks a yes or no question, resolving to `false` when cancelled.
223 fn confirm(
224 &self,
225 _title: &str,
226 _message: &str,
227 _dialog: DialogOptions,
228 ) -> BoxFuture<'static, bool> {
229 Box::pin(async { false })
230 }
231
232 /// Asks for multi-line text starting from `prefill`, resolving to `None`
233 /// when cancelled.
234 fn editor(&self, _title: &str, _prefill: Option<&str>) -> BoxFuture<'static, Option<String>> {
235 Box::pin(async { None })
236 }
237
238 /// Sets the footer status `key`, or clears it.
239 fn set_status(&self, _key: &str, _text: Option<&str>) {}
240
241 /// Sets widget `key`, or removes it. Without a placement it goes above
242 /// the editor.
243 fn set_widget(&self, _key: &str, _widget: Option<Widget>, _placement: Option<Placement>) {}
244
245 /// Replaces the footer, or restores the built-in one.
246 fn set_footer(&self, _component: Option<RemoteComponent>) {}
247
248 /// Replaces the startup header, or restores the built-in one.
249 fn set_header(&self, _component: Option<RemoteComponent>) {}
250
251 /// Sets the terminal title.
252 fn set_title(&self, _title: &str) {}
253
254 /// Completions an extension computed in the background are ready. The
255 /// editor asks for its suggestions again.
256 fn refresh_completions(&self) {}
257
258 /// Sets the message shown while the agent works, or restores the default.
259 fn set_working_message(&self, _message: Option<&str>) {}
260
261 /// Shows or hides the indicator shown while the agent works.
262 fn set_working_visible(&self, _visible: bool) {}
263
264 /// Sets the animation shown while the agent works, or restores the
265 /// default spinner.
266 fn set_working_indicator(&self, _indicator: Option<WorkingIndicator>) {}
267
268 /// Sets the label of hidden thinking blocks, or restores the default.
269 fn set_hidden_thinking_label(&self, _label: Option<&str>) {}
270
271 /// Replaces the editor's text.
272 fn set_editor_text(&self, _text: &str) {}
273
274 /// Pastes into the editor as a terminal paste would.
275 fn paste_to_editor(&self, text: &str) {
276 self.set_editor_text(text);
277 }
278
279 /// The editor's text.
280 fn editor_text(&self) -> String {
281 String::new()
282 }
283
284 /// Whether [`ExtensionUi::custom`] and component widgets are shown.
285 fn shows_components(&self) -> bool {
286 false
287 }
288
289 /// Replaces the editor with `editor`, or restores the built-in one.
290 /// `embeds_status` when the editor shows the working status in its top
291 /// border.
292 fn set_editor(&self, _editor: Option<RemoteComponent>, _embeds_status: bool) {}
293
294 /// The extension's editor now holds `text`, paste markers expanded.
295 fn editor_changed(&self, _text: &str) {}
296
297 /// The extension's editor submitted `text`.
298 fn editor_submit(&self, _text: &str) {}
299
300 /// The extension's editor asks for app action `action`, a key binding id
301 /// such as `app.interrupt`.
302 fn editor_action(&self, _action: &str) {}
303
304 /// Starts passing raw input through the `onTerminalInput` listeners of
305 /// extension runtime `runtime`, which `listeners` runs, or stops with
306 /// `None`. Runtimes' listeners see input in the order they started.
307 fn set_terminal_input(&self, _runtime: u64, _listeners: Option<Arc<dyn ComponentHost>>) {}
308
309 /// Completes through the providers composed with
310 /// `addAutocompleteProvider`, which `providers` runs. `triggers` open
311 /// completion too. They replace the providers of the runtime that set
312 /// them before.
313 fn set_autocomplete(&self, _providers: Arc<dyn ComponentHost>, _triggers: Vec<String>) {}
314
315 /// What the built-in provider suggests for pi's provider arguments
316 /// `request`, `{"lines", "cursorLine", "cursorCol", "force"}` with
317 /// UTF-16 columns: pi's `AutocompleteSuggestions`, or `Null`.
318 fn suggestions(&self, _request: Value) -> BoxFuture<'static, Value> {
319 Box::pin(async { Value::Null })
320 }
321
322 /// The built-in provider's `applyCompletion` for `{"lines",
323 /// "cursorLine", "cursorCol", "item", "prefix"}`: `{"lines",
324 /// "cursorLine", "cursorCol"}`.
325 fn apply_completion(&self, _request: &Value) -> Value {
326 Value::Null
327 }
328
329 /// Whether raw input `data` is an extension shortcut, which then runs.
330 fn editor_shortcut(&self, _data: &str) -> bool {
331 false
332 }
333
334 /// The key bindings components match keys against: `{"kitty",
335 /// "bindings": {id: [key]}, "actions": [id]}`, where `actions` are the
336 /// app actions an extension's editor can ask for. `Null` keeps pi-tui's
337 /// defaults.
338 fn keybindings(&self) -> Value {
339 Value::Null
340 }
341
342 /// Shows `component` with keyboard focus until [`ExtensionUi::close`].
343 fn custom(&self, _component: RemoteComponent, _options: CustomOptions) {}
344
345 /// Removes the custom component `component`.
346 fn close(&self, _component: RemoteComponent) {}
347
348 /// Removes the editor, widgets, footer and header that extension runtime
349 /// `runtime` showed, which it lost when it restarted. The built-in
350 /// editor, footer and header return.
351 fn drop_components(&self, _runtime: u64) {}
352
353 /// Shows a screen of the `/mcp` manager with keyboard focus, opening the
354 /// manager. [`McpScreen::Close`] closes it. Without the TUI the screen
355 /// is dropped, which closes its menu.
356 ///
357 /// [`McpScreen::Close`]: crate::mcp::extension::McpScreen::Close
358 fn mcp_manager(&self, _screen: crate::mcp::extension::McpScreen) {}
359
360 /// Components changed and need rendering.
361 fn request_render(&self) {}
362
363 /// An extension registered transcript renderers or a Markdown
364 /// transformer after its session started. What draws next uses them.
365 fn renderers_changed(&self) {}
366
367 /// Whether tool output is expanded.
368 fn tools_expanded(&self) -> bool {
369 false
370 }
371
372 /// Expands or collapses tool output.
373 fn set_tools_expanded(&self, _expanded: bool) {}
374
375 /// The terminal's state, kept current as it changes, or `None` without a
376 /// terminal, as in print, JSON and RPC modes.
377 fn terminal(&self) -> Option<tokio::sync::watch::Receiver<TerminalState>> {
378 None
379 }
380
381 /// What custom footers read: `{"gitBranch", "statuses": [[key, text]],
382 /// "providers"}`.
383 fn footer_data(&self) -> Value {
384 Value::Null
385 }
386
387 /// The theme as escape sequences, and the colors they come from:
388 /// `{"name", "mode", "fg": {token: sequence}, "bg": {token: sequence},
389 /// "dim": [token], "colors": {token: color}}`. `Null` leaves text
390 /// unstyled.
391 fn theme(&self) -> Value {
392 Value::Null
393 }
394
395 /// The theme called `name`, as [`ExtensionUi::theme`] describes it, or
396 /// `Null` when there is none.
397 fn get_theme(&self, _name: &str) -> Value {
398 Value::Null
399 }
400
401 /// pi's `getAllThemes`: the themes [`ExtensionUi::set_theme`] can switch
402 /// to, as `[{"name", "path"}]` with no `path` for a theme without a file.
403 /// Empty without a UI that switches themes, as in pi's RPC mode.
404 fn all_themes(&self) -> Value {
405 Value::Array(Vec::new())
406 }
407
408 /// Switches to `theme`: a theme name, which is saved as `/settings`
409 /// saves it, or a theme document `{"name", "appearance", "colors"}`,
410 /// which is not saved. The error says why it could not.
411 fn set_theme(&self, _theme: &Value) -> Result<(), String> {
412 Err("UI not available".into())
413 }
414
415 /// A handler of extension `path` failed on `event`, with the error's
416 /// stack when the runtime gave one. By default it reports as print mode
417 /// does.
418 fn extension_error(&self, path: &str, _event: &str, error: &str, _stack: Option<&str>) {
419 use std::io::Write as _;
420 let _ = writeln!(std::io::stderr(), "Extension error ({path}): {error}");
421 }
422}
423
424/// The UI of print and JSON modes: nothing is shown.
425pub struct NoUi;
426
427impl ExtensionUi for NoUi {
428 fn has_ui(&self) -> bool {
429 false
430 }
431
432 fn notify(&self, _message: &str, _kind: NotifyKind) {}
433}