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:
185
frontend/src/components/TerminalNavButtons.vue
Normal file
185
frontend/src/components/TerminalNavButtons.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user