feat: terminal_type MCP tool + tab resize fix + close confirmation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m54s

terminal_type MCP tool (19 tools total):
- Fire-and-forget text input to any session
- Optional press_enter flag (default: true)
- No marker wrapping, no output capture
- Use case: AI sends messages/commands without needing output back

Tab resize fix:
- Double requestAnimationFrame before fitAddon.fit() on tab switch
- Container has real dimensions after browser layout pass
- Also sends ssh_resize/pty_resize to backend with correct cols/rows
- Fixes 6-8 char wide terminals after switching tabs

Close confirmation:
- beforeunload event shows browser "Leave page?" dialog
- Only triggers if sessions are active
- Synchronous — cannot hang the close like onCloseRequested did

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-26 16:21:53 -04:00
parent 037c76384b
commit d78cafba93
5 changed files with 73 additions and 9 deletions

View File

@ -83,6 +83,19 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
id, id,
result: Some(serde_json::json!({ result: Some(serde_json::json!({
"tools": [ "tools": [
{
"name": "terminal_type",
"description": "Type text into a terminal session (like a human typing). Optionally presses Enter after. Use this to send messages or commands without output capture.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": { "type": "string", "description": "The session ID to type into" },
"text": { "type": "string", "description": "The text to type" },
"press_enter": { "type": "boolean", "description": "Whether to press Enter after typing (default: true)" }
},
"required": ["session_id", "text"]
}
},
{ {
"name": "terminal_read", "name": "terminal_read",
"description": "Read recent terminal output from an active SSH or PTY session (ANSI codes stripped)", "description": "Read recent terminal output from an active SSH or PTY session (ANSI codes stripped)",
@ -251,6 +264,7 @@ fn call_wraith(port: u16, endpoint: &str, body: Value) -> Result<Value, String>
fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> JsonRpcResponse { fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> JsonRpcResponse {
let result = match tool_name { let result = match tool_name {
"list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})), "list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})),
"terminal_type" => call_wraith(port, "/mcp/terminal/type", args.clone()),
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()), "terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()), "terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()),
"sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()), "sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()),

View File

@ -52,6 +52,13 @@ struct SftpWriteRequest {
content: String, content: String,
} }
#[derive(Deserialize)]
struct TerminalTypeRequest {
session_id: String,
text: String,
press_enter: Option<bool>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TerminalExecuteRequest { struct TerminalExecuteRequest {
session_id: String, session_id: String,
@ -154,6 +161,22 @@ async fn handle_screenshot(
} }
} }
async fn handle_terminal_type(
AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<TerminalTypeRequest>,
) -> Json<McpResponse<String>> {
let text = if req.press_enter.unwrap_or(true) {
format!("{}\n", req.text)
} else {
req.text.clone()
};
match state.ssh.write(&req.session_id, text.as_bytes()).await {
Ok(()) => ok_response("sent".to_string()),
Err(e) => err_response(e),
}
}
async fn handle_terminal_read( async fn handle_terminal_read(
AxumState(state): AxumState<Arc<McpServerState>>, AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<TerminalReadRequest>, Json(req): Json<TerminalReadRequest>,
@ -350,6 +373,7 @@ pub async fn start_mcp_server(
let app = Router::new() let app = Router::new()
.route("/mcp/sessions", post(handle_list_sessions)) .route("/mcp/sessions", post(handle_list_sessions))
.route("/mcp/terminal/type", post(handle_terminal_type))
.route("/mcp/terminal/read", post(handle_terminal_read)) .route("/mcp/terminal/read", post(handle_terminal_read))
.route("/mcp/terminal/execute", post(handle_terminal_execute)) .route("/mcp/terminal/execute", post(handle_terminal_execute))
.route("/mcp/screenshot", post(handle_screenshot)) .route("/mcp/screenshot", post(handle_screenshot))

View File

@ -50,10 +50,17 @@ watch(
() => props.isActive, () => props.isActive,
(active) => { (active) => {
if (active) { if (active) {
setTimeout(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => {
fit(); fit();
terminal.focus(); terminal.focus();
}, 0); invoke("pty_resize", {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
});
});
} }
}, },
); );

View File

@ -60,6 +60,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, watch } from "vue"; import { ref, nextTick, onMounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal"; import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import MonitorBar from "@/components/terminal/MonitorBar.vue"; import MonitorBar from "@/components/terminal/MonitorBar.vue";
@ -149,15 +150,25 @@ onMounted(() => {
}, 50); }, 50);
}); });
// Re-fit and focus terminal when switching back to this tab // Re-fit and focus terminal when switching back to this tab.
// Must wait for the container to have real dimensions after becoming visible.
watch( watch(
() => props.isActive, () => props.isActive,
(active) => { (active) => {
if (active) { if (active) {
setTimeout(() => { // Double rAF ensures the container has been laid out by the browser
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fit(); fit();
terminal.focus(); terminal.focus();
}, 0); // Also notify the backend of the correct size
invoke("ssh_resize", {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
});
});
} }
}, },
); );

View File

@ -496,6 +496,14 @@ function handleKeydown(event: KeyboardEvent): void {
onMounted(async () => { onMounted(async () => {
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
// Confirm before closing if sessions are active (synchronous won't hang)
window.addEventListener("beforeunload", (e) => {
if (sessionStore.sessions.length > 0) {
e.preventDefault();
}
});
await connectionStore.loadAll(); await connectionStore.loadAll();
// Restore workspace reconnect saved tabs (non-blocking, non-fatal) // Restore workspace reconnect saved tabs (non-blocking, non-fatal)