use std::io;
use std::time::Duration;
use anyhow::Result;
use deck_orchestrator::{Command as OrchCommand, Event as OrchEvent};
use ratatui::backend::Backend;
use ratatui::Terminal;
use crate::event::{Event, EventStream};
use crate::ui;
use crate::AppHandle;
#[derive(Debug)]
pub struct App {
pub input: String,
pub log: Vec<String>,
pub pending_assistant: String,
pub should_quit: bool,
pub status: String,
pub handle: Option<AppHandle>,
}
impl App {
#[must_use]
pub fn new(handle: Option<AppHandle>) -> Self {
let banner = if handle.is_some() {
"// jacked in. LLM connected. cyberspace is a consensual hallucination.".to_owned()
} else {
"// flatlined — offline. no LLM. type `:q` to log out.".to_owned()
};
Self {
input: String::new(),
log: vec![banner],
pending_assistant: String::new(),
should_quit: false,
status: format!("v{}", env!("CARGO_PKG_VERSION")),
handle,
}
}
pub fn handle_input_key(&mut self, c: char) {
self.input.push(c);
}
pub fn handle_backspace(&mut self) {
self.input.pop();
}
pub fn handle_enter(&mut self) -> Option<String> {
if self.input == ":q" {
self.should_quit = true;
return None;
}
if self.input.is_empty() {
return None;
}
let line = std::mem::take(&mut self.input);
self.log.push(format!("> {line}"));
Some(line)
}
fn ingest_event(&mut self, ev: OrchEvent) {
match ev {
OrchEvent::AssistantDelta { text, .. } => {
self.pending_assistant.push_str(&text);
}
OrchEvent::AssistantTurn { message, .. } => {
if self.pending_assistant.is_empty() {
self.log.push(format!("< {}", message.content));
} else {
self.log.push(format!("< {}", self.pending_assistant));
self.pending_assistant.clear();
}
}
OrchEvent::ToolCallProposed { call } => {
self.log
.push(format!("[tool proposal] {}::{}", call.server, call.tool));
}
OrchEvent::ToolCallResult { result } => {
self.log.push(format!("[tool result] {}", result.call_id));
}
OrchEvent::Error { message } => {
self.log.push(format!("[error] {message}"));
}
}
}
pub async fn run<B: Backend + io::Write>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
let mut events = EventStream::new(Duration::from_millis(16));
let mut orch_rx = self.handle.as_ref().map(|h| h.handle.subscribe());
while !self.should_quit {
terminal.draw(|f| ui::draw(f, self))?;
tokio::select! {
ev = events.next() => {
match ev {
Some(Event::Key(c)) => self.handle_input_key(c),
Some(Event::Enter) => {
if let Some(line) = self.handle_enter() {
if let Some(h) = &self.handle {
let _ = h.handle.submit(OrchCommand::UserMessage {
session: h.session,
content: line,
}).await;
} else {
self.log.push(" (offline mode: not forwarded)".into());
}
}
}
Some(Event::Backspace) => self.handle_backspace(),
Some(Event::Quit) => self.should_quit = true,
Some(Event::Tick) | None => {}
}
}
Some(Ok(ev)) = async {
match orch_rx.as_mut() {
Some(r) => Some(r.recv().await),
None => std::future::pending::<Option<_>>().await,
}
} => {
self.ingest_event(ev);
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enter_emits_line_when_buffer_non_empty() {
let mut app = App::new(None);
app.input = "hello".into();
let out = app.handle_enter();
assert_eq!(out.as_deref(), Some("hello"));
assert!(app.input.is_empty());
assert!(app.log.iter().any(|l| l.contains("hello")));
}
#[test]
fn colon_q_quits_and_emits_nothing() {
let mut app = App::new(None);
app.input = ":q".into();
let out = app.handle_enter();
assert!(out.is_none());
assert!(app.should_quit);
}
#[test]
fn assistant_delta_accumulates_then_logs_on_turn() {
let mut app = App::new(None);
app.ingest_event(OrchEvent::AssistantDelta {
session: deck_core::SessionId::new(),
text: "hel".into(),
});
app.ingest_event(OrchEvent::AssistantDelta {
session: deck_core::SessionId::new(),
text: "lo".into(),
});
app.ingest_event(OrchEvent::AssistantTurn {
session: deck_core::SessionId::new(),
message: deck_core::Message {
role: deck_core::Role::Assistant,
content: "hello".into(),
tool_calls: vec![],
},
});
assert!(app.log.iter().any(|l| l.contains("hello")));
}
}