feat: enhance terminal functionality and settings

- Added new endpoints for terminal settings: GET and PUT /settings to retrieve and update terminal configurations.
- Implemented session limit checks during session creation, returning a 429 status when the limit is reached.
- Introduced a new TerminalSection in settings view for customizing terminal appearance and behavior, including font family, default font size, line height, and screen reader mode.
- Added support for new terminal features such as search functionality and improved error handling with a TerminalErrorBoundary component.
- Updated terminal layout persistence to include session IDs for reconnection and enhanced terminal state management.
- Introduced new keyboard shortcuts for terminal actions, including creating new terminal tabs.
- Enhanced UI with scrollbar theming for terminal components.
This commit is contained in:
SuperComboGamer
2025-12-20 22:56:25 -05:00
parent 8fcc6cb4db
commit 195b98e688
24 changed files with 2729 additions and 149 deletions

View File

@@ -21,6 +21,10 @@ import {
} from "./routes/sessions.js";
import { createSessionDeleteHandler } from "./routes/session-delete.js";
import { createSessionResizeHandler } from "./routes/session-resize.js";
import {
createSettingsGetHandler,
createSettingsUpdateHandler,
} from "./routes/settings.js";
// Re-export for use in main index.ts
export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired };
@@ -39,6 +43,8 @@ export function createTerminalRoutes(): Router {
router.post("/sessions", createSessionsCreateHandler());
router.delete("/sessions/:id", createSessionDeleteHandler());
router.post("/sessions/:id/resize", createSessionResizeHandler());
router.get("/settings", createSettingsGetHandler());
router.put("/settings", createSettingsUpdateHandler());
return router;
}

View File

@@ -34,6 +34,21 @@ export function createSessionsCreateHandler() {
shell,
});
// Check if session creation was refused due to limit
if (!session) {
const maxSessions = terminalService.getMaxSessions();
const currentSessions = terminalService.getSessionCount();
logger.warn(`Session limit reached: ${currentSessions}/${maxSessions}`);
res.status(429).json({
success: false,
error: "Maximum terminal sessions reached",
details: `Server limit is ${maxSessions} concurrent sessions. Please close unused terminals.`,
currentSessions,
maxSessions,
});
return;
}
res.json({
success: true,
data: {

View File

@@ -0,0 +1,55 @@
/**
* GET/PUT /settings endpoint - Get/Update terminal settings
*/
import type { Request, Response } from "express";
import { getTerminalService } from "../../../services/terminal-service.js";
import { getErrorMessage, logError } from "../common.js";
export function createSettingsGetHandler() {
return (_req: Request, res: Response): void => {
const terminalService = getTerminalService();
res.json({
success: true,
data: {
maxSessions: terminalService.getMaxSessions(),
currentSessions: terminalService.getSessionCount(),
},
});
};
}
export function createSettingsUpdateHandler() {
return (req: Request, res: Response): void => {
try {
const terminalService = getTerminalService();
const { maxSessions } = req.body;
if (typeof maxSessions === "number") {
if (maxSessions < 1 || maxSessions > 500) {
res.status(400).json({
success: false,
error: "maxSessions must be between 1 and 500",
});
return;
}
terminalService.setMaxSessions(maxSessions);
}
res.json({
success: true,
data: {
maxSessions: terminalService.getMaxSessions(),
currentSessions: terminalService.getSessionCount(),
},
});
} catch (error) {
logError(error, "Update terminal settings failed");
res.status(500).json({
success: false,
error: "Failed to update terminal settings",
details: getErrorMessage(error),
});
}
};
}

View File

@@ -13,9 +13,16 @@ import * as fs from "fs";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Maximum number of concurrent terminal sessions
// Can be overridden via TERMINAL_MAX_SESSIONS environment variable
// Default set to 1000 - effectively unlimited for most use cases
let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || "1000", 10);
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
// Using 4ms for responsive input feedback while still preventing flood
// Note: 16ms caused perceived input lag, especially with backspace
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
export interface TerminalSession {
id: string;
@@ -176,9 +183,40 @@ export class TerminalService extends EventEmitter {
}
/**
* Create a new terminal session
* Get current session count
*/
createSession(options: TerminalOptions = {}): TerminalSession {
getSessionCount(): number {
return this.sessions.size;
}
/**
* Get maximum allowed sessions
*/
getMaxSessions(): number {
return maxSessions;
}
/**
* Set maximum allowed sessions (can be called dynamically)
*/
setMaxSessions(limit: number): void {
if (limit >= 1 && limit <= 500) {
maxSessions = limit;
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
}
}
/**
* Create a new terminal session
* Returns null if the maximum session limit has been reached
*/
createSession(options: TerminalOptions = {}): TerminalSession | null {
// Check session limit
if (this.sessions.size >= maxSessions) {
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
return null;
}
const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
@@ -188,11 +226,15 @@ export class TerminalService extends EventEmitter {
const cwd = this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
// These settings ensure consistent terminal behavior across platforms
const env: Record<string, string> = {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
TERM_PROGRAM: "automaker-terminal",
// Ensure proper locale for character handling
LANG: process.env.LANG || "en_US.UTF-8",
LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8",
...options.env,
};
@@ -350,12 +392,16 @@ export class TerminalService extends EventEmitter {
clearTimeout(session.resizeDebounceTimeout);
session.resizeDebounceTimeout = null;
}
session.pty.kill();
// Use SIGKILL for forceful termination - shell processes may ignore SIGTERM/SIGHUP
// This ensures the PTY process is actually killed, especially on WSL
session.pty.kill("SIGKILL");
this.sessions.delete(sessionId);
console.log(`[Terminal] Session ${sessionId} killed`);
return true;
} catch (error) {
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
// Still try to remove from map even if kill fails
this.sessions.delete(sessionId);
return false;
}
}

View File

@@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config";
import js from "@eslint/js";
import ts from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import globals from "globals";
const eslintConfig = defineConfig([
js.configs.recommended,
@@ -13,6 +14,10 @@ const eslintConfig = defineConfig([
ecmaVersion: "latest",
sourceType: "module",
},
globals: {
...globals.browser,
...globals.es2021,
},
},
plugins: {
"@typescript-eslint": ts,

View File

@@ -60,6 +60,8 @@
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",

View File

@@ -103,6 +103,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
splitTerminalRight: "Split Right",
splitTerminalDown: "Split Down",
closeTerminal: "Close Terminal",
newTerminalTab: "New Tab",
};
// Categorize shortcuts for color coding
@@ -127,6 +128,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
splitTerminalRight: "action",
splitTerminalDown: "action",
closeTerminal: "action",
newTerminalTab: "action",
};
// Category colors

View File

@@ -12,6 +12,7 @@ import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
import { TerminalSection } from "./settings-view/terminal/terminal-section";
import { AudioSection } from "./settings-view/audio/audio-section";
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
@@ -108,6 +109,8 @@ export function SettingsView() {
onThemeChange={handleSetTheme}
/>
);
case "terminal":
return <TerminalSection />;
case "keyboard":
return (
<KeyboardShortcutsSection

View File

@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import {
Key,
Terminal,
SquareTerminal,
Palette,
Settings2,
Volume2,
@@ -23,6 +24,7 @@ export const NAV_ITEMS: NavigationItem[] = [
{ id: "claude", label: "Claude", icon: Terminal },
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "terminal", label: "Terminal", icon: SquareTerminal },
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
{ id: "audio", label: "Audio", icon: Volume2 },
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },

View File

@@ -5,6 +5,7 @@ export type SettingsViewId =
| "claude"
| "ai-enhancement"
| "appearance"
| "terminal"
| "keyboard"
| "audio"
| "defaults"

View File

@@ -0,0 +1,175 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import { SquareTerminal } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
import { toast } from "sonner";
import { TERMINAL_FONT_OPTIONS } from "@/config/terminal-themes";
export function TerminalSection() {
const {
terminalState,
setTerminalDefaultRunScript,
setTerminalScreenReaderMode,
setTerminalFontFamily,
setTerminalScrollbackLines,
setTerminalLineHeight,
setTerminalDefaultFontSize,
} = useAppStore();
const {
defaultRunScript,
screenReaderMode,
fontFamily,
scrollbackLines,
lineHeight,
defaultFontSize,
} = terminalState;
return (
<div
className={cn(
"rounded-2xl overflow-hidden",
"border border-border/50",
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
"shadow-sm shadow-black/5"
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize terminal appearance and behavior. Theme follows your app theme in Appearance settings.
</p>
</div>
<div className="p-6 space-y-6">
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<select
value={fontFamily}
onChange={(e) => {
setTerminalFontFamily(e.target.value);
toast.info("Font family changed", {
description: "Restart terminal for changes to take effect",
});
}}
className={cn(
"w-full px-3 py-2 rounded-lg",
"bg-accent/30 border border-border/50",
"text-foreground text-sm",
"focus:outline-none focus:ring-2 focus:ring-green-500/30"
)}
>
{TERMINAL_FONT_OPTIONS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info("Line height changed", {
description: "Restart terminal for changes to take effect",
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info("Scrollback changed", {
description: "Restart terminal for changes to take effect",
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
</p>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(checked ? "Screen reader mode enabled" : "Screen reader mode disabled", {
description: "Restart terminal for changes to take effect",
});
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -4,14 +4,14 @@ interface TerminalOutputProps {
export function TerminalOutput({ lines }: TerminalOutputProps) {
return (
<div className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto">
<div className="bg-card border border-border rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto">
{lines.map((line, index) => (
<div key={index} className="text-zinc-400">
<span className="text-green-500">$</span> {line}
<div key={index} className="text-foreground">
<span className="text-primary">$</span> {line}
</div>
))}
{lines.length === 0 && (
<div className="text-zinc-500 italic">Waiting for output...</div>
<div className="text-muted-foreground italic">Waiting for output...</div>
)}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import React, { Component, ErrorInfo } from "react";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Props {
children: React.ReactNode;
sessionId: string;
onRestart?: () => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* BUG-06 fix: Error boundary for terminal components
* Catches xterm.js errors (WebGL context loss, canvas errors, etc.)
* and displays a friendly recovery UI instead of crashing the app.
*/
export class TerminalErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[TerminalErrorBoundary] Terminal crashed:", {
sessionId: this.props.sessionId,
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
handleRestart = () => {
this.setState({ hasError: false, error: null });
this.props.onRestart?.();
};
render() {
if (this.state.hasError) {
return (
<div
className={cn(
"flex flex-col items-center justify-center h-full w-full",
"bg-background/95 backdrop-blur-sm",
"p-6 text-center gap-4"
)}
>
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">
Terminal Crashed
</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{this.state.error?.message?.includes("WebGL")
? "WebGL context was lost. This can happen with GPU driver issues."
: "An unexpected error occurred in the terminal."}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={this.handleRestart}
className="gap-2"
>
<RefreshCw className="w-4 h-4" />
Restart Terminal
</Button>
{this.state.error && (
<details className="text-xs text-muted-foreground max-w-md">
<summary className="cursor-pointer hover:text-foreground">
Technical details
</summary>
<pre className="mt-2 p-2 bg-muted/50 rounded text-left overflow-auto max-h-32">
{this.state.error.message}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,30 @@ export interface TerminalTheme {
brightWhite: string;
}
/**
* Terminal font options for user selection
* These are monospace fonts commonly available on different platforms
*/
export interface TerminalFontOption {
value: string;
label: string;
}
export const TERMINAL_FONT_OPTIONS: TerminalFontOption[] = [
{ value: "Menlo, Monaco, 'Courier New', monospace", label: "Menlo / Monaco" },
{ value: "'SF Mono', Menlo, Monaco, monospace", label: "SF Mono" },
{ value: "'JetBrains Mono', monospace", label: "JetBrains Mono" },
{ value: "'Fira Code', monospace", label: "Fira Code" },
{ value: "'Source Code Pro', monospace", label: "Source Code Pro" },
{ value: "Consolas, 'Courier New', monospace", label: "Consolas" },
{ value: "'Ubuntu Mono', monospace", label: "Ubuntu Mono" },
];
/**
* Default terminal font family (first option)
*/
export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
// Dark theme (default)
const darkTheme: TerminalTheme = {
background: "#0a0a0a",
@@ -206,11 +230,11 @@ const tokyonightTheme: TerminalTheme = {
brightWhite: "#c0caf5",
};
// Solarized Dark theme
// Solarized Dark theme (improved contrast for WCAG compliance)
const solarizedTheme: TerminalTheme = {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
foreground: "#93a1a1", // Changed from #839496 (base0) to #93a1a1 (base1) for better contrast
cursor: "#93a1a1",
cursorAccent: "#002b36",
selectionBackground: "#073642",
black: "#073642",

View File

@@ -294,6 +294,11 @@ export interface ElectronAPI {
deleteFile: (filePath: string) => Promise<WriteResult>;
trashItem?: (filePath: string) => Promise<WriteResult>;
getPath: (name: string) => Promise<string>;
openInEditor?: (
filePath: string,
line?: number,
column?: number
) => Promise<{ success: boolean; error?: string }>;
saveImageToTemp?: (
data: string,
filename: string,

View File

@@ -217,6 +217,44 @@ export class HttpApiClient implements ElectronAPI {
return { success: true };
}
async openInEditor(
filePath: string,
line?: number,
column?: number
): Promise<{ success: boolean; error?: string }> {
// Build VS Code URL scheme: vscode://file/path:line:column
// This works on systems where VS Code's URL handler is registered
// URL encode the path to handle special characters (spaces, brackets, etc.)
// but preserve the leading slash for absolute paths
const encodedPath = filePath.startsWith('/')
? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/')
: filePath.split('/').map(encodeURIComponent).join('/');
let url = `vscode://file${encodedPath}`;
if (line !== undefined && line > 0) {
url += `:${line}`;
if (column !== undefined && column > 0) {
url += `:${column}`;
}
}
try {
// Use anchor click approach which is most reliable for custom URL schemes
// This triggers the browser's URL handler without navigation issues
const anchor = document.createElement("a");
anchor.href = url;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to open in editor",
};
}
}
// File picker - uses server-side file browser dialog
async openDirectory(): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();

View File

@@ -411,6 +411,29 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
}
});
// Open file in editor (VS Code, etc.) with optional line/column
ipcMain.handle("shell:openInEditor", async (_, filePath: string, line?: number, column?: number) => {
try {
// Build VS Code URL scheme: vscode://file/path:line:column
// This works on all platforms where VS Code is installed
// URL encode the path to handle special characters (spaces, brackets, etc.)
const encodedPath = filePath.startsWith('/')
? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/')
: filePath.split('/').map(encodeURIComponent).join('/');
let url = `vscode://file${encodedPath}`;
if (line !== undefined && line > 0) {
url += `:${line}`;
if (column !== undefined && column > 0) {
url += `:${column}`;
}
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// App info
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
return app.getPath(name);

View File

@@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("shell:openExternal", url),
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("shell:openPath", filePath),
openInEditor: (filePath: string, line?: number, column?: number): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("shell:openInEditor", filePath, line, column),
// App info
getPath: (name: string): Promise<string> => ipcRenderer.invoke("app:getPath", name),

View File

@@ -36,6 +36,10 @@ function RootLayoutContent() {
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
// Don't intercept when focused inside a terminal
if (activeElement.closest(".xterm") || activeElement.closest("[data-terminal-container]")) {
return;
}
}
if (event.ctrlKey || event.altKey || event.metaKey) {

View File

@@ -176,6 +176,7 @@ export interface KeyboardShortcuts {
splitTerminalRight: string;
splitTerminalDown: string;
closeTerminal: string;
newTerminalTab: string;
}
// Default keyboard shortcuts
@@ -210,6 +211,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
splitTerminalRight: "Alt+D",
splitTerminalDown: "Alt+S",
closeTerminal: "Alt+W",
newTerminalTab: "Alt+T",
};
export interface ImageAttachment {
@@ -356,6 +358,7 @@ export type TerminalPanelContent =
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number }
| {
type: "split";
id: string; // Stable ID for React key stability
direction: "horizontal" | "vertical";
panels: TerminalPanelContent[];
size?: number;
@@ -374,7 +377,57 @@ export interface TerminalState {
tabs: TerminalTab[];
activeTabId: string | null;
activeSessionId: string | null;
maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none)
defaultFontSize: number; // Default font size for new terminals
defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code)
screenReaderMode: boolean; // Enable screen reader accessibility mode
fontFamily: string; // Font family for terminal text
scrollbackLines: number; // Number of lines to keep in scrollback buffer
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
}
// Persisted terminal layout - now includes sessionIds for reconnection
// Used to restore terminal layout structure when switching projects
export type PersistedTerminalPanel =
| { type: "terminal"; size?: number; fontSize?: number; sessionId?: string }
| {
type: "split";
id?: string; // Optional for backwards compatibility with older persisted layouts
direction: "horizontal" | "vertical";
panels: PersistedTerminalPanel[];
size?: number;
};
// Helper to generate unique split IDs
const generateSplitId = () => `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
export interface PersistedTerminalTab {
id: string;
name: string;
layout: PersistedTerminalPanel | null;
}
export interface PersistedTerminalState {
tabs: PersistedTerminalTab[];
activeTabIndex: number; // Use index instead of ID since IDs are regenerated
defaultFontSize: number;
defaultRunScript?: string; // Optional to support existing persisted data
screenReaderMode?: boolean; // Optional to support existing persisted data
fontFamily?: string; // Optional to support existing persisted data
scrollbackLines?: number; // Optional to support existing persisted data
lineHeight?: number; // Optional to support existing persisted data
}
// Persisted terminal settings - stored globally (not per-project)
export interface PersistedTerminalSettings {
defaultFontSize: number;
defaultRunScript: string;
screenReaderMode: boolean;
fontFamily: string;
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
}
export interface AppState {
@@ -491,6 +544,10 @@ export interface AppState {
// Terminal state
terminalState: TerminalState;
// Terminal layout persistence (per-project, keyed by project path)
// Stores the tab/split structure so it can be restored when switching projects
terminalLayoutByProject: Record<string, PersistedTerminalState>;
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
@@ -720,6 +777,7 @@ export interface AppActions {
// Terminal actions
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
setActiveTerminalSession: (sessionId: string | null) => void;
toggleTerminalMaximized: (sessionId: string) => void;
addTerminalToLayout: (
sessionId: string,
direction?: "horizontal" | "vertical",
@@ -729,16 +787,37 @@ export interface AppActions {
swapTerminals: (sessionId1: string, sessionId2: string) => void;
clearTerminalState: () => void;
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
setTerminalDefaultFontSize: (fontSize: number) => void;
setTerminalDefaultRunScript: (script: string) => void;
setTerminalScreenReaderMode: (enabled: boolean) => void;
setTerminalFontFamily: (fontFamily: string) => void;
setTerminalScrollbackLines: (lines: number) => void;
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
renameTerminalTab: (tabId: string, name: string) => void;
reorderTerminalTabs: (fromTabId: string, toTabId: string) => void;
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
addTerminalToTab: (
sessionId: string,
tabId: string,
direction?: "horizontal" | "vertical"
) => void;
setTerminalTabLayout: (
tabId: string,
layout: TerminalPanelContent,
activeSessionId?: string
) => void;
updateTerminalPanelSizes: (
tabId: string,
panelKeys: string[],
sizes: number[]
) => void;
saveTerminalLayout: (projectPath: string) => void;
getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null;
clearPersistedTerminalLayout: (projectPath: string) => void;
// Spec Creation actions
setSpecCreatingForProject: (projectPath: string | null) => void;
@@ -841,8 +920,16 @@ const initialState: AppState = {
tabs: [],
activeTabId: null,
activeSessionId: null,
maximizedSessionId: null,
defaultFontSize: 14,
defaultRunScript: "",
screenReaderMode: false,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
scrollbackLines: 5000,
lineHeight: 1.0,
maxSessions: 100,
},
terminalLayoutByProject: {},
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
@@ -1732,6 +1819,19 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
toggleTerminalMaximized: (sessionId) => {
const current = get().terminalState;
const newMaximized = current.maximizedSessionId === sessionId ? null : sessionId;
set({
terminalState: {
...current,
maximizedSessionId: newMaximized,
// Also set as active when maximizing
activeSessionId: newMaximized ?? current.activeSessionId,
},
});
},
addTerminalToLayout: (
sessionId,
direction = "horizontal",
@@ -1781,6 +1881,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Found the target - split it
return {
type: "split",
id: generateSplitId(),
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
@@ -1805,6 +1906,7 @@ export const useAppStore = create<AppState & AppActions>()(
if (node.type === "terminal") {
return {
type: "split",
id: generateSplitId(),
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
@@ -1823,6 +1925,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Different direction, wrap in new split
return {
type: "split",
id: generateSplitId(),
direction: targetDirection,
panels: [{ ...node, size: 50 }, newTerminal],
};
@@ -1884,7 +1987,12 @@ export const useAppStore = create<AppState & AppActions>()(
}
if (newPanels.length === 0) return null;
if (newPanels.length === 1) return newPanels[0];
return { ...node, panels: newPanels };
// Normalize sizes to sum to 100%
const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0);
const normalizedPanels = totalSize > 0
? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 }))
: newPanels.map((p) => ({ ...p, size: 100 / newPanels.length }));
return { ...node, panels: normalizedPanels };
};
let newTabs = current.tabs.map((tab) => {
@@ -1948,14 +2056,25 @@ export const useAppStore = create<AppState & AppActions>()(
},
clearTerminalState: () => {
const current = get().terminalState;
set({
terminalState: {
isUnlocked: false,
authToken: null,
// Preserve auth state - user shouldn't need to re-authenticate
isUnlocked: current.isUnlocked,
authToken: current.authToken,
// Clear session-specific state only
tabs: [],
activeTabId: null,
activeSessionId: null,
defaultFontSize: 14,
maximizedSessionId: null,
// Preserve user preferences - these should persist across projects
defaultFontSize: current.defaultFontSize,
defaultRunScript: current.defaultRunScript,
screenReaderMode: current.screenReaderMode,
fontFamily: current.fontFamily,
scrollbackLines: current.scrollbackLines,
lineHeight: current.lineHeight,
maxSessions: current.maxSessions,
},
});
},
@@ -1986,6 +2105,62 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
setTerminalDefaultFontSize: (fontSize) => {
const current = get().terminalState;
const clampedSize = Math.max(8, Math.min(32, fontSize));
set({
terminalState: { ...current, defaultFontSize: clampedSize },
});
},
setTerminalDefaultRunScript: (script) => {
const current = get().terminalState;
set({
terminalState: { ...current, defaultRunScript: script },
});
},
setTerminalScreenReaderMode: (enabled) => {
const current = get().terminalState;
set({
terminalState: { ...current, screenReaderMode: enabled },
});
},
setTerminalFontFamily: (fontFamily) => {
const current = get().terminalState;
set({
terminalState: { ...current, fontFamily },
});
},
setTerminalScrollbackLines: (lines) => {
const current = get().terminalState;
// Clamp to reasonable range: 1000 - 100000 lines
const clampedLines = Math.max(1000, Math.min(100000, lines));
set({
terminalState: { ...current, scrollbackLines: clampedLines },
});
},
setTerminalLineHeight: (lineHeight) => {
const current = get().terminalState;
// Clamp to reasonable range: 1.0 - 2.0
const clampedHeight = Math.max(1.0, Math.min(2.0, lineHeight));
set({
terminalState: { ...current, lineHeight: clampedHeight },
});
},
setTerminalMaxSessions: (maxSessions) => {
const current = get().terminalState;
// Clamp to reasonable range: 1 - 500
const clampedMax = Math.max(1, Math.min(500, maxSessions));
set({
terminalState: { ...current, maxSessions: clampedMax },
});
},
addTerminalTab: (name) => {
const current = get().terminalState;
const newTabId = `tab-${Date.now()}`;
@@ -2078,6 +2253,25 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
reorderTerminalTabs: (fromTabId, toTabId) => {
const current = get().terminalState;
const fromIndex = current.tabs.findIndex((t) => t.id === fromTabId);
const toIndex = current.tabs.findIndex((t) => t.id === toTabId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return;
}
// Reorder tabs by moving fromIndex to toIndex
const newTabs = [...current.tabs];
const [movedTab] = newTabs.splice(fromIndex, 1);
newTabs.splice(toIndex, 0, movedTab);
set({
terminalState: { ...current, tabs: newTabs },
});
},
moveTerminalToTab: (sessionId, targetTabId) => {
const current = get().terminalState;
@@ -2128,7 +2322,12 @@ export const useAppStore = create<AppState & AppActions>()(
}
if (newPanels.length === 0) return null;
if (newPanels.length === 1) return newPanels[0];
return { ...node, panels: newPanels };
// Normalize sizes to sum to 100%
const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0);
const normalizedPanels = totalSize > 0
? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 }))
: newPanels.map((p) => ({ ...p, size: 100 / newPanels.length }));
return { ...node, panels: normalizedPanels };
};
const newSourceLayout = removeAndCollapse(sourceTab.layout);
@@ -2178,6 +2377,7 @@ export const useAppStore = create<AppState & AppActions>()(
} else if (targetTab.layout.type === "terminal") {
newTargetLayout = {
type: "split",
id: generateSplitId(),
direction: "horizontal",
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
};
@@ -2228,6 +2428,7 @@ export const useAppStore = create<AppState & AppActions>()(
} else if (tab.layout.type === "terminal") {
newLayout = {
type: "split",
id: generateSplitId(),
direction,
panels: [{ ...tab.layout, size: 50 }, terminalNode],
};
@@ -2244,6 +2445,7 @@ export const useAppStore = create<AppState & AppActions>()(
} else {
newLayout = {
type: "split",
id: generateSplitId(),
direction,
panels: [{ ...tab.layout, size: 50 }, terminalNode],
};
@@ -2264,6 +2466,154 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
setTerminalTabLayout: (tabId, layout, activeSessionId) => {
const current = get().terminalState;
const tab = current.tabs.find((t) => t.id === tabId);
if (!tab) return;
const newTabs = current.tabs.map((t) =>
t.id === tabId ? { ...t, layout } : t
);
// Find first terminal in layout if no activeSessionId provided
const findFirst = (node: TerminalPanelContent): string | null => {
if (node.type === "terminal") return node.sessionId;
for (const p of node.panels) {
const found = findFirst(p);
if (found) return found;
}
return null;
};
const newActiveSessionId = activeSessionId || findFirst(layout);
set({
terminalState: {
...current,
tabs: newTabs,
activeTabId: tabId,
activeSessionId: newActiveSessionId,
},
});
},
updateTerminalPanelSizes: (tabId, panelKeys, sizes) => {
const current = get().terminalState;
const tab = current.tabs.find((t) => t.id === tabId);
if (!tab || !tab.layout) return;
// Create a map of panel key to new size
const sizeMap = new Map<string, number>();
panelKeys.forEach((key, index) => {
sizeMap.set(key, sizes[index]);
});
// Helper to generate panel key (matches getPanelKey in terminal-view.tsx)
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") return panel.sessionId;
const childKeys = panel.panels.map(getPanelKey).join("-");
return `split-${panel.direction}-${childKeys}`;
};
// Recursively update sizes in the layout
const updateSizes = (panel: TerminalPanelContent): TerminalPanelContent => {
const key = getPanelKey(panel);
const newSize = sizeMap.get(key);
if (panel.type === "terminal") {
return newSize !== undefined ? { ...panel, size: newSize } : panel;
}
return {
...panel,
size: newSize !== undefined ? newSize : panel.size,
panels: panel.panels.map(updateSizes),
};
};
const updatedLayout = updateSizes(tab.layout);
const newTabs = current.tabs.map((t) =>
t.id === tabId ? { ...t, layout: updatedLayout } : t
);
set({
terminalState: { ...current, tabs: newTabs },
});
},
// Convert runtime layout to persisted format (preserves sessionIds for reconnection)
saveTerminalLayout: (projectPath) => {
const current = get().terminalState;
if (current.tabs.length === 0) {
// Nothing to save, clear any existing layout
const { [projectPath]: _, ...rest } = get().terminalLayoutByProject;
set({ terminalLayoutByProject: rest });
return;
}
// Convert TerminalPanelContent to PersistedTerminalPanel
// Now preserves sessionId so we can reconnect when switching back
const persistPanel = (
panel: TerminalPanelContent
): PersistedTerminalPanel => {
if (panel.type === "terminal") {
return {
type: "terminal",
size: panel.size,
fontSize: panel.fontSize,
sessionId: panel.sessionId, // Preserve for reconnection
};
}
return {
type: "split",
id: panel.id, // Preserve stable ID
direction: panel.direction,
panels: panel.panels.map(persistPanel),
size: panel.size,
};
};
const persistedTabs: PersistedTerminalTab[] = current.tabs.map(
(tab) => ({
id: tab.id,
name: tab.name,
layout: tab.layout ? persistPanel(tab.layout) : null,
})
);
const activeTabIndex = current.tabs.findIndex(
(t) => t.id === current.activeTabId
);
const persisted: PersistedTerminalState = {
tabs: persistedTabs,
activeTabIndex: activeTabIndex >= 0 ? activeTabIndex : 0,
defaultFontSize: current.defaultFontSize,
defaultRunScript: current.defaultRunScript,
screenReaderMode: current.screenReaderMode,
fontFamily: current.fontFamily,
scrollbackLines: current.scrollbackLines,
lineHeight: current.lineHeight,
};
set({
terminalLayoutByProject: {
...get().terminalLayoutByProject,
[projectPath]: persisted,
},
});
},
getPersistedTerminalLayout: (projectPath) => {
return get().terminalLayoutByProject[projectPath] || null;
},
clearPersistedTerminalLayout: (projectPath) => {
const { [projectPath]: _, ...rest } = get().terminalLayoutByProject;
set({ terminalLayoutByProject: rest });
},
// Spec Creation actions
setSpecCreatingForProject: (projectPath) => {
set({ specCreatingForProject: projectPath });
@@ -2312,6 +2662,30 @@ export const useAppStore = create<AppState & AppActions>()(
}
}
// Rehydrate terminal settings from persisted state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const persistedSettings = (state as any).terminalSettings as PersistedTerminalSettings | undefined;
if (persistedSettings) {
state.terminalState = {
...state.terminalState,
// Preserve session state (tabs, activeTabId, etc.) but restore settings
isUnlocked: state.terminalState?.isUnlocked ?? false,
authToken: state.terminalState?.authToken ?? null,
tabs: state.terminalState?.tabs ?? [],
activeTabId: state.terminalState?.activeTabId ?? null,
activeSessionId: state.terminalState?.activeSessionId ?? null,
maximizedSessionId: state.terminalState?.maximizedSessionId ?? null,
// Restore persisted settings
defaultFontSize: persistedSettings.defaultFontSize ?? 14,
defaultRunScript: persistedSettings.defaultRunScript ?? "",
screenReaderMode: persistedSettings.screenReaderMode ?? false,
fontFamily: persistedSettings.fontFamily ?? "Menlo, Monaco, 'Courier New', monospace",
scrollbackLines: persistedSettings.scrollbackLines ?? 5000,
lineHeight: persistedSettings.lineHeight ?? 1.0,
maxSessions: persistedSettings.maxSessions ?? 100,
};
}
return state as AppState;
},
partialize: (state) => ({
@@ -2349,10 +2723,23 @@ export const useAppStore = create<AppState & AppActions>()(
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
// Board background settings
boardBackgroundByProject: state.boardBackgroundByProject,
// Terminal layout persistence (per-project)
terminalLayoutByProject: state.terminalLayoutByProject,
// Terminal settings persistence (global)
terminalSettings: {
defaultFontSize: state.terminalState.defaultFontSize,
defaultRunScript: state.terminalState.defaultRunScript,
screenReaderMode: state.terminalState.screenReaderMode,
fontFamily: state.terminalState.fontFamily,
scrollbackLines: state.terminalState.scrollbackLines,
lineHeight: state.terminalState.lineHeight,
maxSessions: state.terminalState.maxSessions,
} as PersistedTerminalSettings,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
}
)
);

View File

@@ -2929,3 +2929,23 @@
.animate-accordion-up {
animation: accordion-up 0.2s ease-out forwards;
}
/* Terminal scrollbar theming */
.xterm-viewport::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 4px;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}

22
package-lock.json generated
View File

@@ -80,6 +80,8 @@
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
@@ -1003,7 +1005,7 @@
},
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
@@ -6290,6 +6292,24 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",