Private
Public Access
1
0

kptui: initial commit

This commit is contained in:
2025-12-14 17:50:37 -08:00
parent f4402292a1
commit 68bb94aa0b
7 changed files with 1569 additions and 5 deletions

26
core/kptui/Cargo.toml Normal file
View File

@@ -0,0 +1,26 @@
[package]
name = "kptui"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
crossterm = "0.28.1"
log = "0.4.22"
ratatui = "0.29.0"
time = { version = "0.3.37", features = ["formatting"] }
unicode-width = "0.2.0"
# D-Bus dependencies only on Linux
[target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9.7"
# D-Bus codegen only on Linux
[target.'cfg(target_os = "linux")'.build-dependencies]
dbus-codegen = "0.10.0"
# XPC (libxpc) interface only on macOS
[target.'cfg(target_os = "macos")'.dependencies]
block = "0.1.6"
xpc-connection = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection" }
xpc-connection-sys = { git = "https://github.com/dfrankland/xpc-connection-rs.git", rev = "cd4fb3d", package = "xpc-connection-sys" }

26
core/kptui/build.rs Normal file
View File

@@ -0,0 +1,26 @@
const KORDOPHONE_XML: &str = "../kordophoned/include/net.buzzert.kordophonecd.Server.xml";
#[cfg(not(target_os = "linux"))]
fn main() {
// No D-Bus codegen on non-Linux platforms.
}
#[cfg(target_os = "linux")]
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let out_path = std::path::Path::new(&out_dir).join("kordophone-client.rs");
let opts = dbus_codegen::GenOpts {
connectiontype: dbus_codegen::ConnectionType::Blocking,
methodtype: None,
..Default::default()
};
let xml = std::fs::read_to_string(KORDOPHONE_XML).expect("Error reading server dbus interface");
let output =
dbus_codegen::generate(&xml, &opts).expect("Error generating client dbus interface");
std::fs::write(out_path, output).expect("Error writing client dbus code");
println!("cargo:rerun-if-changed={}", KORDOPHONE_XML);
}

View File

@@ -0,0 +1,581 @@
use anyhow::Result;
use std::time::Duration;
#[derive(Clone, Debug)]
pub struct ConversationSummary {
pub id: String,
pub title: String,
pub preview: String,
pub unread_count: u32,
pub date_unix: i64,
}
#[derive(Clone, Debug)]
pub struct ChatMessage {
pub sender: String,
pub text: String,
pub date_unix: i64,
}
pub enum Request {
RefreshConversations,
RefreshMessages { conversation_id: String },
SendMessage { conversation_id: String, text: String },
MarkRead { conversation_id: String },
SyncConversation { conversation_id: String },
}
pub enum Event {
Conversations(Vec<ConversationSummary>),
Messages {
conversation_id: String,
messages: Vec<ChatMessage>,
},
MessageSent {
conversation_id: String,
outgoing_id: Option<String>,
},
MarkedRead,
ConversationSyncTriggered { conversation_id: String },
ConversationsUpdated,
MessagesUpdated { conversation_id: String },
UpdateStreamReconnected,
Error(String),
}
pub fn spawn_worker(
request_rx: std::sync::mpsc::Receiver<Request>,
event_tx: std::sync::mpsc::Sender<Event>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut client = match new_daemon_client() {
Ok(c) => c,
Err(e) => {
let _ = event_tx.send(Event::Error(format!("Failed to connect to daemon: {e}")));
return;
}
};
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
}
loop {
match request_rx.recv_timeout(Duration::from_millis(100)) {
Ok(req) => {
let res = match req {
Request::RefreshConversations => client
.get_conversations(200, 0)
.map(Event::Conversations),
Request::RefreshMessages { conversation_id } => client
.get_messages(conversation_id.clone(), None)
.map(|messages| Event::Messages {
conversation_id,
messages,
}),
Request::SendMessage {
conversation_id,
text,
} => client
.send_message(conversation_id.clone(), text)
.map(|outgoing_id| Event::MessageSent {
conversation_id,
outgoing_id,
}),
Request::MarkRead { conversation_id } => client
.mark_conversation_as_read(conversation_id.clone())
.map(|_| Event::MarkedRead),
Request::SyncConversation { conversation_id } => client
.sync_conversation(conversation_id.clone())
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
};
match res {
Ok(evt) => {
let _ = event_tx.send(evt);
}
Err(e) => {
let _ = event_tx.send(Event::Error(format!("{e}")));
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
if let Err(e) = client.poll(Duration::from_millis(0)) {
let _ = event_tx.send(Event::Error(format!("Daemon polling error: {e}")));
}
}
})
}
trait DaemonClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>>;
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>>;
fn send_message(
&mut self,
conversation_id: String,
text: String,
) -> Result<Option<String>>;
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
fn install_signal_handlers(&mut self, _event_tx: std::sync::mpsc::Sender<Event>) -> Result<()> {
Ok(())
}
fn poll(&mut self, timeout: Duration) -> Result<()> {
std::thread::sleep(timeout);
Ok(())
}
}
fn new_daemon_client() -> Result<Box<dyn DaemonClient>> {
#[cfg(target_os = "linux")]
{
Ok(Box::new(linux::DBusClient::new()?))
}
#[cfg(target_os = "macos")]
{
Ok(Box::new(macos::XpcClient::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
anyhow::bail!("Unsupported platform")
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::{ChatMessage, ConversationSummary, DaemonClient, Event};
use anyhow::Result;
use dbus::arg::{PropMap, RefArg};
use dbus::blocking::{Connection, Proxy};
use dbus::channel::Token;
use std::sync::mpsc::Sender;
use std::time::Duration;
const DBUS_NAME: &str = "net.buzzert.kordophonecd";
const DBUS_PATH: &str = "/net/buzzert/kordophonecd/daemon";
#[allow(unused)]
mod dbus_interface {
#![allow(unused)]
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
}
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
pub struct DBusClient {
conn: Connection,
signal_tokens: Vec<Token>,
}
impl DBusClient {
pub fn new() -> Result<Self> {
Ok(Self {
conn: Connection::new_session()?,
signal_tokens: Vec::new(),
})
}
fn proxy(&self) -> Proxy<&Connection> {
self.conn
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
}
}
fn get_string(map: &PropMap, key: &str) -> String {
map.get(key)
.and_then(|v| v.0.as_str())
.unwrap_or_default()
.to_string()
}
fn get_i64(map: &PropMap, key: &str) -> i64 {
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0)
}
fn get_u32(map: &PropMap, key: &str) -> u32 {
get_i64(map, key).try_into().unwrap_or(0)
}
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
map.get(key)
.and_then(|v| v.0.as_iter())
.map(|iter| {
iter.filter_map(|item| item.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
impl DaemonClient for DBusClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
let mut conversations = items
.drain(..)
.map(|conv| {
let id = get_string(&conv, "guid");
let display_name = get_string(&conv, "display_name");
let participants = get_vec_string(&conv, "participants");
let title = if !display_name.trim().is_empty() {
display_name
} else if participants.is_empty() {
"<unknown>".to_string()
} else {
participants.join(", ")
};
ConversationSummary {
id,
title,
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
unread_count: get_u32(&conv, "unread_count"),
date_unix: get_i64(&conv, "date"),
}
})
.collect::<Vec<_>>();
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
Ok(conversations)
}
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>> {
let messages = KordophoneRepository::get_messages(
&self.proxy(),
&conversation_id,
&last_message_id.unwrap_or_default(),
)?;
Ok(messages
.into_iter()
.map(|msg| ChatMessage {
sender: get_string(&msg, "sender"),
text: get_string(&msg, "text"),
date_unix: get_i64(&msg, "date"),
})
.collect())
}
fn send_message(
&mut self,
conversation_id: String,
text: String,
) -> Result<Option<String>> {
let attachment_guids: Vec<&str> = vec![];
let outgoing_id = KordophoneRepository::send_message(
&self.proxy(),
&conversation_id,
&text,
attachment_guids,
)?;
Ok(Some(outgoing_id))
}
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
KordophoneRepository::mark_conversation_as_read(&self.proxy(), &conversation_id)
.map_err(|e| anyhow::anyhow!("Failed to mark conversation as read: {e}"))
}
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
KordophoneRepository::sync_conversation(&self.proxy(), &conversation_id)
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
}
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
let conversations_tx = event_tx.clone();
let t1 = self
.proxy()
.match_signal(
move |_: dbus_interface::NetBuzzertKordophoneRepositoryConversationsUpdated,
_: &Connection,
_: &dbus::message::Message| {
let _ = conversations_tx.send(Event::ConversationsUpdated);
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match ConversationsUpdated: {e}"))?;
let messages_tx = event_tx.clone();
let t2 = self
.proxy()
.match_signal(
move |s: dbus_interface::NetBuzzertKordophoneRepositoryMessagesUpdated,
_: &Connection,
_: &dbus::message::Message| {
let _ = messages_tx.send(Event::MessagesUpdated {
conversation_id: s.conversation_id,
});
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
let reconnected_tx = event_tx;
let t3 = self
.proxy()
.match_signal(
move |_: dbus_interface::NetBuzzertKordophoneRepositoryUpdateStreamReconnected,
_: &Connection,
_: &dbus::message::Message| {
let _ = reconnected_tx.send(Event::UpdateStreamReconnected);
true
},
)
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
self.signal_tokens.extend([t1, t2, t3]);
Ok(())
}
fn poll(&mut self, timeout: Duration) -> Result<()> {
self.conn.process(timeout)?;
Ok(())
}
}
}
#[cfg(target_os = "macos")]
mod macos {
use super::{ChatMessage, ConversationSummary, DaemonClient};
use anyhow::Result;
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use xpc_connection::Message;
const SERVICE_NAME: &str = "net.buzzert.kordophonecd\0";
struct XpcTransport {
connection: xpc_connection_sys::xpc_connection_t,
}
impl XpcTransport {
fn connect(name: impl AsRef<CStr>) -> Self {
use xpc_connection_sys::xpc_connection_create_mach_service;
use xpc_connection_sys::xpc_connection_resume;
let name = name.as_ref();
let connection =
unsafe { xpc_connection_create_mach_service(name.as_ptr(), std::ptr::null_mut(), 0) };
unsafe { xpc_connection_resume(connection) };
Self { connection }
}
fn send_with_reply(&self, message: Message) -> Message {
use xpc_connection::message_to_xpc_object;
use xpc_connection::xpc_object_to_message;
use xpc_connection_sys::{xpc_connection_send_message_with_reply_sync, xpc_release};
unsafe {
let xobj = message_to_xpc_object(message);
let reply = xpc_connection_send_message_with_reply_sync(self.connection, xobj);
xpc_release(xobj);
let msg = xpc_object_to_message(reply);
if !reply.is_null() {
xpc_release(reply);
}
msg
}
}
}
impl Drop for XpcTransport {
fn drop(&mut self) {
use xpc_connection_sys::xpc_object_t;
use xpc_connection_sys::xpc_release;
unsafe { xpc_release(self.connection as xpc_object_t) };
}
}
pub struct XpcClient {
transport: XpcTransport,
}
impl XpcClient {
pub fn new() -> Result<Self> {
let mach_port_name = CString::new(SERVICE_NAME).unwrap();
Ok(Self {
transport: XpcTransport::connect(&mach_port_name),
})
}
fn key(s: &str) -> CString {
CString::new(s).unwrap()
}
fn request(name: &str, arguments: Option<HashMap<CString, Message>>) -> Message {
let mut root: HashMap<CString, Message> = HashMap::new();
root.insert(Self::key("name"), Message::String(Self::key(name)));
if let Some(args) = arguments {
root.insert(Self::key("arguments"), Message::Dictionary(args));
}
Message::Dictionary(root)
}
fn get_string(map: &HashMap<CString, Message>, key: &str) -> Option<String> {
match map.get(&Self::key(key)) {
Some(Message::String(s)) => Some(s.to_string_lossy().into_owned()),
_ => None,
}
}
fn get_i64_from_str(map: &HashMap<CString, Message>, key: &str) -> i64 {
Self::get_string(map, key)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
}
}
impl DaemonClient for XpcClient {
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
let mut args = HashMap::new();
args.insert(Self::key("limit"), Message::String(Self::key(&limit.to_string())));
args.insert(Self::key("offset"), Message::String(Self::key(&offset.to_string())));
let reply = self
.transport
.send_with_reply(Self::request("GetConversations", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected conversations response");
};
let Some(Message::Array(items)) = map.get(&Self::key("conversations")) else {
anyhow::bail!("Missing conversations in response");
};
let mut conversations = Vec::new();
for item in items {
let Message::Dictionary(conv) = item else { continue };
let id = Self::get_string(conv, "guid").unwrap_or_default();
let display_name = Self::get_string(conv, "display_name").unwrap_or_default();
let preview = Self::get_string(conv, "last_message_preview").unwrap_or_default();
let unread_count = Self::get_i64_from_str(conv, "unread_count") as u32;
let date_unix = Self::get_i64_from_str(conv, "date");
let participants = match conv.get(&Self::key("participants")) {
Some(Message::Array(arr)) => arr
.iter()
.filter_map(|m| match m {
Message::String(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
let title = if !display_name.trim().is_empty() {
display_name
} else if participants.is_empty() {
"<unknown>".to_string()
} else {
participants.join(", ")
};
conversations.push(ConversationSummary {
id,
title,
preview: preview.replace('\n', " "),
unread_count,
date_unix,
});
}
conversations.sort_by_key(|c| std::cmp::Reverse(c.date_unix));
Ok(conversations)
}
fn get_messages(
&mut self,
conversation_id: String,
last_message_id: Option<String>,
) -> Result<Vec<ChatMessage>> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
if let Some(last) = last_message_id {
args.insert(Self::key("last_message_id"), Message::String(Self::key(&last)));
}
let reply = self
.transport
.send_with_reply(Self::request("GetMessages", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected messages response");
};
let Some(Message::Array(items)) = map.get(&Self::key("messages")) else {
anyhow::bail!("Missing messages in response");
};
let mut messages = Vec::new();
for item in items {
let Message::Dictionary(msg) = item else { continue };
messages.push(ChatMessage {
sender: Self::get_string(msg, "sender").unwrap_or_default(),
text: Self::get_string(msg, "text").unwrap_or_default(),
date_unix: Self::get_i64_from_str(msg, "date"),
});
}
Ok(messages)
}
fn send_message(
&mut self,
conversation_id: String,
text: String,
) -> Result<Option<String>> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
args.insert(Self::key("text"), Message::String(Self::key(&text)));
let reply = self
.transport
.send_with_reply(Self::request("SendMessage", Some(args)));
let Message::Dictionary(map) = reply else {
anyhow::bail!("Unexpected send response");
};
Ok(Self::get_string(&map, "uuid"))
}
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
let _ = self
.transport
.send_with_reply(Self::request("MarkConversationAsRead", Some(args)));
Ok(())
}
fn sync_conversation(&mut self, conversation_id: String) -> Result<()> {
let mut args = HashMap::new();
args.insert(
Self::key("conversation_id"),
Message::String(Self::key(&conversation_id)),
);
let _ = self
.transport
.send_with_reply(Self::request("SyncConversation", Some(args)));
Ok(())
}
}
}

702
core/kptui/src/main.rs Normal file
View File

@@ -0,0 +1,702 @@
mod daemon;
use anyhow::Result;
use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::prelude::*;
use ratatui::widgets::*;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ViewMode {
List,
Chat,
Split,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Focus {
Navigation,
Input,
}
struct AppState {
conversations: Vec<daemon::ConversationSummary>,
selected_idx: usize,
messages: Vec<daemon::ChatMessage>,
active_conversation_id: Option<String>,
active_conversation_title: String,
status: String,
input: String,
focus: Focus,
transcript_scroll: u16,
pinned_to_bottom: bool,
refresh_conversations_in_flight: bool,
refresh_messages_in_flight: bool,
}
impl AppState {
fn new() -> Self {
Self {
conversations: Vec::new(),
selected_idx: 0,
messages: Vec::new(),
active_conversation_id: None,
active_conversation_title: String::new(),
status: String::new(),
input: String::new(),
focus: Focus::Navigation,
transcript_scroll: 0,
pinned_to_bottom: true,
refresh_conversations_in_flight: false,
refresh_messages_in_flight: false,
}
}
fn select_next(&mut self) {
if self.conversations.is_empty() {
self.selected_idx = 0;
return;
}
self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1);
}
fn select_prev(&mut self) {
if self.conversations.is_empty() {
self.selected_idx = 0;
return;
}
self.selected_idx = self.selected_idx.saturating_sub(1);
}
fn open_selected_conversation(&mut self) {
if let Some(conv) = self.conversations.get(self.selected_idx) {
self.active_conversation_id = Some(conv.id.clone());
self.active_conversation_title = conv.title.clone();
self.messages.clear();
self.transcript_scroll = 0;
self.pinned_to_bottom = true;
self.focus = Focus::Input;
self.status = "Loading…".to_string();
}
}
}
fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode {
let min_conversations = 24u16;
let min_chat = 44u16;
let min_total = min_conversations + 1 + min_chat;
if width >= min_total {
return ViewMode::Split;
}
if has_active_conversation {
requested
} else {
ViewMode::List
}
}
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
let area = frame.area();
let mode = view_mode(area.width, app.active_conversation_id.is_some(), requested_view);
let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
let chunks = if show_input {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3), Constraint::Length(1)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area)
};
let (main_area, input_area, status_area) = if show_input {
(chunks[0], Some(chunks[1]), chunks[2])
} else {
(chunks[0], None, chunks[1])
};
match mode {
ViewMode::Split => {
let left_width = (main_area.width / 3).clamp(24, 40);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(left_width), Constraint::Min(1)])
.split(main_area);
render_conversations(frame, app, cols[0], true);
render_transcript(frame, app, cols[1], true);
}
ViewMode::List => render_conversations(frame, app, main_area, false),
ViewMode::Chat => render_transcript(frame, app, main_area, false),
}
if let Some(input_area) = input_area {
render_input(frame, app, input_area);
if app.focus == Focus::Input {
let mut x = input_area
.x
.saturating_add(1)
.saturating_add(app.input.len() as u16);
let max_x = input_area
.x
.saturating_add(input_area.width.saturating_sub(2));
x = x.min(max_x);
let y = input_area.y + 1;
frame.set_cursor_position(Position { x, y });
}
}
render_status(frame, app, status_area, mode);
}
fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
let title = if in_split {
"Conversations (↑/↓, Enter)"
} else {
"Conversations (↑/↓, Enter to open)"
};
let items = app
.conversations
.iter()
.map(|c| {
let unread = if c.unread_count > 0 {
format!(" ({})", c.unread_count)
} else {
String::new()
};
let header = Line::from(vec![
Span::styled(c.title.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(unread),
]);
let preview = Line::from(Span::styled(
c.preview.clone(),
Style::default().fg(Color::DarkGray),
));
ListItem::new(vec![header, preview])
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(if app.conversations.is_empty() {
None
} else {
Some(app.selected_idx)
});
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
let title = if let Some(_) = app.active_conversation_id {
if in_split {
format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title)
} else {
format!("{} (Esc: back)", app.active_conversation_title)
}
} else {
"Chat".to_string()
};
let mut lines: Vec<Line> = Vec::new();
for message in &app.messages {
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
lines.push(Line::from(vec![
Span::styled(message.sender.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(ts, Style::default().fg(Color::DarkGray)),
]));
let mut rendered_any_text = false;
for text_line in message.text.lines() {
rendered_any_text = true;
lines.push(Line::from(Span::raw(text_line.to_string())));
}
if !rendered_any_text {
lines.push(Line::from(Span::styled(
"<non-text message>",
Style::default().fg(Color::DarkGray),
)));
}
lines.push(Line::from(Span::raw("")));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"No messages.",
Style::default().fg(Color::DarkGray),
)));
}
let paragraph = Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false })
.scroll((app.transcript_scroll, 0));
frame.render_widget(paragraph, area);
}
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
let title = if app.focus == Focus::Input {
"Reply (Enter to send)"
} else {
"Reply (press i to type)"
};
let input = Paragraph::new(app.input.as_str())
.block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(input, area);
}
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
let mut parts = vec![
format!(
"{} convs",
app.conversations.len()
),
match mode {
ViewMode::Split => "split".to_string(),
ViewMode::List => "list".to_string(),
ViewMode::Chat => "chat".to_string(),
},
];
if !app.status.trim().is_empty() {
parts.push(app.status.clone());
}
let line = parts.join(" | ");
frame.render_widget(Paragraph::new(line).block(Block::default().borders(Borders::TOP)), area);
}
fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = std::io::stdout();
crossterm::execute!(
stdout,
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
let backend = ratatui::backend::CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let res = run_app(&mut terminal);
disable_raw_mode()?;
crossterm::execute!(
terminal.backend_mut(),
crossterm::event::DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen
)?;
terminal.show_cursor()?;
res
}
fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>) -> Result<()> {
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
let _worker = daemon::spawn_worker(request_rx, event_tx);
let tick_rate = Duration::from_millis(150);
let refresh_rate = Duration::from_secs(2);
let mut last_tick = Instant::now();
let mut last_refresh = Instant::now() - refresh_rate;
let mut requested_view = ViewMode::List;
let mut app = AppState::new();
app.status = "Connecting…".to_string();
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
loop {
let size = terminal.size()?;
while let Ok(evt) = event_rx.try_recv() {
match evt {
daemon::Event::Conversations(convs) => {
app.refresh_conversations_in_flight = false;
app.status.clear();
app.conversations = convs;
if app.selected_idx >= app.conversations.len() {
app.selected_idx = app.conversations.len().saturating_sub(1);
}
if app.active_conversation_id.is_none() && !app.conversations.is_empty() {
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
}
}
daemon::Event::Messages {
conversation_id,
messages,
} => {
app.refresh_messages_in_flight = false;
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
let was_pinned = app.pinned_to_bottom;
app.messages = messages;
app.pinned_to_bottom = was_pinned;
}
}
daemon::Event::MessageSent {
conversation_id,
outgoing_id,
} => {
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
app.status = outgoing_id
.as_deref()
.map(|id| format!("Sent ({id})"))
.unwrap_or_else(|| "Sent".to_string());
app.refresh_messages_in_flight = false;
request_tx
.send(daemon::Request::RefreshMessages { conversation_id })
.ok();
app.refresh_messages_in_flight = true;
}
}
daemon::Event::MarkedRead => {}
daemon::Event::ConversationSyncTriggered { conversation_id } => {
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
app.status = "Syncing…".to_string();
}
}
daemon::Event::ConversationsUpdated => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::MessagesUpdated { conversation_id } => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::UpdateStreamReconnected => {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
}
daemon::Event::Error(e) => {
app.refresh_conversations_in_flight = false;
app.refresh_messages_in_flight = false;
app.status = e;
}
}
}
apply_transcript_scroll_policy(&mut app, size, requested_view);
terminal.draw(|f| ui(f, &app, requested_view))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if crossterm::event::poll(timeout)? {
if let CEvent::Key(key) = crossterm::event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match (key.code, ctrl) {
(KeyCode::Char('c'), true) => return Ok(()),
_ => {}
}
let screen_mode = view_mode(
size.width,
app.active_conversation_id.is_some(),
requested_view,
);
let max_scroll = max_transcript_scroll(&app, size, requested_view);
match screen_mode {
ViewMode::List => match key.code {
KeyCode::Up => app.select_prev(),
KeyCode::Down => app.select_next(),
KeyCode::Enter => {
app.open_selected_conversation();
if app.active_conversation_id.is_some() {
requested_view = ViewMode::Chat;
if let Some(cid) = app.active_conversation_id.clone() {
request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok();
request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok();
request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok();
app.refresh_messages_in_flight = true;
}
}
}
_ => {}
},
ViewMode::Chat => match key.code {
KeyCode::Esc => {
requested_view = ViewMode::List;
app.focus = Focus::Navigation;
}
KeyCode::Char('i') => app.focus = Focus::Input,
_ => {
handle_chat_keys(&mut app, &request_tx, key.code, max_scroll);
}
},
ViewMode::Split => match key.code {
KeyCode::Tab => {
app.focus = match app.focus {
Focus::Navigation => Focus::Input,
Focus::Input => Focus::Navigation,
}
}
KeyCode::Esc => app.focus = Focus::Navigation,
KeyCode::Char('i') => app.focus = Focus::Input,
KeyCode::Up => {
if app.focus == Focus::Navigation {
app.select_prev()
} else {
scroll_up(&mut app, 1);
}
}
KeyCode::Down => {
if app.focus == Focus::Navigation {
app.select_next()
} else {
scroll_down(&mut app, 1, max_scroll);
}
}
KeyCode::Enter => {
if app.focus == Focus::Navigation {
app.open_selected_conversation();
requested_view = ViewMode::Chat;
if let Some(cid) = app.active_conversation_id.clone() {
request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok();
request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok();
request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok();
app.refresh_messages_in_flight = true;
}
} else {
handle_chat_keys(&mut app, &request_tx, key.code, max_scroll);
}
}
_ => handle_chat_keys(&mut app, &request_tx, key.code, max_scroll),
},
}
}
}
if last_refresh.elapsed() >= refresh_rate {
if !app.refresh_conversations_in_flight {
request_tx.send(daemon::Request::RefreshConversations).ok();
app.refresh_conversations_in_flight = true;
}
if let Some(cid) = app.active_conversation_id.clone() {
if !app.refresh_messages_in_flight {
request_tx
.send(daemon::Request::RefreshMessages {
conversation_id: cid,
})
.ok();
app.refresh_messages_in_flight = true;
}
}
last_refresh = Instant::now();
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}
fn handle_chat_keys(
app: &mut AppState,
request_tx: &mpsc::Sender<daemon::Request>,
code: KeyCode,
max_scroll: u16,
) {
match code {
KeyCode::PageUp => scroll_up(app, 10),
KeyCode::PageDown => scroll_down(app, 10, max_scroll),
_ => {}
}
if app.focus != Focus::Input {
return;
}
match code {
KeyCode::Enter => {
let text = app.input.trim().to_string();
if text.is_empty() {
return;
}
let Some(conversation_id) = app.active_conversation_id.clone() else {
app.status = "No conversation selected".to_string();
return;
};
request_tx
.send(daemon::Request::SendMessage {
conversation_id,
text,
})
.ok();
app.refresh_messages_in_flight = true;
app.input.clear();
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => {
if !c.is_control() {
app.input.push(c);
}
}
_ => {}
}
}
fn scroll_up(app: &mut AppState, amount: u16) {
if amount > 0 {
app.pinned_to_bottom = false;
}
app.transcript_scroll = app.transcript_scroll.saturating_sub(amount);
}
fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) {
app.transcript_scroll = app.transcript_scroll.saturating_add(amount);
if app.transcript_scroll >= max_scroll {
app.transcript_scroll = max_scroll;
app.pinned_to_bottom = true;
}
}
fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view);
let outer_width = match mode {
ViewMode::Split => {
let left_width = (size.width / 3).clamp(24, 40);
size.width.saturating_sub(left_width)
}
ViewMode::Chat => size.width,
ViewMode::List => 0,
};
outer_width.saturating_sub(2).max(1)
}
fn visual_line_count(s: &str, inner_width: u16) -> u16 {
let w = s.width();
if w == 0 {
return 1;
}
let iw = inner_width.max(1) as usize;
((w + iw - 1) / iw).min(u16::MAX as usize) as u16
}
fn transcript_content_visual_lines(
messages: &[daemon::ChatMessage],
inner_width: u16,
) -> u16 {
if messages.is_empty() {
return 1;
}
let mut total: u32 = 0;
for message in messages {
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
let header = format!("{} {}", message.sender, ts);
total += visual_line_count(&header, inner_width) as u32;
let mut rendered_any_text = false;
for text_line in message.text.lines() {
rendered_any_text = true;
total += visual_line_count(text_line, inner_width) as u32;
}
if !rendered_any_text {
total += visual_line_count("<non-text message>", inner_width) as u32;
}
total += 1; // spacer line
}
total.min(u16::MAX as u32) as u16
}
fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
let mode = view_mode(size.width, app.active_conversation_id.is_some(), requested_view);
let show_input =
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
let transcript_height = if show_input {
size.height.saturating_sub(4) // input (3) + status (1)
} else {
size.height.saturating_sub(1) // status
};
match mode {
ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders
ViewMode::List => 0,
}
}
fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 {
let viewport_height = transcript_viewport_height(size, app, requested_view);
let inner_width = transcript_inner_width(size, app, requested_view);
let content = transcript_content_visual_lines(&app.messages, inner_width);
content.saturating_sub(viewport_height)
}
fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) {
let max_scroll = max_transcript_scroll(app, size, requested_view);
if app.pinned_to_bottom {
app.transcript_scroll = max_scroll;
} else {
app.transcript_scroll = app.transcript_scroll.min(max_scroll);
}
}