perf: start terminal sessions before renderer mounts
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -97,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.17.1",
|
"version": "1.17.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.17.1",
|
"version": "1.17.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.17.1",
|
"version": "1.17.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.11",
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.17.1",
|
"version": "1.17.2",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,42 @@ import type { TerminalChunk } from '@/stores/useTerminalStore';
|
|||||||
let ghosttyPromise: Promise<Ghostty> | null = null;
|
let ghosttyPromise: Promise<Ghostty> | null = null;
|
||||||
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
||||||
|
|
||||||
|
type TerminalSize = { cols: number; rows: number };
|
||||||
|
|
||||||
|
const getProvisionalTerminalSize = (
|
||||||
|
container: HTMLDivElement,
|
||||||
|
fontFamily: string,
|
||||||
|
fontSize: number,
|
||||||
|
): TerminalSize | null => {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const context = document.createElement('canvas').getContext('2d');
|
||||||
|
if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null;
|
||||||
|
|
||||||
|
context.font = `${fontSize}px ${fontFamily}`;
|
||||||
|
const metrics = context.measureText('M');
|
||||||
|
const cellWidth = Math.ceil(metrics.width);
|
||||||
|
const cellHeight = Math.ceil(
|
||||||
|
(metrics.actualBoundingBoxAscent || fontSize * 0.8) +
|
||||||
|
(metrics.actualBoundingBoxDescent || fontSize * 0.2),
|
||||||
|
) + 2;
|
||||||
|
if (cellWidth < 1 || cellHeight < 1) return null;
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(container);
|
||||||
|
const horizontalPadding =
|
||||||
|
(Number.parseInt(style.paddingLeft, 10) || 0) +
|
||||||
|
(Number.parseInt(style.paddingRight, 10) || 0);
|
||||||
|
const verticalPadding =
|
||||||
|
(Number.parseInt(style.paddingTop, 10) || 0) +
|
||||||
|
(Number.parseInt(style.paddingBottom, 10) || 0);
|
||||||
|
|
||||||
|
// Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions.
|
||||||
|
return {
|
||||||
|
cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)),
|
||||||
|
rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type TerminalController = {
|
export type TerminalController = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
fit: () => void;
|
fit: () => void;
|
||||||
@@ -47,7 +83,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
const fitRef = React.useRef<FitAddon | null>(null);
|
const fitRef = React.useRef<FitAddon | null>(null);
|
||||||
const inputRef = React.useRef(onInput);
|
const inputRef = React.useRef(onInput);
|
||||||
const resizeRef = React.useRef(onResize);
|
const resizeRef = React.useRef(onResize);
|
||||||
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
const lastSizeRef = React.useRef<TerminalSize | null>(null);
|
||||||
|
const provisionalSizeRef = React.useRef<TerminalSize | null>(null);
|
||||||
const lastChunkRef = React.useRef<number | null>(null);
|
const lastChunkRef = React.useRef<number | null>(null);
|
||||||
const writeQueueRef = React.useRef('');
|
const writeQueueRef = React.useRef('');
|
||||||
const outputRewriteCarryRef = React.useRef('');
|
const outputRewriteCarryRef = React.useRef('');
|
||||||
@@ -65,6 +102,14 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
visibleRef.current = isVisible;
|
visibleRef.current = isVisible;
|
||||||
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
|
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const size = getProvisionalTerminalSize(container, fontFamily, fontSize);
|
||||||
|
provisionalSizeRef.current = size;
|
||||||
|
if (size) resizeRef.current(size.cols, size.rows);
|
||||||
|
}, [fontFamily, fontSize]);
|
||||||
|
|
||||||
const fit = React.useCallback(() => {
|
const fit = React.useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
@@ -168,7 +213,10 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
|
|
||||||
loadGhostty().then((ghostty) => {
|
loadGhostty().then((ghostty) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
|
terminal = new GhosttyTerminal({
|
||||||
|
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||||
|
...(provisionalSizeRef.current ?? {}),
|
||||||
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type TerminalViewProps = {
|
|||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
|
||||||
|
|
||||||
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { terminal, runtime } = useRuntimeAPIs();
|
const { terminal, runtime } = useRuntimeAPIs();
|
||||||
@@ -109,7 +111,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||||
const [hasViewportSize, setHasViewportSize] = React.useState(false);
|
|
||||||
|
|
||||||
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
const streamCleanupRef = React.useRef<(() => void) | null>(null);
|
||||||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||||||
@@ -118,7 +119,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
||||||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||||||
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||||
const isTerminalVisibleRef = React.useRef(false);
|
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
|
||||||
const previewScanTailRef = React.useRef('');
|
const previewScanTailRef = React.useRef('');
|
||||||
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
const pendingPreviewProbeUrlsRef = React.useRef<Set<string>>(new Set());
|
||||||
const previewProbeGenerationRef = React.useRef(0);
|
const previewProbeGenerationRef = React.useRef(0);
|
||||||
@@ -157,10 +158,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
}
|
}
|
||||||
}, [isTerminalVisible]);
|
}, [isTerminalVisible]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
isTerminalVisibleRef.current = isTerminalVisible;
|
|
||||||
}, [isTerminalVisible]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
terminalIdRef.current = terminalSessionId;
|
terminalIdRef.current = terminalSessionId;
|
||||||
}, [terminalSessionId]);
|
}, [terminalSessionId]);
|
||||||
@@ -441,11 +438,17 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = lastViewportSizeRef.current;
|
const createKey = `${directory}\u0000${tabId}`;
|
||||||
if (!size && isTerminalVisibleRef.current) {
|
if (pendingTerminalCreatesRef.current.has(createKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launch the shell while Ghostty is still loading and fitting.
|
||||||
|
// The backend accepts 80x24, then receives the measured size as
|
||||||
|
// soon as the viewport is ready.
|
||||||
|
const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
|
||||||
|
pendingTerminalCreatesRef.current.add(createKey);
|
||||||
|
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
setIsFatalError(false);
|
setIsFatalError(false);
|
||||||
setIsReconnectPending(false);
|
setIsReconnectPending(false);
|
||||||
@@ -454,8 +457,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
const session = await terminal.createSession({
|
const session = await terminal.createSession({
|
||||||
cwd: directory,
|
cwd: directory,
|
||||||
sessionId: tabId,
|
sessionId: tabId,
|
||||||
cols: size?.cols,
|
cols: initialSize.cols,
|
||||||
rows: size?.rows,
|
rows: initialSize.rows,
|
||||||
shell: terminalShell,
|
shell: terminalShell,
|
||||||
loginShell: terminalLoginShell,
|
loginShell: terminalLoginShell,
|
||||||
...terminalAppearanceRef.current,
|
...terminalAppearanceRef.current,
|
||||||
@@ -476,6 +479,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
|
|
||||||
setTabSessionId(directory, tabId, session.sessionId);
|
setTabSessionId(directory, tabId, session.sessionId);
|
||||||
if (!stillActive) return;
|
if (!stillActive) return;
|
||||||
|
|
||||||
|
const viewportSize = lastViewportSizeRef.current;
|
||||||
|
if (
|
||||||
|
viewportSize &&
|
||||||
|
(viewportSize.cols !== initialSize.cols || viewportSize.rows !== initialSize.rows)
|
||||||
|
) {
|
||||||
|
void terminal.resize({ sessionId: session.sessionId, ...viewportSize }).catch(() => {});
|
||||||
|
}
|
||||||
terminalId = session.sessionId;
|
terminalId = session.sessionId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -489,6 +500,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
setConnecting(directory, tabId, false);
|
setConnecting(directory, tabId, false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
pendingTerminalCreatesRef.current.delete(createKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +526,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
terminalLifecycle,
|
terminalLifecycle,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
hasOpenedTerminalViewport,
|
hasOpenedTerminalViewport,
|
||||||
hasViewportSize,
|
|
||||||
enableTabs,
|
enableTabs,
|
||||||
terminalHydrated,
|
terminalHydrated,
|
||||||
ensureDirectory,
|
ensureDirectory,
|
||||||
@@ -568,7 +580,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
resetTerminalPreviewScan();
|
resetTerminalPreviewScan();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const size = lastViewportSizeRef.current ?? { cols: 80, rows: 24 };
|
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
|
||||||
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
|
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
|
||||||
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
|
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||||
if (owningTab?.terminalSessionId !== originalSessionId) return;
|
if (owningTab?.terminalSessionId !== originalSessionId) return;
|
||||||
@@ -696,11 +708,10 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
const previous = lastViewportSizeRef.current;
|
const previous = lastViewportSizeRef.current;
|
||||||
if (!previous) {
|
if (!previous) {
|
||||||
lastViewportSizeRef.current = { cols, rows };
|
lastViewportSizeRef.current = { cols, rows };
|
||||||
if (!terminalIdRef.current) setHasViewportSize(true);
|
|
||||||
} else if (previous.cols !== cols || previous.rows !== rows) {
|
} else if (previous.cols !== cols || previous.rows !== rows) {
|
||||||
lastViewportSizeRef.current = { cols, rows };
|
lastViewportSizeRef.current = { cols, rows };
|
||||||
}
|
}
|
||||||
if (!isTerminalVisibleRef.current) {
|
if (!isTerminalVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const terminalId = terminalIdRef.current;
|
const terminalId = terminalIdRef.current;
|
||||||
@@ -709,7 +720,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[terminal]
|
[isTerminalVisible, terminal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModifierToggle = React.useCallback(
|
const handleModifierToggle = React.useCallback(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Regression guard for slow terminal opening on Linux.
|
* Regression guard for slow terminal opening on Linux.
|
||||||
*
|
*
|
||||||
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
|
* `TerminalViewport` is keyed by `terminalViewportKey`. That key used to include
|
||||||
* the PTY session id, which is null until `createSession` resolves. Because the
|
* the PTY session id, which is null until `createSession` resolves. Historically,
|
||||||
* viewport must mount first to report its size before a session can be created,
|
* the viewport had to mount first to report its size before session creation, so
|
||||||
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
|
* every terminal open built a Ghostty terminal (WASM VT + 2D canvas renderer +
|
||||||
* font atlas), threw it away when the session id arrived, and built a second one.
|
* font atlas), threw it away when the session id arrived, and built a second one.
|
||||||
* The same churn repeated on reconnect and on every incidental session-id change,
|
* The same churn repeated on reconnect and on every incidental session-id change,
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
*
|
*
|
||||||
* Viewport identity must therefore be directory + tab only. Session changes are
|
* Viewport identity must therefore be directory + tab only. Session changes are
|
||||||
* handled by the chunk replay path, which resets the existing terminal in place.
|
* handled by the chunk replay path, which resets the existing terminal in place.
|
||||||
|
* New sessions start concurrently with a container-derived size (or 80x24) and
|
||||||
|
* resize after their viewport fits.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -61,4 +63,27 @@ describe('terminal viewport remount guard', () => {
|
|||||||
expect(terminalViewSource).toContain('getBuffer(');
|
expect(terminalViewSource).toContain('getBuffer(');
|
||||||
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
|
expect(terminalViewSource).not.toContain('activeTab?.bufferChunks');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('starts the PTY before Ghostty reports its first viewport size', () => {
|
||||||
|
expect(terminalViewSource).toContain('const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;');
|
||||||
|
expect(terminalViewSource).toContain('const initialSize = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;');
|
||||||
|
expect(terminalViewSource).not.toContain('if (!size && isTerminalVisibleRef.current)');
|
||||||
|
expect(terminalViewSource).toContain('cols: initialSize.cols');
|
||||||
|
expect(terminalViewSource).toContain('rows: initialSize.rows');
|
||||||
|
expect(terminalViewSource).toContain('void terminal.resize({ sessionId: session.sessionId, ...viewportSize })');
|
||||||
|
expect(terminalViewSource).toContain('if (!isTerminalVisible) {');
|
||||||
|
expect(terminalViewSource).not.toContain('isTerminalVisibleRef');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates create attempts while the viewport layout settles', () => {
|
||||||
|
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.has(createKey)');
|
||||||
|
expect(terminalViewSource).toContain('pendingTerminalCreatesRef.current.delete(createKey)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derives the initial PTY size before Ghostty mounts', () => {
|
||||||
|
expect(terminalViewportSource).toContain('const getProvisionalTerminalSize');
|
||||||
|
expect(terminalViewportSource).toContain('React.useLayoutEffect(() => {');
|
||||||
|
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
||||||
|
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
|
|||||||
- IDs are client-provided or generated with `randomUUID()`.
|
- IDs are client-provided or generated with `randomUUID()`.
|
||||||
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
|
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
|
||||||
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
||||||
|
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
|
||||||
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
||||||
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
||||||
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
|
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
|
||||||
|
|||||||
Reference in New Issue
Block a user