Skip to main content

deck_tui/
app.rs

1//! Application state + event loop.
2
3use std::io;
4use std::time::Duration;
5
6use anyhow::Result;
7use deck_orchestrator::{Command as OrchCommand, Event as OrchEvent};
8use ratatui::backend::Backend;
9use ratatui::Terminal;
10
11use crate::event::{Event, EventStream};
12use crate::ui;
13use crate::AppHandle;
14
15#[derive(Debug)]
16pub struct App {
17    pub input: String,
18    pub log: Vec<String>,
19    pub pending_assistant: String,
20    pub should_quit: bool,
21    pub status: String,
22    pub handle: Option<AppHandle>,
23}
24
25impl App {
26    #[must_use]
27    pub fn new(handle: Option<AppHandle>) -> Self {
28        let banner = if handle.is_some() {
29            "// jacked in. LLM connected. cyberspace is a consensual hallucination.".to_owned()
30        } else {
31            "// flatlined — offline. no LLM. type `:q` to log out.".to_owned()
32        };
33        Self {
34            input: String::new(),
35            log: vec![banner],
36            pending_assistant: String::new(),
37            should_quit: false,
38            status: format!("v{}", env!("CARGO_PKG_VERSION")),
39            handle,
40        }
41    }
42
43    pub fn handle_input_key(&mut self, c: char) {
44        self.input.push(c);
45    }
46
47    pub fn handle_backspace(&mut self) {
48        self.input.pop();
49    }
50
51    /// Process the current input buffer. Returns the user line that should
52    /// be forwarded to the orchestrator (if any).
53    pub fn handle_enter(&mut self) -> Option<String> {
54        if self.input == ":q" {
55            self.should_quit = true;
56            return None;
57        }
58        if self.input.is_empty() {
59            return None;
60        }
61        let line = std::mem::take(&mut self.input);
62        self.log.push(format!("> {line}"));
63        Some(line)
64    }
65
66    fn ingest_event(&mut self, ev: OrchEvent) {
67        match ev {
68            OrchEvent::AssistantDelta { text, .. } => {
69                self.pending_assistant.push_str(&text);
70            }
71            OrchEvent::AssistantTurn { message, .. } => {
72                if self.pending_assistant.is_empty() {
73                    self.log.push(format!("< {}", message.content));
74                } else {
75                    self.log.push(format!("< {}", self.pending_assistant));
76                    self.pending_assistant.clear();
77                }
78            }
79            OrchEvent::ToolCallProposed { call } => {
80                self.log
81                    .push(format!("[tool proposal] {}::{}", call.server, call.tool));
82            }
83            OrchEvent::ToolCallResult { result } => {
84                self.log.push(format!("[tool result] {}", result.call_id));
85            }
86            OrchEvent::Error { message } => {
87                self.log.push(format!("[error] {message}"));
88            }
89        }
90    }
91
92    pub async fn run<B: Backend + io::Write>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
93        let mut events = EventStream::new(Duration::from_millis(16));
94        let mut orch_rx = self.handle.as_ref().map(|h| h.handle.subscribe());
95        while !self.should_quit {
96            terminal.draw(|f| ui::draw(f, self))?;
97            tokio::select! {
98                ev = events.next() => {
99                    match ev {
100                        Some(Event::Key(c)) => self.handle_input_key(c),
101                        Some(Event::Enter) => {
102                            if let Some(line) = self.handle_enter() {
103                                if let Some(h) = &self.handle {
104                                    let _ = h.handle.submit(OrchCommand::UserMessage {
105                                        session: h.session,
106                                        content: line,
107                                    }).await;
108                                } else {
109                                    self.log.push("  (offline mode: not forwarded)".into());
110                                }
111                            }
112                        }
113                        Some(Event::Backspace) => self.handle_backspace(),
114                        Some(Event::Quit) => self.should_quit = true,
115                        Some(Event::Tick) | None => {}
116                    }
117                }
118                Some(Ok(ev)) = async {
119                    match orch_rx.as_mut() {
120                        Some(r) => Some(r.recv().await),
121                        None => std::future::pending::<Option<_>>().await,
122                    }
123                } => {
124                    self.ingest_event(ev);
125                }
126            }
127        }
128        Ok(())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn enter_emits_line_when_buffer_non_empty() {
138        let mut app = App::new(None);
139        app.input = "hello".into();
140        let out = app.handle_enter();
141        assert_eq!(out.as_deref(), Some("hello"));
142        assert!(app.input.is_empty());
143        assert!(app.log.iter().any(|l| l.contains("hello")));
144    }
145
146    #[test]
147    fn colon_q_quits_and_emits_nothing() {
148        let mut app = App::new(None);
149        app.input = ":q".into();
150        let out = app.handle_enter();
151        assert!(out.is_none());
152        assert!(app.should_quit);
153    }
154
155    #[test]
156    fn assistant_delta_accumulates_then_logs_on_turn() {
157        let mut app = App::new(None);
158        app.ingest_event(OrchEvent::AssistantDelta {
159            session: deck_core::SessionId::new(),
160            text: "hel".into(),
161        });
162        app.ingest_event(OrchEvent::AssistantDelta {
163            session: deck_core::SessionId::new(),
164            text: "lo".into(),
165        });
166        app.ingest_event(OrchEvent::AssistantTurn {
167            session: deck_core::SessionId::new(),
168            message: deck_core::Message {
169                role: deck_core::Role::Assistant,
170                content: "hello".into(),
171                tool_calls: vec![],
172            },
173        });
174        assert!(app.log.iter().any(|l| l.contains("hello")));
175    }
176}