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

@@ -0,0 +1,185 @@
<script setup lang="ts">
defineProps<{
waitingForToken?: boolean
}>()
defineEmits<{
requestToken: []
runClaude: []
runClaudeContinue: []
runClaudeResume: []
clearBuffer: []
refresh: []
sendKey: [key: string]
scroll: [direction: 'up' | 'down' | 'end']
}>()
</script>
<template>
<div class="tnb-bar">
<!-- Action buttons -->
<button class="tnb-btn mcp" :class="{ waiting: waitingForToken }" title="Connect MCP" @click="$emit('requestToken')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span class="tnb-label">MCP</span>
</button>
<button class="tnb-btn claude" title="Run Claude" @click="$emit('runClaude')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
<span class="tnb-label">Claude</span>
</button>
<button class="tnb-btn continue" title="Continue" @click="$emit('runClaudeContinue')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
<span class="tnb-label">Cont</span>
</button>
<button class="tnb-btn resume-btn" title="Resume" @click="$emit('runClaudeResume')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M21 3v6h-6"/></svg>
<span class="tnb-label">Resume</span>
</button>
<button class="tnb-btn clear" title="Clear Buffer" @click="$emit('clearBuffer')">
<svg width="12" height="12" 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>
<span class="tnb-label">Clear</span>
</button>
<button class="tnb-btn refresh-btn" title="Refresh Screen" @click="$emit('refresh')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
<span class="tnb-label">Refresh</span>
</button>
<span class="tnb-sep"></span>
<!-- Special keys -->
<button class="tnb-btn key esc" title="Escape" @click="$emit('sendKey', 'esc')">Esc</button>
<button class="tnb-btn key tab" title="Tab" @click="$emit('sendKey', 'tab')">Tab</button>
<button class="tnb-btn key ctrl-c" title="Ctrl+C" @click="$emit('sendKey', 'ctrl-c')">^C</button>
<button class="tnb-btn key alt-m" title="Alt+M" @click="$emit('sendKey', 'alt-m')">Alt+M</button>
<span class="tnb-sep"></span>
<!-- Scroll buttons -->
<div class="tnb-group">
<button class="tnb-btn scroll-btn" title="Scroll up" @click="$emit('scroll', 'up')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
</button>
<button class="tnb-btn scroll-btn end" title="Scroll to bottom" @click="$emit('scroll', 'end')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M7 7l5 5 5-5"/><path d="M7 13l5 5 5-5"/></svg>
</button>
<button class="tnb-btn scroll-btn" title="Scroll down" @click="$emit('scroll', 'down')">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
</button>
</div>
<!-- Arrow keys -->
<div class="tnb-group arrows">
<button class="tnb-btn arrow" title="Up" @click="$emit('sendKey', 'up')"></button>
<div class="tnb-arrow-row">
<button class="tnb-btn arrow" title="Left" @click="$emit('sendKey', 'left')"></button>
<button class="tnb-btn arrow" title="Down" @click="$emit('sendKey', 'down')"></button>
<button class="tnb-btn arrow" title="Right" @click="$emit('sendKey', 'right')"></button>
</div>
</div>
</div>
</template>
<style scoped>
.tnb-bar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px 6px;
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
flex-wrap: wrap;
}
.tnb-bar::-webkit-scrollbar { display: none; }
.tnb-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 3px;
flex-shrink: 0;
}
.tnb-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 10px;
font-family: system-ui, sans-serif;
white-space: nowrap;
transition: all 0.12s;
flex-shrink: 0;
}
.tnb-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
.tnb-btn:active {
transform: scale(0.96);
}
.tnb-label {
font-weight: 500;
}
/* Action button variants */
.tnb-btn.mcp:hover { background: rgba(236, 72, 153, 0.2); border-color: rgba(236, 72, 153, 0.3); color: #ec4899; }
.tnb-btn.mcp.waiting { background: rgba(16, 185, 129, 0.2); border-color: #10b981; color: #10b981; animation: tnb-pulse 0.8s infinite; }
.tnb-btn.claude:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
.tnb-btn.continue:hover { background: rgba(6, 182, 212, 0.2); border-color: rgba(6, 182, 212, 0.3); color: #06b6d4; }
.tnb-btn.resume-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
.tnb-btn.clear:hover { background: rgba(245, 158, 11, 0.2); border-color: rgba(245, 158, 11, 0.3); color: #f59e0b; }
.tnb-btn.refresh-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
/* Key buttons */
.tnb-btn.key {
padding: 3px 6px;
font-weight: 600;
font-size: 9px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
.tnb-btn.ctrl-c:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3); color: #ef4444; }
.tnb-btn.alt-m:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
/* Scroll buttons */
.tnb-btn.scroll-btn {
padding: 2px 5px;
}
.tnb-btn.scroll-btn:hover { background: rgba(16, 185, 129, 0.2); border-color: rgba(16, 185, 129, 0.3); color: #10b981; }
.tnb-btn.scroll-btn.end:hover { background: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
/* Arrow buttons */
.tnb-btn.arrow {
padding: 1px 4px;
font-size: 8px;
min-width: 20px;
justify-content: center;
}
/* Groups */
.tnb-group {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
.tnb-group.arrows {
flex-direction: column;
gap: 1px;
}
.tnb-arrow-row {
display: flex;
gap: 1px;
}
@keyframes tnb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
</style>

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;

View File

@@ -40,6 +40,9 @@ export interface AgentTerminal {
// Direct PTY input (char-by-char, no agent auto-start)
sendInput: (text: string) => void
// Raw PTY input (single write, no \r appended)
sendRaw: (data: string) => void
// Cleanup
dispose: () => void
}
@@ -378,6 +381,12 @@ export function useAgentTerminal(agentId: string): AgentTerminal {
}
}
function sendRaw(data: string) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
}
function flushPendingPrompt() {
if (pendingPrompt && socket?.readyState === WebSocket.OPEN) {
typeTextToSocket(pendingPrompt)
@@ -406,6 +415,7 @@ export function useAgentTerminal(agentId: string): AgentTerminal {
checkStatus,
sendPrompt,
sendInput,
sendRaw,
dispose
}
}