2026-07-11 20:21:59 +07:00
|
|
|
#![expect(dead_code)]
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::io;
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use anyhow::Result;
|
|
|
|
|
use crossterm::execute;
|
|
|
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
|
|
|
use ratatui::backend::CrosstermBackend;
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
|
|
|
|
|
mod app;
|
|
|
|
|
mod controller;
|
|
|
|
|
mod dto;
|
|
|
|
|
mod ipc;
|
|
|
|
|
mod model;
|
|
|
|
|
mod security;
|
|
|
|
|
mod service;
|
|
|
|
|
mod tool;
|
|
|
|
|
mod resources;
|
|
|
|
|
mod view;
|
|
|
|
|
|
|
|
|
|
fn main() -> Result<()> {
|
2026-07-11 20:21:59 +07:00
|
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
|
let is_daemon = args.iter().any(|a| a == "--daemon");
|
|
|
|
|
let attach_session = args.iter()
|
|
|
|
|
.position(|a| a == "--attach")
|
|
|
|
|
.and_then(|i| args.get(i + 1).cloned());
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(
|
|
|
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
|
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
|
|
|
)
|
|
|
|
|
.init();
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
if is_daemon && attach_session.is_some() {
|
|
|
|
|
anyhow::bail!("--daemon and --attach are mutually exclusive");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if is_daemon {
|
|
|
|
|
return run_daemon();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(session_id) = attach_session {
|
|
|
|
|
return run_attach(&session_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run_single_process()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_single_process() -> Result<()> {
|
2026-07-11 13:16:10 +07:00
|
|
|
let store = model::store::Store::new();
|
|
|
|
|
store.ensure_dirs()?;
|
|
|
|
|
|
|
|
|
|
let session_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
|
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
|
|
|
|
std::fs::create_dir_all(&session_dir)?;
|
|
|
|
|
|
|
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
|
|
|
|
let mut state = app::state::rest::AppStateRest::new(
|
|
|
|
|
workspace_roots.clone(),
|
|
|
|
|
session_dir,
|
|
|
|
|
store.memory_dir,
|
|
|
|
|
);
|
2026-07-11 20:21:59 +07:00
|
|
|
state.sessions = model::session::Session::list(&store.base_dir);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
|
|
|
|
|
|
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
terminal.clear()?;
|
|
|
|
|
|
|
|
|
|
let run_result = run_loop(&mut state, &mut terminal);
|
|
|
|
|
|
|
|
|
|
let mut restore_stdout = io::stdout();
|
|
|
|
|
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
|
|
|
|
let _ = disable_raw_mode();
|
|
|
|
|
|
|
|
|
|
if let Err(e) = run_result {
|
|
|
|
|
let _ = writeln!(restore_stdout, "error: {}", e);
|
|
|
|
|
let _ = restore_stdout.flush();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = state.settings.save();
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::KeyAction> {
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
match code {
|
|
|
|
|
KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)),
|
|
|
|
|
KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter),
|
|
|
|
|
KeyCode::Esc => Some(ipc::protocol::KeyAction::Escape),
|
|
|
|
|
KeyCode::Backspace => Some(ipc::protocol::KeyAction::Backspace),
|
|
|
|
|
KeyCode::Delete => Some(ipc::protocol::KeyAction::Delete),
|
|
|
|
|
KeyCode::Tab => Some(ipc::protocol::KeyAction::Tab),
|
|
|
|
|
KeyCode::Up => Some(ipc::protocol::KeyAction::Up),
|
|
|
|
|
KeyCode::Down => Some(ipc::protocol::KeyAction::Down),
|
|
|
|
|
KeyCode::Left => Some(ipc::protocol::KeyAction::Left),
|
|
|
|
|
KeyCode::Right => Some(ipc::protocol::KeyAction::Right),
|
|
|
|
|
KeyCode::Home => Some(ipc::protocol::KeyAction::Home),
|
|
|
|
|
KeyCode::End => Some(ipc::protocol::KeyAction::End),
|
|
|
|
|
KeyCode::PageUp => Some(ipc::protocol::KeyAction::PageUp),
|
|
|
|
|
KeyCode::PageDown => Some(ipc::protocol::KeyAction::PageDown),
|
|
|
|
|
KeyCode::F(n) => Some(ipc::protocol::KeyAction::Function(n)),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
|
|
|
|
|
use crossterm::event::KeyCode;
|
|
|
|
|
match action {
|
|
|
|
|
ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c),
|
|
|
|
|
ipc::protocol::KeyAction::Enter => KeyCode::Enter,
|
|
|
|
|
ipc::protocol::KeyAction::Escape => KeyCode::Esc,
|
|
|
|
|
ipc::protocol::KeyAction::Backspace => KeyCode::Backspace,
|
|
|
|
|
ipc::protocol::KeyAction::Delete => KeyCode::Delete,
|
|
|
|
|
ipc::protocol::KeyAction::Tab => KeyCode::Tab,
|
|
|
|
|
ipc::protocol::KeyAction::Up => KeyCode::Up,
|
|
|
|
|
ipc::protocol::KeyAction::Down => KeyCode::Down,
|
|
|
|
|
ipc::protocol::KeyAction::Left => KeyCode::Left,
|
|
|
|
|
ipc::protocol::KeyAction::Right => KeyCode::Right,
|
|
|
|
|
ipc::protocol::KeyAction::Home => KeyCode::Home,
|
|
|
|
|
ipc::protocol::KeyAction::End => KeyCode::End,
|
|
|
|
|
ipc::protocol::KeyAction::PageUp => KeyCode::PageUp,
|
|
|
|
|
ipc::protocol::KeyAction::PageDown => KeyCode::PageDown,
|
|
|
|
|
ipc::protocol::KeyAction::Function(n) => KeyCode::F(*n),
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
|
|
|
|
|
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
|
|
|
|
|
MessageEntry {
|
|
|
|
|
role: format!("{:?}", m.role),
|
|
|
|
|
content: m.content.clone(),
|
|
|
|
|
timestamp: m.timestamp,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
}).collect();
|
|
|
|
|
|
|
|
|
|
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
|
|
|
|
|
ToastEntry {
|
|
|
|
|
kind: format!("{:?}", t.kind),
|
|
|
|
|
message: t.message.clone(),
|
|
|
|
|
created_at: t.created_at,
|
|
|
|
|
lifetime_ms: t.lifetime_ms,
|
|
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
|
|
|
|
|
let overlay = if state.misc.overlay.is_active() {
|
|
|
|
|
Some(format!("{:?}", state.misc.overlay))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let frame = DaemonFrame::StateUpdate(Box::new(StatePayload {
|
|
|
|
|
mode: state.mode.name().to_string(),
|
|
|
|
|
session_id: state.session_id.clone(),
|
|
|
|
|
messages,
|
|
|
|
|
edit_count: state.edit_log.len() as u32,
|
|
|
|
|
message_count: state.transcript_cache.messages.len(),
|
|
|
|
|
overlay,
|
|
|
|
|
toasts,
|
|
|
|
|
dirty: state.dirty,
|
|
|
|
|
input_buffer: state.input.buffer.clone(),
|
|
|
|
|
input_cursor: state.input.cursor,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
conn.send(&frame)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_client_update(
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
payload: ipc::protocol::StatePayload,
|
|
|
|
|
) {
|
|
|
|
|
use app::state::types::{AgentMode, Overlay, Toast, ToastKind};
|
|
|
|
|
|
|
|
|
|
state.mode = match payload.mode.as_str() {
|
|
|
|
|
"Auto" => AgentMode::Auto,
|
|
|
|
|
"Normal" => AgentMode::Normal,
|
|
|
|
|
"Plan" => AgentMode::Plan,
|
|
|
|
|
"Yolo" => AgentMode::Yolo,
|
|
|
|
|
_ => state.mode,
|
|
|
|
|
};
|
|
|
|
|
state.session_id = payload.session_id;
|
|
|
|
|
state.dirty = payload.dirty;
|
|
|
|
|
|
|
|
|
|
state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
|
|
|
|
|
app::state::rest::ChatMessageDisplay {
|
|
|
|
|
role: match m.role.as_str() {
|
|
|
|
|
"User" => crate::dto::chat::message::Role::User,
|
|
|
|
|
"Assistant" => crate::dto::chat::message::Role::Assistant,
|
|
|
|
|
"System" => crate::dto::chat::message::Role::System,
|
|
|
|
|
"Tool" => crate::dto::chat::message::Role::Tool,
|
|
|
|
|
_ => crate::dto::chat::message::Role::User,
|
|
|
|
|
},
|
|
|
|
|
content: m.content,
|
|
|
|
|
timestamp: m.timestamp,
|
|
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
state.transcript_cache.dirty = true;
|
|
|
|
|
|
|
|
|
|
state.misc.overlay = match payload.overlay.as_deref() {
|
|
|
|
|
Some("Help") => Overlay::Help,
|
|
|
|
|
Some("Settings") => Overlay::Settings,
|
|
|
|
|
Some("Agents") => Overlay::Agents,
|
|
|
|
|
Some("Bash") => Overlay::Bash,
|
|
|
|
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
|
|
|
|
Some("Workflow") => Overlay::Workflow,
|
|
|
|
|
Some("Onboard") => Overlay::Onboard,
|
|
|
|
|
Some("OnboardProvider") => Overlay::OnboardProvider,
|
|
|
|
|
Some("KeyInput") => Overlay::KeyInput,
|
|
|
|
|
Some("Editor") => Overlay::Editor,
|
|
|
|
|
Some("Effort") => Overlay::Effort,
|
|
|
|
|
Some("Mcp") => Overlay::Mcp,
|
|
|
|
|
Some("Security") => Overlay::Security,
|
|
|
|
|
Some("Todo") => Overlay::Todo,
|
|
|
|
|
Some("Rewind") => Overlay::Rewind,
|
|
|
|
|
Some("Learning") => Overlay::Learning,
|
|
|
|
|
Some("Usage") => Overlay::Usage,
|
|
|
|
|
Some("Loading") => Overlay::Loading,
|
|
|
|
|
_ => Overlay::None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.misc.toasts = payload.toasts.into_iter().map(|t| {
|
|
|
|
|
Toast {
|
|
|
|
|
kind: match t.kind.as_str() {
|
|
|
|
|
"Info" => ToastKind::Info,
|
|
|
|
|
"Success" => ToastKind::Success,
|
|
|
|
|
"Warning" => ToastKind::Warning,
|
|
|
|
|
"Error" => ToastKind::Error,
|
|
|
|
|
"Lesson" => ToastKind::Lesson,
|
|
|
|
|
_ => ToastKind::Info,
|
|
|
|
|
},
|
|
|
|
|
message: t.message,
|
|
|
|
|
created_at: t.created_at,
|
|
|
|
|
lifetime_ms: t.lifetime_ms,
|
|
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
|
|
|
|
|
state.input.buffer = payload.input_buffer;
|
|
|
|
|
state.input.cursor = payload.input_cursor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_daemon() -> Result<()> {
|
|
|
|
|
use app::runtime::actions::{Action, apply_action};
|
|
|
|
|
use ipc::protocol::ClientRequest;
|
|
|
|
|
|
|
|
|
|
let store = model::store::Store::new();
|
|
|
|
|
store.ensure_dirs()?;
|
|
|
|
|
|
|
|
|
|
let session_id = uuid::Uuid::new_v4().to_string();
|
|
|
|
|
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
|
|
|
|
std::fs::create_dir_all(&session_dir)?;
|
|
|
|
|
|
|
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
|
|
|
|
let mut state = app::state::rest::AppStateRest::new(
|
|
|
|
|
workspace_roots.clone(),
|
|
|
|
|
session_dir,
|
|
|
|
|
store.memory_dir,
|
|
|
|
|
);
|
|
|
|
|
state.sessions = model::session::Session::list(&store.base_dir);
|
|
|
|
|
|
|
|
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
|
|
|
|
|
|
|
|
|
let run_dir = store.base_dir.join("run");
|
|
|
|
|
std::fs::create_dir_all(&run_dir)?;
|
|
|
|
|
let socket_path = run_dir.join(format!("{}.sock", session_id));
|
|
|
|
|
let addr = socket_path.to_string_lossy().to_string();
|
|
|
|
|
|
|
|
|
|
let server = ipc::server::IpcServer::bind_unix(&addr)?;
|
|
|
|
|
eprintln!("daemon: listening on {}", addr);
|
|
|
|
|
|
|
|
|
|
let mut conn = match server.accept() {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("daemon: accept error: {}", e);
|
|
|
|
|
let _ = std::fs::remove_file(&socket_path);
|
|
|
|
|
return Err(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
eprintln!("daemon: client connected");
|
|
|
|
|
|
|
|
|
|
let mut running = true;
|
|
|
|
|
while running {
|
|
|
|
|
match conn.receive::<ClientRequest>()? {
|
|
|
|
|
Some(req) => {
|
|
|
|
|
match req {
|
|
|
|
|
ClientRequest::Tick => {
|
|
|
|
|
apply_action(&mut state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
|
|
|
|
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
|
|
|
|
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
|
|
|
|
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
|
|
|
|
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
|
|
|
|
let key_event = crossterm::event::KeyEvent::new(
|
|
|
|
|
key_action_to_code(&key),
|
|
|
|
|
modifiers,
|
|
|
|
|
);
|
|
|
|
|
let actions = controller::input::handle_key(key_event, &mut state);
|
|
|
|
|
for action in actions {
|
|
|
|
|
apply_action(&mut state, action);
|
|
|
|
|
}
|
|
|
|
|
apply_action(&mut state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Submit(text) => {
|
|
|
|
|
state.input.buffer = text;
|
|
|
|
|
let enter_event = crossterm::event::KeyEvent::new(
|
|
|
|
|
crossterm::event::KeyCode::Enter,
|
|
|
|
|
crossterm::event::KeyModifiers::NONE,
|
|
|
|
|
);
|
|
|
|
|
let actions = controller::input::handle_key(enter_event, &mut state);
|
|
|
|
|
for action in actions {
|
|
|
|
|
apply_action(&mut state, action);
|
|
|
|
|
}
|
|
|
|
|
apply_action(&mut state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Resize(w, h) => {
|
|
|
|
|
apply_action(&mut state, Action::Resize(w, h));
|
|
|
|
|
apply_action(&mut state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Close => {
|
|
|
|
|
running = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
send_daemon_update(&mut conn, &state)?;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
None => {
|
|
|
|
|
running = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = std::fs::remove_file(&socket_path);
|
|
|
|
|
let _ = state.settings.save();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
Ok(())
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
fn run_attach(session_id: &str) -> Result<()> {
|
|
|
|
|
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
|
|
|
|
use ipc::protocol::ClientRequest;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let store = model::store::Store::new();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let socket_path = store.base_dir.join("run").join(format!("{}.sock", session_id));
|
|
|
|
|
let addr = socket_path.to_string_lossy().to_string();
|
|
|
|
|
let mut client = ipc::client::IpcClient::connect_unix(&addr)?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
terminal.clear()?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
|
|
|
|
let session_dir = store.base_dir.join("sessions").join(session_id);
|
|
|
|
|
std::fs::create_dir_all(&session_dir)?;
|
|
|
|
|
let mut client_state = app::state::rest::AppStateRest::new(
|
|
|
|
|
workspace_roots,
|
|
|
|
|
session_dir,
|
|
|
|
|
store.memory_dir,
|
|
|
|
|
);
|
|
|
|
|
client_state.session_id = session_id.to_string();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
loop {
|
|
|
|
|
if client_state.quit {
|
|
|
|
|
let _ = client.send(&ClientRequest::Close);
|
|
|
|
|
break;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
client_state.misc.drain_expired_toasts(now_ms);
|
|
|
|
|
|
|
|
|
|
if crossterm::event::poll(std::time::Duration::from_millis(50))? {
|
|
|
|
|
match crossterm::event::read()? {
|
|
|
|
|
Event::Key(key) => {
|
|
|
|
|
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
|
|
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
|
|
|
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
|
|
|
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
|
|
|
|
|
|
|
|
if key.code == KeyCode::Char('c') && ctrl {
|
|
|
|
|
client_state.quit = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(key_action) = key_code_to_action(key.code) {
|
|
|
|
|
client.send(&ClientRequest::KeyPress {
|
|
|
|
|
key: key_action,
|
|
|
|
|
ctrl,
|
|
|
|
|
alt,
|
|
|
|
|
shift,
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Event::Resize(w, h) => {
|
|
|
|
|
client.send(&ClientRequest::Resize(w, h))?;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
client.send(&ClientRequest::Tick)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match client.receive::<ipc::protocol::DaemonFrame>()? {
|
|
|
|
|
Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => {
|
|
|
|
|
apply_client_update(&mut client_state, *payload);
|
|
|
|
|
}
|
|
|
|
|
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
|
|
|
|
|
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
|
|
|
|
|
client_state.push_toast(
|
|
|
|
|
app::state::types::Toast::new(
|
|
|
|
|
app::state::types::ToastKind::Info,
|
|
|
|
|
message,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Some(ipc::protocol::DaemonFrame::Closed) => {
|
|
|
|
|
client_state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
client_state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
terminal.draw(|f| {
|
|
|
|
|
view::draw(f, &client_state);
|
|
|
|
|
})?;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
|
|
|
|
let _ = disable_raw_mode();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = client_state.settings.save();
|
|
|
|
|
core::mem::drop(_rt);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_loop(
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let result = run_loop_inner(state, terminal);
|
|
|
|
|
if let Err(ref _e) = result {
|
|
|
|
|
let _ = terminal.clear();
|
|
|
|
|
|
|
|
|
|
let _ = disable_raw_mode();
|
|
|
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
|
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_loop_inner(
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
use crossterm::event::{Event, KeyEventKind};
|
|
|
|
|
use controller::input::handle_key;
|
|
|
|
|
use app::runtime::actions::{Action, apply_action};
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
if state.quit {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
state.misc.drain_expired_toasts(now_ms);
|
|
|
|
|
terminal.draw(|f| {
|
|
|
|
|
view::draw(f, state);
|
|
|
|
|
state.dirty = false;
|
|
|
|
|
})?;
|
|
|
|
|
if crossterm::event::poll(Duration::from_millis(50))? {
|
|
|
|
|
match crossterm::event::read()? {
|
|
|
|
|
Event::Key(key) => {
|
|
|
|
|
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
|
|
|
|
let actions = handle_key(key, state);
|
|
|
|
|
for action in actions {
|
|
|
|
|
apply_action(state, action);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Event::Resize(w, h) => {
|
|
|
|
|
apply_action(state, Action::Resize(w, h));
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
terminal.clear()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|