feat: enhance terminal functionality and settings

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

View File

@@ -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,
}
)
);