Add terminal big mode, keyboard shortcuts menu, and UX refinements

- Reduce mobile terminal widths by 2 chars (portrait 42x24, landscape 86x24)
- Add "Big mode" for mobile: desktop sizing (120x36) at 70% zoom
- Click zoom percentage to reset to 100%
- Add keyboard shortcuts submenu in session settings
- Update PRD with all terminal features and documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 00:20:40 -05:00
parent 5171059692
commit a2e10688bf
7 changed files with 473 additions and 36 deletions
+26 -1
View File
@@ -3,7 +3,11 @@
@tailwind utilities;
@layer base {
html {
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
-webkit-tap-highlight-color: transparent;
}
@@ -75,4 +79,25 @@
display: block;
}
}
/* Hide desktop orientation icons on mobile (width < 640px or landscape) */
@media (max-width: 639px), (max-height: 450px) {
.desktop-only {
display: none !important;
}
}
/* Hide extra buttons on mobile portrait */
@media (max-width: 639px) and (min-height: 451px) {
.portrait-hide {
display: none !important;
}
}
/* Hide mobile orientation buttons on XL desktop (>= 1280px) */
@media (min-width: 1280px) {
.mobile-only {
display: none !important;
}
}
}
@@ -4,6 +4,7 @@
export let autoAcceptEdits: boolean = false;
export let autoScroll: boolean = true;
export let provider: 'claude' | 'opencode' | 'tmux' = 'claude';
export let showBigMode: boolean = false;
const dispatch = createEventDispatcher<{
toggleAutoAccept: boolean;
@@ -11,6 +12,7 @@
condenseAll: void;
refresh: void;
eject: void;
bigMode: void;
}>();
function handleToggleAutoScroll() {
@@ -18,6 +20,14 @@
}
let open = false;
let keybindsHovered = false;
const keybinds = [
{ keys: 'Ctrl+Shift+R', action: 'Refresh session' },
{ keys: 'Ctrl+↓', action: 'Scroll to bottom', terminal: true },
{ keys: 'Ctrl+Shift+V', action: 'Paste', terminal: true },
{ keys: 'Ctrl+C', action: 'Interrupt', terminal: true }
];
function handleToggle() {
const newValue = !autoAcceptEdits;
@@ -39,6 +49,11 @@
open = false;
}
function handleBigMode() {
dispatch('bigMode');
open = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.settings-dropdown')) {
@@ -74,7 +89,7 @@
{#if open}
<div
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[240px] overflow-hidden z-50"
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[240px] z-50"
>
<div class="px-3 py-2 border-b border-zinc-700">
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
@@ -98,6 +113,22 @@
</div>
</label>
<!-- Big mode (tmux on mobile only) -->
{#if showBigMode}
<button
on:click={handleBigMode}
class="w-full flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left"
>
<svg class="h-4 w-4 text-cyan-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
<div class="flex-1 min-w-0">
<span class="text-sm text-zinc-200 block">Big mode</span>
<span class="text-xs text-zinc-500 block mt-0.5">Desktop sizing at 70% zoom</span>
</div>
</button>
{/if}
<!-- Refresh button (all providers) -->
<button
on:click={handleRefresh}
@@ -140,6 +171,43 @@
</label>
{/if}
<!-- Keyboard shortcuts -->
<div
class="relative"
on:mouseenter={() => (keybindsHovered = true)}
on:mouseleave={() => (keybindsHovered = false)}
role="menuitem"
>
<button
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left"
>
<svg class="h-4 w-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<svg class="h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="2" y="6" width="20" height="12" rx="2" stroke-width="2" />
<path stroke-linecap="round" stroke-width="2" d="M6 10h2M10 10h4M18 10h-2M6 14h12" />
</svg>
<span class="text-sm text-zinc-200">Keyboard shortcuts</span>
</button>
{#if keybindsHovered}
<div
class="absolute right-full top-0 mr-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[220px] z-[60]"
>
<div class="px-3 py-2 border-b border-zinc-700">
<span class="text-sm font-medium text-zinc-200">Shortcuts</span>
</div>
{#each keybinds as kb}
<div class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span class="text-zinc-400">{kb.action}{kb.terminal ? ' (terminal)' : ''}</span>
<kbd class="px-1.5 py-0.5 bg-zinc-700 border border-zinc-600 rounded text-xs text-zinc-300 font-mono whitespace-nowrap">{kb.keys}</kbd>
</div>
{/each}
</div>
{/if}
</div>
{#if provider === 'tmux'}
<button
on:click={handleEject}
+51 -18
View File
@@ -30,7 +30,7 @@
let lastHash: number | null = null;
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
let initialLoadComplete = false; // Track whether initial load has happened
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' = 'landscape';
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' | null = null;
let resizing = false;
let inputBuffer = '';
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -61,6 +61,14 @@
}
}
// Big mode: desktop sizing (120x36) with 70% zoom for mobile users who want more content
export function setBigMode(enabled: boolean) {
if (enabled) {
fontScale = 0.7;
resizeScreen('desktop');
}
}
// Computed content from lines
$: terminalContent = terminalLines.join('\n');
@@ -372,6 +380,19 @@
}
}
function handleOrientationChange() {
// Only auto-switch on mobile (screen width < 640px or height < 450px)
const isMobile = window.innerWidth < 640 || window.innerHeight < 450;
if (!isMobile) return;
const orientation = screen.orientation?.type || '';
if (orientation.includes('portrait')) {
resizeScreen('portrait');
} else if (orientation.includes('landscape')) {
resizeScreen('landscape');
}
}
onMount(async () => {
// Initial fetch with fresh=true to ensure we get full current content
await fetchTerminalContent(true);
@@ -387,6 +408,11 @@
if (autoFocus) {
setTimeout(() => terminalInput?.focus(), 100);
}
// Listen for orientation changes on mobile
if (screen.orientation) {
screen.orientation.addEventListener('change', handleOrientationChange);
}
});
onDestroy(() => {
@@ -399,6 +425,9 @@
if (batchTimeout) {
clearTimeout(batchTimeout);
}
if (screen.orientation) {
screen.orientation.removeEventListener('change', handleOrientationChange);
}
});
</script>
@@ -431,7 +460,7 @@
<button
on:click={() => ctrlMode = !ctrlMode}
disabled={!isAlive}
class="{btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200 {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
class="portrait-hide {btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200 {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
>^</button>
<button
on:click={sendCtrlC}
@@ -443,23 +472,23 @@
disabled={!isAlive}
class={btnAmber}
>^D</button>
<span class="w-px h-6 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
<button
on:click={() => sendKey('y')}
disabled={!isAlive}
class={btnGreen}
class="portrait-hide {btnGreen}"
>y</button>
<button
on:click={() => sendKey('n')}
disabled={!isAlive}
class={btnRed}
class="portrait-hide {btnRed}"
>n</button>
<span class="w-px h-6 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
{#each ['1', '2', '3', '4'] as num}
<button
on:click={() => sendKey(num)}
disabled={!isAlive}
class={btnDefault}
class="portrait-hide {btnDefault}"
>{num}</button>
{/each}
<span class="w-px h-6 bg-zinc-700"></span>
@@ -478,11 +507,11 @@
disabled={!isAlive}
class={btnGreen}
>↵</button>
<span class="w-px h-6 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700 portrait-hide"></span>
<button
on:click={pasteFromClipboard}
disabled={!isAlive}
class={btnViolet}
class="portrait-hide {btnViolet}"
title="Paste (Ctrl+Shift+V)"
>📋</button>
<!-- Screen size selector (pushed right on wider screens) -->
@@ -495,7 +524,11 @@
class={btnDefault}
title="Zoom out"
>-</button>
<span class="text-xs text-zinc-400 font-mono w-8 text-center">{Math.round(fontScale * 100)}%</span>
<button
on:click={() => fontScale = 1}
class="text-xs text-zinc-400 hover:text-zinc-200 font-mono w-8 text-center transition-colors"
title="Reset to 100%"
>{Math.round(fontScale * 100)}%</button>
<button
on:click={zoomIn}
disabled={fontScale >= fontScales[fontScales.length - 1]}
@@ -507,8 +540,8 @@
<button
on:click={() => resizeScreen('portrait')}
disabled={resizing}
class="{btnBase} {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Portrait (50x60)"
class="mobile-only {btnBase} {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Portrait (42x24)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-3 inline-block" fill="none" viewBox="0 0 10 16" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="14" rx="1" />
@@ -517,8 +550,8 @@
<button
on:click={() => resizeScreen('landscape')}
disabled={resizing}
class="{btnBase} {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Landscape (80x24)"
class="mobile-only {btnBase} {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Landscape (86x24)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="14" height="8" rx="1" />
@@ -527,8 +560,8 @@
<button
on:click={() => resizeScreen('desktop')}
disabled={resizing}
class="{btnBase} {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Split screen (120x48)"
class="desktop-only {btnBase} {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Split screen (120x36)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 20 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="12" rx="1" />
@@ -538,8 +571,8 @@
<button
on:click={() => resizeScreen('fullscreen')}
disabled={resizing}
class="{btnBase} {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Fullscreen (260x48)"
class="desktop-only {btnBase} {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Fullscreen (260x36)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-6 inline-block" fill="none" viewBox="0 0 22 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="20" height="12" rx="1" />
+1 -1
View File
@@ -40,6 +40,6 @@
});
</script>
<div class="flex flex-col bg-zinc-900 text-zinc-100 safe-top overflow-hidden" style="height: {containerHeight};">
<div class="flex flex-col w-full bg-zinc-900 text-zinc-100 safe-top overflow-hidden" style="height: {containerHeight};">
<slot />
</div>
+46 -2
View File
@@ -23,14 +23,22 @@
let menuOpen = false;
let autoScroll = true;
let tmuxAlive = false;
let isMobile = false;
// Load auto-scroll preference from localStorage
// Load auto-scroll preference from localStorage and detect mobile
onMount(() => {
if (browser) {
const stored = localStorage.getItem('spiceflow-auto-scroll');
if (stored !== null) {
autoScroll = stored === 'true';
}
// Detect mobile (screen width < 640px or height < 450px)
const checkMobile = () => {
isMobile = window.innerWidth < 640 || window.innerHeight < 450;
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}
});
@@ -149,6 +157,10 @@
}
}
function handleBigMode() {
terminalView?.setBigMode(true);
}
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
processing: 'bg-green-500 animate-pulse',
@@ -163,13 +175,20 @@
$: statusIndicator = isTmuxSession
? (tmuxAlive ? 'bg-green-500' : 'bg-zinc-600')
: (statusColors[session?.status || 'idle'] || statusColors.idle);
function handleKeydown(event: KeyboardEvent) {
if (event.ctrlKey && event.shiftKey && event.key === 'R') {
event.preventDefault();
handleRefresh();
}
}
</script>
<svelte:head>
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
</svelte:head>
<svelte:window on:click={() => (menuOpen = false)} />
<svelte:window on:click={() => (menuOpen = false)} on:keydown={handleKeydown} />
<!-- Header - Full (portrait) -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3 landscape-mobile:hidden">
@@ -218,13 +237,27 @@
{autoAcceptEdits}
{autoScroll}
provider={session.provider}
showBigMode={isTmuxSession && isMobile}
on:toggleAutoAccept={handleToggleAutoAccept}
on:toggleAutoScroll={handleToggleAutoScroll}
on:condenseAll={() => messageList?.condenseAll()}
on:refresh={handleRefresh}
on:eject={handleEject}
on:bigMode={handleBigMode}
/>
<!-- Refresh button - desktop only -->
<button
on:click={handleRefresh}
class="hidden sm:block p-1.5 hover:bg-zinc-700 rounded transition-colors"
aria-label="Refresh"
title="Refresh"
>
<svg class="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}">
{session.provider === 'tmux' ? 'terminal' : session.provider}
</span>
@@ -273,6 +306,17 @@
/>
<span>Auto-scroll</span>
</label>
{#if isTmuxSession && isMobile}
<button
on:click={() => { menuOpen = false; handleBigMode(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
>
<svg class="h-4 w-4 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
Big mode
</button>
{/if}
<button
on:click={() => { menuOpen = false; handleRefresh(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"