feat(terminal): add mobile shell controls

Add a compact mobile toolbar for fullscreen terminal control keys and adjust terminal sizing so the toolbar does not cover rows.
This commit is contained in:
Andras Bacsai
2026-06-01 10:13:56 +02:00
parent 4f6399aaaf
commit ec367549b4
4 changed files with 194 additions and 12 deletions
+7
View File
@@ -53,6 +53,13 @@
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.
*/
@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 {
*,
+72 -8
View File
@@ -52,6 +52,7 @@ export function initializeTerminalComponent() {
// Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
init() {
this.setupTerminal();
@@ -538,6 +539,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() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
@@ -629,15 +688,20 @@ export function initializeTerminalComponent() {
// Force a refresh of the fit addon dimensions
this.fitAddon.fit();
// Get fresh dimensions after fit
const wrapperHeight = this.$refs.terminalWrapper.clientHeight;
const wrapperWidth = this.$refs.terminalWrapper.clientWidth;
// Get fresh dimensions from the terminal element itself. The mobile
// toolbar can live beside the terminal in normal flow, so wrapper dimensions
// 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)
const horizontalPadding = 16; // 8px * 2 (left + right)
const verticalPadding = 8; // 4px * 2 (top + bottom)
const height = wrapperHeight - verticalPadding;
const width = wrapperWidth - horizontalPadding;
// Account for terminal container padding. In fullscreen mobile mode,
// the fixed toolbar sits over the terminal container, so reserve its height
// when calculating rows to keep the prompt above the controls.
const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
const height = terminalHeight - verticalPadding;
const width = terminalWidth - horizontalPadding;
// Check if dimensions are valid
if (height <= 0 || width <= 0) {
@@ -18,10 +18,32 @@
</div>
@endif
<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 -->
<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>
<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"
@@ -24,11 +24,12 @@ it('keeps terminal browser logging restricted to Vite development mode', functio
->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'));
expect($terminalServer)
->toContain("const terminalDebugEnabled = ['local', 'development'].includes(")
->toContain("const terminalDebugEnabled = ['1', 'true', 'yes'].includes(")
->toContain('process.env.TERMINAL_DEBUG')
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
@@ -104,3 +105,91 @@ it('preserves terminal scrollback across transient reconnects', function () {
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
->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-11rem)] mb-[11rem] 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');
});