mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +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";
|
} from "./routes/sessions.js";
|
||||||
import { createSessionDeleteHandler } from "./routes/session-delete.js";
|
import { createSessionDeleteHandler } from "./routes/session-delete.js";
|
||||||
import { createSessionResizeHandler } from "./routes/session-resize.js";
|
import { createSessionResizeHandler } from "./routes/session-resize.js";
|
||||||
|
import {
|
||||||
|
createSettingsGetHandler,
|
||||||
|
createSettingsUpdateHandler,
|
||||||
|
} from "./routes/settings.js";
|
||||||
|
|
||||||
// Re-export for use in main index.ts
|
// Re-export for use in main index.ts
|
||||||
export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired };
|
export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired };
|
||||||
@@ -39,6 +43,8 @@ export function createTerminalRoutes(): Router {
|
|||||||
router.post("/sessions", createSessionsCreateHandler());
|
router.post("/sessions", createSessionsCreateHandler());
|
||||||
router.delete("/sessions/:id", createSessionDeleteHandler());
|
router.delete("/sessions/:id", createSessionDeleteHandler());
|
||||||
router.post("/sessions/:id/resize", createSessionResizeHandler());
|
router.post("/sessions/:id/resize", createSessionResizeHandler());
|
||||||
|
router.get("/settings", createSettingsGetHandler());
|
||||||
|
router.put("/settings", createSettingsUpdateHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ export function createSessionsCreateHandler() {
|
|||||||
shell,
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
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)
|
// Maximum scrollback buffer size (characters)
|
||||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
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
|
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||||
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
|
// Using 4ms for responsive input feedback while still preventing flood
|
||||||
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
|
// 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 {
|
export interface TerminalSession {
|
||||||
id: string;
|
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 id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||||
@@ -188,11 +226,15 @@ export class TerminalService extends EventEmitter {
|
|||||||
const cwd = this.resolveWorkingDirectory(options.cwd);
|
const cwd = this.resolveWorkingDirectory(options.cwd);
|
||||||
|
|
||||||
// Build environment with some useful defaults
|
// Build environment with some useful defaults
|
||||||
|
// These settings ensure consistent terminal behavior across platforms
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...process.env,
|
...process.env,
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
COLORTERM: "truecolor",
|
COLORTERM: "truecolor",
|
||||||
TERM_PROGRAM: "automaker-terminal",
|
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,
|
...options.env,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -350,12 +392,16 @@ export class TerminalService extends EventEmitter {
|
|||||||
clearTimeout(session.resizeDebounceTimeout);
|
clearTimeout(session.resizeDebounceTimeout);
|
||||||
session.resizeDebounceTimeout = null;
|
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);
|
this.sessions.delete(sessionId);
|
||||||
console.log(`[Terminal] Session ${sessionId} killed`);
|
console.log(`[Terminal] Session ${sessionId} killed`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Terminal] Error killing session ${sessionId}:`, 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config";
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import ts from "@typescript-eslint/eslint-plugin";
|
import ts from "@typescript-eslint/eslint-plugin";
|
||||||
import tsParser from "@typescript-eslint/parser";
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
@@ -13,6 +14,10 @@ const eslintConfig = defineConfig([
|
|||||||
ecmaVersion: "latest",
|
ecmaVersion: "latest",
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2021,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
"@typescript-eslint": ts,
|
"@typescript-eslint": ts,
|
||||||
|
|||||||
@@ -60,6 +60,8 @@
|
|||||||
"@tanstack/react-router": "^1.141.6",
|
"@tanstack/react-router": "^1.141.6",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@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/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
|||||||
splitTerminalRight: "Split Right",
|
splitTerminalRight: "Split Right",
|
||||||
splitTerminalDown: "Split Down",
|
splitTerminalDown: "Split Down",
|
||||||
closeTerminal: "Close Terminal",
|
closeTerminal: "Close Terminal",
|
||||||
|
newTerminalTab: "New Tab",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Categorize shortcuts for color coding
|
// Categorize shortcuts for color coding
|
||||||
@@ -127,6 +128,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
|
|||||||
splitTerminalRight: "action",
|
splitTerminalRight: "action",
|
||||||
splitTerminalDown: "action",
|
splitTerminalDown: "action",
|
||||||
closeTerminal: "action",
|
closeTerminal: "action",
|
||||||
|
newTerminalTab: "action",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Category colors
|
// 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 { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||||
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
||||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
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 { AudioSection } from "./settings-view/audio/audio-section";
|
||||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||||
@@ -108,6 +109,8 @@ export function SettingsView() {
|
|||||||
onThemeChange={handleSetTheme}
|
onThemeChange={handleSetTheme}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "terminal":
|
||||||
|
return <TerminalSection />;
|
||||||
case "keyboard":
|
case "keyboard":
|
||||||
return (
|
return (
|
||||||
<KeyboardShortcutsSection
|
<KeyboardShortcutsSection
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
SquareTerminal,
|
||||||
Palette,
|
Palette,
|
||||||
Settings2,
|
Settings2,
|
||||||
Volume2,
|
Volume2,
|
||||||
@@ -23,6 +24,7 @@ export const NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: "claude", label: "Claude", icon: Terminal },
|
{ id: "claude", label: "Claude", icon: Terminal },
|
||||||
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
|
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
|
{ id: "terminal", label: "Terminal", icon: SquareTerminal },
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type SettingsViewId =
|
|||||||
| "claude"
|
| "claude"
|
||||||
| "ai-enhancement"
|
| "ai-enhancement"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
|
| "terminal"
|
||||||
| "keyboard"
|
| "keyboard"
|
||||||
| "audio"
|
| "audio"
|
||||||
| "defaults"
|
| "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) {
|
export function TerminalOutput({ lines }: TerminalOutputProps) {
|
||||||
return (
|
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) => (
|
{lines.map((line, index) => (
|
||||||
<div key={index} className="text-zinc-400">
|
<div key={index} className="text-foreground">
|
||||||
<span className="text-green-500">$</span> {line}
|
<span className="text-primary">$</span> {line}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{lines.length === 0 && (
|
{lines.length === 0 && (
|
||||||
<div className="text-zinc-500 italic">Waiting for output...</div>
|
<div className="text-muted-foreground italic">Waiting for output...</div>
|
||||||
)}
|
)}
|
||||||
</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;
|
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)
|
// Dark theme (default)
|
||||||
const darkTheme: TerminalTheme = {
|
const darkTheme: TerminalTheme = {
|
||||||
background: "#0a0a0a",
|
background: "#0a0a0a",
|
||||||
@@ -206,11 +230,11 @@ const tokyonightTheme: TerminalTheme = {
|
|||||||
brightWhite: "#c0caf5",
|
brightWhite: "#c0caf5",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Solarized Dark theme
|
// Solarized Dark theme (improved contrast for WCAG compliance)
|
||||||
const solarizedTheme: TerminalTheme = {
|
const solarizedTheme: TerminalTheme = {
|
||||||
background: "#002b36",
|
background: "#002b36",
|
||||||
foreground: "#839496",
|
foreground: "#93a1a1", // Changed from #839496 (base0) to #93a1a1 (base1) for better contrast
|
||||||
cursor: "#839496",
|
cursor: "#93a1a1",
|
||||||
cursorAccent: "#002b36",
|
cursorAccent: "#002b36",
|
||||||
selectionBackground: "#073642",
|
selectionBackground: "#073642",
|
||||||
black: "#073642",
|
black: "#073642",
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ export interface ElectronAPI {
|
|||||||
deleteFile: (filePath: string) => Promise<WriteResult>;
|
deleteFile: (filePath: string) => Promise<WriteResult>;
|
||||||
trashItem?: (filePath: string) => Promise<WriteResult>;
|
trashItem?: (filePath: string) => Promise<WriteResult>;
|
||||||
getPath: (name: string) => Promise<string>;
|
getPath: (name: string) => Promise<string>;
|
||||||
|
openInEditor?: (
|
||||||
|
filePath: string,
|
||||||
|
line?: number,
|
||||||
|
column?: number
|
||||||
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
saveImageToTemp?: (
|
saveImageToTemp?: (
|
||||||
data: string,
|
data: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
|||||||
@@ -217,6 +217,44 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return { success: true };
|
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
|
// File picker - uses server-side file browser dialog
|
||||||
async openDirectory(): Promise<DialogResult> {
|
async openDirectory(): Promise<DialogResult> {
|
||||||
const fileBrowser = getGlobalFileBrowser();
|
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
|
// App info
|
||||||
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
|
ipcMain.handle("app:getPath", async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||||
return app.getPath(name);
|
return app.getPath(name);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
ipcRenderer.invoke("shell:openExternal", url),
|
ipcRenderer.invoke("shell:openExternal", url),
|
||||||
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
ipcRenderer.invoke("shell:openPath", filePath),
|
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
|
// App info
|
||||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke("app:getPath", name),
|
getPath: (name: string): Promise<string> => ipcRenderer.invoke("app:getPath", name),
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ function RootLayoutContent() {
|
|||||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||||
return;
|
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) {
|
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export interface KeyboardShortcuts {
|
|||||||
splitTerminalRight: string;
|
splitTerminalRight: string;
|
||||||
splitTerminalDown: string;
|
splitTerminalDown: string;
|
||||||
closeTerminal: string;
|
closeTerminal: string;
|
||||||
|
newTerminalTab: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default keyboard shortcuts
|
// Default keyboard shortcuts
|
||||||
@@ -210,6 +211,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
splitTerminalRight: "Alt+D",
|
splitTerminalRight: "Alt+D",
|
||||||
splitTerminalDown: "Alt+S",
|
splitTerminalDown: "Alt+S",
|
||||||
closeTerminal: "Alt+W",
|
closeTerminal: "Alt+W",
|
||||||
|
newTerminalTab: "Alt+T",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
@@ -356,6 +358,7 @@ export type TerminalPanelContent =
|
|||||||
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number }
|
| { type: "terminal"; sessionId: string; size?: number; fontSize?: number }
|
||||||
| {
|
| {
|
||||||
type: "split";
|
type: "split";
|
||||||
|
id: string; // Stable ID for React key stability
|
||||||
direction: "horizontal" | "vertical";
|
direction: "horizontal" | "vertical";
|
||||||
panels: TerminalPanelContent[];
|
panels: TerminalPanelContent[];
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -374,7 +377,57 @@ export interface TerminalState {
|
|||||||
tabs: TerminalTab[];
|
tabs: TerminalTab[];
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
activeSessionId: 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
|
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 {
|
export interface AppState {
|
||||||
@@ -491,6 +544,10 @@ export interface AppState {
|
|||||||
// Terminal state
|
// Terminal state
|
||||||
terminalState: TerminalState;
|
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)
|
// Spec Creation State (per-project, keyed by project path)
|
||||||
// Tracks which project is currently having its spec generated
|
// Tracks which project is currently having its spec generated
|
||||||
specCreatingForProject: string | null;
|
specCreatingForProject: string | null;
|
||||||
@@ -720,6 +777,7 @@ export interface AppActions {
|
|||||||
// Terminal actions
|
// Terminal actions
|
||||||
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
|
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
|
||||||
setActiveTerminalSession: (sessionId: string | null) => void;
|
setActiveTerminalSession: (sessionId: string | null) => void;
|
||||||
|
toggleTerminalMaximized: (sessionId: string) => void;
|
||||||
addTerminalToLayout: (
|
addTerminalToLayout: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
direction?: "horizontal" | "vertical",
|
direction?: "horizontal" | "vertical",
|
||||||
@@ -729,16 +787,37 @@ export interface AppActions {
|
|||||||
swapTerminals: (sessionId1: string, sessionId2: string) => void;
|
swapTerminals: (sessionId1: string, sessionId2: string) => void;
|
||||||
clearTerminalState: () => void;
|
clearTerminalState: () => void;
|
||||||
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => 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;
|
addTerminalTab: (name?: string) => string;
|
||||||
removeTerminalTab: (tabId: string) => void;
|
removeTerminalTab: (tabId: string) => void;
|
||||||
setActiveTerminalTab: (tabId: string) => void;
|
setActiveTerminalTab: (tabId: string) => void;
|
||||||
renameTerminalTab: (tabId: string, name: string) => void;
|
renameTerminalTab: (tabId: string, name: string) => void;
|
||||||
|
reorderTerminalTabs: (fromTabId: string, toTabId: string) => void;
|
||||||
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
|
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
|
||||||
addTerminalToTab: (
|
addTerminalToTab: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
tabId: string,
|
tabId: string,
|
||||||
direction?: "horizontal" | "vertical"
|
direction?: "horizontal" | "vertical"
|
||||||
) => void;
|
) => 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
|
// Spec Creation actions
|
||||||
setSpecCreatingForProject: (projectPath: string | null) => void;
|
setSpecCreatingForProject: (projectPath: string | null) => void;
|
||||||
@@ -841,8 +920,16 @@ const initialState: AppState = {
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
|
maximizedSessionId: null,
|
||||||
defaultFontSize: 14,
|
defaultFontSize: 14,
|
||||||
|
defaultRunScript: "",
|
||||||
|
screenReaderMode: false,
|
||||||
|
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||||
|
scrollbackLines: 5000,
|
||||||
|
lineHeight: 1.0,
|
||||||
|
maxSessions: 100,
|
||||||
},
|
},
|
||||||
|
terminalLayoutByProject: {},
|
||||||
specCreatingForProject: null,
|
specCreatingForProject: null,
|
||||||
defaultPlanningMode: 'skip' as PlanningMode,
|
defaultPlanningMode: 'skip' as PlanningMode,
|
||||||
defaultRequirePlanApproval: false,
|
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: (
|
addTerminalToLayout: (
|
||||||
sessionId,
|
sessionId,
|
||||||
direction = "horizontal",
|
direction = "horizontal",
|
||||||
@@ -1781,6 +1881,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Found the target - split it
|
// Found the target - split it
|
||||||
return {
|
return {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction: targetDirection,
|
direction: targetDirection,
|
||||||
panels: [{ ...node, size: 50 }, newTerminal],
|
panels: [{ ...node, size: 50 }, newTerminal],
|
||||||
};
|
};
|
||||||
@@ -1805,6 +1906,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
if (node.type === "terminal") {
|
if (node.type === "terminal") {
|
||||||
return {
|
return {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction: targetDirection,
|
direction: targetDirection,
|
||||||
panels: [{ ...node, size: 50 }, newTerminal],
|
panels: [{ ...node, size: 50 }, newTerminal],
|
||||||
};
|
};
|
||||||
@@ -1823,6 +1925,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Different direction, wrap in new split
|
// Different direction, wrap in new split
|
||||||
return {
|
return {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction: targetDirection,
|
direction: targetDirection,
|
||||||
panels: [{ ...node, size: 50 }, newTerminal],
|
panels: [{ ...node, size: 50 }, newTerminal],
|
||||||
};
|
};
|
||||||
@@ -1884,7 +1987,12 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
}
|
}
|
||||||
if (newPanels.length === 0) return null;
|
if (newPanels.length === 0) return null;
|
||||||
if (newPanels.length === 1) return newPanels[0];
|
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) => {
|
let newTabs = current.tabs.map((tab) => {
|
||||||
@@ -1948,14 +2056,25 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearTerminalState: () => {
|
clearTerminalState: () => {
|
||||||
|
const current = get().terminalState;
|
||||||
set({
|
set({
|
||||||
terminalState: {
|
terminalState: {
|
||||||
isUnlocked: false,
|
// Preserve auth state - user shouldn't need to re-authenticate
|
||||||
authToken: null,
|
isUnlocked: current.isUnlocked,
|
||||||
|
authToken: current.authToken,
|
||||||
|
// Clear session-specific state only
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
activeSessionId: 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) => {
|
addTerminalTab: (name) => {
|
||||||
const current = get().terminalState;
|
const current = get().terminalState;
|
||||||
const newTabId = `tab-${Date.now()}`;
|
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) => {
|
moveTerminalToTab: (sessionId, targetTabId) => {
|
||||||
const current = get().terminalState;
|
const current = get().terminalState;
|
||||||
|
|
||||||
@@ -2128,7 +2322,12 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
}
|
}
|
||||||
if (newPanels.length === 0) return null;
|
if (newPanels.length === 0) return null;
|
||||||
if (newPanels.length === 1) return newPanels[0];
|
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);
|
const newSourceLayout = removeAndCollapse(sourceTab.layout);
|
||||||
@@ -2178,6 +2377,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
} else if (targetTab.layout.type === "terminal") {
|
} else if (targetTab.layout.type === "terminal") {
|
||||||
newTargetLayout = {
|
newTargetLayout = {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
panels: [{ ...targetTab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
@@ -2228,6 +2428,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
} else if (tab.layout.type === "terminal") {
|
} else if (tab.layout.type === "terminal") {
|
||||||
newLayout = {
|
newLayout = {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction,
|
direction,
|
||||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
||||||
};
|
};
|
||||||
@@ -2244,6 +2445,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
} else {
|
} else {
|
||||||
newLayout = {
|
newLayout = {
|
||||||
type: "split",
|
type: "split",
|
||||||
|
id: generateSplitId(),
|
||||||
direction,
|
direction,
|
||||||
panels: [{ ...tab.layout, size: 50 }, terminalNode],
|
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
|
// Spec Creation actions
|
||||||
setSpecCreatingForProject: (projectPath) => {
|
setSpecCreatingForProject: (projectPath) => {
|
||||||
set({ specCreatingForProject: 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;
|
return state as AppState;
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
@@ -2349,10 +2723,23 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||||
// Board background settings
|
// Board background settings
|
||||||
boardBackgroundByProject: state.boardBackgroundByProject,
|
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,
|
defaultPlanningMode: state.defaultPlanningMode,
|
||||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||||
defaultAIProfileId: state.defaultAIProfileId,
|
defaultAIProfileId: state.defaultAIProfileId,
|
||||||
}),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2929,3 +2929,23 @@
|
|||||||
.animate-accordion-up {
|
.animate-accordion-up {
|
||||||
animation: accordion-up 0.2s ease-out forwards;
|
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",
|
"@tanstack/react-router": "^1.141.6",
|
||||||
"@uiw/react-codemirror": "^4.25.4",
|
"@uiw/react-codemirror": "^4.25.4",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@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/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -1003,7 +1005,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@electron/node-gyp": {
|
"node_modules/@electron/node-gyp": {
|
||||||
"version": "10.2.0-electron.1",
|
"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==",
|
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6290,6 +6292,24 @@
|
|||||||
"@xterm/xterm": "^5.0.0"
|
"@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": {
|
"node_modules/@xterm/addon-webgl": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user