diff --git a/crates/sheesh-mcp/Cargo.lock b/crates/sheesh-mcp/Cargo.lock deleted file mode 100644 index ee67e40..0000000 --- a/crates/sheesh-mcp/Cargo.lock +++ /dev/null @@ -1,114 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sheesh-mcp" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/sheesh-mcp/Cargo.toml b/crates/sheesh-mcp/Cargo.toml deleted file mode 100644 index 1af89bf..0000000 --- a/crates/sheesh-mcp/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "sheesh-mcp" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/crates/sheesh-mcp/src/context.rs b/crates/sheesh-mcp/src/context.rs deleted file mode 100644 index c7f8568..0000000 --- a/crates/sheesh-mcp/src/context.rs +++ /dev/null @@ -1,69 +0,0 @@ -/// Output of a command executed on the remote host. -#[derive(Debug, Clone)] -pub struct CommandOutput { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, -} - -impl CommandOutput { - /// Combined stdout + stderr, useful for sending to the LLM. - pub fn combined(&self) -> String { - let mut out = self.stdout.clone(); - if !self.stderr.is_empty() { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&self.stderr); - } - out - } - - pub fn succeeded(&self) -> bool { - self.exit_code == 0 - } -} - -/// The interface that tools use to interact with the remote SSH session. -/// -/// The main app will implement this trait on top of `TerminalTab` (PTY) or -/// a dedicated SSH exec channel — tools don't care which. -pub trait SshContext: Send + Sync { - /// Execute a shell command and return its output. - fn execute(&self, command: &str) -> anyhow::Result; - - /// Read a remote file's full contents as UTF-8. - fn read_file(&self, path: &str) -> anyhow::Result; - - /// Write `content` to `path` on the remote host (create or overwrite). - fn write_file(&self, path: &str, content: &str) -> anyhow::Result<()>; - - /// Append `content` to `path` on the remote host. - fn append_file(&self, path: &str, content: &str) -> anyhow::Result<()>; - - /// List entries in a remote directory. - fn list_dir(&self, path: &str) -> anyhow::Result>; - - /// Return `true` if the remote path exists. - fn path_exists(&self, path: &str) -> anyhow::Result; - - /// Return the current working directory of the remote session. - fn working_dir(&self) -> anyhow::Result; -} - -/// A single entry returned by `SshContext::list_dir`. -#[derive(Debug, Clone)] -pub struct DirEntry { - pub name: String, - pub kind: EntryKind, - /// Size in bytes (None if unknown). - pub size: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EntryKind { - File, - Directory, - Symlink, - Other, -} diff --git a/crates/sheesh-mcp/src/lib.rs b/crates/sheesh-mcp/src/lib.rs deleted file mode 100644 index 254183a..0000000 --- a/crates/sheesh-mcp/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod context; -pub mod registry; -pub mod tool; -pub mod tools; -pub mod types; - -// Convenience re-exports so users only need `use sheesh_mcp::*` or individual items. -pub use context::{CommandOutput, DirEntry, EntryKind, SshContext}; -pub use registry::ToolRegistry; -pub use tool::Tool; -pub use types::{ParamType, ToolCall, ToolContent, ToolDef, ToolParam, ToolResult}; diff --git a/crates/sheesh-mcp/src/registry.rs b/crates/sheesh-mcp/src/registry.rs deleted file mode 100644 index 0ae92ba..0000000 --- a/crates/sheesh-mcp/src/registry.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::collections::HashMap; - -use crate::{ - context::SshContext, - tool::Tool, - types::{ToolCall, ToolDef, ToolResult}, -}; - -/// Holds all registered tools and dispatches calls to the right one. -#[derive(Default)] -pub struct ToolRegistry { - tools: HashMap>, -} - -impl ToolRegistry { - pub fn new() -> Self { - Self::default() - } - - /// Register a tool. If a tool with the same name already exists it is replaced. - pub fn register(&mut self, tool: impl Tool + 'static) { - let name = tool.def().name.clone(); - self.tools.insert(name, Box::new(tool)); - } - - /// Retrieve a tool by name. - pub fn get(&self, name: &str) -> Option<&dyn Tool> { - self.tools.get(name).map(|b| b.as_ref()) - } - - /// All tool definitions — pass this to the LLM's `tools` parameter. - pub fn defs(&self) -> Vec { - self.tools.values().map(|t| t.def()).collect() - } - - /// Names of all registered tools. - pub fn names(&self) -> Vec<&str> { - self.tools.keys().map(|s| s.as_str()).collect() - } - - /// Dispatch a `ToolCall` to the matching tool. - /// - /// Returns `ToolResult::error` if no tool with that name is registered, - /// or if the tool itself returns an `Err`. - pub fn dispatch(&self, call: &ToolCall, ctx: &dyn SshContext) -> ToolResult { - let Some(tool) = self.get(&call.name) else { - return ToolResult::error( - &call.id, - format!("unknown tool: {}", call.name), - ); - }; - - match tool.call(call, ctx) { - Ok(result) => result, - Err(e) => ToolResult::error(&call.id, e.to_string()), - } - } - - /// Dispatch a batch of calls, returning results in the same order. - pub fn dispatch_all( - &self, - calls: &[ToolCall], - ctx: &dyn SshContext, - ) -> Vec { - calls.iter().map(|c| self.dispatch(c, ctx)).collect() - } -} diff --git a/crates/sheesh-mcp/src/tool.rs b/crates/sheesh-mcp/src/tool.rs deleted file mode 100644 index f5cf245..0000000 --- a/crates/sheesh-mcp/src/tool.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::{ - context::SshContext, - types::{ToolCall, ToolDef, ToolResult}, -}; - -/// A tool that can be registered with the MCP registry and called by the LLM. -/// -/// # Example -/// ```rust -/// struct EchoTool; -/// -/// impl Tool for EchoTool { -/// fn def(&self) -> ToolDef { -/// ToolDef { -/// name: "echo".into(), -/// description: "Return the input unchanged.".into(), -/// params: vec![ToolParam { name: "text".into(), ... }], -/// } -/// } -/// -/// fn call(&self, call: &ToolCall, _ctx: &dyn SshContext) -> anyhow::Result { -/// let text = call.arg_str("text").unwrap_or(""); -/// Ok(ToolResult::ok(&call.id, text)) -/// } -/// } -/// ``` -pub trait Tool: Send + Sync { - /// Static metadata: name, description, parameter schema. - fn def(&self) -> ToolDef; - - /// Invoke the tool. Receives the full call (including id and raw arguments) - /// and a reference to the active SSH session. - fn call(&self, call: &ToolCall, ctx: &dyn SshContext) -> anyhow::Result; -} diff --git a/crates/sheesh-mcp/src/tools/mod.rs b/crates/sheesh-mcp/src/tools/mod.rs deleted file mode 100644 index 7b176b4..0000000 --- a/crates/sheesh-mcp/src/tools/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod run_command; - -pub use run_command::RunCommandTool; diff --git a/crates/sheesh-mcp/src/tools/run_command.rs b/crates/sheesh-mcp/src/tools/run_command.rs deleted file mode 100644 index 139c986..0000000 --- a/crates/sheesh-mcp/src/tools/run_command.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::{ - context::SshContext, - tool::Tool, - types::{ParamType, ToolCall, ToolDef, ToolParam, ToolResult}, -}; - -/// MCP tool: execute a shell command on the remote SSH session. -/// -/// The host app is responsible for showing a confirmation dialog before -/// calling `Tool::call` — this struct only contains the pure execution logic. -pub struct RunCommandTool; - -impl Tool for RunCommandTool { - fn def(&self) -> ToolDef { - ToolDef { - name: "run_command".into(), - description: "Execute a shell command on the remote SSH session and return its output." - .into(), - params: vec![ - ToolParam { - name: "command".into(), - description: "The shell command to execute.".into(), - ty: ParamType::String, - required: true, - }, - ToolParam { - name: "description".into(), - description: "One-sentence explanation of what this command does (shown to the user in the confirmation dialog).".into(), - ty: ParamType::String, - required: false, - }, - ], - } - } - - fn call(&self, call: &ToolCall, ctx: &dyn SshContext) -> anyhow::Result { - let command = call - .arg_str("command") - .ok_or_else(|| anyhow::anyhow!("missing required argument: command"))?; - - let output = ctx.execute(command)?; - - let text = if output.combined().is_empty() { - format!("exit code {}", output.exit_code) - } else { - format!("exit code {}\n{}", output.exit_code, output.combined()) - }; - - if output.succeeded() { - Ok(ToolResult::ok(&call.id, text)) - } else { - Ok(ToolResult::error(&call.id, text)) - } - } -} diff --git a/crates/sheesh-mcp/src/types.rs b/crates/sheesh-mcp/src/types.rs deleted file mode 100644 index 2e1fe96..0000000 --- a/crates/sheesh-mcp/src/types.rs +++ /dev/null @@ -1,146 +0,0 @@ -use serde::{Deserialize, Serialize}; - -// ── Parameter schema ────────────────────────────────────────────────────────── - -/// Primitive types a tool parameter can have. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ParamType { - String, - Integer, - Boolean, - Array, - Object, -} - -/// A single parameter in a tool's input schema. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolParam { - pub name: String, - pub description: String, - #[serde(rename = "type")] - pub ty: ParamType, - pub required: bool, -} - -// ── Tool definition ─────────────────────────────────────────────────────────── - -/// Static metadata that describes a tool to the LLM. -/// Maps to the `tools` array in the Anthropic / OpenAI API. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDef { - pub name: String, - pub description: String, - pub params: Vec, -} - -impl ToolDef { - /// Render the parameter list as a JSON Schema `properties` object, - /// which is the format expected by most LLM APIs. - pub fn input_schema(&self) -> serde_json::Value { - let properties: serde_json::Map = self - .params - .iter() - .map(|p| { - let schema = serde_json::json!({ - "type": p.ty, - "description": p.description, - }); - (p.name.clone(), schema) - }) - .collect(); - - let required: Vec<&str> = self - .params - .iter() - .filter(|p| p.required) - .map(|p| p.name.as_str()) - .collect(); - - serde_json::json!({ - "type": "object", - "properties": properties, - "required": required, - }) - } -} - -// ── Tool call (LLM → app) ───────────────────────────────────────────────────── - -/// A request from the LLM to invoke a specific tool. -/// Matches the `tool_use` block in Anthropic responses. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - /// Opaque ID from the LLM (echoed back in the result). - pub id: String, - pub name: String, - /// Arbitrary JSON arguments as specified by the tool's input schema. - pub arguments: serde_json::Value, -} - -impl ToolCall { - pub fn new(id: impl Into, name: impl Into, arguments: serde_json::Value) -> Self { - Self { - id: id.into(), - name: name.into(), - arguments, - } - } - - /// Convenience: get a string argument by key. - pub fn arg_str(&self, key: &str) -> Option<&str> { - self.arguments.get(key)?.as_str() - } - - /// Convenience: get a bool argument by key. - pub fn arg_bool(&self, key: &str) -> Option { - self.arguments.get(key)?.as_bool() - } - - /// Convenience: get an i64 argument by key. - pub fn arg_int(&self, key: &str) -> Option { - self.arguments.get(key)?.as_i64() - } -} - -// ── Tool result (app → LLM) ─────────────────────────────────────────────────── - -/// A content block inside a tool result. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ToolContent { - Text { text: String }, -} - -impl ToolContent { - pub fn text(s: impl Into) -> Self { - Self::Text { text: s.into() } - } -} - -/// The result of invoking a tool, sent back to the LLM. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolResult { - /// Echoes the `id` from the originating `ToolCall`. - pub tool_call_id: String, - pub content: Vec, - pub is_error: bool, -} - -impl ToolResult { - pub fn ok(tool_call_id: impl Into, text: impl Into) -> Self { - Self { - tool_call_id: tool_call_id.into(), - content: vec![ToolContent::text(text)], - is_error: false, - } - } - - pub fn error(tool_call_id: impl Into, message: impl Into) -> Self { - Self { - tool_call_id: tool_call_id.into(), - content: vec![ToolContent::text(message)], - is_error: true, - } - } -} diff --git a/src/app.rs b/src/app.rs index 99a3483..cdf887a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,7 @@ use crate::ssh::SSHConnection; #[derive(Debug, Clone, PartialEq)] +#[allow(clippy::upper_case_acronyms)] pub enum ConnectedFocus { Terminal, LLM, diff --git a/src/config.rs b/src/config.rs index 6238f39..2c3c7c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,10 +60,11 @@ pub fn load_connections(path: &Path) -> Result> { pending_comment.clear(); continue; } - let mut conn = SSHConnection::default(); - conn.name = value; - conn.description = std::mem::take(&mut pending_comment); - current = Some(conn); + current = Some(SSHConnection { + name: value, + description: std::mem::take(&mut pending_comment), + ..Default::default() + }); } "HostName" | "hostname" => { if let Some(ref mut c) = current { diff --git a/src/event.rs b/src/event.rs index f96dc74..569f057 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,70 +1,14 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; - /// Actions that can be emitted by any tab or the main event handler. #[derive(Debug, Clone)] pub enum Action { /// Quit the application Quit, - /// Move focus to the next panel - NextPanel, - /// Move focus to the previous panel - PrevPanel, - /// Navigate the listing cursor down - Down, - /// Navigate the listing cursor up - Up, /// Confirm / connect Confirm, - /// Open add-connection form - Add, - /// Open edit-connection form - Edit, - /// Delete selected connection - Delete, - /// Start filtering the list - Filter, - /// Send terminal context to the LLM - SendContext, /// Disconnect from current SSH session Disconnect, /// Send a command string to the terminal PTY (no trailing newline). SendToTerminal(String), - /// Toggle the help overlay - Help, - /// A raw input character (for text fields / terminal passthrough) - Input(char), - /// Backspace in a text field - Backspace, - /// Enter key in a text field - Enter, - /// Escape / cancel - Escape, /// No-op None, } - -pub fn map_event(event: &Event) -> Action { - match event { - Event::Key(KeyEvent { - code, modifiers, .. - }) => match code { - KeyCode::Char('q') if modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, - KeyCode::Tab => Action::NextPanel, - KeyCode::BackTab => Action::PrevPanel, - KeyCode::Char('j') | KeyCode::Down => Action::Down, - KeyCode::Char('k') | KeyCode::Up => Action::Up, - KeyCode::Enter => Action::Enter, - KeyCode::Char('a') if modifiers.is_empty() => Action::Add, - KeyCode::Char('e') if modifiers.is_empty() => Action::Edit, - KeyCode::Char('d') if modifiers.is_empty() => Action::Delete, - KeyCode::Char('/') => Action::Filter, - KeyCode::Char('c') if modifiers.is_empty() => Action::SendContext, - KeyCode::Char('?') => Action::Help, - KeyCode::Backspace => Action::Backspace, - KeyCode::Esc => Action::Escape, - KeyCode::Char(ch) => Action::Input(*ch), - _ => Action::None, - }, - _ => Action::None, - } -} diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index 152bc99..8d3d04e 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -144,6 +144,11 @@ fn all_tools() -> Value { }, "required": [] } + }, + { + "name": "read_terminal", + "description": "Read the recent output from the user's terminal. Returns the last lines of captured terminal output. Use this to understand what is currently happening in the SSH session.", + "input_schema": { "type": "object", "properties": {}, "required": [] } } ]) } @@ -162,10 +167,10 @@ fn rich_to_json(m: &RichMessage) -> Value { }; // If there's a single Text block we can use the shorthand string form. - if m.content.len() == 1 { - if let ContentBlock::Text { text } = &m.content[0] { - return json!({ "role": role, "content": text }); - } + if m.content.len() == 1 + && let ContentBlock::Text { text } = &m.content[0] + { + return json!({ "role": role, "content": text }); } let blocks: Vec = m @@ -293,10 +298,10 @@ impl LLMProvider for AnthropicProvider { for block in &content { match block["type"].as_str() { Some("text") => { - if let Some(text) = block["text"].as_str() { - if !text.is_empty() { - assistant_blocks.push(ContentBlock::Text { text: text.to_string() }); - } + if let Some(text) = block["text"].as_str() + && !text.is_empty() + { + assistant_blocks.push(ContentBlock::Text { text: text.to_string() }); } } Some("tool_use") => { @@ -317,9 +322,9 @@ impl LLMProvider for AnthropicProvider { // Dispatch by tool name. match name.as_str() { - "system_information" => { - debug!("[Anthropic] local tool: system_information"); - return Ok(LLMEvent::LocalTool { id, name, input, assistant_blocks }); + "system_information" | "read_terminal" => { + debug!("[Anthropic] local tool: {}", name); + return Ok(LLMEvent::LocalTool { id, name, assistant_blocks }); } "run_command" => { let command = input["command"] diff --git a/src/llm/mod.rs b/src/llm/mod.rs index a08fd88..473500a 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -29,9 +29,6 @@ impl Message { Self { role: Role::Assistant, content: content.into() } } - pub fn system(content: impl Into) -> Self { - Self { role: Role::System, content: content.into() } - } } // ── Rich content (Anthropic tool-use format) ────────────────────────────────── @@ -106,7 +103,6 @@ pub enum LLMEvent { LocalTool { id: String, name: String, - input: serde_json::Value, assistant_blocks: Vec, }, /// An error occurred. @@ -154,10 +150,16 @@ pub trait LLMProvider: Send + Sync { pub const DEFAULT_SYSTEM_PROMPT: &str = "\ You are Sheesh, an expert SSH and Linux assistant embedded in a terminal manager. \ You help users understand and manage their remote SSH sessions. \ -When the user shares terminal output, analyse it and provide clear, actionable guidance. \ Prefer concise answers; use shell code blocks for any commands you suggest. \ -You can run commands directly on the user's remote session via the run_command tool — \ -always explain what a command does before proposing to run it."; +You have the following tools available:\n\ +- read_terminal: Read recent output from the user's terminal. \ + Call this proactively whenever the user asks about what is on the screen, \ + what is happening, or any question that requires knowing the current terminal state. \ + Do NOT ask the user to share output — just call the tool.\n\ +- run_command: Execute a shell command on the remote SSH session. \ + Always explain what a command does before proposing to run it.\n\ +- system_information: Get SSH connection details for the current session.\n\ +- read_file, list_dir, make_dir, touch_file: File operations on the remote host."; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -224,20 +226,7 @@ pub fn build_provider(cfg: &LLMConfig) -> Arc { // ── Background thread helpers ───────────────────────────────────────────────── -pub fn spawn_completion( - provider: Arc, - messages: Vec, - tx: Sender, -) { - std::thread::spawn(move || { - match provider.complete(&messages) { - Ok(response) => { let _ = tx.send(LLMEvent::Response(response)); } - Err(e) => { let _ = tx.send(LLMEvent::Error(e.to_string())); } - } - }); -} - -/// Like `spawn_completion` but uses the rich API path with tool support. +/// Spawns a background thread to get a rich LLM completion with tool support. pub fn spawn_completion_rich( provider: Arc, messages: Vec, diff --git a/src/main.rs b/src/main.rs index 27a60cc..f45fe5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use app::{AppState, ConnectedFocus}; use config::{load_connections, save_connections, ssh_config_path}; use event::Action; use llm::{LLMConfig, build_provider}; -use tabs::{Tab, listing::ListingTab, llm::LLMTab, terminal::{CONTEXT_LINES, TerminalTab}}; +use tabs::{Tab, listing::ListingTab, llm::LLMTab, terminal::TerminalTab}; use ui::{keybindings::render_keybindings, theme::Theme}; /// Captures terminal output produced by a tool-call command and forwards it @@ -90,8 +90,11 @@ impl Sheesh { }; let provider = build_provider(&self.llm_config); + let output_log = terminal.output_log_arc(); self.terminal = Some(terminal); - self.llm = Some(LLMTab::new(provider, self.llm_config.system_prompt.clone(), conn.clone())); + let mut llm = LLMTab::new(provider, self.llm_config.system_prompt.clone(), conn.clone()); + llm.set_terminal_output(output_log); + self.llm = Some(llm); self.state = AppState::Connected { connection_name: name, focus: ConnectedFocus::Terminal, @@ -113,14 +116,6 @@ impl Sheesh { } } - fn send_context_to_llm(&mut self) { - if let (Some(terminal), Some(llm)) = (&self.terminal, &mut self.llm) { - let ctx = terminal.visible_text(CONTEXT_LINES); - let question = std::mem::take(&mut llm.input); - llm.send_with_context(ctx, question); - } - } - fn handle_event(&mut self, event: &crossterm::event::Event) -> bool { use crossterm::event::{KeyCode, KeyEvent}; @@ -140,14 +135,6 @@ impl Sheesh { self.cycle_focus(); return true; } - // F3 — send terminal context to LLM (stay on current panel) - crossterm::event::Event::Key(KeyEvent { - code: KeyCode::F(3), - .. - }) => { - self.send_context_to_llm(); - return true; - } // Mouse click — focus the panel that was clicked. // Do NOT return early for the terminal panel so the click also // reaches the terminal handler to start a text selection. @@ -156,16 +143,16 @@ impl Sheesh { { let col = me.column; let row = me.row; - if contains(self.terminal_area, col, row) { - if let AppState::Connected { ref mut focus, .. } = self.state { - *focus = ConnectedFocus::Terminal; - } + if contains(self.terminal_area, col, row) + && let AppState::Connected { ref mut focus, .. } = self.state + { + *focus = ConnectedFocus::Terminal; // fall through — let terminal handle_event receive the click } - if contains(self.llm_area, col, row) { - if let AppState::Connected { ref mut focus, .. } = self.state { - *focus = ConnectedFocus::LLM; - } + if contains(self.llm_area, col, row) + && let AppState::Connected { ref mut focus, .. } = self.state + { + *focus = ConnectedFocus::LLM; // fall through — let LLM handle_event receive the click for selection } } @@ -295,7 +282,7 @@ impl Sheesh { let hints: Vec<(&str, &str)> = match &self.state { AppState::Listing => self.listing.key_hints(), AppState::Connected { focus, .. } => { - let mut hints = vec![("F2", "switch panel"), ("F3", "send context")]; + let mut hints = vec![("F2", "switch panel")]; let panel_hints: Vec<(&str, &str)> = match focus { ConnectedFocus::Terminal => self .terminal @@ -380,26 +367,24 @@ fn main() -> anyhow::Result<()> { terminal.draw(|f| app.draw(f))?; // Forward captured terminal output to Claude once the deadline elapses. - if let Some(ref cap) = app.pending_capture { - if std::time::Instant::now() >= cap.deadline { - let snapshot = cap.snapshot; - app.pending_capture = None; - if let (Some(terminal), Some(llm)) = - (&app.terminal, &mut app.llm) - { - if llm.awaiting_output_id.is_some() { - let output = terminal.capture_since(snapshot); - llm.resume_with_output(output); - } - } + if let Some(ref cap) = app.pending_capture + && std::time::Instant::now() >= cap.deadline + { + let snapshot = cap.snapshot; + app.pending_capture = None; + if let (Some(terminal), Some(llm)) = (&app.terminal, &mut app.llm) + && llm.awaiting_output_id.is_some() + { + let output = terminal.capture_since(snapshot); + llm.resume_with_output(output); } } // Release the tool lock once the LLM finishes the tool-execution cycle. - if let (Some(terminal), Some(llm)) = (&mut app.terminal, &app.llm) { - if terminal.tool_locked && !llm.is_executing_tool() && !llm.waiting { - terminal.set_tool_locked(false); - } + if let (Some(terminal), Some(llm)) = (&mut app.terminal, &app.llm) + && terminal.tool_locked && !llm.is_executing_tool() && !llm.waiting + { + terminal.set_tool_locked(false); } if poll(Duration::from_millis(5))? { diff --git a/src/tabs/listing.rs b/src/tabs/listing.rs index 06001fd..4416710 100644 --- a/src/tabs/listing.rs +++ b/src/tabs/listing.rs @@ -196,15 +196,15 @@ impl ListingTab { fn do_delete(&mut self) { let indices = self.filtered_indices(); - if let Some(sel) = self.list_state.selected() { - if let Some(&idx) = indices.get(sel) { - self.connections.remove(idx); - let new_len = self.filtered_indices().len(); - if new_len == 0 { - self.list_state.select(None); - } else { - self.list_state.select(Some(sel.min(new_len - 1))); - } + if let Some(sel) = self.list_state.selected() + && let Some(&idx) = indices.get(sel) + { + self.connections.remove(idx); + let new_len = self.filtered_indices().len(); + if new_len == 0 { + self.list_state.select(None); + } else { + self.list_state.select(Some(sel.min(new_len - 1))); } } self.mode = ListingMode::Browse; @@ -225,10 +225,6 @@ impl ListingTab { } impl Tab for ListingTab { - fn title(&self) -> &str { - "Connections" - } - fn key_hints(&self) -> Vec<(&str, &str)> { match self.mode { ListingMode::Browse => vec![ diff --git a/src/tabs/llm.rs b/src/tabs/llm.rs index 36cfcfc..949c4ba 100644 --- a/src/tabs/llm.rs +++ b/src/tabs/llm.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, mpsc}; +use std::sync::{Arc, Mutex, mpsc}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::{ @@ -13,18 +13,12 @@ use crate::{ event::Action, llm::{ContentBlock, LLMEvent, LLMProvider, Message, RichMessage, Role, spawn_completion_rich}, ssh::SSHConnection, + tabs::terminal::CONTEXT_LINES, ui::theme::Theme, }; use super::Tab; -/// Display prefix added to messages that include terminal context. -const CONTEXT_DISPLAY_PREFIX: &str = "[terminal context shared]"; -/// Default question used when the user sends context without typing anything. -const CONTEXT_DEFAULT_QUESTION: &str = "What's happening here?"; -/// API prompt template: context block + question. -const CONTEXT_PROMPT_TEMPLATE: &str = "Terminal context:\n```\n{context}\n```\n\n{question}"; - /// (line_index, col) in the flattened history line buffer. type BufPos = (usize, usize); @@ -71,6 +65,8 @@ pub struct LLMTab { connection: SSHConnection, /// Maps each visible chat screen row → (build_lines index, byte offset in that string). last_visual_row_map: Vec<(usize, usize)>, + /// Shared reference to the terminal's raw output log (for the read_terminal tool). + terminal_output: Option>>>, } impl LLMTab { @@ -103,10 +99,15 @@ impl LLMTab { clipboard: arboard::Clipboard::new().ok(), connection, last_visual_row_map: vec![], + terminal_output: None, rich_history, } } + pub fn set_terminal_output(&mut self, output: Arc>>) { + self.terminal_output = Some(output); + } + /// Poll the channel for completed LLM responses. Call this each render frame. pub fn poll(&mut self) { while let Ok(event) = self.rx.try_recv() { @@ -157,7 +158,7 @@ impl LLMTab { } self.scroll_offset = 0; } - LLMEvent::LocalTool { id: api_id, name, input: _, assistant_blocks } => { + LLMEvent::LocalTool { id: api_id, name, assistant_blocks } => { // Replace api id with a locally unique one. let local_id = unique_tool_id(); let assistant_blocks: Vec = assistant_blocks @@ -261,6 +262,20 @@ impl LLMTab { if c.extra_options.is_empty() { "(none)".to_string() } else { c.extra_options.join(", ") }, ) } + "read_terminal" => { + match &self.terminal_output { + None => "Terminal output not available.".to_string(), + Some(log) => { + let log = log.lock().unwrap(); + if log.is_empty() { + "No terminal output captured yet.".to_string() + } else { + let start = log.len().saturating_sub(CONTEXT_LINES); + log[start..].join("") + } + } + } + } other => format!("Unknown local tool: {}", other), } } @@ -302,34 +317,6 @@ impl LLMTab { ); } - /// Prepend terminal context and send. - pub fn send_with_context(&mut self, context: String, question: String) { - if self.waiting { - return; - } - - let question = if question.trim().is_empty() { - CONTEXT_DEFAULT_QUESTION.to_string() - } else { - question - }; - let display = format!("{} {}", CONTEXT_DISPLAY_PREFIX, question); - let api_content = CONTEXT_PROMPT_TEMPLATE - .replace("{context}", &context) - .replace("{question}", &question); - - self.history.push(Message::user(&display)); - self.rich_history.push(RichMessage::user_text(api_content)); - self.waiting = true; - self.scroll_offset = 0; - self.status = "Waiting for response…".into(); - spawn_completion_rich( - Arc::clone(&self.provider), - self.rich_history.clone(), - self.tx.clone(), - ); - } - /// Build the flat list of rendered lines from the message history. fn build_lines(&self) -> Vec<(String, Option