A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

TUI Polish (#21)

* Add `/clear` command to clear current session context

* Add `/new` command to create a new session in the TUI

* Delete session from session picker modal

Add the `shift+D` key combination to delete the selected session

* Install `cargo agents` (a.k.a. Symposium)

* Add `/compact` command to compact session message history

* Update README.md and CLAUDE.md with current project state

authored by

Mason Stallmo and committed by
GitHub
f49c74aa 1e22e944

+507 -56
+4
.claude/skills/.symposium.toml
··· 1 + installed = [ 2 + "find-crate-source", 3 + "rust-best-practice", 4 + ]
+10
.claude/skills/find-crate-source/SKILL.md
··· 1 + --- 2 + name: find-crate-source 3 + description: Find sources for a Rust crate from crates.io. Activate this skill to inspect a crate's API. 4 + --- 5 + 6 + To find the source for a crate, use the `cargo agents` CLI tool available in the PATH: 7 + 8 + * `cargo agents crate-info $CRATE_NAME` will give you the source path for the crate, preferring to use the version found in your current package. If the crate is not yet used, it will give you the latest version. 9 + * `cargo agents crate-info $CRATE_NAME --version $VERSION` will give you the source path for a specific version 10 + * `$VERSION` is a semver constraint, so `1.0` will give you something compatible with `1.0`; `=1.0.0` is guaranteed to give you an exact version.
+11
.claude/skills/rust-best-practice/SKILL.md
··· 1 + --- 2 + name: rust-best-practice 3 + description: [Critical] Best practice for Rust coding. Always activate this skill before authoring Rust code or answering questions about Rust. 4 + --- 5 + 6 + # Important dos and don'ts for working with Rust source code 7 + 8 + * Use `cargo add` to add new dependencies or features rather than editing `Cargo.toml` directly 9 + * Before finishing your turn, when editing Rust code: 10 + * Run `cargo fmt` after modifying Rust source files to ensure consistent formatting 11 + * Run tests (most commonly `cargo test --all --workspace`, though some projects may have other commands)
+28 -6
CLAUDE.md
··· 72 72 73 73 ### Session lifecycle 74 74 75 - Each connection goes through three message types (all variants of `UserInput`): 75 + Each connection goes through several message types (all variants of `UserInput`): 76 76 77 77 1. **Init** — the client sends a `SessionConfig` as the first message (the `init` variant). The server creates or resumes a persisted session, instantiates the model client, and loads tool plugins before starting the prompt loop. 78 78 2. **Prompts** — subsequent messages carry the `prompt` string variant and drive `run_agent`. 79 79 3. **Config update** — a `config_update` message (same shape as `SessionConfig`) may arrive at any time after init. The server re-instantiates the model client with the new credentials mid-session without resetting conversation history. Sent automatically by the TUI when `~/.ein/config.json` changes on disk. 80 + 4. **Clear context** — a `clear_context` boolean message wipes the server's in-memory message history for this session without touching SQLite. Sent by the TUI's `/clear` command. 81 + 5. **Compact context** — a `compact_context` boolean message asks the server to summarise the conversation via the LLM and replace both the in-memory history and the persisted SQLite record with the summary. The server streams the summary back as `ContentDelta` events followed by `AgentFinished`. Sent by the TUI's `/compact` command. 80 82 81 83 `SessionConfig` carries: 82 84 - `allowed_paths` — filesystem paths preopened for all WASM plugins via `WasiCtxBuilder::preopened_dir` ··· 129 131 130 132 ### TUI (`crates/ein-tui/`) 131 133 132 - Two files: `src/main.rs` (app logic + rendering) and `src/config.rs` (config load/save). 134 + Six source files under `crates/ein-tui/src/`: 135 + 136 + | File | Role | 137 + |------|------| 138 + | `main.rs` | Entry point, CLI args (`--debug`), event loop, terminal lifecycle, `KeyAction` dispatch | 139 + | `app.rs` | `App` state struct, `DisplayMessage` variants, `AppEvent`, `SessionPickerState`, `CwdState` | 140 + | `config.rs` | `ClientConfig` — load, save, and migrate `~/.ein/config.json` | 141 + | `connection.rs` | `connection_manager` — background reconnect loop, `ListSessions` handshake, `DeleteSession`, config file watcher | 142 + | `input.rs` | Slash command registry (`COMMANDS`), key event handler, server event handler | 143 + | `render.rs` | Full render pass — conversation pane, input area, autocomplete, session picker and CWD modals, status bar | 133 144 134 145 Uses **Ratatui** (v0.29) for rendering and **crossterm** for keyboard events. 135 146 ··· 139 150 3. **Autocomplete section** — always 3 lines tall; shows slash-command hints when input starts with `/` 140 151 4. **Status bar** — model name (vendor prefix stripped) and cumulative token usage; shows model name only while connecting 141 152 142 - **Color palette** — all colors are named constants at the top of `main.rs`: 153 + **Color palette** — all colors are named constants at the top of `render.rs`: 143 154 - `INPUT_BORDER_COLOR` — muted dark-peach/terracotta border on the input area 144 155 - `TOOL_NAME_COLOR` — steel blue for the `▸ ToolName` tool call indicator 145 156 - `THINKING_COLOR` — soft sky blue for the animated thinking spinner ··· 151 162 152 163 **Connecting animation**: when disconnected, a red `●` icon + grey braille spinner + italic "connecting to server" text appears in the conversation pane. If a previous session dropped with an error, the error message is shown above the spinner (replaced in-place, never appended). 153 164 154 - **CWD modal**: at startup a centered floating window (`Clear` + bordered `Block`) overlays the TUI asking whether to allow access to the current working directory. Press `Y` to add it to `allowed_paths` for the session; `N`, `Enter`, or `Esc` to skip. The connection manager is spawned only after this modal is dismissed. 165 + **Session picker**: shown immediately on first connection. Use `↑`/`↓` to navigate, `Enter` to select. Row 0 is always "New Session"; subsequent rows are existing sessions (newest-first). Press `Shift+D` on an existing session to delete it (sends `DeleteSession` RPC; removes the row immediately on success). 166 + 167 + **CWD modal**: shown after choosing "New Session" in the picker. A centered floating window asks whether to allow access to the current working directory. Press `Y` to add it to `allowed_paths` for the session; `N`, `Enter`, or `Esc` to skip. 155 168 156 - **Connection management** (`connection_manager` / `try_connect`): a background Tokio task retries the gRPC connection every 3 seconds. State transitions are communicated to the main loop via `AppEvent` (an mpsc channel). `AppEvent::Connected` carries the outbound `mpsc::Sender<UserInput>`; `AppEvent::Disconnected` carries an optional error string; `AppEvent::ConfigChanged` carries a freshly parsed `ClientConfig` from the file watcher. 169 + **Connection management** (`connection_manager` / `try_connect`): a background Tokio task retries the gRPC connection every 3 seconds. A `reconnect_notify` (`Arc<Notify>`) lets `/new` and `/sessions` bypass the delay. State transitions are communicated to the main loop via `AppEvent` (an mpsc channel). `AppEvent::Connected` carries the outbound `mpsc::Sender<UserInput>`; `AppEvent::Disconnected` carries an optional error string; `AppEvent::ConfigChanged` carries a freshly parsed `ClientConfig` from the file watcher. A `session_config_cache` (`Arc<Mutex<Option<SessionConfig>>>`) stores the chosen config so reconnects reuse it without reshowing the picker. 157 170 158 171 **Tool call display**: `▸ ToolName primary_arg` — for `Bash` the command is shown; for `Read`/`Write`/`Edit` the file path is shown. `Edit` additionally renders a syntax-highlighted diff (up to `DIFF_MAX_LINES` = 5 lines each of removed/added content) using `syntect` with the `base16-ocean.dark` theme. 159 172 160 - **Slash commands**: defined in the `COMMANDS` constant. Currently only `/exit`. Adding a command requires appending a `CommandDef` entry there. `/exit` works regardless of connection state. 173 + **Slash commands**: defined in the `COMMANDS` constant in `input.rs`. Adding a command requires appending a `CommandDef` entry there. 174 + 175 + | Command | Description | 176 + |---------|-------------| 177 + | `/exit` | Exit Ein (works regardless of connection state) | 178 + | `/config` | Open `~/.ein/config.json` in `$EDITOR` | 179 + | `/clear` | Wipe the in-memory context for this session (sends `clear_context`; SQLite history is kept; clears local display) | 180 + | `/new` | Drop the current session and start a fresh one (shows CWD modal, then reconnects) | 181 + | `/sessions` | Re-open the session picker to switch sessions | 182 + | `/compact` | Summarise the conversation via the LLM, replace context and SQLite history with the summary (sends `compact_context`) | 161 183 162 184 **Scrolling**: `↑`/`↓` arrows scroll the conversation. `scroll_offset` counts lines up from the bottom; auto-scroll re-engages when the view reaches the bottom again. 163 185
+20 -9
README.md
··· 126 126 On first connection a **session picker** modal appears. Use `↑`/`↓` to navigate, `Enter` to select: 127 127 - **New Session** — starts a fresh conversation; a follow-up modal asks whether to grant the agent access to your current working directory for that session 128 128 - **Existing session** — resumes a prior conversation from where it left off 129 + - **Shift+D** on an existing session — permanently deletes it from the server 129 130 130 131 ## Configuration 131 132 ··· 190 191 191 192 ## Usage 192 193 193 - Type a message and press **Enter** to send it to the agent. Type `/` to see available slash commands. 194 + Type a message and press **Enter** to send it to the agent. Type `/` to see available slash commands with autocomplete hints. 194 195 195 - | Key | Action | 196 - |-----|--------| 196 + | Key / Command | Action | 197 + |---------------|--------| 197 198 | `Enter` | Send message / run slash command | 198 199 | `↑` / `↓` | Scroll conversation history (also navigate session picker) | 199 200 | `Ctrl-C` | Force quit | 200 - | `/exit` + `Enter` | Exit the TUI | 201 - | `/config` + `Enter` | Open `~/.ein/config.json` in `$EDITOR` | 201 + | `/exit` | Exit the TUI | 202 + | `/config` | Open `~/.ein/config.json` in `$EDITOR` | 203 + | `/clear` | Wipe the agent's in-memory context (SQLite history preserved; clears display) | 204 + | `/new` | Drop current session and start a fresh one | 205 + | `/sessions` | Re-open the session picker to switch sessions | 206 + | `/compact` | Summarise the conversation via the LLM and replace history with the summary | 202 207 203 208 While the agent is working, an animated thinking spinner appears in the conversation pane. Tool invocations are shown inline as the agent uses them: 204 209 ··· 215 220 216 221 ### Connection behaviour 217 222 218 - The TUI connects in the background immediately on startup. While disconnected, a red `●` icon and animated spinner appear in the conversation pane. The TUI reconnects automatically every 3 seconds. If the server goes away mid-session, an error message is shown and the TUI resumes connecting in the background — the session picker reappears on the next successful reconnect. 223 + The TUI connects in the background immediately on startup. The session picker modal is shown as part of the first successful connection handshake. While disconnected, a red `●` icon and animated spinner appear in the conversation pane. The TUI reconnects automatically every 3 seconds. If the server goes away mid-session, an error message is shown and the TUI resumes connecting in the background — the session picker reappears on the next successful reconnect. Running `/new` or `/sessions` bypasses the 3-second retry delay and triggers an immediate reconnect. 219 224 220 225 ## Tools 221 226 ··· 257 262 258 263 ### Protocol 259 264 260 - The protocol (`crates/ein-proto/proto/ein.proto`) defines a bidirectional streaming RPC (`AgentSession`) and a unary `ListSessions` RPC. Each session opens with a `SessionConfig` message (global sandbox constraints + per-plugin config map + optional `session_id` for resume), followed by `UserInput` prompt messages. The server streams back `AgentEvent` messages as the agent thinks, calls tools, and produces output — starting with a `SessionStarted` event carrying the session's UUID, a `resumed` boolean, and the prior conversation history when resuming. A `config_update` message variant allows the TUI to push plugin config changes to a live session without reconnecting. 265 + The protocol (`crates/ein-proto/proto/ein.proto`) defines a bidirectional streaming RPC (`AgentSession`), a unary `ListSessions` RPC, and a unary `DeleteSession` RPC. Each session opens with a `SessionConfig` message (global sandbox constraints + per-plugin config map + optional `session_id` for resume), followed by `UserInput` prompt messages. The server streams back `AgentEvent` messages as the agent thinks, calls tools, and produces output — starting with a `SessionStarted` event carrying the session's UUID, a `resumed` boolean, and the prior conversation history when resuming. 261 266 262 - `ListSessions` returns a list of `SessionSummary` records (newest-first), each containing the session ID, creation timestamp, a preview of the first user message, and the stored `SessionConfig` JSON needed to reconstruct the session on resume. 267 + `UserInput` variants after `init`: 268 + - `prompt` — a user message driving `run_agent` 269 + - `config_update` — push new plugin credentials to the live session without reconnecting 270 + - `clear_context` — wipe the server's in-memory message history (SQLite history preserved) 271 + - `compact_context` — summarise the conversation via the LLM; replaces both in-memory and persisted history with the summary 272 + 273 + `ListSessions` returns a list of `SessionSummary` records (newest-first), each containing the session ID, creation timestamp, a preview of the first user message, and the stored `SessionConfig` JSON needed to reconstruct the session on resume. `DeleteSession` permanently removes a session and its message history from the store. 263 274 264 275 ### Session persistence 265 276 ··· 272 283 | `main.rs` | Entry point, CLI args, event loop, terminal lifecycle | 273 284 | `app.rs` | `App` state struct, `DisplayMessage` variants, session picker / CWD modal state | 274 285 | `config.rs` | `ClientConfig` — load, save, and migrate `~/.ein/config.json` | 275 - | `connection.rs` | `connection_manager` — background reconnect loop, `ListSessions` handshake, config file watcher | 286 + | `connection.rs` | `connection_manager` — background reconnect loop, `ListSessions` handshake, `DeleteSession`, config file watcher | 276 287 | `input.rs` | Slash command registry (`COMMANDS`), key event handler, server event handler | 277 288 | `render.rs` | Full render pass — conversation pane, input area, autocomplete, session picker and CWD modals, status bar | 278 289
+87
crates/ein-agent/src/agents.rs
··· 164 164 &self.messages 165 165 } 166 166 167 + /// Clears the in-memory message history so the next `chat` call starts 168 + /// with a blank context. The caller is responsible for not persisting 169 + /// the cleared state if the original history should be kept in storage. 170 + pub fn clear_messages(&mut self) { 171 + self.messages.clear(); 172 + } 173 + 174 + /// Summarises the current conversation using the model, then replaces the 175 + /// message history with the original system message(s) plus the summary 176 + /// injected as a new `System` message. 177 + /// 178 + /// Broadcasts a `ContentDelta` event containing the summary so the client 179 + /// can display it. Saves nothing — the caller (`grpc.rs`) owns persistence. 180 + /// 181 + /// Returns the summary string (empty if there was nothing to compact). 182 + pub async fn compact_history(&mut self) -> AgentResult<String> { 183 + // Nothing to compact if there are no conversational turns yet. 184 + if !self.messages.iter().any(|m| matches!(m.role, Role::User)) { 185 + return Ok(String::new()); 186 + } 187 + 188 + const COMPACT_PROMPT: &str = 189 + "Please provide a detailed but concise summary of our conversation so far. \ 190 + Include: goals discussed, files viewed or modified, code written or changed, \ 191 + decisions made, and the current state of any ongoing tasks. \ 192 + This summary will replace the full conversation history as context for \ 193 + future turns — be thorough enough that work can continue without the original."; 194 + 195 + // Build summarization payload: full history + summary request. 196 + let mut summary_msgs = self.messages.clone(); 197 + summary_msgs.push(Message { 198 + role: Role::User, 199 + content: Some(COMPACT_PROMPT.to_string()), 200 + tool_calls: None, 201 + tool_call_id: None, 202 + }); 203 + 204 + // Single non-agentic call — no tools. 205 + let resp = self 206 + .model_client 207 + .complete(&summary_msgs, &[]) 208 + .await 209 + .map_err(|e| AgentError::ModelClient(e.to_string()))?; 210 + 211 + if let Some(error_obj) = &resp.error { 212 + let msg = error_obj 213 + .get("message") 214 + .and_then(|v| v.as_str()) 215 + .unwrap_or("Unknown API error"); 216 + return Err(AgentError::ModelClient(msg.to_string())); 217 + } 218 + 219 + let summary = resp 220 + .choices 221 + .into_iter() 222 + .next() 223 + .and_then(|c| c.message.content) 224 + .unwrap_or_default(); 225 + 226 + // Stream the summary to the client before modifying state. 227 + if !summary.is_empty() { 228 + self.broadcast_event(AgentEvent::ContentDelta(summary.clone())) 229 + .await; 230 + } 231 + 232 + // Replace history: keep the original system message(s), then append the 233 + // summary as a new System message. Using System role means: 234 + // - The LLM receives it as high-priority context on every future turn. 235 + // - grpc.rs filters System messages out of the HistoryMessage replay, 236 + // so on session resume the TUI shows a clean empty conversation rather 237 + // than a fake "user" bubble containing the summary text. 238 + let system_msgs: Vec<Message> = std::mem::take(&mut self.messages) 239 + .into_iter() 240 + .filter(|m| matches!(m.role, Role::System)) 241 + .collect(); 242 + 243 + self.messages = system_msgs; 244 + self.messages.push(Message { 245 + role: Role::System, 246 + content: Some(format!("Summary of prior conversation:\n\n{summary}")), 247 + tool_calls: None, 248 + tool_call_id: None, 249 + }); 250 + 251 + Ok(summary) 252 + } 253 + 167 254 /// Runs the agent loop for one user turn. 168 255 /// 169 256 /// Sends `messages` to the LLM via the model client plugin, streams events
+19 -2
crates/ein-proto/proto/ein.proto
··· 10 10 rpc AgentSession(stream UserInput) returns (stream AgentEvent); 11 11 // Returns a summary of all sessions stored on the server, newest-first. 12 12 rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); 13 + // Permanently deletes a session and its message history. 14 + rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); 13 15 } 14 16 15 17 // A single message sent by the client during a session. 16 - // The first message MUST be the `init` variant; all subsequent 17 - // messages MUST be the `prompt` variant. 18 + // The first message MUST be the `init` variant; subsequent messages 19 + // may be `prompt`, `config_update`, `clear_context`, or `compact_context`. 18 20 message UserInput { 19 21 oneof input { 20 22 SessionConfig init = 1; 21 23 string prompt = 2; 22 24 SessionConfig config_update = 3; 25 + // Clears the model's in-memory context for this session. 26 + // The SQLite history is preserved; only the messages sent to 27 + // the LLM on the next prompt are wiped. 28 + bool clear_context = 4; 29 + // Summarises the conversation using the LLM, then replaces the 30 + // in-memory context and the persisted SQLite history with the 31 + // summary. The server streams the summary back as ContentDelta 32 + // events followed by AgentFinished. 33 + bool compact_context = 5; 23 34 } 24 35 } 25 36 ··· 143 154 message ListSessionsResponse { 144 155 repeated SessionSummary sessions = 1; // Newest-first 145 156 } 157 + 158 + message DeleteSessionRequest { 159 + string session_id = 1; 160 + } 161 + 162 + message DeleteSessionResponse {}
+60 -3
crates/ein-server/src/grpc.rs
··· 34 34 use crate::model_client::ModelClientSessionManager; 35 35 use crate::tools::ToolSetManager; 36 36 use ein_proto::ein::{ 37 - AgentError, AgentEvent as AgentEventProto, HistoryMessage, HistoryToolCall, 38 - ListSessionsRequest, ListSessionsResponse, SessionStarted, SessionSummary, UserInput, 39 - agent_event::Event, agent_server::Agent as AgentService, user_input, 37 + AgentError, AgentEvent as AgentEventProto, DeleteSessionRequest, DeleteSessionResponse, 38 + HistoryMessage, HistoryToolCall, ListSessionsRequest, ListSessionsResponse, SessionStarted, 39 + SessionSummary, UserInput, agent_event::Event, agent_server::Agent as AgentService, 40 + user_input, 40 41 }; 41 42 42 43 /// gRPC service struct. ··· 102 103 }) 103 104 .collect(), 104 105 })) 106 + } 107 + 108 + async fn delete_session( 109 + &self, 110 + request: Request<DeleteSessionRequest>, 111 + ) -> Result<Response<DeleteSessionResponse>, Status> { 112 + let id = request.into_inner().session_id; 113 + self.session_store 114 + .delete_session(&id) 115 + .await 116 + .map_err(|e| Status::internal(e.to_string()))?; 117 + 118 + println!("[session] deleted session {id}"); 119 + Ok(Response::new(DeleteSessionResponse {})) 105 120 } 106 121 107 122 /// Handles one client session. ··· 441 456 })) 442 457 .await; 443 458 continue; 459 + } 460 + } 461 + } 462 + Some(user_input::Input::ClearContext(should_clear)) => { 463 + if should_clear { 464 + // Intentionally skip save_messages — the SQLite history 465 + // is preserved; only the in-memory LLM context is wiped. 466 + println!("[session] context cleared"); 467 + agent.clear_messages(); 468 + } 469 + } 470 + Some(user_input::Input::CompactContext(should_compact)) => { 471 + if should_compact { 472 + println!("[session] compacting context"); 473 + 474 + match agent.compact_history().await { 475 + Ok(_) => { 476 + // compact_history already broadcast ContentDelta events. 477 + channel_sender 478 + .send_event(Event::AgentFinished(AgentFinished { 479 + final_content: String::new(), 480 + })) 481 + .await; 482 + 483 + // Persist the compacted history so resumed sessions stay compact. 484 + if let Err(err) = session_store 485 + .save_messages(&session_id, agent.messages()) 486 + .await 487 + { 488 + eprintln!( 489 + "[session] failed to save compacted messages for {session_id}: {err}" 490 + ); 491 + } 492 + } 493 + Err(err) => { 494 + eprintln!("[session] compact error: {err}"); 495 + channel_sender 496 + .send_event(Event::AgentError(AgentError { 497 + message: format!("Compact failed: {err}"), 498 + })) 499 + .await; 500 + } 444 501 } 445 502 } 446 503 }
+43
crates/ein-server/src/persistence.rs
··· 214 214 .collect()) 215 215 } 216 216 217 + /// Permanently delete a session and its message history. 218 + /// 219 + /// Returns `Ok(())` whether or not the session existed. 220 + pub async fn delete_session(&self, id: &str) -> Result<()> { 221 + sqlx::query("DELETE FROM sessions WHERE id = ?") 222 + .bind(id) 223 + .execute(&self.pool) 224 + .await 225 + .with_context(|| format!("deleting session {id}"))?; 226 + 227 + Ok(()) 228 + } 229 + 217 230 /// Overwrite the stored message history for an existing session. 218 231 pub async fn save_messages(&self, id: &str, messages: &[Message]) -> Result<()> { 219 232 let json = serde_json::to_string(messages).context("serialising messages")?; ··· 338 351 .save_messages("nonexistent", &[simple_message(Role::User, "hi")]) 339 352 .await; 340 353 assert!(result.is_err()); 354 + } 355 + 356 + #[tokio::test] 357 + async fn delete_session_removes_it() { 358 + let store = make_store().await; 359 + store.create_session("del-1", "{}").await.unwrap(); 360 + assert!(store.session_exists("del-1").await.unwrap()); 361 + 362 + store.delete_session("del-1").await.unwrap(); 363 + assert!(!store.session_exists("del-1").await.unwrap()); 364 + } 365 + 366 + #[tokio::test] 367 + async fn delete_session_is_idempotent() { 368 + let store = make_store().await; 369 + // Deleting a non-existent session should not error. 370 + store.delete_session("ghost").await.unwrap(); 371 + } 372 + 373 + #[tokio::test] 374 + async fn deleted_session_absent_from_list() { 375 + let store = make_store().await; 376 + store.create_session("keep", "{}").await.unwrap(); 377 + store.create_session("remove", "{}").await.unwrap(); 378 + 379 + store.delete_session("remove").await.unwrap(); 380 + 381 + let sessions = store.list_sessions().await.unwrap(); 382 + assert_eq!(sessions.len(), 1); 383 + assert_eq!(sessions[0].id, "keep"); 341 384 } 342 385 }
+2
crates/ein-tui/src/app.rs
··· 24 24 /// Server returned the session list. The TUI shows the session picker and 25 25 /// sends the chosen `SessionConfig` back via the oneshot sender. 26 26 SessionsLoaded(Vec<SessionSummary>, oneshot::Sender<SessionConfig>), 27 + /// A session was successfully deleted; remove it from the session picker. 28 + SessionDeleted(String), 27 29 } 28 30 29 31 /// Whether the TUI currently has a live server connection.
+23 -2
crates/ein-tui/src/connection.rs
··· 2 2 // Copyright 2026 Mason Stallmo 3 3 4 4 use ein_proto::ein::{ 5 - ListSessionsRequest, SessionConfig, UserInput, agent_client::AgentClient, user_input, 5 + DeleteSessionRequest, ListSessionsRequest, SessionConfig, UserInput, 6 + agent_client::AgentClient, user_input, 6 7 }; 7 8 use tokio::sync::{mpsc, oneshot}; 8 9 use tokio_stream::wrappers::ReceiverStream; ··· 205 206 } 206 207 } 207 208 209 + /// Opens a short-lived connection and deletes a session by ID. 210 + /// 211 + /// Returns `Ok(())` on success; errors are logged by the caller. 212 + pub(crate) async fn delete_session(server_addr: &str, session_id: String) -> anyhow::Result<()> { 213 + let channel = Channel::from_shared(server_addr.to_string())?.connect().await?; 214 + let mut client = AgentClient::new(channel); 215 + client 216 + .delete_session(tonic::Request::new(DeleteSessionRequest { session_id })) 217 + .await?; 218 + Ok(()) 219 + } 220 + 208 221 /// Background task: connects to the server and retries every 3 s on failure. 209 222 /// 210 223 /// Errors on the initial connection attempt are silent (status bar already 211 224 /// shows "Connecting…"). Errors after a live session was established are 212 225 /// forwarded as `Disconnected(Some(...))` so the conversation shows a message. 226 + /// 227 + /// `reconnect_notify` can be used to interrupt the 3 s retry delay and trigger 228 + /// an immediate reconnect (e.g. when the user runs `/new`). 213 229 pub(crate) async fn connection_manager( 214 230 server_addr: String, 215 231 event_tx: mpsc::Sender<AppEvent>, 216 232 session_config_cache: std::sync::Arc<tokio::sync::Mutex<Option<SessionConfig>>>, 233 + reconnect_notify: std::sync::Arc<tokio::sync::Notify>, 217 234 ) { 218 235 loop { 219 236 match try_connect(&server_addr, &event_tx, &session_config_cache).await { ··· 228 245 } 229 246 } 230 247 231 - tokio::time::sleep(std::time::Duration::from_secs(3)).await; 248 + // Allow /new (or other callers) to skip the retry delay. 249 + tokio::select! { 250 + _ = tokio::time::sleep(std::time::Duration::from_secs(3)) => {} 251 + _ = reconnect_notify.notified() => {} 252 + } 232 253 } 233 254 }
+109 -31
crates/ein-tui/src/input.rs
··· 30 30 name: "/config", 31 31 description: "Edit ~/.ein/config.json", 32 32 }, 33 + CommandDef { 34 + name: "/clear", 35 + description: "Clear conversation history", 36 + }, 37 + CommandDef { 38 + name: "/new", 39 + description: "Start a new session", 40 + }, 41 + CommandDef { 42 + name: "/sessions", 43 + description: "Switch to a different session", 44 + }, 45 + CommandDef { 46 + name: "/compact", 47 + description: "Summarize and compact conversation history", 48 + }, 33 49 ]; 34 50 35 51 /// Recomputes `autocomplete_matches` and `autocomplete_active` based on the ··· 62 78 OpenConfig(std::path::PathBuf), 63 79 /// No further action required; continue the event loop. 64 80 Continue, 81 + /// The user ran `/new`; drop the current session and start a fresh one. 82 + NewSession, 83 + /// The user ran `/sessions`; show the session picker to switch sessions. 84 + OpenSessionPicker, 85 + /// The user pressed Shift+D on an existing session in the picker; delete it. 86 + DeleteSession(String), 65 87 } 66 88 67 89 // --------------------------------------------------------------------------- ··· 103 125 KeyCode::Down => { 104 126 if picker.selected < picker.sessions.len() { 105 127 picker.selected += 1; 128 + } 129 + } 130 + // Shift+D: delete the highlighted existing session (not "New Session"). 131 + KeyCode::Char('D') => { 132 + let picker = app.pending_session_picker.as_ref().unwrap(); 133 + if picker.selected > 0 { 134 + let session_id = picker.sessions[picker.selected - 1].id.clone(); 135 + return KeyAction::DeleteSession(session_id); 106 136 } 107 137 } 108 138 KeyCode::Enter => { ··· 188 218 app.autocomplete_matches.clear(); 189 219 190 220 // Slash commands work regardless of connection state. 191 - if text == "/exit" { 192 - return KeyAction::Quit; 193 - } 221 + match text.as_str() { 222 + "/clear" => { 223 + // Tell the server to wipe its in-memory context (SQLite history is kept). 224 + if let Some(tx) = &app.prompt_tx { 225 + let _ = tx 226 + .send(UserInput { 227 + input: Some(user_input::Input::ClearContext(true)), 228 + }) 229 + .await; 230 + } 231 + // Clear the local display, keeping the header banner. 232 + app.messages 233 + .retain(|m| matches!(m, DisplayMessage::Header { .. })); 234 + app.scroll_offset = 0; 235 + app.auto_scroll = true; 194 236 195 - if text == "/config" { 196 - if let Some(path) = dirs::home_dir().map(|h| h.join(".ein").join("config.json")) { 197 - return KeyAction::OpenConfig(path); 237 + return KeyAction::Continue; 198 238 } 199 - return KeyAction::Continue; 200 - } 239 + "/compact" => { 240 + // Require an active connection — compact triggers a server LLM call. 241 + if app.prompt_tx.is_none() { 242 + app.messages 243 + .push(DisplayMessage::Error("Not connected to server".to_string())); 244 + app.auto_scroll = true; 245 + return KeyAction::Continue; 246 + } 247 + if let Some(tx) = &app.prompt_tx { 248 + let _ = tx 249 + .send(UserInput { 250 + input: Some(user_input::Input::CompactContext(true)), 251 + }) 252 + .await; 253 + } 201 254 202 - // Reject unrecognized slash commands — display a local error, do not send to server. 203 - if text.starts_with('/') { 204 - let cmd = text.split_whitespace().next().unwrap_or(&text); 205 - app.messages 206 - .push(DisplayMessage::Error(format!("Unknown command: {}", cmd))); 207 - app.auto_scroll = true; 208 - return KeyAction::Continue; 209 - } 255 + // Clear display so only the incoming summary is shown. 256 + app.messages 257 + .retain(|m| matches!(m, DisplayMessage::Header { .. })); 258 + app.scroll_offset = 0; 259 + app.auto_scroll = true; 260 + app.agent_busy = true; 261 + 262 + return KeyAction::Continue; 263 + } 264 + "/config" => { 265 + if let Some(path) = dirs::home_dir().map(|h| h.join(".ein").join("config.json")) 266 + { 267 + return KeyAction::OpenConfig(path); 268 + } 269 + return KeyAction::Continue; 270 + } 271 + "/exit" => return KeyAction::Quit, 272 + "/new" => return KeyAction::NewSession, 273 + "/sessions" => return KeyAction::OpenSessionPicker, 274 + _ => { 275 + // Reject unrecognized slash commands — display a local error, do not send to server. 276 + if text.starts_with('/') { 277 + let cmd = text.split_whitespace().next().unwrap_or(&text); 278 + app.messages 279 + .push(DisplayMessage::Error(format!("Unknown command: {}", cmd))); 280 + app.auto_scroll = true; 281 + return KeyAction::Continue; 282 + } 210 283 211 - // Prompts require an active connection. 212 - if app.prompt_tx.is_none() { 213 - return KeyAction::Continue; 214 - } 284 + // Prompts require an active connection. 285 + if app.prompt_tx.is_none() { 286 + return KeyAction::Continue; 287 + } 215 288 216 - app.messages.push(DisplayMessage::User(text.clone())); 217 - app.auto_scroll = true; 218 - app.agent_busy = true; 219 - if let Some(tx) = &app.prompt_tx { 220 - let _ = tx 221 - .send(UserInput { 222 - input: Some(user_input::Input::Prompt(text)), 223 - }) 224 - .await; 289 + app.messages.push(DisplayMessage::User(text.clone())); 290 + app.auto_scroll = true; 291 + app.agent_busy = true; 292 + if let Some(tx) = &app.prompt_tx { 293 + let _ = tx 294 + .send(UserInput { 295 + input: Some(user_input::Input::Prompt(text)), 296 + }) 297 + .await; 298 + } 299 + } 225 300 } 226 301 } 227 302 KeyCode::Char(c) => { ··· 294 369 } 295 370 Some(ServerEvent::ToolCallStart(t)) => { 296 371 debug!(tool = %t.tool_name, "tool call start"); 297 - let arg = if t.display_arg.is_empty() { None } else { Some(t.display_arg.clone()) }; 372 + let arg = if t.display_arg.is_empty() { 373 + None 374 + } else { 375 + Some(t.display_arg.clone()) 376 + }; 298 377 299 378 app.messages.push(DisplayMessage::ToolCall { 300 379 name: t.tool_name.clone(), ··· 420 499 // --------------------------------------------------------------------------- 421 500 // Helpers 422 501 // --------------------------------------------------------------------------- 423 - 424 502 425 503 /// Converts a Unicode scalar-value index into the corresponding byte index 426 504 /// within `s`. Returns `s.len()` when `char_idx` is past the end.
+83 -2
crates/ein-tui/src/main.rs
··· 7 7 mod input; 8 8 mod render; 9 9 10 - use crate::app::{App, AppEvent, ConnectionStatus, SessionPickerState}; 10 + use crate::app::{App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, SessionPickerState}; 11 11 use crate::config::load_or_create_config; 12 - use crate::connection::{connection_manager, spawn_config_watcher, to_proto_session_config}; 12 + use crate::connection::{ 13 + connection_manager, delete_session, spawn_config_watcher, to_proto_session_config, 14 + }; 13 15 use crate::input::{KeyAction, handle_key_event, handle_server_event}; 14 16 use crate::render::render; 15 17 use crossterm::{ ··· 108 110 let session_config_cache: std::sync::Arc<tokio::sync::Mutex<Option<SessionConfig>>> = 109 111 std::sync::Arc::new(tokio::sync::Mutex::new(None)); 110 112 113 + // Shared notify used by /new to skip the connection manager's 3 s retry delay. 114 + let reconnect_notify = std::sync::Arc::new(tokio::sync::Notify::new()); 115 + 111 116 // Spawn the connection manager immediately — the session picker is shown 112 117 // as part of the first connection handshake, not before it. 113 118 tokio::spawn(connection_manager( 114 119 args.server_addr.clone(), 115 120 event_tx.clone(), 116 121 session_config_cache.clone(), 122 + reconnect_notify.clone(), 117 123 )); 118 124 119 125 // Configure the terminal for raw / alternate-screen rendering. ··· 155 161 execute!(terminal.backend_mut(), EnterAlternateScreen)?; 156 162 terminal.clear()?; 157 163 } 164 + KeyAction::NewSession => { 165 + // Build a fresh config from the current ~/.ein/config.json — the 166 + // same source "New Session" in the picker uses — rather than 167 + // recycling the cached SessionConfig from the old session. 168 + let base = to_proto_session_config(&app.current_cfg, String::new()); 169 + 170 + // Drop the sender → closes the gRPC request stream → server 171 + // receives EOF and closes the response stream → try_connect returns. 172 + app.prompt_tx = None; 173 + app.connection_status = ConnectionStatus::Connecting; 174 + app.session_id = None; 175 + app.agent_busy = false; 176 + app.connection_error = None; 177 + 178 + // Clear the conversation display, keeping the welcome banner. 179 + app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 180 + app.scroll_offset = 0; 181 + app.auto_scroll = true; 182 + 183 + if let Some(cwd) = app.cwd.clone() { 184 + // Show the CWD modal — identical to the "New Session" picker 185 + // path. A bridge task receives the final config from the modal 186 + // and updates the cache before signalling the connection manager. 187 + let (tx, rx) = tokio::sync::oneshot::channel::<SessionConfig>(); 188 + app.pending_cwd_prompt = Some(CwdState { 189 + cwd, 190 + base_config: base, 191 + session_tx: tx, 192 + }); 193 + let cache = session_config_cache.clone(); 194 + let notify = reconnect_notify.clone(); 195 + tokio::spawn(async move { 196 + if let Ok(cfg) = rx.await { 197 + *cache.lock().await = Some(cfg); 198 + notify.notify_one(); 199 + } 200 + }); 201 + } else { 202 + // No CWD to ask about — update the cache and reconnect now. 203 + *session_config_cache.lock().await = Some(base); 204 + reconnect_notify.notify_one(); 205 + } 206 + } 207 + KeyAction::OpenSessionPicker => { 208 + app.prompt_tx = None; 209 + app.connection_status = ConnectionStatus::Connecting; 210 + app.session_id = None; 211 + app.agent_busy = false; 212 + app.connection_error = None; 213 + app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 214 + app.scroll_offset = 0; 215 + app.auto_scroll = true; 216 + // Clear the cache so try_connect shows the session picker on reconnect. 217 + *session_config_cache.lock().await = None; 218 + reconnect_notify.notify_one(); 219 + } 220 + KeyAction::DeleteSession(session_id) => { 221 + let addr = args.server_addr.clone(); 222 + let tx = event_tx.clone(); 223 + tokio::spawn(async move { 224 + if delete_session(&addr, session_id.clone()).await.is_ok() { 225 + let _ = tx.send(AppEvent::SessionDeleted(session_id)).await; 226 + } 227 + }); 228 + } 158 229 KeyAction::Continue => {} 159 230 } 160 231 } ··· 199 270 selected: 0, 200 271 session_tx, 201 272 }); 273 + } 274 + AppEvent::SessionDeleted(id) => { 275 + if let Some(picker) = &mut app.pending_session_picker { 276 + picker.sessions.retain(|s| s.id != id); 277 + // Clamp selection: index 0 is always "New Session". 278 + let max_idx = picker.sessions.len(); 279 + if picker.selected > max_idx { 280 + picker.selected = max_idx; 281 + } 282 + } 202 283 } 203 284 } 204 285 }
+8 -1
crates/ein-tui/src/render.rs
··· 676 676 .fg(AUTOCOMPLETE_TOP_COLOR) 677 677 .add_modifier(Modifier::BOLD), 678 678 ), 679 - Span::styled(" Select", Style::default().fg(MUTED_COLOR)), 679 + Span::styled(" Select ", Style::default().fg(MUTED_COLOR)), 680 + Span::styled( 681 + "[Shift+D]", 682 + Style::default() 683 + .fg(AUTOCOMPLETE_TOP_COLOR) 684 + .add_modifier(Modifier::BOLD), 685 + ), 686 + Span::styled(" Delete", Style::default().fg(MUTED_COLOR)), 680 687 ])); 681 688 682 689 frame.render_widget(Paragraph::new(lines), inner);