mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
55
apps/server/src/routes/terminal/routes/settings.ts
Normal file
55
apps/server/src/routes/terminal/routes/settings.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -5,6 +5,7 @@ export type SettingsViewId =
|
||||
| "claude"
|
||||
| "ai-enhancement"
|
||||
| "appearance"
|
||||
| "terminal"
|
||||
| "keyboard"
|
||||
| "audio"
|
||||
| "defaults"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user