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:
+26
-1
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user