1use 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 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}