feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
//! IPC client — connects to the daemon's Unix socket and sends/receives
|
||||
//! framed JSON messages.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket.
|
||||
pub struct IpcClient {
|
||||
conn: Mutex<crate::ipc::conn::Connection>,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
pub fn connect_unix(path: &str) -> anyhow::Result<Self> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
let conn = crate::ipc::conn::Connection::new(stream);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&self, msg: &T) -> anyhow::Result<()> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.send(msg)
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.receive()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Connection wrapper around a Unix socket stream,
|
||||
//! pairing a buffered reader with a raw writer.
|
||||
|
||||
use std::io::BufReader;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
/// A framed JSON connection over a Unix socket.
|
||||
pub struct Connection {
|
||||
reader: BufReader<UnixStream>,
|
||||
writer: UnixStream,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(stream: UnixStream) -> Self {
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.expect("UnixStream::try_clone should never fail on Linux"),
|
||||
);
|
||||
let writer = stream;
|
||||
Self { reader, writer }
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&mut self, msg: &T) -> anyhow::Result<()> {
|
||||
let json = serde_json::to_vec(msg)?;
|
||||
crate::ipc::frame::write_frame(&mut self.writer, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> anyhow::Result<Option<T>> {
|
||||
let raw = crate::ipc::frame::read_frame(&mut self.reader)?;
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(bytes) => {
|
||||
let msg: T = serde_json::from_slice(&bytes)?;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Length-prefixed framing for Unix-socket IPC.
|
||||
//!
|
||||
//! Every message on the wire is encoded as:
|
||||
//! ```text
|
||||
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
|
||||
//! ```
|
||||
|
||||
use anyhow::Context;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
|
||||
|
||||
/// Read one length-prefixed frame from `reader`.
|
||||
pub fn read_frame(reader: &mut impl Read) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
|
||||
match reader.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e).context("failed to read frame length prefix"),
|
||||
}
|
||||
|
||||
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
if payload_len > MAX_PAYLOAD as usize {
|
||||
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
reader.read_exact(&mut payload)?;
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
/// Write one length-prefixed frame to `writer`.
|
||||
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> {
|
||||
let payload_len: u32 = data.len().try_into()?;
|
||||
|
||||
if payload_len > MAX_PAYLOAD {
|
||||
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||
}
|
||||
|
||||
let len_bytes = payload_len.to_be_bytes();
|
||||
writer.write_all(&len_bytes)?;
|
||||
writer.write_all(data)?;
|
||||
writer.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Unix-socket IPC layer for daemon/client communication.
|
||||
|
||||
pub mod client;
|
||||
pub mod conn;
|
||||
pub mod frame;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Wire types for the Zesdex IPC protocol.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A resolved key press sent from the daemon to the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum KeyAction {
|
||||
Char(char),
|
||||
Enter,
|
||||
Escape,
|
||||
Backspace,
|
||||
Delete,
|
||||
Tab,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Function(u8),
|
||||
}
|
||||
|
||||
/// A message sent from the TUI client to the daemon over the IPC socket.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientRequest {
|
||||
Tick,
|
||||
KeyPress {
|
||||
key: KeyAction,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
},
|
||||
Submit(String),
|
||||
Paste(String),
|
||||
Resize(u16, u16),
|
||||
Close,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
|
||||
/// A single chat message within a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEntry {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// A transient toast notification sent to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastEntry {
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
/// Full UI state snapshot pushed from the daemon to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatePayload {
|
||||
pub session_id: String,
|
||||
pub messages: Vec<MessageEntry>,
|
||||
pub edit_count: u32,
|
||||
pub message_count: usize,
|
||||
pub overlay: Option<String>,
|
||||
pub toasts: Vec<ToastEntry>,
|
||||
pub dirty: bool,
|
||||
pub input_buffer: String,
|
||||
pub input_cursor: usize,
|
||||
}
|
||||
|
||||
/// A frame sent from the daemon to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DaemonFrame {
|
||||
StateUpdate(Box<StatePayload>),
|
||||
StreamToken(String),
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
ClipboardCopy(String),
|
||||
Closed,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! IPC server — binds a Unix socket and accepts incoming client connections.
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
|
||||
/// A Unix-socket IPC server.
|
||||
pub struct IpcServer {
|
||||
listener: UnixListener,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
pub fn bind_unix(path: &str) -> anyhow::Result<Self> {
|
||||
let p = Path::new(path);
|
||||
if p.exists() {
|
||||
std::fs::remove_file(p)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(path)?;
|
||||
Ok(Self { listener })
|
||||
}
|
||||
|
||||
pub fn accept(&self) -> anyhow::Result<crate::ipc::conn::Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Ok(crate::ipc::conn::Connection::new(stream))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user