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
+276 -9
View File
@@ -339,6 +339,38 @@ stream_session_response(session_id, callback)
- Filters for `spiceflow-*` prefix
- Returns managed sessions only
#### 4.1.5 External Tmux Session Import
Users can import existing tmux sessions not managed by Spiceflow:
1. **List External:** `GET /api/tmux/external` returns sessions without `spiceflow-` prefix
2. **Import:** `POST /api/tmux/import` with session name
3. **Rename:** Server renames `{name}` to `spiceflow-{name}`
4. **Setup:** Enables pipe-pane capture for imported session
5. **Return:** Session available in Spiceflow with new prefixed ID
#### 4.1.6 Session Rename
Sessions can be renamed via `PATCH /api/sessions/:id`:
**Claude/OpenCode:**
- Updates title in database
- Session ID remains unchanged
**Tmux:**
- Renames tmux session via `tmux rename-session`
- **Session ID changes** to new `spiceflow-{name}` format
- Response includes `idChanged: true` and new session object
- Client navigates to new URL with `replaceState`
#### 4.1.7 Session Eject (Tmux only)
Removes session from Spiceflow management while keeping it running:
1. Rename tmux session from `spiceflow-{name}` to `{name}`
2. Delete session from Spiceflow database
3. Session continues running, attachable via `tmux attach -t {name}`
### 4.2 Process Handle Structure
```clojure
@@ -572,6 +604,11 @@ Permissions are recorded as assistant messages with metadata:
- **Examples:** `spiceflow-brave-fox-0042`, `spiceflow-calm-owl-1337`
- **Purpose:** Human-readable, unique identifiers
**Name Generation:**
- 30 adjectives × 30 nouns = 900 base combinations
- 4-digit random suffix (0000-9999)
- Docker-style naming convention
#### 7.1.2 Output Capture
```bash
@@ -635,10 +672,96 @@ tmux capture-pane -t {session-name} -p -e -S -1000
| Mode | Dimensions | Use Case |
|------|------------|----------|
| `portrait` | 40x24 | Phone portrait |
| `landscape` | 65x24 | Phone landscape |
| `desktop` | 100x24 | Split screen |
| `fullscreen` | 180x24 | Full terminal |
| `portrait` | 42x24 | Phone portrait |
| `landscape` | 86x24 | Phone landscape |
| `desktop` | 120x36 | Split screen |
| `fullscreen` | 260x36 | Full terminal |
### 7.5 Auto Orientation Detection
On mobile devices, the terminal automatically resizes when the user rotates their phone:
```javascript
screen.orientation.addEventListener('change', () => {
const type = screen.orientation.type;
if (type.includes('portrait')) {
resizeScreen('portrait');
} else if (type.includes('landscape')) {
resizeScreen('landscape');
}
});
```
**Trigger Conditions:**
- Only on mobile (width < 640px or height < 450px)
- Uses `screen.orientation` API (modern browsers)
### 7.6 Font Zoom Control
Terminal text size can be adjusted from 50% to 150% in 5% increments:
| Control | Action |
|---------|--------|
| `-` button | Decrease font scale |
| `+` button | Increase font scale |
| Percentage display | Shows current zoom; click to reset to 100% |
**Visibility:** Zoom controls hidden on mobile portrait, visible on landscape/desktop.
### 7.7 Big Mode
Mobile users can enable "Big mode" from the settings menu to view more terminal content:
- **Resize:** Sets terminal to desktop dimensions (120x36)
- **Zoom:** Sets font scale to 70%
- **Access:** Settings menu (gear icon) → "Big mode" button
- **Visibility:** Only shown for tmux sessions on mobile devices
### 7.8 Session Eject
Tmux sessions can be "ejected" from Spiceflow management while keeping them running:
1. User clicks "Eject session" in settings menu
2. Server renames session from `spiceflow-{name}` to `{name}`
3. Session removed from Spiceflow database
4. User can reattach manually via `tmux attach -t {name}`
**Use Case:** Transfer session to local terminal for continued work.
### 7.9 Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+Down` | Scroll to bottom |
| `Ctrl+Shift+V` | Paste from clipboard |
| `Shift+Enter` | Send literal newline |
| `Shift+Tab` | Send reverse-tab escape sequence |
| `^` toggle | Enable Ctrl mode (next letter sends control character) |
### 7.10 Quick Action Buttons
| Button | Function | Color |
|--------|----------|-------|
| `^` | Toggle Ctrl mode | Gray (cyan ring when active) |
| `^C` | Send interrupt | Red |
| `^D` | Send EOF | Amber |
| `y` | Send 'y' | Green |
| `n` | Send 'n' | Red |
| `1-4` | Send number | Gray |
| `⇥` | Send Tab | Cyan |
| `⇤` | Send Shift+Tab | Cyan |
| `↵` | Send Enter | Green |
| `📋` | Paste clipboard | Violet |
**Visibility:** `^`, `y`, `n`, `1-4`, and paste hidden on mobile portrait.
### 7.11 ANSI Color Rendering
Terminal output preserves ANSI escape sequences:
- Converted to HTML via `ansi-to-html` library
- Default foreground: green (#22c55e)
- Background: transparent
- Supports standard terminal colors
---
@@ -954,6 +1077,23 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
}
```
#### POST /api/sessions/:id/eject
Ejects a tmux session from Spiceflow management (tmux only).
**Response:**
```json
{
"status": "ejected",
"message": "Session ejected. Reattach with: tmux attach -t {name}",
"session-name": "my-session"
}
```
**Errors:**
- 400: Not a tmux session
- 404: Session not found
---
## 10. WebSocket Protocol
@@ -1085,6 +1225,24 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
- **Heartbeat:** Ping every 25 seconds
- **Pong Timeout:** 10 seconds
### 10.5 Terminal Update Broadcasting
After tmux input, server broadcasts terminal updates:
1. Input received via `POST /api/sessions/:id/terminal/input`
2. Input sent to tmux immediately
3. 100ms delay for command execution
4. Fresh terminal content captured
5. Diff computed and broadcast via WebSocket
6. Broadcast always sent (even if unchanged) to ensure client sync
### 10.6 Full Frame Refresh
To handle potential drift, the server periodically sends full frames:
- Every 5 seconds during active streaming
- On explicit `fresh=true` request
- After terminal resize operations
---
## 11. User Interface
@@ -1097,6 +1255,9 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
┌─────────────────────────────────────────┐
│ ☰ Spiceflow 🔔 + ↻ │
├─────────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ ● 2 sessions processing │ │ (green badge, pulsing)
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ ● My Claude Session claude │ │
@@ -1108,14 +1269,19 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
│ │ /home/user 1d ago │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ + Import tmux session │ │ (if external sessions exist)
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
**Elements:**
- Header with branding and action buttons
- Processing sessions counter (green pulsing badge)
- Session cards with status indicators
- Provider badges (color-coded)
- Relative timestamps
- Import button for external tmux sessions
#### Session Page (`/session/:id`)
@@ -1135,10 +1301,15 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
│ └─────────────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ [Type a message... ] [➤] │
[⌨] [Type a message... ] [➤] │
└─────────────────────────────────────────┘
```
**Mobile Keyboard Toggle:**
- `⌨` button shows/hides mobile keyboard
- Addresses issue where keyboard can hide input field
- Toggles between up/down arrow indicators
**Terminal Mode (tmux):**
```
┌─────────────────────────────────────────┐
@@ -1164,7 +1335,7 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
│ ⚠️ Claude needs permission │
├─────────────────────────────────────────┤
│ │
│ Write: /home/user/foo.md
│ Write: /home/user/foo.md .md (file extension badge)
│ ┌─────────────────────────────────┐ │
│ │ + 1 # My Haiku │ │
│ │ + 2 │ │
@@ -1177,6 +1348,14 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
└─────────────────────────────────────────┘
```
**File Diff Viewer:**
- **Write operations:** All lines shown as green additions (+)
- **Edit operations:** Old lines in red (-), new lines in green (+)
- **Line numbers:** Shown for both old and new content
- **File extension badge:** Displayed in top-right corner
- **Hover highlighting:** Lines highlight on mouse over
- **Tab handling:** Tabs rendered as 4 spaces
### 11.3 Component Inventory
| Component | Purpose | Props |
@@ -1194,9 +1373,65 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
| Breakpoint | Header | Layout |
|------------|--------|--------|
| Portrait | Full header | Vertical stack |
| Landscape mobile | Hamburger menu | Compact header |
| Desktop | Full header | All controls visible |
| Portrait (<640px width, >450px height) | Full header | Vertical stack |
| Landscape mobile (<450px height) | Hamburger menu | Compact header |
| Desktop (≥640px width, ≥450px height) | Full header | All controls visible |
| XL Desktop (≥1280px width) | Full header | Mobile orientation buttons hidden |
### 11.5 Mobile-Specific UI Classes
| CSS Class | Behavior |
|-----------|----------|
| `portrait-hide` | Hidden on mobile portrait (width < 640px AND height > 450px) |
| `desktop-only` | Hidden on mobile (width < 640px OR height < 450px) |
| `mobile-only` | Hidden on XL desktop (width ≥ 1280px) |
| `landscape-mobile:hidden` | Hidden when height < 450px |
| `landscape-menu` | Shown only when height < 450px |
### 11.6 Message Condensing
Long messages (5+ lines) can be collapsed:
- **Threshold:** 5 lines minimum to show collapse toggle
- **Preview:** Shows first 3 lines when collapsed
- **Toggle:** Chevron indicator expands/collapses
- **Bulk action:** "Condense all" in settings menu
### 11.7 Thinking Indicator
Animated indicator when Claude is processing:
- Three bouncing dots (●●●)
- Separate from streaming content display
- Disappears when response completes or permission requested
### 11.8 Auto-Scroll Control
- **Default:** Enabled
- **Persistence:** Saved to localStorage (`spiceflow-auto-scroll`)
- **Toggle:** Available in session settings menu
- **Behavior:** Scrolls to bottom on new content
- **Override:** Ctrl+Down forces scroll regardless of setting
### 11.9 Session Status Indicators
| Status | Indicator |
|--------|-----------|
| Idle | Gray dot (static) |
| Processing | Green dot (pulsing) |
| Awaiting Permission | Amber dot (pulsing) |
| Tmux Alive | Green dot |
| Tmux Dead | Gray dot |
### 11.10 Markdown Rendering
Assistant messages render Markdown with:
- Headings (h1-h6) with appropriate sizing
- Code blocks with monospace font and background
- Inline code with background highlight
- Lists (ordered and unordered)
- Blockquotes with left border
- Links styled in orange (spice color)
- Line breaks preserved (`breaks: true`)
---
@@ -1262,6 +1497,38 @@ salt (16) || record_size (4) || keyid_len (1) || keyid (65) || ciphertext
8. User taps notification → app opens to session
9. User sees permission UI, responds
### 12.6 Import External Tmux Session
1. User has existing tmux session running (e.g., `dev-session`)
2. User opens Spiceflow home page
3. "Import tmux session" button appears
4. User clicks import → dropdown shows available sessions
5. User selects `dev-session`
6. Server renames to `spiceflow-dev-session`
7. Session appears in Spiceflow list
8. User can now manage session via Spiceflow
### 12.7 Eject Tmux Session
1. User opens tmux session in Spiceflow
2. User clicks settings gear (⚙️)
3. User clicks "Eject session"
4. Confirmation alert shows reattach command
5. Session renamed from `spiceflow-{name}` to `{name}`
6. User redirected to home page
7. Session removed from Spiceflow but continues running
8. User can reattach via `tmux attach -t {name}`
### 12.8 Rotate Phone (Terminal)
1. User viewing terminal session on phone
2. User rotates phone from portrait to landscape
3. `screen.orientation` change event fires
4. App detects orientation is now landscape
5. Terminal automatically resizes to landscape dimensions (88x24)
6. Fresh terminal content fetched after 150ms
7. UI updates to show landscape-specific controls
---
## 13. Security Considerations
+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"
+4 -4
View File
@@ -339,10 +339,10 @@
;; Screen size presets for different device orientations
(def ^:private screen-sizes
{:fullscreen {:width 260 :height 48}
:desktop {:width 120 :height 48}
:landscape {:width 80 :height 24}
:portrait {:width 40 :height 24}})
{:fullscreen {:width 260 :height 36}
:desktop {:width 120 :height 36}
:landscape {:width 86 :height 24}
:portrait {:width 42 :height 24}})
(defn resize-session
"Resize a tmux session window to a preset size.