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::registry::SessionId;
18use crate::streamview::StreamView;
19
20/// A `StreamView` addressable from both the desktop and the event pump.
21pub type SharedView = Rc<RefCell<StreamView>>;
22
23/// Forwards `View` calls into a shared `StreamView`.
24#[derive(Debug)]
25pub struct SharedStreamView(pub SharedView);
26
27impl View for SharedStreamView {
28    fn bounds(&self) -> Rect {
29        self.0.borrow().bounds()
30    }
31    fn set_bounds(&mut self, bounds: Rect) {
32        self.0.borrow_mut().set_bounds(bounds);
33    }
34    fn draw(&mut self, terminal: &mut Terminal) {
35        self.0.borrow_mut().draw(terminal);
36    }
37    fn handle_event(&mut self, event: &mut Event) {
38        self.0.borrow_mut().handle_event(event);
39    }
40    fn can_focus(&self) -> bool {
41        true
42    }
43    fn get_palette(&self) -> Option<turbo_vision::core::palette::Palette> {
44        None
45    }
46}
47
48/// Per-session state owned by the main loop.
49#[derive(Debug)]
50pub struct SessionState {
51    pub name: String,
52    pub port: u16,
53    pub view: SharedView,
54    pub pipeline: Pipeline,
55    pub connected: bool,
56}
57
58impl SessionState {
59    /// Title text for this session's window.
60    #[must_use]
61    pub fn window_title(&self) -> String {
62        let base = format_title(&self.name, self.port);
63        if self.connected {
64            base
65        } else {
66            format!("{base} [disconnected]")
67        }
68    }
69}
70
71/// The `name :port` half of a window title, shared between the initial
72/// title set when a window is created and `SessionState::window_title`'s
73/// later connect/disconnect updates. Port 0 is not a real port — anonymous
74/// sessions and opened captures use it as a sentinel — so it is omitted
75/// rather than displayed as `name :0`.
76#[must_use]
77pub fn format_title(name: &str, port: u16) -> String {
78    if port == 0 {
79        name.to_string()
80    } else {
81        format!("{name} :{port}")
82    }
83}
84
85/// All live sessions, keyed by id.
86#[derive(Debug, Default)]
87pub struct Sessions {
88    inner: HashMap<SessionId, SessionState>,
89}
90
91impl Sessions {
92    pub fn insert(
93        &mut self,
94        id: SessionId,
95        name: String,
96        port: u16,
97        view: SharedView,
98        opts: RenderOptions,
99    ) {
100        self.inner.insert(
101            id,
102            SessionState {
103                name,
104                port,
105                view,
106                pipeline: Pipeline::new(opts),
107                connected: false,
108            },
109        );
110    }
111
112    pub fn get_mut(&mut self, id: SessionId) -> Option<&mut SessionState> {
113        self.inner.get_mut(&id)
114    }
115
116    pub fn remove(&mut self, id: SessionId) -> Option<SessionState> {
117        self.inner.remove(&id)
118    }
119
120    /// Feeds bytes into a session's pipeline and view.
121    pub fn feed(&mut self, id: SessionId, data: &[u8]) {
122        if let Some(s) = self.inner.get_mut(&id) {
123            let mut view = s.view.borrow_mut();
124            s.pipeline.feed(data, &mut view);
125        }
126    }
127
128    /// Draws a horizontal rule announcing a reattached client.
129    pub fn mark_reconnected(&mut self, id: SessionId) {
130        if let Some(s) = self.inner.get_mut(&id) {
131            s.connected = true;
132            let mut view = s.view.borrow_mut();
133            s.pipeline.feed(b"\n-- reconnected --\n", &mut view);
134        }
135    }
136
137    /// Reflects a `ServerEvent::Attached`: always marks the session
138    /// connected, but draws the "-- reconnected --" rule only for a
139    /// genuine reattach (`reattached`), never for a brand-new session's
140    /// first-ever attach — see defect 1 in
141    /// `.superpowers/sdd/lifecycle-fixes-report.md`.
142    pub fn mark_attached(&mut self, id: SessionId, reattached: bool) {
143        if reattached {
144            self.mark_reconnected(id);
145        } else if let Some(s) = self.inner.get_mut(&id) {
146            s.connected = true;
147        }
148    }
149
150    pub fn mark_disconnected(&mut self, id: SessionId) {
151        if let Some(s) = self.inner.get_mut(&id) {
152            s.connected = false;
153            let mut view = s.view.borrow_mut();
154            s.pipeline.finish(&mut view);
155        }
156    }
157
158    /// Applies new render options to every session. Text already on screen
159    /// keeps the styling it was rendered with; only new bytes change.
160    pub fn set_options(&mut self, opts: RenderOptions) {
161        for s in self.inner.values_mut() {
162            let mut view = s.view.borrow_mut();
163            s.pipeline.set_options(opts, &mut view);
164        }
165    }
166
167    /// Empties one session's scrollback.
168    pub fn clear(&mut self, id: SessionId) {
169        if let Some(s) = self.inner.get_mut(&id) {
170            s.view.borrow_mut().clear();
171        }
172    }
173
174    /// One session's scrollback as plain text, for File > Save As.
175    #[must_use]
176    pub fn plain_text(&self, id: SessionId) -> Option<String> {
177        self.inner.get(&id).map(|s| s.view.borrow().plain_text())
178    }
179
180    /// The title a session's window should currently show, for reflecting
181    /// connect/disconnect state after the fact (the window itself is not
182    /// reachable from here — the caller owns the desktop).
183    #[must_use]
184    pub fn window_title(&self, id: SessionId) -> Option<String> {
185        self.inner.get(&id).map(SessionState::window_title)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use trace_stream::render::RenderOptions;
193
194    fn opts() -> RenderOptions {
195        RenderOptions {
196            use_color: true,
197            format_thinking: true,
198            format_markdown: true,
199        }
200    }
201
202    fn view() -> SharedView {
203        Rc::new(RefCell::new(StreamView::new(Rect::new(0, 0, 80, 24))))
204    }
205
206    #[test]
207    fn feed_reaches_the_session_view() {
208        let mut sessions = Sessions::default();
209        sessions.insert(1, "demo".into(), 4242, view(), opts());
210        sessions.feed(1, b"hello\n");
211        assert!(sessions.plain_text(1).unwrap().contains("hello"));
212    }
213
214    #[test]
215    fn window_title_reflects_connection_state() {
216        let mut sessions = Sessions::default();
217        sessions.insert(1, "demo".into(), 4242, view(), opts());
218        assert_eq!(
219            sessions.window_title(1).unwrap(),
220            "demo :4242 [disconnected]"
221        );
222        sessions.mark_reconnected(1);
223        assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
224        sessions.mark_disconnected(1);
225        assert_eq!(
226            sessions.window_title(1).unwrap(),
227            "demo :4242 [disconnected]"
228        );
229    }
230
231    #[test]
232    fn window_title_omits_a_zero_port() {
233        let mut sessions = Sessions::default();
234        sessions.insert(1, "anon-1".into(), 0, view(), opts());
235        assert_eq!(sessions.window_title(1).unwrap(), "anon-1 [disconnected]");
236        sessions.mark_reconnected(1);
237        assert_eq!(sessions.window_title(1).unwrap(), "anon-1");
238    }
239
240    /// Regression test for defect 1: a session's first-ever attach must
241    /// mark it connected (title stops reading `[disconnected]`) without
242    /// drawing the "-- reconnected --" rule — that rule announces a
243    /// genuine rejoin, and would be wrong above the very first line of a
244    /// brand-new session.
245    #[test]
246    fn mark_attached_first_attach_connects_without_a_rule() {
247        let mut sessions = Sessions::default();
248        sessions.insert(1, "demo".into(), 4242, view(), opts());
249        sessions.mark_attached(1, false);
250        assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
251        assert!(!sessions.plain_text(1).unwrap().contains("reconnected"));
252    }
253
254    /// A genuine reattach (`reattached: true`) both connects and draws the
255    /// rule, same as `mark_reconnected`.
256    #[test]
257    fn mark_attached_reattach_connects_and_draws_a_rule() {
258        let mut sessions = Sessions::default();
259        sessions.insert(1, "demo".into(), 4242, view(), opts());
260        sessions.mark_attached(1, true);
261        assert_eq!(sessions.window_title(1).unwrap(), "demo :4242");
262        assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
263    }
264
265    #[test]
266    fn mark_reconnected_draws_a_horizontal_rule() {
267        let mut sessions = Sessions::default();
268        sessions.insert(1, "demo".into(), 4242, view(), opts());
269        sessions.mark_reconnected(1);
270        assert!(sessions.plain_text(1).unwrap().contains("reconnected"));
271    }
272
273    #[test]
274    fn clear_empties_the_scrollback() {
275        let mut sessions = Sessions::default();
276        sessions.insert(1, "demo".into(), 4242, view(), opts());
277        sessions.feed(1, b"hello\n");
278        sessions.clear(1);
279        assert_eq!(sessions.plain_text(1).unwrap(), "");
280    }
281
282    #[test]
283    fn set_options_applies_to_every_session() {
284        let mut sessions = Sessions::default();
285        sessions.insert(1, "demo".into(), 4242, view(), opts());
286        let mut new_opts = opts();
287        new_opts.format_markdown = false;
288        sessions.set_options(new_opts);
289        assert!(
290            !sessions
291                .get_mut(1)
292                .unwrap()
293                .pipeline
294                .options()
295                .format_markdown
296        );
297    }
298
299    #[test]
300    fn remove_drops_the_session() {
301        let mut sessions = Sessions::default();
302        sessions.insert(1, "demo".into(), 4242, view(), opts());
303        assert!(sessions.remove(1).is_some());
304        assert!(sessions.plain_text(1).is_none());
305    }
306
307    #[test]
308    fn unknown_id_returns_none_everywhere() {
309        let sessions = Sessions::default();
310        assert!(sessions.plain_text(99).is_none());
311        assert!(sessions.window_title(99).is_none());
312    }
313}