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,
|
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()),
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -50,10 +50,17 @@ watch(
|
|||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(active) => {
|
(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
fit();
|
requestAnimationFrame(() => {
|
||||||
terminal.focus();
|
fit();
|
||||||
}, 0);
|
terminal.focus();
|
||||||
|
invoke("pty_resize", {
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows,
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
fit();
|
requestAnimationFrame(() => {
|
||||||
terminal.focus();
|
requestAnimationFrame(() => {
|
||||||
}, 0);
|
fit();
|
||||||
|
terminal.focus();
|
||||||
|
// 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 () => {
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user