feat(terminal): add mobile shell controls (#10498)

This commit is contained in:
Andras Bacsai 2026-06-01 10:55:45 +02:00 committed by GitHub
commit e30147c6f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 206 additions and 12 deletions

View file

@ -53,6 +53,13 @@ @theme {
If we ever want to remove these styles, we need to add an explicit border If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults. color utility to any element that depends on these defaults.
*/ */
@layer components {
.terminal-mobile-key {
@apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
}
}
@layer base { @layer base {
*, *,

View file

@ -58,6 +58,7 @@ export function initializeTerminalComponent() {
// Visibility handling - prevent disconnects when tab loses focus // Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true, isDocumentVisible: true,
wasConnectedBeforeHidden: false, wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
terminalSessionStartedAt: null, terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null, terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null, terminalSessionCountdownInterval: null,
@ -526,6 +527,8 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal connection failed // Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected'); this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') { } else if (event.data === 'pty-exited') {
this.fullscreen = false;
this.mobileToolbarCollapsed = false;
this.terminalActive = false; this.terminalActive = false;
this.resetTerminalSessionCountdown(); this.resetTerminalSessionCountdown();
this.term.reset(); this.term.reset();
@ -602,6 +605,64 @@ export function initializeTerminalComponent() {
}); });
}, },
sendTerminalInput(data) {
if (!this.term || !this.terminalActive) {
return;
}
this.term.focus();
this.sendMessage({ message: data });
},
sendTerminalControl(sequence) {
const terminalSequences = {
arrowUp: '\x1b[A',
arrowDown: '\x1b[B',
arrowRight: '\x1b[C',
arrowLeft: '\x1b[D',
tab: '\t',
escape: '\x1b',
ctrlC: '\x03'
};
if (terminalSequences[sequence]) {
this.sendTerminalInput(terminalSequences[sequence]);
}
},
async pasteFromClipboard() {
if (!navigator.clipboard?.readText) {
this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
return;
}
try {
const text = await navigator.clipboard.readText();
if (text) {
this.sendTerminalInput(text);
}
} catch (error) {
logTerminal('warn', '[Terminal] Clipboard paste failed:', error);
this.$wire.dispatch('error', 'Clipboard paste permission was denied.');
}
},
async copyTerminalSelection() {
const selection = this.term?.getSelection();
if (!selection) {
this.$wire.dispatch('error', 'Select terminal text before copying.');
return;
}
try {
await navigator.clipboard.writeText(selection);
} catch (error) {
logTerminal('warn', '[Terminal] Clipboard copy failed:', error);
this.$wire.dispatch('error', 'Clipboard copy permission was denied.');
}
},
keepAlive() { keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true }); this.sendMessage({ ping: true });
@ -693,15 +754,20 @@ export function initializeTerminalComponent() {
// Force a refresh of the fit addon dimensions // Force a refresh of the fit addon dimensions
this.fitAddon.fit(); this.fitAddon.fit();
// Get fresh dimensions after fit // Get fresh dimensions from the terminal element itself. The mobile
const wrapperHeight = this.$refs.terminalWrapper.clientHeight; // toolbar can live beside the terminal in normal flow, so wrapper dimensions
const wrapperWidth = this.$refs.terminalWrapper.clientWidth; // would include controls that should not be counted as terminal rows.
const terminalElement = document.getElementById('terminal');
const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
// Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom) // Account for terminal container padding. In fullscreen mobile mode,
const horizontalPadding = 16; // 8px * 2 (left + right) // the fixed toolbar sits over the terminal container, so reserve its height
const verticalPadding = 8; // 4px * 2 (top + bottom) // when calculating rows to keep the prompt above the controls.
const height = wrapperHeight - verticalPadding; const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
const width = wrapperWidth - horizontalPadding; const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
const height = terminalHeight - verticalPadding;
const width = terminalWidth - horizontalPadding;
// Check if dimensions are valid // Check if dimensions are valid
if (height <= 0 || width <= 0) { if (height <= 0 || width <= 0) {

View file

@ -21,7 +21,7 @@
</div> </div>
@endif @endif
<div x-ref="terminalWrapper" <div x-ref="terminalWrapper"
:class="fullscreen ? 'fullscreen !bg-black' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'"> :class="fullscreen ? 'fixed inset-0 z-[9999] m-0 h-[100dvh] w-screen max-w-none overflow-hidden rounded-none !bg-black p-0' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
<!-- Terminal container --> <!-- Terminal container -->
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start"> <div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium" <div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
@ -29,7 +29,29 @@
</div> </div>
</div> </div>
<div id="terminal" wire:ignore <div id="terminal" wire:ignore
:class="fullscreen ? 'px-2 py-1 h-full bg-black' : 'px-2 py-1 rounded-sm bg-black'" x-show="terminalActive"> :class="fullscreen ? (mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black') : 'h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black'"
x-show="terminalActive">
</div>
<div x-show="terminalActive" x-cloak
:class="fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'"
class="sm:hidden" data-terminal-mobile-toolbar>
<div class="mx-auto max-w-3xl rounded-lg border border-white/10 bg-black/90 p-1.5 text-white shadow-lg backdrop-blur">
<div class="flex items-center justify-between gap-2">
<span class="px-2 text-[11px] font-medium uppercase tracking-wide text-neutral-400">Terminal keys</span>
<button type="button" class="rounded px-2 py-1 text-xs text-neutral-300 hover:bg-white/10 hover:text-white"
x-on:click="mobileToolbarCollapsed = !mobileToolbarCollapsed; $nextTick(() => resizeTerminal())"
x-text="mobileToolbarCollapsed ? 'Show' : 'Hide'"
aria-label="Toggle mobile terminal toolbar"></button>
</div>
<div x-show="!mobileToolbarCollapsed" class="mt-1 grid grid-cols-6 gap-1">
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowUp')" aria-label="Previous command"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowDown')" aria-label="Next command"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowLeft')" aria-label="Move cursor left"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowRight')" aria-label="Move cursor right"></button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('tab')">Tab</button>
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('escape')">Esc</button>
</div>
</div>
</div> </div>
<button title="Minimize" x-show="fullscreen" class="fixed bg-black/40 top-4 right-6 text-white" <button title="Minimize" x-show="fullscreen" class="fixed bg-black/40 top-4 right-6 text-white"
x-on:click="makeFullscreen"><svg class="w-5 h-5 text-gray-500 hover:text-white bg-black/80" x-on:click="makeFullscreen"><svg class="w-5 h-5 text-gray-500 hover:text-white bg-black/80"

View file

@ -24,11 +24,12 @@
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');"); ->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
}); });
it('keeps realtime terminal server logging restricted to development environments', function () { it('keeps realtime terminal server logging behind the explicit debug flag', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer) expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(") ->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG')
->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)")
->toContain('if (!terminalDebugEnabled) {') ->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
}); });
@ -89,6 +90,16 @@
->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"'); ->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
}); });
it('exits fullscreen when the terminal process exits', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("event.data === 'pty-exited'")
->toContain('this.fullscreen = false;
this.mobileToolbarCollapsed = false;
this.terminalActive = false;');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () { it('replays the last command on reconnect so the PTY respawns automatically', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js')); $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
@ -117,3 +128,91 @@
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
->not->toContain("this.term.reset();\n this.term.clear();"); ->not->toContain("this.term.reset();\n this.term.clear();");
}); });
it('renders a compact mobile terminal toolbar with shell control keys', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$appCss = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('Terminal keys')
->toContain('sm:hidden')
->toContain("sendTerminalControl('arrowUp')")
->toContain("sendTerminalControl('arrowDown')")
->toContain("sendTerminalControl('arrowLeft')")
->toContain("sendTerminalControl('arrowRight')")
->toContain("sendTerminalControl('tab')")
->toContain("sendTerminalControl('escape')")
->not->toContain("sendTerminalControl('ctrlC')")
->not->toContain('pasteFromClipboard()')
->not->toContain('copyTerminalSelection()')
->toContain('mobileToolbarCollapsed')
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'")
->toContain('data-terminal-mobile-toolbar')
->and($appCss)
->toContain('.terminal-mobile-key');
});
it('sends terminal mobile toolbar controls through the websocket', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain('sendTerminalInput(data)')
->toContain('sendTerminalControl(sequence)')
->toContain("arrowUp: '\\x1b[A'")
->toContain("arrowDown: '\\x1b[B'")
->toContain("arrowRight: '\\x1b[C'")
->toContain("arrowLeft: '\\x1b[D'")
->toContain("tab: '\\t'")
->toContain("escape: '\\x1b'")
->toContain("ctrlC: '\\x03'")
->toContain('navigator.clipboard.readText()')
->toContain('navigator.clipboard.writeText(selection)');
});
it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($terminalClient)
->toContain("document.getElementById('terminal')")
->toContain('terminalHeight')
->toContain('terminalWidth')
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
});
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalClient)
->not->toContain('updateFullscreenLayout()')
->not->toContain('terminalFullscreenHeight')
->not->toContain('window.visualViewport?.height')
->and($terminalView)
->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'")
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'");
});
it('resizes after toggling the mobile terminal toolbar', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('$nextTick(() => resizeTerminal())');
});
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('fixed inset-0')
->toContain('h-[100dvh]')
->toContain('w-screen')
->toContain('max-w-none')
->toContain('overflow-hidden');
});
it('constrains normal terminal height after leaving fullscreen', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden');
});