@@ -60,6 +155,71 @@ watch(
position: relative;
}
+.window-tabs {
+ display: flex;
+ align-items: stretch;
+ gap: var(--wa-space-3xs);
+ flex-shrink: 0;
+ overflow-x: auto;
+ padding: 0 var(--wa-space-xs);
+ border-bottom: 1px solid var(--wa-color-border-default);
+ background: var(--wa-color-surface-alt);
+}
+
+.window-tab {
+ appearance: none;
+ padding: var(--wa-space-xs) var(--wa-space-m);
+ margin: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ border-bottom: 2px solid transparent;
+ color: var(--wa-color-text-subtle);
+ font-size: var(--wa-font-size-s);
+ font-family: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.window-tab:hover {
+ color: var(--wa-color-text-default);
+}
+
+.window-tab.active {
+ color: var(--wa-color-brand-600);
+ border-bottom-color: var(--wa-color-brand-600);
+}
+
+.tab-preset-icon {
+ font-size: 0.75em;
+ margin-right: 0.25em;
+}
+
+.option-preset-icon {
+ font-size: 0.85em;
+ margin-right: 0.25em;
+ vertical-align: -0.1em;
+}
+
+.mobile-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--wa-space-2xs);
+ padding: var(--wa-space-2xs) var(--wa-space-xs);
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--wa-color-border-default);
+}
+
+.window-select {
+ min-width: 0;
+ max-width: 10rem;
+}
+
+.toolbar-spacer {
+ flex: 1;
+}
+
.terminal-container {
flex: 1;
min-height: 0;
@@ -67,6 +227,10 @@ watch(
padding: var(--wa-space-2xs);
}
+.terminal-container.hidden {
+ display: none;
+}
+
/* Ensure xterm fills its container */
.terminal-container :deep(.xterm) {
height: 100%;
diff --git a/frontend/src/components/TmuxNavigator.vue b/frontend/src/components/TmuxNavigator.vue
new file mode 100644
index 0000000..671a8b4
--- /dev/null
+++ b/frontend/src/components/TmuxNavigator.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
Shells
+
+
+
+
+ ●
+
+ {{ win.name }}
+
+
+
+
+
+ {{ source.label }}
+
+
+
+
+
+ {{ preset.name }}
+ {{ preset.command }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useTerminal.js b/frontend/src/composables/useTerminal.js
index 8ee6316..16c2a70 100644
--- a/frontend/src/composables/useTerminal.js
+++ b/frontend/src/composables/useTerminal.js
@@ -4,7 +4,8 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
-import { ClipboardAddon } from '@xterm/addon-clipboard'
+// ClipboardAddon intentionally not loaded — Ctrl+V must reach the shell
+// (e.g. bash quoted-insert). Paste is handled by right-click / middle-click.
import { useSettingsStore } from '../stores/settings'
import { useDataStore } from '../stores/data'
import { toast } from '../composables/useToast'
@@ -99,12 +100,37 @@ export function useTerminal(sessionId) {
/** @type {boolean} */
let intentionalClose = false
+ // ── tmux window management state ────────────────────────────────────
+ const windows = ref([])
+ const presets = ref([])
+ /** Whether the active tmux pane is in alternate screen (less, vim, etc.) */
+ const paneAlternate = ref(false)
+ const showNavigator = ref(false)
+ /** @type {((wins: Array) => void) | null} — resolver for pending listWindows() call */
+ let windowsResolver = null
+
+ // ── Touch mode (mobile): scroll (default) vs copy ─────────────────────
+ const copyMode = ref(false)
+
// ── Touch selection state (mobile) ─────────────────────────────────────
let selectStartCol = 0
let selectStartRow = 0
/** @type {AbortController | null} */
let touchAbortController = null
+ // ── Mouse interception state (desktop, tmux mode) ─────────────────────
+ /** @type {AbortController | null} */
+ let mouseAbortController = null
+ /** Whether a mouse-drag selection is in progress */
+ let mouseIsSelecting = false
+ /** Start coordinates for mouse selection (terminal cells + screen pixels) */
+ let mouseStartCol = 0
+ let mouseStartRow = 0
+ let mouseStartX = 0
+ let mouseStartY = 0
+ /** Drag threshold in pixels before selection starts */
+ const MOUSE_DRAG_THRESHOLD = 3
+
/**
* Check whether tmux should actually be used for this session.
* Tmux is skipped for draft and archived sessions.
@@ -154,11 +180,42 @@ export function useTerminal(sessionId) {
if (terminal) {
wsSend({ type: 'resize', cols: terminal.cols, rows: terminal.rows })
}
+ // Fetch the window list so the tab bar / dropdown appears immediately
+ if (shouldUseTmux()) {
+ wsSend({ type: 'list_windows' })
+ }
}
ws.onmessage = (event) => {
- // Server sends raw PTY output as text
- terminal?.write(event.data)
+ const data = event.data
+ // Detect JSON control messages from the server
+ if (data.charAt(0) === '{') {
+ try {
+ const msg = JSON.parse(data)
+ if (msg.type === 'windows') {
+ windows.value = msg.windows
+ if (msg.presets) presets.value = msg.presets
+ if ('alternate_on' in msg) paneAlternate.value = msg.alternate_on
+ if (windowsResolver) {
+ windowsResolver(msg.windows)
+ windowsResolver = null
+ }
+ return
+ }
+ if (msg.type === 'window_changed') {
+ // Update active flag in local list
+ for (const w of windows.value) {
+ w.active = (w.name === msg.name)
+ }
+ showNavigator.value = false
+ return
+ }
+ } catch {
+ // Not valid JSON — fall through to terminal.write
+ }
+ }
+ // Raw PTY output
+ terminal?.write(data)
}
ws.onclose = (event) => {
@@ -233,6 +290,10 @@ export function useTerminal(sessionId) {
/** Whether the current touch gesture is a selection (vs scrollbar drag). */
let touchIsSelecting = false
+ /** Y coordinate at touch start — used to compute scroll delta in scroll mode. */
+ let touchStartY = 0
+ /** Accumulated fractional scroll lines (sub-line deltas carry over). */
+ let scrollAccumulator = 0
function onTouchStart(e) {
// Ignore touches on the custom scrollbar — let xterm.js handle those via pointer events
@@ -240,28 +301,76 @@ export function useTerminal(sessionId) {
touchIsSelecting = false
return
}
- touchIsSelecting = true
const touch = e.touches[0]
- const coords = screenToTerminalCoords(touch.clientX, touch.clientY)
- selectStartCol = coords.col
- selectStartRow = coords.row
- terminal?.clearSelection()
+ if (copyMode.value) {
+ // Copy mode: start text selection
+ touchIsSelecting = true
+ const coords = screenToTerminalCoords(touch.clientX, touch.clientY)
+ selectStartCol = coords.col
+ selectStartRow = coords.row
+ terminal?.clearSelection()
+ } else {
+ // Scroll mode: record start position
+ touchIsSelecting = false
+ touchStartY = touch.clientY
+ scrollAccumulator = 0
+ }
}
function onTouchMove(e) {
- if (!touchIsSelecting || !terminal) return
+ if (!terminal) return
const touch = e.touches[0]
- const coords = screenToTerminalCoords(touch.clientX, touch.clientY)
- const startOffset = selectStartRow * terminal.cols + selectStartCol
- const currentOffset = coords.row * terminal.cols + coords.col
- const length = currentOffset - startOffset
- if (length > 0) {
- terminal.select(selectStartCol, selectStartRow, length)
- } else if (length < 0) {
- terminal.select(coords.col, coords.row, -length)
+ if (copyMode.value && touchIsSelecting) {
+ // Copy mode: update text selection
+ const coords = screenToTerminalCoords(touch.clientX, touch.clientY)
+ const startOffset = selectStartRow * terminal.cols + selectStartCol
+ const currentOffset = coords.row * terminal.cols + coords.col
+ const length = currentOffset - startOffset
+
+ if (length > 0) {
+ terminal.select(selectStartCol, selectStartRow, length)
+ } else if (length < 0) {
+ terminal.select(coords.col, coords.row, -length)
+ }
+ e.preventDefault()
+ } else if (!copyMode.value) {
+ // Scroll mode: convert touch swipe into terminal scroll.
+ // Uses "natural" scrolling: swipe up → see content below, swipe down → see history.
+ const screenEl = terminal.element?.querySelector('.xterm-screen')
+ if (!screenEl) return
+ const deltaY = touchStartY - touch.clientY
+ touchStartY = touch.clientY
+ const rect = screenEl.getBoundingClientRect()
+ const cellHeight = rect.height / terminal.rows
+
+ scrollAccumulator += deltaY
+ // Process one line per cell-height of accumulated movement
+ while (Math.abs(scrollAccumulator) >= cellHeight) {
+ const direction = scrollAccumulator > 0 ? 1 : -1
+
+ if (paneAlternate.value) {
+ // Pane is in alternate screen (less, vim, htop, etc.):
+ // Send arrow keys — the app handles scrolling.
+ // Natural: swipe up (dir=1) → down arrow, swipe down (dir=-1) → up arrow
+ wsSend({ type: 'input', data: direction > 0 ? '\x1b[B' : '\x1b[A' })
+ } else if (shouldUseTmux()) {
+ // Shell prompt inside tmux: send SGR mouse wheel sequences.
+ // tmux copy-mode handles the scrollback.
+ // Natural: swipe up (dir=1) → wheel down (65), swipe down (dir=-1) → wheel up (64)
+ const col = Math.max(1, Math.floor((touch.clientX - rect.left) / (rect.width / terminal.cols)) + 1)
+ const row = Math.max(1, Math.floor((touch.clientY - rect.top) / cellHeight) + 1)
+ const button = direction > 0 ? 65 : 64
+ wsSend({ type: 'input', data: `\x1b[<${button};${col};${row}M` })
+ } else {
+ // Raw shell (no tmux): scroll xterm.js viewport buffer.
+ terminal.scrollLines(direction)
+ }
+
+ scrollAccumulator -= direction * cellHeight
+ }
+ e.preventDefault()
}
- e.preventDefault()
}
/**
@@ -288,6 +397,170 @@ export function useTerminal(sessionId) {
}
}
+ // ── Mouse interception (desktop, tmux mode) ─────────────────────────
+ //
+ // When tmux enables SGR mouse tracking, xterm.js converts all mouse
+ // events into escape sequences sent to the PTY instead of doing local
+ // text selection. A single capture-phase mousedown listener blocks all
+ // mouse buttons from reaching xterm.js (no SGR reports to tmux).
+ //
+ // - Left-button: preventDefault + custom selection via terminal.select()
+ // - Middle/right-click: stopPropagation only — browser default actions
+ // (paste from X11 selection, context menu) flow through normally.
+ // xterm.js handles the resulting paste event on its internal textarea.
+ // - Mouse wheel: NOT intercepted (tmux scrollback continues to work).
+
+ /**
+ * Select the word at the given terminal buffer coordinates.
+ * Reads the buffer line and expands around the click position
+ * using whitespace as word boundary.
+ */
+ function selectWordAt(col, row) {
+ if (!terminal) return
+ const line = terminal.buffer.active.getLine(row)
+ if (!line) return
+
+ let lineStr = ''
+ for (let i = 0; i < terminal.cols; i++) {
+ const cell = line.getCell(i)
+ lineStr += cell ? cell.getChars() || ' ' : ' '
+ }
+
+ // Expand left
+ let start = col
+ while (start > 0 && !/\s/.test(lineStr[start - 1])) start--
+ // Expand right
+ let end = col
+ while (end < terminal.cols - 1 && !/\s/.test(lineStr[end + 1])) end++
+
+ const length = end - start + 1
+ if (length > 0) {
+ terminal.select(start, row, length)
+ }
+ }
+
+ /**
+ * Select the entire line at the given terminal buffer row.
+ */
+ function selectLineAt(row) {
+ if (!terminal) return
+ terminal.select(0, row, terminal.cols)
+ }
+
+ /**
+ * Handle mousedown in capture phase on the terminal container.
+ * Intercepts left-button clicks to implement local text selection
+ * instead of letting xterm.js send SGR mouse reports to tmux.
+ */
+ function onMouseDownIntercept(e) {
+ // Let Shift+click pass through — xterm.js handles it as forced selection
+ if (e.shiftKey) return
+
+ // Block all mouse buttons from reaching xterm.js (prevents SGR mouse
+ // reports to tmux). Only preventDefault for left button — middle/right-click
+ // default actions (paste / context menu) must flow through normally.
+ e.stopPropagation()
+ if (e.button !== 0) return
+ e.preventDefault()
+
+ // Focus the terminal (xterm.js normally does this in its own mousedown)
+ terminal?.focus()
+
+ const coords = screenToTerminalCoords(e.clientX, e.clientY)
+
+ if (e.detail === 2) {
+ // Double-click: select word
+ selectWordAt(coords.col, coords.row)
+ return
+ }
+ if (e.detail >= 3) {
+ // Triple-click: select line
+ selectLineAt(coords.row)
+ return
+ }
+
+ // Single click: prepare for potential drag selection
+ mouseIsSelecting = false
+ mouseStartCol = coords.col
+ mouseStartRow = coords.row
+ mouseStartX = e.clientX
+ mouseStartY = e.clientY
+
+ terminal?.clearSelection()
+
+ // Track drag on document so we capture moves outside the terminal area
+ document.addEventListener('mousemove', onMouseMoveIntercept)
+ document.addEventListener('mouseup', onMouseUpIntercept)
+ }
+
+ /**
+ * Handle mousemove during a potential drag selection.
+ * Added on document, active only between mousedown and mouseup.
+ */
+ function onMouseMoveIntercept(e) {
+ if (!terminal) return
+
+ // Apply drag threshold before starting selection
+ if (!mouseIsSelecting) {
+ const dx = e.clientX - mouseStartX
+ const dy = e.clientY - mouseStartY
+ if (Math.abs(dx) < MOUSE_DRAG_THRESHOLD && Math.abs(dy) < MOUSE_DRAG_THRESHOLD) {
+ return
+ }
+ mouseIsSelecting = true
+ }
+
+ const coords = screenToTerminalCoords(e.clientX, e.clientY)
+ const startOffset = mouseStartRow * terminal.cols + mouseStartCol
+ const currentOffset = coords.row * terminal.cols + coords.col
+ const length = currentOffset - startOffset
+
+ if (length > 0) {
+ terminal.select(mouseStartCol, mouseStartRow, length)
+ } else if (length < 0) {
+ terminal.select(coords.col, coords.row, -length)
+ }
+ }
+
+ /**
+ * Handle mouseup — finalize selection and clean up document listeners.
+ * The existing onSelectionChange handler auto-copies to clipboard.
+ */
+ function onMouseUpIntercept() {
+ document.removeEventListener('mousemove', onMouseMoveIntercept)
+ document.removeEventListener('mouseup', onMouseUpIntercept)
+ mouseIsSelecting = false
+ }
+
+ /**
+ * Attach capture-phase mouse event listeners that intercept left-button
+ * click/drag and right/middle-click when in tmux mode on desktop.
+ * Mouse wheel events are NOT intercepted — they continue to flow to
+ * xterm.js for tmux scrolling.
+ */
+ function attachMouseInterceptListeners() {
+ if (!containerRef.value || settingsStore.isTouchDevice) return
+ if (!shouldUseTmux()) return
+
+ mouseAbortController = new AbortController()
+ const signal = mouseAbortController.signal
+
+ containerRef.value.addEventListener('mousedown', onMouseDownIntercept, { capture: true, signal })
+ }
+
+ /**
+ * Detach mouse interception listeners.
+ */
+ function detachMouseInterceptListeners() {
+ if (mouseAbortController) {
+ mouseAbortController.abort()
+ mouseAbortController = null
+ }
+ // Also clean up any lingering document-level drag listeners
+ document.removeEventListener('mousemove', onMouseMoveIntercept)
+ document.removeEventListener('mouseup', onMouseUpIntercept)
+ }
+
/**
* Initialize the xterm.js Terminal and attach it to the container,
* then connect the WebSocket.
@@ -311,7 +584,7 @@ export function useTerminal(sessionId) {
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
- terminal.loadAddon(new ClipboardAddon())
+ // No ClipboardAddon — Ctrl+V goes to the shell, paste via right/middle-click
terminal.open(containerRef.value)
@@ -350,6 +623,8 @@ export function useTerminal(sessionId) {
if (finalSelection) {
clipboardWrite(finalSelection)
toast.success('Copied to clipboard', { duration: 2000 })
+ // Auto-exit copy mode after successful copy
+ copyMode.value = false
}
}, 500)
}
@@ -376,6 +651,10 @@ export function useTerminal(sessionId) {
// Attach touch listeners for mobile text selection
attachTouchListeners()
+ // Attach mouse interception for tmux mode on desktop
+ // (intercepts left-drag for selection, right/middle-click for paste)
+ attachMouseInterceptListeners()
+
// Connect to the backend
connectWs()
}
@@ -403,6 +682,73 @@ export function useTerminal(sessionId) {
connectWs()
}
+ /**
+ * Send raw input data to the PTY (e.g. control characters).
+ * @param {string} data
+ */
+ function sendInput(data) {
+ wsSend({ type: 'input', data })
+ }
+
+ // ── tmux window control ─────────────────────────────────────────────
+
+ /**
+ * Request the list of tmux windows from the backend.
+ * Returns a Promise that resolves with the window list.
+ */
+ function listWindows() {
+ return new Promise((resolve) => {
+ windowsResolver = resolve
+ wsSend({ type: 'list_windows' })
+ // Timeout fallback — resolve with current state after 3s
+ setTimeout(() => {
+ if (windowsResolver === resolve) {
+ windowsResolver = null
+ resolve(windows.value)
+ }
+ }, 3000)
+ })
+ }
+
+ /**
+ * Create a new tmux window.
+ *
+ * Accepts either a plain string (manual create) or a preset object
+ * with {name, cwd?, command?} from .twicc-tmux.json presets.
+ */
+ function createWindow(nameOrPreset) {
+ if (typeof nameOrPreset === 'string') {
+ wsSend({ type: 'create_window', name: nameOrPreset })
+ } else {
+ const msg = { type: 'create_window', name: nameOrPreset.name }
+ if (nameOrPreset.cwd) msg.preset_cwd = nameOrPreset.cwd
+ if (nameOrPreset.command) msg.command = nameOrPreset.command
+ wsSend(msg)
+ }
+ }
+
+ /**
+ * Switch to a tmux window by name.
+ * The backend responds with window_changed, which hides the navigator.
+ */
+ function selectWindow(name) {
+ wsSend({ type: 'select_window', name })
+ }
+
+ /**
+ * Focus the xterm.js terminal (e.g. after selecting a window from a dropdown).
+ */
+ function focusTerminal() {
+ terminal?.focus()
+ }
+
+ /**
+ * Toggle the shell navigator visibility.
+ */
+ function toggleNavigator() {
+ showNavigator.value = !showNavigator.value
+ }
+
/**
* Clean up everything: terminal, WebSocket, observers.
*/
@@ -410,6 +756,7 @@ export function useTerminal(sessionId) {
intentionalClose = true
detachTouchListeners()
+ detachMouseInterceptListeners()
if (resizeObserver) {
resizeObserver.disconnect()
@@ -471,5 +818,8 @@ export function useTerminal(sessionId) {
cleanup()
})
- return { containerRef, isConnected, started, start, reconnect }
+ return {
+ containerRef, isConnected, started, start, reconnect, sendInput, focusTerminal, copyMode,
+ windows, presets, showNavigator, listWindows, createWindow, selectWindow, toggleNavigator,
+ }
}
diff --git a/frontend/src/views/SessionView.vue b/frontend/src/views/SessionView.vue
index 9ee8e4e..2d2dd05 100644
--- a/frontend/src/views/SessionView.vue
+++ b/frontend/src/views/SessionView.vue
@@ -27,6 +27,9 @@ const sessionItemsListRef = ref(null)
// Reference to FilesPanel for cross-tab file reveal
const filesPanelRef = ref(null)
+// Reference to TerminalPanel for navigator toggle
+const terminalPanelRef = ref(null)
+
// ═══════════════════════════════════════════════════════════════════════════
// KeepAlive lifecycle: active state, listener setup/teardown
// ═══════════════════════════════════════════════════════════════════════════
@@ -284,6 +287,29 @@ function onTabShow(event) {
switchToTab(panel)
}
+/**
+ * Handle click on the terminal tab button.
+ * wa-tab-show doesn't fire when re-clicking an already-active tab,
+ * so we intercept the click to toggle the shell navigator.
+ */
+function onTerminalTabClick() {
+ if (activeTabId.value === 'terminal') {
+ terminalPanelRef.value?.toggleNavigator()
+ }
+}
+
+/**
+ * Handle click on the terminal tab button in compact mode.
+ * Same toggle behavior, but also collapses the compact header.
+ */
+function onCompactTerminalTabClick() {
+ if (activeTabId.value === 'terminal') {
+ terminalPanelRef.value?.toggleNavigator()
+ } else {
+ switchToTabAndCollapse('terminal')
+ }
+}
+
// ═══════════════════════════════════════════════════════════════════════════
// Compact tab nav: scroll overflow controls
// (mirrors wa-tab-group's native scroll behavior)
@@ -649,7 +675,7 @@ onBeforeUnmount(() => {
:appearance="activeTabId === 'terminal' ? 'outlined' : 'plain'"
:variant="activeTabId === 'terminal' ? 'brand' : 'neutral'"
size="small"
- @click="switchToTabAndCollapse('terminal')"
+ @click="onCompactTerminalTabClick"
>Terminal
@@ -736,6 +762,7 @@ onBeforeUnmount(() => {
:appearance="activeTabId === 'terminal' ? 'outlined' : 'plain'"
:variant="activeTabId === 'terminal' ? 'brand' : 'neutral'"
size="small"
+ @click="onTerminalTabClick"
>
Terminal
@@ -795,6 +822,7 @@ onBeforeUnmount(() => {