Skip to main content

turbo_debug_console/
session.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! One window per stream session.
5
6use 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
22/// A `StreamView` addressable from both the desktop and the event pump.
23pub type SharedView = Rc<RefCell<StreamView>>;
24
25/// Forwards `View` calls into a shared `StreamView`.
26#[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    // A view that does not delegate these inherits `View`'s fixed default, so
46    // the desktop's resize cascade reaches the window, asks this child whether
47    // it grows, is told no, and leaves it at the old width. The window frame
48    // then resizes around a view still wrapped for the old geometry.
49    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/// A session's renderer: which one it holds depends on its [`StreamKind`].
61/// A trace session has no [`Pipeline`] -- that pipeline is the markdown/DSML
62/// renderer for model token streams, the wrong tool for a structured log
63/// line, so none is ever constructed for one.
64#[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/// Per-session state owned by the main loop.
87#[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    /// Title text for this session's window. A trace session's kind is
99    /// called out with a leading `[trace]` tag -- the `name :port` shape
100    /// alone gives no hint that a window is rendering structured log
101    /// records rather than a token stream, and that distinction matters
102    /// enough at a glance to be worth the few extra characters.
103    #[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    /// Pushes stream bytes through this session's renderer and into its view.
118    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    /// Ends the stream: flushes the renderer and any trailing partial line.
124    pub fn finish(&mut self) {
125        let mut view = self.view.borrow_mut();
126        self.renderer.finish(&mut view);
127    }
128}
129
130/// The `name :port` half of a window title, shared between the initial
131/// title set when a window is created and `SessionState::window_title`'s
132/// later connect/disconnect updates. Port 0 is not a real port — anonymous
133/// sessions and opened captures use it as a sentinel — so it is omitted
134/// rather than displayed as `name :0`.
135#[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/// All live sessions, keyed by id.
145#[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    /// Feeds bytes into a session's renderer and view.
186    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    /// Draws a horizontal rule announcing a reattached client.
193    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    /// Reflects a `ServerEvent::Attached`: always marks the session
201    /// connected, but draws the "-- reconnected --" rule only for a
202    /// genuine reattach (`reattached`), never for a brand-new session's
203    /// first-ever attach — see defect 1 in
204    /// `.superpowers/sdd/lifecycle-fixes-report.md`.
205    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    /// Empties one session's scrollback.
221    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    /// One session's scrollback as plain text, for File > Save As.
228    #[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    /// The title a session's window should currently show, for reflecting
234    /// connect/disconnect state after the fact (the window itself is not
235    /// reachable from here — the caller owns the desktop).
236    #[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    /// Regression test for defect 1: a session's first-ever attach must
294    /// mark it connected (title stops reading `[disconnected]`) without
295    /// drawing the "-- reconnected --" rule — that rule announces a
296    /// genuine rejoin, and would be wrong above the very first line of a
297    /// brand-new session.
298    #[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    /// A genuine reattach (`reattached: true`) both connects and draws the
308    /// rule, same as `mark_reconnected`.
309    #[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}