From 9a2807aa9ab19ad71e0749dfa91ae62150555816 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Mon, 16 Feb 2026 09:07:35 -0600 Subject: [PATCH] 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 --- .../src/components/TerminalNavButtons.vue | 185 ++++++++++++++++++ .../src/components/agent/AgentTerminal.vue | 101 +++++++++- frontend/src/composables/useAgentTerminal.ts | 10 + 3 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/TerminalNavButtons.vue diff --git a/frontend/src/components/TerminalNavButtons.vue b/frontend/src/components/TerminalNavButtons.vue new file mode 100644 index 0000000..917e8d3 --- /dev/null +++ b/frontend/src/components/TerminalNavButtons.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/frontend/src/components/agent/AgentTerminal.vue b/frontend/src/components/agent/AgentTerminal.vue index 6a1364e..c7117a3 100644 --- a/frontend/src/components/agent/AgentTerminal.vue +++ b/frontend/src/components/agent/AgentTerminal.vue @@ -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(null) @@ -81,27 +84,41 @@ const terminalStyle = computed((): Record => { // ── 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 = { + '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(() => { >
-
+
{ > +
+ + +
@@ -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; diff --git a/frontend/src/composables/useAgentTerminal.ts b/frontend/src/composables/useAgentTerminal.ts index 56ad84e..6445668 100644 --- a/frontend/src/composables/useAgentTerminal.ts +++ b/frontend/src/composables/useAgentTerminal.ts @@ -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 } }