kptui: initial commit
This commit is contained in:
26
core/kptui/Cargo.toml
Normal file
26
core/kptui/Cargo.toml
Normal 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
26
core/kptui/build.rs
Normal 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);
|
||||
}
|
||||
|
||||
581
core/kptui/src/daemon/mod.rs
Normal file
581
core/kptui/src/daemon/mod.rs
Normal 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
702
core/kptui/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user