Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
247 lines
9.3 KiB
Rust
247 lines
9.3 KiB
Rust
//! Attach mode — TUI-only client that connects to an existing daemon session
|
|
//! over a Unix socket, forwarding key events and rendering state updates.
|
|
|
|
use anyhow::Result;
|
|
use app::state::rest::AppStateRest;
|
|
use app::state::types::{Overlay, Toast, ToastKind};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
use ipc::protocol::{ClientRequest, DaemonFrame, StatePayload};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::Terminal;
|
|
use std::io;
|
|
use zesdex_cms::domain::repository::SettingsRepository;
|
|
use zesdex_utils::clipboard::write_osc52;
|
|
|
|
use crate::app;
|
|
use crate::daemon::key_code_to_action;
|
|
use crate::ipc;
|
|
use crate::model;
|
|
use crate::view;
|
|
|
|
/// Apply a `StatePayload` received from the daemon onto the client's
|
|
/// local `AppStateRest`, so the attach-mode TUI can render it.
|
|
///
|
|
/// Flow: copy scalar fields directly → rebuild the transcript cache from
|
|
/// `MessageEntry`s (mapping role strings back to the `Role` enum) →
|
|
/// resolve the overlay name string to an `Overlay` variant → rebuild
|
|
/// toasts from `ToastEntry`s.
|
|
///
|
|
/// Why: unrecognized role/overlay/toast-kind strings fall back to a safe
|
|
/// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than
|
|
/// panicking, so a protocol/version mismatch degrades gracefully.
|
|
fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) {
|
|
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() {
|
|
"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("Bash") => Overlay::Bash,
|
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
|
|
|
Some("KeyInput") => Overlay::KeyInput,
|
|
Some("Editor") => Overlay::Editor,
|
|
Some("Effort") => Overlay::Effort,
|
|
Some("Mcp") => Overlay::Mcp,
|
|
Some("Todo") => Overlay::Todo,
|
|
Some("Rewind") => Overlay::Rewind,
|
|
Some("Learning") => Overlay::Learning,
|
|
Some("Usage") => Overlay::Usage,
|
|
Some("Loading") => Overlay::Loading,
|
|
Some("ModelSelector") => Overlay::ModelSelector,
|
|
Some("ClearConfirm") => Overlay::ClearConfirm,
|
|
|
|
_ => Overlay::None,
|
|
};
|
|
|
|
state.misc.toasts = payload
|
|
.toasts
|
|
.into_iter()
|
|
.map(|t| Toast {
|
|
kind: match t.kind.as_str() {
|
|
"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;
|
|
}
|
|
|
|
/// Set up the IPC client connection, terminal, and initial state for attach mode.
|
|
///
|
|
/// Flow: resolve socket path → connect → enable raw/alt mode → create state.
|
|
///
|
|
/// Return: (client, terminal, `client_state`) on success.
|
|
fn setup_attach_client(
|
|
session_id: &str,
|
|
) -> Result<(
|
|
ipc::client::IpcClient,
|
|
Terminal<CrosstermBackend<io::Stdout>>,
|
|
AppStateRest,
|
|
)> {
|
|
let store = model::store::Store::new();
|
|
let socket_path = store
|
|
.base_dir
|
|
.join("run")
|
|
.join(format!("{session_id}.sock"));
|
|
let addr = socket_path.to_string_lossy().to_string();
|
|
let client = ipc::client::IpcClient::connect_unix(&addr)?;
|
|
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
|
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
terminal.clear()?;
|
|
|
|
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 = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
|
|
client_state.session_id = session_id.to_string();
|
|
|
|
Ok((client, terminal, client_state))
|
|
}
|
|
|
|
/// Process a single daemon frame from the IPC channel, updating state accordingly.
|
|
fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option<DaemonFrame>) {
|
|
match frame {
|
|
Some(DaemonFrame::StateUpdate(payload)) => {
|
|
apply_client_update(client_state, *payload);
|
|
}
|
|
Some(DaemonFrame::StreamToken(_token)) => {}
|
|
Some(DaemonFrame::SystemNote { kind: _, message }) => {
|
|
client_state.push_toast(Toast::new(ToastKind::Info, message));
|
|
}
|
|
Some(DaemonFrame::ClipboardCopy(text)) => {
|
|
let _ = write_osc52(&mut io::stdout(), &text);
|
|
client_state.push_toast(Toast::new(
|
|
ToastKind::Success,
|
|
"Copied to clipboard".to_string(),
|
|
));
|
|
}
|
|
Some(DaemonFrame::Closed) | None => {
|
|
client_state.quit = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
|
///
|
|
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
|
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
|
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
|
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
|
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
|
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
|
/// closes or the user quits (sending `ClientRequest::Close` first).
|
|
///
|
|
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
|
/// through the daemon, since the daemon has no notion of "this client
|
|
/// wants to leave" beyond the explicit `Close` request.
|
|
pub fn run_attach(session_id: &str) -> Result<()> {
|
|
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
|
|
|
let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?;
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
|
|
|
loop {
|
|
if client_state.quit {
|
|
let _ = client.send(&ClientRequest::Close);
|
|
break;
|
|
}
|
|
|
|
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::Paste(text) => {
|
|
client.send(&ClientRequest::Paste(text))?;
|
|
}
|
|
Event::Resize(w, h) => {
|
|
client.send(&ClientRequest::Resize(w, h))?;
|
|
}
|
|
Event::Mouse(mouse_event) => {
|
|
if mouse_event.kind == MouseEventKind::ScrollUp {
|
|
client.send(&ClientRequest::ScrollUp)?;
|
|
} else if mouse_event.kind == MouseEventKind::ScrollDown {
|
|
client.send(&ClientRequest::ScrollDown)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
} else {
|
|
client.send(&ClientRequest::Tick)?;
|
|
}
|
|
|
|
handle_daemon_frame(
|
|
&mut client_state,
|
|
client.receive::<DaemonFrame>()?,
|
|
);
|
|
|
|
terminal.draw(|f| {
|
|
view::draw(f, &client_state);
|
|
})?;
|
|
}
|
|
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
|
let _ = disable_raw_mode();
|
|
|
|
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
|
.save(&client_state.store_base_dir(), &client_state.settings);
|
|
|
|
Ok(())
|
|
}
|