feat: local terminal tabs — + button spawns WSL/Git Bash/PowerShell/CMD
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 16s

The + button in the tab bar now shows a dropdown of detected local
shells. Clicking one opens a full-size PTY terminal in the main
content area as a proper tab — not the copilot sidebar.

- New "local" protocol type in Session interface
- LocalTerminalView component uses useTerminal(id, 'pty')
- SessionContainer renders local sessions alongside SSH/RDP
- TabBadge shows purple dot for local sessions
- Shell detection includes WSL (wsl.exe) on Windows
- closeSession handles PTY disconnect for local tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 23:46:09 -04:00
parent 2ad6da43eb
commit 4532f3beb6
6 changed files with 164 additions and 12 deletions

View File

@ -70,6 +70,10 @@ impl PtyService {
break; break;
} }
} }
// WSL (Windows Subsystem for Linux)
if std::path::Path::new(r"C:\Windows\System32\wsl.exe").exists() {
shells.push(ShellInfo { name: "WSL".to_string(), path: r"C:\Windows\System32\wsl.exe".to_string() });
}
} }
shells shells

View File

@ -14,6 +14,19 @@
/> />
</div> </div>
<!-- Local PTY views v-show keeps xterm alive across tab switches -->
<div
v-for="session in localSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<LocalTerminalView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- RDP views toolbar + canvas, kept alive via v-show --> <!-- RDP views toolbar + canvas, kept alive via v-show -->
<div <div
v-for="session in rdpSessions" v-for="session in rdpSessions"
@ -60,6 +73,7 @@ import { computed, ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useSessionStore } from "@/stores/session.store"; import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue"; import TerminalView from "@/components/terminal/TerminalView.vue";
import LocalTerminalView from "@/components/terminal/LocalTerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue"; import RdpView from "@/components/rdp/RdpView.vue";
import RdpToolbar from "@/components/rdp/RdpToolbar.vue"; import RdpToolbar from "@/components/rdp/RdpToolbar.vue";
import { ScancodeMap } from "@/composables/useRdp"; import { ScancodeMap } from "@/composables/useRdp";
@ -81,6 +95,10 @@ const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"), sessionStore.sessions.filter((s) => s.protocol === "ssh"),
); );
const localSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "local"),
);
const rdpSessions = computed(() => const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"), sessionStore.sessions.filter((s) => s.protocol === "rdp"),
); );

View File

@ -31,7 +31,7 @@ import { computed } from "vue";
const props = defineProps<{ const props = defineProps<{
/** Connection protocol — drives the protocol-dot colour. */ /** Connection protocol — drives the protocol-dot colour. */
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp" | "local";
/** Username from the active session (if known). */ /** Username from the active session (if known). */
username?: string; username?: string;
/** Raw tags from the connection record. */ /** Raw tags from the connection record. */
@ -40,9 +40,10 @@ const props = defineProps<{
status?: "connected" | "disconnected"; status?: "connected" | "disconnected";
}>(); }>();
/** Green=connected SSH, blue=connected RDP, red=disconnected. */ /** Green=connected SSH, blue=connected RDP, purple=local, red=disconnected. */
const protocolDotClass = computed(() => { const protocolDotClass = computed(() => {
if (props.status === "disconnected") return "bg-[#f85149]"; if (props.status === "disconnected") return "bg-[#f85149]";
if (props.protocol === "local") return "bg-[#bc8cff]";
return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]"; return props.protocol === "ssh" ? "bg-[#3fb950]" : "bg-[#1f6feb]";
}); });

View File

@ -42,18 +42,39 @@
</div> </div>
</div> </div>
<!-- New tab button --> <!-- New tab button with shell dropdown -->
<button <div class="relative shrink-0">
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0" <button
title="New session" class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
> title="New local terminal"
+ @click="toggleShellMenu"
</button> @blur="closeShellMenuDeferred"
>
+
</button>
<div
v-if="shellMenuOpen"
class="absolute top-full right-0 mt-0.5 w-48 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
v-for="shell in availableShells"
:key="shell.path"
class="w-full flex items-center gap-2 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="spawnShell(shell)"
>
{{ shell.name }}
</button>
<div v-if="availableShells.length === 0" class="px-4 py-2 text-xs text-[var(--wraith-text-muted)]">
No shells found
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useSessionStore, type Session } from "@/stores/session.store"; import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store"; import { useConnectionStore } from "@/stores/connection.store";
import TabBadge from "@/components/session/TabBadge.vue"; import TabBadge from "@/components/session/TabBadge.vue";
@ -61,6 +82,32 @@ import TabBadge from "@/components/session/TabBadge.vue";
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
// Shell menu for + button
interface ShellInfo { name: string; path: string; }
const availableShells = ref<ShellInfo[]>([]);
const shellMenuOpen = ref(false);
function toggleShellMenu(): void {
shellMenuOpen.value = !shellMenuOpen.value;
}
function closeShellMenuDeferred(): void {
setTimeout(() => { shellMenuOpen.value = false; }, 150);
}
async function spawnShell(shell: ShellInfo): Promise<void> {
shellMenuOpen.value = false;
await sessionStore.spawnLocalTab(shell.name, shell.path);
}
onMounted(async () => {
try {
availableShells.value = await invoke<ShellInfo[]>("list_available_shells");
} catch {
availableShells.value = [];
}
});
// Drag-and-drop tab reordering // Drag-and-drop tab reordering
const draggedIndex = ref(-1); const draggedIndex = ref(-1);
const dragOverIndex = ref(-1); const dragOverIndex = ref(-1);

View File

@ -0,0 +1,51 @@
<template>
<div class="flex flex-col h-full">
<div
ref="containerRef"
class="terminal-container flex-1"
@click="terminal.focus()"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useTerminal } from "@/composables/useTerminal";
import "@/assets/css/terminal.css";
const props = defineProps<{
sessionId: string;
isActive: boolean;
}>();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit } = useTerminal(props.sessionId, "pty");
onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
setTimeout(() => {
fit();
terminal.focus();
invoke("pty_resize", {
sessionId: props.sessionId,
cols: terminal.cols,
rows: terminal.rows,
}).catch(() => {});
}, 50);
});
watch(
() => props.isActive,
(active) => {
if (active) {
setTimeout(() => {
fit();
terminal.focus();
}, 0);
}
},
);
</script>

View File

@ -9,7 +9,7 @@ export interface Session {
id: string; id: string;
connectionId: number; connectionId: number;
name: string; name: string;
protocol: "ssh" | "rdp"; protocol: "ssh" | "rdp" | "local";
active: boolean; active: boolean;
username?: string; username?: string;
status: "connected" | "disconnected"; status: "connected" | "disconnected";
@ -70,7 +70,9 @@ export const useSessionStore = defineStore("session", () => {
// Disconnect the backend session using the protocol-appropriate command // Disconnect the backend session using the protocol-appropriate command
try { try {
if (session.protocol === "rdp") { if (session.protocol === "local") {
await invoke("disconnect_pty", { sessionId: session.id });
} else if (session.protocol === "rdp") {
await invoke("disconnect_rdp", { sessionId: session.id }); await invoke("disconnect_rdp", { sessionId: session.id });
} else { } else {
await invoke("disconnect_session", { sessionId: session.id }); await invoke("disconnect_session", { sessionId: session.id });
@ -315,6 +317,34 @@ export const useSessionStore = defineStore("session", () => {
} }
} }
/** Spawn a local shell as a full-size tab. */
async function spawnLocalTab(shellName: string, shellPath: string): Promise<void> {
try {
const sessionId = await invoke<string>("spawn_local_shell", {
shellPath,
cols: 120,
rows: 40,
});
sessions.value.push({
id: sessionId,
connectionId: 0,
name: shellName,
protocol: "local",
active: true,
status: "connected",
});
// Listen for PTY close
listen(`pty:close:${sessionId}`, () => markDisconnected(sessionId));
activeSessionId.value = sessionId;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
alert(`Failed to spawn local shell: ${msg}`);
}
}
/** Apply a theme to all active terminal instances. */ /** Apply a theme to all active terminal instances. */
function setTheme(theme: ThemeDefinition): void { function setTheme(theme: ThemeDefinition): void {
activeTheme.value = theme; activeTheme.value = theme;
@ -344,6 +374,7 @@ export const useSessionStore = defineStore("session", () => {
activateSession, activateSession,
closeSession, closeSession,
connect, connect,
spawnLocalTab,
moveSession, moveSession,
setTheme, setTheme,
setTerminalDimensions, setTerminalDimensions,