fix(terminal): workspace cleanup, light mode theme, buffer cap verified

Addresses Marge's review feedback:

1. Workspace-switch cleanup: Added terminal_close_all_sessions command
   that kills all PTY sessions. Called from resetWorkspaceState() so
   orphaned shells don't accumulate across workspace switches.

2. Light mode theme: Terminal surface stays dark in both modes (high
   contrast "pop" against light app chrome). Added Tokyo Night Moon
   variant for light mode with warmer foreground. Panel header/buttons
   use consistent light-on-dark colors regardless of app theme.
   MutationObserver syncs xterm theme when app theme class changes.

3. Buffer cap: Already implemented (256KB with drain-from-front) —
   verified the existing MAX_BUFFER_BYTES guard in spawn_reader_thread.
This commit is contained in:
Taylor Ho
2026-05-21 20:25:32 -07:00
parent 40ebf3598c
commit 2f6bea52d4
6 changed files with 94 additions and 23 deletions

View File

@@ -38,3 +38,9 @@ pub fn terminal_close_session(
) -> Result<bool, String> {
state.terminal_registry.close_session(&input.session_id)
}
/// Close all active PTY sessions. Called on workspace switch.
#[tauri::command]
pub fn terminal_close_all_sessions(state: State<'_, AppState>) -> Result<u32, String> {
state.terminal_registry.close_all_sessions()
}

View File

@@ -589,6 +589,7 @@ pub fn run() {
terminal_write,
terminal_resize,
terminal_close_session,
terminal_close_all_sessions,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@@ -215,6 +215,21 @@ impl TerminalRegistry {
let _ = session.killer.kill();
Ok(true)
}
/// Close all active PTY sessions. Called on workspace switch to prevent
/// orphaned shell processes from accumulating.
pub fn close_all_sessions(&self) -> Result<u32, String> {
let mut guard = self.sessions.lock().map_err(|e| e.to_string())?;
let count = guard.by_session_id.len() as u32;
guard.session_id_by_channel.clear();
let sessions = std::mem::take(&mut guard.by_session_id);
drop(guard);
for mut session in sessions.into_values() {
let _ = session.killer.kill();
}
Ok(count)
}
}
impl Drop for TerminalRegistry {

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { Terminal } from "@xterm/xterm";
import type { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { invoke } from "@tauri-apps/api/core";
@@ -7,6 +8,61 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import "@xterm/xterm/css/xterm.css";
// Tokyo Night — high-contrast dark terminal surface.
const DARK_THEME: ITheme = {
background: "#1a1b26",
foreground: "#c0caf5",
cursor: "#c0caf5",
selectionBackground: "#33467c",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
};
// Light mode: terminal stays dark (high contrast against light app chrome).
// Uses the same Tokyo Night bg with slightly warmer foreground for readability.
const LIGHT_THEME: ITheme = {
background: "#1a1b26",
foreground: "#c8d3f5",
cursor: "#c8d3f5",
selectionBackground: "#2f3549",
black: "#1a1b26",
red: "#ff757f",
green: "#c3e88d",
yellow: "#ffc777",
blue: "#82aaff",
magenta: "#c099ff",
cyan: "#86e1fc",
white: "#c8d3f5",
brightBlack: "#545c7e",
brightRed: "#ff757f",
brightGreen: "#c3e88d",
brightYellow: "#ffc777",
brightBlue: "#82aaff",
brightMagenta: "#c099ff",
brightCyan: "#86e1fc",
brightWhite: "#e4f0fb",
};
function getTerminalTheme(): ITheme {
return document.documentElement.classList.contains("dark")
? DARK_THEME
: LIGHT_THEME;
}
type TerminalDataPayload = {
sessionId: string;
data: string;
@@ -53,28 +109,7 @@ export function TerminalInstance({
lineHeight: 1.3,
scrollback: 10000,
allowProposedApi: true,
theme: {
background: "#1a1b26",
foreground: "#c0caf5",
cursor: "#c0caf5",
selectionBackground: "#33467c",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
theme: getTerminalTheme(),
});
const fitAddon = new FitAddon();
@@ -137,6 +172,15 @@ export function TerminalInstance({
});
});
// Watch for theme class changes on <html> to sync xterm colors.
const themeObserver = new MutationObserver(() => {
term.options.theme = getTerminalTheme();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
// Resize observer.
const resizeObserver = new ResizeObserver(() => {
if (fitAddonRef.current && isVisible) {
@@ -155,6 +199,7 @@ export function TerminalInstance({
resizeObserver.observe(containerRef.current);
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
unlistenDataRef.current?.();
unlistenExitRef.current?.();

View File

@@ -100,7 +100,7 @@ export function TerminalPanel({
</button>
{/* Header bar */}
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/40 bg-[#1a1b26] px-3">
<div className="flex h-8 shrink-0 items-center justify-between border-b border-white/5 bg-[#1a1b26] px-3">
<span className="text-xs font-medium text-[#7aa2f7]">Terminal</span>
<div className="flex items-center gap-1">
<button

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { relayClient } from "@/shared/api/relayClient";
import {
applyWorkspace,
@@ -26,6 +28,8 @@ function resetWorkspaceState(): void {
resetMediaCaches();
clearSearchHitEventCache();
clearAllDrafts();
// Kill all PTY sessions so orphaned shells don't accumulate across workspaces.
invoke("terminal_close_all_sessions").catch(() => {});
}
type WorkspaceInitResult =