1use std::cell::RefCell;
7use std::collections::HashMap;
8use std::rc::Rc;
9
10use trace_stream::render::RenderOptions;
11use turbo_vision::core::event::Event;
12use turbo_vision::core::geometry::Rect;
13use turbo_vision::terminal::Terminal;
14use turbo_vision::views::view::View;
15
16use crate::pipeline::Pipeline;
17use crate::proto::StreamKind;
18use crate::registry::SessionId;
19use crate::streamview::StreamView;
20use crate::tracefmt::TraceRenderer;
21
22pub type SharedView = Rc<RefCell<StreamView>>;
24
25#[derive(Debug)]
27pub struct SharedStreamView(pub SharedView);
28
29impl View for SharedStreamView {
30 fn bounds(&self) -> Rect {
31 self.0.borrow().bounds()
32 }
33 fn set_bounds(&mut self, bounds: Rect) {
34 self.0.borrow_mut().set_bounds(bounds);
35 }
36 fn draw(&mut self, terminal: &mut Terminal) {
37 self.0.borrow_mut().draw(terminal);
38 }
39 fn handle_event(&mut self, event: &mut Event) {
40 self.0.borrow_mut().handle_event(event);
41 }
42 fn can_focus(&self) -> bool {
43 true
44 }
45 fn grow_mode(&self) -> turbo_vision::core::state::GrowFlags {
50 self.0.borrow().grow_mode()
51 }
52 fn set_grow_mode(&mut self, grow_mode: turbo_vision::core::state::GrowFlags) {
53 self.0.borrow_mut().set_grow_mode(grow_mode);
54 }
55 fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
56 None
57 }
58}
59
60#[derive(Debug)]
65enum Renderer {
66 Tokens(Box<Pipeline>),
67 Trace(TraceRenderer),
68}
69
70impl Renderer {
71 fn feed(&mut self, bytes: &[u8], view: &mut StreamView) {
72 match self {
73 Self::Tokens(p) => p.feed(bytes, view),
74 Self::Trace(t) => t.feed(bytes, view),
75 }
76 }
77
78 fn finish(&mut self, view: &mut StreamView) {
79 match self {
80 Self::Tokens(p) => p.finish(view),
81 Self::Trace(t) => t.finish(view),
82 }
83 }
84}
85
86#[derive(Debug)]
88pub struct SessionState {
89 pub name: String,
90 pub port: u16,
91 pub view: SharedView,
92 pub kind: StreamKind,
93 renderer: Renderer,
94 pub connected: bool,
95}
96
97impl SessionState {
98 #[must_use]
104 pub fn window_title(&self) -> String {
105 let base = format_title(&self.name, self.port);
106 let base = match self.kind {
107 StreamKind::Tokens => base,
108 StreamKind::Trace => format!("[trace] {base}"),
109 };
110 if self.connected {
111 base
112 } else {
113 format!("{base} [disconnected]")
114 }
115 }
116
117 pub fn feed(&mut self, bytes: &[u8]) {
119 let mut view = self.view.borrow_mut();
120 self.renderer.feed(bytes, &mut view);
121 }
122
123 pub fn finish(&mut self) {
125 let mut view = self.view.borrow_mut();
126 self.renderer.finish(&mut view);
127 }
128}
129
130#[must_use]
136pub fn format_title(name: &str, port: u16) -> String {
137 if port == 0 {
138 name.to_string()
139 } else {
140 format!("{name} :{port}")
141 }
142}
143
144#[derive(Debug, Default)]
146pub struct Sessions {
147 inner: HashMap<SessionId, SessionState>,
148}
149
150impl Sessions {
151 pub fn insert(
152 &mut self,
153 id: SessionId,
154 name: String,
155 port: u16,
156 kind: StreamKind,
157 view: SharedView,
158 opts: RenderOptions,
159 ) {
160 let renderer = match kind {
161 StreamKind::Tokens => Renderer::Tokens(Box::new(Pipeline::new(opts))),
162 StreamKind::Trace => Renderer::Trace(TraceRenderer::new()),
163 };
164 self.inner.insert(
165 id,
166 SessionState {
167 name,
168 port,
169 view,
170 kind,
171 renderer,
172 connected: false,
173 },
174 );
175 }
176
177 pub fn get_mut(&mut self, id: SessionId) -> Option<&mut SessionState> {
178 self.inner.get_mut(&id)
179 }
180
181 pub fn remove(&mut self, id: SessionId) -> Option<SessionState> {
182 self.inner.remove(&id)
183 }
184
185 pub fn feed(&mut self, id: SessionId, data: &[u8]) {
187 if let Some(s) = self.inner.get_mut(&id) {
188 s.feed(data);
189 }
190 }
191
192 pub fn mark_reconnected(&mut self, id: SessionId) {
194 if let Some(s) = self.inner.get_mut(&id) {
195 s.connected = true;
196 s.feed(b"\n-- reconnected --\n");
197 }
198 }
199
200 pub fn mark_attached(&mut self, id: SessionId, reattached: bool) {
206 if reattached {
207 self.mark_reconnected(id);
208 } else if let Some(s) = self.inner.get_mut(&id) {
209 s.connected = true;
210 }
211 }
212
213 pub fn mark_disconnected(&mut self, id: SessionId) {
214 if let Some(s) = self.inner.get_mut(&id) {
215 s.connected = false;
216 s.finish();
217 }
218 }
219
220 pub fn clear(&mut self, id: SessionId) {
222 if let Some(s) = self.inner.get_mut(&id) {
223 s.view.borrow_mut().clear();
224 }
225 }
226
227 #[must_use]
229 pub fn plain_text(&self, id: SessionId) -> Option<String> {
230 self.inner.get(&id).map(|s| s.view.borrow().plain_text())
231 }
232
233 #[must_use]
237 pub fn window_title(&self, id: SessionId) -> Option<String> {
238 self.inner.get(&id).map(SessionState::window_title)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use trace_stream::render::RenderOptions;
246
247 fn opts() -> RenderOptions {
248 RenderOptions {
249 use_color: true,
250 format_thinking: true,
251 format_markdown: true,
252 }
253 }
254
255 fn view() -> SharedView {
256 Rc::new(RefCell::new(StreamView::new(Rect::new(0, 0, 80, 24))))
257 }
258
259 #[test]
260 fn feed_reaches_the_session_view() {
261 let mut sessions = Sessions::default();
262 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
263 sessions.feed(1, b"hello\n");
264 assert!(sessions.plain_text(1).unwrap().contains("hello"));
265 }
266
267 #[test]
268 fn window_title_reflects_connection_state() {
269 let mut sessions = Sessions::default();
270 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
271 assert_eq!(
272 sessions.window_title(1).unwrap(),
273 "demo :4242 [disconnected]"
274 );
275 sessions.mark_reconnected(1);
276 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
277 sessions.mark_disconnected(1);
278 assert_eq!(
279 sessions.window_title(1).unwrap(),
280 "demo :4242 [disconnected]"
281 );
282 }
283
284 #[test]
285 fn window_title_omits_a_zero_port() {
286 let mut sessions = Sessions::default();
287 sessions.insert(1, "anon-1".into(), 0, StreamKind::Tokens, view(), opts());
288 assert_eq!(sessions.window_title(1).unwrap(), "anon-1 [disconnected]");
289 sessions.mark_reconnected(1);
290 assert_eq!(sessions.window_title(1).unwrap(), "anon-1");
291 }
292
293 #[test]
299 fn mark_attached_first_attach_connects_without_a_rule() {
300 let mut sessions = Sessions::default();
301 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
302 sessions.mark_attached(1, false);
303 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
304 assert!(!sessions.plain_text(1).unwrap().contains("reconnected"));
305 }
306
307 #[test]
310 fn mark_attached_reattach_connects_and_draws_a_rule() {
311 let mut sessions = Sessions::default();
312 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
313 sessions.mark_attached(1, true);
314 assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
315 assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
316 }
317
318 #[test]
319 fn mark_reconnected_draws_a_horizontal_rule() {
320 let mut sessions = Sessions::default();
321 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
322 sessions.mark_reconnected(1);
323 assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
324 }
325
326 #[test]
327 fn clear_empties_the_scrollback() {
328 let mut sessions = Sessions::default();
329 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
330 sessions.feed(1, b"hello\n");
331 sessions.clear(1);
332 assert_eq!(sessions.plain_text(1).unwrap(), "");
333 }
334
335 #[test]
336 fn remove_drops_the_session() {
337 let mut sessions = Sessions::default();
338 sessions.insert(1, "demo".into(), 4242, StreamKind::Tokens, view(), opts());
339 assert!(sessions.remove(1).is_some());
340 assert!(sessions.plain_text(1).is_none());
341 }
342
343 #[test]
344 fn unknown_id_returns_none_everywhere() {
345 let sessions = Sessions::default();
346 assert!(sessions.plain_text(99).is_none());
347 assert!(sessions.window_title(99).is_none());
348 }
349
350 #[test]
351 fn a_trace_session_renders_through_tracefmt_not_the_pipeline() {
352 let mut sessions = Sessions::default();
353 sessions.insert(1, "myapp".into(), 4242, StreamKind::Trace, view(), opts());
354 sessions.feed(1, b"{\"level\":\"INFO\",\"fields\":{\"message\":\"hi\"}}\n");
355 assert_eq!(sessions.plain_text(1).unwrap(), "INFO hi");
356 }
357
358 #[test]
359 fn a_trace_session_window_title_is_tagged() {
360 let mut sessions = Sessions::default();
361 sessions.insert(1, "myapp".into(), 4242, StreamKind::Trace, view(), opts());
362 assert_eq!(
363 sessions.window_title(1).unwrap(),
364 "[trace] myapp :4242 [disconnected]"
365 );
366 sessions.mark_reconnected(1);
367 assert_eq!(sessions.window_title(1).unwrap(), "[trace] myapp :4242");
368 }
369}