Skip to main content

chat/
commands.rs

1//! Command palette: a VS Code-style quick command overlay (`Ctrl+K`).
2//!
3//! A top-anchored `<select filter>` lists the demo's executable actions —
4//! switch model, toggle the sidebar, quit — followed by every slash
5//! command the composer completes; picking a slash entry stages `/name `
6//! in the composer instead of pretending to run it. The core widget owns
7//! the query editor, fuzzy ranking, cursor movement, windowed scrolling,
8//! hover, wheel, and click activation — the palette only routes the
9//! surfaced [`UiEvent`]s, exactly like the model picker.
10
11use omp_core::{Str, fmts};
12use omp_tui::{
13	Color, Dim, Key, Layer, Mouse, OverlayAnchor, OverlayOptions, Prop, Size, Ui, UiContext,
14	UiEvent, dom,
15};
16
17use crate::demo::demo_commands;
18
19const CYAN: Color = Color::Rgb(62, 190, 203);
20const TEXT: Color = Color::Rgb(194, 198, 204);
21const DIM: Color = Color::Rgb(110, 116, 124);
22
23const HINT: &str = "↑/↓ commands · Enter run · type to search · Esc close";
24
25/// Rows the palette occupies beyond the list: box borders, the select's
26/// query row, and the hint bar.
27const FRAME_ROWS: u16 = 4;
28
29/// What a routed input event did to the palette.
30#[derive(Clone, PartialEq, Eq, Debug)]
31pub enum PaletteEvent {
32	/// The event was handled; the palette stays open.
33	Consumed,
34	/// The palette dismissed without running anything.
35	Close,
36	/// An entry was activated; the host executes it and closes.
37	Run(PaletteAction),
38}
39
40/// One executable palette entry.
41#[derive(Clone, PartialEq, Eq, Debug)]
42pub enum PaletteAction {
43	/// Open the model picker (`Ctrl+P`).
44	SwitchModel,
45	/// Toggle the session rail (`Ctrl+B`).
46	ToggleSidebar,
47	/// Exit the demo (`Ctrl+C`).
48	Quit,
49	/// Stage this text in the composer (slash commands).
50	Insert(Str),
51}
52
53/// Sentinel values for the built-in actions; slash entries carry their
54/// `/name` spelling as the value, so bare names never collide.
55const SWITCH_MODEL: &str = "switch-model";
56const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
57const QUIT: &str = "quit";
58
59/// Retained palette overlay: one `Ui` for the whole entry list, rebuilt
60/// only on width changes; everything else is core select state.
61pub struct CommandPalette {
62	ui:      Ui,
63	ctx:     UiContext,
64	options: OverlayOptions,
65	/// Query carried across width rebuilds.
66	query:   Str,
67	/// List rows granted by the last viewport.
68	rows:    u16,
69}
70
71impl CommandPalette {
72	/// Opens the palette, presenting through the host's detected context.
73	pub fn open(ctx: &UiContext) -> Self {
74		let options = OverlayOptions::default()
75			.anchor(OverlayAnchor::Top)
76			.offset_y(1)
77			.z(10);
78		Self { ui: build("", 8, 100, ctx), ctx: ctx.clone(), options, query: Str::default(), rows: 8 }
79	}
80
81	/// Routes a key through the retained tree and maps the surfaced event.
82	pub fn handle_key(&mut self, key: Key) -> PaletteEvent {
83		let event = self.ui.handle_key(key);
84		self.route(event)
85	}
86
87	/// Routes pasted text into the select's query editor.
88	pub fn handle_paste(&mut self, text: &str) -> PaletteEvent {
89		let event = self.ui.handle_paste(text);
90		self.route(event)
91	}
92
93	/// Routes a mouse report through the compositor's own band; a click
94	/// outside the layer dismisses the palette.
95	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> PaletteEvent {
96		match self
97			.ui
98			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
99		{
100			Some(event) => self.route(event),
101			None if kind == Mouse::Click => PaletteEvent::Close,
102			None => PaletteEvent::Consumed,
103		}
104	}
105
106	/// The composited layer for this frame: top-anchored, 60% wide (at
107	/// least 48 cells), at most half the viewport tall.
108	pub fn layer(&mut self, viewport: Size) -> Layer<'_> {
109		let width = (viewport.width * 3 / 5).max(48).min(viewport.width);
110		let rows = (viewport.height / 2).saturating_sub(FRAME_ROWS).max(5);
111		if rows != self.rows {
112			self.rows = rows;
113			// One query row plus the windowed list.
114			self
115				.ui
116				.set_prop("commands", Prop::H, rows.saturating_add(1));
117		}
118		if self.ui.frame().size().width != width {
119			self.ui = build(&self.query, self.rows, width, &self.ctx);
120		}
121		self.options = self.options.width(Dim::Cells(width));
122		Layer { frame: self.ui.frame(), options: &self.options, active: true }
123	}
124
125	/// Applies one surfaced [`UiEvent`] to palette state.
126	fn route(&mut self, event: UiEvent) -> PaletteEvent {
127		match event {
128			UiEvent::Cancel => PaletteEvent::Close,
129			UiEvent::Changed { value, .. } => match value.as_str() {
130				SWITCH_MODEL => PaletteEvent::Run(PaletteAction::SwitchModel),
131				TOGGLE_SIDEBAR => PaletteEvent::Run(PaletteAction::ToggleSidebar),
132				QUIT => PaletteEvent::Run(PaletteAction::Quit),
133				slash => PaletteEvent::Run(PaletteAction::Insert(fmts!("{slash} "))),
134			},
135			UiEvent::Filtered { query, .. } => {
136				self.query = query;
137				PaletteEvent::Consumed
138			},
139			UiEvent::None | UiEvent::Submit | UiEvent::Highlighted { .. } | UiEvent::Pressed(_) => {
140				PaletteEvent::Consumed
141			},
142		}
143	}
144}
145
146/// One option row's static content.
147struct EntrySpec {
148	value:   Str,
149	label:   Str,
150	name:    Str,
151	name_fg: Color,
152	detail:  Str,
153	/// Right-aligned keybinding column; empty for slash entries.
154	key:     Str,
155}
156
157impl EntrySpec {
158	const fn action(
159		value: &'static str,
160		name: &'static str,
161		detail: &'static str,
162		key: &'static str,
163	) -> Self {
164		Self {
165			value:   Str::new_static(value),
166			label:   Str::new_static(name),
167			name:    Str::new_static(name),
168			name_fg: TEXT,
169			detail:  Str::new_static(detail),
170			key:     Str::new_static(key),
171		}
172	}
173}
174
175/// The full entry list: built-in actions first, slash commands after,
176/// mirroring VS Code's palette ordering (commands, then everything else).
177fn entries() -> Vec<EntrySpec> {
178	let commands = demo_commands();
179	let mut list = Vec::with_capacity(commands.len() + 3);
180	list.push(EntrySpec::action(
181		SWITCH_MODEL,
182		"Switch Model",
183		"Pick the model for this session",
184		"ctrl+p",
185	));
186	list.push(EntrySpec::action(
187		TOGGLE_SIDEBAR,
188		"Toggle Sidebar",
189		"Show or hide the session rail",
190		"ctrl+b",
191	));
192	list.push(EntrySpec::action(QUIT, "Quit", "Exit the demo", "ctrl+c"));
193	list.extend(commands.iter().map(|command| {
194		let name = fmts!("/{}", command.name());
195		EntrySpec {
196			value: name.clone(),
197			label: name.clone(),
198			name,
199			name_fg: CYAN,
200			detail: Str::from(command.description()),
201			key: Str::default(),
202		}
203	}));
204	list
205}
206
207/// Builds the retained overlay tree.
208fn build(query: &str, rows: u16, width: u16, ctx: &UiContext) -> Ui {
209	let list = entries();
210	let seed = Str::from(query);
211	let height = rows.saturating_add(1);
212	Ui::from_root(
213		dom! {
214			<box border=round title="Commands" pad-x=1>
215				<col>
216					<select id="commands" filter={seed} h={height}>
217						for entry in list {
218							<option value={entry.value} label={entry.label}>
219								<td><pre fg={entry.name_fg}>{entry.name}</pre></td>
220								<td truncate grow><pre fg={DIM}>{entry.detail}</pre></td>
221								if !entry.key.is_empty() {
222									<td align=end><pre fg={DIM}>{entry.key}</pre></td>
223								}
224							</option>
225						}
226					</select>
227					<text dim truncate>{HINT}</text>
228				</col>
229			</box>
230		},
231		width,
232		ctx.clone(),
233	)
234}