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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user