feat: Add reusable TerminalNavButtons to AgentTerminal with mobile touch drag support

- Extract nav buttons (MCP, Claude, Continue, Resume, Clear, Refresh, keys, arrows, scroll) into TerminalNavButtons.vue
- Add toggle button in AgentTerminal titlebar to show/hide nav bar
- Add sendRaw() to useAgentTerminal for raw PTY input (no \r append)
- Add touch drag support for AgentTerminal on mobile
- Skip auto-focus on small screens to prevent virtual keyboard popup
This commit is contained in:
2026-02-16 09:07:35 -06:00
parent 5a4192ac2f
commit 9a2807aa9a
3 changed files with 288 additions and 8 deletions

View File

@@ -2,6 +2,7 @@
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import type { Agent } from '../../types/agent'
import type { AgentTerminal as AgentTerminalType } from '../../composables/useAgentTerminal'
import TerminalNavButtons from '../TerminalNavButtons.vue'
const props = defineProps<{
modelValue: boolean
@@ -25,6 +26,8 @@ const agentRunning = props.agentTerminal.agentRunning
const startAgent = props.agentTerminal.startAgent
const stopAgent = props.agentTerminal.stopAgent
const showNavButtons = ref(false)
// Local ref for the xterm container - syncs to composable's containerRef
const terminalContainer = ref<HTMLElement | null>(null)
@@ -81,27 +84,41 @@ const terminalStyle = computed((): Record<string, string> => {
// ── Drag ──
function startDrag(e: MouseEvent) {
function startDrag(e: MouseEvent | TouchEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
if (e instanceof TouchEvent) e.preventDefault()
isDragging.value = true
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const rect = windowRef.value?.getBoundingClientRect()
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
function onDrag(e: MouseEvent) {
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return
if (e instanceof TouchEvent) e.preventDefault()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const w = windowRef.value?.offsetWidth || 560
const h = windowRef.value?.offsetHeight || 340
position.value = {
x: Math.max(-w * 0.75, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
y: Math.max(-h * 0.75, Math.min(e.clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
}
}
@@ -110,6 +127,8 @@ function stopDrag() {
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// ── Resize ──
@@ -152,6 +171,42 @@ function handleRestart() {
startAgent(true)
}
function toggleNavButtons() {
showNavButtons.value = !showNavButtons.value
}
// Nav button actions - send commands through the agent terminal
function navRunClaude() {
props.agentTerminal.sendInput('claude')
}
function navRunClaudeContinue() {
props.agentTerminal.sendInput('claude --continue')
}
function navRunClaudeResume() {
props.agentTerminal.sendInput('claude --resume')
}
function navRefresh() {
renderer.fit()
}
function navSendKey(key: string) {
const keyMap: Record<string, string> = {
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
}
const data = keyMap[key]
if (data) props.agentTerminal.sendRaw(data)
}
function navScroll(direction: 'up' | 'down' | 'end') {
if (direction === 'up') renderer.scrollLines(-10)
else if (direction === 'down') renderer.scrollLines(10)
else renderer.scrollToBottom()
}
// ── Watch open/close to init terminal ──
watch(isOpen, (open) => {
@@ -161,9 +216,13 @@ watch(isOpen, (open) => {
if (!renderer.isReady.value) {
renderer.init()
}
// Wait for CSS transition then fit + focus
// Wait for CSS transition then fit (+ focus only on desktop)
setTimeout(() => {
renderer.onBecameVisible()
renderer.fit()
renderer.terminal.value?.refresh(0, renderer.terminal.value.rows - 1)
if (window.innerWidth > 1024) {
renderer.focus()
}
}, 150)
})
} else {
@@ -174,6 +233,8 @@ watch(isOpen, (open) => {
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
@@ -191,7 +252,7 @@ onBeforeUnmount(() => {
>
<div class="at-glass" :style="{ borderColor: agentBorder + '40' }">
<!-- Titlebar -->
<div class="at-titlebar" @mousedown="startDrag">
<div class="at-titlebar" @mousedown="startDrag" @touchstart="startDrag">
<div class="at-left">
<div
class="at-badge"
@@ -227,12 +288,33 @@ onBeforeUnmount(() => {
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></svg>
</button>
<button
class="wc-btn nav-toggle"
:class="{ active: showNavButtons }"
title="Toggle navigation"
@click.stop="toggleNavButtons"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<button class="wc-btn x" title="Close" @click.stop="close">
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</div>
</div>
<!-- Nav buttons bar -->
<TerminalNavButtons
v-if="showNavButtons"
@request-token="agentTerminal.sendInput('genera token usando tu mcp')"
@run-claude="navRunClaude"
@run-claude-continue="navRunClaudeContinue"
@run-claude-resume="navRunClaudeResume"
@clear-buffer="clearBuffer"
@refresh="navRefresh"
@send-key="navSendKey"
@scroll="navScroll"
/>
<!-- Terminal content -->
<div class="at-content" :style="{ background: agentBg }">
<div ref="terminalContainer" class="at-term"></div>
@@ -298,6 +380,7 @@ onBeforeUnmount(() => {
cursor: grab;
user-select: none;
}
.at-titlebar { touch-action: none; }
.agent-terminal.dragging .at-titlebar { cursor: grabbing; }
.at-left {
@@ -362,6 +445,8 @@ onBeforeUnmount(() => {
.wc-btn.start { color: #0a0; }
.wc-btn.start:hover { background: rgba(16, 185, 129, 0.3); }
.wc-btn.restart:hover { background: rgba(245, 158, 11, 0.3); color: #a80; }
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
.at-content {
flex: 1;