feat: terminal_type MCP tool + tab resize fix + close confirmation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m54s
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:
parent
037c76384b
commit
d78cafba93
@ -83,6 +83,19 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"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",
|
||||
"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 {
|
||||
let result = match tool_name {
|
||||
"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_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()),
|
||||
"sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()),
|
||||
|
||||
@ -52,6 +52,13 @@ struct SftpWriteRequest {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerminalTypeRequest {
|
||||
session_id: String,
|
||||
text: String,
|
||||
press_enter: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerminalExecuteRequest {
|
||||
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(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<TerminalReadRequest>,
|
||||
@ -350,6 +373,7 @@ pub async fn start_mcp_server(
|
||||
|
||||
let app = Router::new()
|
||||
.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/execute", post(handle_terminal_execute))
|
||||
.route("/mcp/screenshot", post(handle_screenshot))
|
||||
|
||||
@ -50,10 +50,17 @@ watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
}, 0);
|
||||
invoke("pty_resize", {
|
||||
sessionId: props.sessionId,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import MonitorBar from "@/components/terminal/MonitorBar.vue";
|
||||
@ -149,15 +150,25 @@ onMounted(() => {
|
||||
}, 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(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
setTimeout(() => {
|
||||
// Double rAF ensures the container has been laid out by the browser
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
}, 0);
|
||||
// Also notify the backend of the correct size
|
||||
invoke("ssh_resize", {
|
||||
sessionId: props.sessionId,
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -496,6 +496,14 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
|
||||
onMounted(async () => {
|
||||
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();
|
||||
|
||||
// Restore workspace — reconnect saved tabs (non-blocking, non-fatal)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user