Skip to main content

chat/
sidebar.rs

1//! Persistent session rail: a non-modal sidebar beside the chat transcript.
2//!
3//! Unlike the modal picker, the rail never holds the alternate screen: it
4//! rides every inline present as a raw [`Layer`], so the transcript keeps
5//! committing to native scrollback beneath it and history receives the
6//! full-width document — never a sidebar cell. `Ctrl+B` toggles the rail
7//! and hands it the keyboard (arrow keys drive the file list); `Esc`
8//! returns typing to the composer ([`Ui::focus_first`] / [`Ui::blur`], the
9//! raw-frame halves of the keyboard hand-off). Clicks move the keyboard
10//! the same way wherever the session reports the pointer — this demo
11//! leaves the inline mouse to the terminal for native text selection, so
12//! that path is live during alternate-screen scenes.
13
14use std::time::Duration;
15
16use omp_core::{Str, fmts};
17use omp_tui::{
18	Color, Dim, Key, Layer, Mouse, OverlayAnchor, OverlayOptions, Prop, Size, Ui, UiContext,
19	UiEvent, dom,
20};
21
22const CYAN: Color = Color::Rgb(62, 190, 203);
23const GREEN: Color = Color::Rgb(81, 196, 112);
24const DIM: Color = Color::Rgb(110, 116, 124);
25
26/// Rail width in cells, vertical rule included.
27const WIDTH: u16 = 30;
28/// Smallest viewport the rail composites in; below it the band gates out
29/// and the transcript gets the whole width back.
30const MIN_VIEWPORT: Size = Size::new(96, 20);
31
32/// Files the demo session pretends to touch while "implementing immutable
33/// seam commits".
34const FILES: [(&str, &str); 5] = [
35	("renderer.rs", "+142"),
36	("frame.rs", "+38"),
37	("seam.rs", "+210"),
38	("commit_tests.rs", "+96"),
39	("seams.md", "+17"),
40];
41
42/// Retained session rail composited as a right-anchored viewport layer.
43pub struct Sidebar {
44	ui:              Ui,
45	options:         OverlayOptions,
46	/// Whether the rail is composited at all (`Ctrl+B`).
47	open:            bool,
48	/// Whether the rail holds the keyboard (arrow keys drive the file list).
49	focused:         bool,
50	elapsed_seconds: u64,
51	height:          u16,
52}
53
54impl Sidebar {
55	/// Builds the rail, presenting through the host's detected context.
56	pub fn new(model: &str, ctx: &UiContext) -> Self {
57		let options = OverlayOptions::default()
58			.anchor(OverlayAnchor::Right)
59			.width(Dim::Cells(WIDTH))
60			.non_modal()
61			.min_viewport(MIN_VIEWPORT);
62		let mut ui = build(model, ctx);
63		// The rail starts without the keyboard: no focus chrome or frame
64		// cursor until `toggle` or a click hands it over.
65		ui.blur();
66		Self { ui, options, open: true, focused: false, elapsed_seconds: 0, height: 0 }
67	}
68
69	/// Whether the rail composites for `viewport`.
70	const fn visible(&self, viewport: Size) -> bool {
71		self.open && viewport.width >= MIN_VIEWPORT.width && viewport.height >= MIN_VIEWPORT.height
72	}
73
74	/// Columns the rail reserves at `viewport`: its full width while
75	/// composited, zero when toggled off or gated out. The composer docks
76	/// its right-aligned chrome against the remaining width.
77	pub const fn reserved(&self, viewport: Size) -> u16 {
78		if self.visible(viewport) { WIDTH } else { 0 }
79	}
80
81	/// Whether the rail currently holds the keyboard.
82	pub const fn focused(&self) -> bool {
83		self.focused
84	}
85
86	/// `Ctrl+B`: opening hands the rail the keyboard, closing returns it.
87	pub fn toggle(&mut self) {
88		self.open = !self.open;
89		if self.open {
90			self.focused = true;
91			self.ui.focus_first();
92		} else {
93			self.blur();
94		}
95	}
96
97	/// Routes a key while the rail holds the keyboard; `Esc` hands it back.
98	pub fn handle_key(&mut self, key: Key) {
99		if self.ui.handle_key(key) == UiEvent::Cancel {
100			self.blur();
101		}
102	}
103
104	/// Routes a mouse report through the rail's band. A click inside takes
105	/// the keyboard, a click outside returns it; `false` means the gesture
106	/// was not consumed and belongs to the transcript.
107	pub fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> bool {
108		if !self.open {
109			return false;
110		}
111		if self
112			.ui
113			.handle_mouse_as_layer(&self.options, viewport, col, row, kind)
114			.is_some()
115		{
116			if kind == Mouse::Click && !self.focused {
117				self.focused = true;
118				self.ui.focus_first();
119			}
120			true
121		} else {
122			if kind == Mouse::Click {
123				self.blur();
124			}
125			false
126		}
127	}
128
129	/// Reflects a session model switch in the rail's model row.
130	pub fn set_model(&mut self, name: &str) {
131		self.ui.set_text("model", name);
132	}
133
134	/// The composited rail for this frame, laid out to the full viewport
135	/// height; `None` when toggled off or gated out by a small viewport.
136	/// Shrinking below the minimum blurs the rail so keys never route into
137	/// an invisible layer.
138	pub fn layer(&mut self, viewport: Size, elapsed: Duration) -> Option<Layer<'_>> {
139		if !self.visible(viewport) {
140			if self.focused {
141				self.blur();
142			}
143			return None;
144		}
145		if self.height != viewport.height {
146			self.height = viewport.height;
147			self.ui.set_prop("rail", Prop::H, viewport.height);
148			self.ui.set_prop("body", Prop::H, viewport.height);
149		}
150		let seconds = elapsed.as_secs();
151		if seconds != self.elapsed_seconds {
152			self.elapsed_seconds = seconds;
153			self.ui.set_text("elapsed", elapsed_label(seconds));
154		}
155		Some(Layer { frame: self.ui.frame(), options: &self.options, active: self.focused })
156	}
157
158	fn blur(&mut self) {
159		self.focused = false;
160		self.ui.blur();
161	}
162}
163
164fn elapsed_label(seconds: u64) -> Str {
165	fmts!("{}:{:02}", seconds / 60, seconds % 60)
166}
167
168/// Builds the retained rail tree: session facts, the touched-file list,
169/// and the key hints pinned to the bottom of the band.
170fn build(model: &str, ctx: &UiContext) -> Ui {
171	let files = FILES;
172	Ui::from_root(
173		dom! {
174			<row id="rail" h=24>
175				<hr/>
176				<col id="body" h=24 grow pad="0 1" gap=1>
177					<text bold fg={CYAN}>{"session"}</text>
178					<col>
179						<row gap=1>
180							<text fg={DIM} w=8>{"model"}</text>
181							<text id="model" truncate>{model}</text>
182						</row>
183						<row gap=1>
184							<text fg={DIM} w=8>{"elapsed"}</text>
185							<text id="elapsed">{"0:00"}</text>
186						</row>
187						<row gap=1>
188							<text fg={DIM} w=8>{"branch"}</text>
189							<text truncate>{"tui/seam-commits"}</text>
190						</row>
191					</col>
192					<hr/>
193					<text bold fg={CYAN}>{"files"}</text>
194					<select id="files" h={files.len() as u16}>
195						for (name, delta) in files {
196							<option value={name} label={name}>
197								<td grow truncate><pre>{name}</pre></td>
198								<td align=end><pre fg={GREEN}>{delta}</pre></td>
199							</option>
200						}
201					</select>
202					<spacer grow/>
203					<text dim truncate>{"ctrl+b rail · esc back"}</text>
204				</col>
205			</row>
206		},
207		WIDTH,
208		ctx.clone(),
209	)
210}
211
212#[cfg(test)]
213mod tests {
214	use std::time::Duration;
215
216	use omp_tui::{Size, UiContext, test_support::frame_row_text};
217
218	use super::Sidebar;
219
220	#[test]
221	fn rail_starts_passive_without_focus_chrome_or_caret() {
222		let ctx = UiContext::default();
223		let viewport = Size::new(120, 30);
224		let mut sidebar = Sidebar::new("Claude Fable 5", &ctx);
225
226		let passive: Vec<String> = {
227			let layer = sidebar
228				.layer(viewport, Duration::ZERO)
229				.expect("the rail opens by default");
230			assert!(!layer.active, "a passive rail never owns the caret");
231			(0..30)
232				.map(|row| frame_row_text(layer.frame, row))
233				.collect()
234		};
235
236		sidebar.toggle(); // hide
237		sidebar.toggle(); // show again, taking the keyboard
238		let layer = sidebar
239			.layer(viewport, Duration::ZERO)
240			.expect("the rail reopened");
241		assert!(layer.active, "the toggled-open rail owns the keyboard");
242		let focused: Vec<String> = (0..30)
243			.map(|row| frame_row_text(layer.frame, row))
244			.collect();
245		assert_ne!(passive, focused, "taking the keyboard adds the focus chrome");
246	}
247}